Skip to main content

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