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 = "ocr-prep")]
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    /// The rendered page bitmap. Present whenever pixels are available at all
50    /// (`ocr-prep` ⊂ `ml`): the native pipeline renders it with pdfium, the
51    /// browser pipeline receives it from the host canvas. Picture regions are
52    /// cropped out of it.
53    #[cfg(feature = "ocr-prep")]
54    pub image: RgbImage,
55    /// The **scale-1.0** page image the layout model runs on (docling parity:
56    /// its layout stage calls `page.get_image(scale=1.0)` — pdfium at 1.5×,
57    /// PIL-BICUBIC down to point size — a *different* image from the 2×
58    /// OCR/crop bitmap above, and a different resampling regime than
59    /// stretching that bitmap). `None` on paths without a pdfium renderer
60    /// (browser, METS/TIFF), which fall back to stretching [`Self::image`].
61    #[cfg(feature = "ocr-prep")]
62    pub image_layout: Option<RgbImage>,
63    /// Hyperlink annotations on the page (rect in top-left page coords + target
64    /// URI), restricted to web/mail/tel schemes. Used only by strict Markdown.
65    pub links: Vec<LinkAnnot>,
66    /// The page's `/Rotate` value (0/90/180/270) when it was normalized away
67    /// before inference: a scanned page with `/Rotate` displays its raster
68    /// rotated, which turns OCR into garbage — so extraction un-rotates the
69    /// bitmaps (and swaps `width`/`height`) and records the display rotation
70    /// here. Assembly rotates the finished geometry *back* by this many
71    /// degrees clockwise, so emitted locations and the page size stay in
72    /// display space (matching docling and every PDF viewer). Always 0 for
73    /// text-layer pages (their cells live in display space already) and on
74    /// paths without a pdfium renderer.
75    pub rotation: u16,
76}
77
78impl PdfPage {
79    /// A page built from recognized cells alone — the browser pipeline's
80    /// shape (#157), where the bitmap lives on the JS side. Exists so callers
81    /// compile identically with and without the `ml` feature: under a
82    /// feature-unified workspace build the struct carries the `image` field,
83    /// which a plain literal in a non-`ml` consumer can't spell.
84    #[cfg(feature = "ocr-prep")]
85    pub fn from_cells(width: f32, height: f32, scale: f32, cells: Vec<TextCell>) -> Self {
86        Self {
87            width,
88            height,
89            scale,
90            cells,
91            code_cells: Vec::new(),
92            word_cells: Vec::new(),
93            #[cfg(feature = "ocr-prep")]
94            image: RgbImage::new(0, 0),
95            #[cfg(feature = "ocr-prep")]
96            image_layout: None,
97            links: Vec::new(),
98            rotation: 0,
99        }
100    }
101
102    /// Same as [`from_cells`](Self::from_cells) but carrying the rendered page
103    /// bitmap, so picture regions can be cropped out of it (#157: the browser
104    /// pipeline gets the same figure bytes the native one does).
105    #[cfg(feature = "ocr-prep")]
106    pub fn from_cells_with_image(
107        width: f32,
108        height: f32,
109        scale: f32,
110        cells: Vec<TextCell>,
111        image: RgbImage,
112    ) -> Self {
113        Self {
114            image,
115            ..Self::from_cells(width, height, scale, cells)
116        }
117    }
118
119    /// Un-rotate the page's bitmaps by `deg` (clockwise 90° steps) and record
120    /// the compensating display rotation, composing with any rotation already
121    /// recorded: the raster becomes upright for inference while assembly
122    /// still maps the finished geometry back into display space. Handles both
123    /// `/Rotate` normalization (extraction) and content-detected orientation
124    /// (#225) — the two compose additively (axis-aligned 90° rotations
125    /// commute through the dimension swaps). Link rectangles follow the
126    /// raster; `width`/`height` swap on odd quarter-turns.
127    #[cfg(feature = "ocr-prep")]
128    pub(crate) fn unrotate(&mut self, deg: u16) {
129        if deg == 0 {
130            return;
131        }
132        use image::imageops::{rotate180, rotate270, rotate90};
133        // Display = upright rotated `deg`° clockwise, so upright = display
134        // rotated the complementary amount clockwise.
135        let un = |img: &RgbImage| match deg {
136            90 => rotate270(img),
137            180 => rotate180(img),
138            _ => rotate90(img),
139        };
140        if self.image.width() > 1 {
141            self.image = un(&self.image);
142        }
143        self.image_layout = self.image_layout.as_ref().map(&un);
144        let (width, height) = (self.width, self.height);
145        // Link rects follow the raster from display into upright space (the
146        // inverse of the geometry rotation assembly applies at the end).
147        for l in &mut self.links {
148            let (nl, nt, nr, nb) = match deg {
149                90 => (l.t, width - l.r, l.b, width - l.l),
150                180 => (width - l.r, height - l.b, width - l.l, height - l.t),
151                _ => (height - l.b, l.l, height - l.t, l.r),
152            };
153            (l.l, l.t, l.r, l.b) = (nl, nt, nr, nb);
154        }
155        if deg != 180 {
156            (self.width, self.height) = (height, width);
157        }
158        self.rotation = (self.rotation + deg) % 360;
159    }
160}
161
162/// A PDF link annotation: its rectangle (top-left page coordinates, matching
163/// [`TextCell`]) and target URI.
164#[derive(Debug, Clone)]
165pub struct LinkAnnot {
166    pub l: f32,
167    pub t: f32,
168    pub r: f32,
169    pub b: f32,
170    pub uri: String,
171}
172
173#[cfg(feature = "ml")]
174/// A parsed PDF: per-page text cells and page images.
175pub struct PdfDocument {
176    pub pages: Vec<PdfPage>,
177}
178
179/// Whether to use the docling-parse line sanitizer ([`crate::dp_lines`]) for prose
180/// reconstruction — the default. Set `DOCLING_LEGACY_LINES` to fall back to the
181/// older gap-heuristic `lines_from_glyphs`.
182pub(crate) fn use_dp_lines() -> bool {
183    !docling_core::env::flag("DOCLING_LEGACY_LINES")
184}
185
186/// Whether to source **word** cells from the pure-Rust parser (roadmap item 6),
187/// the default. The parser's `word_cells` reproduce docling-parse's word grouping
188/// byte-for-byte — the per-word tokens TableFormer matches table-grid cells
189/// against — which moves table extraction closer to docling on the heavy
190/// multi-column fixtures. Set `DOCLING_PDFIUM_WORDS` to keep pdfium's word cells,
191/// or `DOCLING_PDFIUM_TEXT` to fall back to pdfium for all text.
192pub(crate) fn use_parser_words() -> bool {
193    !docling_core::env::flag("DOCLING_PDFIUM_WORDS")
194        && !docling_core::env::flag("DOCLING_PDFIUM_TEXT")
195}
196
197/// Whether to source **code** cells from the parser too (the default) — the last
198/// text layer to leave pdfium, fully retiring its text path. The parser's
199/// gap-based code grouping ([`code_cells_from_glyphs`]) reconstructs monospace
200/// spacing from positioning gaps (`function add(a, b) { … }`), so it no longer
201/// drops the inter-token spaces the old space-glyph-only grouping lost
202/// (`functionadd`). Reverts to pdfium with `DOCLING_PDFIUM_WORDS` (alongside word
203/// cells) or `DOCLING_PDFIUM_TEXT` (all text).
204pub(crate) fn use_parser_code() -> bool {
205    use_parser_words()
206}
207
208#[cfg(feature = "ml")]
209/// Try binding pdfium from a directory (or a literal library file path):
210/// `<dir>/<platform library name>` first, else `<dir>` itself as the file.
211fn try_bind_dir(path: &str) -> Option<Box<dyn pdfium_render::prelude::PdfiumLibraryBindings>> {
212    let name = Pdfium::pdfium_platform_library_name_at_path(path);
213    if let Ok(b) = Pdfium::bind_to_library(&name) {
214        return Some(b);
215    }
216    Pdfium::bind_to_library(path).ok()
217}
218
219#[cfg(feature = "ml")]
220/// Bind to the pdfium dynamic library. Honors `PDFIUM_DYNAMIC_LIB_PATH` (a
221/// directory or file) first; else falls back to `.pdfium/lib` relative to the
222/// current directory (the layout `scripts/install/download_dependencies.sh` and
223/// `scripts/install/pdf_setup.sh` both produce); else the system library.
224fn bind() -> Result<Pdfium, PdfiumError> {
225    if let Some(path) = docling_core::env::nonempty("PDFIUM_DYNAMIC_LIB_PATH") {
226        if let Some(b) = try_bind_dir(&path) {
227            return Ok(Pdfium::new(b));
228        }
229    }
230    // No env var (or it didn't resolve): fall back to `.pdfium/lib` relative to
231    // the current directory — mirroring `layout.rs`/`ocr.rs`'s `.models/…`
232    // defaults — the layout `scripts/install/download_dependencies.sh` (and
233    // `scripts/install/pdf_setup.sh`) produce, so a checkout with the dependencies
234    // downloaded next to it needs no env var at all.
235    if let Some(b) = try_bind_dir(&crate::resolve_asset(".pdfium/lib")) {
236        return Ok(Pdfium::new(b));
237    }
238    Pdfium::bind_to_system_library().map(Pdfium::new)
239}
240
241#[cfg(feature = "ml")]
242impl PdfDocument {
243    /// Parse a PDF from bytes, optionally decrypting with `password`.
244    ///
245    /// Note: this materialises **every** page's rendered bitmap in memory at
246    /// once. For large documents prefer [`for_each_page`], which streams.
247    pub fn open(bytes: &[u8], password: Option<&str>) -> Result<Self, PdfiumError> {
248        let pdfium = bind()?;
249        let ffi = FfiText::load(pdfium.bindings(), bytes, password);
250        let doc = pdfium.load_pdf_from_byte_slice(bytes, password)?;
251        let mut rust = rust_parser_cells(bytes);
252        let mut pages = Vec::new();
253        for (i, page) in doc.pages().iter().enumerate() {
254            let rc = rust.as_mut().map(|p| p.cells_timed(i));
255            pages.push(extract_page(&page, &ffi, i as i32, rc, true, true)?);
256        }
257        Ok(PdfDocument { pages })
258    }
259}
260
261#[cfg(feature = "ml")]
262/// Per-page prose line cells from the pure-Rust text parser. This is the
263/// **default** text layer (it matches docling-parse's char geometry and is a
264/// strict improvement on byte-conformance — e.g. it recovers the Arabic
265/// sentence-period attachment in `right_to_left_01`). Set `DOCLING_PDFIUM_TEXT`
266/// to fall back to pdfium's text layer. The parser returns an empty page when a
267/// PDF (or a page) has no parseable text layer; the caller keeps pdfium's cells
268/// in that case, so scanned/edge-case pages are unaffected.
269fn rust_parser_cells(bytes: &[u8]) -> Option<crate::textparse::PageTextParser> {
270    if docling_core::env::flag("DOCLING_PDFIUM_TEXT") {
271        return None;
272    }
273    // Only the document load happens here; pages are parsed as the walk
274    // reaches them (`cells_timed`), so nothing is decoded for pages outside
275    // a `--pages` window and the parse overlaps the workers' inference.
276    crate::timing::timed("textparse.open", || {
277        crate::textparse::PageTextParser::open(bytes)
278    })
279}
280
281impl crate::textparse::PageTextParser {
282    /// [`cells`](Self::cells) under the `textparse` timing stage (per page).
283    fn cells_timed(&mut self, index: usize) -> crate::textparse::PageParserCells {
284        crate::timing::timed("textparse", || self.cells(index))
285    }
286}
287
288#[cfg(feature = "ml")]
289/// Number of pages in a PDF, without rendering any of them — used to decide
290/// whether a document is worth spinning up the parallel worker pool.
291pub fn page_count(bytes: &[u8], password: Option<&str>) -> Result<usize, PdfiumError> {
292    let pdfium = bind()?;
293    let doc = pdfium.load_pdf_from_byte_slice(bytes, password)?;
294    Ok(doc.pages().len() as usize)
295}
296
297#[cfg(feature = "ml")]
298/// Render + extract pages one at a time, handing each (owned) [`PdfPage`] to `f`.
299/// Only one page bitmap is resident at a time — a rendered page is ~5 MB, so a
300/// large PDF would otherwise hold gigabytes of bitmaps at once. `f` receives the
301/// zero-based page index and the total page count.
302///
303/// `render_image` controls whether the page bitmap is rasterized at all: layout,
304/// OCR, TableFormer, and picture cropping all need it, but a caller that skips
305/// every one of those (the `no_ocr` fast path) doesn't, and rasterizing +
306/// downsampling a page is by far the most expensive step per page — skipping it
307/// is most of `no_ocr`'s speedup. `PdfPage::image` is a 1×1 placeholder when
308/// `false`; do not read it.
309///
310/// `extract_text` decodes the page's text layer (parser or pdfium cells); pass
311/// `false` when full-page OCR is forced and the cells would be discarded
312/// unread (docling#4061).
313///
314/// `range` restricts the walk to a **0-based inclusive** page window (issue
315/// #80's `--pages`); out-of-window pages are skipped *before* text extraction
316/// and rasterization, so a 3-page window over a 500-page PDF costs three
317/// pages, not five hundred. `f` still receives the absolute page index, so
318/// downstream page numbering refers to the source document.
319///
320/// `E` is the caller's error type; pdfium errors convert into it via `From`.
321pub fn for_each_page<E, F>(
322    bytes: &[u8],
323    password: Option<&str>,
324    render_image: bool,
325    extract_text: bool,
326    range: Option<(usize, usize)>,
327    mut f: F,
328) -> Result<(), E>
329where
330    E: From<PdfiumError>,
331    F: FnMut(usize, usize, PdfPage) -> Result<(), E>,
332{
333    let pdfium = bind()?;
334    let ffi = FfiText::load(pdfium.bindings(), bytes, password);
335    let doc = pdfium.load_pdf_from_byte_slice(bytes, password)?;
336    // `extract_text = false` (full-page OCR forced, docling#4061 / 2.122):
337    // the text layer would be cleared unread, so neither the pure-Rust parser
338    // nor pdfium's text page is decoded at all — on vector-dense pages (CAD
339    // drawings as 100k+ path segments) that decode is most of the page cost.
340    let mut rust = if extract_text {
341        rust_parser_cells(bytes)
342    } else {
343        None
344    };
345    let pages = doc.pages();
346    let total = pages.len() as usize;
347    let (first, last) = range.unwrap_or((0, total.saturating_sub(1)));
348    for (i, page) in pages.iter().enumerate() {
349        if i < first || i > last {
350            continue;
351        }
352        let rc = rust.as_mut().map(|p| p.cells_timed(i));
353        let extracted = extract_page(&page, &ffi, i as i32, rc, render_image, extract_text)?;
354        f(i, total, extracted)?;
355    }
356    Ok(())
357}
358
359/// One rasterized page from [`render_pages`] (#243): the absolute 1-based page
360/// number in the source document, the pixel dimensions, and the PNG bytes.
361#[cfg(feature = "ml")]
362#[derive(Debug, Clone)]
363pub struct RenderedPage {
364    pub page_no: usize,
365    pub width: u32,
366    pub height: u32,
367    pub png: Vec<u8>,
368}
369
370#[cfg(feature = "ml")]
371/// Rasterize a PDF's pages to PNG (#243) — the lean path behind serve's
372/// `to=images`: pdfium render only, no text extraction, no models, and only
373/// one page bitmap resident at a time (each is PNG-encoded and dropped before
374/// the next renders). `scale` is pixels per PDF point — 2.0 matches the
375/// pipeline's [`RENDER_SCALE`] (144 dpi). Unlike the pipeline's render there
376/// is no 1.5× supersample + downsample pass: that dance exists only because
377/// TableFormer is pixel-pinned to docling's bitmaps, and nothing downstream
378/// of this output is — a single render is nearly twice as fast.
379///
380/// `range` is a **1-based** inclusive page window (issue #80's `pages`
381/// semantics: the end clamps to the document, a start past the end errors).
382///
383/// pdfium is not thread-safe — callers must serialize this against any other
384/// pdfium use (docling-serve holds its pipeline mutex around this call for
385/// exactly that reason).
386pub fn render_pages(
387    bytes: &[u8],
388    password: Option<&str>,
389    range: Option<(usize, usize)>,
390    scale: f32,
391) -> Result<Vec<RenderedPage>, crate::PdfError> {
392    let pdfium = bind()?;
393    let doc = pdfium.load_pdf_from_byte_slice(bytes, password)?;
394    let pages = doc.pages();
395    let total = pages.len() as usize;
396    let (first, last) = match range {
397        None => (0, total.saturating_sub(1)),
398        Some((first, last)) => {
399            if first == 0 || last < first {
400                return Err(crate::PdfError::Pdfium(format!(
401                    "invalid page range {first}-{last} (pages are 1-based, first <= last)"
402                )));
403            }
404            if first > total {
405                return Err(crate::PdfError::Pdfium(format!(
406                    "page range {first}-{last} is outside the document ({total} page(s))"
407                )));
408            }
409            (first - 1, last.min(total) - 1)
410        }
411    };
412    let mut out = Vec::with_capacity(last.saturating_sub(first) + 1);
413    for (i, page) in pages.iter().enumerate() {
414        if i < first || i > last {
415            continue;
416        }
417        // pdfium applies /Rotate itself, so the bitmap is the page as a viewer
418        // shows it — no orientation handling needed (the pipeline's scanned-page
419        // un-rotation is an OCR-conformance concern, not a display one).
420        let tw = (page.width().value * scale).round().max(1.0) as i32;
421        let th = (page.height().value * scale).round().max(1.0) as i32;
422        let cfg = PdfRenderConfig::new()
423            .set_target_width(tw)
424            .set_target_height(th);
425        let bitmap = crate::timing::timed("pdfium.rasterize", || {
426            page.render_with_config(&cfg)
427                .map(|b| b.as_image().into_rgb8())
428        })?;
429        let mut png = Vec::new();
430        bitmap
431            .write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png)
432            .map_err(|e| crate::PdfError::Pdfium(format!("PNG-encoding page {}: {e}", i + 1)))?;
433        out.push(RenderedPage {
434            page_no: i + 1,
435            width: bitmap.width(),
436            height: bitmap.height(),
437            png,
438        });
439    }
440    Ok(out)
441}
442
443#[cfg(feature = "ml")]
444fn extract_page(
445    page: &pdfium_render::prelude::PdfPage<'_>,
446    ffi: &FfiText<'_>,
447    index: i32,
448    rust_cells: Option<crate::textparse::PageParserCells>,
449    render_image: bool,
450    extract_text: bool,
451) -> Result<PdfPage, PdfiumError> {
452    // pdfium reports the page size (and renders) in the *display* frame —
453    // `/Rotate` applied — while every text coordinate (its own text page, the
454    // pure-Rust parser's MediaBox-based glyphs, link annotation rects) lives
455    // in the unrotated frame (docling#4008, 2.121). Keep the unrotated box
456    // around for the y-flips and bring every rect into the display frame.
457    let width = page.width().value;
458    let height = page.height().value;
459    let rotation = match page.rotation() {
460        Ok(PdfPageRenderRotation::Degrees90) => 90u16,
461        Ok(PdfPageRenderRotation::Degrees180) => 180,
462        Ok(PdfPageRenderRotation::Degrees270) => 270,
463        _ => 0,
464    };
465    let (unrot_w, unrot_h) = if rotation == 90 || rotation == 270 {
466        (height, width)
467    } else {
468        (width, height)
469    };
470
471    // Default: use the pure-Rust text parser instead of pdfium's text layer
472    // (override with `DOCLING_PDFIUM_TEXT`). Prose line cells always come from the
473    // parser; word and code cells do too unless `DOCLING_PDFIUM_WORDS` keeps them
474    // on pdfium (the parser's word grouping reproduces docling-parse's, which
475    // TableFormer matches against — roadmap item 6). A page the parser couldn't
476    // read (no text layer) keeps pdfium's cells.
477    let rc = rust_cells.unwrap_or_default();
478    let need_pdfium_prose = extract_text && rc.prose.is_empty();
479    let need_pdfium_words = extract_text && (!use_parser_words() || rc.words.is_empty());
480    let need_pdfium_code = extract_text && (!use_parser_code() || rc.code.is_empty());
481
482    // The parser covers prose/words/code from one shared glyph pass, so on the
483    // common (parser-succeeded) page all three are already satisfied and this
484    // pdfium FFI call — otherwise fully discarded below — is skipped outright.
485    let (mut cells, mut code_cells, mut word_cells) =
486        if need_pdfium_prose || need_pdfium_words || need_pdfium_code {
487            let (mut cells, code_cells, word_cells) =
488                crate::timing::timed("ffi.page_cells", || ffi.page_cells(index, unrot_h));
489            if cells.is_empty() {
490                cells = segment_cells(&page.text()?, unrot_h);
491            }
492            (cells, code_cells, word_cells)
493        } else {
494            (Vec::new(), Vec::new(), Vec::new())
495        };
496    if !rc.prose.is_empty() {
497        cells = rc.prose;
498    }
499    if use_parser_words() && !rc.words.is_empty() {
500        word_cells = rc.words;
501    }
502    if use_parser_code() && !rc.code.is_empty() {
503        code_cells = rc.code;
504    }
505    if rotation != 0 {
506        for c in cells
507            .iter_mut()
508            .chain(word_cells.iter_mut())
509            .chain(code_cells.iter_mut())
510        {
511            let (l, t, r, b) = to_display_frame((c.l, c.t, c.r, c.b), rotation, unrot_w, unrot_h);
512            (c.l, c.t, c.r, c.b) = (l, t, r, b);
513        }
514    }
515
516    let image = if render_image {
517        // docling renders at 1.5× the target scale and downsamples "to make it
518        // sharper" (pypdfium2 → PIL BICUBIC). Replicate exactly: the TableFormer
519        // model is pixel-sensitive, so the page bitmap must match byte-for-byte.
520        // `CatmullRom` is the same a=-0.5 cubic kernel as PIL's BICUBIC.
521        const SUPERSAMPLE: f32 = 1.5;
522        let tw = (width * RENDER_SCALE * SUPERSAMPLE).round().max(1.0) as i32;
523        let th = (height * RENDER_SCALE * SUPERSAMPLE).round().max(1.0) as i32;
524        let cfg = PdfRenderConfig::new()
525            .set_target_width(tw)
526            .set_target_height(th);
527        let big = crate::timing::timed("pdfium.render", || {
528            page.render_with_config(&cfg)
529                .map(|b| b.as_image().into_rgb8())
530        })?;
531        let dw = (width * RENDER_SCALE).round().max(1.0) as u32;
532        let dh = (height * RENDER_SCALE).round().max(1.0) as u32;
533        crate::timing::timed("image.resize", || fast_downscale(&big, dw, dh))
534    } else {
535        RgbImage::new(1, 1)
536    };
537    // The layout model's input image, built exactly like docling's
538    // `get_page_image(scale=1.0)`: a pdfium render at 1.5× (pypdfium2 sizes
539    // with `ceil`), PIL-BICUBIC down to the point-size image (PIL `resize`'s
540    // default kernel; Python `round` = ties-to-even). Distinct from the 2×
541    // bitmap above — resampling 1224→640 and 612→640 are different regimes,
542    // and the heron model's borderline scores follow the pixels.
543    let image_layout = if render_image {
544        let tw = f64::from(width * 1.5).ceil().max(1.0) as i32;
545        let th = f64::from(height * 1.5).ceil().max(1.0) as i32;
546        let cfg = PdfRenderConfig::new()
547            .set_target_width(tw)
548            .set_target_height(th);
549        let big = crate::timing::timed("pdfium.render_layout", || {
550            page.render_with_config(&cfg)
551                .map(|b| b.as_image().into_rgb8())
552        })?;
553        let dw = f64::from(width).round_ties_even().max(1.0) as u32;
554        let dh = f64::from(height).round_ties_even().max(1.0) as u32;
555        Some(crate::timing::timed("image.resize_layout", || {
556            crate::resample::pil_resize(&big, dw, dh, crate::resample::PilFilter::Bicubic)
557        }))
558    } else {
559        None
560    };
561
562    let mut links = extract_links(page, unrot_h);
563    if rotation != 0 {
564        for l in &mut links {
565            let (a, t, r, b) = to_display_frame((l.l, l.t, l.r, l.b), rotation, unrot_w, unrot_h);
566            (l.l, l.t, l.r, l.b) = (a, t, r, b);
567        }
568    }
569
570    // `/Rotate` normalization for scanned pages: pdfium renders the page as a
571    // viewer displays it — `/Rotate` applied — so a rotated scan hands layout
572    // and OCR a sideways/upside-down raster and the recognition output is
573    // garbage. A page with a text layer needs none of this (its cells carry
574    // the geometry; the models never see its pixels decide text), so the
575    // normalization is gated to pages with no cells at all — exactly the set
576    // the OCR path fires on. The bitmaps are un-rotated to upright (lossless
577    // 90° steps), `width`/`height` swap to the upright box, and the display
578    // rotation is recorded so assembly can rotate the finished geometry back
579    // into display space (docling reports rotated pages in display coords).
580    let scanned = cells.is_empty() && word_cells.is_empty() && code_cells.is_empty();
581    let mut page = PdfPage {
582        width,
583        height,
584        scale: RENDER_SCALE,
585        image_layout,
586        cells,
587        code_cells,
588        word_cells,
589        image,
590        links,
591        rotation: 0,
592    };
593    if rotation != 0 && scanned && render_image {
594        page.unrotate(rotation);
595    }
596    Ok(page)
597}
598
599#[cfg(feature = "ml")]
600/// The supersample→target downscale via `fast_image_resize` (SIMD convolution;
601/// the same a=-0.5 Catmull-Rom kernel as `image::imageops::resize(...,
602/// CatmullRom)` and PIL BICUBIC — see the render comment above). Set
603/// `DOCLING_RS_SLOW_RESIZE=1` to fall back to the `image`-crate scalar resize
604/// (byte-parity with the pre-SIMD pipeline, several times slower).
605fn fast_downscale(big: &RgbImage, dw: u32, dh: u32) -> RgbImage {
606    use fast_image_resize as fir;
607    static SLOW: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
608    let slow = *SLOW.get_or_init(|| docling_core::env::flag("DOCLING_RS_SLOW_RESIZE"));
609    if !slow {
610        if let Some(out) = (|| {
611            let src = fir::images::ImageRef::new(
612                big.width(),
613                big.height(),
614                big.as_raw(),
615                fir::PixelType::U8x3,
616            )
617            .ok()?;
618            let mut dst = fir::images::Image::new(dw, dh, fir::PixelType::U8x3);
619            fir::Resizer::new()
620                .resize(
621                    &src,
622                    &mut dst,
623                    &fir::ResizeOptions::new()
624                        .resize_alg(fir::ResizeAlg::Convolution(fir::FilterType::CatmullRom)),
625                )
626                .ok()?;
627            RgbImage::from_raw(dw, dh, dst.into_vec())
628        })() {
629            return out;
630        }
631        // Unreachable in practice; fall through to the scalar path on any error.
632    }
633    image::imageops::resize(big, dw, dh, image::imageops::FilterType::CatmullRom)
634}
635
636#[cfg(feature = "ml")]
637/// Collect web/mail/tel hyperlink annotations on a page, mapping each link's
638/// rectangle into top-left page coordinates (like [`TextCell`]). `file://` and
639/// in-document destinations are skipped — only externally meaningful targets are
640/// rendered. pdfium occasionally lists a link twice; rects are kept as-is and the
641/// caller dedupes by resolved anchor text.
642fn extract_links(page: &pdfium_render::prelude::PdfPage<'_>, page_h: f32) -> Vec<LinkAnnot> {
643    let mut out = Vec::new();
644    for link in page.links().iter() {
645        let Some(uri) = link
646            .action()
647            .and_then(|a| a.as_uri_action().and_then(|u| u.uri().ok()))
648        else {
649            continue;
650        };
651        let scheme_ok = ["http://", "https://", "mailto:", "tel:"]
652            .iter()
653            .any(|s| uri.starts_with(s));
654        if !scheme_ok {
655            continue;
656        }
657        if let Ok(rect) = link.rect() {
658            out.push(LinkAnnot {
659                l: rect.left().value,
660                t: page_h - rect.top().value,
661                r: rect.right().value,
662                b: page_h - rect.bottom().value,
663                uri,
664            });
665        }
666    }
667    out
668}
669
670/// Map a top-left-origin rect from a page's unrotated (MediaBox) frame into its
671/// `/Rotate`d display frame — the counterpart of docling's pypdfium2
672/// `_rect_to_display_frame` (docling#4008) for our y-down coordinates.
673/// `unrot_w`/`unrot_h` are the unrotated page box; the display box is the same
674/// for 180° and swapped for 90°/270°.
675pub(crate) fn to_display_frame(
676    (l, t, r, b): (f32, f32, f32, f32),
677    rotation: u16,
678    unrot_w: f32,
679    unrot_h: f32,
680) -> (f32, f32, f32, f32) {
681    match rotation {
682        // Page turned 90° clockwise for display: the unrotated top edge becomes
683        // the display right edge, so x' runs from the old bottom edge up.
684        90 => (unrot_h - b, l, unrot_h - t, r),
685        180 => (unrot_w - r, unrot_h - b, unrot_w - l, unrot_h - t),
686        270 => (t, unrot_w - r, b, unrot_w - l),
687        _ => (l, t, r, b),
688    }
689}
690
691#[cfg(feature = "ml")]
692/// Fallback line cells from pdfium-render's style segments (one cell per
693/// segment). Used only when the raw-FFI text page can't be loaded.
694fn segment_cells(text: &PdfPageText, page_h: f32) -> Vec<TextCell> {
695    text.segments()
696        .iter()
697        .filter_map(|seg| {
698            let s = seg.text();
699            if s.trim().is_empty() {
700                return None;
701            }
702            let r = seg.bounds();
703            Some(TextCell {
704                text: s,
705                l: r.left().value,
706                t: page_h - r.top().value,
707                r: r.right().value,
708                b: page_h - r.bottom().value,
709            })
710        })
711        .collect()
712}
713
714#[cfg(feature = "ml")]
715/// A second, raw-FFI handle on the same PDF used to drive the character loop
716/// (`FPDFText_GetUnicode`/`GetCharBox`) that pdfium-render's safe API doesn't
717/// expose. Closes the document on drop.
718struct FfiText<'a> {
719    bindings: &'a dyn PdfiumLibraryBindings,
720    doc: FPDF_DOCUMENT,
721}
722
723/// One glyph: codepoint + native (y-up) box edges. `l/b/r/t` is pdfium's *tight*
724/// ink box (used by the legacy `lines_from_glyphs`); `ll/lb/lr/lt` is the *loose*
725/// box (font ascent/descent + advance — uniform per font/size), which the
726/// docling-parse-style sanitizer needs so adjacent glyphs share a top edge.
727pub(crate) struct Glyph {
728    pub(crate) ch: char,
729    pub(crate) l: f32,
730    pub(crate) b: f32,
731    pub(crate) r: f32,
732    pub(crate) t: f32,
733    pub(crate) ll: f32,
734    pub(crate) lb: f32,
735    pub(crate) lr: f32,
736    pub(crate) lt: f32,
737    /// Hash of the PDF font name + flags (0 when not fetched). The sanitizer uses
738    /// it for docling-parse's `enforce_same_font` (keeps a bold label and regular
739    /// value as separate line cells, e.g. `LABEL : value`).
740    pub(crate) font: u64,
741}
742
743#[cfg(feature = "ml")]
744impl<'a> FfiText<'a> {
745    fn load(bindings: &'a dyn PdfiumLibraryBindings, bytes: &[u8], password: Option<&str>) -> Self {
746        let doc = bindings.FPDF_LoadMemDocument(bytes, password);
747        FfiText { bindings, doc }
748    }
749
750    /// Reconstruct line cells for page `index` (zero-based) via the
751    /// chars→words→lines grouping. Returns `(prose_cells, code_cells)` — the same
752    /// glyphs grouped two ways (gap-heuristic for prose, space-glyph-only for
753    /// code). Both empty on any failure (caller falls back).
754    fn page_cells(&self, index: i32, page_h: f32) -> (Vec<TextCell>, Vec<TextCell>, Vec<TextCell>) {
755        let empty = || (Vec::new(), Vec::new(), Vec::new());
756        if self.doc.is_null() {
757            return empty();
758        }
759        let b = self.bindings;
760        let page = b.FPDF_LoadPage(self.doc, index);
761        if page.is_null() {
762            return empty();
763        }
764        let tp = b.FPDFText_LoadPage(page);
765        let out = if tp.is_null() {
766            empty()
767        } else {
768            let dp = use_dp_lines();
769            let g = glyphs(b, tp, dp);
770            b.FPDFText_ClosePage(tp);
771            // Prose line cells: the docling-parse-style sanitizer (behind a flag
772            // while it's validated) or the legacy gap-heuristic reconstruction.
773            let prose = if dp {
774                crate::dp_lines::line_cells(&g, page_h, false)
775            } else {
776                lines_from_glyphs(&g, page_h, Grouping::Prose)
777            };
778            (
779                prose,
780                lines_from_glyphs(&g, page_h, Grouping::CodeSpaceOnly),
781                words_from_glyphs(&g, page_h),
782            )
783        };
784        b.FPDF_ClosePage(page);
785        out
786    }
787}
788
789#[cfg(feature = "ml")]
790impl Drop for FfiText<'_> {
791    fn drop(&mut self) {
792        if !self.doc.is_null() {
793            self.bindings.FPDF_CloseDocument(self.doc);
794        }
795    }
796}
797
798#[cfg(feature = "ml")]
799/// Read every glyph (codepoint + native box) from the text page, in document
800/// order. A space glyph is kept as a word-boundary marker (NaN box, char `' '`);
801/// pdfium emits these on most lines and they pin word splits exactly. Hard line
802/// breaks are dropped (line structure comes from geometry); the gap heuristic in
803/// [`lines_from_glyphs`] is the fallback for the lines pdfium leaves space-less.
804/// Debug helper: the raw pdfium glyph stream (codepoint + native bottom-left
805/// box) for a page, in pdfium's character order. For comparing against
806/// docling-parse's char cells.
807pub fn debug_glyphs(bytes: &[u8], index: i32) -> Vec<(char, f32, f32)> {
808    let Ok(pdfium) = bind() else {
809        return Vec::new();
810    };
811    let ffi = FfiText::load(pdfium.bindings(), bytes, None);
812    if ffi.doc.is_null() {
813        return Vec::new();
814    }
815    let b = ffi.bindings;
816    let page = b.FPDF_LoadPage(ffi.doc, index);
817    if page.is_null() {
818        return Vec::new();
819    }
820    let tp = b.FPDFText_LoadPage(page);
821    let mut out = Vec::new();
822    if !tp.is_null() {
823        for g in glyphs(b, tp, true) {
824            out.push((g.ch, g.ll, g.lr));
825        }
826        b.FPDFText_ClosePage(tp);
827    }
828    b.FPDF_ClosePage(page);
829    out
830}
831
832#[cfg(feature = "ml")]
833/// One text object on a page, for the hidden-layer diagnostic.
834#[derive(Debug, Clone)]
835pub struct DebugTextObject {
836    /// True when the object is drawn invisibly (text render mode 3) — the marker of
837    /// a hidden duplicate text layer.
838    pub invisible: bool,
839    /// Bounding box in native PDF points (bottom-left origin).
840    pub l: f32,
841    pub b: f32,
842    pub r: f32,
843    pub t: f32,
844    /// The object's text (best-effort; empty if it could not be read).
845    pub text: String,
846}
847
848#[cfg(feature = "ml")]
849/// Diagnostic: every text object on page `index`, each tagged visible/invisible
850/// (via the object-level [`FPDFTextObj_GetTextRenderMode`], which — unlike the
851/// per-character render-mode API — is available on the default pdfium binding).
852/// A hidden duplicate text layer shows up as invisible objects repeating the
853/// visible text. Used by the `dump_render_modes` example.
854///
855/// [`FPDFTextObj_GetTextRenderMode`]: pdfium_render::prelude::PdfiumLibraryBindings::FPDFTextObj_GetTextRenderMode
856pub fn debug_text_objects(bytes: &[u8], index: i32) -> Vec<DebugTextObject> {
857    let Ok(pdfium) = bind() else {
858        return Vec::new();
859    };
860    let ffi = FfiText::load(pdfium.bindings(), bytes, None);
861    if ffi.doc.is_null() {
862        return Vec::new();
863    }
864    let b = ffi.bindings;
865    let page = b.FPDF_LoadPage(ffi.doc, index);
866    if page.is_null() {
867        return Vec::new();
868    }
869    let tp = b.FPDFText_LoadPage(page);
870    let mut out = Vec::new();
871    let n = b.FPDFPage_CountObjects(page);
872    for i in 0..n {
873        let obj = b.FPDFPage_GetObject(page, i);
874        if obj.is_null() || b.FPDFPageObj_GetType(obj) != FPDF_PAGEOBJ_TEXT as i32 {
875            continue;
876        }
877        let (mut l, mut bot, mut r, mut top) = (0f32, 0f32, 0f32, 0f32);
878        if b.FPDFPageObj_GetBounds(obj, &mut l, &mut bot, &mut r, &mut top) == 0 {
879            continue;
880        }
881        let invisible = b.FPDFTextObj_GetTextRenderMode(obj) == INVISIBLE_RENDER_MODE;
882        let text = if tp.is_null() {
883            String::new()
884        } else {
885            // FPDFTextObj_GetText returns the count of UTF-16 code units, including
886            // the trailing NUL; call once for the size, once to fill.
887            let need = b.FPDFTextObj_GetText(obj, tp, std::ptr::null_mut(), 0);
888            if need <= 1 {
889                String::new()
890            } else {
891                let mut buf = vec![0u16; need as usize];
892                b.FPDFTextObj_GetText(obj, tp, buf.as_mut_ptr(), need);
893                if let Some(&0) = buf.last() {
894                    buf.pop();
895                }
896                String::from_utf16_lossy(&buf)
897            }
898        };
899        out.push(DebugTextObject {
900            invisible,
901            l,
902            b: bot,
903            r,
904            t: top,
905            text,
906        });
907    }
908    if !tp.is_null() {
909        b.FPDFText_ClosePage(tp);
910    }
911    b.FPDF_ClosePage(page);
912    out
913}
914
915#[cfg(feature = "ml")]
916/// Hash a glyph's PDF font name + flags, for `enforce_same_font`. 0 if unavailable.
917fn font_hash(b: &dyn PdfiumLibraryBindings, tp: FPDF_TEXTPAGE, i: i32) -> u64 {
918    use std::hash::{Hash, Hasher};
919    let mut flags: std::os::raw::c_int = 0;
920    let len = b.FPDFText_GetFontInfo(tp, i, std::ptr::null_mut(), 0, &mut flags);
921    if len == 0 {
922        return 0;
923    }
924    let mut buf = vec![0u8; len as usize];
925    b.FPDFText_GetFontInfo(
926        tp,
927        i,
928        buf.as_mut_ptr() as *mut std::os::raw::c_void,
929        len,
930        &mut flags,
931    );
932    let mut h = std::collections::hash_map::DefaultHasher::new();
933    buf.hash(&mut h);
934    flags.hash(&mut h);
935    h.finish()
936}
937
938#[cfg(feature = "ml")]
939/// A glyph's PDF font name (NUL-trimmed), or empty if unavailable.
940fn font_name_bytes(b: &dyn PdfiumLibraryBindings, tp: FPDF_TEXTPAGE, i: i32) -> Vec<u8> {
941    let mut flags: std::os::raw::c_int = 0;
942    let len = b.FPDFText_GetFontInfo(tp, i, std::ptr::null_mut(), 0, &mut flags);
943    if len == 0 {
944        return Vec::new();
945    }
946    let mut buf = vec![0u8; len as usize];
947    b.FPDFText_GetFontInfo(
948        tp,
949        i,
950        buf.as_mut_ptr() as *mut std::os::raw::c_void,
951        len,
952        &mut flags,
953    );
954    while buf.last() == Some(&0) {
955        buf.pop();
956    }
957    buf
958}
959
960#[cfg(feature = "ml")]
961/// Read the text layer's glyph boxes and font styles for the given **1-based**
962/// pages — the heading-hierarchy stage's style signal (#302). A separate,
963/// on-demand pass over the text pages (no rendering), so the extraction
964/// pipeline itself stays byte-identical whether or not the stage runs; pages
965/// without a text layer (scans) simply yield no glyphs and the stage falls
966/// back to its other signals. Boxes are the *loose* char boxes (font ascent +
967/// descent — the font-size proxy), converted to top-left origin.
968pub(crate) fn glyph_styles(
969    bytes: &[u8],
970    password: Option<&str>,
971    pages: &[usize],
972) -> std::collections::HashMap<usize, Vec<crate::heading_hierarchy::GlyphStyle>> {
973    use crate::heading_hierarchy::GlyphStyle;
974    let mut out = std::collections::HashMap::new();
975    let Ok(pdfium) = bind() else {
976        return out;
977    };
978    let ffi = FfiText::load(pdfium.bindings(), bytes, password);
979    if ffi.doc.is_null() {
980        return out;
981    }
982    let b = ffi.bindings;
983    // Each distinct font name parses once per document.
984    let mut cache: std::collections::HashMap<Vec<u8>, crate::font_style::FontStyle> =
985        std::collections::HashMap::new();
986    for &page_no in pages {
987        if page_no == 0 {
988            continue;
989        }
990        let page = b.FPDF_LoadPage(ffi.doc, (page_no - 1) as i32);
991        if page.is_null() {
992            continue;
993        }
994        let page_h = b.FPDF_GetPageHeightF(page);
995        let tp = b.FPDFText_LoadPage(page);
996        if !tp.is_null() {
997            let n = b.FPDFText_CountChars(tp);
998            let mut styles = Vec::with_capacity(n.max(0) as usize);
999            for i in 0..n {
1000                let ch = match char::from_u32(b.FPDFText_GetUnicode(tp, i)) {
1001                    Some(c) => c,
1002                    None => continue,
1003                };
1004                if ch.is_whitespace() {
1005                    continue;
1006                }
1007                let mut lr = FS_RECTF {
1008                    left: 0.0,
1009                    top: 0.0,
1010                    right: 0.0,
1011                    bottom: 0.0,
1012                };
1013                if b.FPDFText_GetLooseCharBox(tp, i, &mut lr) == 0 {
1014                    continue;
1015                }
1016                let name = font_name_bytes(b, tp, i);
1017                let style = *cache.entry(name).or_insert_with_key(|n| {
1018                    crate::font_style::parse_font_style(&String::from_utf8_lossy(n))
1019                });
1020                styles.push(GlyphStyle {
1021                    l: lr.left,
1022                    t: page_h - lr.top,
1023                    r: lr.right,
1024                    b: page_h - lr.bottom,
1025                    height: lr.top - lr.bottom,
1026                    weight_cls: crate::font_style::weight_class(style.weight),
1027                    italic: style.italic,
1028                    styled: style.known,
1029                });
1030            }
1031            b.FPDFText_ClosePage(tp);
1032            out.insert(page_no, styles);
1033        }
1034        b.FPDF_ClosePage(page);
1035    }
1036    out
1037}
1038
1039#[cfg(feature = "ml")]
1040/// pdfium text render mode 3: the glyph is drawn with neither fill nor stroke —
1041/// an invisible glyph. Web-to-PDF exporters put a hidden plain-text copy of
1042/// syntax-highlighted code (and other "copy"/accessibility layers) in this mode,
1043/// which the char-level text API then extracts as a duplicate of the visible text.
1044const INVISIBLE_RENDER_MODE: i32 = 3;
1045
1046#[cfg(feature = "ml")]
1047fn glyphs(b: &dyn PdfiumLibraryBindings, tp: FPDF_TEXTPAGE, fetch_font: bool) -> Vec<Glyph> {
1048    let n = b.FPDFText_CountChars(tp);
1049    let mut out = Vec::with_capacity(n.max(0) as usize);
1050    for i in 0..n {
1051        let ch = match char::from_u32(b.FPDFText_GetUnicode(tp, i)) {
1052            Some(c) => c,
1053            None => continue,
1054        };
1055        if ch == '\r' || ch == '\n' {
1056            continue;
1057        }
1058        // Spaces are font-neutral (0): pdfium's generated spaces carry a default
1059        // font that would otherwise block every word↔space merge under
1060        // enforce_same_font; docling-parse's spaces inherit the run's font.
1061        let font = if fetch_font && !ch.is_whitespace() {
1062            font_hash(b, tp, i)
1063        } else {
1064            0
1065        };
1066        let (mut l, mut r, mut bot, mut top) = (0f64, 0f64, 0f64, 0f64);
1067        let has_box = b.FPDFText_GetCharBox(tp, i, &mut l, &mut r, &mut bot, &mut top) != 0;
1068        // Loose box: font ascent/descent + glyph advance, uniform per font/size.
1069        let mut lr = FS_RECTF {
1070            left: 0.0,
1071            top: 0.0,
1072            right: 0.0,
1073            bottom: 0.0,
1074        };
1075        let (ll, lb, lrt, ltop) = if b.FPDFText_GetLooseCharBox(tp, i, &mut lr) != 0 {
1076            (lr.left, lr.bottom, lr.right, lr.top)
1077        } else if has_box {
1078            (l as f32, bot as f32, r as f32, top as f32)
1079        } else {
1080            (f32::NAN, 0.0, 0.0, 0.0)
1081        };
1082        if ch.is_whitespace() {
1083            // Keep the space *with its box* (the docling-parse-style line sanitizer
1084            // needs literal space glyphs); NaN `l` if pdfium reports no box (the
1085            // legacy `lines_from_glyphs` ignores the box and only flags a space).
1086            out.push(Glyph {
1087                ch: ' ',
1088                l: if has_box { l as f32 } else { f32::NAN },
1089                b: if has_box { bot as f32 } else { 0.0 },
1090                r: if has_box { r as f32 } else { 0.0 },
1091                t: if has_box { top as f32 } else { 0.0 },
1092                ll,
1093                lb,
1094                lr: lrt,
1095                lt: ltop,
1096                font,
1097            });
1098            continue;
1099        }
1100        if !has_box {
1101            continue;
1102        }
1103        out.push(Glyph {
1104            ch,
1105            l: l as f32,
1106            b: bot as f32,
1107            r: r as f32,
1108            t: top as f32,
1109            ll,
1110            lb,
1111            lr: lrt,
1112            lt: ltop,
1113            font,
1114        });
1115    }
1116    // pdfium splits the Arabic lam-alef ligature into two chars at the *same* x
1117    // (it's one glyph) in visual order — `alef-variant, lam`. docling-parse and
1118    // logical order are `lam, alef-variant`. Detect the ligature by the shared x
1119    // and swap. The shared-x test reliably distinguishes a true ligature from a
1120    // genuine `alef + lam` sequence (the article `ال`, or `فعالة`), whose two
1121    // glyphs sit at different x and must NOT be reordered.
1122    for i in 0..out.len().saturating_sub(1) {
1123        let same_x = out[i].l.is_finite()
1124            && out[i + 1].l.is_finite()
1125            && (out[i].l - out[i + 1].l).abs() < 1.0;
1126        if same_x
1127            && matches!(out[i].ch, '\u{0622}' | '\u{0623}' | '\u{0625}' | '\u{0627}')
1128            && out[i + 1].ch == '\u{0644}'
1129        {
1130            out.swap(i, i + 1);
1131        }
1132    }
1133    // Reconstruct degenerate (zero-width) loose space boxes by spanning the gap to
1134    // the next glyph on the same line, so the sanitizer keeps them as word
1135    // separators rather than dropping them (which would merge `Information systems`
1136    // → `Informationsystems`). pdfium gives generated spaces a zero-width box at a
1137    // wrong baseline; a wrap (different baseline) or a touching gap is left alone.
1138    for i in 0..out.len() {
1139        if out[i].ch != ' ' || (out[i].lr - out[i].ll).abs() >= 0.5 {
1140            continue;
1141        }
1142        let prev = out[..i]
1143            .iter()
1144            .rev()
1145            .find(|g| g.ch != ' ' && g.ll.is_finite())
1146            .map(|g| (g.lr, g.lb, g.lt));
1147        let next = out[i + 1..]
1148            .iter()
1149            .find(|g| g.ch != ' ' && g.ll.is_finite())
1150            .map(|g| (g.ll, g.lb));
1151        if let (Some((plr, plb, plt)), Some((nll, nlb))) = (prev, next) {
1152            let line_h = (plt - plb).abs().max(1.0);
1153            if (plb - nlb).abs() < line_h * 0.5 && nll > plr + 0.5 {
1154                out[i].ll = plr;
1155                out[i].lr = nll;
1156                out[i].lb = plb;
1157                out[i].lt = plt;
1158            }
1159        }
1160    }
1161    out
1162}
1163
1164/// How [`lines_from_glyphs`] splits a line into words.
1165#[derive(Clone, Copy, PartialEq)]
1166enum Grouping {
1167    /// Gap heuristic + punctuation glue (`engines,`, `[37`, `98.5`) — prose.
1168    Prose,
1169    /// Split only at literal space glyphs, never glue — pdfium code cells.
1170    /// pdfium's monospace listings carry a real space glyph at every source space,
1171    /// and its overhanging loose boxes would make the gap heuristic over-split
1172    /// (`f un c t i o n`), so honouring just the spaces reproduces the spacing.
1173    CodeSpaceOnly,
1174    /// Split on the inter-glyph **gap** (or a space glyph), but never glue — for
1175    /// the parser's code cells: the parser emits no space glyphs (a source space
1176    /// is a positioning gap), and its clean advance boxes make the gap reliable.
1177    /// Unlike [`Grouping::Prose`] there is no punctuation glue, so a real gap
1178    /// always splits (`et al. 2000`, not `et al.2000`) while genuinely touching
1179    /// tokens stay joined (`add(a,` / `b)`).
1180    CodeGap,
1181}
1182
1183/// Group glyphs (document order) into words then lines, the way docling-parse
1184/// does: a new **word** starts where the horizontal gap to the previous glyph
1185/// exceeds ~0.2 × the font height (a real space is ~0.3 × height; letter
1186/// tracking is smaller, so titles don't shatter); a new **line** starts where
1187/// the baseline drops by ~half the font height (a superscript rises without
1188/// dropping, so it stays on its line). Coordinates are flipped to top-left.
1189/// See [`Grouping`] for how each mode decides word boundaries.
1190fn lines_from_glyphs(gs: &[Glyph], page_h: f32, mode: Grouping) -> Vec<TextCell> {
1191    let mut cells: Vec<TextCell> = Vec::new();
1192    let mut words: Vec<String> = Vec::new(); // words on the current line
1193    let mut word = String::new();
1194    // current line bounding box, native
1195    let (mut ll, mut lb, mut lr, mut lt) = (
1196        f32::INFINITY,
1197        f32::INFINITY,
1198        f32::NEG_INFINITY,
1199        f32::NEG_INFINITY,
1200    );
1201    // Tallest glyph seen on the current line: the word-gap threshold is relative
1202    // to it, so a small-font run on the line (a superscript citation) isn't split
1203    // at its tight digit gaps, while a big display title isn't split at its wider
1204    // letter tracking. A real inter-word space is ~0.3× the font height.
1205    let mut line_h: f32 = 0.0;
1206    let mut prev: Option<&Glyph> = None;
1207    // A space glyph between non-space glyphs pins a word split the gap heuristic
1208    // can miss (tight justified spacing); it carries no geometry.
1209    let mut pending_space = false;
1210
1211    for g in gs {
1212        if g.ch == ' ' {
1213            pending_space = true;
1214            continue;
1215        }
1216        let h = (g.t - g.b).abs().max(1.0);
1217        let (mut new_word, mut new_line) = (false, false);
1218        if let Some(p) = prev {
1219            // A new line drops the baseline *and* resets x leftward; requiring the
1220            // x-reset avoids a descending comma/semicolon faking a line break. A
1221            // *large* drop (≥1.5× the line height — a skipped line, e.g. a centered
1222            // page-number footer below a short last word) is always a new line,
1223            // even without the x-reset.
1224            // LTR wraps reset x leftward (`g.l < p.r`); RTL (Arabic) wraps reset
1225            // rightward (the new line begins at the far right). A large drop
1226            // (≥1.5× line height) is a new line regardless of x.
1227            let x_reset = if is_arabic(g.ch) || is_arabic(p.ch) {
1228                g.l > p.r
1229            } else {
1230                g.l < p.r
1231            };
1232            new_line = (p.b - g.b > h * 0.5 && x_reset) || (p.b - g.b > line_h.max(h) * 1.5);
1233            // Don't split before closing punctuation, after opening punctuation, or
1234            // after a period that runs into a digit/lowercase letter — docling
1235            // keeps `engines,` / `[37` / `i.e.` / `98.5` together even across a
1236            // space or gap.
1237            let glued = is_close_punct(g.ch)
1238                || is_open_punct(p.ch)
1239                || (p.ch.is_ascii_digit() && g.ch.is_ascii_digit())
1240                || (p.ch == '.'
1241                    && !pending_space
1242                    && (g.ch.is_ascii_digit() || g.ch.is_ascii_lowercase()));
1243            let word_gap = line_h.max(h) * 0.25;
1244            new_word = if mode == Grouping::CodeSpaceOnly {
1245                new_line || pending_space
1246            } else if mode == Grouping::CodeGap {
1247                // Gap-based, no glue: a real gap always splits, touching tokens join.
1248                new_line || pending_space || g.l - p.r > word_gap
1249            } else if is_arabic(g.ch) || is_arabic(p.ch) {
1250                // RTL runs right-to-left, so the inter-word gap is `p.l - g.r`. A
1251                // real word space has a gap; pdfium also emits spurious zero-gap
1252                // space glyphs inside words (`التي`), so require the gap rather
1253                // than trusting a bare space glyph.
1254                new_line || (p.l - g.r > word_gap && !glued)
1255            } else {
1256                new_line || ((pending_space || g.l - p.r > word_gap) && !glued)
1257            };
1258        }
1259        pending_space = false;
1260        if new_line {
1261            push_word(&mut word, &mut words);
1262            push_line(&mut words, (ll, lb, lr, lt), page_h, &mut cells);
1263            (ll, lb, lr, lt) = (
1264                f32::INFINITY,
1265                f32::INFINITY,
1266                f32::NEG_INFINITY,
1267                f32::NEG_INFINITY,
1268            );
1269            line_h = 0.0;
1270        } else if new_word {
1271            push_word(&mut word, &mut words);
1272        }
1273        word.push(g.ch);
1274        ll = ll.min(g.l);
1275        lb = lb.min(g.b);
1276        lr = lr.max(g.r);
1277        lt = lt.max(g.t);
1278        line_h = line_h.max(h);
1279        prev = Some(g);
1280    }
1281    push_word(&mut word, &mut words);
1282    push_line(&mut words, (ll, lb, lr, lt), page_h, &mut cells);
1283    cells
1284}
1285
1286/// Code line cells from the **parser**'s glyph stream. Unlike pdfium — whose
1287/// monospace listings carry explicit space glyphs (so [`Grouping::CodeSpaceOnly`]
1288/// keeps their spacing) — the parser emits no space glyphs: a source space is a
1289/// positioning gap. So code cells use [`Grouping::CodeGap`], which splits on the
1290/// inter-glyph gap (a space wherever it exceeds ~0.25× the line height) but never
1291/// glues punctuation, so `et al. 2000` keeps its space while `add(a,` / `b)` stay
1292/// joined. The parser's clean advance boxes make the gap heuristic reliable here,
1293/// where pdfium's overhanging loose boxes would over-split (`f un c t i o n`).
1294pub(crate) fn code_cells_from_glyphs(gs: &[Glyph], page_h: f32) -> Vec<TextCell> {
1295    lines_from_glyphs(gs, page_h, Grouping::CodeGap)
1296}
1297
1298/// Per-word cells (each word's text + top-left bbox), using the same word/line
1299/// splitting as [`lines_from_glyphs`] but emitting one cell per word instead of
1300/// joining into lines — the legacy gap-heuristic word grouping, kept for the
1301/// pdfium word path (`DOCLING_PDFIUM_WORDS`). The default parser path uses
1302/// [`crate::dp_lines::word_cells`] instead.
1303pub(crate) fn words_from_glyphs(gs: &[Glyph], page_h: f32) -> Vec<TextCell> {
1304    let mut cells = Vec::new();
1305    let mut word = String::new();
1306    let inf = (
1307        f32::INFINITY,
1308        f32::INFINITY,
1309        f32::NEG_INFINITY,
1310        f32::NEG_INFINITY,
1311    );
1312    let (mut wl, mut wb, mut wr, mut wt) = inf;
1313    let mut line_h: f32 = 0.0;
1314    let mut prev: Option<&Glyph> = None;
1315    let mut pending_space = false;
1316    for g in gs {
1317        if g.ch == ' ' {
1318            pending_space = true;
1319            continue;
1320        }
1321        let h = (g.t - g.b).abs().max(1.0);
1322        let mut new_line = false;
1323        let mut new_word = false;
1324        if let Some(p) = prev {
1325            // LTR wraps reset x leftward (`g.l < p.r`); RTL (Arabic) wraps reset
1326            // rightward (the new line begins at the far right). A large drop
1327            // (≥1.5× line height) is a new line regardless of x.
1328            let x_reset = if is_arabic(g.ch) || is_arabic(p.ch) {
1329                g.l > p.r
1330            } else {
1331                g.l < p.r
1332            };
1333            new_line = (p.b - g.b > h * 0.5 && x_reset) || (p.b - g.b > line_h.max(h) * 1.5);
1334            // No digit-digit glue here (unlike the prose grouping): table cells in
1335            // adjacent columns are numeric and a column gap must still split them
1336            // (`0.965` `0.934`, not `0.9650.934`). Intra-number digits have no gap
1337            // so they stay together regardless.
1338            let glued = is_close_punct(g.ch)
1339                || is_open_punct(p.ch)
1340                || (p.ch == '.'
1341                    && !pending_space
1342                    && (g.ch.is_ascii_digit() || g.ch.is_ascii_lowercase()));
1343            let word_gap = line_h.max(h) * 0.25;
1344            new_word = new_line || ((pending_space || g.l - p.r > word_gap) && !glued);
1345        }
1346        pending_space = false;
1347        if new_word && !word.is_empty() {
1348            cells.push(TextCell {
1349                text: std::mem::take(&mut word),
1350                l: wl,
1351                t: page_h - wt,
1352                r: wr,
1353                b: page_h - wb,
1354            });
1355            (wl, wb, wr, wt) = inf;
1356        }
1357        if new_line {
1358            line_h = 0.0;
1359        }
1360        word.push(g.ch);
1361        wl = wl.min(g.l);
1362        wb = wb.min(g.b);
1363        wr = wr.max(g.r);
1364        wt = wt.max(g.t);
1365        line_h = line_h.max(h);
1366        prev = Some(g);
1367    }
1368    if !word.is_empty() {
1369        cells.push(TextCell {
1370            text: word,
1371            l: wl,
1372            t: page_h - wt,
1373            r: wr,
1374            b: page_h - wb,
1375        });
1376    }
1377    cells
1378}
1379
1380fn is_arabic(c: char) -> bool {
1381    ('\u{0600}'..='\u{06FF}').contains(&c)
1382}
1383
1384fn is_close_punct(c: char) -> bool {
1385    matches!(
1386        c,
1387        ',' | '.' | ';' | '!' | '?' | ')' | ']' | '}' | '%' | '\'' | '\u{2019}' | '\u{2018}'
1388    )
1389}
1390
1391fn is_open_punct(c: char) -> bool {
1392    // `@` glues to what follows (`mAP @0.5`, `bpf@zurich`, `@decorator`).
1393    matches!(c, '(' | '[' | '{' | '@')
1394}
1395
1396fn push_word(word: &mut String, words: &mut Vec<String>) {
1397    if !word.is_empty() {
1398        words.push(std::mem::take(word));
1399    }
1400}
1401
1402fn push_line(
1403    words: &mut Vec<String>,
1404    bbox: (f32, f32, f32, f32),
1405    page_h: f32,
1406    cells: &mut Vec<TextCell>,
1407) {
1408    if words.is_empty() {
1409        return;
1410    }
1411    let text = std::mem::take(words).join(" ");
1412    let (l, b, r, t) = bbox;
1413    cells.push(TextCell {
1414        text,
1415        l,
1416        t: page_h - t,
1417        r,
1418        b: page_h - b,
1419    });
1420}
1421
1422#[cfg(test)]
1423mod tests {
1424    use super::to_display_frame;
1425
1426    /// A 612×792 portrait page displayed under `/Rotate`: a rect near the
1427    /// unrotated top-left lands where a viewer shows it (docling#4008).
1428    #[test]
1429    fn display_frame_follows_the_page_rotation() {
1430        let r = (72.0, 63.0, 387.0, 74.0); // top-left origin, unrotated
1431        assert_eq!(to_display_frame(r, 0, 612.0, 792.0), r);
1432        // 90° clockwise: the page becomes 792×612; the old top edge is the
1433        // display right edge, old left edge the display top.
1434        assert_eq!(
1435            to_display_frame(r, 90, 612.0, 792.0),
1436            (718.0, 72.0, 729.0, 387.0)
1437        );
1438        // 180°: both axes mirror inside the same box.
1439        assert_eq!(
1440            to_display_frame(r, 180, 612.0, 792.0),
1441            (225.0, 718.0, 540.0, 729.0)
1442        );
1443        // 270°: the old top edge is the display left edge, old right edge the
1444        // display top.
1445        assert_eq!(
1446            to_display_frame(r, 270, 612.0, 792.0),
1447            (63.0, 225.0, 74.0, 540.0)
1448        );
1449    }
1450
1451    #[test]
1452    fn display_frame_rotations_compose_to_identity() {
1453        let r = (10.0, 20.0, 110.0, 40.0);
1454        // 90° then 270° from the intermediate (792×612) box round-trips.
1455        let once = to_display_frame(r, 90, 612.0, 792.0);
1456        assert_eq!(to_display_frame(once, 270, 792.0, 612.0), r);
1457        let twice = to_display_frame(to_display_frame(r, 180, 612.0, 792.0), 180, 612.0, 792.0);
1458        assert_eq!(twice, r);
1459    }
1460}