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