Skip to main content

mos_layout/
boundary.rs

1//! Page boundary signatures (issue #70; design note
2//! `docs/incremental-dependencies.md` §4.5, §6, §7).
3//!
4//! Reflow and fixpoint work (manifest §33) needs to detect *where* pagination
5//! changed between two layout runs without re-diffing whole pages or
6//! serializing PDF output. A [`PageBoundarySignature`] is a compact, stable
7//! digest of one laid-out [`Page`]'s break-defining content; a
8//! [`PageGraphSignature`] is the ordered per-page list, so the first index
9//! where two graphs disagree is exactly where the page breaks diverged.
10//!
11//! This is the §4.5 `PageOutputHash` ("did the laid-out page actually change?")
12//! reduced to the layout primitives that exist today (text runs and image
13//! placements). It is identity/comparison only: no `DepNode` graph, no
14//! `CacheKey` wiring, no reflow loop; those consume these signatures later.
15//!
16//! # What feeds a signature
17//!
18//! Per page, in order: the page number, the quantized page box, then each text
19//! run (quantized position + size, a backend-neutral font identity, text) and
20//! each image placement (intrinsic pixel dimensions + quantized rectangle). Run
21//! and image counts are folded too, so adding or removing one shifts the digest.
22//!
23//! Deliberately **excluded**, per the determinism rules (§5) and the §4.2/§4.3
24//! carve-outs:
25//!
26//! - **Shaped glyphs** on a run: derived from text + font + shaper, so folding
27//!   them would bind the signature to a transcoder version it should not care
28//!   about. The authored text and font identity stand in for them.
29//! - **The PDF resource name** of a font (`F1`..): a backend emitter slot;
30//!   layout must not depend on it, so a backend-neutral font identity is folded
31//!   instead (see `font_identity`).
32//! - **Decoded image pixels** (`rgb8`): an asset-content concern (§4.3),
33//!   addressed by the asset's own hash, not the page boundary.
34//! - **`resolved_path`** on an image handle: an absolute filesystem path, which
35//!   §5 rule 1 forbids from any hash.
36//! - **`handle.id`**: assigned in image-encounter order, so folding it would
37//!   churn unrelated pages' signatures when an image is added earlier; the
38//!   intrinsic pixel dimensions identify the asset's footprint instead.
39//!
40//! # Quantization
41//!
42//! Every `f32` dimension is snapped to the 1/64-pt grid (§6) before folding, so
43//! two layouts that reach the same length through slightly different arithmetic
44//! agree. The design note specifies an `i32` count of 1/64 pt; we snap to the
45//! same grid and fold the canonical bit pattern of the integral count instead
46//! (see `quantize_units`), which is equivalent for hashing and avoids a
47//! lint-denied float-to-int cast. The §9.5 quantization newtype can later adopt
48//! the `i32` form without changing this boundary's observable behavior.
49
50use mos_core::{ContentHash, ContentHasher};
51use mos_fonts::{Base14Font, EmbeddedFontId, Font};
52
53use crate::types::{ImagePlacement, LayoutResult, Page, PageGraph, TextRun};
54
55/// Domain tag separating this boundary from every other `H(...)` (§4).
56const PAGE_DOMAIN: &[u8] = b"mos-layout/page-boundary/v1";
57
58/// A backend-neutral, stable identity for a font face.
59///
60/// Deliberately *not* `Font::pdf_resource_name` (`F1`..): that is a PDF emitter
61/// resource slot, and a layout signature must not depend on backend layout. The
62/// returned tag is owned by this boundary and stable forever; the exhaustive
63/// match means a new bundled face fails to compile here until it is assigned a
64/// tag, so the identity can never silently alias.
65const fn font_identity(font: Font) -> &'static [u8] {
66    match font {
67        Font::Base14(Base14Font::Helvetica) => b"b14/helvetica",
68        Font::Base14(Base14Font::HelveticaBold) => b"b14/helvetica-bold",
69        Font::Base14(Base14Font::HelveticaOblique) => b"b14/helvetica-oblique",
70        Font::Base14(Base14Font::HelveticaBoldOblique) => b"b14/helvetica-boldoblique",
71        Font::Base14(Base14Font::TimesRoman) => b"b14/times-roman",
72        Font::Base14(Base14Font::TimesBold) => b"b14/times-bold",
73        Font::Base14(Base14Font::TimesItalic) => b"b14/times-italic",
74        Font::Base14(Base14Font::TimesBoldItalic) => b"b14/times-bolditalic",
75        Font::Base14(Base14Font::Courier) => b"b14/courier",
76        Font::Base14(Base14Font::CourierBold) => b"b14/courier-bold",
77        Font::Base14(Base14Font::CourierOblique) => b"b14/courier-oblique",
78        Font::Base14(Base14Font::CourierBoldOblique) => b"b14/courier-boldoblique",
79        Font::Base14(Base14Font::Symbol) => b"b14/symbol",
80        Font::Base14(Base14Font::ZapfDingbats) => b"b14/zapfdingbats",
81        Font::Embedded(EmbeddedFontId::Regular) => b"emb/noto-sans-regular",
82        Font::Embedded(EmbeddedFontId::Bold) => b"emb/noto-sans-bold",
83        Font::Embedded(EmbeddedFontId::Italic) => b"emb/noto-sans-italic",
84        Font::Embedded(EmbeddedFontId::BoldItalic) => b"emb/noto-sans-bolditalic",
85        Font::Embedded(EmbeddedFontId::Mono) => b"emb/noto-sans-mono",
86        Font::Embedded(EmbeddedFontId::Math) => b"emb/noto-sans-math",
87    }
88}
89
90/// Snap a point measurement to the 1/64-pt grid (§6) and return the canonical
91/// bit pattern of that integral count.
92///
93/// Snapping first means two dimensions within one shaper unit fold equal.
94/// Dividing is avoided entirely: `(pt * 64).round()` is already the integral
95/// count, and while that count stays below `2^24` (i.e. `pt < 262144`, ~five
96/// orders of magnitude above any real page coordinate) it is represented
97/// exactly by `f32`, so its bit pattern is a canonical, collision-free encoding
98/// of that integer. Past `2^24` consecutive counts would alias: out of the
99/// domain layout produces, but the bound the `i32` form (§9.5) would lift.
100/// `-0.0`, `NaN`, and non-finite values normalize to `0` so they cannot create
101/// spurious distinctions.
102fn quantize_units(pt: f32) -> u32 {
103    let units = (pt * 64.0).round();
104    let canonical = if units.is_finite() && units != 0.0 {
105        units
106    } else {
107        0.0
108    };
109    canonical.to_bits()
110}
111
112/// The number of items folded as a count, saturating so an absurd length cannot
113/// silently wrap.
114fn fold_count(hasher: &mut ContentHasher, len: usize) {
115    hasher.u32(u32::try_from(len).unwrap_or(u32::MAX));
116}
117
118fn fold_run(hasher: &mut ContentHasher, run: &TextRun) {
119    hasher
120        .u32(quantize_units(run.x_pt))
121        .u32(quantize_units(run.baseline_from_top_pt))
122        .u32(quantize_units(run.size_pt))
123        .field(font_identity(run.font))
124        .field(run.text.as_bytes());
125    // Optional `/ActualText`: fold a presence flag so `Some("")` and `None`
126    // stay distinct, then the bytes when present.
127    match &run.actual_text {
128        Some(actual) => {
129            hasher.u32(1).field(actual.as_bytes());
130        }
131        None => {
132            hasher.u32(0);
133        }
134    }
135    // `glyphs` excluded: derived from text + font + shaper (§4.2 carve-out).
136}
137
138fn fold_image(hasher: &mut ContentHasher, image: &ImagePlacement) {
139    hasher
140        // Intrinsic pixel dimensions identify the asset's layout footprint.
141        .u32(image.handle.pixel_width)
142        .u32(image.handle.pixel_height)
143        .u32(quantize_units(image.x_pt))
144        .u32(quantize_units(image.top_from_top_pt))
145        .u32(quantize_units(image.width_pt))
146        .u32(quantize_units(image.height_pt));
147    // `handle.id` is excluded: it is assigned in image-encounter order
148    // (`intern_image` uses `image_handles.len()`), so folding it would shift
149    // later images' signatures when an unrelated image is added earlier,
150    // wrecking the page-locality `first_divergence` relies on. `handle.rgb8`
151    // (asset content, §4.3) and `handle.resolved_path` (absolute path, §5
152    // rule 1) are excluded too.
153}
154
155/// A compact, deterministic digest of one laid-out [`Page`]'s break-defining
156/// content (design note §4.5).
157///
158/// Equal for identical pagination of identical content, different when a page's
159/// content or its break changes. It is the cache slot's staleness check, not a
160/// human-readable description: use [`content_hash`](Self::content_hash) to read
161/// the underlying value.
162///
163/// # Examples
164///
165/// ```
166/// use mos_layout::{LayoutEngine, PageBoundarySignature};
167/// use mos_core::Document;
168/// use std::path::PathBuf;
169///
170/// let doc = Document::new(PathBuf::from("doc.mos"));
171/// let result = LayoutEngine::new().layout(&doc);
172/// // Re-signing the same page yields the same signature.
173/// for page in &result.graph.pages {
174///     assert_eq!(
175///         PageBoundarySignature::of_page(page),
176///         PageBoundarySignature::of_page(page),
177///     );
178/// }
179/// ```
180#[derive(Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Debug)]
181pub struct PageBoundarySignature(ContentHash);
182
183impl PageBoundarySignature {
184    /// Compute the boundary signature of one laid-out page.
185    ///
186    /// # Examples
187    ///
188    /// ```
189    /// use std::path::PathBuf;
190    ///
191    /// use mos_core::Document;
192    /// use mos_layout::{LayoutEngine, PageBoundarySignature};
193    ///
194    /// let doc = Document::new(PathBuf::from("doc.mos"));
195    /// let result = LayoutEngine::new().layout(&doc);
196    /// let first_page = &result.graph.pages[0];
197    /// assert_eq!(
198    ///     PageBoundarySignature::of_page(first_page),
199    ///     PageBoundarySignature::of_page(first_page),
200    /// );
201    /// ```
202    #[must_use]
203    pub fn of_page(page: &Page) -> Self {
204        let mut hasher = ContentHasher::new();
205        hasher
206            .field(PAGE_DOMAIN)
207            .u32(page.number)
208            .u32(quantize_units(page.width_pt))
209            .u32(quantize_units(page.height_pt));
210        fold_count(&mut hasher, page.runs.len());
211        for run in &page.runs {
212            fold_run(&mut hasher, run);
213        }
214        fold_count(&mut hasher, page.images.len());
215        for image in &page.images {
216            fold_image(&mut hasher, image);
217        }
218        Self(hasher.finish())
219    }
220
221    /// The underlying content hash.
222    ///
223    /// # Examples
224    ///
225    /// ```
226    /// use std::path::PathBuf;
227    ///
228    /// use mos_core::Document;
229    /// use mos_layout::{LayoutEngine, PageBoundarySignature};
230    ///
231    /// let doc = Document::new(PathBuf::from("doc.mos"));
232    /// let result = LayoutEngine::new().layout(&doc);
233    /// let signature = PageBoundarySignature::of_page(&result.graph.pages[0]);
234    /// assert_eq!(signature.content_hash(), signature.content_hash());
235    /// ```
236    #[must_use]
237    pub const fn content_hash(self) -> ContentHash {
238        self.0
239    }
240}
241
242/// The ordered per-page boundary signatures of a whole [`PageGraph`].
243///
244/// Comparing two graph signatures answers both "did pagination change?"
245/// (inequality) and "where?" ([`first_divergence`](Self::first_divergence)).
246///
247/// # Examples
248///
249/// ```
250/// use mos_layout::{LayoutEngine, PageGraphSignature};
251/// use mos_core::Document;
252/// use std::path::PathBuf;
253///
254/// let doc = Document::new(PathBuf::from("doc.mos"));
255/// let result = LayoutEngine::new().layout(&doc);
256/// let signature = PageGraphSignature::of_graph(&result.graph);
257/// // An unchanged layout signs identically and diverges nowhere.
258/// assert_eq!(signature, PageGraphSignature::of_graph(&result.graph));
259/// assert_eq!(signature.first_divergence(&signature), None);
260/// ```
261#[derive(Clone, Eq, PartialEq, Hash, Debug, Default)]
262pub struct PageGraphSignature(Vec<PageBoundarySignature>);
263
264impl PageGraphSignature {
265    /// Sign every page of `graph`, in page order.
266    ///
267    /// # Examples
268    ///
269    /// ```
270    /// use std::path::PathBuf;
271    ///
272    /// use mos_core::Document;
273    /// use mos_layout::{LayoutEngine, PageGraphSignature};
274    ///
275    /// let doc = Document::new(PathBuf::from("doc.mos"));
276    /// let result = LayoutEngine::new().layout(&doc);
277    /// let signature = PageGraphSignature::of_graph(&result.graph);
278    /// assert_eq!(signature.pages().len(), result.graph.pages.len());
279    /// ```
280    #[must_use]
281    pub fn of_graph(graph: &PageGraph) -> Self {
282        Self(
283            graph
284                .pages
285                .iter()
286                .map(PageBoundarySignature::of_page)
287                .collect(),
288        )
289    }
290
291    /// The per-page signatures, in page order.
292    ///
293    /// # Examples
294    ///
295    /// ```
296    /// use mos_layout::PageGraphSignature;
297    ///
298    /// let signature = PageGraphSignature::default();
299    /// assert!(signature.pages().is_empty());
300    /// ```
301    #[must_use]
302    pub fn pages(&self) -> &[PageBoundarySignature] {
303        &self.0
304    }
305
306    /// The index of the first page whose signature differs from `other`, or
307    /// [`None`] if the two are identical.
308    ///
309    /// When one graph is a prefix of the other (a page was added or removed at
310    /// the end), the divergence is the length of the shorter graph; the first
311    /// page index that exists in one but not the other.
312    ///
313    /// # Examples
314    ///
315    /// ```
316    /// use mos_layout::PageGraphSignature;
317    ///
318    /// let empty = PageGraphSignature::default();
319    /// assert_eq!(empty.first_divergence(&empty), None);
320    /// ```
321    #[must_use]
322    pub fn first_divergence(&self, other: &Self) -> Option<usize> {
323        self.0
324            .iter()
325            .zip(other.0.iter())
326            .position(|(a, b)| a != b)
327            .or_else(|| (self.0.len() != other.0.len()).then_some(self.0.len().min(other.0.len())))
328    }
329}
330
331impl LayoutResult {
332    /// The page boundary signatures of this layout's [`PageGraph`] (design note
333    /// §4.5). Convenience for cache/reflow consumers.
334    ///
335    /// # Examples
336    ///
337    /// ```
338    /// use std::path::PathBuf;
339    ///
340    /// use mos_core::Document;
341    /// use mos_layout::LayoutEngine;
342    ///
343    /// let doc = Document::new(PathBuf::from("doc.mos"));
344    /// let result = LayoutEngine::new().layout(&doc);
345    /// assert_eq!(
346    ///     result.page_boundary_signatures().pages().len(),
347    ///     result.graph.pages.len(),
348    /// );
349    /// ```
350    #[must_use]
351    pub fn page_boundary_signatures(&self) -> PageGraphSignature {
352        PageGraphSignature::of_graph(&self.graph)
353    }
354}
355
356#[cfg(test)]
357mod tests {
358    use std::sync::Arc;
359
360    use mos_fonts::{Base14Font, Font, ShapedGlyph};
361
362    use super::{PageBoundarySignature, PageGraphSignature, quantize_units};
363    use crate::types::{ImageHandle, ImagePlacement, Page, PageGraph, TextRun};
364
365    fn run(text: &str, x_pt: f32) -> TextRun {
366        TextRun {
367            x_pt,
368            baseline_from_top_pt: 100.0,
369            size_pt: 11.0,
370            font: Font::Base14(Base14Font::Helvetica),
371            text: text.to_owned(),
372            actual_text: None,
373            glyphs: Vec::new(),
374        }
375    }
376
377    fn page(number: u32, runs: Vec<TextRun>) -> Page {
378        Page {
379            number,
380            width_pt: 595.276,
381            height_pt: 841.89,
382            runs,
383            images: Vec::new(),
384        }
385    }
386
387    fn graph(pages: Vec<Page>) -> PageGraph {
388        PageGraph {
389            pages,
390            images: Vec::new(),
391            outline: Vec::new(),
392        }
393    }
394
395    #[test]
396    fn unchanged_page_signs_identically() {
397        let a = page(1, vec![run("hello", 68.0), run("world", 110.0)]);
398        let b = page(1, vec![run("hello", 68.0), run("world", 110.0)]);
399        assert_eq!(
400            PageBoundarySignature::of_page(&a),
401            PageBoundarySignature::of_page(&b),
402        );
403    }
404
405    #[test]
406    fn changed_content_changes_the_signature() {
407        let base = page(1, vec![run("hello", 68.0)]);
408        let edited_text = page(1, vec![run("hallo", 68.0)]);
409        let moved = page(1, vec![run("hello", 70.0)]);
410        let extra = page(1, vec![run("hello", 68.0), run("more", 68.0)]);
411
412        let base_sig = PageBoundarySignature::of_page(&base);
413        assert_ne!(base_sig, PageBoundarySignature::of_page(&edited_text));
414        assert_ne!(base_sig, PageBoundarySignature::of_page(&moved));
415        assert_ne!(base_sig, PageBoundarySignature::of_page(&extra));
416    }
417
418    #[test]
419    fn page_number_is_folded() {
420        // Identical content on a differently-numbered page must sign
421        // differently; the page number is part of the boundary.
422        let runs = || vec![run("same", 68.0)];
423        assert_ne!(
424            PageBoundarySignature::of_page(&page(1, runs())),
425            PageBoundarySignature::of_page(&page(2, runs())),
426        );
427    }
428
429    #[test]
430    fn sub_shaper_unit_position_changes_are_ignored() {
431        // Two positions within one 1/64-pt unit snap to the same grid cell.
432        let base = page(1, vec![run("x", 68.0)]);
433        let nudged = page(1, vec![run("x", 68.0 + 0.001)]);
434        assert_eq!(
435            PageBoundarySignature::of_page(&base),
436            PageBoundarySignature::of_page(&nudged),
437        );
438    }
439
440    #[test]
441    fn quantize_snaps_to_one_sixty_fourth_point() {
442        // Within a grid cell: equal. Across a cell boundary: distinct.
443        assert_eq!(quantize_units(10.0), quantize_units(10.001));
444        assert_ne!(quantize_units(10.0), quantize_units(10.5));
445        // -0.0, +0.0, and non-finite values all normalize to the zero cell so
446        // they never create spurious distinctions.
447        assert_eq!(quantize_units(0.0), quantize_units(-0.0));
448        assert_eq!(quantize_units(f32::NAN), quantize_units(0.0));
449        assert_eq!(quantize_units(f32::INFINITY), quantize_units(0.0));
450        assert_eq!(quantize_units(f32::NEG_INFINITY), quantize_units(0.0));
451    }
452
453    #[test]
454    fn shaped_glyphs_do_not_affect_the_signature() {
455        // Glyphs are derived from text + font, so two runs that differ only in
456        // their shaped-glyph stream must sign the same.
457        let plain = page(1, vec![run("hi", 68.0)]);
458        let mut shaped_run = run("hi", 68.0);
459        shaped_run.glyphs = vec![ShapedGlyph {
460            gid: 42,
461            advance_units: 500,
462            x_offset_units: 0,
463            y_offset_units: 0,
464            cluster: 0,
465        }];
466        let shaped = page(1, vec![shaped_run]);
467        assert_eq!(
468            PageBoundarySignature::of_page(&plain),
469            PageBoundarySignature::of_page(&shaped),
470        );
471    }
472
473    #[test]
474    fn image_content_path_and_unstable_id_are_excluded_but_footprint_is_not() {
475        let place = |handle: ImageHandle| Page {
476            number: 1,
477            width_pt: 595.276,
478            height_pt: 841.89,
479            runs: Vec::new(),
480            images: vec![ImagePlacement {
481                handle,
482                x_pt: 68.0,
483                top_from_top_pt: 100.0,
484                width_pt: 200.0,
485                height_pt: 150.0,
486            }],
487        };
488        let handle = |id: u32, path: &str, rgb8: &[u8]| ImageHandle {
489            id,
490            resolved_path: path.to_owned(),
491            pixel_width: 2,
492            pixel_height: 1,
493            rgb8: Arc::from(rgb8.to_vec()),
494        };
495        // Different encounter-order id, different absolute path, and different
496        // decoded bytes, but the same layout footprint: the signature must not
497        // change. None of those belong in a deterministic, locality-preserving
498        // page boundary.
499        let a = place(handle(7, "/home/alice/fig.png", &[1, 2, 3, 4, 5, 6]));
500        let b = place(handle(8, "/home/bob/fig.png", &[9, 9, 9, 9, 9, 9]));
501        assert_eq!(
502            PageBoundarySignature::of_page(&a),
503            PageBoundarySignature::of_page(&b),
504        );
505        // The intrinsic pixel size *is* part of the footprint (the signature
506        // reads dimensions, not the pixel buffer), so a differently-sized asset
507        // signs differently.
508        let mut resized = handle(7, "/home/alice/fig.png", &[1, 2, 3, 4, 5, 6]);
509        resized.pixel_width = 4;
510        assert_ne!(
511            PageBoundarySignature::of_page(&a),
512            PageBoundarySignature::of_page(&place(resized)),
513        );
514    }
515
516    #[test]
517    fn graph_signature_localizes_a_pagination_change() {
518        // Page 1 unchanged; page 2 gains a run (a break moved). The graph
519        // signatures must diverge first at index 1.
520        let before = graph(vec![
521            page(1, vec![run("a", 68.0)]),
522            page(2, vec![run("b", 68.0)]),
523        ]);
524        let after = graph(vec![
525            page(1, vec![run("a", 68.0)]),
526            page(2, vec![run("b", 68.0), run("c", 110.0)]),
527        ]);
528        let before_sig = PageGraphSignature::of_graph(&before);
529        let after_sig = PageGraphSignature::of_graph(&after);
530
531        assert_ne!(before_sig, after_sig);
532        assert_eq!(before_sig.first_divergence(&after_sig), Some(1));
533        assert_eq!(before_sig.first_divergence(&before_sig), None);
534    }
535
536    #[test]
537    fn graph_signature_flags_an_added_trailing_page() {
538        let short = graph(vec![page(1, vec![run("a", 68.0)])]);
539        let long = graph(vec![
540            page(1, vec![run("a", 68.0)]),
541            page(2, vec![run("b", 68.0)]),
542        ]);
543        let short_sig = PageGraphSignature::of_graph(&short);
544        let long_sig = PageGraphSignature::of_graph(&long);
545        // The shared page 0 matches; divergence is the new trailing index.
546        assert_eq!(short_sig.first_divergence(&long_sig), Some(1));
547        assert_eq!(short_sig.pages().len(), 1);
548        assert_eq!(long_sig.pages().len(), 2);
549    }
550}