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