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