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