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