kopitiam_document/reconstruction/mod.rs
1mod citations;
2mod figures;
3mod headings;
4mod lists;
5mod paragraphs;
6mod tables;
7
8use std::cmp::Ordering;
9
10use kopitiam_pdf::{Page, TextSpan};
11
12use crate::{Block, Document, Heading, Metadata, Paragraph};
13
14/// One visual line of text on a page: spans grouped by shared baseline and
15/// sorted left to right.
16struct Line {
17 text: String,
18 y: f32,
19 font_size: f32,
20 /// Sub-runs of this line separated by a gap wide enough to suggest a
21 /// column boundary; used by table detection.
22 cells: Vec<Cell>,
23}
24
25struct Cell {
26 text: String,
27 x: f32,
28 x_end: f32,
29}
30
31const SAME_LINE_Y_TOLERANCE_RATIO: f32 = 0.4;
32const WORD_GAP_RATIO: f32 = 0.15;
33const COLUMN_GAP_RATIO: f32 = 2.5;
34const STRADDLE_LINE_MAX_FRACTION: f32 = 0.15;
35const FULL_WIDTH_CELL_MIN_RATIO: f32 = 0.66;
36
37
38/// Turn a page's raw text spans into the semantic `Document` AST: split each
39/// page into reading-order columns, group spans into lines, then classify
40/// each line (or run of lines) as a heading, list, table, figure caption, or
41/// paragraph. A final pass repairs the one join a per-page pipeline cannot
42/// see by construction: a paragraph split across a page break (see
43/// `merge_page_breaks` / kopitiam-d3n).
44pub fn reconstruct(pages: &[Page]) -> Document {
45 let body_font_size = estimate_body_font_size(pages);
46 let mut citations = Vec::new();
47 let mut pages_blocks: Vec<Vec<Block>> = Vec::with_capacity(pages.len());
48
49 for page in pages {
50 let mut page_blocks = Vec::new();
51 for column_spans in split_columns(page) {
52 let lines = group_lines(&column_spans);
53 for block in build_blocks(&lines, body_font_size) {
54 if let Block::Paragraph(paragraph) = &block {
55 citations.extend(citations::detect(¶graph.text));
56 }
57 page_blocks.push(block);
58 }
59 }
60 pages_blocks.push(page_blocks);
61 }
62
63 let (blocks, block_pages) = merge_page_breaks(pages_blocks);
64
65 Document {
66 title: infer_title(&blocks),
67 metadata: Metadata {
68 source_pages: pages.len(),
69 },
70 blocks,
71 block_pages,
72 citations,
73 }
74}
75
76/// Joins each page's independently-reconstructed blocks into one stream,
77/// repairing a paragraph that a page break cut in two (kopitiam-d3n).
78///
79/// Reconstruction runs per page (`split_columns` and `build_blocks` only see
80/// one page's spans at a time), so a paragraph that runs from the bottom of
81/// page N into the top of page N+1 comes out of the per-page loop as two
82/// separate `Paragraph` blocks with no memory of each other. This pass is
83/// the only place that sees both halves at once, so it is the only place
84/// that can recognise and repair the split.
85///
86/// Only the immediate boundary between two pages is ever considered: the
87/// last block produced for page N against the first block produced for page
88/// N+1. That means a Heading/Table/Figure/List sitting at either boundary
89/// blocks the merge automatically, without extra logic -- the merge check
90/// only fires when *both* boundary blocks are `Block::Paragraph`. Blank
91/// pages (no spans, e.g. an intentional page break) are skipped when
92/// looking for a boundary, so a paragraph can still merge across a blank
93/// page onto the next page with real content.
94/// Returns the flattened blocks alongside the 1-based page each one **starts**
95/// on — see [`crate::Document::block_pages`] for why that page number is worth
96/// carrying rather than discarding, as this function used to.
97///
98/// A block merged across a page break keeps the *earlier* page, because that is
99/// where a reader following the citation should begin looking.
100fn merge_page_breaks(pages_blocks: Vec<Vec<Block>>) -> (Vec<Block>, Vec<usize>) {
101 let mut blocks: Vec<Block> = Vec::new();
102 let mut block_pages: Vec<usize> = Vec::new();
103
104 for (page_index, page_blocks) in pages_blocks.into_iter().enumerate() {
105 if page_blocks.is_empty() {
106 continue;
107 }
108 // Pages are 1-based when a human is going to read the number.
109 let page = page_index + 1;
110
111 let mut page_blocks = page_blocks.into_iter();
112 let leading = page_blocks.next();
113
114 let merged_text = match (blocks.last(), &leading) {
115 (Some(Block::Paragraph(trailing)), Some(Block::Paragraph(leading_paragraph))) => {
116 paragraphs::merge_across_page_break(&trailing.text, &leading_paragraph.text)
117 }
118 _ => None,
119 };
120
121 match merged_text {
122 Some(text) => {
123 *blocks
124 .last_mut()
125 .expect("merged_text is only Some when blocks.last() matched") =
126 Block::Paragraph(Paragraph { text });
127 // Deliberately do NOT touch this block's recorded page: the
128 // merged paragraph began on the previous page, and that is the
129 // page a citation must point at.
130 }
131 None => {
132 if let Some(leading_block) = leading {
133 blocks.push(leading_block);
134 block_pages.push(page);
135 }
136 }
137 }
138
139 for block in page_blocks {
140 blocks.push(block);
141 block_pages.push(page);
142 }
143 }
144
145 debug_assert_eq!(
146 blocks.len(),
147 block_pages.len(),
148 "block_pages must stay parallel to blocks, or every citation this document produces is wrong"
149 );
150
151 (blocks, block_pages)
152}
153
154fn build_blocks(lines: &[Line], body_font_size: f32) -> Vec<Block> {
155 let mut blocks = Vec::new();
156 let mut i = 0;
157
158 while i < lines.len() {
159 if let Some((table, consumed)) = tables::try_table(&lines[i..]) {
160 blocks.push(Block::Table(table));
161 i += consumed;
162 continue;
163 }
164
165 if let Some(figure) = figures::try_figure(&lines[i]) {
166 blocks.push(Block::Figure(figure));
167 i += 1;
168 continue;
169 }
170
171 if let Some(level) = headings::heading_level(&lines[i], body_font_size) {
172 blocks.push(Block::Heading(Heading {
173 level,
174 text: lines[i].text.trim().to_string(),
175 }));
176 i += 1;
177 continue;
178 }
179
180 if let Some((list, consumed)) = lists::try_list(&lines[i..]) {
181 blocks.push(Block::List(list));
182 i += consumed;
183 continue;
184 }
185
186 let (paragraph, consumed) = paragraphs::consume_paragraph(&lines[i..]);
187 blocks.push(Block::Paragraph(paragraph));
188 i += consumed;
189 }
190
191 blocks
192}
193
194fn infer_title(blocks: &[Block]) -> Option<String> {
195 blocks.iter().find_map(|block| match block {
196 Block::Heading(Heading { level: 1, text }) => Some(text.clone()),
197 _ => None,
198 })
199}
200
201/// The most common font size across the document, used as the "body text"
202/// baseline that heading detection compares against.
203///
204/// # Determinism, and the bug this used to have
205///
206/// This counted into a `HashMap` and picked the winner with `max_by_key`. When
207/// two font sizes tie on frequency, `max_by_key` returns whichever the iterator
208/// happened to yield last — and `HashMap`'s iteration order is **randomised per
209/// process**. So `reconstruct()` could produce a *different document from the
210/// same PDF on two runs*: a different body size means different headings, which
211/// means different structure.
212///
213/// That is a direct violation of the Semantic Runtime's reproducibility
214/// principle ("Indexes are reproducible, not synchronized" — CLAUDE.md), and it
215/// was not theoretical: it was hit on a real 3-line endorsement page, where a
216/// tie is entirely normal because there is barely any text to break it.
217///
218/// Ties are now broken **towards the smaller font size**, deterministically.
219/// That is not an arbitrary choice: body text is the thing there is most of, and
220/// when a document is too short to establish that by frequency, the smaller of
221/// two equally-common sizes is far more likely to be the body than the heading.
222/// Guessing "heading" would promote ordinary prose into headings and shred the
223/// structure.
224fn estimate_body_font_size(pages: &[Page]) -> f32 {
225 use std::collections::BTreeMap;
226
227 // BTreeMap, not HashMap: iteration is ordered by key, so the tie-break below
228 // is reproducible across runs and machines.
229 let mut counts: BTreeMap<u32, usize> = BTreeMap::new();
230 for page in pages {
231 for span in &page.spans {
232 // Bucket to the nearest half-point to absorb float noise.
233 let bucket = (span.font_size * 2.0).round() as u32;
234 *counts.entry(bucket).or_default() += 1;
235 }
236 }
237
238 counts
239 .into_iter()
240 // Highest count wins; on a tie, the SMALLEST bucket wins. `min_by_key`
241 // over (Reverse(count), bucket) picks max count, then min bucket — and
242 // because BTreeMap yields buckets in ascending order, the result is the
243 // same on every run.
244 .min_by_key(|&(bucket, count)| (std::cmp::Reverse(count), bucket))
245 .map(|(bucket, _)| bucket as f32 / 2.0)
246 .unwrap_or(12.0)
247}
248
249/// Splits a page's spans into left-to-right, top-to-bottom reading-order
250/// column groups.
251///
252/// A single-column page with normal margins routinely has lines whose text
253/// crosses the page's geometric midpoint (most paragraph lines are wider
254/// than half the page) -- so "spans exist on both sides of the midpoint" is
255/// true for nearly every document and cannot be the two-column test.
256///
257/// Real two-column layouts instead have a genuine empty gutter at the
258/// midpoint. But naively grouping all of a page's spans into y-based lines
259/// first (as `group_lines` does) can still merge left- and right-column text
260/// that happens to share a baseline (common: columns are typeset on a shared
261/// line grid) into one "line" whose overall bounding box crosses the
262/// midpoint -- even though neither column's text actually does. So the test
263/// is per *cell*, not per line's overall extent: a cell is one uninterrupted
264/// glyph run (see `build_line`'s gap detection), so a cell crossing the
265/// midpoint means continuous prose was actually typeset across it, whereas
266/// two same-baseline column fragments merged by `group_lines` show up as two
267/// separate cells that individually stay on one side.
268///
269/// A confirmed two-column page can still contain a full-width element (a
270/// spanning figure, table, or section heading) that interrupts the flow
271/// partway down -- see `split_two_column_page_into_bands` (kopitiam-zay).
272fn split_columns(page: &Page) -> Vec<Vec<TextSpan>> {
273 if page.spans.is_empty() {
274 return vec![Vec::new()];
275 }
276
277 let midpoint = page.width / 2.0;
278 let full_lines = group_lines(&page.spans);
279
280 let straddling = full_lines
281 .iter()
282 .filter(|line| is_full_width_line(line, page.width, midpoint))
283 .count();
284 let straddle_fraction = straddling as f32 / full_lines.len().max(1) as f32;
285
286 if straddle_fraction > STRADDLE_LINE_MAX_FRACTION {
287 return vec![page.spans.clone()];
288 }
289
290 let mut left = Vec::new();
291 let mut right = Vec::new();
292 for span in &page.spans {
293 let center = span.x + span.width / 2.0;
294 if center < midpoint {
295 left.push(span.clone());
296 } else {
297 right.push(span.clone());
298 }
299 }
300
301 if left.is_empty() || right.is_empty() {
302 return vec![page.spans.clone()];
303 }
304
305 split_two_column_page_into_bands(page, midpoint)
306}
307
308/// A line counts as a full-width interruption of a two-column layout under
309/// either of two independent signals:
310///
311/// - One of its cells (a single uninterrupted glyph run, see `build_line`)
312/// literally straddles the column gutter at the page midpoint. This is
313/// the same per-cell test `split_columns` uses to decide two-column vs.
314/// single-column in the first place, for the same reason: a merged same-
315/// baseline `Line` built from two unrelated column fragments must not be
316/// judged by its combined bounding box (see the `split_columns` doc
317/// comment), only by whether one continuous glyph run actually crosses
318/// the midpoint.
319/// - One of its cells is, by itself, wider than a plausible single column
320/// (`FULL_WIDTH_CELL_MIN_RATIO` of the page width). This catches a
321/// spanning element whose own internal layout (e.g. a table with its own
322/// column gap) happens not to cross the exact page midpoint pixel, while
323/// still being deliberately typeset wider than either page column. Like
324/// the straddle test, this is evaluated per cell rather than over the
325/// line's overall extent, so it cannot be fooled by two narrow same-
326/// baseline column fragments that merely sit far apart.
327fn is_full_width_line(line: &Line, page_width: f32, midpoint: f32) -> bool {
328 line.cells.iter().any(|cell| {
329 (cell.x < midpoint && cell.x_end > midpoint)
330 || (cell.x_end - cell.x) > page_width * FULL_WIDTH_CELL_MIN_RATIO
331 })
332}
333
334/// Reading order within a confirmed two-column page, in the presence of a
335/// full-width element that interrupts the two-column flow partway down
336/// (kopitiam-zay).
337///
338/// Without this, `split_columns` would bucket every span on the page into
339/// "left" or "right" purely by which side of the midpoint its centre falls
340/// on -- which is correct for genuine column text, but scrambles a spanning
341/// figure/table/heading: half its spans land in the left group and half in
342/// the right, and both halves get read in the wrong place (after all of the
343/// real left/right column text, instead of at the full-width element's own
344/// vertical position).
345///
346/// Instead this walks the page top to bottom and buckets each line into one
347/// of three running accumulators -- left column, right column, or the
348/// current full-width run -- flushing the other two whenever the mode
349/// changes. Consecutive full-width lines are kept in one run (rather than
350/// flushed line-by-line) so a multi-row full-width table or a multi-line
351/// full-width caption still reaches `build_blocks` as consecutive `Line`s,
352/// which multi-line detectors like `tables::try_table` require. The result
353/// is a sequence of column groups in true reading order: left-then-right
354/// within each vertical band, with full-width runs emitted as their own
355/// single group exactly where they occur between bands.
356fn split_two_column_page_into_bands(page: &Page, midpoint: f32) -> Vec<Vec<TextSpan>> {
357 let mut result = Vec::new();
358 let mut band_left: Vec<TextSpan> = Vec::new();
359 let mut band_right: Vec<TextSpan> = Vec::new();
360 let mut band_full_width: Vec<TextSpan> = Vec::new();
361
362 for mut group in group_spans_by_baseline(&page.spans) {
363 group.sort_by(|a, b| a.x.partial_cmp(&b.x).unwrap_or(Ordering::Equal));
364 let refs: Vec<&TextSpan> = group.iter().collect();
365 let line = build_line(&refs);
366
367 if is_full_width_line(&line, page.width, midpoint) {
368 if !band_left.is_empty() {
369 result.push(std::mem::take(&mut band_left));
370 }
371 if !band_right.is_empty() {
372 result.push(std::mem::take(&mut band_right));
373 }
374 band_full_width.extend(group);
375 } else {
376 if !band_full_width.is_empty() {
377 result.push(std::mem::take(&mut band_full_width));
378 }
379 for span in group {
380 let center = span.x + span.width / 2.0;
381 if center < midpoint {
382 band_left.push(span);
383 } else {
384 band_right.push(span);
385 }
386 }
387 }
388 }
389
390 if !band_full_width.is_empty() {
391 result.push(band_full_width);
392 }
393 if !band_left.is_empty() {
394 result.push(band_left);
395 }
396 if !band_right.is_empty() {
397 result.push(band_right);
398 }
399
400 result
401}
402
403fn group_lines(spans: &[TextSpan]) -> Vec<Line> {
404 group_spans_by_baseline(spans)
405 .into_iter()
406 .map(|mut group| {
407 group.sort_by(|a, b| a.x.partial_cmp(&b.x).unwrap_or(Ordering::Equal));
408 let refs: Vec<&TextSpan> = group.iter().collect();
409 build_line(&refs)
410 })
411 .collect()
412}
413
414/// Groups spans that share a baseline (within `SAME_LINE_Y_TOLERANCE_RATIO`
415/// of font size) into per-line runs, sorted top to bottom.
416///
417/// Factored out of `group_lines` so `split_two_column_page_into_bands` can
418/// reuse the same baseline-matching logic while keeping the original
419/// `TextSpan`s: `group_lines`'s `Line` output only keeps merged, already-
420/// concatenated `Cell` text, which is enough to classify a line but not
421/// enough to re-partition its spans between page columns.
422fn group_spans_by_baseline(spans: &[TextSpan]) -> Vec<Vec<TextSpan>> {
423 let mut ordered: Vec<&TextSpan> = spans.iter().collect();
424 ordered.sort_by(|a, b| b.y.partial_cmp(&a.y).unwrap_or(Ordering::Equal));
425
426 let mut groups: Vec<Vec<TextSpan>> = Vec::new();
427 for span in ordered {
428 let joins_last = groups.last().is_some_and(|group: &Vec<TextSpan>| {
429 let anchor = &group[0];
430 let tolerance = anchor.font_size.max(span.font_size) * SAME_LINE_Y_TOLERANCE_RATIO;
431 (anchor.y - span.y).abs() <= tolerance
432 });
433
434 if joins_last {
435 groups.last_mut().unwrap().push(span.clone());
436 } else {
437 groups.push(vec![span.clone()]);
438 }
439 }
440
441 groups
442}
443
444fn build_line(spans: &[&TextSpan]) -> Line {
445 let mut cells: Vec<Cell> = Vec::new();
446 let mut text = String::new();
447 let mut prev_end: Option<f32> = None;
448
449 for span in spans {
450 let gap = prev_end.map(|end| span.x - end);
451
452 // A real inter-word space is a much smaller gap than a column/cell
453 // boundary. Below `WORD_GAP_RATIO` the spans are contiguous glyphs
454 // (e.g. an OCR text layer that split one word into several spans)
455 // and must be concatenated with no space, or every such split would
456 // otherwise render as a broken word ("Boo k" instead of "Book").
457 let is_word_gap = gap.is_some_and(|gap| gap > span.font_size * WORD_GAP_RATIO);
458 let starts_new_cell = match gap {
459 Some(gap) => gap > span.font_size * COLUMN_GAP_RATIO,
460 None => true,
461 };
462
463 if starts_new_cell {
464 cells.push(Cell {
465 text: span.text.clone(),
466 x: span.x,
467 x_end: span.x + span.width,
468 });
469 } else if let Some(cell) = cells.last_mut() {
470 if is_word_gap {
471 cell.text.push(' ');
472 }
473 cell.text.push_str(&span.text);
474 cell.x_end = span.x + span.width;
475 }
476
477 if is_word_gap {
478 text.push(' ');
479 }
480 text.push_str(&span.text);
481
482 prev_end = Some(span.x + span.width);
483 }
484
485 let y = spans.first().map(|s| s.y).unwrap_or(0.0);
486 let font_size = spans.iter().map(|s| s.font_size).fold(0.0_f32, f32::max);
487
488 Line {
489 text,
490 y,
491 font_size,
492 cells,
493 }
494}
495
496#[cfg(test)]
497mod tests {
498 use super::*;
499
500 fn span(text: &str, x: f32, y: f32, width: f32, font_size: f32) -> TextSpan {
501 TextSpan {
502 text: text.to_string(),
503 x,
504 y,
505 width,
506 height: font_size,
507 font_size,
508 font_name: None,
509 ..TextSpan::default()
510 }
511 }
512
513 #[test]
514 fn build_line_merges_contiguous_glyph_runs_without_a_space() {
515 // "Boo" then "k" with almost no gap simulates an OCR text layer that
516 // split one word into two spans; it must read back as "Book".
517 let boo = span("Boo", 0.0, 0.0, 18.0, 10.0);
518 let k = span("k", 18.2, 0.0, 6.0, 10.0);
519 let line = build_line(&[&boo, &k]);
520 assert_eq!(line.text, "Book");
521 }
522
523 #[test]
524 fn build_line_keeps_a_space_for_a_real_word_gap() {
525 let book = span("Book", 0.0, 0.0, 24.0, 10.0);
526 let reviews = span("Reviews", 27.0, 0.0, 40.0, 10.0);
527 let line = build_line(&[&book, &reviews]);
528 assert_eq!(line.text, "Book Reviews");
529 }
530
531 #[test]
532 fn build_line_splits_a_wide_gap_into_a_new_cell() {
533 let metric = span("Metric", 0.0, 0.0, 30.0, 10.0);
534 let value = span("Value", 60.0, 0.0, 20.0, 10.0);
535 let line = build_line(&[&metric, &value]);
536 assert_eq!(line.cells.len(), 2);
537 }
538
539 #[test]
540 fn single_column_page_is_not_split() {
541 // Every line's text spans the full page width, crossing the
542 // midpoint -- this must not be mistaken for a two-column layout.
543 let page = Page {
544 number: 1,
545 width: 600.0,
546 height: 800.0,
547 spans: vec![
548 span(
549 "Line one spans the whole page width here",
550 50.0,
551 700.0,
552 500.0,
553 10.0,
554 ),
555 span(
556 "Line two also spans the whole page width",
557 50.0,
558 686.0,
559 500.0,
560 10.0,
561 ),
562 ],
563 };
564 assert_eq!(split_columns(&page).len(), 1);
565 }
566
567 #[test]
568 fn two_column_page_is_split() {
569 let page = Page {
570 number: 1,
571 width: 600.0,
572 height: 800.0,
573 spans: vec![
574 span("Left column text here", 40.0, 700.0, 220.0, 10.0),
575 span("Left column text here", 40.0, 686.0, 220.0, 10.0),
576 span("Right column text here", 340.0, 700.0, 220.0, 10.0),
577 span("Right column text here", 340.0, 686.0, 220.0, 10.0),
578 ],
579 };
580 assert_eq!(split_columns(&page).len(), 2);
581 }
582
583 /// Builds a two-column page with a full-width row (e.g. a spanning
584 /// figure/table/heading) interrupting the flow partway down, per
585 /// kopitiam-zay. Three rows of paired left/right text above and below
586 /// the interruption keep the full-width line's share of all lines under
587 /// `STRADDLE_LINE_MAX_FRACTION`, so the page is still correctly
588 /// recognised as two-column rather than falling back to the single-
589 /// column path.
590 fn two_column_page_with_full_width_interruption() -> Page {
591 let mut spans = Vec::new();
592 for (i, y) in [760.0, 748.0, 736.0].into_iter().enumerate() {
593 spans.push(span(&format!("Top left {i}"), 40.0, y, 220.0, 10.0));
594 spans.push(span(&format!("Top right {i}"), 340.0, y, 220.0, 10.0));
595 }
596 spans.push(span(
597 "Full width heading spanning both columns",
598 40.0,
599 700.0,
600 520.0,
601 10.0,
602 ));
603 for (i, y) in [660.0, 648.0, 636.0].into_iter().enumerate() {
604 spans.push(span(&format!("Bottom left {i}"), 40.0, y, 220.0, 10.0));
605 spans.push(span(&format!("Bottom right {i}"), 340.0, y, 220.0, 10.0));
606 }
607
608 Page {
609 number: 1,
610 width: 600.0,
611 height: 800.0,
612 spans,
613 }
614 }
615
616 #[test]
617 fn full_width_element_splits_a_two_column_page_into_bands() {
618 let page = two_column_page_with_full_width_interruption();
619 let columns = split_columns(&page);
620
621 // Top-left, top-right, the full-width run, bottom-left, bottom-right
622 // -- five groups in true top-to-bottom, left-then-right order, not
623 // "everything left of the midpoint, then everything right of it"
624 // (which would scatter the full-width row's spans across both).
625 assert_eq!(columns.len(), 5);
626 assert!(columns[0].iter().all(|s| s.text.starts_with("Top left")));
627 assert!(columns[1].iter().all(|s| s.text.starts_with("Top right")));
628 assert_eq!(columns[2].len(), 1);
629 assert_eq!(columns[2][0].text, "Full width heading spanning both columns");
630 assert!(columns[3].iter().all(|s| s.text.starts_with("Bottom left")));
631 assert!(columns[4].iter().all(|s| s.text.starts_with("Bottom right")));
632 }
633
634 #[test]
635 fn plain_two_column_page_is_unaffected_by_band_splitting() {
636 // Same shape as `two_column_page_is_split`, re-asserted through the
637 // banding path to confirm a page with no full-width interruption
638 // still produces exactly the original left-then-right column split.
639 let page = Page {
640 number: 1,
641 width: 600.0,
642 height: 800.0,
643 spans: vec![
644 span("Left column text here", 40.0, 700.0, 220.0, 10.0),
645 span("Left column text here", 40.0, 686.0, 220.0, 10.0),
646 span("Right column text here", 340.0, 700.0, 220.0, 10.0),
647 span("Right column text here", 340.0, 686.0, 220.0, 10.0),
648 ],
649 };
650 let columns = split_columns(&page);
651 assert_eq!(columns.len(), 2);
652 assert!(columns[0].iter().all(|s| s.text == "Left column text here"));
653 assert!(columns[1].iter().all(|s| s.text == "Right column text here"));
654 }
655
656 #[test]
657 fn paragraph_split_across_a_page_break_is_merged() {
658 // Single-column pages (spans deliberately cross the page midpoint,
659 // as in `single_column_page_is_not_split`) so column splitting is
660 // not a confound for this test -- only the cross-page merge pass is
661 // under test here.
662 let page1 = Page {
663 number: 1,
664 width: 600.0,
665 height: 800.0,
666 spans: vec![span(
667 "This paragraph is cut off at the bottom of the page and",
668 50.0,
669 700.0,
670 500.0,
671 10.0,
672 )],
673 };
674 let page2 = Page {
675 number: 2,
676 width: 600.0,
677 height: 800.0,
678 spans: vec![span(
679 "continues here after the page break.",
680 50.0,
681 700.0,
682 500.0,
683 10.0,
684 )],
685 };
686
687 let document = reconstruct(&[page1, page2]);
688 assert_eq!(document.blocks.len(), 1);
689 match &document.blocks[0] {
690 Block::Paragraph(paragraph) => assert_eq!(
691 paragraph.text,
692 "This paragraph is cut off at the bottom of the page and continues here after the page break."
693 ),
694 other => panic!("expected a merged Paragraph block, got {other:?}"),
695 }
696
697 // A merged paragraph must cite the page it STARTED on. It began on page
698 // 1; a citation pointing at page 2 would send a reader to the middle of
699 // a sentence and look authoritative doing it.
700 assert_eq!(document.page_of(0), Some(1));
701 }
702
703 #[test]
704 fn every_block_records_the_page_it_starts_on() {
705 // The property every provenance-carrying consumer depends on: a
706 // citation without a page is not one a reader can follow.
707 let page1 = Page {
708 number: 1,
709 width: 600.0,
710 height: 800.0,
711 spans: vec![span("Sentence one finishes here.", 50.0, 700.0, 500.0, 10.0)],
712 };
713 let page2 = Page {
714 number: 2,
715 width: 600.0,
716 height: 800.0,
717 spans: vec![span("Sentence two begins the second page.", 50.0, 700.0, 500.0, 10.0)],
718 };
719
720 let document = reconstruct(&[page1, page2]);
721
722 // Parallel, always. If these ever diverge, every citation the Document
723 // Engine produces is silently wrong.
724 assert_eq!(document.blocks.len(), document.block_pages.len());
725 assert_eq!(document.page_of(0), Some(1));
726 assert_eq!(document.page_of(1), Some(2));
727 // Out of range is None, never a guessed page 1.
728 assert_eq!(document.page_of(99), None);
729
730 let paired: Vec<Option<usize>> = document.blocks_with_pages().map(|(_, page)| page).collect();
731 assert_eq!(paired, vec![Some(1), Some(2)]);
732 }
733
734 #[test]
735 fn body_font_size_is_deterministic_when_two_sizes_tie() {
736 // reconstruct() counted font sizes into a HashMap and broke ties with
737 // `max_by_key`, so a tie was resolved by RANDOMISED hash iteration order
738 // -- meaning the same PDF could reconstruct differently on two runs. A
739 // different body size means different headings, which means a different
740 // document. This was hit on a real 3-line endorsement page, where a tie
741 // is entirely normal because there is barely any text to break it.
742 let tied = || Page {
743 number: 1,
744 width: 600.0,
745 height: 800.0,
746 spans: vec![
747 span("Alpha at ten point", 50.0, 700.0, 400.0, 10.0),
748 span("Bravo at fourteen", 50.0, 660.0, 400.0, 14.0),
749 ],
750 };
751
752 // Same input, many runs: the answer must never move.
753 let first = estimate_body_font_size(&[tied()]);
754 for _ in 0..64 {
755 assert_eq!(estimate_body_font_size(&[tied()]), first, "font-size estimate is not deterministic");
756 }
757
758 // And the tie breaks towards the SMALLER size: when a document is too
759 // short to establish the body size by frequency, the smaller of two
760 // equally-common sizes is far likelier to be body text than a heading.
761 // Guessing "heading" would promote ordinary prose and shred the structure.
762 assert_eq!(first, 10.0);
763 }
764
765 #[test]
766 fn a_document_with_no_page_information_reports_none_rather_than_guessing() {
767 // `Default` (and any hand-built Document) has no page information. It
768 // must say so, not silently attribute everything to page 1 -- a wrong
769 // page in a citation is worse than an absent one.
770 let document = Document {
771 blocks: vec![Block::Paragraph(Paragraph { text: "orphan".to_string() })],
772 ..Document::default()
773 };
774 assert_eq!(document.page_of(0), None);
775 assert_eq!(document.blocks_with_pages().next().unwrap().1, None);
776 }
777
778 #[test]
779 fn paragraph_ending_a_sentence_does_not_merge_across_a_page_break() {
780 let page1 = Page {
781 number: 1,
782 width: 600.0,
783 height: 800.0,
784 spans: vec![span(
785 "This sentence finishes cleanly on the first page.",
786 50.0,
787 700.0,
788 500.0,
789 10.0,
790 )],
791 };
792 let page2 = Page {
793 number: 2,
794 width: 600.0,
795 height: 800.0,
796 spans: vec![span(
797 "New paragraph starts capitalized on the next page.",
798 50.0,
799 700.0,
800 500.0,
801 10.0,
802 )],
803 };
804
805 let document = reconstruct(&[page1, page2]);
806 assert_eq!(document.blocks.len(), 2);
807 for block in &document.blocks {
808 assert!(matches!(block, Block::Paragraph(_)));
809 }
810 }
811}