Skip to main content

docling_pdf/
pdfium_backend.rs

1//! The page walk of the PDF pipeline: per page, the geometry and links from
2//! the object model (`pdf_meta`), the text layer from the pure-Rust parser
3//! (`textparse`), the model-input bitmaps from the docling-parse renderer
4//! plugin, the Rust raster or the Rust page renderer — and, behind the
5//! `pdfium` feature, the pdfium library for `DOCLING_RS_RENDERER=pdfium`
6//! (docling's pypdfium2 chain) and for a file lopdf cannot read ([`native`]).
7//! The module keeps its historical name; every type in it is pdfium-free.
8
9#[cfg(feature = "ml")]
10use crate::PdfError;
11#[cfg(feature = "ocr-prep")]
12use image::RgbImage;
13
14/// A run of text with its bounding box, in PDF points with a **top-left** origin
15/// (pdfium's native origin is bottom-left; we flip it to match docling's
16/// `BoundingBox(..., origin=TOPLEFT)`).
17#[derive(Debug, Clone)]
18pub struct TextCell {
19    pub text: String,
20    pub l: f32,
21    pub t: f32,
22    pub r: f32,
23    pub b: f32,
24}
25
26/// Pixels-per-point used to render page images. Layout is scale-invariant (it
27/// scales normalized boxes by the page point size), but OCR benefits from the
28/// extra resolution.
29pub const RENDER_SCALE: f32 = 2.0;
30
31/// One page's geometry, extracted text cells, and a rendered RGB image. The
32/// image is rendered at [`RENDER_SCALE`] pixels per PDF point; `image px =
33/// page point × scale`.
34#[derive(Clone)]
35pub struct PdfPage {
36    pub width: f32,
37    pub height: f32,
38    pub scale: f32,
39    pub cells: Vec<TextCell>,
40    /// Same text grouped for code regions: split only at pdfium space glyphs, so
41    /// monospace runs keep their source spacing instead of the prose heuristic's.
42    pub code_cells: Vec<TextCell>,
43    /// Per-word cells (one per word, not joined into lines) for TableFormer cell
44    /// matching.
45    pub word_cells: Vec<TextCell>,
46    /// Checkbox squares drawn on the page (#609): assembly gives each line
47    /// with one in front of it its own checkbox item. Empty without a text
48    /// layer (OCR pages) or vector paths.
49    pub checkboxes: Vec<crate::checkbox::CheckBox>,
50    /// The rendered page bitmap. Present whenever pixels are available at all
51    /// (`ocr-prep` ⊂ `ml`): the native pipeline renders it with pdfium, the
52    /// browser pipeline receives it from the host canvas. Picture regions are
53    /// cropped out of it.
54    #[cfg(feature = "ocr-prep")]
55    pub image: RgbImage,
56    /// The **scale-1.0** page image the layout model runs on (parity with
57    /// docling's `PyPdfiumDocumentBackend`: the layout stage calls
58    /// `page.get_image(scale=1.0)`, which that backend serves as pdfium at
59    /// 1.5×, PIL-BICUBIC down to point size — a *different* image from the 2×
60    /// OCR/crop bitmap above, and a different resampling regime than
61    /// stretching that bitmap; docling 2.123+'s default docling-parse backend
62    /// renders the same request with its own Blend2D/FreeType renderer, whose
63    /// glyph anti-aliasing differs — #478). `None` on paths without a pdfium
64    /// renderer (browser, METS/TIFF), which fall back to stretching
65    /// [`Self::image`].
66    #[cfg(feature = "ocr-prep")]
67    pub image_layout: Option<RgbImage>,
68    /// The 2x bitmap of a page with a text layer, still rendering on the
69    /// page walk's helper thread (#682): [`Self::image`] holds a 1×1
70    /// placeholder until [`Self::resolve_image`] installs the render, which
71    /// the ML pipeline does right after layout — the stage that reads
72    /// [`Self::image_layout`] instead — so the render overlaps it. `None`
73    /// once resolved, and on every path that renders synchronously (scanned
74    /// pages, the plugin and pdfium renderers, images, METS).
75    #[cfg(feature = "ml")]
76    pub pending_image: Option<PendingImage>,
77    /// Hyperlink annotations on the page (rect in top-left page coords + target
78    /// URI), restricted to web/mail/tel schemes. Used only by strict Markdown.
79    pub links: Vec<LinkAnnot>,
80    /// The page's `/Rotate` value (0/90/180/270) when it was normalized away
81    /// before inference: a scanned page with `/Rotate` displays its raster
82    /// rotated, which turns OCR into garbage — so extraction un-rotates the
83    /// bitmaps (and swaps `width`/`height`) and records the display rotation
84    /// here. Assembly rotates the finished geometry *back* by this many
85    /// degrees clockwise, so emitted locations and the page size stay in
86    /// display space (matching docling and every PDF viewer). Always 0 for
87    /// text-layer pages (their cells live in display space already) and on
88    /// paths without a pdfium renderer.
89    pub rotation: u16,
90}
91
92impl PdfPage {
93    /// A page built from recognized cells alone — the browser pipeline's
94    /// shape (#157), where the bitmap lives on the JS side. Exists so callers
95    /// compile identically with and without the `ml` feature: under a
96    /// feature-unified workspace build the struct carries the `image` field,
97    /// which a plain literal in a non-`ml` consumer can't spell.
98    #[cfg(feature = "ocr-prep")]
99    pub fn from_cells(width: f32, height: f32, scale: f32, cells: Vec<TextCell>) -> Self {
100        Self {
101            width,
102            height,
103            scale,
104            cells,
105            code_cells: Vec::new(),
106            word_cells: Vec::new(),
107            checkboxes: Vec::new(),
108            #[cfg(feature = "ocr-prep")]
109            image: RgbImage::new(0, 0),
110            #[cfg(feature = "ocr-prep")]
111            image_layout: None,
112            #[cfg(feature = "ml")]
113            pending_image: None,
114            links: Vec::new(),
115            rotation: 0,
116        }
117    }
118
119    /// Wait for a deferred 2x render (#682) and install it as
120    /// [`Self::image`]; a no-op when nothing is pending. The ML pipeline
121    /// calls it once layout is done, before anything reads the bitmap.
122    #[cfg(feature = "ml")]
123    pub fn resolve_image(&mut self) -> Result<(), PdfError> {
124        if let Some(pending) = self.pending_image.take() {
125            self.image = crate::timing::timed("render.wait", || pending.wait())?;
126        }
127        Ok(())
128    }
129
130    /// Same as [`from_cells`](Self::from_cells) but carrying the rendered page
131    /// bitmap, so picture regions can be cropped out of it (#157: the browser
132    /// pipeline gets the same figure bytes the native one does).
133    #[cfg(feature = "ocr-prep")]
134    pub fn from_cells_with_image(
135        width: f32,
136        height: f32,
137        scale: f32,
138        cells: Vec<TextCell>,
139        image: RgbImage,
140    ) -> Self {
141        Self {
142            image,
143            ..Self::from_cells(width, height, scale, cells)
144        }
145    }
146
147    /// Un-rotate the page's bitmaps by `deg` (clockwise 90° steps) and record
148    /// the compensating display rotation, composing with any rotation already
149    /// recorded: the raster becomes upright for inference while assembly
150    /// still maps the finished geometry back into display space. Handles both
151    /// `/Rotate` normalization (extraction) and content-detected orientation
152    /// (#225) — the two compose additively (axis-aligned 90° rotations
153    /// commute through the dimension swaps). Link rectangles follow the
154    /// raster; `width`/`height` swap on odd quarter-turns.
155    #[cfg(feature = "ocr-prep")]
156    pub(crate) fn unrotate(&mut self, deg: u16) {
157        if deg == 0 {
158            return;
159        }
160        use image::imageops::{rotate180, rotate270, rotate90};
161        // Display = upright rotated `deg`° clockwise, so upright = display
162        // rotated the complementary amount clockwise.
163        let un = |img: &RgbImage| match deg {
164            90 => rotate270(img),
165            180 => rotate180(img),
166            _ => rotate90(img),
167        };
168        if self.image.width() > 1 {
169            self.image = un(&self.image);
170        }
171        self.image_layout = self.image_layout.as_ref().map(&un);
172        let (width, height) = (self.width, self.height);
173        // Link rects follow the raster from display into upright space (the
174        // inverse of the geometry rotation assembly applies at the end).
175        for l in &mut self.links {
176            let (nl, nt, nr, nb) = match deg {
177                90 => (l.t, width - l.r, l.b, width - l.l),
178                180 => (width - l.r, height - l.b, width - l.l, height - l.t),
179                _ => (height - l.b, l.l, height - l.t, l.r),
180            };
181            (l.l, l.t, l.r, l.b) = (nl, nt, nr, nb);
182        }
183        // Checkbox squares likewise (a scan has none; kept consistent).
184        for c in &mut self.checkboxes {
185            let (nl, nt, nr, nb) = match deg {
186                90 => (c.t, width - c.r, c.b, width - c.l),
187                180 => (width - c.r, height - c.b, width - c.l, height - c.t),
188                _ => (height - c.b, c.l, height - c.t, c.r),
189            };
190            (c.l, c.t, c.r, c.b) = (nl, nt, nr, nb);
191        }
192        if deg != 180 {
193            (self.width, self.height) = (height, width);
194        }
195        self.rotation = (self.rotation + deg) % 360;
196    }
197}
198
199/// A PDF link annotation: its rectangle (top-left page coordinates, matching
200/// [`TextCell`]) and target URI.
201#[derive(Debug, Clone)]
202pub struct LinkAnnot {
203    pub l: f32,
204    pub t: f32,
205    pub r: f32,
206    pub b: f32,
207    pub uri: String,
208}
209
210#[cfg(feature = "ml")]
211/// A parsed PDF: per-page text cells and page images.
212pub struct PdfDocument {
213    pub pages: Vec<PdfPage>,
214}
215
216#[cfg(feature = "ml")]
217impl PdfDocument {
218    /// Parse a PDF from bytes, optionally decrypting with `password`.
219    ///
220    /// Note: this materialises **every** page's rendered bitmap in memory at
221    /// once. For large documents prefer [`for_each_page`], which streams.
222    pub fn open(bytes: &[u8], password: Option<&str>) -> Result<Self, PdfError> {
223        let mut pages = Vec::new();
224        for_each_page::<PdfError, _>(bytes, password, true, true, None, |_, _, page| {
225            pages.push(page);
226            Ok(())
227        })?;
228        Ok(PdfDocument { pages })
229    }
230}
231
232#[cfg(feature = "ml")]
233/// The document's text layer: the pure-Rust text parser (docling-parse's
234/// char geometry; the only text source since phase 4 of "Retiring pdfium" —
235/// on the whole corpus every page it reads no text from is one pdfium read
236/// no text from either, i.e. a scan for the OCR path). `None` for a file
237/// lopdf cannot open at all.
238fn rust_parser_cells(
239    bytes: &[u8],
240    password: Option<&str>,
241) -> Option<crate::textparse::PageTextParser> {
242    // Only the document load happens here; pages are parsed as the walk
243    // reaches them (`cells_timed`), so nothing is decoded for pages outside
244    // a `--pages` window and the parse overlaps the workers' inference.
245    crate::timing::timed("textparse.open", || {
246        crate::textparse::PageTextParser::open_with_password(bytes, password)
247    })
248}
249
250impl crate::textparse::PageTextParser {
251    /// [`cells`](Self::cells) under the `textparse` timing stage (per page).
252    fn cells_timed(&mut self, index: usize) -> crate::textparse::PageParserCells {
253        crate::timing::timed("textparse", || self.cells(index))
254    }
255}
256
257#[cfg(feature = "ml")]
258/// Number of pages in a PDF, without rendering any of them — used to decide
259/// whether a document is worth spinning up the parallel worker pool.
260pub fn page_count(bytes: &[u8], password: Option<&str>) -> Result<usize, PdfError> {
261    // The pure-Rust object model first, then the docling-parse plugin's
262    // count, then pdfium (the `pdfium` feature) — the order every entry point
263    // here follows.
264    if let Some(meta) = crate::timing::timed("meta.open", || {
265        crate::pdf_meta::PdfMeta::open_with_password(bytes, password)
266    })? {
267        return Ok(meta.page_count());
268    }
269    if let Some(dp) = crate::dparse_render::Doc::open_if_enabled(bytes, password) {
270        return Ok(dp.page_count());
271    }
272    crate::timing::timed("pdfium.page_count", || {
273        let lib = native::bind()?;
274        let n = lib.open(bytes, password)?.page_count();
275        Ok(n)
276    })
277}
278
279#[cfg(feature = "ml")]
280/// Render + extract pages one at a time, handing each (owned) [`PdfPage`] to `f`.
281/// Only one page bitmap is resident at a time — a rendered page is ~5 MB, so a
282/// large PDF would otherwise hold gigabytes of bitmaps at once. `f` receives the
283/// zero-based page index and the total page count.
284///
285/// `render_image` controls whether the page bitmap is rasterized at all: layout,
286/// OCR, TableFormer, and picture cropping all need it, but a caller that skips
287/// every one of those (the `no_ocr` fast path) doesn't, and rasterizing +
288/// downsampling a page is by far the most expensive step per page — skipping it
289/// is most of `no_ocr`'s speedup. `PdfPage::image` is a 1×1 placeholder when
290/// `false`; do not read it.
291///
292/// `extract_text` decodes the page's text layer (parser or pdfium cells); pass
293/// `false` when full-page OCR is forced and the cells would be discarded
294/// unread (docling#4061).
295///
296/// `range` restricts the walk to a **0-based inclusive** page window (issue
297/// #80's `--pages`); out-of-window pages are skipped *before* text extraction
298/// and rasterization, so a 3-page window over a 500-page PDF costs three
299/// pages, not five hundred. `f` still receives the absolute page index, so
300/// downstream page numbering refers to the source document.
301///
302/// `E` is the caller's error type; [`PdfError`]s convert into it via `From`.
303pub fn for_each_page<E, F>(
304    bytes: &[u8],
305    password: Option<&str>,
306    render_image: bool,
307    extract_text: bool,
308    range: Option<(usize, usize)>,
309    mut f: F,
310) -> Result<(), E>
311where
312    E: From<PdfError>,
313    F: FnMut(usize, usize, PdfPage) -> Result<(), E>,
314{
315    // The pure-Rust object model: page count, geometry, `/Rotate`, links.
316    // `None` only for a file lopdf cannot read even after the parser's
317    // repairs; pdfium (the `pdfium` feature) then answers for it.
318    let meta = crate::timing::timed("meta.open", || {
319        crate::pdf_meta::PdfMeta::open_with_password(bytes, password)
320    })?;
321    // The docling-parse renderer (#478, `DOCLING_RS_RENDERER=docling-parse`):
322    // the page images the models see come from it when it is asked for.
323    let dparse = if render_image {
324        crate::dparse_render::Doc::open_if_enabled(bytes, password)
325    } else {
326        None
327    };
328    // pdfium, when it has a job (`bind_or_skip`).
329    let pdfium = bind_or_skip(meta.is_some())?;
330    let session = match &pdfium {
331        Some(p) => Some(p.open(bytes, password)?),
332        None => None,
333    };
334    // `extract_text = false` (full-page OCR forced, docling#4061 / 2.122):
335    // the text layer would be cleared unread, so neither the pure-Rust parser
336    // nor pdfium's text page is decoded at all — on vector-dense pages (CAD
337    // drawings as 100k+ path segments) that decode is most of the page cost.
338    let mut rust = if extract_text {
339        rust_parser_cells(bytes, password)
340    } else {
341        None
342    };
343    // pdfium's count when it is loaded (a damaged file can make the two
344    // object models disagree, and pdfium's pages are the ones rendered), the
345    // object model's otherwise.
346    let total = match (&session, &meta) {
347        (Some(s), _) => s.page_count(),
348        (None, Some(m)) => m.page_count(),
349        (None, None) => 0,
350    };
351    // The object model's geometry and links are used whenever it read the same
352    // number of pages pdfium did (or pdfium is absent); a disagreeing file keeps
353    // pdfium's answers throughout.
354    let meta = meta.filter(|m| {
355        session
356            .as_ref()
357            .is_none_or(|s| s.page_count() == m.page_count())
358    });
359    // The pure-Rust renderer (phase 3 of retiring pdfium): the model inputs
360    // of every page the plugin does not render and the Rust raster declines
361    // — the default renderer.
362    let renderer = match (&meta, render_image) {
363        (Some(m), true) => Some(crate::render::Renderer::new(m)),
364        _ => None,
365    };
366    let (first, last) = range.unwrap_or((0, total.saturating_sub(1)));
367    // The 2x bitmap of a page with a text layer renders on a helper thread
368    // (#682, `DeferredRender`) when the pure-Rust renderer is the only one
369    // drawing: the plugin and pdfium hold per-document handles the walk
370    // thread owns, so their pages render in place as before.
371    let defer_meta = match (&meta, &renderer, &dparse, &session) {
372        (Some(m), Some(_), None, None) => Some(m),
373        _ => None,
374    };
375    std::thread::scope(|scope| {
376        let defer = defer_meta.map(|m| {
377            let (tx, rx) = std::sync::mpsc::channel();
378            scope.spawn(move || DeferredRender::serve(m, rx));
379            DeferredRender { tx }
380        });
381        // Index the window directly: iterating pdfium's pages from page 0 and
382        // skipping to `first` would load (and close) every page before the
383        // window — ~0.7 ms each, 1.3 s of pure overhead for a one-page window
384        // over the 1913-page .NET reference.
385        for i in first..=last {
386            if i >= total {
387                break;
388            }
389            let page = match &session {
390                Some(s) => Some(s.page(i)?),
391                None => None,
392            };
393            let geom = match (&meta, &page) {
394                (Some(m), _) => m.geometry(i).ok_or_else(|| no_raster(i as i32))?,
395                (None, Some(p)) => p.geom(),
396                (None, None) => return Err(no_raster(i as i32).into()),
397            };
398            let links = match (&meta, &page) {
399                (Some(m), _) => m.links(i),
400                (None, Some(p)) => p.links(geom.unrotated().1),
401                (None, None) => Vec::new(),
402            };
403            let rc = rust.as_mut().map(|p| p.cells_timed(i));
404            let extracted = extract_page(
405                PageSources {
406                    page: page.as_ref(),
407                    dparse: dparse.as_ref(),
408                    meta: meta.as_ref(),
409                    renderer: renderer.as_ref(),
410                    defer: defer.as_ref(),
411                },
412                geom,
413                links,
414                i as i32,
415                rc,
416                render_image,
417            )?;
418            f(i, total, extracted)?;
419        }
420        // Dropping the job sender ends the helper; the scope joins it.
421        drop(defer);
422        Ok::<(), E>(())
423    })?;
424    // Tearing down the parsed document (hundreds of thousands of lopdf
425    // objects on a long PDF — 250 ms for the 1913-page .NET reference) is
426    // nobody's business but the allocator's: hand it to a detached thread so
427    // the last page's output isn't held up by it. `Arc`, not `Rc`, in the
428    // caches is what makes the parser `Send`.
429    if let Some(parser) = rust {
430        std::thread::spawn(move || crate::timing::timed("textparse.close", || drop(parser)));
431    }
432    Ok(())
433}
434
435/// One rasterized page from [`render_pages`] (#243): the absolute 1-based page
436/// number in the source document, the pixel dimensions, and the PNG bytes.
437#[cfg(feature = "ml")]
438#[derive(Debug, Clone)]
439pub struct RenderedPage {
440    pub page_no: usize,
441    pub width: u32,
442    pub height: u32,
443    pub png: Vec<u8>,
444}
445
446/// Upper bound, in pixels, on a rendered page bitmap's side. A crafted PDF can
447/// declare an enormous `MediaBox` in a few hundred bytes; the page render then
448/// asks pdfium — and `into_rgb8` — to allocate `w * h * 4` bytes. At the
449/// pipeline's 3x supersample a 12000 pt box is 36000x36000 ~ 5 GB: pdfium
450/// returns an opaque internal error at the extreme, and just below it the
451/// `image` crate *panics* (a `TryReserveError`, not a recoverable error) when
452/// the allocation fails. Real pages, even large-format (A0 at 3x ~ 10110 px),
453/// stay well under this cap; it only rejects the implausible, turning an abort
454/// into a clean error. Mirrors `decode_image_limited`'s guard on the
455/// standalone-image path. `DOCLING_RS_MAX_RENDER_PIXELS` overrides it.
456#[cfg(feature = "ml")]
457fn max_render_side() -> u32 {
458    static M: std::sync::OnceLock<u32> = std::sync::OnceLock::new();
459    *M.get_or_init(|| docling_core::env::parse("DOCLING_RS_MAX_RENDER_PIXELS").unwrap_or(15_000))
460}
461
462/// Round float pixel dimensions to the `i32` pdfium wants, rejecting a page
463/// whose bitmap would exceed [`max_render_side`] on either side before either
464/// pdfium or `image` tries to allocate it.
465#[cfg(feature = "ml")]
466fn checked_render_dims(
467    w_px: f64,
468    h_px: f64,
469    page_no: usize,
470) -> Result<(i32, i32), crate::PdfError> {
471    let cap = max_render_side();
472    let w = w_px.round().max(1.0);
473    let h = h_px.round().max(1.0);
474    if w > f64::from(cap) || h > f64::from(cap) {
475        return Err(crate::PdfError::Pdfium(format!(
476            "page {page_no}: render size {w:.0}x{h:.0} px exceeds the {cap}px per-side cap \
477             (raise DOCLING_RS_MAX_RENDER_PIXELS); the page's declared size is implausibly large"
478        )));
479    }
480    Ok((w as i32, h as i32))
481}
482
483#[cfg(feature = "ml")]
484/// Rasterize a PDF's pages to PNG (#243) — the lean path behind serve's
485/// `to=images`: pdfium render only, no text extraction, no models, and only
486/// one page bitmap resident at a time (each is PNG-encoded and dropped before
487/// the next renders). `scale` is pixels per PDF point — 2.0 matches the
488/// pipeline's [`RENDER_SCALE`] (144 dpi). Unlike the pipeline's render there
489/// is no 1.5× supersample + downsample pass: that dance exists only because
490/// TableFormer is pixel-pinned to docling's bitmaps, and nothing downstream
491/// of this output is — a single render is nearly twice as fast.
492///
493/// `range` is a **1-based** inclusive page window (issue #80's `pages`
494/// semantics: the end clamps to the document, a start past the end errors).
495///
496/// pdfium is not thread-safe — callers must serialize this against any other
497/// pdfium use (docling-serve holds its pipeline mutex around this call for
498/// exactly that reason).
499pub fn render_pages(
500    bytes: &[u8],
501    password: Option<&str>,
502    range: Option<(usize, usize)>,
503    scale: f32,
504) -> Result<Vec<RenderedPage>, crate::PdfError> {
505    // The docling-parse renderer plugin renders the display bitmap when it is
506    // asked for (full-resolution bitmap decode: this is a viewer's render,
507    // not a model input); the Rust raster / renderer otherwise, pdfium under
508    // `DOCLING_RS_RENDERER=pdfium` or for a file lopdf cannot read.
509    let dparse = crate::dparse_render::Doc::open_if_enabled(bytes, password);
510    // The object model: the page count and the Rust raster / renderer.
511    let meta = crate::pdf_meta::PdfMeta::open_with_password(bytes, password)?;
512    let pdfium = match &dparse {
513        Some(_) => None,
514        None => bind_or_skip(meta.is_some())?,
515    };
516    let session = match &pdfium {
517        Some(p) => Some(p.open(bytes, password)?),
518        None => None,
519    };
520    let total = match (&session, &dparse, &meta) {
521        (Some(s), _, _) => s.page_count(),
522        (None, Some(dp), _) => dp.page_count(),
523        (None, None, Some(m)) => m.page_count(),
524        (None, None, None) => 0,
525    };
526    let (first, last) = match range {
527        None => (0, total.saturating_sub(1)),
528        Some((first, last)) => {
529            if first == 0 || last < first {
530                return Err(PdfError::Document(format!(
531                    "invalid page range {first}-{last} (pages are 1-based, first <= last)"
532                )));
533            }
534            if first > total {
535                return Err(PdfError::Document(format!(
536                    "page range {first}-{last} is outside the document ({total} page(s))"
537                )));
538            }
539            (first - 1, last.min(total) - 1)
540        }
541    };
542    let renderer = match (&dparse, &meta) {
543        // Page rasters a caller keeps decode every image at full size (the
544        // shim is asked with bitmap hint 0.0 below for the same reason).
545        (None, Some(m)) => Some(crate::render::Renderer::with_bitmap_hint(m, 0.0)),
546        _ => None,
547    };
548    let mut out = Vec::with_capacity(last.saturating_sub(first) + 1);
549    for i in first..=last {
550        if i >= total {
551            break;
552        }
553        // Both renderers apply /Rotate themselves, so the bitmap is the page as
554        // a viewer shows it — no orientation handling needed (the pipeline's
555        // scanned-page un-rotation is an OCR-conformance concern, not a display
556        // one).
557        // The plugin's canvas first (the renderer whenever it resolves), then
558        // the Rust raster of an image-only page (pdfium's bitmap byte for
559        // byte, no library needed) or the Rust page renderer, then pdfium
560        // (`DOCLING_RS_RENDERER=pdfium`, or a file lopdf cannot read).
561        let rust = || {
562            let m = meta.as_ref()?;
563            let g = m.geometry(i)?;
564            let (tw, th) = checked_render_dims(
565                f64::from(g.width * scale),
566                f64::from(g.height * scale),
567                i + 1,
568            )
569            .ok()?;
570            rust_bitmap(
571                Some(m),
572                renderer.as_ref(),
573                session.is_some(),
574                i as i32,
575                tw as u32,
576                th as u32,
577                0.0,
578            )
579        };
580        let bitmap = match (&dparse, &session) {
581            (Some(dp), _) => {
582                crate::timing::timed("dparse.rasterize", || dp.render(i, f64::from(scale), 0.0))
583                    .map_err(PdfError::Document)?
584            }
585            (None, session) => match rust() {
586                Some(img) => img,
587                None => {
588                    let Some(s) = session else {
589                        return Err(no_raster(i as i32));
590                    };
591                    let page = s.page(i)?;
592                    let (pw, ph) = page.size();
593                    let (tw, th) =
594                        checked_render_dims(f64::from(pw * scale), f64::from(ph * scale), i + 1)?;
595                    page.render(tw, th, "pdfium.rasterize")?
596                }
597            },
598        };
599        let mut png = Vec::new();
600        bitmap
601            .write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png)
602            .map_err(|e| PdfError::Document(format!("PNG-encoding page {}: {e}", i + 1)))?;
603        out.push(RenderedPage {
604            page_no: i + 1,
605            width: bitmap.width(),
606            height: bitmap.height(),
607            png,
608        });
609    }
610    Ok(out)
611}
612
613#[cfg(feature = "ml")]
614/// The error for a page that needs a raster when the object model could not
615/// be read and neither the docling-parse renderer plugin nor pdfium is there.
616fn no_raster(index: i32) -> PdfError {
617    PdfError::Document(format!(
618        "page {}: no page renderer — the file's object model could not be read (lopdf), \
619         the docling-parse renderer plugin is not installed (.docling-parse/lib) and \
620         pdfium is not available (the `pdfium` feature + PDFIUM_DYNAMIC_LIB_PATH / \
621         .pdfium/lib)",
622        index + 1
623    ))
624}
625
626/// The pure-Rust bitmap of a page at `width` × `height`: the raster of an
627/// image-only page (`raster::render`, pdfium's bytes) when the page
628/// qualifies, the page renderer (`render`) otherwise — `None` only when the
629/// object model is not loaded, or when `DOCLING_RS_RENDERER=pdfium` asks for
630/// pdfium's render of a page the raster declines *and* pdfium's page is open
631/// (`pdfium_page`; a build or machine without the library degrades to the
632/// Rust renderer, as [`bind_or_skip`] warned).
633#[cfg(feature = "ml")]
634fn rust_bitmap(
635    meta: Option<&crate::pdf_meta::PdfMeta>,
636    renderer: Option<&crate::render::Renderer<'_>>,
637    pdfium_page: bool,
638    index: i32,
639    width: u32,
640    height: u32,
641    bitmap_hint: f64,
642) -> Option<RgbImage> {
643    let meta = meta?;
644    if let Some(img) = crate::timing::timed("raster.render", || {
645        crate::raster::render(meta, index as usize, width, height)
646    }) {
647        return Some(img);
648    }
649    if pdfium_page && crate::dparse_render::choice() == crate::dparse_render::Choice::Pdfium {
650        return None;
651    }
652    let renderer = renderer?;
653    crate::timing::timed("render.page", || {
654        renderer.render_with_hint(index as usize, width, height, bitmap_hint)
655    })
656}
657
658#[cfg(feature = "ml")]
659/// Bind pdfium for a conversion, or decide it can run without it. pdfium has
660/// a job in two cases only: its own render was asked for
661/// (`DOCLING_RS_RENDERER=pdfium`, which only the `pdfium` feature offers), or
662/// the pure-Rust object model could not read the file (`meta_ok` false) — then
663/// pdfium's page tree, geometry and render stand in, and a build without the
664/// feature (or without the library) fails the conversion with the hint.
665/// Otherwise the library is never loaded.
666fn bind_or_skip(meta_ok: bool) -> Result<Option<native::Lib>, PdfError> {
667    if meta_ok && crate::dparse_render::choice() != crate::dparse_render::Choice::Pdfium {
668        return Ok(None);
669    }
670    match native::bind() {
671        Ok(p) => Ok(Some(p)),
672        Err(e) if meta_ok => {
673            // The library was asked for by name and is not there: say so
674            // once, then degrade — the Rust renderer draws every page.
675            static WARNED: std::sync::Once = std::sync::Once::new();
676            WARNED.call_once(|| {
677                eprintln!(
678                    "docling-pdf: DOCLING_RS_RENDERER=pdfium but the pdfium library could not be \
679                     loaded ({e}); rendering with the Rust renderer"
680                );
681            });
682            Ok(None)
683        }
684        Err(e) => Err(e),
685    }
686}
687
688/// The native sources a page may draw on, each optional: pdfium's page handle
689/// (present only when [`bind_or_skip`] opened the library) and the
690/// docling-parse renderer (absent when the plugin is not asked for or no
691/// raster is wanted).
692#[cfg(feature = "ml")]
693#[derive(Clone, Copy)]
694struct PageSources<'a> {
695    page: Option<&'a native::Page<'a>>,
696    dparse: Option<&'a crate::dparse_render::Doc>,
697    /// The object model, for the Rust raster of an image-only page.
698    meta: Option<&'a crate::pdf_meta::PdfMeta>,
699    /// The pure-Rust page renderer over the same object model (its
700    /// per-document caches live for the whole conversion).
701    renderer: Option<&'a crate::render::Renderer<'a>>,
702    /// The helper thread that renders the 2x bitmap of pages with a text
703    /// layer off the walk (#682); `None` when another renderer draws.
704    defer: Option<&'a DeferredRender>,
705}
706
707/// A page's 2x bitmap being rendered on the walk's helper thread (#682):
708/// the result arrives through a one-shot channel. Cloning a page clones the
709/// handle; whichever copy resolves first gets the bitmap.
710#[cfg(feature = "ml")]
711#[derive(Clone)]
712pub struct PendingImage(std::sync::Arc<std::sync::Mutex<Option<PendingReceiver>>>);
713
714/// The one-shot channel a [`PendingImage`] waits on.
715#[cfg(feature = "ml")]
716type PendingReceiver = std::sync::mpsc::Receiver<Result<RgbImage, PdfError>>;
717
718#[cfg(feature = "ml")]
719impl PendingImage {
720    fn wait(self) -> Result<RgbImage, PdfError> {
721        let rx = self.0.lock().unwrap_or_else(|p| p.into_inner()).take();
722        match rx {
723            Some(rx) => rx
724                .recv()
725                .unwrap_or_else(|_| Err(PdfError::Pdfium("page render thread ended early".into()))),
726            None => Err(PdfError::Pdfium("page bitmap resolved twice".into())),
727        }
728    }
729}
730
731#[cfg(feature = "ml")]
732impl std::fmt::Debug for PendingImage {
733    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
734        f.write_str("PendingImage")
735    }
736}
737
738/// One deferred render: the 3x supersample of page `index` at `w` × `h`
739/// with the decode `hint`, box-downscaled to `dw` × `dh` — exactly the
740/// synchronous path's steps, on another thread.
741#[cfg(feature = "ml")]
742struct RenderJob {
743    index: i32,
744    w: u32,
745    h: u32,
746    hint: f64,
747    dw: u32,
748    dh: u32,
749    reply: std::sync::mpsc::SyncSender<Result<RgbImage, PdfError>>,
750}
751
752/// The walk's render helper (#682): a second pure-Rust renderer, with its
753/// own per-document caches, on a scoped thread, fed one [`RenderJob`] per
754/// page with a text layer. Its bitmaps are the ones the walk thread would
755/// have drawn — the same renderer code over the same object model — so the
756/// output is byte-identical; what changes is that the page leaves the walk
757/// as soon as its 1x layout image is drawn and the 2x bitmap lands while
758/// layout runs. The walk's own channel bounds the jobs in flight.
759#[cfg(feature = "ml")]
760struct DeferredRender {
761    tx: std::sync::mpsc::Sender<RenderJob>,
762}
763
764#[cfg(feature = "ml")]
765impl DeferredRender {
766    fn submit(&self, index: i32, w: u32, h: u32, hint: f64, dw: u32, dh: u32) -> PendingImage {
767        let (reply, rx) = std::sync::mpsc::sync_channel(1);
768        // A helper that is gone surfaces as the page's resolve error.
769        let _ = self.tx.send(RenderJob {
770            index,
771            w,
772            h,
773            hint,
774            dw,
775            dh,
776            reply,
777        });
778        PendingImage(std::sync::Arc::new(std::sync::Mutex::new(Some(rx))))
779    }
780
781    /// The helper's loop: runs until the job sender is dropped.
782    fn serve(meta: &crate::pdf_meta::PdfMeta, jobs: std::sync::mpsc::Receiver<RenderJob>) {
783        let renderer = crate::render::Renderer::new(meta);
784        for job in jobs {
785            let out = rust_bitmap(
786                Some(meta),
787                Some(&renderer),
788                false,
789                job.index,
790                job.w,
791                job.h,
792                job.hint,
793            )
794            .map(|big| {
795                crate::timing::timed("image.resize", || fast_downscale(&big, job.dw, job.dh))
796            })
797            .ok_or_else(|| no_raster(job.index));
798            let _ = job.reply.send(out);
799        }
800    }
801}
802
803#[cfg(feature = "ml")]
804fn extract_page(
805    sources: PageSources<'_>,
806    geom: crate::pdf_meta::PageGeom,
807    links: Vec<LinkAnnot>,
808    index: i32,
809    rust_cells: Option<crate::textparse::PageParserCells>,
810    render_image: bool,
811) -> Result<PdfPage, PdfError> {
812    let PageSources {
813        page,
814        dparse,
815        meta,
816        renderer,
817        defer,
818    } = sources;
819    // The page size (and the render) is the *display* frame — `/Rotate`
820    // applied — while every text coordinate (pdfium's own text page, the
821    // pure-Rust parser's MediaBox-based glyphs, link annotation rects) lives
822    // in the unrotated frame (docling#4008, 2.121). Keep the unrotated box
823    // around for the y-flips and bring every rect into the display frame.
824    let width = geom.width;
825    let height = geom.height;
826    let rotation = geom.rotation;
827    let (unrot_w, unrot_h) = geom.unrotated();
828
829    // The text layer: the pure-Rust parser's prose, word and code cells (its
830    // word grouping reproduces docling-parse's, which TableFormer matches
831    // against). A page it reads nothing from is a scanned page for the OCR
832    // path — pdfium's text page is gone (phase 4 of "Retiring pdfium").
833    let rc = rust_cells.unwrap_or_default();
834    let (mut cells, mut code_cells, mut word_cells) = (rc.prose, rc.code, rc.words);
835    let mut checkboxes = rc.checkboxes;
836    if rotation != 0 {
837        for c in cells
838            .iter_mut()
839            .chain(word_cells.iter_mut())
840            .chain(code_cells.iter_mut())
841        {
842            let (l, t, r, b) = to_display_frame((c.l, c.t, c.r, c.b), rotation, unrot_w, unrot_h);
843            (c.l, c.t, c.r, c.b) = (l, t, r, b);
844        }
845        for c in &mut checkboxes {
846            let (l, t, r, b) = to_display_frame((c.l, c.t, c.r, c.b), rotation, unrot_w, unrot_h);
847            (c.l, c.t, c.r, c.b) = (l, t, r, b);
848        }
849    }
850
851    // The docling-parse renderer (#478, `dparse_render.rs`): the model inputs
852    // come from docling-parse's Blend2D canvas, requested in docling's order
853    // and with its decode hint — the scale-1.0 layout image (docling decodes
854    // the page once, at its `render_scale` of 1.0), then the scale-2.0 bitmap
855    // TableFormer crops from (`_render_image_at_scale` on the same decoder,
856    // hence the same hint). One deliberate exception: a *scanned* page — no
857    // text layer, the OCR path — keeps pdfium's bitmap. docling's 2.0/3.0
858    // re-renders draw the raster its decoders reduced to 72 dpi, and Blend2D's
859    // blit of a scan differs from pdfium's render + downscale in a way the
860    // `ch` conformance recognizer feels (`scanned/ocr_test.pdf` read `JsON` for
861    // `JSON` from either docling-parse raster, hint 1.0 or full resolution);
862    // pdfium's raster is the one the scanned groundtruth was matched with, so
863    // OCR keeps it (recorded in PDF_CONFORMANCE.md) — as long as pdfium is
864    // loaded: a checkout with the plugin and no `libpdfium` OCRs the
865    // docling-parse raster rather than failing the page (degradation over
866    // failure). The canvases are `ceil`-sized where pdfium's are `round`ed;
867    // every consumer maps points through `RENDER_SCALE`, not through the
868    // image size, so the extra row/column is harmless.
869    let scanned = cells.is_empty() && word_cells.is_empty() && code_cells.is_empty();
870    let mut pending_image = None;
871    let (mut dp_image, mut dp_layout) = (None, None);
872    if let (true, Some(dp)) = (render_image, dparse) {
873        let layout = crate::timing::timed("dparse.render_layout", || {
874            dp.render(index as usize, 1.0, 1.0)
875        })
876        .map_err(PdfError::Document)?;
877        if !scanned {
878            let full = crate::timing::timed("dparse.render", || {
879                dp.render(index as usize, f64::from(RENDER_SCALE), 1.0)
880            })
881            .map_err(PdfError::Document)?;
882            dp_image = Some(full);
883        }
884        dp.release_page(index as usize);
885        dp_layout = Some(layout);
886    }
887    // A scanned page's bitmap, in order: the pure-Rust raster (pdfium's bytes;
888    // `raster`), pdfium itself, and — with neither — the plugin's scale-2.0
889    // canvas rather than a failed page.
890    let scanned_fallback = |page: Option<&native::Page<'_>>| match (page, dparse) {
891        (None, Some(dp)) if scanned => {
892            let full = crate::timing::timed("dparse.render", || {
893                dp.render(index as usize, f64::from(RENDER_SCALE), 1.0)
894            })
895            .map_err(PdfError::Document)?;
896            dp.release_page(index as usize);
897            Ok(Some(full))
898        }
899        _ => Ok::<_, PdfError>(None),
900    };
901    let image = if let Some(img) = dp_image.take() {
902        img
903    } else if render_image {
904        // docling's pypdfium2 backend renders at 1.5× the target scale and
905        // downsamples "to make it sharper" (pypdfium2 → PIL BICUBIC). Replicate
906        // exactly: the TableFormer model is pixel-sensitive, so the page bitmap
907        // must match that backend's byte-for-byte (docling 2.123+'s default
908        // docling-parse backend renders it itself — #478).
909        // `CatmullRom` is the same a=-0.5 cubic kernel as PIL's BICUBIC.
910        const SUPERSAMPLE: f32 = 1.5;
911        // The 3x supersample is the largest bitmap the pipeline renders, so the
912        // per-side cap is enforced here; the 1.5x layout render below is always
913        // smaller and needs no separate guard.
914        let (tw, th) = checked_render_dims(
915            f64::from(width * RENDER_SCALE * SUPERSAMPLE),
916            f64::from(height * RENDER_SCALE * SUPERSAMPLE),
917            (index + 1) as usize,
918        )?;
919        // An image-only page (a scan) is rendered by the pure-Rust raster —
920        // pdfium's bitmap byte for byte (`raster`, its oracle test) — and any
921        // other page by the pure-Rust renderer (`render`), so the OCR path
922        // needs no `libpdfium`; pdfium renders only when asked for
923        // (`DOCLING_RS_RENDERER=pdfium`).
924        let dw = (width * RENDER_SCALE).round().max(1.0) as u32;
925        let dh = (height * RENDER_SCALE).round().max(1.0) as u32;
926        // A page without a text layer takes this bitmap to OCR: decode its
927        // images at full size (hint 0.0); a digital page's TableFormer input
928        // follows docling's hint 1.0 like the layout image below.
929        let hint = if cells.is_empty() { 0.0 } else { 1.0 };
930        // A page with a text layer needs this bitmap only after layout
931        // (TableFormer, crops): the helper thread draws it meanwhile (#682).
932        // A scanned page reads it first (orientation, OCR) and renders here.
933        if let (Some(defer), false) = (defer, scanned) {
934            pending_image = Some(defer.submit(index, tw as u32, th as u32, hint, dw, dh));
935            RgbImage::new(1, 1)
936        } else {
937            let big = match rust_bitmap(
938                meta,
939                renderer,
940                page.is_some(),
941                index,
942                tw as u32,
943                th as u32,
944                hint,
945            ) {
946                Some(img) => Some(img),
947                None if page.is_some() => {
948                    let page = page.ok_or_else(|| no_raster(index))?;
949                    Some(page.render(tw, th, "pdfium.render")?)
950                }
951                None => None,
952            };
953            match (big, scanned_fallback(page)?) {
954                (Some(big), _) => {
955                    crate::timing::timed("image.resize", || fast_downscale(&big, dw, dh))
956                }
957                (None, Some(canvas)) => canvas,
958                (None, None) => return Err(no_raster(index)),
959            }
960        }
961    } else {
962        RgbImage::new(1, 1)
963    };
964    // The layout model's input image, built exactly like docling's pypdfium2
965    // `get_page_image(scale=1.0)`: a pdfium render at 1.5× (pypdfium2 sizes
966    // with `ceil`), PIL-BICUBIC down to the point-size image (PIL `resize`'s
967    // default kernel; Python `round` = ties-to-even). Distinct from the 2×
968    // bitmap above — resampling 1224→640 and 612→640 are different regimes,
969    // and the heron model's borderline scores follow the pixels. "Exactly" is
970    // against the pypdfium2 backend: docling 2.123+'s default docling-parse
971    // backend renders this image with its own renderer (#478, see
972    // docs/PDF_CONFORMANCE.md).
973    let image_layout = if let Some(img) = dp_layout.take() {
974        Some(img)
975    } else if render_image {
976        let tw = f64::from(width * 1.5).ceil().max(1.0) as i32;
977        let th = f64::from(height * 1.5).ceil().max(1.0) as i32;
978        let big = match rust_bitmap(
979            meta,
980            renderer,
981            page.is_some(),
982            index,
983            tw as u32,
984            th as u32,
985            1.0,
986        ) {
987            Some(img) => img,
988            None => {
989                let page = page.ok_or_else(|| no_raster(index))?;
990                page.render(tw, th, "pdfium.render_layout")?
991            }
992        };
993        let dw = f64::from(width).round_ties_even().max(1.0) as u32;
994        let dh = f64::from(height).round_ties_even().max(1.0) as u32;
995        Some(crate::timing::timed("image.resize_layout", || {
996            crate::resample::pil_resize(&big, dw, dh, crate::resample::PilFilter::Bicubic)
997        }))
998    } else {
999        None
1000    };
1001
1002    let mut links = links;
1003    if rotation != 0 {
1004        for l in &mut links {
1005            let (a, t, r, b) = to_display_frame((l.l, l.t, l.r, l.b), rotation, unrot_w, unrot_h);
1006            (l.l, l.t, l.r, l.b) = (a, t, r, b);
1007        }
1008    }
1009
1010    // `/Rotate` normalization for scanned pages: pdfium renders the page as a
1011    // viewer displays it — `/Rotate` applied — so a rotated scan hands layout
1012    // and OCR a sideways/upside-down raster and the recognition output is
1013    // garbage. A page with a text layer needs none of this (its cells carry
1014    // the geometry; the models never see its pixels decide text), so the
1015    // normalization is gated to pages with no cells at all — exactly the set
1016    // the OCR path fires on. The bitmaps are un-rotated to upright (lossless
1017    // 90° steps), `width`/`height` swap to the upright box, and the display
1018    // rotation is recorded so assembly can rotate the finished geometry back
1019    // into display space (docling reports rotated pages in display coords).
1020    let mut page = PdfPage {
1021        width,
1022        height,
1023        scale: RENDER_SCALE,
1024        image_layout,
1025        pending_image,
1026        cells,
1027        code_cells,
1028        word_cells,
1029        checkboxes,
1030        image,
1031        links,
1032        rotation: 0,
1033    };
1034    if rotation != 0 && scanned && render_image {
1035        page.unrotate(rotation);
1036    }
1037    Ok(page)
1038}
1039
1040#[cfg(feature = "ml")]
1041/// The supersample→target downscale via `fast_image_resize` (SIMD convolution;
1042/// the same a=-0.5 Catmull-Rom kernel as `image::imageops::resize(...,
1043/// CatmullRom)` and PIL BICUBIC — see the render comment above). Set
1044/// `DOCLING_RS_SLOW_RESIZE=1` to fall back to the `image`-crate scalar resize
1045/// (byte-parity with the pre-SIMD pipeline, several times slower).
1046fn fast_downscale(big: &RgbImage, dw: u32, dh: u32) -> RgbImage {
1047    use fast_image_resize as fir;
1048    static SLOW: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
1049    let slow = *SLOW.get_or_init(|| docling_core::env::flag("DOCLING_RS_SLOW_RESIZE"));
1050    if !slow {
1051        if let Some(out) = (|| {
1052            let src = fir::images::ImageRef::new(
1053                big.width(),
1054                big.height(),
1055                big.as_raw(),
1056                fir::PixelType::U8x3,
1057            )
1058            .ok()?;
1059            let mut dst = fir::images::Image::new(dw, dh, fir::PixelType::U8x3);
1060            fir::Resizer::new()
1061                .resize(
1062                    &src,
1063                    &mut dst,
1064                    &fir::ResizeOptions::new()
1065                        .resize_alg(fir::ResizeAlg::Convolution(fir::FilterType::CatmullRom)),
1066                )
1067                .ok()?;
1068            RgbImage::from_raw(dw, dh, dst.into_vec())
1069        })() {
1070            return out;
1071        }
1072        // Unreachable in practice; fall through to the scalar path on any error.
1073    }
1074    image::imageops::resize(big, dw, dh, image::imageops::FilterType::CatmullRom)
1075}
1076
1077/// Map a top-left-origin rect from a page's unrotated (MediaBox) frame into its
1078/// `/Rotate`d display frame — the counterpart of docling's pypdfium2
1079/// `_rect_to_display_frame` (docling#4008) for our y-down coordinates.
1080/// `unrot_w`/`unrot_h` are the unrotated page box; the display box is the same
1081/// for 180° and swapped for 90°/270°.
1082pub(crate) fn to_display_frame(
1083    (l, t, r, b): (f32, f32, f32, f32),
1084    rotation: u16,
1085    unrot_w: f32,
1086    unrot_h: f32,
1087) -> (f32, f32, f32, f32) {
1088    match rotation {
1089        // Page turned 90° clockwise for display: the unrotated top edge becomes
1090        // the display right edge, so x' runs from the old bottom edge up.
1091        90 => (unrot_h - b, l, unrot_h - t, r),
1092        180 => (unrot_w - r, unrot_h - b, unrot_w - l, unrot_h - t),
1093        270 => (t, unrot_w - r, b, unrot_w - l),
1094        _ => (l, t, r, b),
1095    }
1096}
1097
1098/// One glyph: codepoint + native (y-up) box edges. `l/b/r/t` is pdfium's *tight*
1099/// ink box (used by the legacy `lines_from_glyphs`); `ll/lb/lr/lt` is the *loose*
1100/// box (font ascent/descent + advance — uniform per font/size), which the
1101/// docling-parse-style sanitizer needs so adjacent glyphs share a top edge.
1102pub(crate) struct Glyph {
1103    pub(crate) ch: char,
1104    pub(crate) l: f32,
1105    pub(crate) b: f32,
1106    pub(crate) r: f32,
1107    pub(crate) t: f32,
1108    pub(crate) ll: f32,
1109    pub(crate) lb: f32,
1110    pub(crate) lr: f32,
1111    pub(crate) lt: f32,
1112    /// Hash of the PDF font name + flags (0 when not fetched). The sanitizer uses
1113    /// it for docling-parse's `enforce_same_font` (keeps a bold label and regular
1114    /// value as separate line cells, e.g. `LABEL : value`).
1115    pub(crate) font: u64,
1116    /// The loose box as the text matrix actually lays it — corners
1117    /// bottom-left, bottom-right, top-right, top-left as `[x0, y0, … x3, y3]`
1118    /// (docling-parse's `r_x0…r_y3`) — for a glyph whose baseline is **not**
1119    /// upright: rotated 90°/180°/270° or tilted (#528). `ll/lb/lr/lt` are then
1120    /// this quad's axis-aligned extent. `None` for upright text, whose quad is
1121    /// the `ll/lb/lr/lt` rectangle itself — that path stays exactly as it was.
1122    pub(crate) quad: Option<[f32; 8]>,
1123}
1124
1125impl Glyph {
1126    /// The font's height on the glyph's own axis (ascent − descent): the
1127    /// loose box's height for upright text, the quad's left edge otherwise —
1128    /// a 90°-turned glyph's vertical extent is its advance, not its size.
1129    pub(crate) fn height(&self) -> f32 {
1130        match self.quad {
1131            Some(q) => (q[6] - q[0]).hypot(q[7] - q[1]),
1132            None => self.lt - self.lb,
1133        }
1134    }
1135}
1136
1137/// How [`lines_from_glyphs`] splits a line into words. Two more modes lived
1138/// here — the prose gap heuristic with punctuation glue and pdfium's
1139/// space-glyph-only code split — for pdfium's glyph stream; the parser's
1140/// prose goes through `dp_lines` and pdfium's text page is gone.
1141#[derive(Clone, Copy, PartialEq)]
1142enum Grouping {
1143    /// Split on the inter-glyph **gap** (or a space glyph), but never glue — for
1144    /// the parser's code cells: the parser emits no space glyphs (a source space
1145    /// is a positioning gap), and its clean advance boxes make the gap reliable.
1146    /// There is no punctuation glue, so a real gap always splits (`et al.
1147    /// 2000`, not `et al.2000`) while genuinely touching tokens stay joined
1148    /// (`add(a,` / `b)`).
1149    CodeGap,
1150}
1151
1152/// Group glyphs (document order) into words then lines, the way docling-parse
1153/// does: a new **word** starts where the horizontal gap to the previous glyph
1154/// exceeds ~0.2 × the font height (a real space is ~0.3 × height; letter
1155/// tracking is smaller, so titles don't shatter); a new **line** starts where
1156/// the baseline drops by ~half the font height (a superscript rises without
1157/// dropping, so it stays on its line). Coordinates are flipped to top-left.
1158/// See [`Grouping`] for how each mode decides word boundaries.
1159fn lines_from_glyphs(gs: &[Glyph], page_h: f32, mode: Grouping) -> Vec<TextCell> {
1160    let mut cells: Vec<TextCell> = Vec::new();
1161    let mut words: Vec<String> = Vec::new(); // words on the current line
1162    let mut word = String::new();
1163    // current line bounding box, native
1164    let (mut ll, mut lb, mut lr, mut lt) = (
1165        f32::INFINITY,
1166        f32::INFINITY,
1167        f32::NEG_INFINITY,
1168        f32::NEG_INFINITY,
1169    );
1170    // Tallest glyph seen on the current line: the word-gap threshold is relative
1171    // to it, so a small-font run on the line (a superscript citation) isn't split
1172    // at its tight digit gaps, while a big display title isn't split at its wider
1173    // letter tracking. A real inter-word space is ~0.3× the font height.
1174    let mut line_h: f32 = 0.0;
1175    let mut prev: Option<&Glyph> = None;
1176    // A space glyph between non-space glyphs pins a word split the gap heuristic
1177    // can miss (tight justified spacing); it carries no geometry.
1178    let mut pending_space = false;
1179
1180    for g in gs {
1181        if g.ch == ' ' {
1182            pending_space = true;
1183            continue;
1184        }
1185        let h = (g.t - g.b).abs().max(1.0);
1186        let (mut new_word, mut new_line) = (false, false);
1187        if let Some(p) = prev {
1188            // A new line drops the baseline *and* resets x leftward; requiring the
1189            // x-reset avoids a descending comma/semicolon faking a line break. A
1190            // *large* drop (≥1.5× the line height — a skipped line, e.g. a centered
1191            // page-number footer below a short last word) is always a new line,
1192            // even without the x-reset.
1193            // LTR wraps reset x leftward (`g.l < p.r`); RTL (Arabic) wraps reset
1194            // rightward (the new line begins at the far right). A large drop
1195            // (≥1.5× line height) is a new line regardless of x.
1196            let x_reset = if is_arabic(g.ch) || is_arabic(p.ch) {
1197                g.l > p.r
1198            } else {
1199                g.l < p.r
1200            };
1201            new_line = (p.b - g.b > h * 0.5 && x_reset) || (p.b - g.b > line_h.max(h) * 1.5);
1202            let word_gap = line_h.max(h) * 0.25;
1203            new_word = match mode {
1204                // Gap-based, no glue: a real gap always splits, touching tokens join.
1205                Grouping::CodeGap => new_line || pending_space || g.l - p.r > word_gap,
1206            };
1207        }
1208        pending_space = false;
1209        if new_line {
1210            push_word(&mut word, &mut words);
1211            push_line(&mut words, (ll, lb, lr, lt), page_h, &mut cells);
1212            (ll, lb, lr, lt) = (
1213                f32::INFINITY,
1214                f32::INFINITY,
1215                f32::NEG_INFINITY,
1216                f32::NEG_INFINITY,
1217            );
1218            line_h = 0.0;
1219        } else if new_word {
1220            push_word(&mut word, &mut words);
1221        }
1222        word.push(g.ch);
1223        ll = ll.min(g.l);
1224        lb = lb.min(g.b);
1225        lr = lr.max(g.r);
1226        lt = lt.max(g.t);
1227        line_h = line_h.max(h);
1228        prev = Some(g);
1229    }
1230    push_word(&mut word, &mut words);
1231    push_line(&mut words, (ll, lb, lr, lt), page_h, &mut cells);
1232    cells
1233}
1234
1235/// Code line cells from the parser's glyph stream. The parser emits no space
1236/// glyphs — a source space is a positioning gap — so code cells use
1237/// [`Grouping::CodeGap`], which splits on the inter-glyph gap (a space
1238/// wherever it exceeds ~0.25× the line height) but never glues punctuation,
1239/// so `et al. 2000` keeps its space while `add(a,` / `b)` stay joined. The
1240/// parser's clean advance boxes make the gap heuristic reliable here, where
1241/// pdfium's overhanging loose boxes used to over-split (`f un c t i o n`).
1242pub(crate) fn code_cells_from_glyphs(gs: &[Glyph], page_h: f32) -> Vec<TextCell> {
1243    lines_from_glyphs(gs, page_h, Grouping::CodeGap)
1244}
1245
1246fn is_arabic(c: char) -> bool {
1247    ('\u{0600}'..='\u{06FF}').contains(&c)
1248}
1249
1250fn push_word(word: &mut String, words: &mut Vec<String>) {
1251    if !word.is_empty() {
1252        words.push(std::mem::take(word));
1253    }
1254}
1255
1256fn push_line(
1257    words: &mut Vec<String>,
1258    bbox: (f32, f32, f32, f32),
1259    page_h: f32,
1260    cells: &mut Vec<TextCell>,
1261) {
1262    if words.is_empty() {
1263        return;
1264    }
1265    let text = std::mem::take(words).join(" ");
1266    let (l, b, r, t) = bbox;
1267    cells.push(TextCell {
1268        text,
1269        l,
1270        t: page_h - t,
1271        r,
1272        b: page_h - b,
1273    });
1274}
1275
1276/// The pdfium library, behind the `pdfium` feature (phase 5 of "Retiring
1277/// pdfium"): the render `DOCLING_RS_RENDERER=pdfium` asks for — docling's
1278/// pypdfium2 chain — the last resort for a file lopdf cannot read, and the
1279/// oracle of the raster and object-model tests. The default build has the
1280/// stub below and never links or loads pdfium.
1281#[cfg(feature = "pdfium")]
1282mod native {
1283    use super::LinkAnnot;
1284    use crate::PdfError;
1285    use image::RgbImage;
1286    use pdfium_render::prelude::*;
1287
1288    /// The bound library.
1289    pub(super) struct Lib(Pdfium);
1290    /// An open document.
1291    pub(super) struct Session<'a> {
1292        doc: PdfDocument<'a>,
1293    }
1294    /// A loaded page.
1295    pub(super) struct Page<'a>(PdfPage<'a>);
1296
1297    /// Try binding pdfium from a directory (or a literal library file path):
1298    /// `<dir>/<platform library name>` first, else `<dir>` itself as the file.
1299    fn try_bind_dir(path: &str) -> Option<Box<dyn PdfiumLibraryBindings>> {
1300        let name = Pdfium::pdfium_platform_library_name_at_path(path);
1301        if let Ok(b) = Pdfium::bind_to_library(&name) {
1302            return Some(b);
1303        }
1304        Pdfium::bind_to_library(path).ok()
1305    }
1306
1307    /// Bind to the pdfium dynamic library. Honors `PDFIUM_DYNAMIC_LIB_PATH` (a
1308    /// directory or file) first; else falls back to `.pdfium/lib` relative to
1309    /// the current directory (the layout `scripts/install/pdf_setup.sh`
1310    /// produces); else the system library.
1311    pub(super) fn bind() -> Result<Lib, PdfError> {
1312        if let Some(path) = docling_core::env::nonempty("PDFIUM_DYNAMIC_LIB_PATH") {
1313            if let Some(b) = try_bind_dir(&path) {
1314                return Ok(Lib(Pdfium::new(b)));
1315            }
1316        }
1317        if let Some(b) = try_bind_dir(&crate::resolve_asset(".pdfium/lib")) {
1318            return Ok(Lib(Pdfium::new(b)));
1319        }
1320        Pdfium::bind_to_system_library()
1321            .map(|b| Lib(Pdfium::new(b)))
1322            .map_err(Into::into)
1323    }
1324
1325    /// `bind()` for unit tests: point `PDFIUM_DYNAMIC_LIB_PATH` at the
1326    /// repo-root `.pdfium/lib` first (tests run with CWD = the crate dir,
1327    /// where the CWD-relative default cannot see it).
1328    #[cfg(test)]
1329    pub(crate) fn bind_for_tests() -> Result<Pdfium, PdfError> {
1330        if std::env::var_os("PDFIUM_DYNAMIC_LIB_PATH").is_none() {
1331            let lib = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../.pdfium/lib");
1332            if lib.is_dir() {
1333                std::env::set_var("PDFIUM_DYNAMIC_LIB_PATH", &lib);
1334            }
1335        }
1336        bind().map(|l| l.0)
1337    }
1338
1339    impl Lib {
1340        pub(super) fn open<'a>(
1341            &'a self,
1342            bytes: &'a [u8],
1343            password: Option<&str>,
1344        ) -> Result<Session<'a>, PdfError> {
1345            crate::timing::timed("pdfium.open", || {
1346                self.0
1347                    .load_pdf_from_byte_slice(bytes, password)
1348                    .map(|doc| Session { doc })
1349                    .map_err(Into::into)
1350            })
1351        }
1352    }
1353
1354    impl Session<'_> {
1355        pub(super) fn page_count(&self) -> usize {
1356            self.doc.pages().len() as usize
1357        }
1358
1359        pub(super) fn page(&self, index: usize) -> Result<Page<'_>, PdfError> {
1360            self.doc
1361                .pages()
1362                .get(index as PdfPageIndex)
1363                .map(Page)
1364                .map_err(Into::into)
1365        }
1366    }
1367
1368    impl Page<'_> {
1369        /// The display size in points.
1370        pub(super) fn size(&self) -> (f32, f32) {
1371            (self.0.width().value, self.0.height().value)
1372        }
1373
1374        /// pdfium's view of the page's geometry, for the files lopdf cannot
1375        /// read — the same numbers `pdf_meta` computes from the object model.
1376        pub(super) fn geom(&self) -> crate::pdf_meta::PageGeom {
1377            crate::pdf_meta::PageGeom {
1378                width: self.0.width().value,
1379                height: self.0.height().value,
1380                rotation: match self.0.rotation() {
1381                    Ok(PdfPageRenderRotation::Degrees90) => 90u16,
1382                    Ok(PdfPageRenderRotation::Degrees180) => 180,
1383                    Ok(PdfPageRenderRotation::Degrees270) => 270,
1384                    _ => 0,
1385                },
1386            }
1387        }
1388
1389        /// Render the page into a `w` × `h` RGB bitmap under timing `stage`.
1390        pub(super) fn render(
1391            &self,
1392            w: i32,
1393            h: i32,
1394            stage: &'static str,
1395        ) -> Result<RgbImage, PdfError> {
1396            let cfg = PdfRenderConfig::new()
1397                .set_target_width(w)
1398                .set_target_height(h);
1399            crate::timing::timed(stage, || {
1400                self.0
1401                    .render_with_config(&cfg)
1402                    .map(|b| b.as_image().into_rgb8())
1403                    .map_err(Into::into)
1404            })
1405        }
1406
1407        /// Collect web/mail/tel hyperlink annotations on the page, mapping
1408        /// each link's rectangle into top-left page coordinates (like
1409        /// [`super::TextCell`]). `file://` and in-document destinations are
1410        /// skipped — only externally meaningful targets are rendered. pdfium
1411        /// occasionally lists a link twice; rects are kept as-is and the
1412        /// caller dedupes by resolved anchor text.
1413        pub(super) fn links(&self, page_h: f32) -> Vec<LinkAnnot> {
1414            let mut out = Vec::new();
1415            for link in self.0.links().iter() {
1416                let Some(uri) = link
1417                    .action()
1418                    .and_then(|a| a.as_uri_action().and_then(|u| u.uri().ok()))
1419                else {
1420                    continue;
1421                };
1422                let scheme_ok = ["http://", "https://", "mailto:", "tel:"]
1423                    .iter()
1424                    .any(|s| uri.starts_with(s));
1425                if !scheme_ok {
1426                    continue;
1427                }
1428                if let Ok(rect) = link.rect() {
1429                    out.push(LinkAnnot {
1430                        l: rect.left().value,
1431                        t: page_h - rect.top().value,
1432                        r: rect.right().value,
1433                        b: page_h - rect.bottom().value,
1434                        uri,
1435                    });
1436                }
1437            }
1438            out
1439        }
1440    }
1441}
1442
1443/// The `pdfium`-less build: pdfium never binds, so every entry point falls
1444/// through to the pure-Rust stack, and a file the object model cannot read
1445/// fails with [`no_raster`]'s hint.
1446#[cfg(all(feature = "ml", not(feature = "pdfium")))]
1447mod native {
1448    use super::LinkAnnot;
1449    use crate::PdfError;
1450    use image::RgbImage;
1451    use std::convert::Infallible;
1452    use std::marker::PhantomData;
1453
1454    pub(super) struct Lib(Infallible);
1455    pub(super) struct Session<'a>(Infallible, PhantomData<&'a ()>);
1456    pub(super) struct Page<'a>(Infallible, PhantomData<&'a ()>);
1457
1458    /// Reached only for a file the pure-Rust object model could not read
1459    /// (every entry point tries it first), so the error is about the file:
1460    /// naming the missing library sent users of a corrupt or non-PDF upload
1461    /// looking for a pdfium install that would not have helped either.
1462    pub(super) fn bind() -> Result<Lib, PdfError> {
1463        Err(PdfError::Document(crate::UNREADABLE.into()))
1464    }
1465
1466    impl Lib {
1467        pub(super) fn open<'a>(
1468            &'a self,
1469            _bytes: &'a [u8],
1470            _password: Option<&str>,
1471        ) -> Result<Session<'a>, PdfError> {
1472            match self.0 {}
1473        }
1474    }
1475
1476    impl Session<'_> {
1477        pub(super) fn page_count(&self) -> usize {
1478            match self.0 {}
1479        }
1480
1481        pub(super) fn page(&self, _index: usize) -> Result<Page<'_>, PdfError> {
1482            match self.0 {}
1483        }
1484    }
1485
1486    impl Page<'_> {
1487        pub(super) fn size(&self) -> (f32, f32) {
1488            match self.0 {}
1489        }
1490
1491        pub(super) fn geom(&self) -> crate::pdf_meta::PageGeom {
1492            match self.0 {}
1493        }
1494
1495        pub(super) fn render(
1496            &self,
1497            _w: i32,
1498            _h: i32,
1499            _stage: &'static str,
1500        ) -> Result<RgbImage, PdfError> {
1501            match self.0 {}
1502        }
1503
1504        pub(super) fn links(&self, _page_h: f32) -> Vec<LinkAnnot> {
1505            match self.0 {}
1506        }
1507    }
1508}
1509
1510#[cfg(all(test, feature = "pdfium"))]
1511pub(crate) use native::bind_for_tests;
1512
1513#[cfg(test)]
1514mod tests {
1515    use super::{checked_render_dims, max_render_side, to_display_frame};
1516
1517    /// The pure-Rust object model must answer exactly what pdfium answers —
1518    /// page count, display size, `/Rotate`, URI links — on every corpus PDF,
1519    /// or a checkout without pdfium would convert differently. Needs the
1520    /// library; skips cleanly without it (CI has no pdfium).
1521    #[test]
1522    #[cfg(feature = "pdfium")]
1523    fn pdf_meta_matches_pdfium_on_the_corpus() {
1524        let Ok(_) = super::bind_for_tests() else {
1525            eprintln!("skipping: pdfium not found");
1526            return;
1527        };
1528        let lib = super::native::bind().unwrap();
1529        let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../..");
1530        let mut files: Vec<std::path::PathBuf> = Vec::new();
1531        for dir in ["tests/data/pdf/sources", "tests/data/scanned/sources"] {
1532            let Ok(rd) = std::fs::read_dir(root.join(dir)) else {
1533                continue;
1534            };
1535            files.extend(
1536                rd.flatten()
1537                    .map(|e| e.path())
1538                    .filter(|p| p.extension().is_some_and(|e| e == "pdf")),
1539            );
1540        }
1541        files.sort();
1542        assert!(!files.is_empty(), "corpus not found");
1543        let mut checked = 0;
1544        for path in files {
1545            let bytes = std::fs::read(&path).unwrap();
1546            let Ok(doc) = lib.open(&bytes, None) else {
1547                continue; // password fixtures etc.
1548            };
1549            let Some(meta) = crate::pdf_meta::PdfMeta::open(&bytes) else {
1550                panic!(
1551                    "{}: lopdf could not read a file pdfium reads",
1552                    path.display()
1553                );
1554            };
1555            assert_eq!(meta.page_count(), doc.page_count(), "{}", path.display());
1556            for i in 0..meta.page_count() {
1557                let page = doc.page(i).unwrap();
1558                let want = page.geom();
1559                let got = meta.geometry(i).unwrap();
1560                assert!(
1561                    (got.width - want.width).abs() < 0.01
1562                        && (got.height - want.height).abs() < 0.01
1563                        && got.rotation == want.rotation,
1564                    "{} p{}: meta {got:?} vs pdfium {want:?}",
1565                    path.display(),
1566                    i + 1
1567                );
1568                let mut want_links = page.links(want.unrotated().1);
1569                let mut got_links = meta.links(i);
1570                let key = |l: &super::LinkAnnot| {
1571                    (
1572                        l.uri.clone(),
1573                        (l.l * 10.0) as i64,
1574                        (l.t * 10.0) as i64,
1575                        (l.r * 10.0) as i64,
1576                        (l.b * 10.0) as i64,
1577                    )
1578                };
1579                want_links.sort_by_key(key);
1580                got_links.sort_by_key(key);
1581                let want_keys: Vec<_> = want_links.iter().map(key).collect();
1582                let got_keys: Vec<_> = got_links.iter().map(key).collect();
1583                assert_eq!(got_keys, want_keys, "{} p{}: links", path.display(), i + 1);
1584                checked += 1;
1585            }
1586        }
1587        eprintln!("pdf_meta checked against pdfium on {checked} pages");
1588    }
1589
1590    /// A page whose declared size renders past the per-side cap is rejected
1591    /// with a recoverable error, before pdfium or `image` allocates the
1592    /// multi-gigabyte bitmap that would otherwise abort the process; a normal
1593    /// page passes through with its dimensions rounded to `i32`.
1594    #[test]
1595    fn oversized_render_is_rejected_not_allocated() {
1596        let cap = f64::from(max_render_side());
1597        // A 12000 pt box at the pipeline's 3x supersample is 36000 px/side.
1598        let huge = checked_render_dims(cap + 1.0, 10.0, 1);
1599        assert!(huge.is_err(), "over-cap width must be rejected");
1600        let tall = checked_render_dims(10.0, cap + 1.0, 7);
1601        assert!(tall.is_err(), "over-cap height must be rejected");
1602        assert!(
1603            tall.unwrap_err().to_string().contains("page 7"),
1604            "the error names the offending page"
1605        );
1606        // A Letter page at 2x supersample (612x792 pt -> 1836x2376 px) is fine.
1607        assert_eq!(
1608            checked_render_dims(1836.4, 2375.6, 1).unwrap(),
1609            (1836, 2376)
1610        );
1611        // Exactly at the cap is allowed; a zero-or-negative size floors to 1.
1612        assert_eq!(
1613            checked_render_dims(cap, cap, 1).unwrap(),
1614            (cap as i32, cap as i32)
1615        );
1616        assert_eq!(checked_render_dims(0.0, 0.0, 1).unwrap(), (1, 1));
1617    }
1618
1619    /// A 612×792 portrait page displayed under `/Rotate`: a rect near the
1620    /// unrotated top-left lands where a viewer shows it (docling#4008).
1621    #[test]
1622    fn display_frame_follows_the_page_rotation() {
1623        let r = (72.0, 63.0, 387.0, 74.0); // top-left origin, unrotated
1624        assert_eq!(to_display_frame(r, 0, 612.0, 792.0), r);
1625        // 90° clockwise: the page becomes 792×612; the old top edge is the
1626        // display right edge, old left edge the display top.
1627        assert_eq!(
1628            to_display_frame(r, 90, 612.0, 792.0),
1629            (718.0, 72.0, 729.0, 387.0)
1630        );
1631        // 180°: both axes mirror inside the same box.
1632        assert_eq!(
1633            to_display_frame(r, 180, 612.0, 792.0),
1634            (225.0, 718.0, 540.0, 729.0)
1635        );
1636        // 270°: the old top edge is the display left edge, old right edge the
1637        // display top.
1638        assert_eq!(
1639            to_display_frame(r, 270, 612.0, 792.0),
1640            (63.0, 225.0, 74.0, 540.0)
1641        );
1642    }
1643
1644    #[test]
1645    fn display_frame_rotations_compose_to_identity() {
1646        let r = (10.0, 20.0, 110.0, 40.0);
1647        // 90° then 270° from the intermediate (792×612) box round-trips.
1648        let once = to_display_frame(r, 90, 612.0, 792.0);
1649        assert_eq!(to_display_frame(once, 270, 792.0, 612.0), r);
1650        let twice = to_display_frame(to_display_frame(r, 180, 612.0, 792.0), 180, 612.0, 792.0);
1651        assert_eq!(twice, r);
1652    }
1653}