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(¶graph.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(¶graph.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}