Skip to main content

fleischwolf_pdf/
pdfium_backend.rs

1//! pdfium-based text extraction and page rendering.
2//!
3//! Text is reconstructed the way docling's `docling-parse` does it, so the
4//! output spacing matches the groundtruth: the page's **character** stream is
5//! grouped into **words** (split at a horizontal gap wider than a fraction of
6//! the font height — font-relative, so letter-tracking in display titles does
7//! not split a word) and words into **lines** (by baseline). pdfium-render's
8//! safe API only exposes whole style runs / `GetBoundedText`, so the character
9//! loop is driven through the raw `PdfiumLibraryBindings` FFI on a second handle
10//! to the same bytes (no fork; stays publishable).
11
12use image::RgbImage;
13use pdfium_render::prelude::*;
14
15/// A run of text with its bounding box, in PDF points with a **top-left** origin
16/// (pdfium's native origin is bottom-left; we flip it to match docling's
17/// `BoundingBox(..., origin=TOPLEFT)`).
18#[derive(Debug, Clone)]
19pub struct TextCell {
20    pub text: String,
21    pub l: f32,
22    pub t: f32,
23    pub r: f32,
24    pub b: f32,
25}
26
27/// Pixels-per-point used to render page images. Layout is scale-invariant (it
28/// scales normalized boxes by the page point size), but OCR benefits from the
29/// extra resolution.
30pub const RENDER_SCALE: f32 = 2.0;
31
32/// One page's geometry, extracted text cells, and a rendered RGB image. The
33/// image is rendered at [`RENDER_SCALE`] pixels per PDF point; `image px =
34/// page point × scale`.
35#[derive(Clone)]
36pub struct PdfPage {
37    pub width: f32,
38    pub height: f32,
39    pub scale: f32,
40    pub cells: Vec<TextCell>,
41    /// Same text grouped for code regions: split only at pdfium space glyphs, so
42    /// monospace runs keep their source spacing instead of the prose heuristic's.
43    pub code_cells: Vec<TextCell>,
44    /// Per-word cells (one per word, not joined into lines) for TableFormer cell
45    /// matching.
46    pub word_cells: Vec<TextCell>,
47    pub image: RgbImage,
48    /// Hyperlink annotations on the page (rect in top-left page coords + target
49    /// URI), restricted to web/mail/tel schemes. Used only by strict Markdown.
50    pub links: Vec<LinkAnnot>,
51}
52
53/// A PDF link annotation: its rectangle (top-left page coordinates, matching
54/// [`TextCell`]) and target URI.
55#[derive(Debug, Clone)]
56pub struct LinkAnnot {
57    pub l: f32,
58    pub t: f32,
59    pub r: f32,
60    pub b: f32,
61    pub uri: String,
62}
63
64/// A parsed PDF: per-page text cells and page images.
65pub struct PdfDocument {
66    pub pages: Vec<PdfPage>,
67}
68
69/// Bind to the pdfium dynamic library. Honors `PDFIUM_DYNAMIC_LIB_PATH` (a
70/// directory or file), else the directory of the current exe, else the system
71/// library — mirroring how a deployment ships `libpdfium` alongside the binary.
72/// Whether to use the docling-parse line sanitizer ([`crate::dp_lines`]) for prose
73/// reconstruction — the default. Set `DOCLING_LEGACY_LINES` to fall back to the
74/// older gap-heuristic `lines_from_glyphs`.
75pub(crate) fn use_dp_lines() -> bool {
76    std::env::var("DOCLING_LEGACY_LINES").is_err()
77}
78
79fn bind() -> Result<Pdfium, PdfiumError> {
80    if let Ok(path) = std::env::var("PDFIUM_DYNAMIC_LIB_PATH") {
81        let name = Pdfium::pdfium_platform_library_name_at_path(&path);
82        if let Ok(b) = Pdfium::bind_to_library(&name) {
83            return Ok(Pdfium::new(b));
84        }
85        if let Ok(b) = Pdfium::bind_to_library(&path) {
86            return Ok(Pdfium::new(b));
87        }
88    }
89    Pdfium::bind_to_system_library().map(Pdfium::new)
90}
91
92impl PdfDocument {
93    /// Parse a PDF from bytes, optionally decrypting with `password`.
94    ///
95    /// Note: this materialises **every** page's rendered bitmap in memory at
96    /// once. For large documents prefer [`for_each_page`], which streams.
97    pub fn open(bytes: &[u8], password: Option<&str>) -> Result<Self, PdfiumError> {
98        let pdfium = bind()?;
99        let ffi = FfiText::load(pdfium.bindings(), bytes, password);
100        let doc = pdfium.load_pdf_from_byte_slice(bytes, password)?;
101        let mut rust = rust_parser_cells(bytes);
102        let mut pages = Vec::new();
103        for (i, page) in doc.pages().iter().enumerate() {
104            let rc = rust.as_mut().and_then(|v| v.get_mut(i).map(std::mem::take));
105            pages.push(extract_page(&page, &ffi, i as i32, rc)?);
106        }
107        Ok(PdfDocument { pages })
108    }
109}
110
111/// Per-page prose line cells from the pure-Rust text parser. This is the
112/// **default** text layer (it matches docling-parse's char geometry and is a
113/// strict improvement on byte-conformance — e.g. it recovers the Arabic
114/// sentence-period attachment in `right_to_left_01`). Set `DOCLING_PDFIUM_TEXT`
115/// to fall back to pdfium's text layer. The parser returns an empty page when a
116/// PDF (or a page) has no parseable text layer; the caller keeps pdfium's cells
117/// in that case, so scanned/edge-case pages are unaffected.
118fn rust_parser_cells(bytes: &[u8]) -> Option<Vec<Vec<TextCell>>> {
119    if std::env::var("DOCLING_PDFIUM_TEXT").is_ok() {
120        return None;
121    }
122    Some(crate::timing::timed("textparse", || {
123        crate::textparse::pdf_textlines(bytes)
124            .into_iter()
125            .map(|(_, _, cells)| cells)
126            .collect()
127    }))
128}
129
130/// Number of pages in a PDF, without rendering any of them — used to decide
131/// whether a document is worth spinning up the parallel worker pool.
132pub fn page_count(bytes: &[u8], password: Option<&str>) -> Result<usize, PdfiumError> {
133    let pdfium = bind()?;
134    let doc = pdfium.load_pdf_from_byte_slice(bytes, password)?;
135    Ok(doc.pages().len() as usize)
136}
137
138/// Render + extract pages one at a time, handing each (owned) [`PdfPage`] to `f`.
139/// Only one page bitmap is resident at a time — a rendered page is ~5 MB, so a
140/// large PDF would otherwise hold gigabytes of bitmaps at once. `f` receives the
141/// zero-based page index and the total page count.
142///
143/// `E` is the caller's error type; pdfium errors convert into it via `From`.
144pub fn for_each_page<E, F>(bytes: &[u8], password: Option<&str>, mut f: F) -> Result<(), E>
145where
146    E: From<PdfiumError>,
147    F: FnMut(usize, usize, PdfPage) -> Result<(), E>,
148{
149    let pdfium = bind()?;
150    let ffi = FfiText::load(pdfium.bindings(), bytes, password);
151    let doc = pdfium.load_pdf_from_byte_slice(bytes, password)?;
152    let mut rust = rust_parser_cells(bytes);
153    let pages = doc.pages();
154    let total = pages.len() as usize;
155    for (i, page) in pages.iter().enumerate() {
156        let rc = rust.as_mut().and_then(|v| v.get_mut(i).map(std::mem::take));
157        let extracted = extract_page(&page, &ffi, i as i32, rc)?;
158        f(i, total, extracted)?;
159    }
160    Ok(())
161}
162
163fn extract_page(
164    page: &pdfium_render::prelude::PdfPage<'_>,
165    ffi: &FfiText<'_>,
166    index: i32,
167    rust_cells: Option<Vec<TextCell>>,
168) -> Result<PdfPage, PdfiumError> {
169    let width = page.width().value;
170    let height = page.height().value;
171
172    let (mut cells, code_cells, word_cells) =
173        crate::timing::timed("ffi.page_cells", || ffi.page_cells(index, height));
174    if cells.is_empty() {
175        cells = segment_cells(&page.text()?, height);
176    }
177    // Default: use the pure-Rust text parser's prose line cells instead of
178    // pdfium's (override with `DOCLING_PDFIUM_TEXT`). Word/code cells stay on
179    // pdfium so TableFormer cell-matching is unaffected.
180    if let Some(rc) = rust_cells {
181        if !rc.is_empty() {
182            cells = rc;
183        }
184    }
185
186    // docling renders at 1.5× the target scale and downsamples "to make it
187    // sharper" (pypdfium2 → PIL BICUBIC). Replicate exactly: the TableFormer
188    // model is pixel-sensitive, so the page bitmap must match byte-for-byte.
189    // `CatmullRom` is the same a=-0.5 cubic kernel as PIL's BICUBIC.
190    const SUPERSAMPLE: f32 = 1.5;
191    let tw = (width * RENDER_SCALE * SUPERSAMPLE).round().max(1.0) as i32;
192    let th = (height * RENDER_SCALE * SUPERSAMPLE).round().max(1.0) as i32;
193    let cfg = PdfRenderConfig::new()
194        .set_target_width(tw)
195        .set_target_height(th);
196    let big = crate::timing::timed("pdfium.render", || {
197        page.render_with_config(&cfg)
198            .map(|b| b.as_image().into_rgb8())
199    })?;
200    let dw = (width * RENDER_SCALE).round().max(1.0) as u32;
201    let dh = (height * RENDER_SCALE).round().max(1.0) as u32;
202    let image = crate::timing::timed("image.resize", || {
203        image::imageops::resize(&big, dw, dh, image::imageops::FilterType::CatmullRom)
204    });
205
206    Ok(PdfPage {
207        width,
208        height,
209        scale: RENDER_SCALE,
210        cells,
211        code_cells,
212        word_cells,
213        image,
214        links: extract_links(page, height),
215    })
216}
217
218/// Collect web/mail/tel hyperlink annotations on a page, mapping each link's
219/// rectangle into top-left page coordinates (like [`TextCell`]). `file://` and
220/// in-document destinations are skipped — only externally meaningful targets are
221/// rendered. pdfium occasionally lists a link twice; rects are kept as-is and the
222/// caller dedupes by resolved anchor text.
223fn extract_links(page: &pdfium_render::prelude::PdfPage<'_>, page_h: f32) -> Vec<LinkAnnot> {
224    let mut out = Vec::new();
225    for link in page.links().iter() {
226        let Some(uri) = link
227            .action()
228            .and_then(|a| a.as_uri_action().and_then(|u| u.uri().ok()))
229        else {
230            continue;
231        };
232        let scheme_ok = ["http://", "https://", "mailto:", "tel:"]
233            .iter()
234            .any(|s| uri.starts_with(s));
235        if !scheme_ok {
236            continue;
237        }
238        if let Ok(rect) = link.rect() {
239            out.push(LinkAnnot {
240                l: rect.left().value,
241                t: page_h - rect.top().value,
242                r: rect.right().value,
243                b: page_h - rect.bottom().value,
244                uri,
245            });
246        }
247    }
248    out
249}
250
251/// Fallback line cells from pdfium-render's style segments (one cell per
252/// segment). Used only when the raw-FFI text page can't be loaded.
253fn segment_cells(text: &PdfPageText, page_h: f32) -> Vec<TextCell> {
254    text.segments()
255        .iter()
256        .filter_map(|seg| {
257            let s = seg.text();
258            if s.trim().is_empty() {
259                return None;
260            }
261            let r = seg.bounds();
262            Some(TextCell {
263                text: s,
264                l: r.left().value,
265                t: page_h - r.top().value,
266                r: r.right().value,
267                b: page_h - r.bottom().value,
268            })
269        })
270        .collect()
271}
272
273/// A second, raw-FFI handle on the same PDF used to drive the character loop
274/// (`FPDFText_GetUnicode`/`GetCharBox`) that pdfium-render's safe API doesn't
275/// expose. Closes the document on drop.
276struct FfiText<'a> {
277    bindings: &'a dyn PdfiumLibraryBindings,
278    doc: FPDF_DOCUMENT,
279}
280
281/// One glyph: codepoint + native (y-up) box edges. `l/b/r/t` is pdfium's *tight*
282/// ink box (used by the legacy `lines_from_glyphs`); `ll/lb/lr/lt` is the *loose*
283/// box (font ascent/descent + advance — uniform per font/size), which the
284/// docling-parse-style sanitizer needs so adjacent glyphs share a top edge.
285pub(crate) struct Glyph {
286    pub(crate) ch: char,
287    pub(crate) l: f32,
288    pub(crate) b: f32,
289    pub(crate) r: f32,
290    pub(crate) t: f32,
291    pub(crate) ll: f32,
292    pub(crate) lb: f32,
293    pub(crate) lr: f32,
294    pub(crate) lt: f32,
295    /// Hash of the PDF font name + flags (0 when not fetched). The sanitizer uses
296    /// it for docling-parse's `enforce_same_font` (keeps a bold label and regular
297    /// value as separate line cells, e.g. `LABEL : value`).
298    pub(crate) font: u64,
299}
300
301impl<'a> FfiText<'a> {
302    fn load(bindings: &'a dyn PdfiumLibraryBindings, bytes: &[u8], password: Option<&str>) -> Self {
303        let doc = bindings.FPDF_LoadMemDocument(bytes, password);
304        FfiText { bindings, doc }
305    }
306
307    /// Reconstruct line cells for page `index` (zero-based) via the
308    /// chars→words→lines grouping. Returns `(prose_cells, code_cells)` — the same
309    /// glyphs grouped two ways (gap-heuristic for prose, space-glyph-only for
310    /// code). Both empty on any failure (caller falls back).
311    fn page_cells(&self, index: i32, page_h: f32) -> (Vec<TextCell>, Vec<TextCell>, Vec<TextCell>) {
312        let empty = || (Vec::new(), Vec::new(), Vec::new());
313        if self.doc.is_null() {
314            return empty();
315        }
316        let b = self.bindings;
317        let page = b.FPDF_LoadPage(self.doc, index);
318        if page.is_null() {
319            return empty();
320        }
321        let tp = b.FPDFText_LoadPage(page);
322        let out = if tp.is_null() {
323            empty()
324        } else {
325            let dp = use_dp_lines();
326            let g = glyphs(b, tp, dp);
327            b.FPDFText_ClosePage(tp);
328            // Prose line cells: the docling-parse-style sanitizer (behind a flag
329            // while it's validated) or the legacy gap-heuristic reconstruction.
330            let prose = if dp {
331                crate::dp_lines::line_cells(&g, page_h, false)
332            } else {
333                lines_from_glyphs(&g, page_h, false)
334            };
335            (
336                prose,
337                lines_from_glyphs(&g, page_h, true),
338                words_from_glyphs(&g, page_h),
339            )
340        };
341        b.FPDF_ClosePage(page);
342        out
343    }
344}
345
346impl Drop for FfiText<'_> {
347    fn drop(&mut self) {
348        if !self.doc.is_null() {
349            self.bindings.FPDF_CloseDocument(self.doc);
350        }
351    }
352}
353
354/// Read every glyph (codepoint + native box) from the text page, in document
355/// order. A space glyph is kept as a word-boundary marker (NaN box, char `' '`);
356/// pdfium emits these on most lines and they pin word splits exactly. Hard line
357/// breaks are dropped (line structure comes from geometry); the gap heuristic in
358/// [`lines_from_glyphs`] is the fallback for the lines pdfium leaves space-less.
359/// Debug helper: the raw pdfium glyph stream (codepoint + native bottom-left
360/// box) for a page, in pdfium's character order. For comparing against
361/// docling-parse's char cells.
362pub fn debug_glyphs(bytes: &[u8], index: i32) -> Vec<(char, f32, f32)> {
363    let Ok(pdfium) = bind() else {
364        return Vec::new();
365    };
366    let ffi = FfiText::load(pdfium.bindings(), bytes, None);
367    if ffi.doc.is_null() {
368        return Vec::new();
369    }
370    let b = ffi.bindings;
371    let page = b.FPDF_LoadPage(ffi.doc, index);
372    if page.is_null() {
373        return Vec::new();
374    }
375    let tp = b.FPDFText_LoadPage(page);
376    let mut out = Vec::new();
377    if !tp.is_null() {
378        for g in glyphs(b, tp, true) {
379            out.push((g.ch, g.ll, g.lr));
380        }
381        b.FPDFText_ClosePage(tp);
382    }
383    b.FPDF_ClosePage(page);
384    out
385}
386
387/// Hash a glyph's PDF font name + flags, for `enforce_same_font`. 0 if unavailable.
388fn font_hash(b: &dyn PdfiumLibraryBindings, tp: FPDF_TEXTPAGE, i: i32) -> u64 {
389    use std::hash::{Hash, Hasher};
390    let mut flags: std::os::raw::c_int = 0;
391    let len = b.FPDFText_GetFontInfo(tp, i, std::ptr::null_mut(), 0, &mut flags);
392    if len == 0 {
393        return 0;
394    }
395    let mut buf = vec![0u8; len as usize];
396    b.FPDFText_GetFontInfo(
397        tp,
398        i,
399        buf.as_mut_ptr() as *mut std::os::raw::c_void,
400        len,
401        &mut flags,
402    );
403    let mut h = std::collections::hash_map::DefaultHasher::new();
404    buf.hash(&mut h);
405    flags.hash(&mut h);
406    h.finish()
407}
408
409fn glyphs(b: &dyn PdfiumLibraryBindings, tp: FPDF_TEXTPAGE, fetch_font: bool) -> Vec<Glyph> {
410    let n = b.FPDFText_CountChars(tp);
411    let mut out = Vec::with_capacity(n.max(0) as usize);
412    for i in 0..n {
413        let ch = match char::from_u32(b.FPDFText_GetUnicode(tp, i)) {
414            Some(c) => c,
415            None => continue,
416        };
417        if ch == '\r' || ch == '\n' {
418            continue;
419        }
420        // Spaces are font-neutral (0): pdfium's generated spaces carry a default
421        // font that would otherwise block every word↔space merge under
422        // enforce_same_font; docling-parse's spaces inherit the run's font.
423        let font = if fetch_font && !ch.is_whitespace() {
424            font_hash(b, tp, i)
425        } else {
426            0
427        };
428        let (mut l, mut r, mut bot, mut top) = (0f64, 0f64, 0f64, 0f64);
429        let has_box = b.FPDFText_GetCharBox(tp, i, &mut l, &mut r, &mut bot, &mut top) != 0;
430        // Loose box: font ascent/descent + glyph advance, uniform per font/size.
431        let mut lr = FS_RECTF {
432            left: 0.0,
433            top: 0.0,
434            right: 0.0,
435            bottom: 0.0,
436        };
437        let (ll, lb, lrt, ltop) = if b.FPDFText_GetLooseCharBox(tp, i, &mut lr) != 0 {
438            (lr.left, lr.bottom, lr.right, lr.top)
439        } else if has_box {
440            (l as f32, bot as f32, r as f32, top as f32)
441        } else {
442            (f32::NAN, 0.0, 0.0, 0.0)
443        };
444        if ch.is_whitespace() {
445            // Keep the space *with its box* (the docling-parse-style line sanitizer
446            // needs literal space glyphs); NaN `l` if pdfium reports no box (the
447            // legacy `lines_from_glyphs` ignores the box and only flags a space).
448            out.push(Glyph {
449                ch: ' ',
450                l: if has_box { l as f32 } else { f32::NAN },
451                b: if has_box { bot as f32 } else { 0.0 },
452                r: if has_box { r as f32 } else { 0.0 },
453                t: if has_box { top as f32 } else { 0.0 },
454                ll,
455                lb,
456                lr: lrt,
457                lt: ltop,
458                font,
459            });
460            continue;
461        }
462        if !has_box {
463            continue;
464        }
465        out.push(Glyph {
466            ch,
467            l: l as f32,
468            b: bot as f32,
469            r: r as f32,
470            t: top as f32,
471            ll,
472            lb,
473            lr: lrt,
474            lt: ltop,
475            font,
476        });
477    }
478    // pdfium splits the Arabic lam-alef ligature into two chars at the *same* x
479    // (it's one glyph) in visual order — `alef-variant, lam`. docling-parse and
480    // logical order are `lam, alef-variant`. Detect the ligature by the shared x
481    // and swap. The shared-x test reliably distinguishes a true ligature from a
482    // genuine `alef + lam` sequence (the article `ال`, or `فعالة`), whose two
483    // glyphs sit at different x and must NOT be reordered.
484    for i in 0..out.len().saturating_sub(1) {
485        let same_x = out[i].l.is_finite()
486            && out[i + 1].l.is_finite()
487            && (out[i].l - out[i + 1].l).abs() < 1.0;
488        if same_x
489            && matches!(out[i].ch, '\u{0622}' | '\u{0623}' | '\u{0625}' | '\u{0627}')
490            && out[i + 1].ch == '\u{0644}'
491        {
492            out.swap(i, i + 1);
493        }
494    }
495    // Reconstruct degenerate (zero-width) loose space boxes by spanning the gap to
496    // the next glyph on the same line, so the sanitizer keeps them as word
497    // separators rather than dropping them (which would merge `Information systems`
498    // → `Informationsystems`). pdfium gives generated spaces a zero-width box at a
499    // wrong baseline; a wrap (different baseline) or a touching gap is left alone.
500    for i in 0..out.len() {
501        if out[i].ch != ' ' || (out[i].lr - out[i].ll).abs() >= 0.5 {
502            continue;
503        }
504        let prev = out[..i]
505            .iter()
506            .rev()
507            .find(|g| g.ch != ' ' && g.ll.is_finite())
508            .map(|g| (g.lr, g.lb, g.lt));
509        let next = out[i + 1..]
510            .iter()
511            .find(|g| g.ch != ' ' && g.ll.is_finite())
512            .map(|g| (g.ll, g.lb));
513        if let (Some((plr, plb, plt)), Some((nll, nlb))) = (prev, next) {
514            let line_h = (plt - plb).abs().max(1.0);
515            if (plb - nlb).abs() < line_h * 0.5 && nll > plr + 0.5 {
516                out[i].ll = plr;
517                out[i].lr = nll;
518                out[i].lb = plb;
519                out[i].lt = plt;
520            }
521        }
522    }
523    out
524}
525
526/// Group glyphs (document order) into words then lines, the way docling-parse
527/// does: a new **word** starts where the horizontal gap to the previous glyph
528/// exceeds ~0.2 × the font height (a real space is ~0.3 × height; letter
529/// tracking is smaller, so titles don't shatter); a new **line** starts where
530/// the baseline drops by ~half the font height (a superscript rises without
531/// dropping, so it stays on its line). Coordinates are flipped to top-left.
532/// `code` mode splits words **only** at pdfium's own space glyphs and never glues
533/// punctuation — monospace code has wide inter-glyph advances that the prose
534/// gap heuristic mistakes for spaces (`f un c t i o n`), but pdfium emits a real
535/// space glyph at every true gap, so honoring just those reproduces the source
536/// spacing (`function add(a, b)`).
537fn lines_from_glyphs(gs: &[Glyph], page_h: f32, code: bool) -> Vec<TextCell> {
538    let mut cells: Vec<TextCell> = Vec::new();
539    let mut words: Vec<String> = Vec::new(); // words on the current line
540    let mut word = String::new();
541    // current line bounding box, native
542    let (mut ll, mut lb, mut lr, mut lt) = (
543        f32::INFINITY,
544        f32::INFINITY,
545        f32::NEG_INFINITY,
546        f32::NEG_INFINITY,
547    );
548    // Tallest glyph seen on the current line: the word-gap threshold is relative
549    // to it, so a small-font run on the line (a superscript citation) isn't split
550    // at its tight digit gaps, while a big display title isn't split at its wider
551    // letter tracking. A real inter-word space is ~0.3× the font height.
552    let mut line_h: f32 = 0.0;
553    let mut prev: Option<&Glyph> = None;
554    // A space glyph between non-space glyphs pins a word split the gap heuristic
555    // can miss (tight justified spacing); it carries no geometry.
556    let mut pending_space = false;
557
558    for g in gs {
559        if g.ch == ' ' {
560            pending_space = true;
561            continue;
562        }
563        let h = (g.t - g.b).abs().max(1.0);
564        let (mut new_word, mut new_line) = (false, false);
565        if let Some(p) = prev {
566            // A new line drops the baseline *and* resets x leftward; requiring the
567            // x-reset avoids a descending comma/semicolon faking a line break. A
568            // *large* drop (≥1.5× the line height — a skipped line, e.g. a centered
569            // page-number footer below a short last word) is always a new line,
570            // even without the x-reset.
571            // LTR wraps reset x leftward (`g.l < p.r`); RTL (Arabic) wraps reset
572            // rightward (the new line begins at the far right). A large drop
573            // (≥1.5× line height) is a new line regardless of x.
574            let x_reset = if is_arabic(g.ch) || is_arabic(p.ch) {
575                g.l > p.r
576            } else {
577                g.l < p.r
578            };
579            new_line = (p.b - g.b > h * 0.5 && x_reset) || (p.b - g.b > line_h.max(h) * 1.5);
580            // Don't split before closing punctuation, after opening punctuation, or
581            // after a period that runs into a digit/lowercase letter — docling
582            // keeps `engines,` / `[37` / `i.e.` / `98.5` together even across a
583            // space or gap.
584            let glued = is_close_punct(g.ch)
585                || is_open_punct(p.ch)
586                || (p.ch.is_ascii_digit() && g.ch.is_ascii_digit())
587                || (p.ch == '.'
588                    && !pending_space
589                    && (g.ch.is_ascii_digit() || g.ch.is_ascii_lowercase()));
590            let word_gap = line_h.max(h) * 0.25;
591            new_word = if code {
592                new_line || pending_space
593            } else if is_arabic(g.ch) || is_arabic(p.ch) {
594                // RTL runs right-to-left, so the inter-word gap is `p.l - g.r`. A
595                // real word space has a gap; pdfium also emits spurious zero-gap
596                // space glyphs inside words (`التي`), so require the gap rather
597                // than trusting a bare space glyph.
598                new_line || (p.l - g.r > word_gap && !glued)
599            } else {
600                new_line || ((pending_space || g.l - p.r > word_gap) && !glued)
601            };
602        }
603        pending_space = false;
604        if new_line {
605            push_word(&mut word, &mut words);
606            push_line(&mut words, (ll, lb, lr, lt), page_h, &mut cells);
607            (ll, lb, lr, lt) = (
608                f32::INFINITY,
609                f32::INFINITY,
610                f32::NEG_INFINITY,
611                f32::NEG_INFINITY,
612            );
613            line_h = 0.0;
614        } else if new_word {
615            push_word(&mut word, &mut words);
616        }
617        word.push(g.ch);
618        ll = ll.min(g.l);
619        lb = lb.min(g.b);
620        lr = lr.max(g.r);
621        lt = lt.max(g.t);
622        line_h = line_h.max(h);
623        prev = Some(g);
624    }
625    push_word(&mut word, &mut words);
626    push_line(&mut words, (ll, lb, lr, lt), page_h, &mut cells);
627    cells
628}
629
630/// Per-word cells (each word's text + top-left bbox), using the same word/line
631/// splitting as [`lines_from_glyphs`] but emitting one cell per word instead of
632/// joining into lines — the TableFormer matcher places individual words into
633/// grid cells (a table line spans many cells, so line cells can't be matched).
634fn words_from_glyphs(gs: &[Glyph], page_h: f32) -> Vec<TextCell> {
635    let mut cells = Vec::new();
636    let mut word = String::new();
637    let inf = (
638        f32::INFINITY,
639        f32::INFINITY,
640        f32::NEG_INFINITY,
641        f32::NEG_INFINITY,
642    );
643    let (mut wl, mut wb, mut wr, mut wt) = inf;
644    let mut line_h: f32 = 0.0;
645    let mut prev: Option<&Glyph> = None;
646    let mut pending_space = false;
647    for g in gs {
648        if g.ch == ' ' {
649            pending_space = true;
650            continue;
651        }
652        let h = (g.t - g.b).abs().max(1.0);
653        let mut new_line = false;
654        let mut new_word = false;
655        if let Some(p) = prev {
656            // LTR wraps reset x leftward (`g.l < p.r`); RTL (Arabic) wraps reset
657            // rightward (the new line begins at the far right). A large drop
658            // (≥1.5× line height) is a new line regardless of x.
659            let x_reset = if is_arabic(g.ch) || is_arabic(p.ch) {
660                g.l > p.r
661            } else {
662                g.l < p.r
663            };
664            new_line = (p.b - g.b > h * 0.5 && x_reset) || (p.b - g.b > line_h.max(h) * 1.5);
665            // No digit-digit glue here (unlike the prose grouping): table cells in
666            // adjacent columns are numeric and a column gap must still split them
667            // (`0.965` `0.934`, not `0.9650.934`). Intra-number digits have no gap
668            // so they stay together regardless.
669            let glued = is_close_punct(g.ch)
670                || is_open_punct(p.ch)
671                || (p.ch == '.'
672                    && !pending_space
673                    && (g.ch.is_ascii_digit() || g.ch.is_ascii_lowercase()));
674            let word_gap = line_h.max(h) * 0.25;
675            new_word = new_line || ((pending_space || g.l - p.r > word_gap) && !glued);
676        }
677        pending_space = false;
678        if new_word && !word.is_empty() {
679            cells.push(TextCell {
680                text: std::mem::take(&mut word),
681                l: wl,
682                t: page_h - wt,
683                r: wr,
684                b: page_h - wb,
685            });
686            (wl, wb, wr, wt) = inf;
687        }
688        if new_line {
689            line_h = 0.0;
690        }
691        word.push(g.ch);
692        wl = wl.min(g.l);
693        wb = wb.min(g.b);
694        wr = wr.max(g.r);
695        wt = wt.max(g.t);
696        line_h = line_h.max(h);
697        prev = Some(g);
698    }
699    if !word.is_empty() {
700        cells.push(TextCell {
701            text: word,
702            l: wl,
703            t: page_h - wt,
704            r: wr,
705            b: page_h - wb,
706        });
707    }
708    cells
709}
710
711fn is_arabic(c: char) -> bool {
712    ('\u{0600}'..='\u{06FF}').contains(&c)
713}
714
715fn is_close_punct(c: char) -> bool {
716    matches!(
717        c,
718        ',' | '.' | ';' | '!' | '?' | ')' | ']' | '}' | '%' | '\'' | '\u{2019}' | '\u{2018}'
719    )
720}
721
722fn is_open_punct(c: char) -> bool {
723    // `@` glues to what follows (`mAP @0.5`, `bpf@zurich`, `@decorator`).
724    matches!(c, '(' | '[' | '{' | '@')
725}
726
727fn push_word(word: &mut String, words: &mut Vec<String>) {
728    if !word.is_empty() {
729        words.push(std::mem::take(word));
730    }
731}
732
733fn push_line(
734    words: &mut Vec<String>,
735    bbox: (f32, f32, f32, f32),
736    page_h: f32,
737    cells: &mut Vec<TextCell>,
738) {
739    if words.is_empty() {
740        return;
741    }
742    let text = std::mem::take(words).join(" ");
743    let (l, b, r, t) = bbox;
744    cells.push(TextCell {
745        text,
746        l,
747        t: page_h - t,
748        r,
749        b: page_h - b,
750    });
751}