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