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