Skip to main content

kopitiam_document/reconstruction/
mod.rs

1mod citations;
2mod figures;
3mod headers;
4mod headings;
5mod lists;
6mod paragraphs;
7mod tables;
8
9use std::cmp::Ordering;
10
11use kopitiam_pdf::{Page, TextSpan};
12
13use crate::{Block, Document, Heading, Metadata, Paragraph};
14
15/// Re-exported for `validation`, which must run the *identical* stripping over
16/// the same pages to keep the recovery ratio honest -- see `headers.rs` and
17/// `kopitiam_token_max.md` §2.1.
18pub(crate) use headers::strip_marginalia;
19
20/// Re-exported for `validation` for the same reason: the figure-label collapse
21/// deletes spans, so validation must rerun the *identical* pure pass over the
22/// same pages to discount those labels from the extracted side too (see
23/// `figures.rs` and `kopitiam_token_max.md` §2.1).
24pub(crate) use figures::collapse_figure_regions;
25
26/// One visual line of text on a page: spans grouped by shared baseline and
27/// sorted left to right.
28struct Line {
29    text: String,
30    y: f32,
31    font_size: f32,
32    /// Sub-runs of this line separated by a gap wide enough to suggest a
33    /// column boundary; used by table detection.
34    cells: Vec<Cell>,
35}
36
37struct Cell {
38    text: String,
39    x: f32,
40    x_end: f32,
41}
42
43const SAME_LINE_Y_TOLERANCE_RATIO: f32 = 0.4;
44const WORD_GAP_RATIO: f32 = 0.15;
45const COLUMN_GAP_RATIO: f32 = 2.5;
46const STRADDLE_LINE_MAX_FRACTION: f32 = 0.15;
47const FULL_WIDTH_CELL_MIN_RATIO: f32 = 0.66;
48
49
50/// Turn a page's raw text spans into the semantic `Document` AST: split each
51/// page into reading-order columns, group spans into lines, then classify
52/// each line (or run of lines) as a heading, list, table, figure caption, or
53/// paragraph. A final pass repairs the one join a per-page pipeline cannot
54/// see by construction: a paragraph split across a page break (see
55/// `merge_page_breaks` / kopitiam-d3n).
56pub fn reconstruct(pages: &[Page]) -> Document {
57    // Drop running heads/feet and bare page numbers before any layout analysis,
58    // so they never become spurious paragraphs. `validation::validate` reruns
59    // this same pass so the recovery ratio is not distorted (see `headers.rs`).
60    let pages = strip_marginalia(pages);
61    // Then collapse figure regions -- scattered diagram-label soup anchored to a
62    // `Fig. N` caption -- down to the caption alone, before those labels can
63    // each become a spurious `Paragraph`. `validation::validate` reruns this
64    // same pass too, for the same recovery-ratio reason (see `figures.rs`).
65    let pages = collapse_figure_regions(&pages);
66    let pages = pages.as_slice();
67
68    let body_font_size = estimate_body_font_size(pages);
69    let mut citations = Vec::new();
70    let mut pages_blocks: Vec<Vec<Block>> = Vec::with_capacity(pages.len());
71
72    for page in pages {
73        let mut page_blocks = Vec::new();
74        for column_spans in split_columns(page) {
75            let lines = group_lines(&column_spans);
76            for block in build_blocks(&lines, body_font_size) {
77                if let Block::Paragraph(paragraph) = &block {
78                    citations.extend(citations::detect(&paragraph.text));
79                }
80                page_blocks.push(block);
81            }
82        }
83        pages_blocks.push(page_blocks);
84    }
85
86    let (blocks, block_pages) = merge_page_breaks(pages_blocks);
87
88    Document {
89        title: infer_title(&blocks),
90        metadata: Metadata {
91            source_pages: pages.len(),
92        },
93        blocks,
94        block_pages,
95        citations,
96    }
97}
98
99/// Like [`reconstruct`], but for pages whose spans are **already in true
100/// reading order** — one [`TextSpan`] per visual line, columns already
101/// linearised and inter-word spacing already correct (what
102/// `kopitiam_pdf::extract_mupdf` produces via the ported MuPDF `stext` engine).
103///
104/// # Why a separate entry point (integration choice (a))
105///
106/// [`reconstruct`] does its own layout analysis: `split_columns` re-orders a
107/// page into reading-order column groups and `group_lines` re-groups spans by
108/// shared baseline. Both are exactly the work the MuPDF engine has *already*
109/// done — feeding its pre-ordered output back through them risks double
110/// processing: `split_columns`'s midpoint heuristic could re-interleave a
111/// layout the boxer already linearised, and baseline re-grouping could merge
112/// two already-distinct lines. So this variant **trusts the incoming order**:
113/// it takes each span as one line, in the given sequence, and skips
114/// `split_columns` + baseline re-grouping entirely.
115///
116/// Everything *downstream* of layout is deliberately kept — heading, list,
117/// table, figure, citation, and paragraph detection ([`build_blocks`]) and the
118/// cross-page paragraph merge ([`merge_page_breaks`]) all run unchanged on the
119/// ordered lines. That is the whole point of reusing this pipeline rather than
120/// mapping straight to the AST (option (b)): the semantic classifiers are
121/// engine-independent and worth keeping.
122///
123/// The one capability lost relative to [`reconstruct`] is per-line *cell*
124/// splitting for table detection: with one span per line the line has a single
125/// cell, so multi-column table recognition degrades to best-effort. Headings,
126/// lists, paragraphs, citations, and cross-page merge are unaffected.
127pub fn reconstruct_preordered(pages: &[Page]) -> Document {
128    // Same marginalia strip as the legacy path. `strip_marginalia` preserves
129    // span order within each page, so the pre-ordered reading order this path
130    // trusts is not disturbed -- only header/footer/page-number spans are
131    // removed. `validation::validate` reruns it to keep the ratio honest.
132    let pages = strip_marginalia(pages);
133    // Figure-region collapse likewise preserves the surviving spans' order
134    // (it only removes label spans), so the pre-ordered reading order is kept.
135    let pages = collapse_figure_regions(&pages);
136    let pages = pages.as_slice();
137
138    let body_font_size = estimate_body_font_size(pages);
139    let mut citations = Vec::new();
140    let mut pages_blocks: Vec<Vec<Block>> = Vec::with_capacity(pages.len());
141
142    for page in pages {
143        // Each span is already one line in true reading order. Build one `Line`
144        // per span, preserving order — no `split_columns`, no baseline
145        // re-grouping.
146        let lines: Vec<Line> = page.spans.iter().map(|span| build_line(&[span])).collect();
147
148        let mut page_blocks = Vec::new();
149        for block in build_blocks(&lines, body_font_size) {
150            if let Block::Paragraph(paragraph) = &block {
151                citations.extend(citations::detect(&paragraph.text));
152            }
153            page_blocks.push(block);
154        }
155        pages_blocks.push(page_blocks);
156    }
157
158    let (blocks, block_pages) = merge_page_breaks(pages_blocks);
159
160    Document {
161        title: infer_title(&blocks),
162        metadata: Metadata {
163            source_pages: pages.len(),
164        },
165        blocks,
166        block_pages,
167        citations,
168    }
169}
170
171/// Joins each page's independently-reconstructed blocks into one stream,
172/// repairing a paragraph that a page break cut in two (kopitiam-d3n).
173///
174/// Reconstruction runs per page (`split_columns` and `build_blocks` only see
175/// one page's spans at a time), so a paragraph that runs from the bottom of
176/// page N into the top of page N+1 comes out of the per-page loop as two
177/// separate `Paragraph` blocks with no memory of each other. This pass is
178/// the only place that sees both halves at once, so it is the only place
179/// that can recognise and repair the split.
180///
181/// Only the immediate boundary between two pages is ever considered: the
182/// last block produced for page N against the first block produced for page
183/// N+1. That means a Heading/Table/Figure/List sitting at either boundary
184/// blocks the merge automatically, without extra logic -- the merge check
185/// only fires when *both* boundary blocks are `Block::Paragraph`. Blank
186/// pages (no spans, e.g. an intentional page break) are skipped when
187/// looking for a boundary, so a paragraph can still merge across a blank
188/// page onto the next page with real content.
189/// Returns the flattened blocks alongside the 1-based page each one **starts**
190/// on — see [`crate::Document::block_pages`] for why that page number is worth
191/// carrying rather than discarding, as this function used to.
192///
193/// A block merged across a page break keeps the *earlier* page, because that is
194/// where a reader following the citation should begin looking.
195fn merge_page_breaks(pages_blocks: Vec<Vec<Block>>) -> (Vec<Block>, Vec<usize>) {
196    let mut blocks: Vec<Block> = Vec::new();
197    let mut block_pages: Vec<usize> = Vec::new();
198
199    for (page_index, page_blocks) in pages_blocks.into_iter().enumerate() {
200        if page_blocks.is_empty() {
201            continue;
202        }
203        // Pages are 1-based when a human is going to read the number.
204        let page = page_index + 1;
205
206        let mut page_blocks = page_blocks.into_iter();
207        let leading = page_blocks.next();
208
209        let merged_text = match (blocks.last(), &leading) {
210            (Some(Block::Paragraph(trailing)), Some(Block::Paragraph(leading_paragraph))) => {
211                paragraphs::merge_across_page_break(&trailing.text, &leading_paragraph.text)
212            }
213            _ => None,
214        };
215
216        match merged_text {
217            Some(text) => {
218                *blocks
219                    .last_mut()
220                    .expect("merged_text is only Some when blocks.last() matched") =
221                    Block::Paragraph(Paragraph { text });
222                // Deliberately do NOT touch this block's recorded page: the
223                // merged paragraph began on the previous page, and that is the
224                // page a citation must point at.
225            }
226            None => {
227                if let Some(leading_block) = leading {
228                    blocks.push(leading_block);
229                    block_pages.push(page);
230                }
231            }
232        }
233
234        for block in page_blocks {
235            blocks.push(block);
236            block_pages.push(page);
237        }
238    }
239
240    debug_assert_eq!(
241        blocks.len(),
242        block_pages.len(),
243        "block_pages must stay parallel to blocks, or every citation this document produces is wrong"
244    );
245
246    (blocks, block_pages)
247}
248
249fn build_blocks(lines: &[Line], body_font_size: f32) -> Vec<Block> {
250    let mut blocks = Vec::new();
251    let mut i = 0;
252
253    while i < lines.len() {
254        if let Some((table, consumed)) = tables::try_table(&lines[i..]) {
255            blocks.push(Block::Table(table));
256            i += consumed;
257            continue;
258        }
259
260        if let Some(figure) = figures::try_figure(&lines[i]) {
261            blocks.push(Block::Figure(figure));
262            i += 1;
263            continue;
264        }
265
266        if let Some(level) = headings::heading_level(&lines[i], body_font_size) {
267            blocks.push(Block::Heading(Heading {
268                level,
269                text: lines[i].text.trim().to_string(),
270            }));
271            i += 1;
272            continue;
273        }
274
275        if let Some((list, consumed)) = lists::try_list(&lines[i..]) {
276            blocks.push(Block::List(list));
277            i += consumed;
278            continue;
279        }
280
281        let (paragraph, consumed) = paragraphs::consume_paragraph(&lines[i..]);
282        blocks.push(Block::Paragraph(paragraph));
283        i += consumed;
284    }
285
286    blocks
287}
288
289fn infer_title(blocks: &[Block]) -> Option<String> {
290    blocks.iter().find_map(|block| match block {
291        Block::Heading(Heading { level: 1, text }) => Some(text.clone()),
292        _ => None,
293    })
294}
295
296/// The most common font size across the document, used as the "body text"
297/// baseline that heading detection compares against.
298///
299/// # Determinism, and the bug this used to have
300///
301/// This counted into a `HashMap` and picked the winner with `max_by_key`. When
302/// two font sizes tie on frequency, `max_by_key` returns whichever the iterator
303/// happened to yield last — and `HashMap`'s iteration order is **randomised per
304/// process**. So `reconstruct()` could produce a *different document from the
305/// same PDF on two runs*: a different body size means different headings, which
306/// means different structure.
307///
308/// That is a direct violation of the Semantic Runtime's reproducibility
309/// principle ("Indexes are reproducible, not synchronized" — CLAUDE.md), and it
310/// was not theoretical: it was hit on a real 3-line endorsement page, where a
311/// tie is entirely normal because there is barely any text to break it.
312///
313/// Ties are now broken **towards the smaller font size**, deterministically.
314/// That is not an arbitrary choice: body text is the thing there is most of, and
315/// when a document is too short to establish that by frequency, the smaller of
316/// two equally-common sizes is far more likely to be the body than the heading.
317/// Guessing "heading" would promote ordinary prose into headings and shred the
318/// structure.
319fn estimate_body_font_size(pages: &[Page]) -> f32 {
320    use std::collections::BTreeMap;
321
322    // BTreeMap, not HashMap: iteration is ordered by key, so the tie-break below
323    // is reproducible across runs and machines.
324    let mut counts: BTreeMap<u32, usize> = BTreeMap::new();
325    for page in pages {
326        for span in &page.spans {
327            // Bucket to the nearest half-point to absorb float noise.
328            let bucket = (span.font_size * 2.0).round() as u32;
329            *counts.entry(bucket).or_default() += 1;
330        }
331    }
332
333    counts
334        .into_iter()
335        // Highest count wins; on a tie, the SMALLEST bucket wins. `min_by_key`
336        // over (Reverse(count), bucket) picks max count, then min bucket — and
337        // because BTreeMap yields buckets in ascending order, the result is the
338        // same on every run.
339        .min_by_key(|&(bucket, count)| (std::cmp::Reverse(count), bucket))
340        .map(|(bucket, _)| bucket as f32 / 2.0)
341        .unwrap_or(12.0)
342}
343
344/// Splits a page's spans into left-to-right, top-to-bottom reading-order
345/// column groups.
346///
347/// A single-column page with normal margins routinely has lines whose text
348/// crosses the page's geometric midpoint (most paragraph lines are wider
349/// than half the page) -- so "spans exist on both sides of the midpoint" is
350/// true for nearly every document and cannot be the two-column test.
351///
352/// Real two-column layouts instead have a genuine empty gutter at the
353/// midpoint. But naively grouping all of a page's spans into y-based lines
354/// first (as `group_lines` does) can still merge left- and right-column text
355/// that happens to share a baseline (common: columns are typeset on a shared
356/// line grid) into one "line" whose overall bounding box crosses the
357/// midpoint -- even though neither column's text actually does. So the test
358/// is per *cell*, not per line's overall extent: a cell is one uninterrupted
359/// glyph run (see `build_line`'s gap detection), so a cell crossing the
360/// midpoint means continuous prose was actually typeset across it, whereas
361/// two same-baseline column fragments merged by `group_lines` show up as two
362/// separate cells that individually stay on one side.
363///
364/// A confirmed two-column page can still contain a full-width element (a
365/// spanning figure, table, or section heading) that interrupts the flow
366/// partway down -- see `split_two_column_page_into_bands` (kopitiam-zay).
367fn split_columns(page: &Page) -> Vec<Vec<TextSpan>> {
368    if page.spans.is_empty() {
369        return vec![Vec::new()];
370    }
371
372    let midpoint = page.width / 2.0;
373    let full_lines = group_lines(&page.spans);
374
375    let straddling = full_lines
376        .iter()
377        .filter(|line| is_full_width_line(line, page.width, midpoint))
378        .count();
379    let straddle_fraction = straddling as f32 / full_lines.len().max(1) as f32;
380
381    if straddle_fraction > STRADDLE_LINE_MAX_FRACTION {
382        return vec![page.spans.clone()];
383    }
384
385    let mut left = Vec::new();
386    let mut right = Vec::new();
387    for span in &page.spans {
388        let center = span.x + span.width / 2.0;
389        if center < midpoint {
390            left.push(span.clone());
391        } else {
392            right.push(span.clone());
393        }
394    }
395
396    if left.is_empty() || right.is_empty() {
397        return vec![page.spans.clone()];
398    }
399
400    split_two_column_page_into_bands(page, midpoint)
401}
402
403/// A line counts as a full-width interruption of a two-column layout under
404/// either of two independent signals:
405///
406/// - One of its cells (a single uninterrupted glyph run, see `build_line`)
407///   literally straddles the column gutter at the page midpoint. This is
408///   the same per-cell test `split_columns` uses to decide two-column vs.
409///   single-column in the first place, for the same reason: a merged same-
410///   baseline `Line` built from two unrelated column fragments must not be
411///   judged by its combined bounding box (see the `split_columns` doc
412///   comment), only by whether one continuous glyph run actually crosses
413///   the midpoint.
414/// - One of its cells is, by itself, wider than a plausible single column
415///   (`FULL_WIDTH_CELL_MIN_RATIO` of the page width). This catches a
416///   spanning element whose own internal layout (e.g. a table with its own
417///   column gap) happens not to cross the exact page midpoint pixel, while
418///   still being deliberately typeset wider than either page column. Like
419///   the straddle test, this is evaluated per cell rather than over the
420///   line's overall extent, so it cannot be fooled by two narrow same-
421///   baseline column fragments that merely sit far apart.
422fn is_full_width_line(line: &Line, page_width: f32, midpoint: f32) -> bool {
423    line.cells.iter().any(|cell| {
424        (cell.x < midpoint && cell.x_end > midpoint)
425            || (cell.x_end - cell.x) > page_width * FULL_WIDTH_CELL_MIN_RATIO
426    })
427}
428
429/// Reading order within a confirmed two-column page, in the presence of a
430/// full-width element that interrupts the two-column flow partway down
431/// (kopitiam-zay).
432///
433/// Without this, `split_columns` would bucket every span on the page into
434/// "left" or "right" purely by which side of the midpoint its centre falls
435/// on -- which is correct for genuine column text, but scrambles a spanning
436/// figure/table/heading: half its spans land in the left group and half in
437/// the right, and both halves get read in the wrong place (after all of the
438/// real left/right column text, instead of at the full-width element's own
439/// vertical position).
440///
441/// Instead this walks the page top to bottom and buckets each line into one
442/// of three running accumulators -- left column, right column, or the
443/// current full-width run -- flushing the other two whenever the mode
444/// changes. Consecutive full-width lines are kept in one run (rather than
445/// flushed line-by-line) so a multi-row full-width table or a multi-line
446/// full-width caption still reaches `build_blocks` as consecutive `Line`s,
447/// which multi-line detectors like `tables::try_table` require. The result
448/// is a sequence of column groups in true reading order: left-then-right
449/// within each vertical band, with full-width runs emitted as their own
450/// single group exactly where they occur between bands.
451fn split_two_column_page_into_bands(page: &Page, midpoint: f32) -> Vec<Vec<TextSpan>> {
452    let mut result = Vec::new();
453    let mut band_left: Vec<TextSpan> = Vec::new();
454    let mut band_right: Vec<TextSpan> = Vec::new();
455    let mut band_full_width: Vec<TextSpan> = Vec::new();
456
457    for mut group in group_spans_by_baseline(&page.spans) {
458        group.sort_by(|a, b| a.x.partial_cmp(&b.x).unwrap_or(Ordering::Equal));
459        let refs: Vec<&TextSpan> = group.iter().collect();
460        let line = build_line(&refs);
461
462        if is_full_width_line(&line, page.width, midpoint) {
463            if !band_left.is_empty() {
464                result.push(std::mem::take(&mut band_left));
465            }
466            if !band_right.is_empty() {
467                result.push(std::mem::take(&mut band_right));
468            }
469            band_full_width.extend(group);
470        } else {
471            if !band_full_width.is_empty() {
472                result.push(std::mem::take(&mut band_full_width));
473            }
474            for span in group {
475                let center = span.x + span.width / 2.0;
476                if center < midpoint {
477                    band_left.push(span);
478                } else {
479                    band_right.push(span);
480                }
481            }
482        }
483    }
484
485    if !band_full_width.is_empty() {
486        result.push(band_full_width);
487    }
488    if !band_left.is_empty() {
489        result.push(band_left);
490    }
491    if !band_right.is_empty() {
492        result.push(band_right);
493    }
494
495    result
496}
497
498fn group_lines(spans: &[TextSpan]) -> Vec<Line> {
499    group_spans_by_baseline(spans)
500        .into_iter()
501        .map(|mut group| {
502            group.sort_by(|a, b| a.x.partial_cmp(&b.x).unwrap_or(Ordering::Equal));
503            let refs: Vec<&TextSpan> = group.iter().collect();
504            build_line(&refs)
505        })
506        .collect()
507}
508
509/// Groups spans that share a baseline (within `SAME_LINE_Y_TOLERANCE_RATIO`
510/// of font size) into per-line runs, sorted top to bottom.
511///
512/// Factored out of `group_lines` so `split_two_column_page_into_bands` can
513/// reuse the same baseline-matching logic while keeping the original
514/// `TextSpan`s: `group_lines`'s `Line` output only keeps merged, already-
515/// concatenated `Cell` text, which is enough to classify a line but not
516/// enough to re-partition its spans between page columns.
517fn group_spans_by_baseline(spans: &[TextSpan]) -> Vec<Vec<TextSpan>> {
518    let mut ordered: Vec<&TextSpan> = spans.iter().collect();
519    ordered.sort_by(|a, b| b.y.partial_cmp(&a.y).unwrap_or(Ordering::Equal));
520
521    let mut groups: Vec<Vec<TextSpan>> = Vec::new();
522    for span in ordered {
523        let joins_last = groups.last().is_some_and(|group: &Vec<TextSpan>| {
524            let anchor = &group[0];
525            let tolerance = anchor.font_size.max(span.font_size) * SAME_LINE_Y_TOLERANCE_RATIO;
526            (anchor.y - span.y).abs() <= tolerance
527        });
528
529        if joins_last {
530            groups.last_mut().unwrap().push(span.clone());
531        } else {
532            groups.push(vec![span.clone()]);
533        }
534    }
535
536    groups
537}
538
539fn build_line(spans: &[&TextSpan]) -> Line {
540    let mut cells: Vec<Cell> = Vec::new();
541    let mut text = String::new();
542    let mut prev_end: Option<f32> = None;
543
544    for span in spans {
545        let gap = prev_end.map(|end| span.x - end);
546
547        // A real inter-word space is a much smaller gap than a column/cell
548        // boundary. Below `WORD_GAP_RATIO` the spans are contiguous glyphs
549        // (e.g. an OCR text layer that split one word into several spans)
550        // and must be concatenated with no space, or every such split would
551        // otherwise render as a broken word ("Boo k" instead of "Book").
552        let is_word_gap = gap.is_some_and(|gap| gap > span.font_size * WORD_GAP_RATIO);
553        let starts_new_cell = match gap {
554            Some(gap) => gap > span.font_size * COLUMN_GAP_RATIO,
555            None => true,
556        };
557
558        if starts_new_cell {
559            cells.push(Cell {
560                text: span.text.clone(),
561                x: span.x,
562                x_end: span.x + span.width,
563            });
564        } else if let Some(cell) = cells.last_mut() {
565            if is_word_gap {
566                cell.text.push(' ');
567            }
568            cell.text.push_str(&span.text);
569            cell.x_end = span.x + span.width;
570        }
571
572        if is_word_gap {
573            text.push(' ');
574        }
575        text.push_str(&span.text);
576
577        prev_end = Some(span.x + span.width);
578    }
579
580    let y = spans.first().map(|s| s.y).unwrap_or(0.0);
581    let font_size = spans.iter().map(|s| s.font_size).fold(0.0_f32, f32::max);
582
583    Line {
584        text,
585        y,
586        font_size,
587        cells,
588    }
589}
590
591#[cfg(test)]
592mod tests {
593    use super::*;
594
595    fn span(text: &str, x: f32, y: f32, width: f32, font_size: f32) -> TextSpan {
596        TextSpan {
597            text: text.to_string(),
598            x,
599            y,
600            width,
601            height: font_size,
602            font_size,
603            font_name: None,
604            ..TextSpan::default()
605        }
606    }
607
608    #[test]
609    fn build_line_merges_contiguous_glyph_runs_without_a_space() {
610        // "Boo" then "k" with almost no gap simulates an OCR text layer that
611        // split one word into two spans; it must read back as "Book".
612        let boo = span("Boo", 0.0, 0.0, 18.0, 10.0);
613        let k = span("k", 18.2, 0.0, 6.0, 10.0);
614        let line = build_line(&[&boo, &k]);
615        assert_eq!(line.text, "Book");
616    }
617
618    #[test]
619    fn build_line_keeps_a_space_for_a_real_word_gap() {
620        let book = span("Book", 0.0, 0.0, 24.0, 10.0);
621        let reviews = span("Reviews", 27.0, 0.0, 40.0, 10.0);
622        let line = build_line(&[&book, &reviews]);
623        assert_eq!(line.text, "Book Reviews");
624    }
625
626    #[test]
627    fn build_line_splits_a_wide_gap_into_a_new_cell() {
628        let metric = span("Metric", 0.0, 0.0, 30.0, 10.0);
629        let value = span("Value", 60.0, 0.0, 20.0, 10.0);
630        let line = build_line(&[&metric, &value]);
631        assert_eq!(line.cells.len(), 2);
632    }
633
634    #[test]
635    fn single_column_page_is_not_split() {
636        // Every line's text spans the full page width, crossing the
637        // midpoint -- this must not be mistaken for a two-column layout.
638        let page = Page {
639            number: 1,
640            width: 600.0,
641            height: 800.0,
642            spans: vec![
643                span(
644                    "Line one spans the whole page width here",
645                    50.0,
646                    700.0,
647                    500.0,
648                    10.0,
649                ),
650                span(
651                    "Line two also spans the whole page width",
652                    50.0,
653                    686.0,
654                    500.0,
655                    10.0,
656                ),
657            ],
658        };
659        assert_eq!(split_columns(&page).len(), 1);
660    }
661
662    #[test]
663    fn two_column_page_is_split() {
664        let page = Page {
665            number: 1,
666            width: 600.0,
667            height: 800.0,
668            spans: vec![
669                span("Left column text here", 40.0, 700.0, 220.0, 10.0),
670                span("Left column text here", 40.0, 686.0, 220.0, 10.0),
671                span("Right column text here", 340.0, 700.0, 220.0, 10.0),
672                span("Right column text here", 340.0, 686.0, 220.0, 10.0),
673            ],
674        };
675        assert_eq!(split_columns(&page).len(), 2);
676    }
677
678    /// Builds a two-column page with a full-width row (e.g. a spanning
679    /// figure/table/heading) interrupting the flow partway down, per
680    /// kopitiam-zay. Three rows of paired left/right text above and below
681    /// the interruption keep the full-width line's share of all lines under
682    /// `STRADDLE_LINE_MAX_FRACTION`, so the page is still correctly
683    /// recognised as two-column rather than falling back to the single-
684    /// column path.
685    fn two_column_page_with_full_width_interruption() -> Page {
686        let mut spans = Vec::new();
687        for (i, y) in [760.0, 748.0, 736.0].into_iter().enumerate() {
688            spans.push(span(&format!("Top left {i}"), 40.0, y, 220.0, 10.0));
689            spans.push(span(&format!("Top right {i}"), 340.0, y, 220.0, 10.0));
690        }
691        spans.push(span(
692            "Full width heading spanning both columns",
693            40.0,
694            700.0,
695            520.0,
696            10.0,
697        ));
698        for (i, y) in [660.0, 648.0, 636.0].into_iter().enumerate() {
699            spans.push(span(&format!("Bottom left {i}"), 40.0, y, 220.0, 10.0));
700            spans.push(span(&format!("Bottom right {i}"), 340.0, y, 220.0, 10.0));
701        }
702
703        Page {
704            number: 1,
705            width: 600.0,
706            height: 800.0,
707            spans,
708        }
709    }
710
711    #[test]
712    fn full_width_element_splits_a_two_column_page_into_bands() {
713        let page = two_column_page_with_full_width_interruption();
714        let columns = split_columns(&page);
715
716        // Top-left, top-right, the full-width run, bottom-left, bottom-right
717        // -- five groups in true top-to-bottom, left-then-right order, not
718        // "everything left of the midpoint, then everything right of it"
719        // (which would scatter the full-width row's spans across both).
720        assert_eq!(columns.len(), 5);
721        assert!(columns[0].iter().all(|s| s.text.starts_with("Top left")));
722        assert!(columns[1].iter().all(|s| s.text.starts_with("Top right")));
723        assert_eq!(columns[2].len(), 1);
724        assert_eq!(columns[2][0].text, "Full width heading spanning both columns");
725        assert!(columns[3].iter().all(|s| s.text.starts_with("Bottom left")));
726        assert!(columns[4].iter().all(|s| s.text.starts_with("Bottom right")));
727    }
728
729    #[test]
730    fn plain_two_column_page_is_unaffected_by_band_splitting() {
731        // Same shape as `two_column_page_is_split`, re-asserted through the
732        // banding path to confirm a page with no full-width interruption
733        // still produces exactly the original left-then-right column split.
734        let page = Page {
735            number: 1,
736            width: 600.0,
737            height: 800.0,
738            spans: vec![
739                span("Left column text here", 40.0, 700.0, 220.0, 10.0),
740                span("Left column text here", 40.0, 686.0, 220.0, 10.0),
741                span("Right column text here", 340.0, 700.0, 220.0, 10.0),
742                span("Right column text here", 340.0, 686.0, 220.0, 10.0),
743            ],
744        };
745        let columns = split_columns(&page);
746        assert_eq!(columns.len(), 2);
747        assert!(columns[0].iter().all(|s| s.text == "Left column text here"));
748        assert!(columns[1].iter().all(|s| s.text == "Right column text here"));
749    }
750
751    #[test]
752    fn paragraph_split_across_a_page_break_is_merged() {
753        // Single-column pages (spans deliberately cross the page midpoint,
754        // as in `single_column_page_is_not_split`) so column splitting is
755        // not a confound for this test -- only the cross-page merge pass is
756        // under test here.
757        let page1 = Page {
758            number: 1,
759            width: 600.0,
760            height: 800.0,
761            spans: vec![span(
762                "This paragraph is cut off at the bottom of the page and",
763                50.0,
764                700.0,
765                500.0,
766                10.0,
767            )],
768        };
769        let page2 = Page {
770            number: 2,
771            width: 600.0,
772            height: 800.0,
773            spans: vec![span(
774                "continues here after the page break.",
775                50.0,
776                700.0,
777                500.0,
778                10.0,
779            )],
780        };
781
782        let document = reconstruct(&[page1, page2]);
783        assert_eq!(document.blocks.len(), 1);
784        match &document.blocks[0] {
785            Block::Paragraph(paragraph) => assert_eq!(
786                paragraph.text,
787                "This paragraph is cut off at the bottom of the page and continues here after the page break."
788            ),
789            other => panic!("expected a merged Paragraph block, got {other:?}"),
790        }
791
792        // A merged paragraph must cite the page it STARTED on. It began on page
793        // 1; a citation pointing at page 2 would send a reader to the middle of
794        // a sentence and look authoritative doing it.
795        assert_eq!(document.page_of(0), Some(1));
796    }
797
798    #[test]
799    fn every_block_records_the_page_it_starts_on() {
800        // The property every provenance-carrying consumer depends on: a
801        // citation without a page is not one a reader can follow.
802        let page1 = Page {
803            number: 1,
804            width: 600.0,
805            height: 800.0,
806            spans: vec![span("Sentence one finishes here.", 50.0, 700.0, 500.0, 10.0)],
807        };
808        let page2 = Page {
809            number: 2,
810            width: 600.0,
811            height: 800.0,
812            spans: vec![span("Sentence two begins the second page.", 50.0, 700.0, 500.0, 10.0)],
813        };
814
815        let document = reconstruct(&[page1, page2]);
816
817        // Parallel, always. If these ever diverge, every citation the Document
818        // Engine produces is silently wrong.
819        assert_eq!(document.blocks.len(), document.block_pages.len());
820        assert_eq!(document.page_of(0), Some(1));
821        assert_eq!(document.page_of(1), Some(2));
822        // Out of range is None, never a guessed page 1.
823        assert_eq!(document.page_of(99), None);
824
825        let paired: Vec<Option<usize>> = document.blocks_with_pages().map(|(_, page)| page).collect();
826        assert_eq!(paired, vec![Some(1), Some(2)]);
827    }
828
829    #[test]
830    fn body_font_size_is_deterministic_when_two_sizes_tie() {
831        // reconstruct() counted font sizes into a HashMap and broke ties with
832        // `max_by_key`, so a tie was resolved by RANDOMISED hash iteration order
833        // -- meaning the same PDF could reconstruct differently on two runs. A
834        // different body size means different headings, which means a different
835        // document. This was hit on a real 3-line endorsement page, where a tie
836        // is entirely normal because there is barely any text to break it.
837        let tied = || Page {
838            number: 1,
839            width: 600.0,
840            height: 800.0,
841            spans: vec![
842                span("Alpha at ten point", 50.0, 700.0, 400.0, 10.0),
843                span("Bravo at fourteen", 50.0, 660.0, 400.0, 14.0),
844            ],
845        };
846
847        // Same input, many runs: the answer must never move.
848        let first = estimate_body_font_size(&[tied()]);
849        for _ in 0..64 {
850            assert_eq!(estimate_body_font_size(&[tied()]), first, "font-size estimate is not deterministic");
851        }
852
853        // And the tie breaks towards the SMALLER size: when a document is too
854        // short to establish the body size by frequency, the smaller of two
855        // equally-common sizes is far likelier to be body text than a heading.
856        // Guessing "heading" would promote ordinary prose and shred the structure.
857        assert_eq!(first, 10.0);
858    }
859
860    #[test]
861    fn a_document_with_no_page_information_reports_none_rather_than_guessing() {
862        // `Default` (and any hand-built Document) has no page information. It
863        // must say so, not silently attribute everything to page 1 -- a wrong
864        // page in a citation is worse than an absent one.
865        let document = Document {
866            blocks: vec![Block::Paragraph(Paragraph { text: "orphan".to_string() })],
867            ..Document::default()
868        };
869        assert_eq!(document.page_of(0), None);
870        assert_eq!(document.blocks_with_pages().next().unwrap().1, None);
871    }
872
873    #[test]
874    fn preordered_trusts_span_order_and_does_not_reinterleave_columns() {
875        // Two spans laid out as if they were the LEFT and RIGHT columns of a
876        // two-column page, but already linearised by the mupdf engine into
877        // reading order: left line first, right line second. `reconstruct`'s
878        // column logic keys off x-position; `reconstruct_preordered` must
879        // ignore geometry and keep the given order verbatim.
880        let page = Page {
881            number: 1,
882            width: 600.0,
883            height: 800.0,
884            spans: vec![
885                // Given SECOND in x (right column) but FIRST in reading order.
886                span("Left column sentence one.", 40.0, 700.0, 220.0, 10.0),
887                span("Right column sentence two.", 340.0, 700.0, 220.0, 10.0),
888            ],
889        };
890
891        let document = reconstruct_preordered(&[page]);
892        // The two consecutive lines join into one paragraph (that is normal
893        // paragraph behaviour); what matters is the ORDER — left line first,
894        // right line second, exactly as supplied. `reconstruct`'s column logic
895        // would instead bucket by x and could reorder; the pre-ordered path
896        // must not.
897        let texts: Vec<&str> = document
898            .blocks
899            .iter()
900            .filter_map(|b| match b {
901                Block::Paragraph(p) => Some(p.text.as_str()),
902                _ => None,
903            })
904            .collect();
905        assert_eq!(
906            texts,
907            vec!["Left column sentence one. Right column sentence two."],
908            "preordered reconstruction must preserve the given reading order"
909        );
910    }
911
912    #[test]
913    fn preordered_still_detects_headings_and_merges_page_breaks() {
914        // Heading detection (font-size ratio) and cross-page paragraph merge
915        // must still run on the pre-ordered path.
916        let page1 = Page {
917            number: 1,
918            width: 600.0,
919            height: 800.0,
920            spans: vec![
921                span("Big Heading", 50.0, 750.0, 200.0, 20.0),
922                span("A paragraph that runs off the bottom of the first page and", 50.0, 700.0, 500.0, 10.0),
923            ],
924        };
925        let page2 = Page {
926            number: 2,
927            width: 600.0,
928            height: 800.0,
929            spans: vec![span("continues onto the second page.", 50.0, 750.0, 400.0, 10.0)],
930        };
931
932        let document = reconstruct_preordered(&[page1, page2]);
933
934        assert!(
935            matches!(&document.blocks[0], Block::Heading(h) if h.text == "Big Heading"),
936            "large-font line must still be a heading, got {:?}",
937            document.blocks[0]
938        );
939        // The split paragraph must be merged across the page break into one.
940        let paragraphs: Vec<&str> = document
941            .blocks
942            .iter()
943            .filter_map(|b| match b {
944                Block::Paragraph(p) => Some(p.text.as_str()),
945                _ => None,
946            })
947            .collect();
948        assert_eq!(
949            paragraphs,
950            vec!["A paragraph that runs off the bottom of the first page and continues onto the second page."]
951        );
952    }
953
954    fn cell(text: &str, x: f32) -> Cell {
955        Cell {
956            text: text.to_string(),
957            x,
958            x_end: x + 20.0,
959        }
960    }
961
962    fn line_with_cells(cells: Vec<Cell>) -> Line {
963        Line {
964            text: cells
965                .iter()
966                .map(|c| c.text.as_str())
967                .collect::<Vec<_>>()
968                .join(" "),
969            y: 0.0,
970            font_size: 10.0,
971            cells,
972        }
973    }
974
975    #[test]
976    fn ragged_table_row_truncates_the_table_and_the_remainder_survives() {
977        // The one-cell-per-line failure from `kopitiam_token_max.md` §6 card
978        // I-E, exercised through `build_blocks`: a five-line run whose fourth
979        // line is ragged (a merged "subtotal" cell). The three uniform rows
980        // must become a Table, and the ragged row plus what follows must
981        // survive as their own block(s) -- not be swallowed into the table,
982        // and not collapse the whole run into one paragraph per cell.
983        let lines = vec![
984            line_with_cells(vec![cell("Metric", 0.0), cell("Value", 60.0)]),
985            line_with_cells(vec![cell("Commits", 0.0), cell("282", 60.0)]),
986            line_with_cells(vec![cell("Outside", 0.0), cell("81", 60.0)]),
987            line_with_cells(vec![cell("Subtotal across both columns", 0.0)]),
988            line_with_cells(vec![cell("Reviews", 0.0), cell("7", 60.0)]),
989        ];
990
991        let blocks = build_blocks(&lines, 10.0);
992
993        assert!(
994            matches!(&blocks[0], Block::Table(t)
995                if t.headers == vec!["Metric", "Value"]
996                && t.rows == vec![vec!["Commits", "282"], vec!["Outside", "81"]]),
997            "first block must be the uniform table prefix, got {:?}",
998            blocks[0]
999        );
1000        // Everything after the prefix is preserved somewhere in the remaining
1001        // blocks -- the ragged row is neither lost nor merged into the table.
1002        let tail: String = blocks[1..]
1003            .iter()
1004            .filter_map(|b| match b {
1005                Block::Paragraph(p) => Some(p.text.clone()),
1006                _ => None,
1007            })
1008            .collect::<Vec<_>>()
1009            .join(" ");
1010        assert!(
1011            tail.contains("Subtotal across both columns"),
1012            "ragged subtotal row must survive as normal text, got {tail:?}"
1013        );
1014    }
1015
1016    #[test]
1017    fn paragraph_ending_a_sentence_does_not_merge_across_a_page_break() {
1018        let page1 = Page {
1019            number: 1,
1020            width: 600.0,
1021            height: 800.0,
1022            spans: vec![span(
1023                "This sentence finishes cleanly on the first page.",
1024                50.0,
1025                700.0,
1026                500.0,
1027                10.0,
1028            )],
1029        };
1030        let page2 = Page {
1031            number: 2,
1032            width: 600.0,
1033            height: 800.0,
1034            spans: vec![span(
1035                "New paragraph starts capitalized on the next page.",
1036                50.0,
1037                700.0,
1038                500.0,
1039                10.0,
1040            )],
1041        };
1042
1043        let document = reconstruct(&[page1, page2]);
1044        assert_eq!(document.blocks.len(), 2);
1045        for block in &document.blocks {
1046            assert!(matches!(block, Block::Paragraph(_)));
1047        }
1048    }
1049}