kopitiam_document/reconstruction/
mod.rs1mod 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
14struct Line {
17 text: String,
18 y: f32,
19 font_size: f32,
20 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
36pub 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(¶graph.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
114fn 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 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
135fn 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 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 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 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}