Skip to main content

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}