Skip to main content

kopitiam_document/reconstruction/
mod.rs

1mod citations;
2mod figures;
3mod headings;
4mod lists;
5mod paragraphs;
6mod tables;
7
8use std::cmp::Ordering;
9
10use kopitiam_pdf::{Page, TextSpan};
11
12use crate::{Block, Document, Heading, Metadata};
13
14/// One visual line of text on a page: spans grouped by shared baseline and
15/// sorted left to right.
16struct Line {
17    text: String,
18    y: f32,
19    font_size: f32,
20    /// Sub-runs of this line separated by a gap wide enough to suggest a
21    /// column boundary; used by table detection.
22    cells: Vec<Cell>,
23}
24
25struct Cell {
26    text: String,
27    x: f32,
28    x_end: f32,
29}
30
31const SAME_LINE_Y_TOLERANCE_RATIO: f32 = 0.4;
32const WORD_GAP_RATIO: f32 = 0.15;
33const COLUMN_GAP_RATIO: f32 = 2.5;
34const STRADDLE_LINE_MAX_FRACTION: f32 = 0.15;
35
36/// Turn a page's raw text spans into the semantic `Document` AST: split each
37/// page into reading-order columns, group spans into lines, then classify
38/// each line (or run of lines) as a heading, list, table, figure caption, or
39/// paragraph.
40pub fn reconstruct(pages: &[Page]) -> Document {
41    let body_font_size = estimate_body_font_size(pages);
42    let mut blocks = Vec::new();
43    let mut citations = Vec::new();
44
45    for page in pages {
46        for column_spans in split_columns(page) {
47            let lines = group_lines(&column_spans);
48            for block in build_blocks(&lines, body_font_size) {
49                if let Block::Paragraph(paragraph) = &block {
50                    citations.extend(citations::detect(&paragraph.text));
51                }
52                blocks.push(block);
53            }
54        }
55    }
56
57    Document {
58        title: infer_title(&blocks),
59        metadata: Metadata {
60            source_pages: pages.len(),
61        },
62        blocks,
63        citations,
64    }
65}
66
67fn build_blocks(lines: &[Line], body_font_size: f32) -> Vec<Block> {
68    let mut blocks = Vec::new();
69    let mut i = 0;
70
71    while i < lines.len() {
72        if let Some((table, consumed)) = tables::try_table(&lines[i..]) {
73            blocks.push(Block::Table(table));
74            i += consumed;
75            continue;
76        }
77
78        if let Some(figure) = figures::try_figure(&lines[i]) {
79            blocks.push(Block::Figure(figure));
80            i += 1;
81            continue;
82        }
83
84        if let Some(level) = headings::heading_level(&lines[i], body_font_size) {
85            blocks.push(Block::Heading(Heading {
86                level,
87                text: lines[i].text.trim().to_string(),
88            }));
89            i += 1;
90            continue;
91        }
92
93        if let Some((list, consumed)) = lists::try_list(&lines[i..]) {
94            blocks.push(Block::List(list));
95            i += consumed;
96            continue;
97        }
98
99        let (paragraph, consumed) = paragraphs::consume_paragraph(&lines[i..]);
100        blocks.push(Block::Paragraph(paragraph));
101        i += consumed;
102    }
103
104    blocks
105}
106
107fn infer_title(blocks: &[Block]) -> Option<String> {
108    blocks.iter().find_map(|block| match block {
109        Block::Heading(Heading { level: 1, text }) => Some(text.clone()),
110        _ => None,
111    })
112}
113
114/// The most common font size across the document, used as the "body text"
115/// baseline that heading detection compares against.
116fn estimate_body_font_size(pages: &[Page]) -> f32 {
117    use std::collections::HashMap;
118
119    let mut counts: HashMap<u32, usize> = HashMap::new();
120    for page in pages {
121        for span in &page.spans {
122            // Bucket to the nearest half-point to absorb float noise.
123            let bucket = (span.font_size * 2.0).round() as u32;
124            *counts.entry(bucket).or_default() += 1;
125        }
126    }
127
128    counts
129        .into_iter()
130        .max_by_key(|&(_, count)| count)
131        .map(|(bucket, _)| bucket as f32 / 2.0)
132        .unwrap_or(12.0)
133}
134
135/// Splits a page's spans into left-to-right reading-order column groups.
136///
137/// A single-column page with normal margins routinely has lines whose text
138/// crosses the page's geometric midpoint (most paragraph lines are wider
139/// than half the page) -- so "spans exist on both sides of the midpoint" is
140/// true for nearly every document and cannot be the two-column test.
141///
142/// Real two-column layouts instead have a genuine empty gutter at the
143/// midpoint. But naively grouping all of a page's spans into y-based lines
144/// first (as `group_lines` does) can still merge left- and right-column text
145/// that happens to share a baseline (common: columns are typeset on a shared
146/// line grid) into one "line" whose overall bounding box crosses the
147/// midpoint -- even though neither column's text actually does. So the test
148/// is per *cell*, not per line's overall extent: a cell is one uninterrupted
149/// glyph run (see `build_line`'s gap detection), so a cell crossing the
150/// midpoint means continuous prose was actually typeset across it, whereas
151/// two same-baseline column fragments merged by `group_lines` show up as two
152/// separate cells that individually stay on one side.
153///
154/// Does not handle a full-width element (e.g. a spanning figure or table)
155/// that interrupts a two-column region partway down the page -- see
156/// kopitiam-zay.
157fn split_columns(page: &Page) -> Vec<Vec<TextSpan>> {
158    if page.spans.is_empty() {
159        return vec![Vec::new()];
160    }
161
162    let midpoint = page.width / 2.0;
163    let full_lines = group_lines(&page.spans);
164
165    let straddling = full_lines
166        .iter()
167        .filter(|line| {
168            line.cells
169                .iter()
170                .any(|cell| cell.x < midpoint && cell.x_end > midpoint)
171        })
172        .count();
173    let straddle_fraction = straddling as f32 / full_lines.len().max(1) as f32;
174
175    if straddle_fraction > STRADDLE_LINE_MAX_FRACTION {
176        return vec![page.spans.clone()];
177    }
178
179    let mut left = Vec::new();
180    let mut right = Vec::new();
181    for span in &page.spans {
182        let center = span.x + span.width / 2.0;
183        if center < midpoint {
184            left.push(span.clone());
185        } else {
186            right.push(span.clone());
187        }
188    }
189
190    if left.is_empty() || right.is_empty() {
191        vec![page.spans.clone()]
192    } else {
193        vec![left, right]
194    }
195}
196
197fn group_lines(spans: &[TextSpan]) -> Vec<Line> {
198    let mut ordered: Vec<&TextSpan> = spans.iter().collect();
199    ordered.sort_by(|a, b| b.y.partial_cmp(&a.y).unwrap_or(Ordering::Equal));
200
201    let mut groups: Vec<Vec<&TextSpan>> = Vec::new();
202    for span in ordered {
203        let joins_last = groups.last().is_some_and(|group: &Vec<&TextSpan>| {
204            let anchor = group[0];
205            let tolerance = anchor.font_size.max(span.font_size) * SAME_LINE_Y_TOLERANCE_RATIO;
206            (anchor.y - span.y).abs() <= tolerance
207        });
208
209        if joins_last {
210            groups.last_mut().unwrap().push(span);
211        } else {
212            groups.push(vec![span]);
213        }
214    }
215
216    groups
217        .into_iter()
218        .map(|mut group| {
219            group.sort_by(|a, b| a.x.partial_cmp(&b.x).unwrap_or(Ordering::Equal));
220            build_line(&group)
221        })
222        .collect()
223}
224
225fn build_line(spans: &[&TextSpan]) -> Line {
226    let mut cells: Vec<Cell> = Vec::new();
227    let mut text = String::new();
228    let mut prev_end: Option<f32> = None;
229
230    for span in spans {
231        let gap = prev_end.map(|end| span.x - end);
232
233        // A real inter-word space is a much smaller gap than a column/cell
234        // boundary. Below `WORD_GAP_RATIO` the spans are contiguous glyphs
235        // (e.g. an OCR text layer that split one word into several spans)
236        // and must be concatenated with no space, or every such split would
237        // otherwise render as a broken word ("Boo k" instead of "Book").
238        let is_word_gap = gap.is_some_and(|gap| gap > span.font_size * WORD_GAP_RATIO);
239        let starts_new_cell = match gap {
240            Some(gap) => gap > span.font_size * COLUMN_GAP_RATIO,
241            None => true,
242        };
243
244        if starts_new_cell {
245            cells.push(Cell {
246                text: span.text.clone(),
247                x: span.x,
248                x_end: span.x + span.width,
249            });
250        } else if let Some(cell) = cells.last_mut() {
251            if is_word_gap {
252                cell.text.push(' ');
253            }
254            cell.text.push_str(&span.text);
255            cell.x_end = span.x + span.width;
256        }
257
258        if is_word_gap {
259            text.push(' ');
260        }
261        text.push_str(&span.text);
262
263        prev_end = Some(span.x + span.width);
264    }
265
266    let y = spans.first().map(|s| s.y).unwrap_or(0.0);
267    let font_size = spans.iter().map(|s| s.font_size).fold(0.0_f32, f32::max);
268
269    Line {
270        text,
271        y,
272        font_size,
273        cells,
274    }
275}
276
277#[cfg(test)]
278mod tests {
279    use super::*;
280
281    fn span(text: &str, x: f32, y: f32, width: f32, font_size: f32) -> TextSpan {
282        TextSpan {
283            text: text.to_string(),
284            x,
285            y,
286            width,
287            height: font_size,
288            font_size,
289            font_name: None,
290        }
291    }
292
293    #[test]
294    fn build_line_merges_contiguous_glyph_runs_without_a_space() {
295        // "Boo" then "k" with almost no gap simulates an OCR text layer that
296        // split one word into two spans; it must read back as "Book".
297        let boo = span("Boo", 0.0, 0.0, 18.0, 10.0);
298        let k = span("k", 18.2, 0.0, 6.0, 10.0);
299        let line = build_line(&[&boo, &k]);
300        assert_eq!(line.text, "Book");
301    }
302
303    #[test]
304    fn build_line_keeps_a_space_for_a_real_word_gap() {
305        let book = span("Book", 0.0, 0.0, 24.0, 10.0);
306        let reviews = span("Reviews", 27.0, 0.0, 40.0, 10.0);
307        let line = build_line(&[&book, &reviews]);
308        assert_eq!(line.text, "Book Reviews");
309    }
310
311    #[test]
312    fn build_line_splits_a_wide_gap_into_a_new_cell() {
313        let metric = span("Metric", 0.0, 0.0, 30.0, 10.0);
314        let value = span("Value", 60.0, 0.0, 20.0, 10.0);
315        let line = build_line(&[&metric, &value]);
316        assert_eq!(line.cells.len(), 2);
317    }
318
319    #[test]
320    fn single_column_page_is_not_split() {
321        // Every line's text spans the full page width, crossing the
322        // midpoint -- this must not be mistaken for a two-column layout.
323        let page = Page {
324            number: 1,
325            width: 600.0,
326            height: 800.0,
327            spans: vec![
328                span(
329                    "Line one spans the whole page width here",
330                    50.0,
331                    700.0,
332                    500.0,
333                    10.0,
334                ),
335                span(
336                    "Line two also spans the whole page width",
337                    50.0,
338                    686.0,
339                    500.0,
340                    10.0,
341                ),
342            ],
343        };
344        assert_eq!(split_columns(&page).len(), 1);
345    }
346
347    #[test]
348    fn two_column_page_is_split() {
349        let page = Page {
350            number: 1,
351            width: 600.0,
352            height: 800.0,
353            spans: vec![
354                span("Left column text here", 40.0, 700.0, 220.0, 10.0),
355                span("Left column text here", 40.0, 686.0, 220.0, 10.0),
356                span("Right column text here", 340.0, 700.0, 220.0, 10.0),
357                span("Right column text here", 340.0, 686.0, 220.0, 10.0),
358            ],
359        };
360        assert_eq!(split_columns(&page).len(), 2);
361    }
362}