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}