Skip to main content

docling_pdf/
textparse.rs

1//! Pure-Rust PDF text extraction (replacing pdfium's glyph layer).
2//!
3//! pdfium reports *rendered* glyph boxes, which diverge from docling's
4//! `docling-parse` C++ parser at exactly the points that drive conformance:
5//! generated spaces get a zero-width box, combining diacritics get a real-width
6//! box, and ligature/fraction glyphs land at different x. This module instead
7//! reconstructs each glyph's box from the **font's own advance widths** and the
8//! PDF text/graphics matrices — the same information docling-parse uses — so a
9//! space is as wide as the font says and a combining mark has zero advance.
10//!
11//! The output is the same [`Glyph`] stream pdfium produces (native PDF
12//! coordinates, y-up), fed straight into the existing docling-parse line
13//! sanitizer ([`crate::dp_lines`]). Only the digital text layer is handled here;
14//! pages without one still fall back to OCR upstream.
15
16use std::collections::HashMap;
17use std::sync::Arc;
18
19use lopdf::{Dictionary, Document, Object};
20
21use crate::pdfium_backend::Glyph;
22
23/// Per-document caches for the content-stream interpreter. Fonts are indirect
24/// objects shared by many pages, but were fully re-parsed — ToUnicode CMap
25/// decompression + tokenization, embedded Type1 program scan, width tables —
26/// for **every page and every Form XObject invocation**; decoded form content
27/// streams were likewise re-inflated on every `Do`. Cached per document,
28/// keyed by the referenced object id (fonts also by resource name — the
29/// `/Font` entry a page binds the object to). Inline (non-reference) dicts
30/// are rare and stay uncached.
31#[derive(Default)]
32struct DocCaches {
33    fonts: HashMap<(lopdf::ObjectId, Vec<u8>), Arc<Font>>,
34    forms: HashMap<lopdf::ObjectId, Arc<lopdf::content::Content>>,
35}
36
37/// A 2×3 affine matrix `[a b c d e f]`: maps `(x,y)` → `(a·x+c·y+e, b·x+d·y+f)`.
38#[derive(Clone, Copy)]
39struct Mat {
40    a: f64,
41    b: f64,
42    c: f64,
43    d: f64,
44    e: f64,
45    f: f64,
46}
47
48impl Mat {
49    const ID: Mat = Mat {
50        a: 1.0,
51        b: 0.0,
52        c: 0.0,
53        d: 1.0,
54        e: 0.0,
55        f: 0.0,
56    };
57
58    /// `self ∘ m`: the matrix that applies `self` first, then `m`.
59    fn then(self, m: Mat) -> Mat {
60        Mat {
61            a: self.a * m.a + self.b * m.c,
62            b: self.a * m.b + self.b * m.d,
63            c: self.c * m.a + self.d * m.c,
64            d: self.c * m.b + self.d * m.d,
65            e: self.e * m.a + self.f * m.c + m.e,
66            f: self.e * m.b + self.f * m.d + m.f,
67        }
68    }
69
70    fn apply(self, x: f64, y: f64) -> (f64, f64) {
71        (
72            self.a * x + self.c * y + self.e,
73            self.b * x + self.d * y + self.f,
74        )
75    }
76}
77
78/// A parsed font: how to turn raw string bytes into (unicode, advance) pairs.
79struct Font {
80    /// 2-byte codes (Type0 / Identity-H) vs 1-byte (simple fonts).
81    two_byte: bool,
82    /// code → Unicode string (from ToUnicode; may be multi-char, e.g. ligatures).
83    to_unicode: HashMap<u32, String>,
84    /// code → glyph advance, in 1000-unit glyph space.
85    widths: HashMap<u32, f64>,
86    default_width: f64,
87    /// 1-byte fallback decoding when ToUnicode lacks a code (WinAnsi-ish).
88    simple_encoding: Option<HashMap<u8, char>>,
89    /// code → raw `/Differences` glyph name, for GID-style names (`g115`) that
90    /// have no Unicode mapping. docling-parse emits these verbatim as `/g115`
91    /// (see the redp5110 bulleted list); matching it keeps no text skipped.
92    fallback_names: HashMap<u8, String>,
93    /// code → char from the embedded Type1 font program's own `/Encoding` vector,
94    /// used only as a last resort for glyphs the base encoding leaves unmapped
95    /// (standard TeX math fonts: `λ`, `≤`, …).
96    program_encoding: HashMap<u8, char>,
97    ascent: f64,
98    descent: f64,
99    hash: u64,
100    /// Weight and slant read from the `/BaseFont` name (the heading
101    /// hierarchy's style signal, #302).
102    style: crate::font_style::FontStyle,
103}
104
105impl Font {
106    fn decode_code(&self, code: u32) -> (Option<String>, f64) {
107        let w = self
108            .widths
109            .get(&code)
110            .copied()
111            .unwrap_or(self.default_width);
112        if let Some(s) = self.to_unicode.get(&code) {
113            return (Some(decompose_ligatures(s)), w);
114        }
115        if !self.two_byte {
116            // A GID-style `/Differences` name (no Unicode) overrides the base
117            // encoding, matching docling's verbatim `/g115` fallback.
118            if let Some(name) = self.fallback_names.get(&(code as u8)) {
119                return (Some(format!("/{name}")), w);
120            }
121            if let Some(enc) = &self.simple_encoding {
122                if let Some(&ch) = enc.get(&(code as u8)) {
123                    return (Some(decompose_ligatures(&ch.to_string())), w);
124                }
125            }
126            // Last resort: the embedded Type1 font program's own `/Encoding`
127            // vector (`dup N /glyphname put`). Standard TeX math fonts (CMMI, CMSY,
128            // …) ship no PDF `/Encoding` and no ToUnicode, so a glyph like `λ`
129            // (CMMI code 21 → `/lambda`) or `≤` (CMSY code 20 → `/lessequal`) has
130            // no other mapping and would otherwise be silently dropped. docling
131            // recovers these from the same font program. This only fills codes the
132            // base encoding left unmapped, so it never changes an existing decode.
133            if let Some(&ch) = self.program_encoding.get(&(code as u8)) {
134                return (Some(decompose_ligatures(&ch.to_string())), w);
135            }
136        }
137        (None, w)
138    }
139}
140
141/// Spell out Latin presentation-form ligatures (`fi`→`fi`, `ffi`→`ffi`, …) the way
142/// docling does, so `configuration`/`difficult` don't keep the ligature glyph.
143/// The chars share the ligature's box, so the line sanitizer recomposes them.
144fn decompose_ligatures(s: &str) -> String {
145    if !s.chars().any(|c| ('\u{FB00}'..='\u{FB06}').contains(&c)) {
146        return s.to_string();
147    }
148    s.chars()
149        .map(|c| {
150            match c {
151                '\u{FB00}' => "ff",
152                '\u{FB01}' => "fi",
153                '\u{FB02}' => "fl",
154                '\u{FB03}' => "ffi",
155                '\u{FB04}' => "ffl",
156                '\u{FB05}' => "ft",
157                '\u{FB06}' => "st",
158                _ => return c.to_string(),
159            }
160            .to_string()
161        })
162        .collect()
163}
164
165fn hash_name(name: &[u8]) -> u64 {
166    use std::hash::{Hash, Hasher};
167    let mut h = std::collections::hash_map::DefaultHasher::new();
168    name.hash(&mut h);
169    h.finish()
170}
171
172/// Resolve a possibly-indirect object to a dictionary.
173fn as_dict<'a>(doc: &'a Document, obj: &'a Object) -> Option<&'a Dictionary> {
174    match obj {
175        Object::Dictionary(d) => Some(d),
176        Object::Reference(id) => doc.get_object(*id).ok().and_then(|o| o.as_dict().ok()),
177        _ => None,
178    }
179}
180
181fn deref<'a>(doc: &'a Document, obj: &'a Object) -> Option<&'a Object> {
182    match obj {
183        Object::Reference(id) => doc.get_object(*id).ok(),
184        other => Some(other),
185    }
186}
187
188/// The font identity the line sanitizer's `enforce_same_font` compares —
189/// docling-parse's `page_font::init_font_name`: `/FontDescriptor/FontName`
190/// (the descendant CIDFont's for a Type0), else `/Name`, else `/BaseFont`
191/// (its `base_font`), else the literal `"null"`. The resource key (`/F1`,
192/// `/F2`) is deliberately **not** part of it: a producer that references one
193/// face twice — the same Helvetica under two keys, one re-encoded so a code
194/// maps to the `fi` glyph — draws one word across the two resources, and
195/// docling-parse reads both as the same font. Keyed by resource name (as
196/// this port was until #728) the `fi` ligature, which bridges fonts, glued
197/// itself to the preceding run while the font barrier then cut it off from
198/// its own word (`fi nancial`); docling's `financial` needs the two
199/// resources to compare equal.
200fn sanitizer_font_name(doc: &Document, fdict: &Dictionary, two_byte: bool) -> Vec<u8> {
201    let descriptor_font_name = |owner: &Dictionary| {
202        owner
203            .get(b"FontDescriptor")
204            .ok()
205            .and_then(|o| as_dict(doc, o))
206            .and_then(|fd| fd.get(b"FontName").ok())
207            .and_then(|o| deref(doc, o))
208            .and_then(|o| o.as_name().ok().map(<[u8]>::to_vec))
209    };
210    if let Some(n) = descriptor_font_name(fdict) {
211        return n;
212    }
213    if two_byte {
214        let descendant = fdict
215            .get(b"DescendantFonts")
216            .ok()
217            .and_then(|o| deref(doc, o))
218            .and_then(|o| match o {
219                Object::Array(a) => a.first(),
220                _ => None,
221            })
222            .and_then(|o| as_dict(doc, o));
223        if let Some(n) = descendant.and_then(descriptor_font_name) {
224            return n;
225        }
226    }
227    if let Some(n) = fdict
228        .get(b"Name")
229        .ok()
230        .and_then(|o| deref(doc, o))
231        .and_then(|o| o.as_name().ok())
232    {
233        return n.to_vec();
234    }
235    // docling-parse's `base_font` keeps the subset prefix (`ABCDEF+Helvetica`):
236    // two subsets of one face are two fonts there, and so here.
237    if let Some(n) = fdict.get(b"BaseFont").ok().and_then(|o| o.as_name().ok()) {
238        return n.to_vec();
239    }
240    b"null".to_vec()
241}
242
243/// Parse one font dictionary into a [`Font`].
244fn parse_font(doc: &Document, name: &[u8], fdict: &Dictionary) -> Font {
245    let subtype: &[u8] = fdict
246        .get(b"Subtype")
247        .ok()
248        .and_then(|o| o.as_name().ok())
249        .unwrap_or(&[]);
250    let two_byte = subtype == b"Type0".as_slice();
251
252    let to_unicode = fdict
253        .get(b"ToUnicode")
254        .ok()
255        .and_then(|o| deref(doc, o))
256        .and_then(|o| o.as_stream().ok())
257        .and_then(|s| s.decompressed_content().ok())
258        .map(|data| parse_tounicode(&data))
259        .unwrap_or_default();
260
261    let (mut widths, mut default_width) = if two_byte {
262        cid_widths(doc, fdict)
263    } else {
264        simple_widths(doc, fdict)
265    };
266
267    let simple_encoding = if two_byte {
268        None
269    } else {
270        Some(simple_encoding_table(doc, fdict))
271    };
272
273    // A standard-14 font referenced without an embedded program usually ships
274    // no `/Widths` and no `/FontDescriptor` either (ReportLab's default, #187).
275    // Every advance then resolved to 0, the cells collapsed to zero width, and
276    // the page's whole text layer was silently dropped — while pdfium, with
277    // its built-in metrics, reads the same file fine. Fill the widths from the
278    // built-in Adobe Core 14 AFM tables via the font's own code→char decode
279    // (base encoding + `/Differences`), so an explicit `/Widths` always wins.
280    if !two_byte && widths.is_empty() && default_width == 0.0 {
281        if let Some(std14) = base_font_name(fdict).and_then(|n| crate::std14::widths_for(&n)) {
282            if let Some(enc) = &simple_encoding {
283                for (&code, &ch) in enc {
284                    if let Some(w) = std14.width(ch) {
285                        widths.insert(u32::from(code), w);
286                    }
287                }
288            }
289            // Codes the table misses still advance a typical width instead of
290            // stacking at x=0 (the failure mode this whole branch fixes).
291            default_width = 500.0;
292        }
293    }
294    let fallback_names = if two_byte {
295        HashMap::new()
296    } else {
297        differences_gid_names(doc, fdict)
298    };
299    let program_encoding = if two_byte {
300        HashMap::new()
301    } else {
302        type1_program_encoding(doc, fdict)
303    };
304
305    let (ascent, descent) = font_ascent_descent(doc, fdict, two_byte);
306
307    Font {
308        two_byte,
309        to_unicode,
310        widths,
311        default_width,
312        simple_encoding,
313        fallback_names,
314        program_encoding,
315        ascent,
316        descent,
317        hash: hash_name(&sanitizer_font_name(doc, fdict, two_byte)),
318        style: crate::font_style::parse_font_style(&String::from_utf8_lossy(
319            &base_font_name(fdict).unwrap_or_else(|| name.to_vec()),
320        )),
321    }
322}
323
324/// Collect `/Differences` entries whose glyph name is a GID placeholder
325/// (`g115`, `cid42`, `glyph7`, `index9`) with no Unicode mapping. docling-parse
326/// emits such glyphs as the literal name `/g115`; mapping them here keeps the
327/// text from being silently dropped (subsetted fonts with no ToUnicode). The
328/// GID-name restriction keeps real Adobe glyph names on the normal path so this
329/// never invents garbage on the clean files.
330fn differences_gid_names(doc: &Document, fdict: &Dictionary) -> HashMap<u8, String> {
331    let mut map = HashMap::new();
332    let Some(Object::Dictionary(enc)) = fdict.get(b"Encoding").ok().and_then(|o| deref(doc, o))
333    else {
334        return map;
335    };
336    let Some(Object::Array(diffs)) = enc.get(b"Differences").ok().and_then(|o| deref(doc, o))
337    else {
338        return map;
339    };
340    let mut code = 0u8;
341    for el in diffs {
342        match el {
343            Object::Integer(i) => code = *i as u8,
344            Object::Name(name) => {
345                if glyph_name_to_char(name).is_none() && is_gid_name(name) {
346                    map.insert(code, String::from_utf8_lossy(name).into_owned());
347                }
348                code = code.wrapping_add(1);
349            }
350            _ => {}
351        }
352    }
353    map
354}
355
356/// Parse the embedded Type1 font program's built-in `/Encoding` vector
357/// (`dup <code> /<glyphname> put` entries in the clear-text header before
358/// `eexec`) into `code → char`. This is how docling recovers glyphs from
359/// standard TeX math fonts (CMMI/CMSY/…) that carry no PDF `/Encoding` and no
360/// ToUnicode — e.g. CMMI's `dup 21 /lambda` or CMSY's `dup 20 /lessequal`.
361/// Only `FontFile` (Type1) is parsed; CFF (`FontFile3`) and TrueType
362/// (`FontFile2`) store their encoding in a binary table and are left alone.
363fn type1_program_encoding(doc: &Document, fdict: &Dictionary) -> HashMap<u8, char> {
364    let mut map = HashMap::new();
365    let Some(desc) = fdict
366        .get(b"FontDescriptor")
367        .ok()
368        .and_then(|o| deref(doc, o))
369        .and_then(|o| o.as_dict().ok())
370    else {
371        return map;
372    };
373    let Some(data) = desc
374        .get(b"FontFile")
375        .ok()
376        .and_then(|o| deref(doc, o))
377        .and_then(|o| o.as_stream().ok())
378        .and_then(|s| s.decompressed_content().ok())
379    else {
380        return map;
381    };
382    // The clear-text header (PostScript) ends at `eexec`; the rest is encrypted.
383    let head_end = data
384        .windows(5)
385        .position(|w| w == b"eexec")
386        .unwrap_or(data.len());
387    let head = String::from_utf8_lossy(&data[..head_end]);
388    // Scan for `dup <code> /<name> put` tokens.
389    let toks: Vec<&str> = head.split_whitespace().collect();
390    for w in toks.windows(4) {
391        if w[0] == "dup" && w[3] == "put" {
392            if let (Ok(code), Some(name)) = (w[1].parse::<u32>(), w[2].strip_prefix('/')) {
393                if code <= 255 {
394                    if let Some(ch) = glyph_name_to_char(name.as_bytes()) {
395                        map.insert(code as u8, ch);
396                    }
397                }
398            }
399        }
400    }
401    map
402}
403
404/// A glyph name that is a synthetic placeholder, not a real Adobe name:
405/// `g115`, `cid42`, `glyph7`, `index9`, `G12`, or a short-prefix code name like
406/// `SM590000` (IBM BookMaster). These carry no Unicode meaning, and docling-parse
407/// emits them verbatim (`/SM590000`). `afii####` / `uni####` are real Adobe names
408/// and excluded. The restriction keeps genuine glyph names on the Unicode path.
409fn is_gid_name(name: &[u8]) -> bool {
410    let Ok(s) = std::str::from_utf8(name) else {
411        return false;
412    };
413    if s.starts_with("afii") || s.starts_with("uni") {
414        return false;
415    }
416    for prefix in ["g", "G", "cid", "CID", "glyph", "index"] {
417        if let Some(rest) = s.strip_prefix(prefix) {
418            if !rest.is_empty() && rest.bytes().all(|b| b.is_ascii_digit()) {
419                return true;
420            }
421        }
422    }
423    // Short alpha prefix (≤3 letters) followed by a run of ≥3 digits — synthetic
424    // code names like `SM590000`, distinct from real Adobe names (whole words or
425    // letter+`.suffix` variants).
426    let alpha = s.bytes().take_while(|b| b.is_ascii_alphabetic()).count();
427    let digits = s.len() - alpha;
428    (1..=3).contains(&alpha)
429        && digits >= 3
430        && s.as_bytes()[alpha..].iter().all(|b| b.is_ascii_digit())
431}
432
433fn font_ascent_descent(doc: &Document, fdict: &Dictionary, two_byte: bool) -> (f64, f64) {
434    // For Type0, the descriptor lives on the descendant CIDFont.
435    let descr_owner = if two_byte {
436        fdict
437            .get(b"DescendantFonts")
438            .ok()
439            .and_then(|o| deref(doc, o))
440            .and_then(|o| match o {
441                Object::Array(a) => a.first(),
442                _ => None,
443            })
444            .and_then(|o| as_dict(doc, o))
445    } else {
446        Some(fdict)
447    };
448    let fd = descr_owner
449        .and_then(|d| d.get(b"FontDescriptor").ok())
450        .and_then(|o| as_dict(doc, o));
451    let asc = fd
452        .and_then(|d| d.get(b"Ascent").ok())
453        .and_then(|o| {
454            o.as_float()
455                .ok()
456                .or_else(|| o.as_i64().ok().map(|i| i as f32))
457        })
458        .unwrap_or(750.0) as f64;
459    let desc = fd
460        .and_then(|d| d.get(b"Descent").ok())
461        .and_then(|o| {
462            o.as_float()
463                .ok()
464                .or_else(|| o.as_i64().ok().map(|i| i as f32))
465        })
466        .unwrap_or(-250.0) as f64;
467    // Some subsetted fonts carry a degenerate FontDescriptor (`/Ascent 0
468    // /Descent 0`) — the real metrics live in the font program. That collapses
469    // the loose box to zero height, so the line cells get zero area and the
470    // layout's region/text assignment drops them (2305's References list lost
471    // every prose line, keeping only the URLs). Fall back to typical text metrics
472    // so the box has height.
473    if asc - desc <= 1.0 {
474        return (750.0, -250.0);
475    }
476    (asc, desc)
477}
478
479/// The `/BaseFont` name with any `ABCDEF+` subset prefix stripped (#187).
480fn base_font_name(fdict: &Dictionary) -> Option<Vec<u8>> {
481    let name = fdict.get(b"BaseFont").ok()?.as_name().ok()?;
482    let stripped = match name.iter().position(|&b| b == b'+') {
483        Some(i) if i == 6 => &name[i + 1..],
484        _ => name,
485    };
486    Some(stripped.to_vec())
487}
488
489/// Simple-font widths: `/FirstChar` + `/Widths` array, `/MissingWidth` default.
490fn simple_widths(doc: &Document, fdict: &Dictionary) -> (HashMap<u32, f64>, f64) {
491    let mut map = HashMap::new();
492    let first = fdict
493        .get(b"FirstChar")
494        .ok()
495        .and_then(|o| o.as_i64().ok())
496        .unwrap_or(0) as u32;
497    if let Some(Object::Array(arr)) = fdict.get(b"Widths").ok().and_then(|o| deref(doc, o)) {
498        for (i, w) in arr.iter().enumerate() {
499            if let Some(w) = num(w) {
500                map.insert(first + i as u32, w);
501            }
502        }
503    }
504    let dw = fdict
505        .get(b"FontDescriptor")
506        .ok()
507        .and_then(|o| as_dict(doc, o))
508        .and_then(|d| d.get(b"MissingWidth").ok())
509        .and_then(num)
510        .unwrap_or(0.0);
511    (map, dw)
512}
513
514/// CIDFont widths: the `/W` array on the descendant font (`/DW` default = 1000).
515fn cid_widths(doc: &Document, fdict: &Dictionary) -> (HashMap<u32, f64>, f64) {
516    let mut map = HashMap::new();
517    let Some(desc) = fdict
518        .get(b"DescendantFonts")
519        .ok()
520        .and_then(|o| deref(doc, o))
521        .and_then(|o| match o {
522            Object::Array(a) => a.first(),
523            _ => None,
524        })
525        .and_then(|o| as_dict(doc, o))
526    else {
527        return (map, 1000.0);
528    };
529    let dw = desc.get(b"DW").ok().and_then(num).unwrap_or(1000.0);
530    if let Some(Object::Array(w)) = desc.get(b"W").ok().and_then(|o| deref(doc, o)) {
531        let mut i = 0;
532        while i < w.len() {
533            let c = w.get(i).and_then(num);
534            match (c, w.get(i + 1)) {
535                // `c [w1 w2 ...]`: consecutive CIDs starting at c.
536                (Some(c), Some(Object::Array(list))) => {
537                    for (k, wv) in list.iter().enumerate() {
538                        if let Some(wv) = num(wv) {
539                            map.insert(c as u32 + k as u32, wv);
540                        }
541                    }
542                    i += 2;
543                }
544                // `c_first c_last w`: a run all of width w.
545                (Some(c1), Some(o2)) => {
546                    if let (Some(c2), Some(wv)) = (num(o2), w.get(i + 2).and_then(num)) {
547                        for cid in c1 as u32..=c2 as u32 {
548                            map.insert(cid, wv);
549                        }
550                    }
551                    i += 3;
552                }
553                _ => break,
554            }
555        }
556    }
557    (map, dw)
558}
559
560fn num(o: &Object) -> Option<f64> {
561    match o {
562        Object::Integer(i) => Some(*i as f64),
563        Object::Real(r) => Some(*r as f64),
564        _ => None,
565    }
566}
567
568/// Parse a ToUnicode CMap's `bfchar` / `bfrange` sections into code→string.
569pub(crate) fn parse_tounicode(data: &[u8]) -> HashMap<u32, String> {
570    let text = String::from_utf8_lossy(data);
571    let mut map = HashMap::new();
572    let hex = |s: &str| -> Option<Vec<u16>> {
573        let s = s.trim();
574        if !s.starts_with('<') || !s.ends_with('>') {
575            return None;
576        }
577        let h = &s[1..s.len() - 1];
578        let bytes: Vec<u8> = (0..h.len())
579            .step_by(2)
580            .filter_map(|i| u8::from_str_radix(h.get(i..i + 2)?, 16).ok())
581            .collect();
582        Some(
583            bytes
584                .chunks(2)
585                .map(|c| {
586                    if c.len() == 2 {
587                        u16::from_be_bytes([c[0], c[1]])
588                    } else {
589                        c[0] as u16
590                    }
591                })
592                .collect(),
593        )
594    };
595    let u16s_to_string = |u: &[u16]| String::from_utf16_lossy(u);
596    let code_of = |u: &[u16]| u.iter().fold(0u32, |acc, &x| (acc << 16) | x as u32);
597
598    // Tokenize by structure, not whitespace: CMap hex groups are often written
599    // back-to-back with no separators (`<21><21><0054>`), so scan for `<…>`
600    // groups, `[`/`]` brackets, and bareword keywords.
601    let tokens: Vec<String> = {
602        let bytes = text.as_bytes();
603        let mut toks = Vec::new();
604        let mut i = 0;
605        while i < bytes.len() {
606            let c = bytes[i];
607            if c.is_ascii_whitespace() {
608                i += 1;
609            } else if c == b'<' {
610                let start = i;
611                while i < bytes.len() && bytes[i] != b'>' {
612                    i += 1;
613                }
614                i += 1; // include '>'
615                toks.push(String::from_utf8_lossy(&bytes[start..i.min(bytes.len())]).into_owned());
616            } else if c == b'[' || c == b']' {
617                toks.push((c as char).to_string());
618                i += 1;
619            } else {
620                let start = i;
621                while i < bytes.len()
622                    && !bytes[i].is_ascii_whitespace()
623                    && bytes[i] != b'<'
624                    && bytes[i] != b'['
625                    && bytes[i] != b']'
626                {
627                    i += 1;
628                }
629                toks.push(String::from_utf8_lossy(&bytes[start..i]).into_owned());
630            }
631        }
632        toks
633    };
634    let tokens: Vec<&str> = tokens.iter().map(|s| s.as_str()).collect();
635    let mut i = 0;
636    while i < tokens.len() {
637        match tokens[i] {
638            "beginbfchar" => {
639                i += 1;
640                while i + 1 < tokens.len() && tokens[i] != "endbfchar" {
641                    if let (Some(src), Some(dst)) = (hex(tokens[i]), hex(tokens[i + 1])) {
642                        map.insert(code_of(&src), u16s_to_string(&dst));
643                    }
644                    i += 2;
645                }
646            }
647            "beginbfrange" => {
648                i += 1;
649                while i + 2 < tokens.len() && tokens[i] != "endbfrange" {
650                    let (Some(lo), Some(hi)) = (hex(tokens[i]), hex(tokens[i + 1])) else {
651                        i += 1;
652                        continue;
653                    };
654                    let lo = code_of(&lo);
655                    let hi = code_of(&hi);
656                    if tokens[i + 2] == "[" {
657                        // `<lo> <hi> [ <d0> <d1> ... ]`: one dst per code in the range.
658                        let mut j = i + 3;
659                        let mut code = lo;
660                        while j < tokens.len() && tokens[j] != "]" {
661                            if let Some(dst) = hex(tokens[j]) {
662                                map.insert(code, u16s_to_string(&dst));
663                            }
664                            code += 1;
665                            j += 1;
666                        }
667                        i = j + 1;
668                    } else if let Some(dst) = hex(tokens[i + 2]) {
669                        // `<lo> <hi> <dst>`: consecutive Unicode from a base.
670                        let base = code_of(&dst);
671                        for (k, code) in (lo..=hi).enumerate() {
672                            if let Some(ch) = char::from_u32(base + k as u32) {
673                                map.insert(code, ch.to_string());
674                            }
675                        }
676                        i += 3;
677                    } else {
678                        i += 1;
679                    }
680                }
681            }
682            _ => i += 1,
683        }
684    }
685    map
686}
687
688/// Decode a PDF string literal in a Tj/TJ operand into raw code units.
689fn codes(font: &Font, bytes: &[u8]) -> Vec<u32> {
690    if font.two_byte {
691        bytes
692            .chunks(2)
693            .map(|c| {
694                if c.len() == 2 {
695                    ((c[0] as u32) << 8) | c[1] as u32
696                } else {
697                    c[0] as u32
698                }
699            })
700            .collect()
701    } else {
702        bytes.iter().map(|&b| b as u32).collect()
703    }
704}
705
706/// A page's display box, in PDF user space: the `/CropBox` clipped to the
707/// `/MediaBox`, both inherited through the page tree and normalized — a
708/// missing or empty MediaBox is US Letter, an empty CropBox is the MediaBox
709/// (pdfium's `CPDF_Page::UpdateDimensions`). pdfium reports the page size from
710/// this box, renders exactly it, and translates every content coordinate so
711/// its lower-left corner is the origin (`m_PageMatrix`); docling's backends
712/// inherit that frame, so text cells, `prov` boxes and destinations all count
713/// from the CropBox corner, not the MediaBox one. The parser used to flip
714/// glyphs with the MediaBox *height* and no translation at all, so a page
715/// whose boxes do not start at (0, 0) — a trimmed book page with
716/// `MediaBox [-56 -58 576 723]` / `CropBox [1 -0.6 519 666]`, or a LaTeX
717/// figure cropped to `[156 147 637 391]` — had its text displaced against the
718/// rendered bitmap by the box offset, the bottom lines pushed past the page
719/// edge and clamped to `t = b`.
720#[derive(Debug, Clone, Copy, PartialEq)]
721pub(crate) struct PageBox {
722    /// Left edge, user space.
723    pub l: f32,
724    /// Bottom edge, user space.
725    pub b: f32,
726    pub w: f32,
727    pub h: f32,
728}
729
730impl PageBox {
731    /// Top edge, user space — the y that becomes `0` in the y-down frame.
732    pub fn top(&self) -> f32 {
733        self.b + self.h
734    }
735}
736
737/// A page-tree rect attribute (`/MediaBox`, `/CropBox`), inherited from the
738/// nearest ancestor that sets it, as normalized `(l, b, r, t)`.
739fn inherited_rect(
740    doc: &Document,
741    page_id: lopdf::ObjectId,
742    key: &[u8],
743) -> Option<(f32, f32, f32, f32)> {
744    let mut id = page_id;
745    for _ in 0..32 {
746        let dict = doc.get_object(id).ok()?.as_dict().ok()?;
747        if let Some(Object::Array(a)) = dict.get(key).ok().and_then(|o| deref(doc, o)) {
748            let v: Vec<f32> = a.iter().filter_map(|o| num(o).map(|x| x as f32)).collect();
749            if v.len() == 4 && v.iter().all(|x| x.is_finite()) {
750                return Some((
751                    v[0].min(v[2]),
752                    v[1].min(v[3]),
753                    v[0].max(v[2]),
754                    v[1].max(v[3]),
755                ));
756            }
757            return None;
758        }
759        id = dict.get(b"Parent").ok()?.as_reference().ok()?;
760    }
761    None
762}
763
764pub(crate) fn page_box(doc: &Document, page_id: lopdf::ObjectId) -> PageBox {
765    let nonempty = |r: &(f32, f32, f32, f32)| r.2 > r.0 && r.3 > r.1;
766    let media = inherited_rect(doc, page_id, b"MediaBox")
767        .filter(nonempty)
768        .unwrap_or((0.0, 0.0, 612.0, 792.0));
769    let crop = inherited_rect(doc, page_id, b"CropBox")
770        .map(|c| {
771            (
772                c.0.max(media.0),
773                c.1.max(media.1),
774                c.2.min(media.2),
775                c.3.min(media.3),
776            )
777        })
778        .filter(nonempty)
779        .unwrap_or(media);
780    PageBox {
781        l: crop.0,
782        b: crop.1,
783        w: crop.2 - crop.0,
784        h: crop.3 - crop.1,
785    }
786}
787
788/// Page size (width, height) in PDF points — the display box's, like pdfium's
789/// `FPDF_GetPageWidthF/HeightF`.
790fn page_size(doc: &Document, page_id: lopdf::ObjectId) -> (f32, f32) {
791    let pb = page_box(doc, page_id);
792    (pb.w, pb.h)
793}
794
795/// Localize where a page's text is lost, for the `text_layer` diagnostic.
796/// Extraction can come up empty at three different points — no content stream
797/// reached the parser, the stream did not decode into operators, or it ran but
798/// produced no glyphs (fonts/encodings) — and from the outside all three look
799/// the same. Report them per page.
800pub fn content_diagnosis(bytes: &[u8]) -> String {
801    let Some(doc) = load_document(bytes) else {
802        return "document does not load".into();
803    };
804    let mut pages: Vec<_> = doc.get_pages().into_iter().collect();
805    pages.sort_by_key(|(n, _)| *n);
806    let mut out = String::new();
807    let mut caches = DocCaches::default();
808    for (n, pid) in pages.into_iter().take(4) {
809        let content_bytes = doc.get_page_content(pid);
810        let ops = lopdf::content::Content::decode(&content_bytes)
811            .map(|c| c.operations.len())
812            .ok();
813        let res = page_res(&doc, pid);
814        let fonts = res.map(|r| fonts_from_res(&doc, r, &mut caches).len());
815        let glyphs = page_glyphs_cached(&doc, pid, &mut caches).len();
816        out.push_str(&format!(
817            "\n   page {n}: content {} B, ops {}, resources {}, fonts {}, glyphs {}",
818            content_bytes.len(),
819            ops.map_or("UNDECODABLE".to_string(), |n| n.to_string()),
820            if res.is_some() { "ok" } else { "MISSING" },
821            fonts.map_or("-".to_string(), |n| n.to_string()),
822            glyphs,
823        ));
824    }
825    out
826}
827
828/// Is this "text layer" a vestige rather than the document's text?
829///
830/// Scanned forms often carry a handful of typed-in strings — a date filled
831/// into three form fields, say — on top of pages that are otherwise images.
832/// Treating that as a real text layer is the worst of both worlds: the text
833/// path proudly extracts thirteen characters, and no OCR ever runs on the
834/// letter the pages actually show. The reported form did exactly this (3
835/// lines, 13 chars, 3 pages).
836///
837/// The rule is deliberately tight so genuinely sparse *digital* documents are
838/// not misrouted into OCR: only a document averaging at most one line per page
839/// **and** totalling fewer than 32 characters is called vestigial.
840pub fn text_layer_is_vestigial(pages: &[crate::pdfium_backend::PdfPage]) -> bool {
841    let mut tally = TextLayerTally::default();
842    pages.iter().for_each(|p| tally.add(p));
843    tally.is_vestigial()
844}
845
846/// [`text_layer_is_vestigial`]'s counts, accumulated a page at a time so a
847/// streaming caller need not keep the pages around to decide.
848#[derive(Debug, Default, Clone, Copy)]
849pub struct TextLayerTally {
850    pages: usize,
851    lines: usize,
852    chars: usize,
853}
854
855impl TextLayerTally {
856    pub fn add(&mut self, page: &crate::pdfium_backend::PdfPage) {
857        self.pages += 1;
858        self.lines += page.cells.len();
859        self.chars += page
860            .cells
861            .iter()
862            .map(|c| c.text.chars().count())
863            .sum::<usize>();
864    }
865
866    /// The verdict over the pages added so far.
867    pub fn is_vestigial(&self) -> bool {
868        self.lines == 0 || (self.lines <= self.pages && self.chars < 32)
869    }
870
871    /// Whether the pages added so far already rule vestigial out for a
872    /// document of `total_pages`, whatever the remaining pages hold — both
873    /// counts only grow, so a caller may stop parsing pages it does not need.
874    pub fn proves_text(&self, total_pages: usize) -> bool {
875        self.lines > 0 && (self.lines > total_pages || self.chars >= 32)
876    }
877}
878
879/// Why the cross-reference repair did or did not fire, for the `text_layer`
880/// diagnostic. A PDF that will not load is indistinguishable from a scan in
881/// production (both convert to nothing), so the reason has to be askable.
882pub fn xref_repair_status(bytes: &[u8]) -> String {
883    if Document::load_mem(bytes).is_ok() {
884        return "loads unaided; no repair needed".into();
885    }
886    match pad_short_xref_entries(bytes) {
887        Ok(fixed) => match Document::load_mem(&fixed) {
888            Ok(_) => "repaired: cross-reference entries padded to 20 bytes".into(),
889            Err(e) => format!("padded the entries, but it still will not load: {e}"),
890        },
891        Err(why) => format!("repair declined — {why}"),
892    }
893}
894
895/// Load a PDF, repairing the one malformation that otherwise costs us the whole
896/// document: **19-byte cross-reference entries**.
897///
898/// The spec fixes an xref entry at 20 bytes — `nnnnnnnnnn ggggg n` plus a
899/// *two*-byte EOL. Some generators (an Austrian telecom's invoices, for one)
900/// emit a bare LF instead, making each entry 19 bytes. lopdf rejects the file
901/// outright (`invalid file trailer`) where pdfium reads it happily, so a
902/// perfectly good text layer looked to the browser exactly like a scan and cost
903/// ten seconds of OCR.
904///
905/// Padding is only attempted when it cannot move anything the xref points at:
906/// a single `xref` section that begins after the last object. The repair then
907/// has to prove itself — the padded bytes are used only if they load — so a
908/// mis-repair degrades to today's behaviour rather than to silent garbage.
909pub(crate) fn load_document(bytes: &[u8]) -> Option<Document> {
910    open_document(bytes, None).ok()
911}
912
913/// Why [`open_document`] could not hand back a readable document.
914#[derive(Debug, Clone, Copy, PartialEq, Eq)]
915pub(crate) enum OpenError {
916    /// lopdf cannot read the file even after the repairs.
917    Unreadable,
918    /// The file is encrypted and the password given (or none) does not open it.
919    Password,
920}
921
922/// Load `bytes` with the document's password. lopdf decrypts while reading —
923/// with `password`, or the empty user password most "protected" PDFs carry
924/// (what every viewer opens silently) — so every reader downstream sees plain
925/// streams; a file the password does not open loads with its streams still
926/// encrypted, which is reported as [`OpenError::Password`] rather than handed
927/// on as a document whose every stream decodes to nothing.
928pub(crate) fn open_document(bytes: &[u8], password: Option<&str>) -> Result<Document, OpenError> {
929    let Some(doc) = load_document_raw(bytes, password) else {
930        // lopdf refuses to load at all under a *wrong* password (a missing
931        // one loads the file with its streams still encrypted); tell the two
932        // apart by loading without it.
933        if password.is_some() && load_document_raw(bytes, None).is_some_and(|d| d.is_encrypted()) {
934            return Err(OpenError::Password);
935        }
936        return Err(OpenError::Unreadable);
937    };
938    if doc.is_encrypted() {
939        return Err(OpenError::Password);
940    }
941    Ok(doc)
942}
943
944fn load_options(password: Option<&str>) -> lopdf::LoadOptions {
945    lopdf::LoadOptions {
946        password: password.map(str::to_string),
947        ..lopdf::LoadOptions::default()
948    }
949}
950
951fn load_document_raw(bytes: &[u8], password: Option<&str>) -> Option<Document> {
952    // Try progressively more repair, and accept a candidate only once the pages
953    // actually carry content — a document whose streams were dropped still
954    // "loads", so loading alone is not evidence the repair helped. A
955    // well-formed file returns on the first attempt and pays for nothing.
956    let mut fallback = None;
957    if let Some(doc) = best_effort_load(bytes, password, &mut fallback) {
958        return Some(doc);
959    }
960    let xref_fixed = pad_short_xref_entries(bytes).ok();
961    if let Some(fixed) = &xref_fixed {
962        if let Some(doc) = best_effort_load(fixed, password, &mut fallback) {
963            return Some(doc);
964        }
965    }
966    // Both defects can coexist, and the second only becomes visible once the
967    // first is repaired, so build on whatever the previous step produced.
968    let lengths_fixed = fix_stream_lengths(xref_fixed.as_deref().unwrap_or(bytes));
969    if let Some(doc) = best_effort_load(&lengths_fixed, password, &mut fallback) {
970        return Some(doc);
971    }
972    fallback
973}
974
975/// Load `data`, returning it only when its pages carry content; a document that
976/// merely parses is remembered as the fallback for when nothing does better.
977fn best_effort_load(
978    data: &[u8],
979    password: Option<&str>,
980    fallback: &mut Option<Document>,
981) -> Option<Document> {
982    match Document::load_mem_with_options(data, load_options(password)) {
983        Ok(doc) if has_page_content(&doc) => Some(doc),
984        Ok(doc) => {
985            fallback.get_or_insert(doc);
986            None
987        }
988        Err(_) => None,
989    }
990}
991
992/// Does any page actually hand us a content stream? A document whose streams
993/// were dropped still parses — it simply has nothing to read — so this is what
994/// tells a successful repair from a pointless one.
995fn has_page_content(doc: &Document) -> bool {
996    doc.get_pages()
997        .into_values()
998        .take(4)
999        .any(|pid| !doc.get_page_content(pid).is_empty())
1000}
1001
1002/// Correct `/Length` values that disagree with where `endstream` actually is.
1003///
1004/// The same generator that writes short xref entries also overstates its
1005/// content-stream lengths by a byte or two. lopdf trusts `/Length`, reads past
1006/// the data, fails to find `endstream` there and drops the stream — the object
1007/// comes back as a bare dictionary, so the page has no content at all and the
1008/// document looks like a scan. pdfium instead trusts `endstream`, which is what
1009/// this does.
1010///
1011/// The rewrite is length-preserving: the corrected number is written over the
1012/// old digits and padded with spaces, so every byte offset in the file — and
1013/// therefore the whole cross-reference table — stays valid.
1014fn fix_stream_lengths(bytes: &[u8]) -> Vec<u8> {
1015    let mut out = bytes.to_vec();
1016    let mut i = 0;
1017    while let Some(rel) = find(&out[i..], b"stream") {
1018        let kw = i + rel;
1019        i = kw + 6;
1020        // Skip `endstream` (the keyword we are measuring *to*).
1021        if kw >= 3 && &out[kw - 3..kw] == b"end" {
1022            continue;
1023        }
1024        // The stream data starts after the EOL that follows the keyword.
1025        let mut data = kw + 6;
1026        if out.get(data..data + 2) == Some(b"\r\n".as_slice()) {
1027            data += 2;
1028        } else if matches!(out.get(data), Some(b'\n' | b'\r')) {
1029            data += 1;
1030        }
1031        let Some(end) = find(&out[data..], b"endstream").map(|r| data + r) else {
1032            continue;
1033        };
1034        // `/Length <digits>` in the dictionary just before the keyword.
1035        let dict_start = out[..kw].iter().rposition(|&c| c == b'<').unwrap_or(0);
1036        let Some(lrel) = find(&out[dict_start..kw], b"/Length") else {
1037            continue;
1038        };
1039        let mut d = dict_start + lrel + 7;
1040        while matches!(out.get(d), Some(b' ')) {
1041            d += 1;
1042        }
1043        let digits = out[d..].iter().take_while(|c| c.is_ascii_digit()).count();
1044        if digits == 0 {
1045            continue;
1046        }
1047        let declared: usize = match std::str::from_utf8(&out[d..d + digits])
1048            .ok()
1049            .and_then(|s| s.parse().ok())
1050        {
1051            Some(v) => v,
1052            None => continue,
1053        };
1054        let actual = end - data;
1055        // Only shrink, and only when the new value fits the space the old one
1056        // occupied — growing the number would move every following byte.
1057        let replacement = actual.to_string();
1058        if actual == declared || replacement.len() > digits {
1059            continue;
1060        }
1061        out[d..d + digits].fill(b' ');
1062        out[d..d + replacement.len()].copy_from_slice(replacement.as_bytes());
1063    }
1064    out
1065}
1066
1067fn find(haystack: &[u8], needle: &[u8]) -> Option<usize> {
1068    haystack.windows(needle.len()).position(|w| w == needle)
1069}
1070
1071/// Rewrite a classic cross-reference table's entries to the spec's 20 bytes,
1072/// or `None` when the file's shape makes that unsafe (see [`load_document`]).
1073fn pad_short_xref_entries(bytes: &[u8]) -> Result<Vec<u8>, &'static str> {
1074    // Exactly one xref section, and it must start after every object, so that
1075    // growing it shifts nothing the table's offsets refer to.
1076    let is_boundary = |i: usize| i == 0 || matches!(bytes[i - 1], b'\n' | b'\r');
1077    let mut starts = (0..bytes.len().saturating_sub(4))
1078        .filter(|&i| &bytes[i..i + 4] == b"xref" && is_boundary(i));
1079    let xref_at = starts
1080        .next()
1081        .ok_or("no classic `xref` section (an xref stream?)")?;
1082    if starts.next().is_some() {
1083        return Err("more than one xref section (incremental update)");
1084    }
1085    let last_obj = bytes
1086        .windows(3)
1087        .rposition(|w| w == b"obj")
1088        .ok_or("no objects found")?;
1089    if last_obj > xref_at {
1090        return Err("an object follows the xref — padding would move it");
1091    }
1092
1093    let mut out = bytes[..xref_at].to_vec();
1094    out.extend_from_slice(b"xref\n");
1095    let mut i = xref_at + 4;
1096    let skip_ws = |i: &mut usize| {
1097        while matches!(bytes.get(*i), Some(b'\r' | b'\n' | b' ')) {
1098            *i += 1;
1099        }
1100    };
1101    loop {
1102        skip_ws(&mut i);
1103        // Either the next subsection header ("first count") or the trailer.
1104        if bytes[i..].starts_with(b"trailer") {
1105            out.extend_from_slice(&bytes[i..]);
1106            return Ok(out);
1107        }
1108        let header_end = i + bytes[i..]
1109            .iter()
1110            .position(|c| matches!(c, b'\n' | b'\r'))
1111            .ok_or("subsection header runs off the end")?;
1112        let header = std::str::from_utf8(&bytes[i..header_end])
1113            .map_err(|_| "subsection header is not text")?
1114            .trim();
1115        let mut parts = header.split_whitespace();
1116        let count: usize = parts
1117            .nth(1)
1118            .and_then(|c| c.parse().ok())
1119            .ok_or("unparseable subsection header")?;
1120        if parts.next().is_some() || count == 0 {
1121            return Err("unexpected subsection header shape");
1122        }
1123        out.extend_from_slice(header.as_bytes());
1124        out.push(b'\n');
1125        i = header_end;
1126        for _ in 0..count {
1127            skip_ws(&mut i);
1128            // `nnnnnnnnnn ggggg n` — the 18 bytes before whatever EOL follows.
1129            let entry = bytes.get(i..i + 18).ok_or("xref entry runs off the end")?;
1130            let well_formed = entry[..10].iter().all(u8::is_ascii_digit)
1131                && entry[10] == b' '
1132                && entry[11..16].iter().all(u8::is_ascii_digit)
1133                && entry[16] == b' '
1134                && matches!(entry[17], b'n' | b'f');
1135            if !well_formed {
1136                return Err("xref entry is not `nnnnnnnnnn ggggg n`");
1137            }
1138            out.extend_from_slice(entry);
1139            out.extend_from_slice(b" \n"); // the spec's 2-byte EOL -> 20 bytes
1140            i += 18;
1141        }
1142    }
1143}
1144
1145/// Debug: raw glyph stream `(ch, ll, lr, lb, lt)` (native coords) for page
1146/// `index`, before the sanitizer. For comparing char cells to docling-parse.
1147pub fn debug_glyphs(bytes: &[u8], index: usize) -> Vec<(char, f32, f32, f32, f32)> {
1148    let Some(doc) = load_document(bytes) else {
1149        return Vec::new();
1150    };
1151    let mut pages: Vec<_> = doc.get_pages().into_iter().collect();
1152    pages.sort_by_key(|(n, _)| *n);
1153    let Some((_, pid)) = pages.get(index) else {
1154        return Vec::new();
1155    };
1156    page_glyphs(&doc, *pid)
1157        .into_iter()
1158        .map(|g| (g.ch, g.ll, g.lr, g.lb, g.lt))
1159        .collect()
1160}
1161
1162/// Public entry: per-page (width, height, line cells) for a PDF, via the Rust
1163/// text parser + the docling-parse line sanitizer. Used by the pipeline and the
1164/// `textparse_dump` example.
1165pub fn pdf_textlines(bytes: &[u8]) -> Vec<(f32, f32, Vec<crate::pdfium_backend::TextCell>)> {
1166    let Some(doc) = load_document(bytes) else {
1167        return Vec::new();
1168    };
1169    let mut caches = DocCaches::default();
1170    let mut pages: Vec<_> = doc.get_pages().into_iter().collect();
1171    pages.sort_by_key(|(n, _)| *n);
1172    pages
1173        .into_iter()
1174        .map(|(_, pid)| {
1175            let (w, h) = page_size(&doc, pid);
1176            let glyphs = page_glyphs_cached(&doc, pid, &mut caches);
1177            let cells = crate::dp_lines::line_cells(&glyphs, h, true);
1178            (w, h, cells)
1179        })
1180        .collect()
1181}
1182
1183/// Debug/diagnostic entry: per-page (width, height, word cells) for a PDF, via
1184/// the Rust parser glyphs run through the docling-parse word grouping. Used to
1185/// compare parser word cells against docling-parse's `word_cells` oracle (roadmap
1186/// item 6).
1187pub fn pdf_words(bytes: &[u8]) -> Vec<(f32, f32, Vec<crate::pdfium_backend::TextCell>)> {
1188    let Some(doc) = load_document(bytes) else {
1189        return Vec::new();
1190    };
1191    let mut caches = DocCaches::default();
1192    let mut pages: Vec<_> = doc.get_pages().into_iter().collect();
1193    pages.sort_by_key(|(n, _)| *n);
1194    pages
1195        .into_iter()
1196        .map(|(_, pid)| {
1197            let (w, h) = page_size(&doc, pid);
1198            let glyphs = page_glyphs_cached(&doc, pid, &mut caches);
1199            let cells = crate::dp_lines::word_cells(&glyphs, h, true);
1200            (w, h, cells)
1201        })
1202        .collect()
1203}
1204
1205/// One page's text cells from the pure-Rust parser: prose line cells, per-word
1206/// cells, and code line cells — all from a single glyph parse. Replaces the
1207/// pdfium text path (roadmap item 6) when the parser drop is enabled.
1208#[derive(Default)]
1209pub struct PageParserCells {
1210    pub prose: Vec<crate::pdfium_backend::TextCell>,
1211    pub words: Vec<crate::pdfium_backend::TextCell>,
1212    pub code: Vec<crate::pdfium_backend::TextCell>,
1213    /// Drawn checkbox squares (#609), in the cells' frame.
1214    pub checkboxes: Vec<crate::checkbox::CheckBox>,
1215}
1216
1217/// The parser text layer, driven one page at a time: the document is loaded
1218/// (and repaired, see [`load_document`]) once, the font/form caches persist
1219/// across pages, and each page's glyphs are parsed only when asked for.
1220///
1221/// The eager whole-document walk this replaces ran *before* the first page
1222/// was rendered, so on a long PDF it was a serial prefix the page-worker pool
1223/// sat idle through — 6.2 s on the 1913-page .NET reference, in front of a
1224/// pipeline that otherwise overlaps parsing with inference — and a `--pages`
1225/// window still paid for every page in the file. Pulling pages on demand
1226/// keeps the parse on the producer thread but interleaved with rendering,
1227/// and skips unselected pages entirely. Output per page is unchanged: same
1228/// glyph walk, same shared caches, same contraction.
1229pub struct PageTextParser {
1230    doc: Document,
1231    caches: DocCaches,
1232    /// Page object ids in document order (page 1 first).
1233    pages: Vec<lopdf::ObjectId>,
1234}
1235
1236impl PageTextParser {
1237    /// Load the document; `None` when lopdf cannot read it at all.
1238    pub fn open(bytes: &[u8]) -> Option<Self> {
1239        Self::open_with_password(bytes, None)
1240    }
1241
1242    /// [`open`](Self::open) with the document's password (an encrypted file
1243    /// the password does not open reads as `None` here; the pipeline reports
1244    /// it through [`crate::pdf_meta::PdfMeta::open_with_password`] first).
1245    pub fn open_with_password(bytes: &[u8], password: Option<&str>) -> Option<Self> {
1246        let doc = open_document(bytes, password).ok()?;
1247        let mut pages: Vec<_> = doc.get_pages().into_iter().collect();
1248        pages.sort_by_key(|(n, _)| *n);
1249        Some(Self {
1250            doc,
1251            caches: DocCaches::default(),
1252            pages: pages.into_iter().map(|(_, pid)| pid).collect(),
1253        })
1254    }
1255
1256    /// The glyph boxes and font styles of the 0-based page `index` — the
1257    /// heading-hierarchy stage's style signal (#302): every non-space glyph's
1258    /// box (font ascent + descent at its size — the font-size proxy pdfium's
1259    /// loose char box also gave) in top-left coordinates, with the weight
1260    /// class and slant its `/BaseFont` name declares. Empty for a page
1261    /// without a text layer (a scan), and the stage falls back to its other
1262    /// signals.
1263    pub(crate) fn glyph_styles(
1264        &mut self,
1265        index: usize,
1266    ) -> Vec<crate::heading_hierarchy::GlyphStyle> {
1267        let Some(&pid) = self.pages.get(index) else {
1268            return Vec::new();
1269        };
1270        let (_w, h) = page_size(&self.doc, pid);
1271        let glyphs = page_glyphs_cached(&self.doc, pid, &mut self.caches);
1272        // Font hash → style, over the fonts the walk just parsed (an inline,
1273        // uncached font dictionary reads as unstyled).
1274        let styles: HashMap<u64, crate::font_style::FontStyle> = self
1275            .caches
1276            .fonts
1277            .values()
1278            .map(|f| (f.hash, f.style))
1279            .collect();
1280        glyphs
1281            .iter()
1282            .filter(|g| !g.ch.is_whitespace() && g.ll.is_finite())
1283            .map(|g| {
1284                let st = styles.get(&g.font).copied().unwrap_or_default();
1285                crate::heading_hierarchy::GlyphStyle {
1286                    l: g.ll,
1287                    t: h - g.lt,
1288                    r: g.lr,
1289                    b: h - g.lb,
1290                    height: g.height(),
1291                    weight_cls: crate::font_style::weight_class(st.weight),
1292                    italic: st.italic,
1293                    styled: st.known,
1294                }
1295            })
1296            .collect()
1297    }
1298
1299    /// Prose, word and code cells of the 0-based page `index` — empty for an
1300    /// index the parser's page tree doesn't have (a damaged file whose page
1301    /// tree disagrees with the object model's count).
1302    pub fn cells(&mut self, index: usize) -> PageParserCells {
1303        let Some(&pid) = self.pages.get(index) else {
1304            return PageParserCells::default();
1305        };
1306        let (_w, h) = page_size(&self.doc, pid);
1307        let (glyphs, inks) = page_glyphs_and_inks(&self.doc, pid, &mut self.caches);
1308        let (prose, words) = crate::dp_lines::line_and_word_cells(&glyphs, h, true);
1309        PageParserCells {
1310            prose,
1311            words,
1312            code: crate::pdfium_backend::code_cells_from_glyphs(&glyphs, h),
1313            checkboxes: crate::checkbox::find(&inks, h),
1314        }
1315    }
1316
1317    /// Pages in the document's page tree.
1318    pub fn page_count(&self) -> usize {
1319        self.pages.len()
1320    }
1321
1322    /// The 0-based page `index` as the text-layer-only conversion sees it —
1323    /// one element of [`pdf_text_pages`], parsed on demand so a caller can
1324    /// drop each page once it is assembled instead of holding the whole
1325    /// document's cells. A blank page (no cells) for an index the page tree
1326    /// doesn't have.
1327    pub fn text_page(&mut self, index: usize) -> crate::pdfium_backend::PdfPage {
1328        let (w, h, (glyphs, inks)) = match self.pages.get(index) {
1329            Some(&pid) => {
1330                let (w, h) = page_size(&self.doc, pid);
1331                (w, h, page_glyphs_and_inks(&self.doc, pid, &mut self.caches))
1332            }
1333            None => (0.0, 0.0, Default::default()),
1334        };
1335        let (mut prose, mut words) = crate::dp_lines::line_and_word_cells(&glyphs, h, true);
1336        drop_overpainted_cells(&mut prose);
1337        drop_overpainted_cells(&mut words);
1338        crate::pdfium_backend::PdfPage {
1339            #[cfg(feature = "ocr-prep")]
1340            image_layout: None,
1341            width: w,
1342            height: h,
1343            // Cells are native PDF points; there is no rendered bitmap.
1344            scale: 1.0,
1345            cells: prose,
1346            code_cells: crate::pdfium_backend::code_cells_from_glyphs(&glyphs, h),
1347            word_cells: words,
1348            checkboxes: crate::checkbox::find(&inks, h),
1349            #[cfg(feature = "ocr-prep")]
1350            image: image::RgbImage::new(1, 1),
1351            links: Vec::new(),
1352            rotation: 0,
1353            #[cfg(feature = "ml")]
1354            pending_image: None,
1355        }
1356    }
1357}
1358
1359/// Full parser text layer: prose + word + code cells per page, glyphs parsed once.
1360/// `prose`/`words` come from the docling-parse contraction ([`crate::dp_lines`]);
1361/// `code` splits only at the parser's own space glyphs (monospace keeps its
1362/// source spacing). The eager form of [`PageTextParser`].
1363pub fn pdf_all_cells(bytes: &[u8]) -> Vec<PageParserCells> {
1364    let Some(mut parser) = PageTextParser::open(bytes) else {
1365        return Vec::new();
1366    };
1367    (0..parser.pages.len()).map(|i| parser.cells(i)).collect()
1368}
1369
1370/// Whole pages for the text-layer-only conversion ([`crate::convert_text_layer`]):
1371/// the parser's prose/word/code cells plus page geometry, assembled into
1372/// [`PdfPage`]s with no rendered image and no link annotations. Everything here
1373/// is pure Rust (lopdf), so it compiles without the `ml` feature — including on
1374/// wasm32. A page the parser can't read (no text layer) comes back with empty
1375/// cells; there is no pdfium fallback on this path.
1376pub fn pdf_text_pages(bytes: &[u8]) -> Vec<crate::pdfium_backend::PdfPage> {
1377    let Some(mut parser) = PageTextParser::open(bytes) else {
1378        return Vec::new();
1379    };
1380    (0..parser.page_count())
1381        .map(|i| parser.text_page(i))
1382        .collect()
1383}
1384
1385/// Drop line cells that are *painted over each other* — glyphs used as artwork.
1386///
1387/// Some generators draw their logo with a symbol font: on the reporting
1388/// invoice, a `TeleLogo` Type1 paints the T-Mobile mark by stacking the glyphs
1389/// encoded as `"` and `==` on top of one another, and the flat text-layer
1390/// output opened with that garbage. Nothing in the font metadata gives it away
1391/// (the *text* fonts in the same file are also flagged symbolic, and the logo
1392/// font names its glyphs `quotedbl` &c.), but the geometry does: two cells with
1393/// different text where one lies inside the other on the same line is
1394/// physically impossible for prose — ink from two words never occupies the
1395/// same box. Both cells of such a pair are paint, not text.
1396///
1397/// Containment (not mere overlap) keeps this narrow: adjacent words touch but
1398/// never contain each other, and a same-text near-duplicate (double-draw faux
1399/// bold) is left alone for the sanitizer's usual handling. Applied on the
1400/// flat/browser path only — the ML pipeline's text layer is byte-pinned by the
1401/// PDF corpus, and there the layout model already sinks logo marks into
1402/// `picture` regions.
1403fn drop_overpainted_cells(cells: &mut Vec<crate::pdfium_backend::TextCell>) {
1404    let mut paint = vec![false; cells.len()];
1405    for i in 0..cells.len() {
1406        for j in 0..cells.len() {
1407            if i == j || cells[i].text == cells[j].text {
1408                continue;
1409            }
1410            let (a, b) = (&cells[i], &cells[j]);
1411            // Same line band: the vertical overlap covers most of the shorter.
1412            let vo = (a.b.min(b.b) - a.t.max(b.t)).max(0.0);
1413            if vo < 0.6 * (a.b - a.t).min(b.b - b.t) {
1414                continue;
1415            }
1416            // `a` horizontally inside `b` (with a small tolerance).
1417            let ho = (a.r.min(b.r) - a.l.max(b.l)).max(0.0);
1418            if ho >= 0.8 * (a.r - a.l) && (a.r - a.l) <= (b.r - b.l) {
1419                paint[i] = true;
1420                paint[j] = true;
1421            }
1422        }
1423    }
1424    let mut keep = paint.iter().map(|p| !p);
1425    cells.retain(|_| keep.next().unwrap());
1426}
1427
1428/// The text-state scalars inherited by a Form XObject when it is invoked via
1429/// `Do` (the PDF graphics state includes the text parameters, but not the text
1430/// matrices, which a form re-establishes inside its own `BT`/`ET`).
1431#[derive(Clone, Copy)]
1432struct TextState {
1433    tc: f64,
1434    tw: f64,
1435    th: f64,
1436    tl: f64,
1437    trise: f64,
1438    fsize: f64,
1439}
1440
1441impl TextState {
1442    const INIT: TextState = TextState {
1443        tc: 0.0,
1444        tw: 0.0,
1445        th: 1.0,
1446        tl: 0.0,
1447        trise: 0.0,
1448        fsize: 0.0,
1449    };
1450}
1451
1452/// The effective `/Resources` dictionary for a page (inline or via reference,
1453/// falling back to an inherited one from a `/Parent`).
1454fn page_res(doc: &Document, page_id: lopdf::ObjectId) -> Option<&Dictionary> {
1455    let (inline, ids) = doc.get_page_resources(page_id).ok()?;
1456    if let Some(d) = inline {
1457        return Some(d);
1458    }
1459    ids.into_iter().find_map(|id| doc.get_dictionary(id).ok())
1460}
1461
1462/// Build the code→[`Font`] map for a resources dictionary's `/Font` sub-dict,
1463/// reusing the per-document cache for fonts referenced indirectly (the common
1464/// case — the same font objects recur on every page).
1465fn fonts_from_res(
1466    doc: &Document,
1467    res: &Dictionary,
1468    caches: &mut DocCaches,
1469) -> HashMap<Vec<u8>, Arc<Font>> {
1470    let mut map = HashMap::new();
1471    let font_dict = res
1472        .get(b"Font")
1473        .ok()
1474        .and_then(|o| deref(doc, o))
1475        .and_then(|o| o.as_dict().ok());
1476    if let Some(fd) = font_dict {
1477        for (name, value) in fd.iter() {
1478            let font = match value {
1479                Object::Reference(id) => {
1480                    let key = (*id, name.clone());
1481                    if let Some(f) = caches.fonts.get(&key) {
1482                        Arc::clone(f)
1483                    } else if let Some(fdict) = deref(doc, value).and_then(|o| o.as_dict().ok()) {
1484                        let f = Arc::new(parse_font(doc, name, fdict));
1485                        caches.fonts.insert(key, Arc::clone(&f));
1486                        f
1487                    } else {
1488                        continue;
1489                    }
1490                }
1491                _ => {
1492                    if let Some(fdict) = deref(doc, value).and_then(|o| o.as_dict().ok()) {
1493                        Arc::new(parse_font(doc, name, fdict))
1494                    } else {
1495                        continue;
1496                    }
1497                }
1498            };
1499            map.insert(name.clone(), font);
1500        }
1501    }
1502    map
1503}
1504
1505/// Extract every glyph on a page as a native-coordinate [`Glyph`].
1506/// [`PageTextParser::glyph_styles`] for the given **1-based** pages of a
1507/// document, keyed by page number — a separate, on-demand pass (no
1508/// rendering), so the extraction pipeline stays byte-identical whether or not
1509/// the heading-hierarchy stage runs. Empty when lopdf cannot open the file.
1510pub(crate) fn glyph_styles(
1511    bytes: &[u8],
1512    pages: &[usize],
1513) -> HashMap<usize, Vec<crate::heading_hierarchy::GlyphStyle>> {
1514    let mut out = HashMap::new();
1515    let Some(mut parser) = PageTextParser::open(bytes) else {
1516        return out;
1517    };
1518    for &page_no in pages {
1519        if page_no == 0 {
1520            continue;
1521        }
1522        let styles = parser.glyph_styles(page_no - 1);
1523        if !styles.is_empty() {
1524            out.insert(page_no, styles);
1525        }
1526    }
1527    out
1528}
1529
1530pub(crate) fn page_glyphs(doc: &Document, page_id: lopdf::ObjectId) -> Vec<Glyph> {
1531    page_glyphs_cached(doc, page_id, &mut DocCaches::default())
1532}
1533
1534/// [`page_glyphs`] with an explicit per-document cache, so a multi-page walk
1535/// parses each font / decodes each form once instead of once per page.
1536fn page_glyphs_cached(
1537    doc: &Document,
1538    page_id: lopdf::ObjectId,
1539    caches: &mut DocCaches,
1540) -> Vec<Glyph> {
1541    page_glyphs_and_inks(doc, page_id, caches).0
1542}
1543
1544/// [`page_glyphs_cached`] plus the small painted path pieces the same walk
1545/// passed — what [`crate::checkbox::find`] looks for checkbox squares in
1546/// (#609) — in the glyphs' frame.
1547fn page_glyphs_and_inks(
1548    doc: &Document,
1549    page_id: lopdf::ObjectId,
1550    caches: &mut DocCaches,
1551) -> (Vec<Glyph>, Vec<crate::checkbox::Ink>) {
1552    let mut out = Vec::new();
1553    let mut inks = Vec::new();
1554    // lopdf 0.44: get_page_content returns the assembled content-stream bytes
1555    // directly (an empty Vec when the page has none).
1556    let content_bytes = doc.get_page_content(page_id);
1557    let Ok(content) = lopdf::content::Content::decode(&content_bytes) else {
1558        return (out, inks);
1559    };
1560    if let Some(res) = page_res(doc, page_id) {
1561        // pdfium's page matrix: user space translated so the display box's
1562        // lower-left corner is the origin (see [`PageBox`]).
1563        let pb = page_box(doc, page_id);
1564        let base = Mat {
1565            e: -(pb.l as f64),
1566            f: -(pb.b as f64),
1567            ..Mat::ID
1568        };
1569        run_content(
1570            doc,
1571            res,
1572            &content,
1573            base,
1574            TextState::INIT,
1575            0,
1576            caches,
1577            &mut out,
1578            &mut inks,
1579        );
1580        out.retain(|g| on_page(g, pb.w, pb.h));
1581    }
1582    (out, inks)
1583}
1584
1585/// Whether a glyph is on the page (#529): docling-parse keeps a character
1586/// only when its whole box lies inside the display box — the CropBox, or
1587/// the MediaBox without one — edges included, so a FrameMaker print slug
1588/// drawn beside the CropBox or a tiled page's neighbouring text beyond the
1589/// MediaBox never becomes a cell, and a line crossing the edge is cut at the
1590/// last glyph that fits (`Crossing the rig`). The box is docling-parse's
1591/// char box — the advance by the font's ascent/descent, the loose box here
1592/// (its axis-aligned extent for rotated text) — in the frame `page_glyphs`
1593/// already moved to the display box's corner, so the page is `[0, w] × [0,
1594/// h]`. A glyph without a finite box is kept, as before. (A standard-14 font
1595/// without a FontDescriptor gets the 750 / −250 default ascent / descent
1596/// here where docling-parse reads the AFM's — Helvetica's 718 / −207 — so
1597/// for those a glyph within ~0.04 em of the top or bottom edge can fall the
1598/// other way; horizontally the advance is the same.)
1599fn on_page(g: &Glyph, w: f32, h: f32) -> bool {
1600    // f32 noise only: docling-parse already drops a glyph 0.01 pt over.
1601    const EPS: f32 = 1e-3;
1602    let (l, b, r, t) = if [g.ll, g.lb, g.lr, g.lt].iter().all(|v| v.is_finite()) {
1603        (g.ll, g.lb, g.lr, g.lt)
1604    } else {
1605        (g.l, g.b, g.r, g.t)
1606    };
1607    if ![l, b, r, t].iter().all(|v| v.is_finite()) {
1608        return true;
1609    }
1610    l >= -EPS && b >= -EPS && r <= w + EPS && t <= h + EPS
1611}
1612
1613/// Run a content stream's operators, emitting glyphs into `out`. Recurses into
1614/// Form XObjects on `Do` (bulk body text in heavy PDFs lives inside a form, not
1615/// the page content stream). `res` is the resources dict in scope (the page's,
1616/// or the form's own); `base_ctm` is the CTM at the point of invocation.
1617#[allow(clippy::too_many_arguments)]
1618fn run_content(
1619    doc: &Document,
1620    res: &Dictionary,
1621    content: &lopdf::content::Content,
1622    base_ctm: Mat,
1623    init: TextState,
1624    depth: u32,
1625    caches: &mut DocCaches,
1626    out: &mut Vec<Glyph>,
1627    inks: &mut Vec<crate::checkbox::Ink>,
1628) {
1629    let fonts = fonts_from_res(doc, res, caches);
1630    let xobjects = res
1631        .get(b"XObject")
1632        .ok()
1633        .and_then(|o| deref(doc, o))
1634        .and_then(|o| o.as_dict().ok());
1635
1636    // Graphics + text state. `q`/`Q` save and restore the whole graphics state,
1637    // which includes the text parameters (Tc, Tw, Tz, TL, Tfs, Trise, font) —
1638    // *not* the text matrix (that is reset by BT). Saving only the CTM let a Tc
1639    // set inside a `q…Q` block leak out and drift every later glyph.
1640    #[allow(clippy::type_complexity)]
1641    let mut gstate_stack: Vec<(Mat, f64, f64, f64, f64, f64, f64, Option<&Arc<Font>>)> = Vec::new();
1642    let mut ctm = base_ctm;
1643    let mut tm = Mat::ID;
1644    let mut tlm = Mat::ID;
1645    let mut font: Option<&Arc<Font>> = None;
1646    let mut fsize = init.fsize;
1647    let mut tc = init.tc; // char spacing
1648    let mut tw = init.tw; // word spacing
1649    let mut th = init.th; // horizontal scale (Tz/100)
1650    let mut tl = init.tl; // leading
1651    let mut trise = init.trise;
1652
1653    let op_f = |operands: &[Object], i: usize| operands.get(i).and_then(num).unwrap_or(0.0);
1654    // The path under construction, already in page space (a path is built
1655    // under one CTM — `cm` is not allowed inside it), for the checkbox
1656    // squares (#609). Text extraction never reads it.
1657    let mut path: Vec<PathPiece> = Vec::new();
1658    let (mut cur, mut start) = ((0.0, 0.0), (0.0, 0.0));
1659    // Whether the fill colour is (near) white, saved/restored with `q`/`Q`:
1660    // a white-filled stroked box is a checkbox outline, a coloured one a
1661    // chart legend's swatch. The initial fill colour is black.
1662    let mut fill_light = false;
1663    let mut fill_stack: Vec<bool> = Vec::new();
1664
1665    for op in &content.operations {
1666        let operands = &op.operands;
1667        match op.operator.as_str() {
1668            "q" => {
1669                gstate_stack.push((ctm, tc, tw, th, tl, trise, fsize, font));
1670                fill_stack.push(fill_light);
1671            }
1672            "Q" => {
1673                fill_light = fill_stack.pop().unwrap_or(false);
1674                if let Some((c, a, b, h, l, r, fs, f)) = gstate_stack.pop() {
1675                    ctm = c;
1676                    tc = a;
1677                    tw = b;
1678                    th = h;
1679                    tl = l;
1680                    trise = r;
1681                    fsize = fs;
1682                    font = f;
1683                }
1684            }
1685            "cm" => {
1686                let m = Mat {
1687                    a: op_f(operands, 0),
1688                    b: op_f(operands, 1),
1689                    c: op_f(operands, 2),
1690                    d: op_f(operands, 3),
1691                    e: op_f(operands, 4),
1692                    f: op_f(operands, 5),
1693                };
1694                ctm = m.then(ctm);
1695            }
1696            "BT" => {
1697                tm = Mat::ID;
1698                tlm = Mat::ID;
1699            }
1700            "ET" => {}
1701            "Tf" => {
1702                if let Some(Object::Name(n)) = operands.first() {
1703                    font = fonts.get(n.as_slice());
1704                }
1705                fsize = op_f(operands, 1);
1706            }
1707            "Td" => {
1708                tlm = Mat {
1709                    a: 1.0,
1710                    b: 0.0,
1711                    c: 0.0,
1712                    d: 1.0,
1713                    e: op_f(operands, 0),
1714                    f: op_f(operands, 1),
1715                }
1716                .then(tlm);
1717                tm = tlm;
1718            }
1719            "TD" => {
1720                tl = -op_f(operands, 1);
1721                tlm = Mat {
1722                    a: 1.0,
1723                    b: 0.0,
1724                    c: 0.0,
1725                    d: 1.0,
1726                    e: op_f(operands, 0),
1727                    f: op_f(operands, 1),
1728                }
1729                .then(tlm);
1730                tm = tlm;
1731            }
1732            "Tm" => {
1733                tlm = Mat {
1734                    a: op_f(operands, 0),
1735                    b: op_f(operands, 1),
1736                    c: op_f(operands, 2),
1737                    d: op_f(operands, 3),
1738                    e: op_f(operands, 4),
1739                    f: op_f(operands, 5),
1740                };
1741                tm = tlm;
1742            }
1743            "T*" => {
1744                tlm = Mat {
1745                    a: 1.0,
1746                    b: 0.0,
1747                    c: 0.0,
1748                    d: 1.0,
1749                    e: 0.0,
1750                    f: -tl,
1751                }
1752                .then(tlm);
1753                tm = tlm;
1754            }
1755            "Tc" => tc = op_f(operands, 0),
1756            "Tw" => tw = op_f(operands, 0),
1757            "Tz" => th = op_f(operands, 0) / 100.0,
1758            "TL" => tl = op_f(operands, 0),
1759            "Ts" => trise = op_f(operands, 0),
1760            "Tj" | "'" | "\"" => {
1761                if op.operator == "'" || op.operator == "\"" {
1762                    // move to next line first
1763                    tlm = Mat {
1764                        a: 1.0,
1765                        b: 0.0,
1766                        c: 0.0,
1767                        d: 1.0,
1768                        e: 0.0,
1769                        f: -tl,
1770                    }
1771                    .then(tlm);
1772                    tm = tlm;
1773                }
1774                if op.operator == "\"" {
1775                    // `aw ac string "` sets word- and char-spacing before
1776                    // showing the string (PDF 32000-1 §9.4.3), persisting after.
1777                    tw = op_f(operands, 0);
1778                    tc = op_f(operands, 1);
1779                }
1780                if let (Some(f), Some(Object::String(s, _))) = (font, operands.last()) {
1781                    show_text(f, s, fsize, tc, tw, th, trise, &mut tm, ctm, out);
1782                }
1783            }
1784            "TJ" => {
1785                if let (Some(f), Some(Object::Array(arr))) = (font, operands.first()) {
1786                    for el in arr {
1787                        match el {
1788                            Object::String(s, _) => {
1789                                show_text(f, s, fsize, tc, tw, th, trise, &mut tm, ctm, out)
1790                            }
1791                            other => {
1792                                if let Some(adj) = num(other) {
1793                                    // negative number moves text right (PDF: subtract)
1794                                    let tx = -adj / 1000.0 * fsize * th;
1795                                    tm = Mat {
1796                                        a: 1.0,
1797                                        b: 0.0,
1798                                        c: 0.0,
1799                                        d: 1.0,
1800                                        e: tx,
1801                                        f: 0.0,
1802                                    }
1803                                    .then(tm);
1804                                }
1805                            }
1806                        }
1807                    }
1808                }
1809            }
1810            "m" => {
1811                cur = ctm.apply(op_f(operands, 0), op_f(operands, 1));
1812                start = cur;
1813            }
1814            "l" => {
1815                let p = ctm.apply(op_f(operands, 0), op_f(operands, 1));
1816                path.push(PathPiece::Line(cur, p));
1817                cur = p;
1818            }
1819            "c" | "v" | "y" => {
1820                let pts: Vec<(f64, f64)> = (0..operands.len() / 2)
1821                    .map(|k| ctm.apply(op_f(operands, 2 * k), op_f(operands, 2 * k + 1)))
1822                    .collect();
1823                if let Some(&end) = pts.last() {
1824                    path.push(PathPiece::Curve(
1825                        std::iter::once(cur).chain(pts.iter().copied()).collect(),
1826                    ));
1827                    cur = end;
1828                }
1829            }
1830            "h" => {
1831                if cur != start {
1832                    path.push(PathPiece::Line(cur, start));
1833                }
1834                cur = start;
1835            }
1836            "re" => {
1837                let (x, y, w, h) = (
1838                    op_f(operands, 0),
1839                    op_f(operands, 1),
1840                    op_f(operands, 2),
1841                    op_f(operands, 3),
1842                );
1843                path.push(PathPiece::Rect([
1844                    ctm.apply(x, y),
1845                    ctm.apply(x + w, y),
1846                    ctm.apply(x + w, y + h),
1847                    ctm.apply(x, y + h),
1848                ]));
1849                cur = ctm.apply(x, y);
1850                start = cur;
1851            }
1852            "S" | "s" | "f" | "F" | "f*" | "B" | "B*" | "b" | "b*" => {
1853                let op = op.operator.as_str();
1854                if matches!(op, "s" | "b" | "b*") && cur != start {
1855                    path.push(PathPiece::Line(cur, start));
1856                }
1857                let stroke = matches!(op, "S" | "s" | "B" | "B*" | "b" | "b*");
1858                let fill = !matches!(op, "S" | "s");
1859                paint_path(&path, stroke, fill, fill_light, inks);
1860                path.clear();
1861            }
1862            "n" => path.clear(),
1863            // The fill colour, by its components: gray `g`, `rg`, CMYK `k`,
1864            // and `sc`/`scn` by operand count (a pattern name is not light).
1865            "g" | "rg" | "k" | "sc" | "scn" => {
1866                let v: Vec<f64> = operands
1867                    .iter()
1868                    .map(num)
1869                    .collect::<Option<_>>()
1870                    .unwrap_or_default();
1871                fill_light = match v.as_slice() {
1872                    [g] => *g >= 0.9,
1873                    [r, g, b] => r.min(*g).min(*b) >= 0.9,
1874                    [c, m, y, k] => c.max(*m).max(*y).max(*k) <= 0.1,
1875                    _ => false,
1876                };
1877            }
1878            "cs" => fill_light = false,
1879            "Do" => {
1880                // Invoke a Form XObject: bulk body text in many PDFs lives inside
1881                // a form, reached only here. Image XObjects are skipped (no text).
1882                if depth >= 8 {
1883                    continue;
1884                }
1885                let Some(Object::Name(n)) = operands.first() else {
1886                    continue;
1887                };
1888                let obj = xobjects.and_then(|d| d.get(n.as_slice()).ok());
1889                let form_id = match obj {
1890                    Some(Object::Reference(id)) => Some(*id),
1891                    _ => None,
1892                };
1893                let stream = obj
1894                    .and_then(|o| deref(doc, o))
1895                    .and_then(|o| o.as_stream().ok());
1896                let Some(stream) = stream else { continue };
1897                let is_form = stream
1898                    .dict
1899                    .get(b"Subtype")
1900                    .ok()
1901                    .and_then(|o| o.as_name().ok())
1902                    == Some(b"Form".as_slice());
1903                if !is_form {
1904                    continue;
1905                }
1906                // Decode the form's content once per document (headers/footers
1907                // and bulk body text invoke the same form on every page).
1908                let cached = form_id.and_then(|id| caches.forms.get(&id).cloned());
1909                let form_content = match cached {
1910                    Some(c) => c,
1911                    None => {
1912                        let Ok(data) = stream.decompressed_content() else {
1913                            continue;
1914                        };
1915                        let Ok(c) = lopdf::content::Content::decode(&data) else {
1916                            continue;
1917                        };
1918                        let c = Arc::new(c);
1919                        if let Some(id) = form_id {
1920                            caches.forms.insert(id, Arc::clone(&c));
1921                        }
1922                        c
1923                    }
1924                };
1925                // The form's /Matrix maps form space into the CTM at invocation.
1926                let form_mat = match stream.dict.get(b"Matrix").ok() {
1927                    Some(Object::Array(a)) if a.len() == 6 => {
1928                        let v: Vec<f64> = a.iter().filter_map(num).collect();
1929                        if v.len() == 6 {
1930                            Mat {
1931                                a: v[0],
1932                                b: v[1],
1933                                c: v[2],
1934                                d: v[3],
1935                                e: v[4],
1936                                f: v[5],
1937                            }
1938                        } else {
1939                            Mat::ID
1940                        }
1941                    }
1942                    _ => Mat::ID,
1943                };
1944                // The form's own /Resources, falling back to the inherited ones.
1945                let form_res = stream
1946                    .dict
1947                    .get(b"Resources")
1948                    .ok()
1949                    .and_then(|o| deref(doc, o))
1950                    .and_then(|o| o.as_dict().ok())
1951                    .unwrap_or(res);
1952                let state = TextState {
1953                    tc,
1954                    tw,
1955                    th,
1956                    tl,
1957                    trise,
1958                    fsize,
1959                };
1960                run_content(
1961                    doc,
1962                    form_res,
1963                    &form_content,
1964                    form_mat.then(ctm),
1965                    state,
1966                    depth + 1,
1967                    caches,
1968                    out,
1969                    inks,
1970                );
1971            }
1972            _ => {}
1973        }
1974    }
1975}
1976
1977/// One piece of a path under construction, page space (see `run_content`).
1978enum PathPiece {
1979    Line((f64, f64), (f64, f64)),
1980    /// An `re`: its four corners, counter-clockwise from `(x, y)`.
1981    Rect([(f64, f64); 4]),
1982    /// A Bézier segment: its start and control/end points (their hull).
1983    Curve(Vec<(f64, f64)>),
1984}
1985
1986/// Record a painted path's pieces for the checkbox finder (#609): a stroked
1987/// line is a [`Seg`](crate::checkbox::Ink::Seg), a stroked rectangle that
1988/// stays axis-aligned a [`Rect`](crate::checkbox::Ink::Rect) (one filled
1989/// white too — still an outline), and a coloured fill or a curve only its
1990/// bounding box, a [`Blot`](crate::checkbox::Ink::Blot): a possible tick
1991/// mark, or a swatch. White fills draw nothing and are dropped. Only pieces small
1992/// enough to be a checkbox edge or a mark inside one are kept.
1993fn paint_path(
1994    path: &[PathPiece],
1995    stroke: bool,
1996    fill: bool,
1997    fill_light: bool,
1998    inks: &mut Vec<crate::checkbox::Ink>,
1999) {
2000    use crate::checkbox::Ink;
2001    let bbox = |pts: &mut dyn Iterator<Item = (f64, f64)>| {
2002        pts.fold(
2003            (
2004                f64::INFINITY,
2005                f64::INFINITY,
2006                f64::NEG_INFINITY,
2007                f64::NEG_INFINITY,
2008            ),
2009            |(l, b, r, t), (x, y)| (l.min(x), b.min(y), r.max(x), t.max(y)),
2010        )
2011    };
2012    let mut keep = |ink: Ink| {
2013        if ink.worth_keeping() {
2014            inks.push(ink);
2015        }
2016    };
2017    if fill && !fill_light {
2018        // A coloured fill is a blot — a tick mark, or (the size of a square)
2019        // a legend swatch, which `checkbox::find` then rules out. A white
2020        // fill is no ink on paper: it only leaves the stroke, if any, below.
2021        let (l, b, r, t) = bbox(&mut path.iter().flat_map(|p| match p {
2022            PathPiece::Line(a, z) => vec![*a, *z],
2023            PathPiece::Rect(c) => c.to_vec(),
2024            PathPiece::Curve(c) => c.clone(),
2025        }));
2026        if l.is_finite() {
2027            keep(Ink::Blot { l, b, r, t });
2028        }
2029        return;
2030    }
2031    if !stroke {
2032        return;
2033    }
2034    for piece in path {
2035        match piece {
2036            PathPiece::Line(a, z) => keep(Ink::Seg {
2037                x0: a.0,
2038                y0: a.1,
2039                x1: z.0,
2040                y1: z.1,
2041            }),
2042            PathPiece::Rect(c) => {
2043                let axis = ((c[0].1 - c[1].1).abs() < 1e-6 && (c[1].0 - c[2].0).abs() < 1e-6)
2044                    || ((c[0].0 - c[1].0).abs() < 1e-6 && (c[1].1 - c[2].1).abs() < 1e-6);
2045                if axis {
2046                    let (l, b, r, t) = bbox(&mut c.iter().copied());
2047                    keep(Ink::Rect { l, b, r, t });
2048                } else {
2049                    for k in 0..4 {
2050                        let (a, z) = (c[k], c[(k + 1) % 4]);
2051                        keep(Ink::Seg {
2052                            x0: a.0,
2053                            y0: a.1,
2054                            x1: z.0,
2055                            y1: z.1,
2056                        });
2057                    }
2058                }
2059            }
2060            PathPiece::Curve(c) => {
2061                let (l, b, r, t) = bbox(&mut c.iter().copied());
2062                keep(Ink::Blot { l, b, r, t });
2063            }
2064        }
2065    }
2066}
2067
2068#[allow(clippy::too_many_arguments)]
2069fn show_text(
2070    font: &Font,
2071    bytes: &[u8],
2072    fsize: f64,
2073    tc: f64,
2074    tw: f64,
2075    th: f64,
2076    trise: f64,
2077    tm: &mut Mat,
2078    ctm: Mat,
2079    out: &mut Vec<Glyph>,
2080) {
2081    for code in codes(font, bytes) {
2082        let (text, w) = font.decode_code(code);
2083        let w0 = w / 1000.0; // advance in text-space (em) units
2084                             // The glyph→user transform: scale glyph space by font size, then Tm, CTM.
2085        let scale = Mat {
2086            a: fsize * th,
2087            b: 0.0,
2088            c: 0.0,
2089            d: fsize,
2090            e: 0.0,
2091            f: trise,
2092        };
2093        let trm = scale.then(*tm).then(ctm);
2094        // Box in glyph space (1000-unit em): x 0..w, y descent..ascent.
2095        let (desc, asc) = (font.descent / 1000.0, font.ascent / 1000.0);
2096        let (x0, y0) = trm.apply(0.0, desc);
2097        let (x1, y1) = trm.apply(w0, desc);
2098        let (x2, y2) = trm.apply(w0, asc);
2099        let (x3, y3) = trm.apply(0.0, asc);
2100        // Upright = the baseline runs left-to-right along +x. Only then is the
2101        // loose box the rectangle spanned by the baseline's x and the em's y
2102        // (a synthetic oblique's skew is ignored, as it always was). A rotated
2103        // matrix — the `0 s -s 0 tx ty Tm` landscape pages are built with
2104        // (#528) — collapsed that rectangle to zero width, and every such run
2105        // vanished; those glyphs keep their real quad (docling-parse's char
2106        // rect) and its extent instead.
2107        let upright = trm.a > 0.0 && trm.b.abs() <= 1e-6 * trm.a;
2108        let (left, bot, right, top, quad) = if upright {
2109            (x0.min(x1), y0.min(y3), x0.max(x1), y0.max(y3), None)
2110        } else {
2111            let (xs, ys) = ([x0, x1, x2, x3], [y0, y1, y2, y3]);
2112            let fold = |v: [f64; 4], f: fn(f64, f64) -> f64| v.into_iter().reduce(f).unwrap();
2113            let q = [x0, y0, x1, y1, x2, y2, x3, y3].map(|v| v as f32);
2114            (
2115                fold(xs, f64::min),
2116                fold(ys, f64::min),
2117                fold(xs, f64::max),
2118                fold(ys, f64::max),
2119                Some(q),
2120            )
2121        };
2122        if let Some(s) = text {
2123            // A run may map one code to multiple chars (ligature/fraction); share box.
2124            for ch in s.chars() {
2125                if ch != '\u{0}' {
2126                    out.push(Glyph {
2127                        ch,
2128                        l: left as f32,
2129                        b: bot as f32,
2130                        r: right as f32,
2131                        t: top as f32,
2132                        ll: left as f32,
2133                        lb: bot as f32,
2134                        lr: right as f32,
2135                        lt: top as f32,
2136                        font: font.hash,
2137                        quad,
2138                    });
2139                }
2140            }
2141        }
2142        // Advance the text matrix. Word spacing applies to single-byte code 32.
2143        let is_space = !font.two_byte && code == 32;
2144        let tx = (w0 * fsize + tc + if is_space { tw } else { 0.0 }) * th;
2145        *tm = Mat {
2146            a: 1.0,
2147            b: 0.0,
2148            c: 0.0,
2149            d: 1.0,
2150            e: tx,
2151            f: 0.0,
2152        }
2153        .then(*tm);
2154    }
2155}
2156
2157/// Build a simple font's code→char table from its `/Encoding`: the base
2158/// encoding (WinAnsi / MacRoman) plus any `/Differences` overrides (glyph names
2159/// resolved through a small Adobe-glyph-name subset).
2160fn simple_encoding_table(doc: &Document, fdict: &Dictionary) -> HashMap<u8, char> {
2161    let enc = fdict.get(b"Encoding").ok().and_then(|o| deref(doc, o));
2162    let base_name = match enc {
2163        Some(Object::Name(n)) => n.clone(),
2164        Some(Object::Dictionary(d)) => d
2165            .get(b"BaseEncoding")
2166            .ok()
2167            .and_then(|o| o.as_name().ok())
2168            .map(|n| n.to_vec())
2169            .unwrap_or_default(),
2170        _ => Vec::new(),
2171    };
2172    let mut m = if base_name == b"MacRomanEncoding" {
2173        macroman_table()
2174    } else if base_name.is_empty() {
2175        // No PDF /Encoding at all: the font's *built-in* encoding applies. For
2176        // the standard TeX math fonts that is their fixed TeX layout — falling
2177        // back to StandardEncoding read CMSY's braces as `f`/`g`, `→` as `!`,
2178        // `∈` as `2` (2203's `{ahn,…}` author line). The font program (often
2179        // CFF, which this parser does not read) carries the same mapping;
2180        // docling-parse decodes it from there.
2181        tex_math_builtin(fdict).unwrap_or_else(winansi_table)
2182    } else {
2183        winansi_table()
2184    };
2185    // Apply /Differences: `code /glyphname /glyphname ... code ...`.
2186    if let Some(Object::Dictionary(d)) = enc {
2187        if let Some(Object::Array(diffs)) = d.get(b"Differences").ok().and_then(|o| deref(doc, o)) {
2188            let mut code = 0u8;
2189            for el in diffs {
2190                match el {
2191                    Object::Integer(i) => code = *i as u8,
2192                    Object::Name(name) => {
2193                        if let Some(ch) = glyph_name_to_char(name) {
2194                            m.insert(code, ch);
2195                        }
2196                        code = code.wrapping_add(1);
2197                    }
2198                    _ => {}
2199                }
2200            }
2201        }
2202    }
2203    m
2204}
2205
2206/// The fixed built-in encodings of the standard TeX math fonts (TeXbook
2207/// Appendix F), keyed off the base font name: `CMSY*` (symbols; `CMBSY` is its
2208/// bold) and `CMMI*` (math italic). These fonts ship no PDF `/Encoding` and no
2209/// ToUnicode, and their program is usually CFF — without this table the codes
2210/// fell through to StandardEncoding and rendered as the wrong ASCII.
2211fn tex_math_builtin(fdict: &Dictionary) -> Option<HashMap<u8, char>> {
2212    const CMSY: [char; 128] = [
2213        '−', '·', '×', '∗', '÷', '⋄', '±', '∓', '⊕', '⊖', '⊗', '⊘', '⊙', '◯', '∘', '•', '≍', '≡',
2214        '⊆', '⊇', '≤', '≥', '≼', '≽', '∼', '≈', '⊂', '⊃', '≪', '≫', '≺', '≻', '←', '→', '↑', '↓',
2215        '↔', '↗', '↘', '≃', '⇐', '⇒', '⇑', '⇓', '⇔', '↖', '↙', '∝', '′', '∞', '∈', '∋', '△', '▽',
2216        '\u{338}', '↦', '∀', '∃', '¬', '∅', 'ℜ', 'ℑ', '⊤', '⊥', 'ℵ', 'A', 'B', 'C', 'D', 'E', 'F',
2217        'G', 'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X',
2218        'Y', 'Z', '∪', '∩', '⊎', '∧', '∨', '⊢', '⊣', '⌊', '⌋', '⌈', '⌉', '{', '}', '⟨', '⟩', '|',
2219        '∥', '↕', '⇕', '\\', '≀', '√', '∐', '∇', '∫', '⊔', '⊓', '⊑', '⊒', '§', '†', '‡', '¶', '♣',
2220        '♢', '♡', '♠',
2221    ];
2222    const CMMI: [char; 128] = [
2223        'Γ', 'Δ', 'Θ', 'Λ', 'Ξ', 'Π', 'Σ', 'Υ', 'Φ', 'Ψ', 'Ω', 'α', 'β', 'γ', 'δ', 'ε', 'ζ', 'η',
2224        'θ', 'ι', 'κ', 'λ', 'μ', 'ν', 'ξ', 'π', 'ρ', 'σ', 'τ', 'υ', 'φ', 'χ', 'ψ', 'ω', 'ϵ', 'ϑ',
2225        'ϖ', 'ϱ', 'ς', 'ϕ', '↼', '↽', '⇀', '⇁', '↩', '↪', '▷', '◁', '0', '1', '2', '3', '4', '5',
2226        '6', '7', '8', '9', '.', ',', '<', '/', '>', '⋆', '∂', 'A', 'B', 'C', 'D', 'E', 'F', 'G',
2227        'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', 'Y',
2228        'Z', '♭', '♮', '♯', '⌣', '⌢', 'ℓ', 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k',
2229        'l', 'm', 'n', 'o', 'p', 'q', 'r', 's', 't', 'u', 'v', 'w', 'x', 'y', 'z', 'ı', 'ȷ', '℘',
2230        '\u{20d7}', '⁀',
2231    ];
2232    let name = base_font_name(fdict)?;
2233    let up = name.to_ascii_uppercase();
2234    let table: &[char; 128] = if up.starts_with(b"CMSY") || up.starts_with(b"CMBSY") {
2235        &CMSY
2236    } else if up.starts_with(b"CMMI") {
2237        &CMMI
2238    } else {
2239        return None;
2240    };
2241    Some(
2242        table
2243            .iter()
2244            .enumerate()
2245            .map(|(i, &c)| (i as u8, c))
2246            .collect(),
2247    )
2248}
2249
2250/// Resolve an Adobe glyph name to Unicode: `uniXXXX`, single ASCII letters, the
2251/// digit/punctuation names from the Adobe Glyph List, and common typographic
2252/// names. A `.suffix` (`one.taboldstyle`, `a.sc`) is stripped and the base name
2253/// retried — docling renders these as the base character.
2254pub(crate) fn glyph_name_to_char(name: &[u8]) -> Option<char> {
2255    let s = std::str::from_utf8(name).ok()?;
2256    if let Some(hex) = s.strip_prefix("uni") {
2257        if let Ok(cp) = u32::from_str_radix(hex.get(0..4)?, 16) {
2258            return char::from_u32(cp);
2259        }
2260    }
2261    // Single ASCII letter names (`A`, `m`) map to themselves.
2262    if s.len() == 1 {
2263        let b = s.as_bytes()[0];
2264        if b.is_ascii_alphabetic() {
2265            return Some(b as char);
2266        }
2267    }
2268    let resolved = match s {
2269        "space" => ' ',
2270        "exclam" => '!',
2271        "quotedbl" => '"',
2272        "numbersign" => '#',
2273        "dollar" => '$',
2274        "percent" => '%',
2275        "ampersand" => '&',
2276        "quotesingle" => '\'',
2277        "parenleft" => '(',
2278        "parenright" => ')',
2279        "asterisk" => '*',
2280        "plus" => '+',
2281        "comma" => ',',
2282        "hyphen" => '-',
2283        "period" => '.',
2284        "slash" => '/',
2285        "zero" => '0',
2286        "one" => '1',
2287        "two" => '2',
2288        "three" => '3',
2289        "four" => '4',
2290        "five" => '5',
2291        "six" => '6',
2292        "seven" => '7',
2293        "eight" => '8',
2294        "nine" => '9',
2295        "colon" => ':',
2296        "semicolon" => ';',
2297        "less" => '<',
2298        "equal" => '=',
2299        "greater" => '>',
2300        "question" => '?',
2301        "at" => '@',
2302        "bracketleft" => '[',
2303        "backslash" => '\\',
2304        "bracketright" => ']',
2305        "asciicircum" => '^',
2306        "underscore" => '_',
2307        "grave" => '`',
2308        "braceleft" => '{',
2309        "bar" => '|',
2310        "braceright" => '}',
2311        "asciitilde" => '~',
2312        "bullet" => '\u{2022}',
2313        "periodcentered" => '\u{00B7}',
2314        "endash" => '\u{2013}',
2315        "emdash" => '\u{2014}',
2316        "quoteright" => '\u{2019}',
2317        "quoteleft" => '\u{2018}',
2318        "quotedblleft" => '\u{201C}',
2319        "quotedblright" => '\u{201D}',
2320        "quotedblbase" => '\u{201E}',
2321        "quotesinglbase" => '\u{201A}',
2322        // Latin f-ligatures named in `/Differences` (e.g. 2305's body font). These
2323        // map to the presentation-form code points, which `decompose_ligatures`
2324        // then spells back out (`ff`→"ff") — without them the glyph decodes to
2325        // nothing and the sanitizer fills the gap with a space (`di erences`).
2326        "ff" => '\u{FB00}',
2327        "fi" => '\u{FB01}',
2328        "fl" => '\u{FB02}',
2329        "ffi" => '\u{FB03}',
2330        "ffl" => '\u{FB04}',
2331        "ft" => '\u{FB05}',
2332        "st" => '\u{FB06}',
2333        "degree" => '\u{00B0}',
2334        "trademark" => '\u{2122}',
2335        "registered" => '\u{00AE}',
2336        "copyright" => '\u{00A9}',
2337        "ellipsis" => '\u{2026}',
2338        "minus" => '\u{2212}',
2339        "fraction" => '\u{2044}',
2340        "nbspace" => '\u{00A0}',
2341        // Greek + math glyph names (standard Adobe Glyph List). Standard TeX math
2342        // fonts (CMMI/CMSY/…) name their glyphs this way in the embedded font
2343        // program's `/Encoding`; without these a `λ`/`≤` decodes to nothing and is
2344        // dropped from body text (`and λ set to 0.5` → `and set to 0.5`).
2345        "alpha" => '\u{03B1}',
2346        "beta" => '\u{03B2}',
2347        "gamma" => '\u{03B3}',
2348        "delta" => '\u{03B4}',
2349        "epsilon" | "epsilon1" => '\u{03B5}',
2350        "zeta" => '\u{03B6}',
2351        "eta" => '\u{03B7}',
2352        "theta" | "theta1" => '\u{03B8}',
2353        "iota" => '\u{03B9}',
2354        "kappa" => '\u{03BA}',
2355        "lambda" => '\u{03BB}',
2356        "mu" => '\u{03BC}',
2357        "nu" => '\u{03BD}',
2358        "xi" => '\u{03BE}',
2359        "omicron" => '\u{03BF}',
2360        "pi" | "pi1" => '\u{03C0}',
2361        "rho" | "rho1" => '\u{03C1}',
2362        "sigma" => '\u{03C3}',
2363        "sigma1" => '\u{03C2}',
2364        "tau" => '\u{03C4}',
2365        "upsilon" => '\u{03C5}',
2366        "phi" | "phi1" => '\u{03C6}',
2367        "chi" => '\u{03C7}',
2368        "psi" => '\u{03C8}',
2369        "omega" | "omega1" => '\u{03C9}',
2370        "Gamma" => '\u{0393}',
2371        "Delta" => '\u{0394}',
2372        "Theta" => '\u{0398}',
2373        "Lambda" => '\u{039B}',
2374        "Xi" => '\u{039E}',
2375        "Pi" => '\u{03A0}',
2376        "Sigma" => '\u{03A3}',
2377        "Upsilon" => '\u{03A5}',
2378        "Phi" => '\u{03A6}',
2379        "Psi" => '\u{03A8}',
2380        "Omega" => '\u{03A9}',
2381        "lessequal" => '\u{2264}',
2382        "greaterequal" => '\u{2265}',
2383        "notequal" => '\u{2260}',
2384        "approxequal" => '\u{2248}',
2385        "equivalence" => '\u{2261}',
2386        "element" => '\u{2208}',
2387        "plusminus" => '\u{00B1}',
2388        "multiply" => '\u{00D7}',
2389        "divide" => '\u{00F7}',
2390        "infinity" => '\u{221E}',
2391        "partialdiff" => '\u{2202}',
2392        "gradient" => '\u{2207}',
2393        "summation" => '\u{2211}',
2394        "product" => '\u{220F}',
2395        "integral" => '\u{222B}',
2396        "radical" => '\u{221A}',
2397        "proportional" => '\u{221D}',
2398        "arrowright" => '\u{2192}',
2399        "arrowleft" => '\u{2190}',
2400        "arrowup" => '\u{2191}',
2401        "arrowdown" => '\u{2193}',
2402        "arrowboth" => '\u{2194}',
2403        "arrowdblright" => '\u{21D2}',
2404        "logicaland" => '\u{2227}',
2405        "logicalor" => '\u{2228}',
2406        "intersection" => '\u{2229}',
2407        "union" => '\u{222A}',
2408        "similar" => '\u{223C}',
2409        "congruent" => '\u{2245}',
2410        "dotmath" => '\u{22C5}',
2411        "asteriskmath" => '\u{2217}',
2412        _ => {
2413            // Strip an AGL `.suffix` (oldstyle/small-cap variant) and retry.
2414            if let Some((base, _)) = s.split_once('.') {
2415                if !base.is_empty() {
2416                    return glyph_name_to_char(base.as_bytes());
2417                }
2418            }
2419            return None;
2420        }
2421    };
2422    Some(resolved)
2423}
2424
2425/// Minimal WinAnsiEncoding (Latin-1-ish) for simple fonts lacking ToUnicode.
2426fn winansi_table() -> HashMap<u8, char> {
2427    let mut m = HashMap::new();
2428    for b in 0x20u8..=0x7e {
2429        m.insert(b, b as char);
2430    }
2431    // High range: Windows-1252 printable points that differ from Latin-1.
2432    let extra: &[(u8, char)] = &[
2433        (0x91, '\u{2018}'),
2434        (0x92, '\u{2019}'),
2435        (0x93, '\u{201C}'),
2436        (0x94, '\u{201D}'),
2437        (0x95, '\u{2022}'),
2438        (0x96, '\u{2013}'),
2439        (0x97, '\u{2014}'),
2440        (0x85, '\u{2026}'),
2441        (0xA0, '\u{00A0}'),
2442    ];
2443    for &(b, c) in extra {
2444        m.insert(b, c);
2445    }
2446    for b in 0xA1u8..=0xFF {
2447        m.entry(b).or_insert(b as char);
2448    }
2449    m
2450}
2451
2452/// Minimal MacRomanEncoding: ASCII plus the high-range points our corpus hits
2453/// (notably 0xA5 = bullet, used as a list marker).
2454fn macroman_table() -> HashMap<u8, char> {
2455    let mut m = HashMap::new();
2456    for b in 0x20u8..=0x7e {
2457        m.insert(b, b as char);
2458    }
2459    let high: &[(u8, char)] = &[
2460        (0xA5, '\u{2022}'), // bullet
2461        (0xD0, '\u{2013}'), // endash
2462        (0xD1, '\u{2014}'), // emdash
2463        (0xD2, '\u{201C}'),
2464        (0xD3, '\u{201D}'),
2465        (0xD4, '\u{2018}'),
2466        (0xD5, '\u{2019}'),
2467        (0xCA, '\u{00A0}'),
2468        (0xC9, '\u{2026}'),
2469        (0xDE, '\u{FB01}'),
2470        (0xDF, '\u{FB02}'),
2471    ];
2472    for &(b, c) in high {
2473        m.insert(b, c);
2474    }
2475    m
2476}
2477
2478#[cfg(test)]
2479mod page_box_frame {
2480    use super::*;
2481
2482    /// One page, `boxes` spliced into the page dictionary verbatim, one text
2483    /// run at user-space `(x, y)`.
2484    fn pdf(boxes: &str, x: f32, y: f32) -> Vec<u8> {
2485        pdf_content(
2486            boxes,
2487            &format!("BT /F1 12 Tf {x} {y} Td (First printing) Tj ET\n"),
2488        )
2489    }
2490
2491    /// One page, `boxes` spliced into the page dictionary, `content` as its
2492    /// content stream (font `/F1` = Helvetica).
2493    fn pdf_content(boxes: &str, content: &str) -> Vec<u8> {
2494        let objs: Vec<String> = vec![
2495            "<</Type/Catalog/Pages 2 0 R>>".into(),
2496            format!("<</Type/Pages/Kids[3 0 R]/Count 1{boxes}>>"),
2497            "<</Type/Page/Parent 2 0 R/Contents 4 0 R/Resources<</Font<</F1 5 0 R>>>>>>".into(),
2498            format!("<</Length {}>>stream\n{content}endstream", content.len()),
2499            "<</Type/Font/Subtype/Type1/BaseFont/Helvetica>>".into(),
2500        ];
2501        let mut out = b"%PDF-1.4\n".to_vec();
2502        let mut offsets = Vec::new();
2503        for (i, body) in objs.iter().enumerate() {
2504            offsets.push(out.len());
2505            out.extend_from_slice(format!("{} 0 obj{body}endobj\n", i + 1).as_bytes());
2506        }
2507        let xref_at = out.len();
2508        out.extend_from_slice(
2509            format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes(),
2510        );
2511        for off in &offsets {
2512            out.extend_from_slice(format!("{off:010} 00000 n \n").as_bytes());
2513        }
2514        out.extend_from_slice(
2515            format!(
2516                "trailer<</Size {}/Root 1 0 R>>\nstartxref\n{xref_at}\n%%EOF\n",
2517                objs.len() + 1
2518            )
2519            .as_bytes(),
2520        );
2521        out
2522    }
2523
2524    fn only_page(bytes: &[u8]) -> (PageBox, Vec<Glyph>) {
2525        let doc = load_document(bytes).expect("loads");
2526        let pid = *doc.get_pages().values().next().expect("one page");
2527        (page_box(&doc, pid), page_glyphs(&doc, pid))
2528    }
2529
2530    /// The trimmed-book-page shape: MediaBox and CropBox both away from the
2531    /// origin (inherited from `/Pages`). The display box is the CropBox, and a
2532    /// glyph's coordinates count from its lower-left corner — the same numbers
2533    /// the same text gets on a page whose CropBox *is* `[0 0 w h]`.
2534    #[test]
2535    fn glyphs_count_from_the_cropbox_corner_like_pdfium() {
2536        let (pb, shifted) = only_page(&pdf(
2537            "/MediaBox[-56.505 -58.25 576.303 723.31]/CropBox[1.095 -0.65 518.703 665.71]",
2538            37.0 + 1.095,
2539            58.0 - 0.65,
2540        ));
2541        assert!((pb.l - 1.095).abs() < 1e-3 && (pb.b + 0.65).abs() < 1e-3);
2542        assert!(
2543            (pb.w - 517.608).abs() < 1e-3 && (pb.h - 666.36).abs() < 1e-3,
2544            "{pb:?}"
2545        );
2546        let (pb0, plain) = only_page(&pdf("/MediaBox[0 0 517.608 666.36]", 37.0, 58.0));
2547        assert!((pb0.w - pb.w).abs() < 1e-3 && (pb0.h - pb.h).abs() < 1e-3);
2548        assert_eq!(shifted.len(), plain.len());
2549        assert!(!plain.is_empty());
2550        for (a, b) in shifted.iter().zip(&plain) {
2551            assert!(
2552                (a.l - b.l).abs() < 1e-3 && (a.b - b.b).abs() < 1e-3,
2553                "{:?} vs {:?}",
2554                (a.l, a.b),
2555                (b.l, b.b)
2556            );
2557        }
2558        assert!((plain[0].l - 37.0).abs() < 1e-3, "{}", plain[0].l);
2559    }
2560
2561    fn text(glyphs: &[Glyph]) -> String {
2562        glyphs.iter().map(|g| g.ch).collect()
2563    }
2564
2565    /// #529: text drawn outside the display box — a print slug beside the
2566    /// CropBox, a tiled page's neighbour beyond the MediaBox — is dropped,
2567    /// glyph by glyph like docling-parse, instead of being clamped onto the
2568    /// page edge; a line crossing the edge keeps the glyphs that fit.
2569    #[test]
2570    fn glyphs_outside_the_display_box_are_dropped() {
2571        let (_, g) = only_page(&pdf_content(
2572            "/MediaBox[0 0 400 400]/CropBox[100 100 400 400]",
2573            "BT /F1 14 Tf 120 300 Td (Visible.) Tj ET\nBT /F1 8 Tf 5 40 Td (Slug) Tj ET\n",
2574        ));
2575        assert_eq!(text(&g), "Visible.");
2576        let (_, g) = only_page(&pdf_content(
2577            "/MediaBox[0 0 300 400]",
2578            "BT /F1 14 Tf 40 300 Td (On page) Tj ET\nBT /F1 14 Tf 400 250 Td (Beyond) Tj ET\n",
2579        ));
2580        assert_eq!(text(&g), "On page");
2581        // docling-parse: "Crossing the right edge" on a 300 pt page → "Crossing the rig".
2582        let (_, g) = only_page(&pdf_content(
2583            "/MediaBox[0 0 300 400]",
2584            "BT /F1 14 Tf 200 250 Td (Crossing the right edge) Tj ET\n",
2585        ));
2586        assert_eq!(text(&g), "Crossing the rig");
2587    }
2588
2589    /// The containment test is docling-parse's: the whole char box (advance
2590    /// × the font's ascent / descent) inside the display box, edges
2591    /// included — a Helvetica `W` at 50 pt (47.2 wide) ending exactly on the
2592    /// right edge stays, 0.01 pt further it goes; likewise on the left.
2593    #[test]
2594    fn a_glyph_on_the_edge_stays_one_over_it_goes() {
2595        let w_at = |x: f32, y: f32| {
2596            let (_, g) = only_page(&pdf_content(
2597                "/MediaBox[0 0 300 400]",
2598                &format!("BT /F1 50 Tf {x} {y} Td (W) Tj ET\n"),
2599            ));
2600            text(&g)
2601        };
2602        assert_eq!(w_at(252.8, 200.0), "W");
2603        assert_eq!(w_at(252.81, 200.0), "");
2604        assert_eq!(w_at(0.0, 200.0), "W");
2605        assert_eq!(w_at(-0.01, 200.0), "");
2606        // Vertically the box is the font's ascent / descent: a baseline far
2607        // enough up keeps the descender on the page, one at 0 does not.
2608        assert_eq!(w_at(100.0, 20.0), "W");
2609        assert_eq!(w_at(100.0, 0.0), "");
2610        assert_eq!(w_at(100.0, 380.0), "");
2611    }
2612
2613    /// pdfium's fallbacks: no MediaBox → Letter; a CropBox is clipped to the
2614    /// MediaBox, and one that misses it entirely is ignored.
2615    #[test]
2616    fn page_box_follows_pdfium_fallbacks() {
2617        let (pb, _) = only_page(&pdf("", 10.0, 10.0));
2618        assert_eq!((pb.l, pb.b, pb.w, pb.h), (0.0, 0.0, 612.0, 792.0));
2619        let (pb, _) = only_page(&pdf(
2620            "/MediaBox[0 0 500 700]/CropBox[-100 100 600 900]",
2621            10.0,
2622            10.0,
2623        ));
2624        assert_eq!((pb.l, pb.b, pb.w, pb.h), (0.0, 100.0, 500.0, 600.0));
2625        let (pb, _) = only_page(&pdf(
2626            "/MediaBox[0 0 500 700]/CropBox[800 800 900 900]",
2627            10.0,
2628            10.0,
2629        ));
2630        assert_eq!((pb.l, pb.b, pb.w, pb.h), (0.0, 0.0, 500.0, 700.0));
2631        // Reversed corners normalize.
2632        let (pb, _) = only_page(&pdf("/MediaBox[500 700 0 0]", 10.0, 10.0));
2633        assert_eq!((pb.l, pb.b, pb.w, pb.h), (0.0, 0.0, 500.0, 700.0));
2634    }
2635}
2636
2637#[cfg(test)]
2638mod xref_repair {
2639    /// Build a tiny one-page PDF whose cross-reference entries are either the
2640    /// spec's 20 bytes (`two_byte_eol`) or the 19-byte form some generators
2641    /// emit — everything else about the two files is identical.
2642    fn pdf_with_xref(two_byte_eol: bool) -> Vec<u8> {
2643        let content = b"BT /F1 12 Tf 72 700 Td (Invoice 922769430725) Tj ET\n";
2644        let stream = format!("<</Length {}>>stream\n", content.len()).into_bytes();
2645        let objs: Vec<Vec<u8>> = vec![
2646            b"<</Type/Catalog/Pages 2 0 R>>".to_vec(),
2647            b"<</Type/Pages/Kids[3 0 R]/Count 1>>".to_vec(),
2648            b"<</Type/Page/Parent 2 0 R/MediaBox[0 0 595 842]/Contents 4 0 R\
2649               /Resources<</Font<</F1 5 0 R>>>>>>"
2650                .to_vec(),
2651            [stream.as_slice(), content.as_slice(), b"endstream"].concat(),
2652            b"<</Type/Font/Subtype/Type1/BaseFont/Helvetica>>".to_vec(),
2653        ];
2654
2655        let mut out = b"%PDF-1.4\n".to_vec();
2656        let mut offsets = Vec::new();
2657        for (i, body) in objs.iter().enumerate() {
2658            offsets.push(out.len());
2659            out.extend_from_slice(format!("{} 0 obj", i + 1).as_bytes());
2660            out.extend_from_slice(body);
2661            out.extend_from_slice(b"endobj\n");
2662        }
2663        let xref_at = out.len();
2664        let eol: &[u8] = if two_byte_eol { b" \n" } else { b"\n" };
2665        out.extend_from_slice(format!("xref\n0 {}\n", objs.len() + 1).as_bytes());
2666        out.extend_from_slice(b"0000000000 65535 f");
2667        out.extend_from_slice(eol);
2668        for off in &offsets {
2669            out.extend_from_slice(format!("{off:010} 00000 n").as_bytes());
2670            out.extend_from_slice(eol);
2671        }
2672        out.extend_from_slice(
2673            format!("trailer<</Size {}/Root 1 0 R>>\n", objs.len() + 1).as_bytes(),
2674        );
2675        out.extend_from_slice(format!("startxref\n{xref_at}\n%%EOF\n").as_bytes());
2676        out
2677    }
2678
2679    /// A 19-byte cross-reference entry (a bare LF where the spec wants a
2680    /// two-byte EOL) makes lopdf reject the whole file, so a readable text
2681    /// layer used to look exactly like a scan — in the browser that meant ten
2682    /// seconds of OCR for nothing. The repair must recover *the same* parse the
2683    /// well-formed file gives.
2684    #[test]
2685    fn short_xref_entries_still_parse() {
2686        let good = pdf_with_xref(true);
2687        let broken = pdf_with_xref(false);
2688        assert!(
2689            broken.len() < good.len(),
2690            "the broken file is the shorter one"
2691        );
2692        assert!(
2693            lopdf::Document::load_mem(&good).is_ok(),
2694            "the control file must load unaided"
2695        );
2696        assert!(
2697            lopdf::Document::load_mem(&broken).is_err(),
2698            "lopdf rejects 19-byte entries — if this ever passes, drop the repair"
2699        );
2700
2701        let cells = |b: &[u8]| -> Vec<String> {
2702            super::pdf_textlines(b)
2703                .into_iter()
2704                .flat_map(|(_, _, c)| c.into_iter().map(|c| c.text))
2705                .collect()
2706        };
2707        let from_good = cells(&good);
2708        assert!(
2709            from_good.iter().any(|t| t.contains("922769430725")),
2710            "control text: {from_good:?}"
2711        );
2712        assert_eq!(
2713            cells(&broken),
2714            from_good,
2715            "repair must match the good parse"
2716        );
2717    }
2718
2719    /// The same generator overstates `/Length`, so lopdf reads past the data,
2720    /// misses `endstream` and drops the stream — the object comes back as a
2721    /// bare dictionary and the page has no content at all. Trust `endstream`
2722    /// instead, and do it without moving a single byte.
2723    #[test]
2724    fn overstated_stream_length_still_yields_content() {
2725        let good = pdf_with_xref(true);
2726        // Inflate the content stream's /Length by one, exactly as the invoice
2727        // that prompted this does.
2728        let broken = {
2729            let at = good
2730                .windows(8)
2731                .position(|w| w == b"/Length ")
2732                .expect("a /Length")
2733                + 8;
2734            let digits = good[at..].iter().take_while(|c| c.is_ascii_digit()).count();
2735            let n: usize = std::str::from_utf8(&good[at..at + digits])
2736                .unwrap()
2737                .parse()
2738                .unwrap();
2739            let inflated = (n + 1).to_string();
2740            assert_eq!(inflated.len(), digits, "keep the digit count");
2741            let mut b = good.clone();
2742            b[at..at + digits].copy_from_slice(inflated.as_bytes());
2743            b
2744        };
2745        assert_eq!(broken.len(), good.len(), "the defect must not move bytes");
2746        // lopdf alone loses the stream: the page parses but carries no content.
2747        let raw = lopdf::Document::load_mem(&broken).expect("still loads");
2748        assert!(
2749            raw.get_pages()
2750                .into_values()
2751                .all(|p| raw.get_page_content(p).is_empty()),
2752            "lopdf should drop the stream — if it stops, drop this repair"
2753        );
2754        // Ours recovers the same text the well-formed file gives.
2755        let text = |b: &[u8]| -> Vec<String> {
2756            super::pdf_textlines(b)
2757                .into_iter()
2758                .flat_map(|(_, _, c)| c.into_iter().map(|c| c.text))
2759                .collect()
2760        };
2761        let expected = text(&good);
2762        assert!(!expected.is_empty(), "control must produce text");
2763        assert_eq!(text(&broken), expected);
2764    }
2765
2766    /// The repair only fires where padding cannot move an object: it declines a
2767    /// file whose xref precedes an object (an incremental update), rather than
2768    /// shifting every offset the table records.
2769    #[test]
2770    fn repair_declines_when_padding_would_move_objects() {
2771        let mut incremental = pdf_with_xref(false);
2772        incremental.extend_from_slice(b"6 0 obj<</Type/Whatever>>endobj\n");
2773        let declined = super::pad_short_xref_entries(&incremental).unwrap_err();
2774        assert!(
2775            declined.contains("object follows the xref"),
2776            "reason: {declined}"
2777        );
2778    }
2779}
2780
2781/// #187: standard-14 fonts referenced without an embedded program (and thus
2782/// usually without `/Widths` or a `/FontDescriptor`) must decode with the
2783/// built-in Adobe Core 14 metrics instead of collapsing every cell to zero
2784/// width — the failure mode where a valid text layer was silently dropped
2785/// while pdfium read the same file fine.
2786#[cfg(test)]
2787mod base14_fonts {
2788    /// A one-page PDF whose single `Tj` uses `fontdict` (no embedded program).
2789    fn pdf_with_font(fontdict: &[u8], text: &[u8]) -> Vec<u8> {
2790        let content = [b"BT /F1 12 Tf 72 700 Td (".as_slice(), text, b") Tj ET\n"].concat();
2791        pdf_with_content(fontdict, &content)
2792    }
2793
2794    /// A one-page A4 PDF drawing `content` with `fontdict` as `/F1`.
2795    fn pdf_with_content(fontdict: &[u8], content: &[u8]) -> Vec<u8> {
2796        pdf_with_fonts(&[fontdict], content)
2797    }
2798
2799    /// A one-page A4 PDF drawing `content` with `fontdicts[i]` bound as
2800    /// `/F{i+1}` (objects 5, 6, …).
2801    fn pdf_with_fonts(fontdicts: &[&[u8]], content: &[u8]) -> Vec<u8> {
2802        let stream = format!("<</Length {}>>stream\n", content.len()).into_bytes();
2803        let font_res: String = fontdicts
2804            .iter()
2805            .enumerate()
2806            .map(|(i, _)| format!("/F{} {} 0 R", i + 1, i + 5))
2807            .collect();
2808        let mut objs: Vec<Vec<u8>> = vec![
2809            b"<</Type/Catalog/Pages 2 0 R>>".to_vec(),
2810            b"<</Type/Pages/Kids[3 0 R]/Count 1>>".to_vec(),
2811            format!(
2812                "<</Type/Page/Parent 2 0 R/MediaBox[0 0 595 842]/Contents 4 0 R\
2813                 /Resources<</Font<<{font_res}>>>>>>"
2814            )
2815            .into_bytes(),
2816            [stream.as_slice(), content, b"endstream"].concat(),
2817        ];
2818        objs.extend(fontdicts.iter().map(|d| d.to_vec()));
2819        let mut out = b"%PDF-1.4\n".to_vec();
2820        let mut offsets = Vec::new();
2821        for (i, body) in objs.iter().enumerate() {
2822            offsets.push(out.len());
2823            out.extend_from_slice(format!("{} 0 obj", i + 1).as_bytes());
2824            out.extend_from_slice(body);
2825            out.extend_from_slice(b"endobj\n");
2826        }
2827        let xref_at = out.len();
2828        out.extend_from_slice(format!("xref\n0 {}\n", objs.len() + 1).as_bytes());
2829        out.extend_from_slice(b"0000000000 65535 f \n");
2830        for off in &offsets {
2831            out.extend_from_slice(format!("{off:010} 00000 n \n").as_bytes());
2832        }
2833        out.extend_from_slice(
2834            format!("trailer<</Size {}/Root 1 0 R>>\n", objs.len() + 1).as_bytes(),
2835        );
2836        out.extend_from_slice(format!("startxref\n{xref_at}\n%%EOF\n").as_bytes());
2837        out
2838    }
2839
2840    /// The parsed cells of the only page.
2841    fn cells(pdf: &[u8]) -> Vec<crate::pdfium_backend::TextCell> {
2842        super::pdf_textlines(pdf)
2843            .into_iter()
2844            .flat_map(|(_, _, c)| c)
2845            .collect()
2846    }
2847
2848    /// Text drawn with a rotated text matrix reads as one line in its own
2849    /// reading order, boxed by the run's axis-aligned extent (#528). The
2850    /// landscape case is `0 s -s 0 tx ty Tm` on a `/Rotate 90` page; every
2851    /// such run used to collapse to zero width and vanish (90°/270°/tilted)
2852    /// or read backwards (180°). Expected boxes are docling-parse 7.22's line
2853    /// cells for the same runs (y-up, PDF points).
2854    #[test]
2855    fn rotated_text_matrix_reads_in_order() {
2856        const HELV: &[u8] = b"<</Type/Font/Subtype/Type1/BaseFont/Helvetica>>";
2857        const TEXT: &str = "Upright text on a rotated page.";
2858        for (tm, [l, b, r, t]) in [
2859            ("0 14 -14 0 200 100", [189.9, 100.0, 202.9, 289.1]), // 90°
2860            ("-14 0 0 -14 350 300", [160.9, 289.9, 350.0, 302.9]), // 180°
2861            ("0 -14 14 0 200 500", [197.1, 310.9, 210.1, 500.0]), // 270°
2862            (
2863                "9.8995 9.8995 -9.8995 9.8995 100 100",
2864                [92.9, 98.0, 235.8, 240.8],
2865            ), // 45°
2866        ] {
2867            let content = format!("BT /F1 1 Tf {tm} Tm ({TEXT}) Tj ET\n");
2868            let pdf = pdf_with_content(HELV, content.as_bytes());
2869            let cs = cells(&pdf);
2870            assert_eq!(cs.len(), 1, "{tm}: {cs:?}");
2871            let c = &cs[0];
2872            assert_eq!(c.text, TEXT, "{tm}");
2873            // `cells` is top-left-origin on the 842 pt page. The descriptor-
2874            // less base-14 face gets the parser's 1-em box where docling-parse
2875            // reads Helvetica's AFM (718/−207) — the same ≤ 0.6 pt
2876            // across-the-baseline offset upright text has — so allow 1 pt.
2877            let got = [c.l, 842.0 - c.b, c.r, 842.0 - c.t];
2878            for (g, w) in got.iter().zip([l, b, r, t]) {
2879                assert!(
2880                    (g - w).abs() < 1.0,
2881                    "{tm}: box {got:?} vs docling-parse {:?}",
2882                    [l, b, r, t]
2883                );
2884            }
2885            let words: Vec<String> = super::pdf_words(&pdf)
2886                .into_iter()
2887                .flat_map(|(_, _, c)| c)
2888                .map(|c| c.text)
2889                .collect();
2890            assert_eq!(words, TEXT.split(' ').collect::<Vec<_>>(), "{tm}");
2891        }
2892    }
2893
2894    /// A rotated run past the page edge (a cover's spine title) is dropped
2895    /// by the on-page test (#529) — measured on the quad's extent, not the
2896    /// zero-width box rotated glyphs used to get — instead of clamping onto
2897    /// the edge as a zero-width cell. One inside the page is kept.
2898    #[test]
2899    fn rotated_text_off_the_page_is_dropped() {
2900        const HELV: &[u8] = b"<</Type/Font/Subtype/Type1/BaseFont/Helvetica>>";
2901        let off = pdf_with_content(
2902            HELV,
2903            b"BT /F1 1 Tf 0 14 -14 0 -4 200 Tm (Spine title) Tj ET\n",
2904        );
2905        assert!(cells(&off).is_empty(), "{:?}", cells(&off));
2906        let on = pdf_with_content(
2907            HELV,
2908            b"BT /F1 1 Tf 0 14 -14 0 30 200 Tm (Margin note) Tj ET\n",
2909        );
2910        assert_eq!(cells(&on).len(), 1);
2911    }
2912
2913    /// Upright text keeps the plain loose rectangle — no quad, and the
2914    /// sanitizer path is the one every pinned PDF baseline was made with.
2915    #[test]
2916    fn upright_glyphs_carry_no_quad() {
2917        let only_page = |pdf: &[u8]| {
2918            let doc = super::load_document(pdf).expect("loads");
2919            let pid = *doc.get_pages().values().next().expect("one page");
2920            super::page_glyphs(&doc, pid)
2921        };
2922        let pdf = pdf_with_font(b"<</Type/Font/Subtype/Type1/BaseFont/Helvetica>>", b"Plain");
2923        let glyphs = only_page(&pdf);
2924        assert!(!glyphs.is_empty());
2925        assert!(glyphs.iter().all(|g| g.quad.is_none() && g.lr > g.ll));
2926        // A 90° glyph's style height is its font size, not its advance.
2927        let pdf = pdf_with_content(
2928            b"<</Type/Font/Subtype/Type1/BaseFont/Helvetica>>",
2929            b"BT /F1 1 Tf 0 14 -14 0 200 100 Tm (W) Tj ET\n",
2930        );
2931        let glyphs = only_page(&pdf);
2932        let g = &glyphs[0];
2933        assert!(g.quad.is_some());
2934        // 14 pt × the face's 1-em box (no descriptor: ascent − descent = 1).
2935        assert!((g.height() - 14.0).abs() < 0.05, "{}", g.height());
2936    }
2937
2938    /// #609: ReportLab's chart axis sets each tick label with the *same* `Tm`
2939    /// inside its own `q … cm … Q`, so only the CTM stacks `3`…`8` one under
2940    /// another at one x. Each tick is its own line cell with its own box (as
2941    /// docling-parse reads it), not one `345678` cell boxed like the first.
2942    #[test]
2943    fn ticks_stacked_by_cm_are_separate_line_cells() {
2944        let mut content = String::new();
2945        for (k, tick) in "345678".chars().enumerate() {
2946            let y = 300.0 + 25.92 * k as f64;
2947            content.push_str(&format!(
2948                "q 1 0 0 1 125 {y} cm BT /F1 10 Tf 1 0 0 1 -5 -4 Tm ({tick}) Tj ET Q\n"
2949            ));
2950        }
2951        let pdf = pdf_with_content(
2952            b"<</Type/Font/Subtype/Type1/BaseFont/Times-Roman>>",
2953            content.as_bytes(),
2954        );
2955        let cells = cells(&pdf);
2956        let texts: Vec<&str> = cells.iter().map(|c| c.text.as_str()).collect();
2957        assert_eq!(texts, ["3", "4", "5", "6", "7", "8"]);
2958        for (k, c) in cells.iter().enumerate() {
2959            // Top-left y of a tick whose baseline is 4 pt under its cm origin.
2960            let base = 842.0 - (300.0 + 25.92 * k as f32 - 4.0);
2961            assert!((c.l - 120.0).abs() < 0.1, "{k}: l {}", c.l);
2962            assert!(
2963                c.t < base && base - c.t < 10.0,
2964                "{k}: t {} base {base}",
2965                c.t
2966            );
2967        }
2968    }
2969
2970    /// #609: the path walk finds drawn checkbox squares — ReportLab's four
2971    /// `m … l S` edges under a `cm`, and a stroked `re` with a tick inside
2972    /// (checked) — in the text cells' top-left frame, and a stroked box
2973    /// filled white; a filled bar, a big frame and a colour-filled legend
2974    /// swatch are not checkboxes.
2975    #[test]
2976    fn drawn_checkbox_squares_are_found() {
2977        let content = b"q 1 0 0 1 119.52 449.04 cm \
2978            n 0 12.96 m 12.96 12.96 l S n 0 0 m 12.96 0 l S \
2979            n 0 0 m 0 12.96 l S n 12.96 0 m 12.96 12.96 l S Q\n\
2980            200 400 10 10 re S 202 405 m 204.5 402 l 208.5 408.5 l S\n\
2981            300 400 12 12 re f 50 50 100 100 re S\n\
2982            q 0.2 0.4 0.8 rg 400 400 12 12 re B Q q 1 g 500 400 12 12 re B Q\n";
2983        let pdf = pdf_with_content(b"<</Type/Font/Subtype/Type1/BaseFont/Helvetica>>", content);
2984        let mut parser = super::PageTextParser::open(&pdf).expect("parses");
2985        let mut boxes = parser.cells(0).checkboxes;
2986        boxes.sort_by(|a, b| a.l.total_cmp(&b.l));
2987        let got: Vec<([i32; 4], bool)> = boxes
2988            .iter()
2989            .map(|c| {
2990                let r = |v: f32| (v * 100.0).round() as i32;
2991                ([r(c.l), r(c.t), r(c.r), r(c.b)], c.checked)
2992            })
2993            .collect();
2994        // Page height 842: y-up 449.04..462.0 → top-left 380.0..392.96.
2995        assert_eq!(
2996            got,
2997            [
2998                ([11952, 38000, 13248, 39296], false),
2999                ([20000, 43200, 21000, 44200], true),
3000                ([50000, 43000, 51200, 44200], false),
3001            ]
3002        );
3003    }
3004
3005    /// Every standard-14 alias/style decodes with real (positive-width) boxes.
3006    #[test]
3007    fn standard14_faces_get_builtin_widths() {
3008        for fontdict in [
3009            // ReportLab's default: base-14 Helvetica, WinAnsi, nothing else.
3010            b"<</Type/Font/Subtype/Type1/BaseFont/Helvetica/Encoding/WinAnsiEncoding>>".as_slice(),
3011            // No /Encoding at all (StandardEncoding-ish default).
3012            b"<</Type/Font/Subtype/Type1/BaseFont/Times-BoldItalic>>",
3013            // Substitution aliases + a subset prefix.
3014            b"<</Type/Font/Subtype/TrueType/BaseFont/Arial,Bold>>",
3015            b"<</Type/Font/Subtype/Type1/BaseFont/ABCDEF+Courier-Oblique>>",
3016        ] {
3017            let pdf = pdf_with_font(fontdict, b"Words have width now");
3018            let cs = cells(&pdf);
3019            let text: String = cs
3020                .iter()
3021                .map(|c| c.text.as_str())
3022                .collect::<Vec<_>>()
3023                .join(" ");
3024            assert!(
3025                text.contains("Words have width now"),
3026                "{}: text lost: {text:?}",
3027                String::from_utf8_lossy(fontdict)
3028            );
3029            assert!(
3030                cs.iter().all(|c| c.r > c.l),
3031                "{}: zero-width cells: {cs:?}",
3032                String::from_utf8_lossy(fontdict)
3033            );
3034        }
3035    }
3036
3037    /// An explicit `/Widths` array always wins over the built-in metrics, and a
3038    /// non-standard face without `/Widths` stays as before (no invented boxes).
3039    #[test]
3040    fn explicit_widths_win_and_unknown_faces_are_untouched() {
3041        // Helvetica with explicit 100/1000-em widths: the word's box must be
3042        // ~4×100 units at 12pt = 4.8pt wide — far narrower than the ~2.7×
3043        // wider built-in Helvetica advances would make it.
3044        let explicit = pdf_with_font(
3045            b"<</Type/Font/Subtype/Type1/BaseFont/Helvetica/FirstChar 65\
3046               /Widths[100 100 100 100]/Encoding/WinAnsiEncoding>>",
3047            b"ABBA",
3048        );
3049        let builtin = pdf_with_font(
3050            b"<</Type/Font/Subtype/Type1/BaseFont/Helvetica/Encoding/WinAnsiEncoding>>",
3051            b"ABBA",
3052        );
3053        let w = |pdf: &[u8]| {
3054            let cs = cells(pdf);
3055            assert_eq!(cs.len(), 1, "one word cell: {cs:?}");
3056            cs[0].r - cs[0].l
3057        };
3058        let (we, wb) = (w(&explicit), w(&builtin));
3059        assert!(
3060            (we - 4.8).abs() < 0.1,
3061            "explicit widths must win: got {we}, want 4×100×12/1000"
3062        );
3063        assert!(
3064            wb > 2.0 * we,
3065            "built-in Helvetica is much wider: {wb} vs {we}"
3066        );
3067
3068        // An unknown face with no /Widths: still parses (text kept), but no
3069        // built-in table applies — the old zero-width behavior is preserved
3070        // rather than inventing Helvetica metrics for an arbitrary font.
3071        let unknown = pdf_with_font(
3072            b"<</Type/Font/Subtype/Type1/BaseFont/FancyCorp-Display>>",
3073            b"Mystery",
3074        );
3075        let cs = cells(&unknown);
3076        let text: String = cs.iter().map(|c| c.text.as_str()).collect();
3077        assert!(text.contains("Mystery"), "text still decodes: {cs:?}");
3078    }
3079    /// #728: one face bound under two resource keys — `/F1` Helvetica in
3080    /// WinAnsi, `/F2` the same Helvetica re-encoded so code 0x81 is the `fi`
3081    /// glyph — with every fi-word drawn in `/F2` right after an `/F1` run.
3082    /// docling-parse names a font by `/FontDescriptor/FontName` → `/Name` →
3083    /// `/BaseFont`, never by the resource key, so both resources are the same
3084    /// font and the line reads `financial`. Keyed by resource key the `fi`
3085    /// ligature (font-neutral in `applicable_for_merge`) joined the `/F1`
3086    /// run and the font barrier then split it from `nancial`.
3087    #[test]
3088    fn same_face_under_two_resource_keys_is_one_font_for_the_sanitizer() {
3089        let f1 = b"<</Type/Font/Subtype/Type1/BaseFont/Helvetica/Encoding/WinAnsiEncoding>>";
3090        let f2 = b"<</Type/Font/Subtype/Type1/BaseFont/Helvetica/Encoding<</Type/Encoding\
3091                   /BaseEncoding/WinAnsiEncoding/Differences[129/fi]>>>>";
3092        let content = b"BT 12 TL 72 720 Td \
3093            /F1 12 Tf (The ) Tj /F2 12 Tf (\\201nancial position) Tj \
3094            /F1 12 Tf ( of the company is shown in ) Tj /F2 12 Tf (\\201gure 3) Tj \
3095            /F1 12 Tf ( with the ) Tj /F2 12 Tf (\\201rst results.) Tj \
3096            T* T* /F2 12 Tf (Same words in /F2 only: \\201nancial, \\201gure, \\201rst.) Tj ET\n";
3097        let cs = cells(&pdf_with_fonts(&[f1, f2], content));
3098        let lines: Vec<&str> = cs.iter().map(|c| c.text.as_str()).collect();
3099        assert_eq!(
3100            lines,
3101            [
3102                "The financial position of the company is shown in figure 3 with the first results.",
3103                "Same words in /F2 only: financial, figure, first.",
3104            ],
3105            "{cs:?}"
3106        );
3107    }
3108
3109    /// The font barrier itself is untouched: two *different* faces (regular
3110    /// and bold Helvetica, which docling-parse names apart) set flush against
3111    /// each other stay separate line cells, as `enforce_same_font` demands.
3112    #[test]
3113    fn different_faces_still_keep_the_font_barrier() {
3114        let f1 = b"<</Type/Font/Subtype/Type1/BaseFont/Helvetica/Encoding/WinAnsiEncoding>>";
3115        let f2 = b"<</Type/Font/Subtype/Type1/BaseFont/Helvetica-Bold/Encoding/WinAnsiEncoding>>";
3116        let content = b"BT 72 720 Td /F2 12 Tf (Total:) Tj /F1 12 Tf (42) Tj ET\n";
3117        let cs = cells(&pdf_with_fonts(&[f1, f2], content));
3118        let texts: Vec<&str> = cs.iter().map(|c| c.text.as_str()).collect();
3119        assert_eq!(texts, ["Total:", "42"], "{cs:?}");
3120    }
3121
3122    /// The identity follows docling-parse's precedence: a `/FontDescriptor/FontName`
3123    /// wins over `/BaseFont`, so two dictionaries that share a `/BaseFont` but
3124    /// describe different programs are two fonts, and two that share the
3125    /// descriptor name are one whatever their `/BaseFont` says.
3126    #[test]
3127    fn sanitizer_font_name_follows_docling_parse_precedence() {
3128        use lopdf::{dictionary, Document, Object};
3129        let mut doc = Document::with_version("1.4");
3130        let fd_a =
3131            doc.add_object(dictionary! { "Type" => "FontDescriptor", "FontName" => "AAAAAA+Face" });
3132        let fd_b =
3133            doc.add_object(dictionary! { "Type" => "FontDescriptor", "FontName" => "BBBBBB+Face" });
3134        let with = |fd: Option<lopdf::ObjectId>, base: &str| {
3135            let mut d = dictionary! { "Type" => "Font", "Subtype" => "Type1", "BaseFont" => base };
3136            if let Some(id) = fd {
3137                d.set("FontDescriptor", Object::Reference(id));
3138            }
3139            d
3140        };
3141        let name = |d: &lopdf::Dictionary| {
3142            String::from_utf8(super::sanitizer_font_name(&doc, d, false)).unwrap()
3143        };
3144        assert_eq!(name(&with(Some(fd_a), "Face")), "AAAAAA+Face");
3145        assert_eq!(name(&with(Some(fd_b), "Face")), "BBBBBB+Face");
3146        assert_eq!(name(&with(None, "ABCDEF+Face")), "ABCDEF+Face");
3147        assert_eq!(
3148            name(&dictionary! { "Type" => "Font", "Name" => "F7" }),
3149            "F7"
3150        );
3151        assert_eq!(name(&dictionary! { "Type" => "Font" }), "null");
3152    }
3153}
3154
3155#[cfg(test)]
3156mod overpainted {
3157    use crate::pdfium_backend::TextCell;
3158
3159    fn cell(text: &str, l: f32, t: f32, r: f32, b: f32) -> TextCell {
3160        TextCell {
3161            text: text.into(),
3162            l,
3163            t,
3164            r,
3165            b,
3166        }
3167    }
3168
3169    /// The reporting invoice's logo: a `"` painted inside a `==` on one band —
3170    /// artwork drawn with glyphs. Both cells go; the real text on the next
3171    /// band stays.
3172    #[test]
3173    fn stacked_logo_glyphs_are_dropped() {
3174        let mut cells = vec![
3175            cell("\"", 72.7, 21.5, 86.4, 31.5),
3176            cell("==", 59.4, 21.5, 99.6, 31.5),
3177            cell("Herr", 65.2, 151.3, 81.7, 161.3),
3178        ];
3179        super::drop_overpainted_cells(&mut cells);
3180        assert_eq!(cells.len(), 1, "cells: {cells:?}");
3181        assert_eq!(cells[0].text, "Herr");
3182    }
3183
3184    /// Adjacent words on a line touch but never contain each other — prose is
3185    /// untouched, and so is a same-text near-duplicate (double-drawn faux
3186    /// bold), which is not evidence of artwork.
3187    #[test]
3188    fn prose_and_double_draw_are_kept() {
3189        let mut cells = vec![
3190            cell("Telefon", 354.3, 133.2, 381.5, 143.2),
3191            cell("0676/2000", 387.3, 133.2, 428.7, 143.2),
3192            cell("Bold", 100.0, 50.0, 130.0, 60.0),
3193            cell("Bold", 100.3, 50.0, 130.3, 60.0),
3194        ];
3195        super::drop_overpainted_cells(&mut cells);
3196        assert_eq!(cells.len(), 4);
3197    }
3198}
3199
3200#[cfg(test)]
3201mod vestigial_layer {
3202    use crate::pdfium_backend::{PdfPage, TextCell};
3203
3204    fn page_with(texts: &[&str]) -> PdfPage {
3205        let cells = texts
3206            .iter()
3207            .enumerate()
3208            .map(|(i, t)| TextCell {
3209                text: t.to_string(),
3210                l: 10.0,
3211                t: 10.0 + 12.0 * i as f32,
3212                r: 90.0,
3213                b: 20.0 + 12.0 * i as f32,
3214            })
3215            .collect();
3216        PdfPage::from_cells(595.0, 842.0, 1.0, cells)
3217    }
3218
3219    /// The reported scanned form: three typed-in field values ("03", "05",
3220    /// "2025") over three image pages. That must read as *no usable layer*,
3221    /// so the browser routes the document to OCR instead of extracting
3222    /// thirteen characters and skipping the letter entirely.
3223    #[test]
3224    fn typed_in_form_fields_are_not_a_text_layer() {
3225        let pages = vec![
3226            page_with(&["03", "05", "2025"]),
3227            page_with(&[]),
3228            page_with(&[]),
3229        ];
3230        assert!(super::text_layer_is_vestigial(&pages));
3231        assert!(super::text_layer_is_vestigial(&[page_with(&[])]));
3232    }
3233
3234    /// A short but genuine digital document — one page, a few real lines —
3235    /// keeps the fast text path.
3236    #[test]
3237    fn sparse_but_real_documents_pass() {
3238        let one_pager = vec![page_with(&[
3239            "Confidential briefing",
3240            "Prepared for the board meeting",
3241            "Do not distribute",
3242        ])];
3243        assert!(!super::text_layer_is_vestigial(&one_pager));
3244    }
3245}