Skip to main content

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(&paragraph.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}