Skip to main content

docling_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
12#[cfg(feature = "ml")]
13use image::RgbImage;
14#[cfg(feature = "ml")]
15use pdfium_render::prelude::*;
16
17/// A run of text with its bounding box, in PDF points with a **top-left** origin
18/// (pdfium's native origin is bottom-left; we flip it to match docling's
19/// `BoundingBox(..., origin=TOPLEFT)`).
20#[derive(Debug, Clone)]
21pub struct TextCell {
22    pub text: String,
23    pub l: f32,
24    pub t: f32,
25    pub r: f32,
26    pub b: f32,
27}
28
29/// Pixels-per-point used to render page images. Layout is scale-invariant (it
30/// scales normalized boxes by the page point size), but OCR benefits from the
31/// extra resolution.
32pub const RENDER_SCALE: f32 = 2.0;
33
34/// One page's geometry, extracted text cells, and a rendered RGB image. The
35/// image is rendered at [`RENDER_SCALE`] pixels per PDF point; `image px =
36/// page point × scale`.
37#[derive(Clone)]
38pub struct PdfPage {
39    pub width: f32,
40    pub height: f32,
41    pub scale: f32,
42    pub cells: Vec<TextCell>,
43    /// Same text grouped for code regions: split only at pdfium space glyphs, so
44    /// monospace runs keep their source spacing instead of the prose heuristic's.
45    pub code_cells: Vec<TextCell>,
46    /// Per-word cells (one per word, not joined into lines) for TableFormer cell
47    /// matching.
48    pub word_cells: Vec<TextCell>,
49    #[cfg(feature = "ml")]
50    pub image: RgbImage,
51    /// Hyperlink annotations on the page (rect in top-left page coords + target
52    /// URI), restricted to web/mail/tel schemes. Used only by strict Markdown.
53    pub links: Vec<LinkAnnot>,
54}
55
56/// A PDF link annotation: its rectangle (top-left page coordinates, matching
57/// [`TextCell`]) and target URI.
58#[derive(Debug, Clone)]
59pub struct LinkAnnot {
60    pub l: f32,
61    pub t: f32,
62    pub r: f32,
63    pub b: f32,
64    pub uri: String,
65}
66
67#[cfg(feature = "ml")]
68/// A parsed PDF: per-page text cells and page images.
69pub struct PdfDocument {
70    pub pages: Vec<PdfPage>,
71}
72
73/// Whether to use the docling-parse line sanitizer ([`crate::dp_lines`]) for prose
74/// reconstruction — the default. Set `DOCLING_LEGACY_LINES` to fall back to the
75/// older gap-heuristic `lines_from_glyphs`.
76pub(crate) fn use_dp_lines() -> bool {
77    std::env::var("DOCLING_LEGACY_LINES").is_err()
78}
79
80/// Whether to source **word** cells from the pure-Rust parser (roadmap item 6),
81/// the default. The parser's `word_cells` reproduce docling-parse's word grouping
82/// byte-for-byte — the per-word tokens TableFormer matches table-grid cells
83/// against — which moves table extraction closer to docling on the heavy
84/// multi-column fixtures. Set `DOCLING_PDFIUM_WORDS` to keep pdfium's word cells,
85/// or `DOCLING_PDFIUM_TEXT` to fall back to pdfium for all text.
86pub(crate) fn use_parser_words() -> bool {
87    std::env::var("DOCLING_PDFIUM_WORDS").is_err() && std::env::var("DOCLING_PDFIUM_TEXT").is_err()
88}
89
90/// Whether to source **code** cells from the parser too (the default) — the last
91/// text layer to leave pdfium, fully retiring its text path. The parser's
92/// gap-based code grouping ([`code_cells_from_glyphs`]) reconstructs monospace
93/// spacing from positioning gaps (`function add(a, b) { … }`), so it no longer
94/// drops the inter-token spaces the old space-glyph-only grouping lost
95/// (`functionadd`). Reverts to pdfium with `DOCLING_PDFIUM_WORDS` (alongside word
96/// cells) or `DOCLING_PDFIUM_TEXT` (all text).
97pub(crate) fn use_parser_code() -> bool {
98    std::env::var("DOCLING_PDFIUM_WORDS").is_err() && std::env::var("DOCLING_PDFIUM_TEXT").is_err()
99}
100
101#[cfg(feature = "ml")]
102/// Try binding pdfium from a directory (or a literal library file path):
103/// `<dir>/<platform library name>` first, else `<dir>` itself as the file.
104fn try_bind_dir(path: &str) -> Option<Box<dyn pdfium_render::prelude::PdfiumLibraryBindings>> {
105    let name = Pdfium::pdfium_platform_library_name_at_path(path);
106    if let Ok(b) = Pdfium::bind_to_library(&name) {
107        return Some(b);
108    }
109    Pdfium::bind_to_library(path).ok()
110}
111
112#[cfg(feature = "ml")]
113/// Bind to the pdfium dynamic library. Honors `PDFIUM_DYNAMIC_LIB_PATH` (a
114/// directory or file) first; else falls back to `.pdfium/lib` relative to the
115/// current directory (the layout `scripts/install/download_dependencies.sh` and
116/// `scripts/install/pdf_setup.sh` both produce); else the system library.
117fn bind() -> Result<Pdfium, PdfiumError> {
118    if let Ok(path) = std::env::var("PDFIUM_DYNAMIC_LIB_PATH") {
119        if let Some(b) = try_bind_dir(&path) {
120            return Ok(Pdfium::new(b));
121        }
122    }
123    // No env var (or it didn't resolve): fall back to `.pdfium/lib` relative to
124    // the current directory — mirroring `layout.rs`/`ocr.rs`'s `models/…`
125    // defaults — the layout `scripts/install/download_dependencies.sh` (and
126    // `scripts/install/pdf_setup.sh`) produce, so a checkout with the dependencies
127    // downloaded next to it needs no env var at all.
128    if let Some(b) = try_bind_dir(&crate::resolve_asset(".pdfium/lib")) {
129        return Ok(Pdfium::new(b));
130    }
131    Pdfium::bind_to_system_library().map(Pdfium::new)
132}
133
134#[cfg(feature = "ml")]
135impl PdfDocument {
136    /// Parse a PDF from bytes, optionally decrypting with `password`.
137    ///
138    /// Note: this materialises **every** page's rendered bitmap in memory at
139    /// once. For large documents prefer [`for_each_page`], which streams.
140    pub fn open(bytes: &[u8], password: Option<&str>) -> Result<Self, PdfiumError> {
141        let pdfium = bind()?;
142        let ffi = FfiText::load(pdfium.bindings(), bytes, password);
143        let doc = pdfium.load_pdf_from_byte_slice(bytes, password)?;
144        let mut rust = rust_parser_cells(bytes);
145        let mut pages = Vec::new();
146        for (i, page) in doc.pages().iter().enumerate() {
147            let rc = rust.as_mut().and_then(|v| v.get_mut(i).map(std::mem::take));
148            pages.push(extract_page(&page, &ffi, i as i32, rc, true)?);
149        }
150        Ok(PdfDocument { pages })
151    }
152}
153
154#[cfg(feature = "ml")]
155/// Per-page prose line cells from the pure-Rust text parser. This is the
156/// **default** text layer (it matches docling-parse's char geometry and is a
157/// strict improvement on byte-conformance — e.g. it recovers the Arabic
158/// sentence-period attachment in `right_to_left_01`). Set `DOCLING_PDFIUM_TEXT`
159/// to fall back to pdfium's text layer. The parser returns an empty page when a
160/// PDF (or a page) has no parseable text layer; the caller keeps pdfium's cells
161/// in that case, so scanned/edge-case pages are unaffected.
162fn rust_parser_cells(bytes: &[u8]) -> Option<Vec<crate::textparse::PageParserCells>> {
163    if std::env::var("DOCLING_PDFIUM_TEXT").is_ok() {
164        return None;
165    }
166    Some(crate::timing::timed("textparse", || {
167        crate::textparse::pdf_all_cells(bytes)
168    }))
169}
170
171#[cfg(feature = "ml")]
172/// Number of pages in a PDF, without rendering any of them — used to decide
173/// whether a document is worth spinning up the parallel worker pool.
174pub fn page_count(bytes: &[u8], password: Option<&str>) -> Result<usize, PdfiumError> {
175    let pdfium = bind()?;
176    let doc = pdfium.load_pdf_from_byte_slice(bytes, password)?;
177    Ok(doc.pages().len() as usize)
178}
179
180#[cfg(feature = "ml")]
181/// Render + extract pages one at a time, handing each (owned) [`PdfPage`] to `f`.
182/// Only one page bitmap is resident at a time — a rendered page is ~5 MB, so a
183/// large PDF would otherwise hold gigabytes of bitmaps at once. `f` receives the
184/// zero-based page index and the total page count.
185///
186/// `render_image` controls whether the page bitmap is rasterized at all: layout,
187/// OCR, TableFormer, and picture cropping all need it, but a caller that skips
188/// every one of those (the `no_ocr` fast path) doesn't, and rasterizing +
189/// downsampling a page is by far the most expensive step per page — skipping it
190/// is most of `no_ocr`'s speedup. `PdfPage::image` is a 1×1 placeholder when
191/// `false`; do not read it.
192///
193/// `E` is the caller's error type; pdfium errors convert into it via `From`.
194pub fn for_each_page<E, F>(
195    bytes: &[u8],
196    password: Option<&str>,
197    render_image: bool,
198    mut f: F,
199) -> Result<(), E>
200where
201    E: From<PdfiumError>,
202    F: FnMut(usize, usize, PdfPage) -> Result<(), E>,
203{
204    let pdfium = bind()?;
205    let ffi = FfiText::load(pdfium.bindings(), bytes, password);
206    let doc = pdfium.load_pdf_from_byte_slice(bytes, password)?;
207    let mut rust = rust_parser_cells(bytes);
208    let pages = doc.pages();
209    let total = pages.len() as usize;
210    for (i, page) in pages.iter().enumerate() {
211        let rc = rust.as_mut().and_then(|v| v.get_mut(i).map(std::mem::take));
212        let extracted = extract_page(&page, &ffi, i as i32, rc, render_image)?;
213        f(i, total, extracted)?;
214    }
215    Ok(())
216}
217
218#[cfg(feature = "ml")]
219fn extract_page(
220    page: &pdfium_render::prelude::PdfPage<'_>,
221    ffi: &FfiText<'_>,
222    index: i32,
223    rust_cells: Option<crate::textparse::PageParserCells>,
224    render_image: bool,
225) -> Result<PdfPage, PdfiumError> {
226    let width = page.width().value;
227    let height = page.height().value;
228
229    // Default: use the pure-Rust text parser instead of pdfium's text layer
230    // (override with `DOCLING_PDFIUM_TEXT`). Prose line cells always come from the
231    // parser; word and code cells do too unless `DOCLING_PDFIUM_WORDS` keeps them
232    // on pdfium (the parser's word grouping reproduces docling-parse's, which
233    // TableFormer matches against — roadmap item 6). A page the parser couldn't
234    // read (no text layer) keeps pdfium's cells.
235    let rc = rust_cells.unwrap_or_default();
236    let need_pdfium_prose = rc.prose.is_empty();
237    let need_pdfium_words = !use_parser_words() || rc.words.is_empty();
238    let need_pdfium_code = !use_parser_code() || rc.code.is_empty();
239
240    // The parser covers prose/words/code from one shared glyph pass, so on the
241    // common (parser-succeeded) page all three are already satisfied and this
242    // pdfium FFI call — otherwise fully discarded below — is skipped outright.
243    let (mut cells, mut code_cells, mut word_cells) =
244        if need_pdfium_prose || need_pdfium_words || need_pdfium_code {
245            let (mut cells, code_cells, word_cells) =
246                crate::timing::timed("ffi.page_cells", || ffi.page_cells(index, height));
247            if cells.is_empty() {
248                cells = segment_cells(&page.text()?, height);
249            }
250            (cells, code_cells, word_cells)
251        } else {
252            (Vec::new(), Vec::new(), Vec::new())
253        };
254    if !rc.prose.is_empty() {
255        cells = rc.prose;
256    }
257    if use_parser_words() && !rc.words.is_empty() {
258        word_cells = rc.words;
259    }
260    if use_parser_code() && !rc.code.is_empty() {
261        code_cells = rc.code;
262    }
263
264    let image = if render_image {
265        // docling renders at 1.5× the target scale and downsamples "to make it
266        // sharper" (pypdfium2 → PIL BICUBIC). Replicate exactly: the TableFormer
267        // model is pixel-sensitive, so the page bitmap must match byte-for-byte.
268        // `CatmullRom` is the same a=-0.5 cubic kernel as PIL's BICUBIC.
269        const SUPERSAMPLE: f32 = 1.5;
270        let tw = (width * RENDER_SCALE * SUPERSAMPLE).round().max(1.0) as i32;
271        let th = (height * RENDER_SCALE * SUPERSAMPLE).round().max(1.0) as i32;
272        let cfg = PdfRenderConfig::new()
273            .set_target_width(tw)
274            .set_target_height(th);
275        let big = crate::timing::timed("pdfium.render", || {
276            page.render_with_config(&cfg)
277                .map(|b| b.as_image().into_rgb8())
278        })?;
279        let dw = (width * RENDER_SCALE).round().max(1.0) as u32;
280        let dh = (height * RENDER_SCALE).round().max(1.0) as u32;
281        crate::timing::timed("image.resize", || fast_downscale(&big, dw, dh))
282    } else {
283        RgbImage::new(1, 1)
284    };
285
286    Ok(PdfPage {
287        width,
288        height,
289        scale: RENDER_SCALE,
290        cells,
291        code_cells,
292        word_cells,
293        image,
294        links: extract_links(page, height),
295    })
296}
297
298#[cfg(feature = "ml")]
299/// The supersample→target downscale via `fast_image_resize` (SIMD convolution;
300/// the same a=-0.5 Catmull-Rom kernel as `image::imageops::resize(...,
301/// CatmullRom)` and PIL BICUBIC — see the render comment above). Set
302/// `DOCLING_RS_SLOW_RESIZE=1` to fall back to the `image`-crate scalar resize
303/// (byte-parity with the pre-SIMD pipeline, several times slower).
304fn fast_downscale(big: &RgbImage, dw: u32, dh: u32) -> RgbImage {
305    use fast_image_resize as fir;
306    static SLOW: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
307    let slow = *SLOW.get_or_init(|| {
308        std::env::var("DOCLING_RS_SLOW_RESIZE")
309            .map(|v| v != "0")
310            .unwrap_or(false)
311    });
312    if !slow {
313        if let Some(out) = (|| {
314            let src = fir::images::ImageRef::new(
315                big.width(),
316                big.height(),
317                big.as_raw(),
318                fir::PixelType::U8x3,
319            )
320            .ok()?;
321            let mut dst = fir::images::Image::new(dw, dh, fir::PixelType::U8x3);
322            fir::Resizer::new()
323                .resize(
324                    &src,
325                    &mut dst,
326                    &fir::ResizeOptions::new()
327                        .resize_alg(fir::ResizeAlg::Convolution(fir::FilterType::CatmullRom)),
328                )
329                .ok()?;
330            RgbImage::from_raw(dw, dh, dst.into_vec())
331        })() {
332            return out;
333        }
334        // Unreachable in practice; fall through to the scalar path on any error.
335    }
336    image::imageops::resize(big, dw, dh, image::imageops::FilterType::CatmullRom)
337}
338
339#[cfg(feature = "ml")]
340/// Collect web/mail/tel hyperlink annotations on a page, mapping each link's
341/// rectangle into top-left page coordinates (like [`TextCell`]). `file://` and
342/// in-document destinations are skipped — only externally meaningful targets are
343/// rendered. pdfium occasionally lists a link twice; rects are kept as-is and the
344/// caller dedupes by resolved anchor text.
345fn extract_links(page: &pdfium_render::prelude::PdfPage<'_>, page_h: f32) -> Vec<LinkAnnot> {
346    let mut out = Vec::new();
347    for link in page.links().iter() {
348        let Some(uri) = link
349            .action()
350            .and_then(|a| a.as_uri_action().and_then(|u| u.uri().ok()))
351        else {
352            continue;
353        };
354        let scheme_ok = ["http://", "https://", "mailto:", "tel:"]
355            .iter()
356            .any(|s| uri.starts_with(s));
357        if !scheme_ok {
358            continue;
359        }
360        if let Ok(rect) = link.rect() {
361            out.push(LinkAnnot {
362                l: rect.left().value,
363                t: page_h - rect.top().value,
364                r: rect.right().value,
365                b: page_h - rect.bottom().value,
366                uri,
367            });
368        }
369    }
370    out
371}
372
373#[cfg(feature = "ml")]
374/// Fallback line cells from pdfium-render's style segments (one cell per
375/// segment). Used only when the raw-FFI text page can't be loaded.
376fn segment_cells(text: &PdfPageText, page_h: f32) -> Vec<TextCell> {
377    text.segments()
378        .iter()
379        .filter_map(|seg| {
380            let s = seg.text();
381            if s.trim().is_empty() {
382                return None;
383            }
384            let r = seg.bounds();
385            Some(TextCell {
386                text: s,
387                l: r.left().value,
388                t: page_h - r.top().value,
389                r: r.right().value,
390                b: page_h - r.bottom().value,
391            })
392        })
393        .collect()
394}
395
396#[cfg(feature = "ml")]
397/// A second, raw-FFI handle on the same PDF used to drive the character loop
398/// (`FPDFText_GetUnicode`/`GetCharBox`) that pdfium-render's safe API doesn't
399/// expose. Closes the document on drop.
400struct FfiText<'a> {
401    bindings: &'a dyn PdfiumLibraryBindings,
402    doc: FPDF_DOCUMENT,
403}
404
405/// One glyph: codepoint + native (y-up) box edges. `l/b/r/t` is pdfium's *tight*
406/// ink box (used by the legacy `lines_from_glyphs`); `ll/lb/lr/lt` is the *loose*
407/// box (font ascent/descent + advance — uniform per font/size), which the
408/// docling-parse-style sanitizer needs so adjacent glyphs share a top edge.
409pub(crate) struct Glyph {
410    pub(crate) ch: char,
411    pub(crate) l: f32,
412    pub(crate) b: f32,
413    pub(crate) r: f32,
414    pub(crate) t: f32,
415    pub(crate) ll: f32,
416    pub(crate) lb: f32,
417    pub(crate) lr: f32,
418    pub(crate) lt: f32,
419    /// Hash of the PDF font name + flags (0 when not fetched). The sanitizer uses
420    /// it for docling-parse's `enforce_same_font` (keeps a bold label and regular
421    /// value as separate line cells, e.g. `LABEL : value`).
422    pub(crate) font: u64,
423}
424
425#[cfg(feature = "ml")]
426impl<'a> FfiText<'a> {
427    fn load(bindings: &'a dyn PdfiumLibraryBindings, bytes: &[u8], password: Option<&str>) -> Self {
428        let doc = bindings.FPDF_LoadMemDocument(bytes, password);
429        FfiText { bindings, doc }
430    }
431
432    /// Reconstruct line cells for page `index` (zero-based) via the
433    /// chars→words→lines grouping. Returns `(prose_cells, code_cells)` — the same
434    /// glyphs grouped two ways (gap-heuristic for prose, space-glyph-only for
435    /// code). Both empty on any failure (caller falls back).
436    fn page_cells(&self, index: i32, page_h: f32) -> (Vec<TextCell>, Vec<TextCell>, Vec<TextCell>) {
437        let empty = || (Vec::new(), Vec::new(), Vec::new());
438        if self.doc.is_null() {
439            return empty();
440        }
441        let b = self.bindings;
442        let page = b.FPDF_LoadPage(self.doc, index);
443        if page.is_null() {
444            return empty();
445        }
446        let tp = b.FPDFText_LoadPage(page);
447        let out = if tp.is_null() {
448            empty()
449        } else {
450            let dp = use_dp_lines();
451            let g = glyphs(b, tp, dp);
452            b.FPDFText_ClosePage(tp);
453            // Prose line cells: the docling-parse-style sanitizer (behind a flag
454            // while it's validated) or the legacy gap-heuristic reconstruction.
455            let prose = if dp {
456                crate::dp_lines::line_cells(&g, page_h, false)
457            } else {
458                lines_from_glyphs(&g, page_h, Grouping::Prose)
459            };
460            (
461                prose,
462                lines_from_glyphs(&g, page_h, Grouping::CodeSpaceOnly),
463                words_from_glyphs(&g, page_h),
464            )
465        };
466        b.FPDF_ClosePage(page);
467        out
468    }
469}
470
471#[cfg(feature = "ml")]
472impl Drop for FfiText<'_> {
473    fn drop(&mut self) {
474        if !self.doc.is_null() {
475            self.bindings.FPDF_CloseDocument(self.doc);
476        }
477    }
478}
479
480#[cfg(feature = "ml")]
481/// Read every glyph (codepoint + native box) from the text page, in document
482/// order. A space glyph is kept as a word-boundary marker (NaN box, char `' '`);
483/// pdfium emits these on most lines and they pin word splits exactly. Hard line
484/// breaks are dropped (line structure comes from geometry); the gap heuristic in
485/// [`lines_from_glyphs`] is the fallback for the lines pdfium leaves space-less.
486/// Debug helper: the raw pdfium glyph stream (codepoint + native bottom-left
487/// box) for a page, in pdfium's character order. For comparing against
488/// docling-parse's char cells.
489pub fn debug_glyphs(bytes: &[u8], index: i32) -> Vec<(char, f32, f32)> {
490    let Ok(pdfium) = bind() else {
491        return Vec::new();
492    };
493    let ffi = FfiText::load(pdfium.bindings(), bytes, None);
494    if ffi.doc.is_null() {
495        return Vec::new();
496    }
497    let b = ffi.bindings;
498    let page = b.FPDF_LoadPage(ffi.doc, index);
499    if page.is_null() {
500        return Vec::new();
501    }
502    let tp = b.FPDFText_LoadPage(page);
503    let mut out = Vec::new();
504    if !tp.is_null() {
505        for g in glyphs(b, tp, true) {
506            out.push((g.ch, g.ll, g.lr));
507        }
508        b.FPDFText_ClosePage(tp);
509    }
510    b.FPDF_ClosePage(page);
511    out
512}
513
514#[cfg(feature = "ml")]
515/// One text object on a page, for the hidden-layer diagnostic.
516#[derive(Debug, Clone)]
517pub struct DebugTextObject {
518    /// True when the object is drawn invisibly (text render mode 3) — the marker of
519    /// a hidden duplicate text layer.
520    pub invisible: bool,
521    /// Bounding box in native PDF points (bottom-left origin).
522    pub l: f32,
523    pub b: f32,
524    pub r: f32,
525    pub t: f32,
526    /// The object's text (best-effort; empty if it could not be read).
527    pub text: String,
528}
529
530#[cfg(feature = "ml")]
531/// Diagnostic: every text object on page `index`, each tagged visible/invisible
532/// (via the object-level [`FPDFTextObj_GetTextRenderMode`], which — unlike the
533/// per-character render-mode API — is available on the default pdfium binding).
534/// A hidden duplicate text layer shows up as invisible objects repeating the
535/// visible text. Used by the `dump_render_modes` example.
536///
537/// [`FPDFTextObj_GetTextRenderMode`]: pdfium_render::prelude::PdfiumLibraryBindings::FPDFTextObj_GetTextRenderMode
538pub fn debug_text_objects(bytes: &[u8], index: i32) -> Vec<DebugTextObject> {
539    let Ok(pdfium) = bind() else {
540        return Vec::new();
541    };
542    let ffi = FfiText::load(pdfium.bindings(), bytes, None);
543    if ffi.doc.is_null() {
544        return Vec::new();
545    }
546    let b = ffi.bindings;
547    let page = b.FPDF_LoadPage(ffi.doc, index);
548    if page.is_null() {
549        return Vec::new();
550    }
551    let tp = b.FPDFText_LoadPage(page);
552    let mut out = Vec::new();
553    let n = b.FPDFPage_CountObjects(page);
554    for i in 0..n {
555        let obj = b.FPDFPage_GetObject(page, i);
556        if obj.is_null() || b.FPDFPageObj_GetType(obj) != FPDF_PAGEOBJ_TEXT as i32 {
557            continue;
558        }
559        let (mut l, mut bot, mut r, mut top) = (0f32, 0f32, 0f32, 0f32);
560        if b.FPDFPageObj_GetBounds(obj, &mut l, &mut bot, &mut r, &mut top) == 0 {
561            continue;
562        }
563        let invisible = b.FPDFTextObj_GetTextRenderMode(obj) == INVISIBLE_RENDER_MODE;
564        let text = if tp.is_null() {
565            String::new()
566        } else {
567            // FPDFTextObj_GetText returns the count of UTF-16 code units, including
568            // the trailing NUL; call once for the size, once to fill.
569            let need = b.FPDFTextObj_GetText(obj, tp, std::ptr::null_mut(), 0);
570            if need <= 1 {
571                String::new()
572            } else {
573                let mut buf = vec![0u16; need as usize];
574                b.FPDFTextObj_GetText(obj, tp, buf.as_mut_ptr(), need);
575                if let Some(&0) = buf.last() {
576                    buf.pop();
577                }
578                String::from_utf16_lossy(&buf)
579            }
580        };
581        out.push(DebugTextObject {
582            invisible,
583            l,
584            b: bot,
585            r,
586            t: top,
587            text,
588        });
589    }
590    if !tp.is_null() {
591        b.FPDFText_ClosePage(tp);
592    }
593    b.FPDF_ClosePage(page);
594    out
595}
596
597#[cfg(feature = "ml")]
598/// Hash a glyph's PDF font name + flags, for `enforce_same_font`. 0 if unavailable.
599fn font_hash(b: &dyn PdfiumLibraryBindings, tp: FPDF_TEXTPAGE, i: i32) -> u64 {
600    use std::hash::{Hash, Hasher};
601    let mut flags: std::os::raw::c_int = 0;
602    let len = b.FPDFText_GetFontInfo(tp, i, std::ptr::null_mut(), 0, &mut flags);
603    if len == 0 {
604        return 0;
605    }
606    let mut buf = vec![0u8; len as usize];
607    b.FPDFText_GetFontInfo(
608        tp,
609        i,
610        buf.as_mut_ptr() as *mut std::os::raw::c_void,
611        len,
612        &mut flags,
613    );
614    let mut h = std::collections::hash_map::DefaultHasher::new();
615    buf.hash(&mut h);
616    flags.hash(&mut h);
617    h.finish()
618}
619
620#[cfg(feature = "ml")]
621/// pdfium text render mode 3: the glyph is drawn with neither fill nor stroke —
622/// an invisible glyph. Web-to-PDF exporters put a hidden plain-text copy of
623/// syntax-highlighted code (and other "copy"/accessibility layers) in this mode,
624/// which the char-level text API then extracts as a duplicate of the visible text.
625const INVISIBLE_RENDER_MODE: i32 = 3;
626
627#[cfg(feature = "ml")]
628fn glyphs(b: &dyn PdfiumLibraryBindings, tp: FPDF_TEXTPAGE, fetch_font: bool) -> Vec<Glyph> {
629    let n = b.FPDFText_CountChars(tp);
630    let mut out = Vec::with_capacity(n.max(0) as usize);
631    for i in 0..n {
632        let ch = match char::from_u32(b.FPDFText_GetUnicode(tp, i)) {
633            Some(c) => c,
634            None => continue,
635        };
636        if ch == '\r' || ch == '\n' {
637            continue;
638        }
639        // Spaces are font-neutral (0): pdfium's generated spaces carry a default
640        // font that would otherwise block every word↔space merge under
641        // enforce_same_font; docling-parse's spaces inherit the run's font.
642        let font = if fetch_font && !ch.is_whitespace() {
643            font_hash(b, tp, i)
644        } else {
645            0
646        };
647        let (mut l, mut r, mut bot, mut top) = (0f64, 0f64, 0f64, 0f64);
648        let has_box = b.FPDFText_GetCharBox(tp, i, &mut l, &mut r, &mut bot, &mut top) != 0;
649        // Loose box: font ascent/descent + glyph advance, uniform per font/size.
650        let mut lr = FS_RECTF {
651            left: 0.0,
652            top: 0.0,
653            right: 0.0,
654            bottom: 0.0,
655        };
656        let (ll, lb, lrt, ltop) = if b.FPDFText_GetLooseCharBox(tp, i, &mut lr) != 0 {
657            (lr.left, lr.bottom, lr.right, lr.top)
658        } else if has_box {
659            (l as f32, bot as f32, r as f32, top as f32)
660        } else {
661            (f32::NAN, 0.0, 0.0, 0.0)
662        };
663        if ch.is_whitespace() {
664            // Keep the space *with its box* (the docling-parse-style line sanitizer
665            // needs literal space glyphs); NaN `l` if pdfium reports no box (the
666            // legacy `lines_from_glyphs` ignores the box and only flags a space).
667            out.push(Glyph {
668                ch: ' ',
669                l: if has_box { l as f32 } else { f32::NAN },
670                b: if has_box { bot as f32 } else { 0.0 },
671                r: if has_box { r as f32 } else { 0.0 },
672                t: if has_box { top as f32 } else { 0.0 },
673                ll,
674                lb,
675                lr: lrt,
676                lt: ltop,
677                font,
678            });
679            continue;
680        }
681        if !has_box {
682            continue;
683        }
684        out.push(Glyph {
685            ch,
686            l: l as f32,
687            b: bot as f32,
688            r: r as f32,
689            t: top as f32,
690            ll,
691            lb,
692            lr: lrt,
693            lt: ltop,
694            font,
695        });
696    }
697    // pdfium splits the Arabic lam-alef ligature into two chars at the *same* x
698    // (it's one glyph) in visual order — `alef-variant, lam`. docling-parse and
699    // logical order are `lam, alef-variant`. Detect the ligature by the shared x
700    // and swap. The shared-x test reliably distinguishes a true ligature from a
701    // genuine `alef + lam` sequence (the article `ال`, or `فعالة`), whose two
702    // glyphs sit at different x and must NOT be reordered.
703    for i in 0..out.len().saturating_sub(1) {
704        let same_x = out[i].l.is_finite()
705            && out[i + 1].l.is_finite()
706            && (out[i].l - out[i + 1].l).abs() < 1.0;
707        if same_x
708            && matches!(out[i].ch, '\u{0622}' | '\u{0623}' | '\u{0625}' | '\u{0627}')
709            && out[i + 1].ch == '\u{0644}'
710        {
711            out.swap(i, i + 1);
712        }
713    }
714    // Reconstruct degenerate (zero-width) loose space boxes by spanning the gap to
715    // the next glyph on the same line, so the sanitizer keeps them as word
716    // separators rather than dropping them (which would merge `Information systems`
717    // → `Informationsystems`). pdfium gives generated spaces a zero-width box at a
718    // wrong baseline; a wrap (different baseline) or a touching gap is left alone.
719    for i in 0..out.len() {
720        if out[i].ch != ' ' || (out[i].lr - out[i].ll).abs() >= 0.5 {
721            continue;
722        }
723        let prev = out[..i]
724            .iter()
725            .rev()
726            .find(|g| g.ch != ' ' && g.ll.is_finite())
727            .map(|g| (g.lr, g.lb, g.lt));
728        let next = out[i + 1..]
729            .iter()
730            .find(|g| g.ch != ' ' && g.ll.is_finite())
731            .map(|g| (g.ll, g.lb));
732        if let (Some((plr, plb, plt)), Some((nll, nlb))) = (prev, next) {
733            let line_h = (plt - plb).abs().max(1.0);
734            if (plb - nlb).abs() < line_h * 0.5 && nll > plr + 0.5 {
735                out[i].ll = plr;
736                out[i].lr = nll;
737                out[i].lb = plb;
738                out[i].lt = plt;
739            }
740        }
741    }
742    out
743}
744
745/// How [`lines_from_glyphs`] splits a line into words.
746#[derive(Clone, Copy, PartialEq)]
747enum Grouping {
748    /// Gap heuristic + punctuation glue (`engines,`, `[37`, `98.5`) — prose.
749    Prose,
750    /// Split only at literal space glyphs, never glue — pdfium code cells.
751    /// pdfium's monospace listings carry a real space glyph at every source space,
752    /// and its overhanging loose boxes would make the gap heuristic over-split
753    /// (`f un c t i o n`), so honouring just the spaces reproduces the spacing.
754    CodeSpaceOnly,
755    /// Split on the inter-glyph **gap** (or a space glyph), but never glue — for
756    /// the parser's code cells: the parser emits no space glyphs (a source space
757    /// is a positioning gap), and its clean advance boxes make the gap reliable.
758    /// Unlike [`Grouping::Prose`] there is no punctuation glue, so a real gap
759    /// always splits (`et al. 2000`, not `et al.2000`) while genuinely touching
760    /// tokens stay joined (`add(a,` / `b)`).
761    CodeGap,
762}
763
764/// Group glyphs (document order) into words then lines, the way docling-parse
765/// does: a new **word** starts where the horizontal gap to the previous glyph
766/// exceeds ~0.2 × the font height (a real space is ~0.3 × height; letter
767/// tracking is smaller, so titles don't shatter); a new **line** starts where
768/// the baseline drops by ~half the font height (a superscript rises without
769/// dropping, so it stays on its line). Coordinates are flipped to top-left.
770/// See [`Grouping`] for how each mode decides word boundaries.
771fn lines_from_glyphs(gs: &[Glyph], page_h: f32, mode: Grouping) -> Vec<TextCell> {
772    let mut cells: Vec<TextCell> = Vec::new();
773    let mut words: Vec<String> = Vec::new(); // words on the current line
774    let mut word = String::new();
775    // current line bounding box, native
776    let (mut ll, mut lb, mut lr, mut lt) = (
777        f32::INFINITY,
778        f32::INFINITY,
779        f32::NEG_INFINITY,
780        f32::NEG_INFINITY,
781    );
782    // Tallest glyph seen on the current line: the word-gap threshold is relative
783    // to it, so a small-font run on the line (a superscript citation) isn't split
784    // at its tight digit gaps, while a big display title isn't split at its wider
785    // letter tracking. A real inter-word space is ~0.3× the font height.
786    let mut line_h: f32 = 0.0;
787    let mut prev: Option<&Glyph> = None;
788    // A space glyph between non-space glyphs pins a word split the gap heuristic
789    // can miss (tight justified spacing); it carries no geometry.
790    let mut pending_space = false;
791
792    for g in gs {
793        if g.ch == ' ' {
794            pending_space = true;
795            continue;
796        }
797        let h = (g.t - g.b).abs().max(1.0);
798        let (mut new_word, mut new_line) = (false, false);
799        if let Some(p) = prev {
800            // A new line drops the baseline *and* resets x leftward; requiring the
801            // x-reset avoids a descending comma/semicolon faking a line break. A
802            // *large* drop (≥1.5× the line height — a skipped line, e.g. a centered
803            // page-number footer below a short last word) is always a new line,
804            // even without the x-reset.
805            // LTR wraps reset x leftward (`g.l < p.r`); RTL (Arabic) wraps reset
806            // rightward (the new line begins at the far right). A large drop
807            // (≥1.5× line height) is a new line regardless of x.
808            let x_reset = if is_arabic(g.ch) || is_arabic(p.ch) {
809                g.l > p.r
810            } else {
811                g.l < p.r
812            };
813            new_line = (p.b - g.b > h * 0.5 && x_reset) || (p.b - g.b > line_h.max(h) * 1.5);
814            // Don't split before closing punctuation, after opening punctuation, or
815            // after a period that runs into a digit/lowercase letter — docling
816            // keeps `engines,` / `[37` / `i.e.` / `98.5` together even across a
817            // space or gap.
818            let glued = is_close_punct(g.ch)
819                || is_open_punct(p.ch)
820                || (p.ch.is_ascii_digit() && g.ch.is_ascii_digit())
821                || (p.ch == '.'
822                    && !pending_space
823                    && (g.ch.is_ascii_digit() || g.ch.is_ascii_lowercase()));
824            let word_gap = line_h.max(h) * 0.25;
825            new_word = if mode == Grouping::CodeSpaceOnly {
826                new_line || pending_space
827            } else if mode == Grouping::CodeGap {
828                // Gap-based, no glue: a real gap always splits, touching tokens join.
829                new_line || pending_space || g.l - p.r > word_gap
830            } else if is_arabic(g.ch) || is_arabic(p.ch) {
831                // RTL runs right-to-left, so the inter-word gap is `p.l - g.r`. A
832                // real word space has a gap; pdfium also emits spurious zero-gap
833                // space glyphs inside words (`التي`), so require the gap rather
834                // than trusting a bare space glyph.
835                new_line || (p.l - g.r > word_gap && !glued)
836            } else {
837                new_line || ((pending_space || g.l - p.r > word_gap) && !glued)
838            };
839        }
840        pending_space = false;
841        if new_line {
842            push_word(&mut word, &mut words);
843            push_line(&mut words, (ll, lb, lr, lt), page_h, &mut cells);
844            (ll, lb, lr, lt) = (
845                f32::INFINITY,
846                f32::INFINITY,
847                f32::NEG_INFINITY,
848                f32::NEG_INFINITY,
849            );
850            line_h = 0.0;
851        } else if new_word {
852            push_word(&mut word, &mut words);
853        }
854        word.push(g.ch);
855        ll = ll.min(g.l);
856        lb = lb.min(g.b);
857        lr = lr.max(g.r);
858        lt = lt.max(g.t);
859        line_h = line_h.max(h);
860        prev = Some(g);
861    }
862    push_word(&mut word, &mut words);
863    push_line(&mut words, (ll, lb, lr, lt), page_h, &mut cells);
864    cells
865}
866
867/// Code line cells from the **parser**'s glyph stream. Unlike pdfium — whose
868/// monospace listings carry explicit space glyphs (so [`Grouping::CodeSpaceOnly`]
869/// keeps their spacing) — the parser emits no space glyphs: a source space is a
870/// positioning gap. So code cells use [`Grouping::CodeGap`], which splits on the
871/// inter-glyph gap (a space wherever it exceeds ~0.25× the line height) but never
872/// glues punctuation, so `et al. 2000` keeps its space while `add(a,` / `b)` stay
873/// joined. The parser's clean advance boxes make the gap heuristic reliable here,
874/// where pdfium's overhanging loose boxes would over-split (`f un c t i o n`).
875pub(crate) fn code_cells_from_glyphs(gs: &[Glyph], page_h: f32) -> Vec<TextCell> {
876    lines_from_glyphs(gs, page_h, Grouping::CodeGap)
877}
878
879/// Per-word cells (each word's text + top-left bbox), using the same word/line
880/// splitting as [`lines_from_glyphs`] but emitting one cell per word instead of
881/// joining into lines — the legacy gap-heuristic word grouping, kept for the
882/// pdfium word path (`DOCLING_PDFIUM_WORDS`). The default parser path uses
883/// [`crate::dp_lines::word_cells`] instead.
884pub(crate) fn words_from_glyphs(gs: &[Glyph], page_h: f32) -> Vec<TextCell> {
885    let mut cells = Vec::new();
886    let mut word = String::new();
887    let inf = (
888        f32::INFINITY,
889        f32::INFINITY,
890        f32::NEG_INFINITY,
891        f32::NEG_INFINITY,
892    );
893    let (mut wl, mut wb, mut wr, mut wt) = inf;
894    let mut line_h: f32 = 0.0;
895    let mut prev: Option<&Glyph> = None;
896    let mut pending_space = false;
897    for g in gs {
898        if g.ch == ' ' {
899            pending_space = true;
900            continue;
901        }
902        let h = (g.t - g.b).abs().max(1.0);
903        let mut new_line = false;
904        let mut new_word = false;
905        if let Some(p) = prev {
906            // LTR wraps reset x leftward (`g.l < p.r`); RTL (Arabic) wraps reset
907            // rightward (the new line begins at the far right). A large drop
908            // (≥1.5× line height) is a new line regardless of x.
909            let x_reset = if is_arabic(g.ch) || is_arabic(p.ch) {
910                g.l > p.r
911            } else {
912                g.l < p.r
913            };
914            new_line = (p.b - g.b > h * 0.5 && x_reset) || (p.b - g.b > line_h.max(h) * 1.5);
915            // No digit-digit glue here (unlike the prose grouping): table cells in
916            // adjacent columns are numeric and a column gap must still split them
917            // (`0.965` `0.934`, not `0.9650.934`). Intra-number digits have no gap
918            // so they stay together regardless.
919            let glued = is_close_punct(g.ch)
920                || is_open_punct(p.ch)
921                || (p.ch == '.'
922                    && !pending_space
923                    && (g.ch.is_ascii_digit() || g.ch.is_ascii_lowercase()));
924            let word_gap = line_h.max(h) * 0.25;
925            new_word = new_line || ((pending_space || g.l - p.r > word_gap) && !glued);
926        }
927        pending_space = false;
928        if new_word && !word.is_empty() {
929            cells.push(TextCell {
930                text: std::mem::take(&mut word),
931                l: wl,
932                t: page_h - wt,
933                r: wr,
934                b: page_h - wb,
935            });
936            (wl, wb, wr, wt) = inf;
937        }
938        if new_line {
939            line_h = 0.0;
940        }
941        word.push(g.ch);
942        wl = wl.min(g.l);
943        wb = wb.min(g.b);
944        wr = wr.max(g.r);
945        wt = wt.max(g.t);
946        line_h = line_h.max(h);
947        prev = Some(g);
948    }
949    if !word.is_empty() {
950        cells.push(TextCell {
951            text: word,
952            l: wl,
953            t: page_h - wt,
954            r: wr,
955            b: page_h - wb,
956        });
957    }
958    cells
959}
960
961fn is_arabic(c: char) -> bool {
962    ('\u{0600}'..='\u{06FF}').contains(&c)
963}
964
965fn is_close_punct(c: char) -> bool {
966    matches!(
967        c,
968        ',' | '.' | ';' | '!' | '?' | ')' | ']' | '}' | '%' | '\'' | '\u{2019}' | '\u{2018}'
969    )
970}
971
972fn is_open_punct(c: char) -> bool {
973    // `@` glues to what follows (`mAP @0.5`, `bpf@zurich`, `@decorator`).
974    matches!(c, '(' | '[' | '{' | '@')
975}
976
977fn push_word(word: &mut String, words: &mut Vec<String>) {
978    if !word.is_empty() {
979        words.push(std::mem::take(word));
980    }
981}
982
983fn push_line(
984    words: &mut Vec<String>,
985    bbox: (f32, f32, f32, f32),
986    page_h: f32,
987    cells: &mut Vec<TextCell>,
988) {
989    if words.is_empty() {
990        return;
991    }
992    let text = std::mem::take(words).join(" ");
993    let (l, b, r, t) = bbox;
994    cells.push(TextCell {
995        text,
996        l,
997        t: page_h - t,
998        r,
999        b: page_h - b,
1000    });
1001}