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 crate::rules::Rule;
10use pdf_extract::TextBlock;
11
12/// Tolerance for grouping text blocks into lines (points).
13const LINE_Y_TOLERANCE: f64 = 2.0;
14
15/// Vertical gap threshold for paragraph breaks (fraction of font size).
16const PARAGRAPH_GAP_FACTOR: f64 = 1.5;
17
18/// A change in font size (points) that starts a new paragraph.
19const STYLE_SIZE_STEP: f64 = 1.0;
20
21/// Tolerance for column alignment in table detection (points).
22const TABLE_X_TOLERANCE: f64 = 5.0;
23
24/// A run of text with consistent formatting — the smallest unit emitted to
25/// the resulting DOCX.
26///
27/// A [`Paragraph`] is a sequence of one or more runs. A new run is started
28/// whenever the layout detector observes a change in font, size, weight, or
29/// style on the same line; consecutive characters with the same formatting
30/// stay in a single run.
31#[derive(Debug, Clone)]
32pub struct Run {
33    /// The actual text content of this run, in source order. May contain
34    /// any UTF-8 characters extracted from the PDF page.
35    pub text: String,
36    /// PostScript name of the font as it appears in the PDF (e.g.
37    /// `Helvetica`, `TimesNewRomanPS-BoldMT`). Mapped to a Word font name
38    /// during DOCX writing.
39    pub font_name: String,
40    /// Font size in PDF user-space points. Persisted to DOCX as half-points
41    /// (Word's native unit).
42    pub font_size: f64,
43    /// Whether the run is rendered bold. Detected from font name suffix
44    /// (`-Bold`, `Bd`) or PDF font flags.
45    pub bold: bool,
46    /// Whether the run is rendered italic. Detected from font name suffix
47    /// (`-Italic`, `It`, `Oblique`) or PDF font flags.
48    pub italic: bool,
49}
50
51/// A paragraph composed of one or more [`Run`]s.
52///
53/// Produced by the line-grouping pass: lines whose vertical gap is below
54/// [`PARAGRAPH_GAP_FACTOR`] times the font size are considered part of the
55/// same paragraph; a larger gap ends the paragraph.
56#[derive(Debug, Clone)]
57pub struct Paragraph {
58    /// The runs that make up this paragraph, in reading order. Empty
59    /// paragraphs are valid and represent blank lines.
60    pub runs: Vec<Run>,
61}
62
63/// A table reconstructed from text blocks aligned in columns.
64///
65/// Detected when consecutive lines share the same column x-coordinates
66/// within [`TABLE_X_TOLERANCE`]. Rebuilt into a regular grid where each
67/// row has the same number of cells (`col_count`); short rows are
68/// right-padded with empty strings.
69#[derive(Debug, Clone)]
70pub struct Table {
71    /// Row-major cell content. `rows[r][c]` is the text in column `c` of
72    /// row `r`. All rows have length [`Self::col_count`].
73    pub rows: Vec<Vec<String>>,
74    /// Number of columns in the table — the maximum column count observed
75    /// during column-alignment detection.
76    pub col_count: usize,
77}
78
79/// An image to embed into the resulting DOCX document.
80///
81/// Produced when the layout pass identifies an image XObject on the page
82/// that should be carried over to the Word document. The bytes are kept
83/// verbatim; the DOCX writer wraps them in the appropriate `w:drawing`
84/// element with the given dimensions.
85#[derive(Debug, Clone)]
86pub struct DocxImage {
87    /// Raw image bytes in the format described by [`Self::content_type`]
88    /// (typically PNG or JPEG).
89    pub data: Vec<u8>,
90    /// Image width in pixels. Used to compute the on-page rendered size.
91    pub width: u32,
92    /// Image height in pixels. Used to compute the on-page rendered size.
93    pub height: u32,
94    /// MIME type of [`Self::data`] — e.g. `image/png`, `image/jpeg`. Drives
95    /// the part name and `Override` content-type entry in the DOCX `[Content_Types].xml`.
96    pub content_type: String,
97    /// Stable identifier used to deduplicate images that appear on multiple
98    /// pages and to wire up the relationship reference in the DOCX.
99    pub id: String,
100}
101
102/// One element in the per-page layout: a paragraph, a table, or an image.
103///
104/// Produced by the layout analysis pass. Pages emit a `Vec<PageElement>` in
105/// reading order; the DOCX writer iterates these and produces matching
106/// Word document parts.
107#[derive(Debug, Clone)]
108pub enum PageElement {
109    /// A flowing paragraph of text — see [`Paragraph`].
110    Para(Paragraph),
111    /// A reconstructed table — see [`Table`].
112    Tbl(Table),
113    /// An embedded image — see [`DocxImage`].
114    Img(DocxImage),
115}
116
117/// A line of text (blocks at roughly the same y-coordinate).
118#[derive(Debug)]
119struct Line {
120    y: f64,
121    font_size: f64,
122    blocks: Vec<TextBlock>,
123}
124
125/// Analyze text blocks from a page and group them into paragraphs and tables.
126///
127/// Without ruling lines this can only find borderless tables by column
128/// alignment. [`analyze_page_with_rules`] also uses the page's drawn borders.
129pub fn analyze_page(blocks: &[TextBlock]) -> Vec<PageElement> {
130    analyze_page_with_rules(blocks, &[])
131}
132
133/// Like [`analyze_page`], and additionally reconstructs every ruled grid.
134///
135/// A ruled grid is a set of connected horizontal and vertical strokes that
136/// forms at least two columns and two rows. Its cells are the bands between
137/// neighbouring strokes; the text blocks whose centre lies inside a band belong
138/// to that cell. Text outside every grid stays ordinary paragraphs, so a
139/// paragraph directly above a table is never taken for its first row, and a page
140/// can hold several tables with different column counts. Elements come back in
141/// reading order (top to bottom).
142pub fn analyze_page_with_rules(blocks: &[TextBlock], rules: &[Rule]) -> Vec<PageElement> {
143    if blocks.is_empty() {
144        return Vec::new();
145    }
146
147    let grids = ruled_grids(rules);
148    if grids.is_empty() {
149        let lines = group_into_lines(blocks);
150        if let Some(tbl) = try_detect_table(&lines) {
151            return vec![PageElement::Tbl(tbl)];
152        }
153        return group_into_paragraphs(&lines)
154            .into_iter()
155            .map(|(_, e)| e)
156            .collect();
157    }
158
159    let mut inside: Vec<Vec<TextBlock>> = vec![Vec::new(); grids.len()];
160    let mut outside: Vec<TextBlock> = Vec::new();
161    for block in blocks {
162        let cx = (block.bbox[0] + block.bbox[2]) / 2.0;
163        let cy = (block.bbox[1] + block.bbox[3]) / 2.0;
164        match grids.iter().position(|g| g.contains(cx, cy)) {
165            Some(i) => inside[i].push(block.clone()),
166            None => outside.push(block.clone()),
167        }
168    }
169
170    let mut items: Vec<(f64, PageElement)> = Vec::new();
171    for (grid, cell_blocks) in grids.iter().zip(&inside) {
172        items.push((grid.y1, PageElement::Tbl(grid_table(grid, cell_blocks))));
173    }
174
175    // A table ends a paragraph: lines above it and below it never join.
176    let lines = group_into_lines(&outside);
177    let section = |y: f64| grids.iter().filter(|g| g.y0 > y).count();
178    let mut start = 0;
179    while start < lines.len() {
180        let id = section(lines[start].y);
181        let mut end = start + 1;
182        while end < lines.len() && section(lines[end].y) == id {
183            end += 1;
184        }
185        items.extend(group_into_paragraphs(&lines[start..end]));
186        start = end;
187    }
188
189    items.sort_by(|a, b| b.0.partial_cmp(&a.0).unwrap_or(std::cmp::Ordering::Equal));
190    items.into_iter().map(|(_, e)| e).collect()
191}
192
193/// A table drawn with ruling lines.
194#[derive(Debug)]
195struct Grid {
196    x0: f64,
197    y0: f64,
198    x1: f64,
199    y1: f64,
200    /// Column boundaries, left to right.
201    xs: Vec<f64>,
202    /// Row boundaries, top to bottom.
203    ys: Vec<f64>,
204}
205
206impl Grid {
207    fn contains(&self, x: f64, y: f64) -> bool {
208        x >= self.x0 - 1.0 && x <= self.x1 + 1.0 && y >= self.y0 - 1.0 && y <= self.y1 + 1.0
209    }
210}
211
212/// Strokes closer than this are the same border (also the gap tolerated
213/// between two strokes that meet at a corner).
214const RULE_TOLERANCE: f64 = 2.0;
215
216fn cluster(mut values: Vec<f64>) -> Vec<f64> {
217    values.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
218    let mut out: Vec<(f64, usize)> = Vec::new();
219    for v in values {
220        match out.last_mut() {
221            Some((mean, n)) if (v - *mean).abs() <= RULE_TOLERANCE => {
222                *mean = (*mean * *n as f64 + v) / (*n as f64 + 1.0);
223                *n += 1;
224            }
225            _ => out.push((v, 1)),
226        }
227    }
228    out.into_iter().map(|(m, _)| m).collect()
229}
230
231fn find(parent: &mut [usize], mut i: usize) -> usize {
232    while parent[i] != i {
233        parent[i] = parent[parent[i]];
234        i = parent[i];
235    }
236    i
237}
238
239/// Connected groups of strokes that form at least two columns and two rows.
240fn ruled_grids(rules: &[Rule]) -> Vec<Grid> {
241    let n = rules.len();
242    if n < 4 {
243        return Vec::new();
244    }
245    let mut parent: Vec<usize> = (0..n).collect();
246    for i in 0..n {
247        for j in (i + 1)..n {
248            let (a, b) = (&rules[i], &rules[j]);
249            let touch = a.x0 - RULE_TOLERANCE <= b.x1
250                && b.x0 - RULE_TOLERANCE <= a.x1
251                && a.y0 - RULE_TOLERANCE <= b.y1
252                && b.y0 - RULE_TOLERANCE <= a.y1;
253            if touch {
254                let (ri, rj) = (find(&mut parent, i), find(&mut parent, j));
255                parent[ri] = rj;
256            }
257        }
258    }
259
260    let mut groups: std::collections::BTreeMap<usize, Vec<usize>> = Default::default();
261    for i in 0..n {
262        let root = find(&mut parent, i);
263        groups.entry(root).or_default().push(i);
264    }
265
266    let mut grids = Vec::new();
267    for members in groups.values() {
268        let mut xs = Vec::new();
269        let mut ys = Vec::new();
270        let (mut x0, mut y0, mut x1, mut y1) = (f64::MAX, f64::MAX, f64::MIN, f64::MIN);
271        for &m in members {
272            let r = &rules[m];
273            if r.is_horizontal() {
274                ys.push((r.y0 + r.y1) / 2.0);
275            } else {
276                xs.push((r.x0 + r.x1) / 2.0);
277            }
278            x0 = x0.min(r.x0);
279            y0 = y0.min(r.y0);
280            x1 = x1.max(r.x1);
281            y1 = y1.max(r.y1);
282        }
283        let xs = cluster(xs);
284        let mut ys = cluster(ys);
285        if xs.len() < 3 || ys.len() < 3 {
286            continue;
287        }
288        ys.reverse();
289        grids.push(Grid {
290            x0,
291            y0,
292            x1,
293            y1,
294            xs,
295            ys,
296        });
297    }
298    grids
299}
300
301/// Index of the band `[bounds[i], bounds[i+1])` that holds `v`, clamped.
302fn band(bounds: &[f64], v: f64, descending: bool) -> usize {
303    let cells = bounds.len() - 1;
304    for i in 0..cells {
305        let inside = if descending {
306            v <= bounds[i] && v > bounds[i + 1]
307        } else {
308            v >= bounds[i] && v < bounds[i + 1]
309        };
310        if inside {
311            return i;
312        }
313    }
314    let first = bounds[0];
315    let past_start = if descending { v > first } else { v < first };
316    if past_start {
317        0
318    } else {
319        cells - 1
320    }
321}
322
323fn grid_table(grid: &Grid, blocks: &[TextBlock]) -> Table {
324    let cols = grid.xs.len() - 1;
325    let rows = grid.ys.len() - 1;
326    let mut cells: Vec<Vec<Vec<TextBlock>>> = vec![vec![Vec::new(); cols]; rows];
327    for block in blocks {
328        let cx = (block.bbox[0] + block.bbox[2]) / 2.0;
329        let cy = (block.bbox[1] + block.bbox[3]) / 2.0;
330        let c = band(&grid.xs, cx, false);
331        let r = band(&grid.ys, cy, true);
332        cells[r][c].push(block.clone());
333    }
334    let rows = cells
335        .into_iter()
336        .map(|row| {
337            row.into_iter()
338                .map(|cell| {
339                    group_into_lines(&cell)
340                        .iter()
341                        .map(line_text)
342                        .collect::<Vec<_>>()
343                        .join(" ")
344                })
345                .collect()
346        })
347        .collect();
348    Table {
349        rows,
350        col_count: cols,
351    }
352}
353
354fn line_text(line: &Line) -> String {
355    line.blocks
356        .iter()
357        .map(|b| b.text.as_str())
358        .collect::<Vec<_>>()
359        .join(" ")
360}
361
362/// Group text blocks into lines based on y-coordinate proximity.
363fn group_into_lines(blocks: &[TextBlock]) -> Vec<Line> {
364    let mut sorted: Vec<&TextBlock> = blocks.iter().collect();
365    // Sort by y descending (PDF origin is bottom-left), then x ascending.
366    sorted.sort_by(|a, b| {
367        let y_cmp = b.bbox[1]
368            .partial_cmp(&a.bbox[1])
369            .unwrap_or(std::cmp::Ordering::Equal);
370        if y_cmp == std::cmp::Ordering::Equal {
371            a.bbox[0]
372                .partial_cmp(&b.bbox[0])
373                .unwrap_or(std::cmp::Ordering::Equal)
374        } else {
375            y_cmp
376        }
377    });
378
379    let mut lines: Vec<Line> = Vec::new();
380
381    for block in sorted {
382        let y = block.bbox[1];
383        let matched = lines
384            .iter_mut()
385            .find(|line| (line.y - y).abs() < LINE_Y_TOLERANCE);
386
387        if let Some(line) = matched {
388            line.blocks.push(block.clone());
389        } else {
390            lines.push(Line {
391                y,
392                font_size: block.font_size,
393                blocks: vec![block.clone()],
394            });
395        }
396    }
397
398    // Sort each line's blocks by x-coordinate, then fuse glyph runs into words.
399    for line in &mut lines {
400        line.blocks.sort_by(|a, b| {
401            a.bbox[0]
402                .partial_cmp(&b.bbox[0])
403                .unwrap_or(std::cmp::Ordering::Equal)
404        });
405        line.blocks = merge_glyph_runs(std::mem::take(&mut line.blocks));
406    }
407
408    lines
409}
410
411/// Gap between two single-glyph blocks, as a fraction of the font size, below
412/// which they are one word. Wider gaps are word spaces.
413const GLYPH_JOIN_GAP: f64 = 0.15;
414
415/// Fuse neighbouring single-glyph blocks into words.
416///
417/// Some producers (design-tool exports in particular) position every glyph with
418/// its own text-positioning operator. Each glyph then arrives as a block of its
419/// own, and every glyph start looked like a column start: a heading came out as
420/// one cell per letter (#408). Only runs of single-glyph blocks are fused;
421/// blocks that already hold a word are left as they are.
422fn merge_glyph_runs(blocks: Vec<TextBlock>) -> Vec<TextBlock> {
423    let mut out: Vec<TextBlock> = Vec::with_capacity(blocks.len());
424    let mut prev_was_glyph = false;
425    for block in blocks {
426        let is_glyph = block.text.chars().count() == 1;
427        if let Some(last) = out.last_mut() {
428            let gap = block.bbox[0] - last.bbox[2];
429            if prev_was_glyph && is_glyph && gap < GLYPH_JOIN_GAP * last.font_size.max(1.0) {
430                last.text.push_str(&block.text);
431                last.bbox[2] = last.bbox[2].max(block.bbox[2]);
432                last.bbox[3] = last.bbox[3].max(block.bbox[3]);
433                prev_was_glyph = is_glyph;
434                continue;
435            }
436        }
437        prev_was_glyph = is_glyph;
438        out.push(block);
439    }
440    out
441}
442
443/// A column is an x-position that recurs on several lines.
444///
445/// Not every distinct x. That was the bug: `col_count` was the number of
446/// distinct x-positions across the whole block set, and in a real PDF almost
447/// every word starts at its own x. A line of twenty words therefore produced
448/// twenty columns -- roughly one word per cell, which leaves the file valid and
449/// unusable. See #161.
450///
451/// What distinguishes a column from an accidental word position is recurrence:
452/// text beginning at (nearly) the same x on several lines. An x that appears on
453/// one line only is a word.
454///
455/// Two lines or 30% was still too little, and the end-to-end test said so: a
456/// grid of four rows by two columns came back with six columns. In a real table
457/// a cell holds words, and every word starts at its own x. Those word positions
458/// shift only a few points from row to row, so within [`TABLE_X_TOLERANCE`] two
459/// of them keep landing together -- which already satisfied the old threshold.
460///
461/// What separates a column from a word is therefore not *that* the x recurs but
462/// *how often*: a column start recurs on nearly every line, a word position does
463/// not. Hence 75%. Lower lets word positions through; much higher loses tables
464/// in which a cell is occasionally empty.
465const MIN_COLUMN_LINES: usize = 2;
466/// The gap before a column start, as a fraction of the font size. One word
467/// space is about a third of an em; a column gap is more than an em.
468const MIN_COLUMN_GAP_EM: f64 = 1.0;
469const MIN_COLUMN_SUPPORT_PERCENT: usize = 75;
470
471/// The x-positions that behave as a column, ordered left to right.
472fn column_positions(lines: &[Line]) -> Vec<f64> {
473    // Per candidate x: on how many distinct lines does text start there?
474    let mut candidates: Vec<(f64, usize)> = Vec::new();
475    for line in lines {
476        let mut counted: Vec<usize> = Vec::new();
477        for (i, block) in line.blocks.iter().enumerate() {
478            // A column starts after a gap wider than a word space. Words of
479            // running text that happen to share an x on several lines (equal
480            // word lengths, a monospace face, glyph-per-block exports) sit one
481            // space apart and are not columns; a table's columns are further
482            // apart than that.
483            if i > 0
484                && block.bbox[0] - line.blocks[i - 1].bbox[2] < MIN_COLUMN_GAP_EM * line.font_size
485            {
486                continue;
487            }
488            let x = block.bbox[0];
489            match candidates
490                .iter()
491                .position(|&(px, _)| (px - x).abs() < TABLE_X_TOLERANCE)
492            {
493                Some(i) => {
494                    // One line counts at most once for the same column;
495                    // otherwise a line with five words at the same x invents a
496                    // column that is not there.
497                    if !counted.contains(&i) {
498                        candidates[i].1 += 1;
499                        counted.push(i);
500                    }
501                }
502                None => {
503                    candidates.push((x, 1));
504                    counted.push(candidates.len() - 1);
505                }
506            }
507        }
508    }
509
510    // Rounded up, not down.
511    //
512    // At three lines `3 * 75 / 100` gave two, so a word position that happened
513    // to coincide on two of the three lines still counted as a column. On a
514    // small table every line is evidence, and the requirement must not evaporate
515    // through integer division.
516    let required = (lines.len() * MIN_COLUMN_SUPPORT_PERCENT).div_ceil(100);
517    let threshold = std::cmp::max(MIN_COLUMN_LINES, required);
518    let mut positions: Vec<f64> = candidates
519        .into_iter()
520        .filter(|&(_, support)| support >= threshold)
521        .map(|(x, _)| x)
522        .collect();
523    positions.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
524    positions
525}
526
527/// The column this block belongs to: the nearest column that does not sit to
528/// the right of the block.
529///
530/// Previously everything that did not start exactly on a column fell back to
531/// column 0 (`unwrap_or(0)`), which dumped unaligned text from the whole line
532/// into the first cell. Text belongs to the column it sits under.
533fn column_for(x: f64, positions: &[f64]) -> usize {
534    let mut best = 0;
535    for (i, &px) in positions.iter().enumerate() {
536        if px <= x + TABLE_X_TOLERANCE {
537            best = i;
538        } else {
539            break;
540        }
541    }
542    best
543}
544
545/// Try to detect a table from aligned text lines.
546///
547/// A table is detected when multiple lines share the same column structure
548/// (i.e., text blocks start at similar x-positions across lines).
549fn try_detect_table(lines: &[Line]) -> Option<Table> {
550    if lines.len() < 2 {
551        return None;
552    }
553
554    let x_positions = column_positions(lines);
555    if x_positions.len() < 2 {
556        return None;
557    }
558
559    // Do most lines carry text in more than one column?
560    let multi_col_lines = lines
561        .iter()
562        .filter(|line| {
563            line.blocks
564                .iter()
565                .map(|b| column_for(b.bbox[0], &x_positions))
566                .collect::<std::collections::HashSet<_>>()
567                .len()
568                >= 2
569        })
570        .count();
571
572    // At least 60% of lines need multiple columns for table detection.
573    if multi_col_lines * 100 / lines.len() < 60 {
574        return None;
575    }
576
577    let col_count = x_positions.len();
578    let mut rows = Vec::new();
579
580    for line in lines {
581        let mut row = vec![String::new(); col_count];
582        for block in &line.blocks {
583            let col_idx = column_for(block.bbox[0], &x_positions);
584            if !row[col_idx].is_empty() {
585                row[col_idx].push(' ');
586            }
587            row[col_idx].push_str(&block.text);
588        }
589        rows.push(row);
590    }
591
592    Some(Table { rows, col_count })
593}
594
595/// Group lines into paragraphs based on vertical spacing.
596///
597/// Returns each paragraph with the y of its first line. A paragraph ends at a
598/// large vertical gap and also where the type changes (size or weight): a
599/// heading is never glued to the body text under it. Lines inside a paragraph
600/// are joined with a space.
601fn group_into_paragraphs(lines: &[Line]) -> Vec<(f64, PageElement)> {
602    let mut elements = Vec::new();
603    let mut current_runs: Vec<Run> = Vec::new();
604    let mut current_y = 0.0;
605    let mut prev_y: Option<f64> = None;
606    let mut prev_font_size: f64 = 12.0;
607    let mut prev_bold = false;
608
609    for line in lines {
610        let mut line_text = line_text(line);
611        if line_text.trim().is_empty() {
612            continue;
613        }
614
615        let font_name = line
616            .blocks
617            .first()
618            .map(|b| b.font_name.clone())
619            .unwrap_or_default();
620        let font_size = line.font_size;
621
622        let bold = font_name.contains("Bold")
623            || font_name.contains("bold")
624            || line.blocks.first().is_some_and(|b| b.is_bold);
625        let italic = font_name.contains("Italic")
626            || font_name.contains("italic")
627            || font_name.contains("Oblique")
628            || line.blocks.first().is_some_and(|b| b.is_italic);
629
630        let is_new_paragraph = match prev_y {
631            Some(py) => {
632                (py - line.y).abs() > prev_font_size * PARAGRAPH_GAP_FACTOR
633                    || (font_size - prev_font_size).abs() > STYLE_SIZE_STEP
634                    || bold != prev_bold
635            }
636            None => false,
637        };
638
639        if is_new_paragraph && !current_runs.is_empty() {
640            elements.push((
641                current_y,
642                PageElement::Para(Paragraph {
643                    runs: std::mem::take(&mut current_runs),
644                }),
645            ));
646        }
647        if current_runs.is_empty() {
648            current_y = line.y;
649        } else {
650            line_text.insert(0, ' ');
651        }
652
653        current_runs.push(Run {
654            text: line_text,
655            font_name,
656            font_size,
657            bold,
658            italic,
659        });
660
661        prev_y = Some(line.y);
662        prev_font_size = font_size;
663        prev_bold = bold;
664    }
665
666    if !current_runs.is_empty() {
667        elements.push((
668            current_y,
669            PageElement::Para(Paragraph { runs: current_runs }),
670        ));
671    }
672
673    elements
674}
675
676/// Detect bold/italic from a PDF font name.
677pub fn map_font_name(pdf_font: &str) -> &str {
678    // Strip common prefixes like "ABCDEF+" used in subset fonts.
679    let name = if let Some(pos) = pdf_font.find('+') {
680        &pdf_font[pos + 1..]
681    } else {
682        pdf_font
683    };
684
685    // Map common font families.
686    if name.contains("Times") || name.contains("Serif") {
687        "Times New Roman"
688    } else if name.contains("Arial") || name.contains("Helvetica") || name.contains("Sans") {
689        "Arial"
690    } else if name.contains("Courier") || name.contains("Mono") {
691        "Courier New"
692    } else if name.contains("Symbol") {
693        "Symbol"
694    } else {
695        "Calibri"
696    }
697}
698
699#[cfg(test)]
700mod tests {
701    use super::*;
702
703    fn make_block(text: &str, x: f64, y: f64, font_size: f64) -> TextBlock {
704        TextBlock {
705            text: text.to_string(),
706            page: 1,
707            bbox: [x, y, x + text.len() as f64 * font_size * 0.5, y + font_size],
708            font_name: "F1".to_string(),
709            font_size,
710            actual_text: None,
711            base_font: None,
712            is_bold: false,
713            is_italic: false,
714            color: None,
715            width_source: Default::default(),
716            char_bounds: vec![],
717        }
718    }
719
720    #[test]
721    fn single_line_becomes_paragraph() {
722        let blocks = vec![make_block("Hello World", 72.0, 720.0, 12.0)];
723        let elements = analyze_page(&blocks);
724        assert_eq!(elements.len(), 1);
725        assert!(matches!(elements[0], PageElement::Para(_)));
726    }
727
728    #[test]
729    fn two_close_lines_same_paragraph() {
730        let blocks = vec![
731            make_block("Line 1", 72.0, 720.0, 12.0),
732            make_block("Line 2", 72.0, 706.0, 12.0), // gap = 14, < 12*1.5=18
733        ];
734        let elements = analyze_page(&blocks);
735        assert_eq!(elements.len(), 1);
736    }
737
738    #[test]
739    fn two_distant_lines_different_paragraphs() {
740        let blocks = vec![
741            make_block("Para 1", 72.0, 720.0, 12.0),
742            make_block("Para 2", 72.0, 680.0, 12.0), // gap = 40, > 18
743        ];
744        let elements = analyze_page(&blocks);
745        assert_eq!(elements.len(), 2);
746    }
747
748    #[test]
749    fn table_detection() {
750        let blocks = vec![
751            // Row 1
752            make_block("Name", 72.0, 700.0, 12.0),
753            make_block("Age", 200.0, 700.0, 12.0),
754            // Row 2
755            make_block("Alice", 72.0, 684.0, 12.0),
756            make_block("30", 200.0, 684.0, 12.0),
757            // Row 3
758            make_block("Bob", 72.0, 668.0, 12.0),
759            make_block("25", 200.0, 668.0, 12.0),
760        ];
761        let elements = analyze_page(&blocks);
762        assert_eq!(elements.len(), 1);
763        assert!(matches!(elements[0], PageElement::Tbl(_)));
764        if let PageElement::Tbl(ref tbl) = elements[0] {
765            assert_eq!(tbl.rows.len(), 3);
766            assert_eq!(tbl.col_count, 2);
767        }
768    }
769
770    #[test]
771    fn empty_blocks_returns_empty() {
772        let elements = analyze_page(&[]);
773        assert!(elements.is_empty());
774    }
775
776    // ---- column detection (#161) ----
777
778    #[test]
779    fn a_sentence_does_not_become_a_table_with_a_column_per_word() {
780        // The symptom from #161, reduced to its core: ordinary lines of text in
781        // which every word has its own x. Previously every distinct x became a
782        // column, so you got roughly one word per cell -- a valid .docx nobody
783        // can use.
784        let mut blocks = Vec::new();
785        for (row, y) in [(0, 700.0), (1, 684.0), (2, 668.0)] {
786            // Words at ever-different x-positions, as in flowing text.
787            for w in 0..8 {
788                let x = 72.0 + (w as f64) * 37.0 + (row as f64) * 11.0;
789                blocks.push(make_block("word", x, y, 12.0));
790            }
791        }
792        let elements = analyze_page(&blocks);
793        for el in &elements {
794            if let PageElement::Tbl(t) = el {
795                assert!(
796                    t.col_count <= 4,
797                    "flowing text became a table of {} columns; that is the \
798                     symptom from #161",
799                    t.col_count
800                );
801            }
802        }
803    }
804
805    #[test]
806    fn a_real_table_keeps_its_columns() {
807        // The counter-test. Were the new requirement too strict, tables that do
808        // exist would disappear -- a worse failure than too many columns, since
809        // then the structure is gone entirely.
810        let mut blocks = Vec::new();
811        for y in [700.0_f64, 684.0, 668.0, 652.0] {
812            blocks.push(make_block("left", 72.0, y, 12.0));
813            blocks.push(make_block("middle", 200.0, y, 12.0));
814            blocks.push(make_block("right", 340.0, y, 12.0));
815        }
816        let elements = analyze_page(&blocks);
817        let table = elements.iter().find_map(|e| match e {
818            PageElement::Tbl(t) => Some(t),
819            _ => None,
820        });
821        let t = table.expect("an aligned three-column table was not detected");
822        assert_eq!(t.col_count, 3, "columns: {:?}", t.rows.first());
823        assert_eq!(t.rows.len(), 4);
824    }
825
826    #[test]
827    fn text_without_a_column_of_its_own_does_not_land_in_the_first_cell() {
828        // Previously everything that did not start exactly on a column ended up
829        // in column 0 (`unwrap_or(0)`), so loose text from the whole line landed
830        // in the first cell. Text belongs to the column it sits under.
831        let mut blocks = Vec::new();
832        for y in [700.0_f64, 684.0, 668.0] {
833            blocks.push(make_block("A", 72.0, y, 12.0));
834            blocks.push(make_block("B", 300.0, y, 12.0));
835        }
836        // One block that forms no column anywhere, but does sit on the right.
837        blocks.push(make_block("loose", 310.0, 652.0, 12.0));
838        blocks.push(make_block("A", 72.0, 652.0, 12.0));
839
840        let elements = analyze_page(&blocks);
841        if let Some(PageElement::Tbl(t)) =
842            elements.iter().find(|e| matches!(e, PageElement::Tbl(_)))
843        {
844            let last = t.rows.last().expect("no rows");
845            assert!(
846                !last[0].contains("loose"),
847                "unaligned text was dumped into the first cell: {last:?}"
848            );
849        }
850    }
851}