pdf_docx/layout.rs
1//! Spatial grouping of text blocks into lines, paragraphs, and tables.
2
3// Copyright (c) 2026 Innovation Trigger B.V.
4//
5// PDFluent is available under two licences, at your option: the GNU AGPLv3, or
6// the PDFluent Commercial Licence. See the LICENSE file in this repository --
7// that file travels with the copy you received, which a URL does not.
8
9use pdf_extract::TextBlock;
10
11/// Tolerance for grouping text blocks into lines (points).
12const LINE_Y_TOLERANCE: f64 = 2.0;
13
14/// Vertical gap threshold for paragraph breaks (fraction of font size).
15const PARAGRAPH_GAP_FACTOR: f64 = 1.5;
16
17/// Tolerance for column alignment in table detection (points).
18const TABLE_X_TOLERANCE: f64 = 5.0;
19
20/// A run of text with consistent formatting — the smallest unit emitted to
21/// the resulting DOCX.
22///
23/// A [`Paragraph`] is a sequence of one or more runs. A new run is started
24/// whenever the layout detector observes a change in font, size, weight, or
25/// style on the same line; consecutive characters with the same formatting
26/// stay in a single run.
27#[derive(Debug, Clone)]
28pub struct Run {
29 /// The actual text content of this run, in source order. May contain
30 /// any UTF-8 characters extracted from the PDF page.
31 pub text: String,
32 /// PostScript name of the font as it appears in the PDF (e.g.
33 /// `Helvetica`, `TimesNewRomanPS-BoldMT`). Mapped to a Word font name
34 /// during DOCX writing.
35 pub font_name: String,
36 /// Font size in PDF user-space points. Persisted to DOCX as half-points
37 /// (Word's native unit).
38 pub font_size: f64,
39 /// Whether the run is rendered bold. Detected from font name suffix
40 /// (`-Bold`, `Bd`) or PDF font flags.
41 pub bold: bool,
42 /// Whether the run is rendered italic. Detected from font name suffix
43 /// (`-Italic`, `It`, `Oblique`) or PDF font flags.
44 pub italic: bool,
45}
46
47/// A paragraph composed of one or more [`Run`]s.
48///
49/// Produced by the line-grouping pass: lines whose vertical gap is below
50/// [`PARAGRAPH_GAP_FACTOR`] times the font size are considered part of the
51/// same paragraph; a larger gap ends the paragraph.
52#[derive(Debug, Clone)]
53pub struct Paragraph {
54 /// The runs that make up this paragraph, in reading order. Empty
55 /// paragraphs are valid and represent blank lines.
56 pub runs: Vec<Run>,
57}
58
59/// A table reconstructed from text blocks aligned in columns.
60///
61/// Detected when consecutive lines share the same column x-coordinates
62/// within [`TABLE_X_TOLERANCE`]. Rebuilt into a regular grid where each
63/// row has the same number of cells (`col_count`); short rows are
64/// right-padded with empty strings.
65#[derive(Debug, Clone)]
66pub struct Table {
67 /// Row-major cell content. `rows[r][c]` is the text in column `c` of
68 /// row `r`. All rows have length [`Self::col_count`].
69 pub rows: Vec<Vec<String>>,
70 /// Number of columns in the table — the maximum column count observed
71 /// during column-alignment detection.
72 pub col_count: usize,
73}
74
75/// An image to embed into the resulting DOCX document.
76///
77/// Produced when the layout pass identifies an image XObject on the page
78/// that should be carried over to the Word document. The bytes are kept
79/// verbatim; the DOCX writer wraps them in the appropriate `w:drawing`
80/// element with the given dimensions.
81#[derive(Debug, Clone)]
82pub struct DocxImage {
83 /// Raw image bytes in the format described by [`Self::content_type`]
84 /// (typically PNG or JPEG).
85 pub data: Vec<u8>,
86 /// Image width in pixels. Used to compute the on-page rendered size.
87 pub width: u32,
88 /// Image height in pixels. Used to compute the on-page rendered size.
89 pub height: u32,
90 /// MIME type of [`Self::data`] — e.g. `image/png`, `image/jpeg`. Drives
91 /// the part name and `Override` content-type entry in the DOCX `[Content_Types].xml`.
92 pub content_type: String,
93 /// Stable identifier used to deduplicate images that appear on multiple
94 /// pages and to wire up the relationship reference in the DOCX.
95 pub id: String,
96}
97
98/// One element in the per-page layout: a paragraph, a table, or an image.
99///
100/// Produced by the layout analysis pass. Pages emit a `Vec<PageElement>` in
101/// reading order; the DOCX writer iterates these and produces matching
102/// Word document parts.
103#[derive(Debug, Clone)]
104pub enum PageElement {
105 /// A flowing paragraph of text — see [`Paragraph`].
106 Para(Paragraph),
107 /// A reconstructed table — see [`Table`].
108 Tbl(Table),
109 /// An embedded image — see [`DocxImage`].
110 Img(DocxImage),
111}
112
113/// A line of text (blocks at roughly the same y-coordinate).
114#[derive(Debug)]
115struct Line {
116 y: f64,
117 font_size: f64,
118 blocks: Vec<TextBlock>,
119}
120
121/// Analyze text blocks from a page and group them into paragraphs and tables.
122pub fn analyze_page(blocks: &[TextBlock]) -> Vec<PageElement> {
123 if blocks.is_empty() {
124 return Vec::new();
125 }
126
127 let lines = group_into_lines(blocks);
128 let table = try_detect_table(&lines);
129
130 if let Some(tbl) = table {
131 return vec![PageElement::Tbl(tbl)];
132 }
133
134 group_into_paragraphs(&lines)
135}
136
137/// Group text blocks into lines based on y-coordinate proximity.
138fn group_into_lines(blocks: &[TextBlock]) -> Vec<Line> {
139 let mut sorted: Vec<&TextBlock> = blocks.iter().collect();
140 // Sort by y descending (PDF origin is bottom-left), then x ascending.
141 sorted.sort_by(|a, b| {
142 let y_cmp = b.bbox[1]
143 .partial_cmp(&a.bbox[1])
144 .unwrap_or(std::cmp::Ordering::Equal);
145 if y_cmp == std::cmp::Ordering::Equal {
146 a.bbox[0]
147 .partial_cmp(&b.bbox[0])
148 .unwrap_or(std::cmp::Ordering::Equal)
149 } else {
150 y_cmp
151 }
152 });
153
154 let mut lines: Vec<Line> = Vec::new();
155
156 for block in sorted {
157 let y = block.bbox[1];
158 let matched = lines
159 .iter_mut()
160 .find(|line| (line.y - y).abs() < LINE_Y_TOLERANCE);
161
162 if let Some(line) = matched {
163 line.blocks.push(block.clone());
164 } else {
165 lines.push(Line {
166 y,
167 font_size: block.font_size,
168 blocks: vec![block.clone()],
169 });
170 }
171 }
172
173 // Sort each line's blocks by x-coordinate.
174 for line in &mut lines {
175 line.blocks.sort_by(|a, b| {
176 a.bbox[0]
177 .partial_cmp(&b.bbox[0])
178 .unwrap_or(std::cmp::Ordering::Equal)
179 });
180 }
181
182 lines
183}
184
185/// A column is an x-position that recurs on several lines.
186///
187/// Not every distinct x. That was the bug: `col_count` was the number of
188/// distinct x-positions across the whole block set, and in a real PDF almost
189/// every word starts at its own x. A line of twenty words therefore produced
190/// twenty columns -- roughly one word per cell, which leaves the file valid and
191/// unusable. See #161.
192///
193/// What distinguishes a column from an accidental word position is recurrence:
194/// text beginning at (nearly) the same x on several lines. An x that appears on
195/// one line only is a word.
196///
197/// Two lines or 30% was still too little, and the end-to-end test said so: a
198/// grid of four rows by two columns came back with six columns. In a real table
199/// a cell holds words, and every word starts at its own x. Those word positions
200/// shift only a few points from row to row, so within [`TABLE_X_TOLERANCE`] two
201/// of them keep landing together -- which already satisfied the old threshold.
202///
203/// What separates a column from a word is therefore not *that* the x recurs but
204/// *how often*: a column start recurs on nearly every line, a word position does
205/// not. Hence 75%. Lower lets word positions through; much higher loses tables
206/// in which a cell is occasionally empty.
207const MIN_COLUMN_LINES: usize = 2;
208const MIN_COLUMN_SUPPORT_PERCENT: usize = 75;
209
210/// The x-positions that behave as a column, ordered left to right.
211fn column_positions(lines: &[Line]) -> Vec<f64> {
212 // Per candidate x: on how many distinct lines does text start there?
213 let mut candidates: Vec<(f64, usize)> = Vec::new();
214 for line in lines {
215 let mut counted: Vec<usize> = Vec::new();
216 for block in &line.blocks {
217 let x = block.bbox[0];
218 match candidates
219 .iter()
220 .position(|&(px, _)| (px - x).abs() < TABLE_X_TOLERANCE)
221 {
222 Some(i) => {
223 // One line counts at most once for the same column;
224 // otherwise a line with five words at the same x invents a
225 // column that is not there.
226 if !counted.contains(&i) {
227 candidates[i].1 += 1;
228 counted.push(i);
229 }
230 }
231 None => {
232 candidates.push((x, 1));
233 counted.push(candidates.len() - 1);
234 }
235 }
236 }
237 }
238
239 // Rounded up, not down.
240 //
241 // At three lines `3 * 75 / 100` gave two, so a word position that happened
242 // to coincide on two of the three lines still counted as a column. On a
243 // small table every line is evidence, and the requirement must not evaporate
244 // through integer division.
245 let required = (lines.len() * MIN_COLUMN_SUPPORT_PERCENT).div_ceil(100);
246 let threshold = std::cmp::max(MIN_COLUMN_LINES, required);
247 let mut positions: Vec<f64> = candidates
248 .into_iter()
249 .filter(|&(_, support)| support >= threshold)
250 .map(|(x, _)| x)
251 .collect();
252 positions.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
253 positions
254}
255
256/// The column this block belongs to: the nearest column that does not sit to
257/// the right of the block.
258///
259/// Previously everything that did not start exactly on a column fell back to
260/// column 0 (`unwrap_or(0)`), which dumped unaligned text from the whole line
261/// into the first cell. Text belongs to the column it sits under.
262fn column_for(x: f64, positions: &[f64]) -> usize {
263 let mut best = 0;
264 for (i, &px) in positions.iter().enumerate() {
265 if px <= x + TABLE_X_TOLERANCE {
266 best = i;
267 } else {
268 break;
269 }
270 }
271 best
272}
273
274/// Try to detect a table from aligned text lines.
275///
276/// A table is detected when multiple lines share the same column structure
277/// (i.e., text blocks start at similar x-positions across lines).
278fn try_detect_table(lines: &[Line]) -> Option<Table> {
279 if lines.len() < 2 {
280 return None;
281 }
282
283 let x_positions = column_positions(lines);
284 if x_positions.len() < 2 {
285 return None;
286 }
287
288 // Do most lines carry text in more than one column?
289 let multi_col_lines = lines
290 .iter()
291 .filter(|line| {
292 line.blocks
293 .iter()
294 .map(|b| column_for(b.bbox[0], &x_positions))
295 .collect::<std::collections::HashSet<_>>()
296 .len()
297 >= 2
298 })
299 .count();
300
301 // At least 60% of lines need multiple columns for table detection.
302 if multi_col_lines * 100 / lines.len() < 60 {
303 return None;
304 }
305
306 let col_count = x_positions.len();
307 let mut rows = Vec::new();
308
309 for line in lines {
310 let mut row = vec![String::new(); col_count];
311 for block in &line.blocks {
312 let col_idx = column_for(block.bbox[0], &x_positions);
313 if !row[col_idx].is_empty() {
314 row[col_idx].push(' ');
315 }
316 row[col_idx].push_str(&block.text);
317 }
318 rows.push(row);
319 }
320
321 Some(Table { rows, col_count })
322}
323
324/// Group lines into paragraphs based on vertical spacing.
325fn group_into_paragraphs(lines: &[Line]) -> Vec<PageElement> {
326 let mut elements = Vec::new();
327 let mut current_runs: Vec<Run> = Vec::new();
328 let mut prev_y: Option<f64> = None;
329 let mut prev_font_size: f64 = 12.0;
330
331 for line in lines {
332 let line_text: String = line
333 .blocks
334 .iter()
335 .map(|b| b.text.as_str())
336 .collect::<Vec<_>>()
337 .join(" ");
338
339 if line_text.trim().is_empty() {
340 continue;
341 }
342
343 let is_new_paragraph = if let Some(py) = prev_y {
344 let gap = (py - line.y).abs();
345 gap > prev_font_size * PARAGRAPH_GAP_FACTOR
346 } else {
347 false
348 };
349
350 if is_new_paragraph && !current_runs.is_empty() {
351 elements.push(PageElement::Para(Paragraph {
352 runs: std::mem::take(&mut current_runs),
353 }));
354 }
355
356 let font_name = line
357 .blocks
358 .first()
359 .map(|b| b.font_name.clone())
360 .unwrap_or_default();
361 let font_size = line.font_size;
362
363 let bold = font_name.contains("Bold") || font_name.contains("bold");
364 let italic = font_name.contains("Italic")
365 || font_name.contains("italic")
366 || font_name.contains("Oblique");
367
368 current_runs.push(Run {
369 text: line_text,
370 font_name,
371 font_size,
372 bold,
373 italic,
374 });
375
376 prev_y = Some(line.y);
377 prev_font_size = font_size;
378 }
379
380 if !current_runs.is_empty() {
381 elements.push(PageElement::Para(Paragraph { runs: current_runs }));
382 }
383
384 elements
385}
386
387/// Detect bold/italic from a PDF font name.
388pub fn map_font_name(pdf_font: &str) -> &str {
389 // Strip common prefixes like "ABCDEF+" used in subset fonts.
390 let name = if let Some(pos) = pdf_font.find('+') {
391 &pdf_font[pos + 1..]
392 } else {
393 pdf_font
394 };
395
396 // Map common font families.
397 if name.contains("Times") || name.contains("Serif") {
398 "Times New Roman"
399 } else if name.contains("Arial") || name.contains("Helvetica") || name.contains("Sans") {
400 "Arial"
401 } else if name.contains("Courier") || name.contains("Mono") {
402 "Courier New"
403 } else if name.contains("Symbol") {
404 "Symbol"
405 } else {
406 "Calibri"
407 }
408}
409
410#[cfg(test)]
411mod tests {
412 use super::*;
413
414 fn make_block(text: &str, x: f64, y: f64, font_size: f64) -> TextBlock {
415 TextBlock {
416 text: text.to_string(),
417 page: 1,
418 bbox: [x, y, x + text.len() as f64 * font_size * 0.5, y + font_size],
419 font_name: "F1".to_string(),
420 font_size,
421 actual_text: None,
422 base_font: None,
423 is_bold: false,
424 is_italic: false,
425 color: None,
426 width_source: Default::default(),
427 char_bounds: vec![],
428 }
429 }
430
431 #[test]
432 fn single_line_becomes_paragraph() {
433 let blocks = vec![make_block("Hello World", 72.0, 720.0, 12.0)];
434 let elements = analyze_page(&blocks);
435 assert_eq!(elements.len(), 1);
436 assert!(matches!(elements[0], PageElement::Para(_)));
437 }
438
439 #[test]
440 fn two_close_lines_same_paragraph() {
441 let blocks = vec![
442 make_block("Line 1", 72.0, 720.0, 12.0),
443 make_block("Line 2", 72.0, 706.0, 12.0), // gap = 14, < 12*1.5=18
444 ];
445 let elements = analyze_page(&blocks);
446 assert_eq!(elements.len(), 1);
447 }
448
449 #[test]
450 fn two_distant_lines_different_paragraphs() {
451 let blocks = vec![
452 make_block("Para 1", 72.0, 720.0, 12.0),
453 make_block("Para 2", 72.0, 680.0, 12.0), // gap = 40, > 18
454 ];
455 let elements = analyze_page(&blocks);
456 assert_eq!(elements.len(), 2);
457 }
458
459 #[test]
460 fn table_detection() {
461 let blocks = vec![
462 // Row 1
463 make_block("Name", 72.0, 700.0, 12.0),
464 make_block("Age", 200.0, 700.0, 12.0),
465 // Row 2
466 make_block("Alice", 72.0, 684.0, 12.0),
467 make_block("30", 200.0, 684.0, 12.0),
468 // Row 3
469 make_block("Bob", 72.0, 668.0, 12.0),
470 make_block("25", 200.0, 668.0, 12.0),
471 ];
472 let elements = analyze_page(&blocks);
473 assert_eq!(elements.len(), 1);
474 assert!(matches!(elements[0], PageElement::Tbl(_)));
475 if let PageElement::Tbl(ref tbl) = elements[0] {
476 assert_eq!(tbl.rows.len(), 3);
477 assert_eq!(tbl.col_count, 2);
478 }
479 }
480
481 #[test]
482 fn empty_blocks_returns_empty() {
483 let elements = analyze_page(&[]);
484 assert!(elements.is_empty());
485 }
486
487 // ---- column detection (#161) ----
488
489 #[test]
490 fn a_sentence_does_not_become_a_table_with_a_column_per_word() {
491 // The symptom from #161, reduced to its core: ordinary lines of text in
492 // which every word has its own x. Previously every distinct x became a
493 // column, so you got roughly one word per cell -- a valid .docx nobody
494 // can use.
495 let mut blocks = Vec::new();
496 for (row, y) in [(0, 700.0), (1, 684.0), (2, 668.0)] {
497 // Words at ever-different x-positions, as in flowing text.
498 for w in 0..8 {
499 let x = 72.0 + (w as f64) * 37.0 + (row as f64) * 11.0;
500 blocks.push(make_block("word", x, y, 12.0));
501 }
502 }
503 let elements = analyze_page(&blocks);
504 for el in &elements {
505 if let PageElement::Tbl(t) = el {
506 assert!(
507 t.col_count <= 4,
508 "flowing text became a table of {} columns; that is the \
509 symptom from #161",
510 t.col_count
511 );
512 }
513 }
514 }
515
516 #[test]
517 fn a_real_table_keeps_its_columns() {
518 // The counter-test. Were the new requirement too strict, tables that do
519 // exist would disappear -- a worse failure than too many columns, since
520 // then the structure is gone entirely.
521 let mut blocks = Vec::new();
522 for y in [700.0_f64, 684.0, 668.0, 652.0] {
523 blocks.push(make_block("left", 72.0, y, 12.0));
524 blocks.push(make_block("middle", 200.0, y, 12.0));
525 blocks.push(make_block("right", 340.0, y, 12.0));
526 }
527 let elements = analyze_page(&blocks);
528 let table = elements.iter().find_map(|e| match e {
529 PageElement::Tbl(t) => Some(t),
530 _ => None,
531 });
532 let t = table.expect("an aligned three-column table was not detected");
533 assert_eq!(t.col_count, 3, "columns: {:?}", t.rows.first());
534 assert_eq!(t.rows.len(), 4);
535 }
536
537 #[test]
538 fn text_without_a_column_of_its_own_does_not_land_in_the_first_cell() {
539 // Previously everything that did not start exactly on a column ended up
540 // in column 0 (`unwrap_or(0)`), so loose text from the whole line landed
541 // in the first cell. Text belongs to the column it sits under.
542 let mut blocks = Vec::new();
543 for y in [700.0_f64, 684.0, 668.0] {
544 blocks.push(make_block("A", 72.0, y, 12.0));
545 blocks.push(make_block("B", 300.0, y, 12.0));
546 }
547 // One block that forms no column anywhere, but does sit on the right.
548 blocks.push(make_block("loose", 310.0, 652.0, 12.0));
549 blocks.push(make_block("A", 72.0, 652.0, 12.0));
550
551 let elements = analyze_page(&blocks);
552 if let Some(PageElement::Tbl(t)) =
553 elements.iter().find(|e| matches!(e, PageElement::Tbl(_)))
554 {
555 let last = t.rows.last().expect("no rows");
556 assert!(
557 !last[0].contains("loose"),
558 "unaligned text was dumped into the first cell: {last:?}"
559 );
560 }
561 }
562}