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, which
29/// feeds the docling-parse font hash). Inline (non-reference) dicts are rare
30/// 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}
101
102impl Font {
103    fn decode_code(&self, code: u32) -> (Option<String>, f64) {
104        let w = self
105            .widths
106            .get(&code)
107            .copied()
108            .unwrap_or(self.default_width);
109        if let Some(s) = self.to_unicode.get(&code) {
110            return (Some(decompose_ligatures(s)), w);
111        }
112        if !self.two_byte {
113            // A GID-style `/Differences` name (no Unicode) overrides the base
114            // encoding, matching docling's verbatim `/g115` fallback.
115            if let Some(name) = self.fallback_names.get(&(code as u8)) {
116                return (Some(format!("/{name}")), w);
117            }
118            if let Some(enc) = &self.simple_encoding {
119                if let Some(&ch) = enc.get(&(code as u8)) {
120                    return (Some(decompose_ligatures(&ch.to_string())), w);
121                }
122            }
123            // Last resort: the embedded Type1 font program's own `/Encoding`
124            // vector (`dup N /glyphname put`). Standard TeX math fonts (CMMI, CMSY,
125            // …) ship no PDF `/Encoding` and no ToUnicode, so a glyph like `λ`
126            // (CMMI code 21 → `/lambda`) or `≤` (CMSY code 20 → `/lessequal`) has
127            // no other mapping and would otherwise be silently dropped. docling
128            // recovers these from the same font program. This only fills codes the
129            // base encoding left unmapped, so it never changes an existing decode.
130            if let Some(&ch) = self.program_encoding.get(&(code as u8)) {
131                return (Some(decompose_ligatures(&ch.to_string())), w);
132            }
133        }
134        (None, w)
135    }
136}
137
138/// Spell out Latin presentation-form ligatures (`fi`→`fi`, `ffi`→`ffi`, …) the way
139/// docling does, so `configuration`/`difficult` don't keep the ligature glyph.
140/// The chars share the ligature's box, so the line sanitizer recomposes them.
141fn decompose_ligatures(s: &str) -> String {
142    if !s.chars().any(|c| ('\u{FB00}'..='\u{FB06}').contains(&c)) {
143        return s.to_string();
144    }
145    s.chars()
146        .map(|c| {
147            match c {
148                '\u{FB00}' => "ff",
149                '\u{FB01}' => "fi",
150                '\u{FB02}' => "fl",
151                '\u{FB03}' => "ffi",
152                '\u{FB04}' => "ffl",
153                '\u{FB05}' => "ft",
154                '\u{FB06}' => "st",
155                _ => return c.to_string(),
156            }
157            .to_string()
158        })
159        .collect()
160}
161
162fn hash_name(name: &[u8]) -> u64 {
163    use std::hash::{Hash, Hasher};
164    let mut h = std::collections::hash_map::DefaultHasher::new();
165    name.hash(&mut h);
166    h.finish()
167}
168
169/// Resolve a possibly-indirect object to a dictionary.
170fn as_dict<'a>(doc: &'a Document, obj: &'a Object) -> Option<&'a Dictionary> {
171    match obj {
172        Object::Dictionary(d) => Some(d),
173        Object::Reference(id) => doc.get_object(*id).ok().and_then(|o| o.as_dict().ok()),
174        _ => None,
175    }
176}
177
178fn deref<'a>(doc: &'a Document, obj: &'a Object) -> Option<&'a Object> {
179    match obj {
180        Object::Reference(id) => doc.get_object(*id).ok(),
181        other => Some(other),
182    }
183}
184
185/// Parse one font dictionary into a [`Font`].
186fn parse_font(doc: &Document, name: &[u8], fdict: &Dictionary) -> Font {
187    let subtype: &[u8] = fdict
188        .get(b"Subtype")
189        .ok()
190        .and_then(|o| o.as_name().ok())
191        .unwrap_or(&[]);
192    let two_byte = subtype == b"Type0".as_slice();
193
194    let to_unicode = fdict
195        .get(b"ToUnicode")
196        .ok()
197        .and_then(|o| deref(doc, o))
198        .and_then(|o| o.as_stream().ok())
199        .and_then(|s| s.decompressed_content().ok())
200        .map(|data| parse_tounicode(&data))
201        .unwrap_or_default();
202
203    let (mut widths, mut default_width) = if two_byte {
204        cid_widths(doc, fdict)
205    } else {
206        simple_widths(doc, fdict)
207    };
208
209    let simple_encoding = if two_byte {
210        None
211    } else {
212        Some(simple_encoding_table(doc, fdict))
213    };
214
215    // A standard-14 font referenced without an embedded program usually ships
216    // no `/Widths` and no `/FontDescriptor` either (ReportLab's default, #187).
217    // Every advance then resolved to 0, the cells collapsed to zero width, and
218    // the page's whole text layer was silently dropped — while pdfium, with
219    // its built-in metrics, reads the same file fine. Fill the widths from the
220    // built-in Adobe Core 14 AFM tables via the font's own code→char decode
221    // (base encoding + `/Differences`), so an explicit `/Widths` always wins.
222    if !two_byte && widths.is_empty() && default_width == 0.0 {
223        if let Some(std14) = base_font_name(fdict).and_then(|n| crate::std14::widths_for(&n)) {
224            if let Some(enc) = &simple_encoding {
225                for (&code, &ch) in enc {
226                    if let Some(w) = std14.width(ch) {
227                        widths.insert(u32::from(code), w);
228                    }
229                }
230            }
231            // Codes the table misses still advance a typical width instead of
232            // stacking at x=0 (the failure mode this whole branch fixes).
233            default_width = 500.0;
234        }
235    }
236    let fallback_names = if two_byte {
237        HashMap::new()
238    } else {
239        differences_gid_names(doc, fdict)
240    };
241    let program_encoding = if two_byte {
242        HashMap::new()
243    } else {
244        type1_program_encoding(doc, fdict)
245    };
246
247    let (ascent, descent) = font_ascent_descent(doc, fdict, two_byte);
248
249    Font {
250        two_byte,
251        to_unicode,
252        widths,
253        default_width,
254        simple_encoding,
255        fallback_names,
256        program_encoding,
257        ascent,
258        descent,
259        hash: hash_name(name),
260    }
261}
262
263/// Collect `/Differences` entries whose glyph name is a GID placeholder
264/// (`g115`, `cid42`, `glyph7`, `index9`) with no Unicode mapping. docling-parse
265/// emits such glyphs as the literal name `/g115`; mapping them here keeps the
266/// text from being silently dropped (subsetted fonts with no ToUnicode). The
267/// GID-name restriction keeps real Adobe glyph names on the normal path so this
268/// never invents garbage on the clean files.
269fn differences_gid_names(doc: &Document, fdict: &Dictionary) -> HashMap<u8, String> {
270    let mut map = HashMap::new();
271    let Some(Object::Dictionary(enc)) = fdict.get(b"Encoding").ok().and_then(|o| deref(doc, o))
272    else {
273        return map;
274    };
275    let Some(Object::Array(diffs)) = enc.get(b"Differences").ok().and_then(|o| deref(doc, o))
276    else {
277        return map;
278    };
279    let mut code = 0u8;
280    for el in diffs {
281        match el {
282            Object::Integer(i) => code = *i as u8,
283            Object::Name(name) => {
284                if glyph_name_to_char(name).is_none() && is_gid_name(name) {
285                    map.insert(code, String::from_utf8_lossy(name).into_owned());
286                }
287                code = code.wrapping_add(1);
288            }
289            _ => {}
290        }
291    }
292    map
293}
294
295/// Parse the embedded Type1 font program's built-in `/Encoding` vector
296/// (`dup <code> /<glyphname> put` entries in the clear-text header before
297/// `eexec`) into `code → char`. This is how docling recovers glyphs from
298/// standard TeX math fonts (CMMI/CMSY/…) that carry no PDF `/Encoding` and no
299/// ToUnicode — e.g. CMMI's `dup 21 /lambda` or CMSY's `dup 20 /lessequal`.
300/// Only `FontFile` (Type1) is parsed; CFF (`FontFile3`) and TrueType
301/// (`FontFile2`) store their encoding in a binary table and are left alone.
302fn type1_program_encoding(doc: &Document, fdict: &Dictionary) -> HashMap<u8, char> {
303    let mut map = HashMap::new();
304    let Some(desc) = fdict
305        .get(b"FontDescriptor")
306        .ok()
307        .and_then(|o| deref(doc, o))
308        .and_then(|o| o.as_dict().ok())
309    else {
310        return map;
311    };
312    let Some(data) = desc
313        .get(b"FontFile")
314        .ok()
315        .and_then(|o| deref(doc, o))
316        .and_then(|o| o.as_stream().ok())
317        .and_then(|s| s.decompressed_content().ok())
318    else {
319        return map;
320    };
321    // The clear-text header (PostScript) ends at `eexec`; the rest is encrypted.
322    let head_end = data
323        .windows(5)
324        .position(|w| w == b"eexec")
325        .unwrap_or(data.len());
326    let head = String::from_utf8_lossy(&data[..head_end]);
327    // Scan for `dup <code> /<name> put` tokens.
328    let toks: Vec<&str> = head.split_whitespace().collect();
329    for w in toks.windows(4) {
330        if w[0] == "dup" && w[3] == "put" {
331            if let (Ok(code), Some(name)) = (w[1].parse::<u32>(), w[2].strip_prefix('/')) {
332                if code <= 255 {
333                    if let Some(ch) = glyph_name_to_char(name.as_bytes()) {
334                        map.insert(code as u8, ch);
335                    }
336                }
337            }
338        }
339    }
340    map
341}
342
343/// A glyph name that is a synthetic placeholder, not a real Adobe name:
344/// `g115`, `cid42`, `glyph7`, `index9`, `G12`, or a short-prefix code name like
345/// `SM590000` (IBM BookMaster). These carry no Unicode meaning, and docling-parse
346/// emits them verbatim (`/SM590000`). `afii####` / `uni####` are real Adobe names
347/// and excluded. The restriction keeps genuine glyph names on the Unicode path.
348fn is_gid_name(name: &[u8]) -> bool {
349    let Ok(s) = std::str::from_utf8(name) else {
350        return false;
351    };
352    if s.starts_with("afii") || s.starts_with("uni") {
353        return false;
354    }
355    for prefix in ["g", "G", "cid", "CID", "glyph", "index"] {
356        if let Some(rest) = s.strip_prefix(prefix) {
357            if !rest.is_empty() && rest.bytes().all(|b| b.is_ascii_digit()) {
358                return true;
359            }
360        }
361    }
362    // Short alpha prefix (≤3 letters) followed by a run of ≥3 digits — synthetic
363    // code names like `SM590000`, distinct from real Adobe names (whole words or
364    // letter+`.suffix` variants).
365    let alpha = s.bytes().take_while(|b| b.is_ascii_alphabetic()).count();
366    let digits = s.len() - alpha;
367    (1..=3).contains(&alpha)
368        && digits >= 3
369        && s.as_bytes()[alpha..].iter().all(|b| b.is_ascii_digit())
370}
371
372fn font_ascent_descent(doc: &Document, fdict: &Dictionary, two_byte: bool) -> (f64, f64) {
373    // For Type0, the descriptor lives on the descendant CIDFont.
374    let descr_owner = if two_byte {
375        fdict
376            .get(b"DescendantFonts")
377            .ok()
378            .and_then(|o| deref(doc, o))
379            .and_then(|o| match o {
380                Object::Array(a) => a.first(),
381                _ => None,
382            })
383            .and_then(|o| as_dict(doc, o))
384    } else {
385        Some(fdict)
386    };
387    let fd = descr_owner
388        .and_then(|d| d.get(b"FontDescriptor").ok())
389        .and_then(|o| as_dict(doc, o));
390    let asc = fd
391        .and_then(|d| d.get(b"Ascent").ok())
392        .and_then(|o| {
393            o.as_float()
394                .ok()
395                .or_else(|| o.as_i64().ok().map(|i| i as f32))
396        })
397        .unwrap_or(750.0) as f64;
398    let desc = fd
399        .and_then(|d| d.get(b"Descent").ok())
400        .and_then(|o| {
401            o.as_float()
402                .ok()
403                .or_else(|| o.as_i64().ok().map(|i| i as f32))
404        })
405        .unwrap_or(-250.0) as f64;
406    // Some subsetted fonts carry a degenerate FontDescriptor (`/Ascent 0
407    // /Descent 0`) — the real metrics live in the font program. That collapses
408    // the loose box to zero height, so the line cells get zero area and the
409    // layout's region/text assignment drops them (2305's References list lost
410    // every prose line, keeping only the URLs). Fall back to typical text metrics
411    // so the box has height.
412    if asc - desc <= 1.0 {
413        return (750.0, -250.0);
414    }
415    (asc, desc)
416}
417
418/// The `/BaseFont` name with any `ABCDEF+` subset prefix stripped (#187).
419fn base_font_name(fdict: &Dictionary) -> Option<Vec<u8>> {
420    let name = fdict.get(b"BaseFont").ok()?.as_name().ok()?;
421    let stripped = match name.iter().position(|&b| b == b'+') {
422        Some(i) if i == 6 => &name[i + 1..],
423        _ => name,
424    };
425    Some(stripped.to_vec())
426}
427
428/// Simple-font widths: `/FirstChar` + `/Widths` array, `/MissingWidth` default.
429fn simple_widths(doc: &Document, fdict: &Dictionary) -> (HashMap<u32, f64>, f64) {
430    let mut map = HashMap::new();
431    let first = fdict
432        .get(b"FirstChar")
433        .ok()
434        .and_then(|o| o.as_i64().ok())
435        .unwrap_or(0) as u32;
436    if let Some(Object::Array(arr)) = fdict.get(b"Widths").ok().and_then(|o| deref(doc, o)) {
437        for (i, w) in arr.iter().enumerate() {
438            if let Some(w) = num(w) {
439                map.insert(first + i as u32, w);
440            }
441        }
442    }
443    let dw = fdict
444        .get(b"FontDescriptor")
445        .ok()
446        .and_then(|o| as_dict(doc, o))
447        .and_then(|d| d.get(b"MissingWidth").ok())
448        .and_then(num)
449        .unwrap_or(0.0);
450    (map, dw)
451}
452
453/// CIDFont widths: the `/W` array on the descendant font (`/DW` default = 1000).
454fn cid_widths(doc: &Document, fdict: &Dictionary) -> (HashMap<u32, f64>, f64) {
455    let mut map = HashMap::new();
456    let Some(desc) = fdict
457        .get(b"DescendantFonts")
458        .ok()
459        .and_then(|o| deref(doc, o))
460        .and_then(|o| match o {
461            Object::Array(a) => a.first(),
462            _ => None,
463        })
464        .and_then(|o| as_dict(doc, o))
465    else {
466        return (map, 1000.0);
467    };
468    let dw = desc.get(b"DW").ok().and_then(num).unwrap_or(1000.0);
469    if let Some(Object::Array(w)) = desc.get(b"W").ok().and_then(|o| deref(doc, o)) {
470        let mut i = 0;
471        while i < w.len() {
472            let c = w.get(i).and_then(num);
473            match (c, w.get(i + 1)) {
474                // `c [w1 w2 ...]`: consecutive CIDs starting at c.
475                (Some(c), Some(Object::Array(list))) => {
476                    for (k, wv) in list.iter().enumerate() {
477                        if let Some(wv) = num(wv) {
478                            map.insert(c as u32 + k as u32, wv);
479                        }
480                    }
481                    i += 2;
482                }
483                // `c_first c_last w`: a run all of width w.
484                (Some(c1), Some(o2)) => {
485                    if let (Some(c2), Some(wv)) = (num(o2), w.get(i + 2).and_then(num)) {
486                        for cid in c1 as u32..=c2 as u32 {
487                            map.insert(cid, wv);
488                        }
489                    }
490                    i += 3;
491                }
492                _ => break,
493            }
494        }
495    }
496    (map, dw)
497}
498
499fn num(o: &Object) -> Option<f64> {
500    match o {
501        Object::Integer(i) => Some(*i as f64),
502        Object::Real(r) => Some(*r as f64),
503        _ => None,
504    }
505}
506
507/// Parse a ToUnicode CMap's `bfchar` / `bfrange` sections into code→string.
508fn parse_tounicode(data: &[u8]) -> HashMap<u32, String> {
509    let text = String::from_utf8_lossy(data);
510    let mut map = HashMap::new();
511    let hex = |s: &str| -> Option<Vec<u16>> {
512        let s = s.trim();
513        if !s.starts_with('<') || !s.ends_with('>') {
514            return None;
515        }
516        let h = &s[1..s.len() - 1];
517        let bytes: Vec<u8> = (0..h.len())
518            .step_by(2)
519            .filter_map(|i| u8::from_str_radix(h.get(i..i + 2)?, 16).ok())
520            .collect();
521        Some(
522            bytes
523                .chunks(2)
524                .map(|c| {
525                    if c.len() == 2 {
526                        u16::from_be_bytes([c[0], c[1]])
527                    } else {
528                        c[0] as u16
529                    }
530                })
531                .collect(),
532        )
533    };
534    let u16s_to_string = |u: &[u16]| String::from_utf16_lossy(u);
535    let code_of = |u: &[u16]| u.iter().fold(0u32, |acc, &x| (acc << 16) | x as u32);
536
537    // Tokenize by structure, not whitespace: CMap hex groups are often written
538    // back-to-back with no separators (`<21><21><0054>`), so scan for `<…>`
539    // groups, `[`/`]` brackets, and bareword keywords.
540    let tokens: Vec<String> = {
541        let bytes = text.as_bytes();
542        let mut toks = Vec::new();
543        let mut i = 0;
544        while i < bytes.len() {
545            let c = bytes[i];
546            if c.is_ascii_whitespace() {
547                i += 1;
548            } else if c == b'<' {
549                let start = i;
550                while i < bytes.len() && bytes[i] != b'>' {
551                    i += 1;
552                }
553                i += 1; // include '>'
554                toks.push(String::from_utf8_lossy(&bytes[start..i.min(bytes.len())]).into_owned());
555            } else if c == b'[' || c == b']' {
556                toks.push((c as char).to_string());
557                i += 1;
558            } else {
559                let start = i;
560                while i < bytes.len()
561                    && !bytes[i].is_ascii_whitespace()
562                    && bytes[i] != b'<'
563                    && bytes[i] != b'['
564                    && bytes[i] != b']'
565                {
566                    i += 1;
567                }
568                toks.push(String::from_utf8_lossy(&bytes[start..i]).into_owned());
569            }
570        }
571        toks
572    };
573    let tokens: Vec<&str> = tokens.iter().map(|s| s.as_str()).collect();
574    let mut i = 0;
575    while i < tokens.len() {
576        match tokens[i] {
577            "beginbfchar" => {
578                i += 1;
579                while i + 1 < tokens.len() && tokens[i] != "endbfchar" {
580                    if let (Some(src), Some(dst)) = (hex(tokens[i]), hex(tokens[i + 1])) {
581                        map.insert(code_of(&src), u16s_to_string(&dst));
582                    }
583                    i += 2;
584                }
585            }
586            "beginbfrange" => {
587                i += 1;
588                while i + 2 < tokens.len() && tokens[i] != "endbfrange" {
589                    let (Some(lo), Some(hi)) = (hex(tokens[i]), hex(tokens[i + 1])) else {
590                        i += 1;
591                        continue;
592                    };
593                    let lo = code_of(&lo);
594                    let hi = code_of(&hi);
595                    if tokens[i + 2] == "[" {
596                        // `<lo> <hi> [ <d0> <d1> ... ]`: one dst per code in the range.
597                        let mut j = i + 3;
598                        let mut code = lo;
599                        while j < tokens.len() && tokens[j] != "]" {
600                            if let Some(dst) = hex(tokens[j]) {
601                                map.insert(code, u16s_to_string(&dst));
602                            }
603                            code += 1;
604                            j += 1;
605                        }
606                        i = j + 1;
607                    } else if let Some(dst) = hex(tokens[i + 2]) {
608                        // `<lo> <hi> <dst>`: consecutive Unicode from a base.
609                        let base = code_of(&dst);
610                        for (k, code) in (lo..=hi).enumerate() {
611                            if let Some(ch) = char::from_u32(base + k as u32) {
612                                map.insert(code, ch.to_string());
613                            }
614                        }
615                        i += 3;
616                    } else {
617                        i += 1;
618                    }
619                }
620            }
621            _ => i += 1,
622        }
623    }
624    map
625}
626
627/// Decode a PDF string literal in a Tj/TJ operand into raw code units.
628fn codes(font: &Font, bytes: &[u8]) -> Vec<u32> {
629    if font.two_byte {
630        bytes
631            .chunks(2)
632            .map(|c| {
633                if c.len() == 2 {
634                    ((c[0] as u32) << 8) | c[1] as u32
635                } else {
636                    c[0] as u32
637                }
638            })
639            .collect()
640    } else {
641        bytes.iter().map(|&b| b as u32).collect()
642    }
643}
644
645/// A page's display box, in PDF user space: the `/CropBox` clipped to the
646/// `/MediaBox`, both inherited through the page tree and normalized — a
647/// missing or empty MediaBox is US Letter, an empty CropBox is the MediaBox
648/// (pdfium's `CPDF_Page::UpdateDimensions`). pdfium reports the page size from
649/// this box, renders exactly it, and translates every content coordinate so
650/// its lower-left corner is the origin (`m_PageMatrix`); docling's backends
651/// inherit that frame, so text cells, `prov` boxes and destinations all count
652/// from the CropBox corner, not the MediaBox one. The parser used to flip
653/// glyphs with the MediaBox *height* and no translation at all, so a page
654/// whose boxes do not start at (0, 0) — a trimmed book page with
655/// `MediaBox [-56 -58 576 723]` / `CropBox [1 -0.6 519 666]`, or a LaTeX
656/// figure cropped to `[156 147 637 391]` — had its text displaced against the
657/// rendered bitmap by the box offset, the bottom lines pushed past the page
658/// edge and clamped to `t = b`.
659#[derive(Debug, Clone, Copy, PartialEq)]
660pub(crate) struct PageBox {
661    /// Left edge, user space.
662    pub l: f32,
663    /// Bottom edge, user space.
664    pub b: f32,
665    pub w: f32,
666    pub h: f32,
667}
668
669impl PageBox {
670    /// Top edge, user space — the y that becomes `0` in the y-down frame.
671    pub fn top(&self) -> f32 {
672        self.b + self.h
673    }
674}
675
676/// A page-tree rect attribute (`/MediaBox`, `/CropBox`), inherited from the
677/// nearest ancestor that sets it, as normalized `(l, b, r, t)`.
678fn inherited_rect(
679    doc: &Document,
680    page_id: lopdf::ObjectId,
681    key: &[u8],
682) -> Option<(f32, f32, f32, f32)> {
683    let mut id = page_id;
684    for _ in 0..32 {
685        let dict = doc.get_object(id).ok()?.as_dict().ok()?;
686        if let Some(Object::Array(a)) = dict.get(key).ok().and_then(|o| deref(doc, o)) {
687            let v: Vec<f32> = a.iter().filter_map(|o| num(o).map(|x| x as f32)).collect();
688            if v.len() == 4 && v.iter().all(|x| x.is_finite()) {
689                return Some((
690                    v[0].min(v[2]),
691                    v[1].min(v[3]),
692                    v[0].max(v[2]),
693                    v[1].max(v[3]),
694                ));
695            }
696            return None;
697        }
698        id = dict.get(b"Parent").ok()?.as_reference().ok()?;
699    }
700    None
701}
702
703pub(crate) fn page_box(doc: &Document, page_id: lopdf::ObjectId) -> PageBox {
704    let nonempty = |r: &(f32, f32, f32, f32)| r.2 > r.0 && r.3 > r.1;
705    let media = inherited_rect(doc, page_id, b"MediaBox")
706        .filter(nonempty)
707        .unwrap_or((0.0, 0.0, 612.0, 792.0));
708    let crop = inherited_rect(doc, page_id, b"CropBox")
709        .map(|c| {
710            (
711                c.0.max(media.0),
712                c.1.max(media.1),
713                c.2.min(media.2),
714                c.3.min(media.3),
715            )
716        })
717        .filter(nonempty)
718        .unwrap_or(media);
719    PageBox {
720        l: crop.0,
721        b: crop.1,
722        w: crop.2 - crop.0,
723        h: crop.3 - crop.1,
724    }
725}
726
727/// Page size (width, height) in PDF points — the display box's, like pdfium's
728/// `FPDF_GetPageWidthF/HeightF`.
729fn page_size(doc: &Document, page_id: lopdf::ObjectId) -> (f32, f32) {
730    let pb = page_box(doc, page_id);
731    (pb.w, pb.h)
732}
733
734/// Localize where a page's text is lost, for the `text_layer` diagnostic.
735/// Extraction can come up empty at three different points — no content stream
736/// reached the parser, the stream did not decode into operators, or it ran but
737/// produced no glyphs (fonts/encodings) — and from the outside all three look
738/// the same. Report them per page.
739pub fn content_diagnosis(bytes: &[u8]) -> String {
740    let Some(doc) = load_document(bytes) else {
741        return "document does not load".into();
742    };
743    let mut pages: Vec<_> = doc.get_pages().into_iter().collect();
744    pages.sort_by_key(|(n, _)| *n);
745    let mut out = String::new();
746    let mut caches = DocCaches::default();
747    for (n, pid) in pages.into_iter().take(4) {
748        let content_bytes = doc.get_page_content(pid);
749        let ops = lopdf::content::Content::decode(&content_bytes)
750            .map(|c| c.operations.len())
751            .ok();
752        let res = page_res(&doc, pid);
753        let fonts = res.map(|r| fonts_from_res(&doc, r, &mut caches).len());
754        let glyphs = page_glyphs_cached(&doc, pid, &mut caches).len();
755        out.push_str(&format!(
756            "\n   page {n}: content {} B, ops {}, resources {}, fonts {}, glyphs {}",
757            content_bytes.len(),
758            ops.map_or("UNDECODABLE".to_string(), |n| n.to_string()),
759            if res.is_some() { "ok" } else { "MISSING" },
760            fonts.map_or("-".to_string(), |n| n.to_string()),
761            glyphs,
762        ));
763    }
764    out
765}
766
767/// Is this "text layer" a vestige rather than the document's text?
768///
769/// Scanned forms often carry a handful of typed-in strings — a date filled
770/// into three form fields, say — on top of pages that are otherwise images.
771/// Treating that as a real text layer is the worst of both worlds: the text
772/// path proudly extracts thirteen characters, and no OCR ever runs on the
773/// letter the pages actually show. The reported form did exactly this (3
774/// lines, 13 chars, 3 pages).
775///
776/// The rule is deliberately tight so genuinely sparse *digital* documents are
777/// not misrouted into OCR: only a document averaging at most one line per page
778/// **and** totalling fewer than 32 characters is called vestigial.
779pub fn text_layer_is_vestigial(pages: &[crate::pdfium_backend::PdfPage]) -> bool {
780    let lines: usize = pages.iter().map(|p| p.cells.len()).sum();
781    if lines == 0 {
782        return true;
783    }
784    let chars: usize = pages
785        .iter()
786        .flat_map(|p| &p.cells)
787        .map(|c| c.text.chars().count())
788        .sum();
789    lines <= pages.len() && chars < 32
790}
791
792/// Why the cross-reference repair did or did not fire, for the `text_layer`
793/// diagnostic. A PDF that will not load is indistinguishable from a scan in
794/// production (both convert to nothing), so the reason has to be askable.
795pub fn xref_repair_status(bytes: &[u8]) -> String {
796    if Document::load_mem(bytes).is_ok() {
797        return "loads unaided; no repair needed".into();
798    }
799    match pad_short_xref_entries(bytes) {
800        Ok(fixed) => match Document::load_mem(&fixed) {
801            Ok(_) => "repaired: cross-reference entries padded to 20 bytes".into(),
802            Err(e) => format!("padded the entries, but it still will not load: {e}"),
803        },
804        Err(why) => format!("repair declined — {why}"),
805    }
806}
807
808/// Load a PDF, repairing the one malformation that otherwise costs us the whole
809/// document: **19-byte cross-reference entries**.
810///
811/// The spec fixes an xref entry at 20 bytes — `nnnnnnnnnn ggggg n` plus a
812/// *two*-byte EOL. Some generators (an Austrian telecom's invoices, for one)
813/// emit a bare LF instead, making each entry 19 bytes. lopdf rejects the file
814/// outright (`invalid file trailer`) where pdfium reads it happily, so a
815/// perfectly good text layer looked to the browser exactly like a scan and cost
816/// ten seconds of OCR.
817///
818/// Padding is only attempted when it cannot move anything the xref points at:
819/// a single `xref` section that begins after the last object. The repair then
820/// has to prove itself — the padded bytes are used only if they load — so a
821/// mis-repair degrades to today's behaviour rather than to silent garbage.
822fn load_document(bytes: &[u8]) -> Option<Document> {
823    // Try progressively more repair, and accept a candidate only once the pages
824    // actually carry content — a document whose streams were dropped still
825    // "loads", so loading alone is not evidence the repair helped. A
826    // well-formed file returns on the first attempt and pays for nothing.
827    let mut fallback = None;
828    if let Some(doc) = best_effort_load(bytes, &mut fallback) {
829        return Some(doc);
830    }
831    let xref_fixed = pad_short_xref_entries(bytes).ok();
832    if let Some(fixed) = &xref_fixed {
833        if let Some(doc) = best_effort_load(fixed, &mut fallback) {
834            return Some(doc);
835        }
836    }
837    // Both defects can coexist, and the second only becomes visible once the
838    // first is repaired, so build on whatever the previous step produced.
839    let lengths_fixed = fix_stream_lengths(xref_fixed.as_deref().unwrap_or(bytes));
840    if let Some(doc) = best_effort_load(&lengths_fixed, &mut fallback) {
841        return Some(doc);
842    }
843    fallback
844}
845
846/// Load `data`, returning it only when its pages carry content; a document that
847/// merely parses is remembered as the fallback for when nothing does better.
848fn best_effort_load(data: &[u8], fallback: &mut Option<Document>) -> Option<Document> {
849    match Document::load_mem(data) {
850        Ok(doc) if has_page_content(&doc) => Some(doc),
851        Ok(doc) => {
852            fallback.get_or_insert(doc);
853            None
854        }
855        Err(_) => None,
856    }
857}
858
859/// Does any page actually hand us a content stream? A document whose streams
860/// were dropped still parses — it simply has nothing to read — so this is what
861/// tells a successful repair from a pointless one.
862fn has_page_content(doc: &Document) -> bool {
863    doc.get_pages()
864        .into_values()
865        .take(4)
866        .any(|pid| !doc.get_page_content(pid).is_empty())
867}
868
869/// Correct `/Length` values that disagree with where `endstream` actually is.
870///
871/// The same generator that writes short xref entries also overstates its
872/// content-stream lengths by a byte or two. lopdf trusts `/Length`, reads past
873/// the data, fails to find `endstream` there and drops the stream — the object
874/// comes back as a bare dictionary, so the page has no content at all and the
875/// document looks like a scan. pdfium instead trusts `endstream`, which is what
876/// this does.
877///
878/// The rewrite is length-preserving: the corrected number is written over the
879/// old digits and padded with spaces, so every byte offset in the file — and
880/// therefore the whole cross-reference table — stays valid.
881fn fix_stream_lengths(bytes: &[u8]) -> Vec<u8> {
882    let mut out = bytes.to_vec();
883    let mut i = 0;
884    while let Some(rel) = find(&out[i..], b"stream") {
885        let kw = i + rel;
886        i = kw + 6;
887        // Skip `endstream` (the keyword we are measuring *to*).
888        if kw >= 3 && &out[kw - 3..kw] == b"end" {
889            continue;
890        }
891        // The stream data starts after the EOL that follows the keyword.
892        let mut data = kw + 6;
893        if out.get(data..data + 2) == Some(b"\r\n".as_slice()) {
894            data += 2;
895        } else if matches!(out.get(data), Some(b'\n' | b'\r')) {
896            data += 1;
897        }
898        let Some(end) = find(&out[data..], b"endstream").map(|r| data + r) else {
899            continue;
900        };
901        // `/Length <digits>` in the dictionary just before the keyword.
902        let dict_start = out[..kw].iter().rposition(|&c| c == b'<').unwrap_or(0);
903        let Some(lrel) = find(&out[dict_start..kw], b"/Length") else {
904            continue;
905        };
906        let mut d = dict_start + lrel + 7;
907        while matches!(out.get(d), Some(b' ')) {
908            d += 1;
909        }
910        let digits = out[d..].iter().take_while(|c| c.is_ascii_digit()).count();
911        if digits == 0 {
912            continue;
913        }
914        let declared: usize = match std::str::from_utf8(&out[d..d + digits])
915            .ok()
916            .and_then(|s| s.parse().ok())
917        {
918            Some(v) => v,
919            None => continue,
920        };
921        let actual = end - data;
922        // Only shrink, and only when the new value fits the space the old one
923        // occupied — growing the number would move every following byte.
924        let replacement = actual.to_string();
925        if actual == declared || replacement.len() > digits {
926            continue;
927        }
928        out[d..d + digits].fill(b' ');
929        out[d..d + replacement.len()].copy_from_slice(replacement.as_bytes());
930    }
931    out
932}
933
934fn find(haystack: &[u8], needle: &[u8]) -> Option<usize> {
935    haystack.windows(needle.len()).position(|w| w == needle)
936}
937
938/// Rewrite a classic cross-reference table's entries to the spec's 20 bytes,
939/// or `None` when the file's shape makes that unsafe (see [`load_document`]).
940fn pad_short_xref_entries(bytes: &[u8]) -> Result<Vec<u8>, &'static str> {
941    // Exactly one xref section, and it must start after every object, so that
942    // growing it shifts nothing the table's offsets refer to.
943    let is_boundary = |i: usize| i == 0 || matches!(bytes[i - 1], b'\n' | b'\r');
944    let mut starts = (0..bytes.len().saturating_sub(4))
945        .filter(|&i| &bytes[i..i + 4] == b"xref" && is_boundary(i));
946    let xref_at = starts
947        .next()
948        .ok_or("no classic `xref` section (an xref stream?)")?;
949    if starts.next().is_some() {
950        return Err("more than one xref section (incremental update)");
951    }
952    let last_obj = bytes
953        .windows(3)
954        .rposition(|w| w == b"obj")
955        .ok_or("no objects found")?;
956    if last_obj > xref_at {
957        return Err("an object follows the xref — padding would move it");
958    }
959
960    let mut out = bytes[..xref_at].to_vec();
961    out.extend_from_slice(b"xref\n");
962    let mut i = xref_at + 4;
963    let skip_ws = |i: &mut usize| {
964        while matches!(bytes.get(*i), Some(b'\r' | b'\n' | b' ')) {
965            *i += 1;
966        }
967    };
968    loop {
969        skip_ws(&mut i);
970        // Either the next subsection header ("first count") or the trailer.
971        if bytes[i..].starts_with(b"trailer") {
972            out.extend_from_slice(&bytes[i..]);
973            return Ok(out);
974        }
975        let header_end = i + bytes[i..]
976            .iter()
977            .position(|c| matches!(c, b'\n' | b'\r'))
978            .ok_or("subsection header runs off the end")?;
979        let header = std::str::from_utf8(&bytes[i..header_end])
980            .map_err(|_| "subsection header is not text")?
981            .trim();
982        let mut parts = header.split_whitespace();
983        let count: usize = parts
984            .nth(1)
985            .and_then(|c| c.parse().ok())
986            .ok_or("unparseable subsection header")?;
987        if parts.next().is_some() || count == 0 {
988            return Err("unexpected subsection header shape");
989        }
990        out.extend_from_slice(header.as_bytes());
991        out.push(b'\n');
992        i = header_end;
993        for _ in 0..count {
994            skip_ws(&mut i);
995            // `nnnnnnnnnn ggggg n` — the 18 bytes before whatever EOL follows.
996            let entry = bytes.get(i..i + 18).ok_or("xref entry runs off the end")?;
997            let well_formed = entry[..10].iter().all(u8::is_ascii_digit)
998                && entry[10] == b' '
999                && entry[11..16].iter().all(u8::is_ascii_digit)
1000                && entry[16] == b' '
1001                && matches!(entry[17], b'n' | b'f');
1002            if !well_formed {
1003                return Err("xref entry is not `nnnnnnnnnn ggggg n`");
1004            }
1005            out.extend_from_slice(entry);
1006            out.extend_from_slice(b" \n"); // the spec's 2-byte EOL -> 20 bytes
1007            i += 18;
1008        }
1009    }
1010}
1011
1012/// Debug: raw glyph stream `(ch, ll, lr, lb, lt)` (native coords) for page
1013/// `index`, before the sanitizer. For comparing char cells to docling-parse.
1014pub fn debug_glyphs(bytes: &[u8], index: usize) -> Vec<(char, f32, f32, f32, f32)> {
1015    let Some(doc) = load_document(bytes) else {
1016        return Vec::new();
1017    };
1018    let mut pages: Vec<_> = doc.get_pages().into_iter().collect();
1019    pages.sort_by_key(|(n, _)| *n);
1020    let Some((_, pid)) = pages.get(index) else {
1021        return Vec::new();
1022    };
1023    page_glyphs(&doc, *pid)
1024        .into_iter()
1025        .map(|g| (g.ch, g.ll, g.lr, g.lb, g.lt))
1026        .collect()
1027}
1028
1029/// Public entry: per-page (width, height, line cells) for a PDF, via the Rust
1030/// text parser + the docling-parse line sanitizer. Used by the pipeline and the
1031/// `textparse_dump` example.
1032pub fn pdf_textlines(bytes: &[u8]) -> Vec<(f32, f32, Vec<crate::pdfium_backend::TextCell>)> {
1033    let Some(doc) = load_document(bytes) else {
1034        return Vec::new();
1035    };
1036    let mut caches = DocCaches::default();
1037    let mut pages: Vec<_> = doc.get_pages().into_iter().collect();
1038    pages.sort_by_key(|(n, _)| *n);
1039    pages
1040        .into_iter()
1041        .map(|(_, pid)| {
1042            let (w, h) = page_size(&doc, pid);
1043            let glyphs = page_glyphs_cached(&doc, pid, &mut caches);
1044            let cells = crate::dp_lines::line_cells(&glyphs, h, true);
1045            (w, h, cells)
1046        })
1047        .collect()
1048}
1049
1050/// Debug/diagnostic entry: per-page (width, height, word cells) for a PDF, via
1051/// the Rust parser glyphs run through the docling-parse word grouping. Used to
1052/// compare parser word cells against docling-parse's `word_cells` oracle (roadmap
1053/// item 6).
1054pub fn pdf_words(bytes: &[u8]) -> Vec<(f32, f32, Vec<crate::pdfium_backend::TextCell>)> {
1055    let Some(doc) = load_document(bytes) else {
1056        return Vec::new();
1057    };
1058    let mut caches = DocCaches::default();
1059    let mut pages: Vec<_> = doc.get_pages().into_iter().collect();
1060    pages.sort_by_key(|(n, _)| *n);
1061    pages
1062        .into_iter()
1063        .map(|(_, pid)| {
1064            let (w, h) = page_size(&doc, pid);
1065            let glyphs = page_glyphs_cached(&doc, pid, &mut caches);
1066            let cells = crate::dp_lines::word_cells(&glyphs, h, true);
1067            (w, h, cells)
1068        })
1069        .collect()
1070}
1071
1072/// One page's text cells from the pure-Rust parser: prose line cells, per-word
1073/// cells, and code line cells — all from a single glyph parse. Replaces the
1074/// pdfium text path (roadmap item 6) when the parser drop is enabled.
1075#[derive(Default)]
1076pub struct PageParserCells {
1077    pub prose: Vec<crate::pdfium_backend::TextCell>,
1078    pub words: Vec<crate::pdfium_backend::TextCell>,
1079    pub code: Vec<crate::pdfium_backend::TextCell>,
1080}
1081
1082/// The parser text layer, driven one page at a time: the document is loaded
1083/// (and repaired, see [`load_document`]) once, the font/form caches persist
1084/// across pages, and each page's glyphs are parsed only when asked for.
1085///
1086/// The eager whole-document walk this replaces ran *before* the first page
1087/// was rendered, so on a long PDF it was a serial prefix the page-worker pool
1088/// sat idle through — 6.2 s on the 1913-page .NET reference, in front of a
1089/// pipeline that otherwise overlaps parsing with inference — and a `--pages`
1090/// window still paid for every page in the file. Pulling pages on demand
1091/// keeps the parse on the producer thread but interleaved with rendering,
1092/// and skips unselected pages entirely. Output per page is unchanged: same
1093/// glyph walk, same shared caches, same contraction.
1094pub struct PageTextParser {
1095    doc: Document,
1096    caches: DocCaches,
1097    /// Page object ids in document order (page 1 first).
1098    pages: Vec<lopdf::ObjectId>,
1099}
1100
1101impl PageTextParser {
1102    /// Load the document; `None` when it has no parseable text layer at all
1103    /// (the caller then keeps pdfium's cells, as before).
1104    pub fn open(bytes: &[u8]) -> Option<Self> {
1105        let doc = load_document(bytes)?;
1106        let mut pages: Vec<_> = doc.get_pages().into_iter().collect();
1107        pages.sort_by_key(|(n, _)| *n);
1108        Some(Self {
1109            doc,
1110            caches: DocCaches::default(),
1111            pages: pages.into_iter().map(|(_, pid)| pid).collect(),
1112        })
1113    }
1114
1115    /// Prose, word and code cells of the 0-based page `index` — empty for an
1116    /// index the parser's page tree doesn't have (pdfium and lopdf can
1117    /// disagree on a damaged file; the caller falls back to pdfium's text).
1118    pub fn cells(&mut self, index: usize) -> PageParserCells {
1119        let Some(&pid) = self.pages.get(index) else {
1120            return PageParserCells::default();
1121        };
1122        let (_w, h) = page_size(&self.doc, pid);
1123        let glyphs = page_glyphs_cached(&self.doc, pid, &mut self.caches);
1124        let (prose, words) = crate::dp_lines::line_and_word_cells(&glyphs, h, true);
1125        PageParserCells {
1126            prose,
1127            words,
1128            code: crate::pdfium_backend::code_cells_from_glyphs(&glyphs, h),
1129        }
1130    }
1131}
1132
1133/// Full parser text layer: prose + word + code cells per page, glyphs parsed once.
1134/// `prose`/`words` come from the docling-parse contraction ([`crate::dp_lines`]);
1135/// `code` splits only at the parser's own space glyphs (monospace keeps its
1136/// source spacing). The eager form of [`PageTextParser`].
1137pub fn pdf_all_cells(bytes: &[u8]) -> Vec<PageParserCells> {
1138    let Some(mut parser) = PageTextParser::open(bytes) else {
1139        return Vec::new();
1140    };
1141    (0..parser.pages.len()).map(|i| parser.cells(i)).collect()
1142}
1143
1144/// Whole pages for the text-layer-only conversion ([`crate::convert_text_layer`]):
1145/// the parser's prose/word/code cells plus page geometry, assembled into
1146/// [`PdfPage`]s with no rendered image and no link annotations. Everything here
1147/// is pure Rust (lopdf), so it compiles without the `ml` feature — including on
1148/// wasm32. A page the parser can't read (no text layer) comes back with empty
1149/// cells; there is no pdfium fallback on this path.
1150pub fn pdf_text_pages(bytes: &[u8]) -> Vec<crate::pdfium_backend::PdfPage> {
1151    let Some(doc) = load_document(bytes) else {
1152        return Vec::new();
1153    };
1154    let mut caches = DocCaches::default();
1155    let mut pages: Vec<_> = doc.get_pages().into_iter().collect();
1156    pages.sort_by_key(|(n, _)| *n);
1157    pages
1158        .into_iter()
1159        .map(|(_, pid)| {
1160            let (w, h) = page_size(&doc, pid);
1161            let glyphs = page_glyphs_cached(&doc, pid, &mut caches);
1162            let (mut prose, mut words) = crate::dp_lines::line_and_word_cells(&glyphs, h, true);
1163            drop_overpainted_cells(&mut prose);
1164            drop_overpainted_cells(&mut words);
1165            crate::pdfium_backend::PdfPage {
1166                #[cfg(feature = "ocr-prep")]
1167                image_layout: None,
1168                width: w,
1169                height: h,
1170                // Cells are native PDF points; there is no rendered bitmap.
1171                scale: 1.0,
1172                cells: prose,
1173                code_cells: crate::pdfium_backend::code_cells_from_glyphs(&glyphs, h),
1174                word_cells: words,
1175                #[cfg(feature = "ocr-prep")]
1176                image: image::RgbImage::new(1, 1),
1177                links: Vec::new(),
1178                rotation: 0,
1179            }
1180        })
1181        .collect()
1182}
1183
1184/// Drop line cells that are *painted over each other* — glyphs used as artwork.
1185///
1186/// Some generators draw their logo with a symbol font: on the reporting
1187/// invoice, a `TeleLogo` Type1 paints the T-Mobile mark by stacking the glyphs
1188/// encoded as `"` and `==` on top of one another, and the flat text-layer
1189/// output opened with that garbage. Nothing in the font metadata gives it away
1190/// (the *text* fonts in the same file are also flagged symbolic, and the logo
1191/// font names its glyphs `quotedbl` &c.), but the geometry does: two cells with
1192/// different text where one lies inside the other on the same line is
1193/// physically impossible for prose — ink from two words never occupies the
1194/// same box. Both cells of such a pair are paint, not text.
1195///
1196/// Containment (not mere overlap) keeps this narrow: adjacent words touch but
1197/// never contain each other, and a same-text near-duplicate (double-draw faux
1198/// bold) is left alone for the sanitizer's usual handling. Applied on the
1199/// flat/browser path only — the ML pipeline's text layer is byte-pinned by the
1200/// PDF corpus, and there the layout model already sinks logo marks into
1201/// `picture` regions.
1202fn drop_overpainted_cells(cells: &mut Vec<crate::pdfium_backend::TextCell>) {
1203    let mut paint = vec![false; cells.len()];
1204    for i in 0..cells.len() {
1205        for j in 0..cells.len() {
1206            if i == j || cells[i].text == cells[j].text {
1207                continue;
1208            }
1209            let (a, b) = (&cells[i], &cells[j]);
1210            // Same line band: the vertical overlap covers most of the shorter.
1211            let vo = (a.b.min(b.b) - a.t.max(b.t)).max(0.0);
1212            if vo < 0.6 * (a.b - a.t).min(b.b - b.t) {
1213                continue;
1214            }
1215            // `a` horizontally inside `b` (with a small tolerance).
1216            let ho = (a.r.min(b.r) - a.l.max(b.l)).max(0.0);
1217            if ho >= 0.8 * (a.r - a.l) && (a.r - a.l) <= (b.r - b.l) {
1218                paint[i] = true;
1219                paint[j] = true;
1220            }
1221        }
1222    }
1223    let mut keep = paint.iter().map(|p| !p);
1224    cells.retain(|_| keep.next().unwrap());
1225}
1226
1227/// The text-state scalars inherited by a Form XObject when it is invoked via
1228/// `Do` (the PDF graphics state includes the text parameters, but not the text
1229/// matrices, which a form re-establishes inside its own `BT`/`ET`).
1230#[derive(Clone, Copy)]
1231struct TextState {
1232    tc: f64,
1233    tw: f64,
1234    th: f64,
1235    tl: f64,
1236    trise: f64,
1237    fsize: f64,
1238}
1239
1240impl TextState {
1241    const INIT: TextState = TextState {
1242        tc: 0.0,
1243        tw: 0.0,
1244        th: 1.0,
1245        tl: 0.0,
1246        trise: 0.0,
1247        fsize: 0.0,
1248    };
1249}
1250
1251/// The effective `/Resources` dictionary for a page (inline or via reference,
1252/// falling back to an inherited one from a `/Parent`).
1253fn page_res(doc: &Document, page_id: lopdf::ObjectId) -> Option<&Dictionary> {
1254    let (inline, ids) = doc.get_page_resources(page_id).ok()?;
1255    if let Some(d) = inline {
1256        return Some(d);
1257    }
1258    ids.into_iter().find_map(|id| doc.get_dictionary(id).ok())
1259}
1260
1261/// Build the code→[`Font`] map for a resources dictionary's `/Font` sub-dict,
1262/// reusing the per-document cache for fonts referenced indirectly (the common
1263/// case — the same font objects recur on every page).
1264fn fonts_from_res(
1265    doc: &Document,
1266    res: &Dictionary,
1267    caches: &mut DocCaches,
1268) -> HashMap<Vec<u8>, Arc<Font>> {
1269    let mut map = HashMap::new();
1270    let font_dict = res
1271        .get(b"Font")
1272        .ok()
1273        .and_then(|o| deref(doc, o))
1274        .and_then(|o| o.as_dict().ok());
1275    if let Some(fd) = font_dict {
1276        for (name, value) in fd.iter() {
1277            let font = match value {
1278                Object::Reference(id) => {
1279                    let key = (*id, name.clone());
1280                    if let Some(f) = caches.fonts.get(&key) {
1281                        Arc::clone(f)
1282                    } else if let Some(fdict) = deref(doc, value).and_then(|o| o.as_dict().ok()) {
1283                        let f = Arc::new(parse_font(doc, name, fdict));
1284                        caches.fonts.insert(key, Arc::clone(&f));
1285                        f
1286                    } else {
1287                        continue;
1288                    }
1289                }
1290                _ => {
1291                    if let Some(fdict) = deref(doc, value).and_then(|o| o.as_dict().ok()) {
1292                        Arc::new(parse_font(doc, name, fdict))
1293                    } else {
1294                        continue;
1295                    }
1296                }
1297            };
1298            map.insert(name.clone(), font);
1299        }
1300    }
1301    map
1302}
1303
1304/// Extract every glyph on a page as a native-coordinate [`Glyph`].
1305pub(crate) fn page_glyphs(doc: &Document, page_id: lopdf::ObjectId) -> Vec<Glyph> {
1306    page_glyphs_cached(doc, page_id, &mut DocCaches::default())
1307}
1308
1309/// [`page_glyphs`] with an explicit per-document cache, so a multi-page walk
1310/// parses each font / decodes each form once instead of once per page.
1311fn page_glyphs_cached(
1312    doc: &Document,
1313    page_id: lopdf::ObjectId,
1314    caches: &mut DocCaches,
1315) -> Vec<Glyph> {
1316    let mut out = Vec::new();
1317    // lopdf 0.44: get_page_content returns the assembled content-stream bytes
1318    // directly (an empty Vec when the page has none).
1319    let content_bytes = doc.get_page_content(page_id);
1320    let Ok(content) = lopdf::content::Content::decode(&content_bytes) else {
1321        return out;
1322    };
1323    if let Some(res) = page_res(doc, page_id) {
1324        // pdfium's page matrix: user space translated so the display box's
1325        // lower-left corner is the origin (see [`PageBox`]).
1326        let pb = page_box(doc, page_id);
1327        let base = Mat {
1328            e: -(pb.l as f64),
1329            f: -(pb.b as f64),
1330            ..Mat::ID
1331        };
1332        run_content(
1333            doc,
1334            res,
1335            &content,
1336            base,
1337            TextState::INIT,
1338            0,
1339            caches,
1340            &mut out,
1341        );
1342    }
1343    out
1344}
1345
1346/// Run a content stream's operators, emitting glyphs into `out`. Recurses into
1347/// Form XObjects on `Do` (bulk body text in heavy PDFs lives inside a form, not
1348/// the page content stream). `res` is the resources dict in scope (the page's,
1349/// or the form's own); `base_ctm` is the CTM at the point of invocation.
1350#[allow(clippy::too_many_arguments)]
1351fn run_content(
1352    doc: &Document,
1353    res: &Dictionary,
1354    content: &lopdf::content::Content,
1355    base_ctm: Mat,
1356    init: TextState,
1357    depth: u32,
1358    caches: &mut DocCaches,
1359    out: &mut Vec<Glyph>,
1360) {
1361    let fonts = fonts_from_res(doc, res, caches);
1362    let xobjects = res
1363        .get(b"XObject")
1364        .ok()
1365        .and_then(|o| deref(doc, o))
1366        .and_then(|o| o.as_dict().ok());
1367
1368    // Graphics + text state. `q`/`Q` save and restore the whole graphics state,
1369    // which includes the text parameters (Tc, Tw, Tz, TL, Tfs, Trise, font) —
1370    // *not* the text matrix (that is reset by BT). Saving only the CTM let a Tc
1371    // set inside a `q…Q` block leak out and drift every later glyph.
1372    #[allow(clippy::type_complexity)]
1373    let mut gstate_stack: Vec<(Mat, f64, f64, f64, f64, f64, f64, Option<&Arc<Font>>)> = Vec::new();
1374    let mut ctm = base_ctm;
1375    let mut tm = Mat::ID;
1376    let mut tlm = Mat::ID;
1377    let mut font: Option<&Arc<Font>> = None;
1378    let mut fsize = init.fsize;
1379    let mut tc = init.tc; // char spacing
1380    let mut tw = init.tw; // word spacing
1381    let mut th = init.th; // horizontal scale (Tz/100)
1382    let mut tl = init.tl; // leading
1383    let mut trise = init.trise;
1384
1385    let op_f = |operands: &[Object], i: usize| operands.get(i).and_then(num).unwrap_or(0.0);
1386
1387    for op in &content.operations {
1388        let operands = &op.operands;
1389        match op.operator.as_str() {
1390            "q" => gstate_stack.push((ctm, tc, tw, th, tl, trise, fsize, font)),
1391            "Q" => {
1392                if let Some((c, a, b, h, l, r, fs, f)) = gstate_stack.pop() {
1393                    ctm = c;
1394                    tc = a;
1395                    tw = b;
1396                    th = h;
1397                    tl = l;
1398                    trise = r;
1399                    fsize = fs;
1400                    font = f;
1401                }
1402            }
1403            "cm" => {
1404                let m = Mat {
1405                    a: op_f(operands, 0),
1406                    b: op_f(operands, 1),
1407                    c: op_f(operands, 2),
1408                    d: op_f(operands, 3),
1409                    e: op_f(operands, 4),
1410                    f: op_f(operands, 5),
1411                };
1412                ctm = m.then(ctm);
1413            }
1414            "BT" => {
1415                tm = Mat::ID;
1416                tlm = Mat::ID;
1417            }
1418            "ET" => {}
1419            "Tf" => {
1420                if let Some(Object::Name(n)) = operands.first() {
1421                    font = fonts.get(n.as_slice());
1422                }
1423                fsize = op_f(operands, 1);
1424            }
1425            "Td" => {
1426                tlm = Mat {
1427                    a: 1.0,
1428                    b: 0.0,
1429                    c: 0.0,
1430                    d: 1.0,
1431                    e: op_f(operands, 0),
1432                    f: op_f(operands, 1),
1433                }
1434                .then(tlm);
1435                tm = tlm;
1436            }
1437            "TD" => {
1438                tl = -op_f(operands, 1);
1439                tlm = Mat {
1440                    a: 1.0,
1441                    b: 0.0,
1442                    c: 0.0,
1443                    d: 1.0,
1444                    e: op_f(operands, 0),
1445                    f: op_f(operands, 1),
1446                }
1447                .then(tlm);
1448                tm = tlm;
1449            }
1450            "Tm" => {
1451                tlm = Mat {
1452                    a: op_f(operands, 0),
1453                    b: op_f(operands, 1),
1454                    c: op_f(operands, 2),
1455                    d: op_f(operands, 3),
1456                    e: op_f(operands, 4),
1457                    f: op_f(operands, 5),
1458                };
1459                tm = tlm;
1460            }
1461            "T*" => {
1462                tlm = Mat {
1463                    a: 1.0,
1464                    b: 0.0,
1465                    c: 0.0,
1466                    d: 1.0,
1467                    e: 0.0,
1468                    f: -tl,
1469                }
1470                .then(tlm);
1471                tm = tlm;
1472            }
1473            "Tc" => tc = op_f(operands, 0),
1474            "Tw" => tw = op_f(operands, 0),
1475            "Tz" => th = op_f(operands, 0) / 100.0,
1476            "TL" => tl = op_f(operands, 0),
1477            "Ts" => trise = op_f(operands, 0),
1478            "Tj" | "'" | "\"" => {
1479                if op.operator == "'" || op.operator == "\"" {
1480                    // move to next line first
1481                    tlm = Mat {
1482                        a: 1.0,
1483                        b: 0.0,
1484                        c: 0.0,
1485                        d: 1.0,
1486                        e: 0.0,
1487                        f: -tl,
1488                    }
1489                    .then(tlm);
1490                    tm = tlm;
1491                }
1492                if op.operator == "\"" {
1493                    // `aw ac string "` sets word- and char-spacing before
1494                    // showing the string (PDF 32000-1 §9.4.3), persisting after.
1495                    tw = op_f(operands, 0);
1496                    tc = op_f(operands, 1);
1497                }
1498                if let (Some(f), Some(Object::String(s, _))) = (font, operands.last()) {
1499                    show_text(f, s, fsize, tc, tw, th, trise, &mut tm, ctm, out);
1500                }
1501            }
1502            "TJ" => {
1503                if let (Some(f), Some(Object::Array(arr))) = (font, operands.first()) {
1504                    for el in arr {
1505                        match el {
1506                            Object::String(s, _) => {
1507                                show_text(f, s, fsize, tc, tw, th, trise, &mut tm, ctm, out)
1508                            }
1509                            other => {
1510                                if let Some(adj) = num(other) {
1511                                    // negative number moves text right (PDF: subtract)
1512                                    let tx = -adj / 1000.0 * fsize * th;
1513                                    tm = Mat {
1514                                        a: 1.0,
1515                                        b: 0.0,
1516                                        c: 0.0,
1517                                        d: 1.0,
1518                                        e: tx,
1519                                        f: 0.0,
1520                                    }
1521                                    .then(tm);
1522                                }
1523                            }
1524                        }
1525                    }
1526                }
1527            }
1528            "Do" => {
1529                // Invoke a Form XObject: bulk body text in many PDFs lives inside
1530                // a form, reached only here. Image XObjects are skipped (no text).
1531                if depth >= 8 {
1532                    continue;
1533                }
1534                let Some(Object::Name(n)) = operands.first() else {
1535                    continue;
1536                };
1537                let obj = xobjects.and_then(|d| d.get(n.as_slice()).ok());
1538                let form_id = match obj {
1539                    Some(Object::Reference(id)) => Some(*id),
1540                    _ => None,
1541                };
1542                let stream = obj
1543                    .and_then(|o| deref(doc, o))
1544                    .and_then(|o| o.as_stream().ok());
1545                let Some(stream) = stream else { continue };
1546                let is_form = stream
1547                    .dict
1548                    .get(b"Subtype")
1549                    .ok()
1550                    .and_then(|o| o.as_name().ok())
1551                    == Some(b"Form".as_slice());
1552                if !is_form {
1553                    continue;
1554                }
1555                // Decode the form's content once per document (headers/footers
1556                // and bulk body text invoke the same form on every page).
1557                let cached = form_id.and_then(|id| caches.forms.get(&id).cloned());
1558                let form_content = match cached {
1559                    Some(c) => c,
1560                    None => {
1561                        let Ok(data) = stream.decompressed_content() else {
1562                            continue;
1563                        };
1564                        let Ok(c) = lopdf::content::Content::decode(&data) else {
1565                            continue;
1566                        };
1567                        let c = Arc::new(c);
1568                        if let Some(id) = form_id {
1569                            caches.forms.insert(id, Arc::clone(&c));
1570                        }
1571                        c
1572                    }
1573                };
1574                // The form's /Matrix maps form space into the CTM at invocation.
1575                let form_mat = match stream.dict.get(b"Matrix").ok() {
1576                    Some(Object::Array(a)) if a.len() == 6 => {
1577                        let v: Vec<f64> = a.iter().filter_map(num).collect();
1578                        if v.len() == 6 {
1579                            Mat {
1580                                a: v[0],
1581                                b: v[1],
1582                                c: v[2],
1583                                d: v[3],
1584                                e: v[4],
1585                                f: v[5],
1586                            }
1587                        } else {
1588                            Mat::ID
1589                        }
1590                    }
1591                    _ => Mat::ID,
1592                };
1593                // The form's own /Resources, falling back to the inherited ones.
1594                let form_res = stream
1595                    .dict
1596                    .get(b"Resources")
1597                    .ok()
1598                    .and_then(|o| deref(doc, o))
1599                    .and_then(|o| o.as_dict().ok())
1600                    .unwrap_or(res);
1601                let state = TextState {
1602                    tc,
1603                    tw,
1604                    th,
1605                    tl,
1606                    trise,
1607                    fsize,
1608                };
1609                run_content(
1610                    doc,
1611                    form_res,
1612                    &form_content,
1613                    form_mat.then(ctm),
1614                    state,
1615                    depth + 1,
1616                    caches,
1617                    out,
1618                );
1619            }
1620            _ => {}
1621        }
1622    }
1623}
1624
1625#[allow(clippy::too_many_arguments)]
1626fn show_text(
1627    font: &Font,
1628    bytes: &[u8],
1629    fsize: f64,
1630    tc: f64,
1631    tw: f64,
1632    th: f64,
1633    trise: f64,
1634    tm: &mut Mat,
1635    ctm: Mat,
1636    out: &mut Vec<Glyph>,
1637) {
1638    for code in codes(font, bytes) {
1639        let (text, w) = font.decode_code(code);
1640        let w0 = w / 1000.0; // advance in text-space (em) units
1641                             // The glyph→user transform: scale glyph space by font size, then Tm, CTM.
1642        let scale = Mat {
1643            a: fsize * th,
1644            b: 0.0,
1645            c: 0.0,
1646            d: fsize,
1647            e: 0.0,
1648            f: trise,
1649        };
1650        let trm = scale.then(*tm).then(ctm);
1651        // Box in glyph space (1000-unit em): x 0..w, y descent..ascent.
1652        let (x0, y0) = trm.apply(0.0, font.descent / 1000.0);
1653        let (x1, _y1) = trm.apply(w0, font.descent / 1000.0);
1654        let (_x2, y2) = trm.apply(0.0, font.ascent / 1000.0);
1655        let (left, right) = (x0.min(x1), x0.max(x1));
1656        let (bot, top) = (y0.min(y2), y0.max(y2));
1657        if let Some(s) = text {
1658            // A run may map one code to multiple chars (ligature/fraction); share box.
1659            for ch in s.chars() {
1660                if ch != '\u{0}' {
1661                    out.push(Glyph {
1662                        ch,
1663                        l: left as f32,
1664                        b: bot as f32,
1665                        r: right as f32,
1666                        t: top as f32,
1667                        ll: left as f32,
1668                        lb: bot as f32,
1669                        lr: right as f32,
1670                        lt: top as f32,
1671                        font: font.hash,
1672                    });
1673                }
1674            }
1675        }
1676        // Advance the text matrix. Word spacing applies to single-byte code 32.
1677        let is_space = !font.two_byte && code == 32;
1678        let tx = (w0 * fsize + tc + if is_space { tw } else { 0.0 }) * th;
1679        *tm = Mat {
1680            a: 1.0,
1681            b: 0.0,
1682            c: 0.0,
1683            d: 1.0,
1684            e: tx,
1685            f: 0.0,
1686        }
1687        .then(*tm);
1688    }
1689}
1690
1691/// Build a simple font's code→char table from its `/Encoding`: the base
1692/// encoding (WinAnsi / MacRoman) plus any `/Differences` overrides (glyph names
1693/// resolved through a small Adobe-glyph-name subset).
1694fn simple_encoding_table(doc: &Document, fdict: &Dictionary) -> HashMap<u8, char> {
1695    let enc = fdict.get(b"Encoding").ok().and_then(|o| deref(doc, o));
1696    let base_name = match enc {
1697        Some(Object::Name(n)) => n.clone(),
1698        Some(Object::Dictionary(d)) => d
1699            .get(b"BaseEncoding")
1700            .ok()
1701            .and_then(|o| o.as_name().ok())
1702            .map(|n| n.to_vec())
1703            .unwrap_or_default(),
1704        _ => Vec::new(),
1705    };
1706    let mut m = if base_name == b"MacRomanEncoding" {
1707        macroman_table()
1708    } else if base_name.is_empty() {
1709        // No PDF /Encoding at all: the font's *built-in* encoding applies. For
1710        // the standard TeX math fonts that is their fixed TeX layout — falling
1711        // back to StandardEncoding read CMSY's braces as `f`/`g`, `→` as `!`,
1712        // `∈` as `2` (2203's `{ahn,…}` author line). The font program (often
1713        // CFF, which this parser does not read) carries the same mapping;
1714        // docling-parse decodes it from there.
1715        tex_math_builtin(fdict).unwrap_or_else(winansi_table)
1716    } else {
1717        winansi_table()
1718    };
1719    // Apply /Differences: `code /glyphname /glyphname ... code ...`.
1720    if let Some(Object::Dictionary(d)) = enc {
1721        if let Some(Object::Array(diffs)) = d.get(b"Differences").ok().and_then(|o| deref(doc, o)) {
1722            let mut code = 0u8;
1723            for el in diffs {
1724                match el {
1725                    Object::Integer(i) => code = *i as u8,
1726                    Object::Name(name) => {
1727                        if let Some(ch) = glyph_name_to_char(name) {
1728                            m.insert(code, ch);
1729                        }
1730                        code = code.wrapping_add(1);
1731                    }
1732                    _ => {}
1733                }
1734            }
1735        }
1736    }
1737    m
1738}
1739
1740/// The fixed built-in encodings of the standard TeX math fonts (TeXbook
1741/// Appendix F), keyed off the base font name: `CMSY*` (symbols; `CMBSY` is its
1742/// bold) and `CMMI*` (math italic). These fonts ship no PDF `/Encoding` and no
1743/// ToUnicode, and their program is usually CFF — without this table the codes
1744/// fell through to StandardEncoding and rendered as the wrong ASCII.
1745fn tex_math_builtin(fdict: &Dictionary) -> Option<HashMap<u8, char>> {
1746    const CMSY: [char; 128] = [
1747        '−', '·', '×', '∗', '÷', '⋄', '±', '∓', '⊕', '⊖', '⊗', '⊘', '⊙', '◯', '∘', '•', '≍', '≡',
1748        '⊆', '⊇', '≤', '≥', '≼', '≽', '∼', '≈', '⊂', '⊃', '≪', '≫', '≺', '≻', '←', '→', '↑', '↓',
1749        '↔', '↗', '↘', '≃', '⇐', '⇒', '⇑', '⇓', '⇔', '↖', '↙', '∝', '′', '∞', '∈', '∋', '△', '▽',
1750        '\u{338}', '↦', '∀', '∃', '¬', '∅', 'ℜ', 'ℑ', '⊤', '⊥', 'ℵ', 'A', 'B', 'C', 'D', 'E', 'F',
1751        'G', 'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X',
1752        'Y', 'Z', '∪', '∩', '⊎', '∧', '∨', '⊢', '⊣', '⌊', '⌋', '⌈', '⌉', '{', '}', '⟨', '⟩', '|',
1753        '∥', '↕', '⇕', '\\', '≀', '√', '∐', '∇', '∫', '⊔', '⊓', '⊑', '⊒', '§', '†', '‡', '¶', '♣',
1754        '♢', '♡', '♠',
1755    ];
1756    const CMMI: [char; 128] = [
1757        'Γ', 'Δ', 'Θ', 'Λ', 'Ξ', 'Π', 'Σ', 'Υ', 'Φ', 'Ψ', 'Ω', 'α', 'β', 'γ', 'δ', 'ε', 'ζ', 'η',
1758        'θ', 'ι', 'κ', 'λ', 'μ', 'ν', 'ξ', 'π', 'ρ', 'σ', 'τ', 'υ', 'φ', 'χ', 'ψ', 'ω', 'ϵ', 'ϑ',
1759        'ϖ', 'ϱ', 'ς', 'ϕ', '↼', '↽', '⇀', '⇁', '↩', '↪', '▷', '◁', '0', '1', '2', '3', '4', '5',
1760        '6', '7', '8', '9', '.', ',', '<', '/', '>', '⋆', '∂', 'A', 'B', 'C', 'D', 'E', 'F', 'G',
1761        'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', 'Y',
1762        'Z', '♭', '♮', '♯', '⌣', '⌢', 'ℓ', 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k',
1763        'l', 'm', 'n', 'o', 'p', 'q', 'r', 's', 't', 'u', 'v', 'w', 'x', 'y', 'z', 'ı', 'ȷ', '℘',
1764        '\u{20d7}', '⁀',
1765    ];
1766    let name = base_font_name(fdict)?;
1767    let up = name.to_ascii_uppercase();
1768    let table: &[char; 128] = if up.starts_with(b"CMSY") || up.starts_with(b"CMBSY") {
1769        &CMSY
1770    } else if up.starts_with(b"CMMI") {
1771        &CMMI
1772    } else {
1773        return None;
1774    };
1775    Some(
1776        table
1777            .iter()
1778            .enumerate()
1779            .map(|(i, &c)| (i as u8, c))
1780            .collect(),
1781    )
1782}
1783
1784/// Resolve an Adobe glyph name to Unicode: `uniXXXX`, single ASCII letters, the
1785/// digit/punctuation names from the Adobe Glyph List, and common typographic
1786/// names. A `.suffix` (`one.taboldstyle`, `a.sc`) is stripped and the base name
1787/// retried — docling renders these as the base character.
1788fn glyph_name_to_char(name: &[u8]) -> Option<char> {
1789    let s = std::str::from_utf8(name).ok()?;
1790    if let Some(hex) = s.strip_prefix("uni") {
1791        if let Ok(cp) = u32::from_str_radix(hex.get(0..4)?, 16) {
1792            return char::from_u32(cp);
1793        }
1794    }
1795    // Single ASCII letter names (`A`, `m`) map to themselves.
1796    if s.len() == 1 {
1797        let b = s.as_bytes()[0];
1798        if b.is_ascii_alphabetic() {
1799            return Some(b as char);
1800        }
1801    }
1802    let resolved = match s {
1803        "space" => ' ',
1804        "exclam" => '!',
1805        "quotedbl" => '"',
1806        "numbersign" => '#',
1807        "dollar" => '$',
1808        "percent" => '%',
1809        "ampersand" => '&',
1810        "quotesingle" => '\'',
1811        "parenleft" => '(',
1812        "parenright" => ')',
1813        "asterisk" => '*',
1814        "plus" => '+',
1815        "comma" => ',',
1816        "hyphen" => '-',
1817        "period" => '.',
1818        "slash" => '/',
1819        "zero" => '0',
1820        "one" => '1',
1821        "two" => '2',
1822        "three" => '3',
1823        "four" => '4',
1824        "five" => '5',
1825        "six" => '6',
1826        "seven" => '7',
1827        "eight" => '8',
1828        "nine" => '9',
1829        "colon" => ':',
1830        "semicolon" => ';',
1831        "less" => '<',
1832        "equal" => '=',
1833        "greater" => '>',
1834        "question" => '?',
1835        "at" => '@',
1836        "bracketleft" => '[',
1837        "backslash" => '\\',
1838        "bracketright" => ']',
1839        "asciicircum" => '^',
1840        "underscore" => '_',
1841        "grave" => '`',
1842        "braceleft" => '{',
1843        "bar" => '|',
1844        "braceright" => '}',
1845        "asciitilde" => '~',
1846        "bullet" => '\u{2022}',
1847        "periodcentered" => '\u{00B7}',
1848        "endash" => '\u{2013}',
1849        "emdash" => '\u{2014}',
1850        "quoteright" => '\u{2019}',
1851        "quoteleft" => '\u{2018}',
1852        "quotedblleft" => '\u{201C}',
1853        "quotedblright" => '\u{201D}',
1854        "quotedblbase" => '\u{201E}',
1855        "quotesinglbase" => '\u{201A}',
1856        // Latin f-ligatures named in `/Differences` (e.g. 2305's body font). These
1857        // map to the presentation-form code points, which `decompose_ligatures`
1858        // then spells back out (`ff`→"ff") — without them the glyph decodes to
1859        // nothing and the sanitizer fills the gap with a space (`di erences`).
1860        "ff" => '\u{FB00}',
1861        "fi" => '\u{FB01}',
1862        "fl" => '\u{FB02}',
1863        "ffi" => '\u{FB03}',
1864        "ffl" => '\u{FB04}',
1865        "ft" => '\u{FB05}',
1866        "st" => '\u{FB06}',
1867        "degree" => '\u{00B0}',
1868        "trademark" => '\u{2122}',
1869        "registered" => '\u{00AE}',
1870        "copyright" => '\u{00A9}',
1871        "ellipsis" => '\u{2026}',
1872        "minus" => '\u{2212}',
1873        "fraction" => '\u{2044}',
1874        "nbspace" => '\u{00A0}',
1875        // Greek + math glyph names (standard Adobe Glyph List). Standard TeX math
1876        // fonts (CMMI/CMSY/…) name their glyphs this way in the embedded font
1877        // program's `/Encoding`; without these a `λ`/`≤` decodes to nothing and is
1878        // dropped from body text (`and λ set to 0.5` → `and set to 0.5`).
1879        "alpha" => '\u{03B1}',
1880        "beta" => '\u{03B2}',
1881        "gamma" => '\u{03B3}',
1882        "delta" => '\u{03B4}',
1883        "epsilon" | "epsilon1" => '\u{03B5}',
1884        "zeta" => '\u{03B6}',
1885        "eta" => '\u{03B7}',
1886        "theta" | "theta1" => '\u{03B8}',
1887        "iota" => '\u{03B9}',
1888        "kappa" => '\u{03BA}',
1889        "lambda" => '\u{03BB}',
1890        "mu" => '\u{03BC}',
1891        "nu" => '\u{03BD}',
1892        "xi" => '\u{03BE}',
1893        "omicron" => '\u{03BF}',
1894        "pi" | "pi1" => '\u{03C0}',
1895        "rho" | "rho1" => '\u{03C1}',
1896        "sigma" => '\u{03C3}',
1897        "sigma1" => '\u{03C2}',
1898        "tau" => '\u{03C4}',
1899        "upsilon" => '\u{03C5}',
1900        "phi" | "phi1" => '\u{03C6}',
1901        "chi" => '\u{03C7}',
1902        "psi" => '\u{03C8}',
1903        "omega" | "omega1" => '\u{03C9}',
1904        "Gamma" => '\u{0393}',
1905        "Delta" => '\u{0394}',
1906        "Theta" => '\u{0398}',
1907        "Lambda" => '\u{039B}',
1908        "Xi" => '\u{039E}',
1909        "Pi" => '\u{03A0}',
1910        "Sigma" => '\u{03A3}',
1911        "Upsilon" => '\u{03A5}',
1912        "Phi" => '\u{03A6}',
1913        "Psi" => '\u{03A8}',
1914        "Omega" => '\u{03A9}',
1915        "lessequal" => '\u{2264}',
1916        "greaterequal" => '\u{2265}',
1917        "notequal" => '\u{2260}',
1918        "approxequal" => '\u{2248}',
1919        "equivalence" => '\u{2261}',
1920        "element" => '\u{2208}',
1921        "plusminus" => '\u{00B1}',
1922        "multiply" => '\u{00D7}',
1923        "divide" => '\u{00F7}',
1924        "infinity" => '\u{221E}',
1925        "partialdiff" => '\u{2202}',
1926        "gradient" => '\u{2207}',
1927        "summation" => '\u{2211}',
1928        "product" => '\u{220F}',
1929        "integral" => '\u{222B}',
1930        "radical" => '\u{221A}',
1931        "proportional" => '\u{221D}',
1932        "arrowright" => '\u{2192}',
1933        "arrowleft" => '\u{2190}',
1934        "arrowup" => '\u{2191}',
1935        "arrowdown" => '\u{2193}',
1936        "arrowboth" => '\u{2194}',
1937        "arrowdblright" => '\u{21D2}',
1938        "logicaland" => '\u{2227}',
1939        "logicalor" => '\u{2228}',
1940        "intersection" => '\u{2229}',
1941        "union" => '\u{222A}',
1942        "similar" => '\u{223C}',
1943        "congruent" => '\u{2245}',
1944        "dotmath" => '\u{22C5}',
1945        "asteriskmath" => '\u{2217}',
1946        _ => {
1947            // Strip an AGL `.suffix` (oldstyle/small-cap variant) and retry.
1948            if let Some((base, _)) = s.split_once('.') {
1949                if !base.is_empty() {
1950                    return glyph_name_to_char(base.as_bytes());
1951                }
1952            }
1953            return None;
1954        }
1955    };
1956    Some(resolved)
1957}
1958
1959/// Minimal WinAnsiEncoding (Latin-1-ish) for simple fonts lacking ToUnicode.
1960fn winansi_table() -> HashMap<u8, char> {
1961    let mut m = HashMap::new();
1962    for b in 0x20u8..=0x7e {
1963        m.insert(b, b as char);
1964    }
1965    // High range: Windows-1252 printable points that differ from Latin-1.
1966    let extra: &[(u8, char)] = &[
1967        (0x91, '\u{2018}'),
1968        (0x92, '\u{2019}'),
1969        (0x93, '\u{201C}'),
1970        (0x94, '\u{201D}'),
1971        (0x95, '\u{2022}'),
1972        (0x96, '\u{2013}'),
1973        (0x97, '\u{2014}'),
1974        (0x85, '\u{2026}'),
1975        (0xA0, '\u{00A0}'),
1976    ];
1977    for &(b, c) in extra {
1978        m.insert(b, c);
1979    }
1980    for b in 0xA1u8..=0xFF {
1981        m.entry(b).or_insert(b as char);
1982    }
1983    m
1984}
1985
1986/// Minimal MacRomanEncoding: ASCII plus the high-range points our corpus hits
1987/// (notably 0xA5 = bullet, used as a list marker).
1988fn macroman_table() -> HashMap<u8, char> {
1989    let mut m = HashMap::new();
1990    for b in 0x20u8..=0x7e {
1991        m.insert(b, b as char);
1992    }
1993    let high: &[(u8, char)] = &[
1994        (0xA5, '\u{2022}'), // bullet
1995        (0xD0, '\u{2013}'), // endash
1996        (0xD1, '\u{2014}'), // emdash
1997        (0xD2, '\u{201C}'),
1998        (0xD3, '\u{201D}'),
1999        (0xD4, '\u{2018}'),
2000        (0xD5, '\u{2019}'),
2001        (0xCA, '\u{00A0}'),
2002        (0xC9, '\u{2026}'),
2003        (0xDE, '\u{FB01}'),
2004        (0xDF, '\u{FB02}'),
2005    ];
2006    for &(b, c) in high {
2007        m.insert(b, c);
2008    }
2009    m
2010}
2011
2012#[cfg(test)]
2013mod page_box_frame {
2014    use super::*;
2015
2016    /// One page, `boxes` spliced into the page dictionary verbatim, one text
2017    /// run at user-space `(x, y)`.
2018    fn pdf(boxes: &str, x: f32, y: f32) -> Vec<u8> {
2019        let content = format!("BT /F1 12 Tf {x} {y} Td (First printing) Tj ET\n");
2020        let objs: Vec<String> = vec![
2021            "<</Type/Catalog/Pages 2 0 R>>".into(),
2022            format!("<</Type/Pages/Kids[3 0 R]/Count 1{boxes}>>"),
2023            "<</Type/Page/Parent 2 0 R/Contents 4 0 R/Resources<</Font<</F1 5 0 R>>>>>>".into(),
2024            format!("<</Length {}>>stream\n{content}endstream", content.len()),
2025            "<</Type/Font/Subtype/Type1/BaseFont/Helvetica>>".into(),
2026        ];
2027        let mut out = b"%PDF-1.4\n".to_vec();
2028        let mut offsets = Vec::new();
2029        for (i, body) in objs.iter().enumerate() {
2030            offsets.push(out.len());
2031            out.extend_from_slice(format!("{} 0 obj{body}endobj\n", i + 1).as_bytes());
2032        }
2033        let xref_at = out.len();
2034        out.extend_from_slice(
2035            format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes(),
2036        );
2037        for off in &offsets {
2038            out.extend_from_slice(format!("{off:010} 00000 n \n").as_bytes());
2039        }
2040        out.extend_from_slice(
2041            format!(
2042                "trailer<</Size {}/Root 1 0 R>>\nstartxref\n{xref_at}\n%%EOF\n",
2043                objs.len() + 1
2044            )
2045            .as_bytes(),
2046        );
2047        out
2048    }
2049
2050    fn only_page(bytes: &[u8]) -> (PageBox, Vec<Glyph>) {
2051        let doc = load_document(bytes).expect("loads");
2052        let pid = *doc.get_pages().values().next().expect("one page");
2053        (page_box(&doc, pid), page_glyphs(&doc, pid))
2054    }
2055
2056    /// The trimmed-book-page shape: MediaBox and CropBox both away from the
2057    /// origin (inherited from `/Pages`). The display box is the CropBox, and a
2058    /// glyph's coordinates count from its lower-left corner — the same numbers
2059    /// the same text gets on a page whose CropBox *is* `[0 0 w h]`.
2060    #[test]
2061    fn glyphs_count_from_the_cropbox_corner_like_pdfium() {
2062        let (pb, shifted) = only_page(&pdf(
2063            "/MediaBox[-56.505 -58.25 576.303 723.31]/CropBox[1.095 -0.65 518.703 665.71]",
2064            37.0 + 1.095,
2065            58.0 - 0.65,
2066        ));
2067        assert!((pb.l - 1.095).abs() < 1e-3 && (pb.b + 0.65).abs() < 1e-3);
2068        assert!(
2069            (pb.w - 517.608).abs() < 1e-3 && (pb.h - 666.36).abs() < 1e-3,
2070            "{pb:?}"
2071        );
2072        let (pb0, plain) = only_page(&pdf("/MediaBox[0 0 517.608 666.36]", 37.0, 58.0));
2073        assert!((pb0.w - pb.w).abs() < 1e-3 && (pb0.h - pb.h).abs() < 1e-3);
2074        assert_eq!(shifted.len(), plain.len());
2075        assert!(!plain.is_empty());
2076        for (a, b) in shifted.iter().zip(&plain) {
2077            assert!(
2078                (a.l - b.l).abs() < 1e-3 && (a.b - b.b).abs() < 1e-3,
2079                "{:?} vs {:?}",
2080                (a.l, a.b),
2081                (b.l, b.b)
2082            );
2083        }
2084        assert!((plain[0].l - 37.0).abs() < 1e-3, "{}", plain[0].l);
2085    }
2086
2087    /// pdfium's fallbacks: no MediaBox → Letter; a CropBox is clipped to the
2088    /// MediaBox, and one that misses it entirely is ignored.
2089    #[test]
2090    fn page_box_follows_pdfium_fallbacks() {
2091        let (pb, _) = only_page(&pdf("", 10.0, 10.0));
2092        assert_eq!((pb.l, pb.b, pb.w, pb.h), (0.0, 0.0, 612.0, 792.0));
2093        let (pb, _) = only_page(&pdf(
2094            "/MediaBox[0 0 500 700]/CropBox[-100 100 600 900]",
2095            10.0,
2096            10.0,
2097        ));
2098        assert_eq!((pb.l, pb.b, pb.w, pb.h), (0.0, 100.0, 500.0, 600.0));
2099        let (pb, _) = only_page(&pdf(
2100            "/MediaBox[0 0 500 700]/CropBox[800 800 900 900]",
2101            10.0,
2102            10.0,
2103        ));
2104        assert_eq!((pb.l, pb.b, pb.w, pb.h), (0.0, 0.0, 500.0, 700.0));
2105        // Reversed corners normalize.
2106        let (pb, _) = only_page(&pdf("/MediaBox[500 700 0 0]", 10.0, 10.0));
2107        assert_eq!((pb.l, pb.b, pb.w, pb.h), (0.0, 0.0, 500.0, 700.0));
2108    }
2109}
2110
2111#[cfg(test)]
2112mod xref_repair {
2113    /// Build a tiny one-page PDF whose cross-reference entries are either the
2114    /// spec's 20 bytes (`two_byte_eol`) or the 19-byte form some generators
2115    /// emit — everything else about the two files is identical.
2116    fn pdf_with_xref(two_byte_eol: bool) -> Vec<u8> {
2117        let content = b"BT /F1 12 Tf 72 700 Td (Invoice 922769430725) Tj ET\n";
2118        let stream = format!("<</Length {}>>stream\n", content.len()).into_bytes();
2119        let objs: Vec<Vec<u8>> = vec![
2120            b"<</Type/Catalog/Pages 2 0 R>>".to_vec(),
2121            b"<</Type/Pages/Kids[3 0 R]/Count 1>>".to_vec(),
2122            b"<</Type/Page/Parent 2 0 R/MediaBox[0 0 595 842]/Contents 4 0 R\
2123               /Resources<</Font<</F1 5 0 R>>>>>>"
2124                .to_vec(),
2125            [stream.as_slice(), content.as_slice(), b"endstream"].concat(),
2126            b"<</Type/Font/Subtype/Type1/BaseFont/Helvetica>>".to_vec(),
2127        ];
2128
2129        let mut out = b"%PDF-1.4\n".to_vec();
2130        let mut offsets = Vec::new();
2131        for (i, body) in objs.iter().enumerate() {
2132            offsets.push(out.len());
2133            out.extend_from_slice(format!("{} 0 obj", i + 1).as_bytes());
2134            out.extend_from_slice(body);
2135            out.extend_from_slice(b"endobj\n");
2136        }
2137        let xref_at = out.len();
2138        let eol: &[u8] = if two_byte_eol { b" \n" } else { b"\n" };
2139        out.extend_from_slice(format!("xref\n0 {}\n", objs.len() + 1).as_bytes());
2140        out.extend_from_slice(b"0000000000 65535 f");
2141        out.extend_from_slice(eol);
2142        for off in &offsets {
2143            out.extend_from_slice(format!("{off:010} 00000 n").as_bytes());
2144            out.extend_from_slice(eol);
2145        }
2146        out.extend_from_slice(
2147            format!("trailer<</Size {}/Root 1 0 R>>\n", objs.len() + 1).as_bytes(),
2148        );
2149        out.extend_from_slice(format!("startxref\n{xref_at}\n%%EOF\n").as_bytes());
2150        out
2151    }
2152
2153    /// A 19-byte cross-reference entry (a bare LF where the spec wants a
2154    /// two-byte EOL) makes lopdf reject the whole file, so a readable text
2155    /// layer used to look exactly like a scan — in the browser that meant ten
2156    /// seconds of OCR for nothing. The repair must recover *the same* parse the
2157    /// well-formed file gives.
2158    #[test]
2159    fn short_xref_entries_still_parse() {
2160        let good = pdf_with_xref(true);
2161        let broken = pdf_with_xref(false);
2162        assert!(
2163            broken.len() < good.len(),
2164            "the broken file is the shorter one"
2165        );
2166        assert!(
2167            lopdf::Document::load_mem(&good).is_ok(),
2168            "the control file must load unaided"
2169        );
2170        assert!(
2171            lopdf::Document::load_mem(&broken).is_err(),
2172            "lopdf rejects 19-byte entries — if this ever passes, drop the repair"
2173        );
2174
2175        let cells = |b: &[u8]| -> Vec<String> {
2176            super::pdf_textlines(b)
2177                .into_iter()
2178                .flat_map(|(_, _, c)| c.into_iter().map(|c| c.text))
2179                .collect()
2180        };
2181        let from_good = cells(&good);
2182        assert!(
2183            from_good.iter().any(|t| t.contains("922769430725")),
2184            "control text: {from_good:?}"
2185        );
2186        assert_eq!(
2187            cells(&broken),
2188            from_good,
2189            "repair must match the good parse"
2190        );
2191    }
2192
2193    /// The same generator overstates `/Length`, so lopdf reads past the data,
2194    /// misses `endstream` and drops the stream — the object comes back as a
2195    /// bare dictionary and the page has no content at all. Trust `endstream`
2196    /// instead, and do it without moving a single byte.
2197    #[test]
2198    fn overstated_stream_length_still_yields_content() {
2199        let good = pdf_with_xref(true);
2200        // Inflate the content stream's /Length by one, exactly as the invoice
2201        // that prompted this does.
2202        let broken = {
2203            let at = good
2204                .windows(8)
2205                .position(|w| w == b"/Length ")
2206                .expect("a /Length")
2207                + 8;
2208            let digits = good[at..].iter().take_while(|c| c.is_ascii_digit()).count();
2209            let n: usize = std::str::from_utf8(&good[at..at + digits])
2210                .unwrap()
2211                .parse()
2212                .unwrap();
2213            let inflated = (n + 1).to_string();
2214            assert_eq!(inflated.len(), digits, "keep the digit count");
2215            let mut b = good.clone();
2216            b[at..at + digits].copy_from_slice(inflated.as_bytes());
2217            b
2218        };
2219        assert_eq!(broken.len(), good.len(), "the defect must not move bytes");
2220        // lopdf alone loses the stream: the page parses but carries no content.
2221        let raw = lopdf::Document::load_mem(&broken).expect("still loads");
2222        assert!(
2223            raw.get_pages()
2224                .into_values()
2225                .all(|p| raw.get_page_content(p).is_empty()),
2226            "lopdf should drop the stream — if it stops, drop this repair"
2227        );
2228        // Ours recovers the same text the well-formed file gives.
2229        let text = |b: &[u8]| -> Vec<String> {
2230            super::pdf_textlines(b)
2231                .into_iter()
2232                .flat_map(|(_, _, c)| c.into_iter().map(|c| c.text))
2233                .collect()
2234        };
2235        let expected = text(&good);
2236        assert!(!expected.is_empty(), "control must produce text");
2237        assert_eq!(text(&broken), expected);
2238    }
2239
2240    /// The repair only fires where padding cannot move an object: it declines a
2241    /// file whose xref precedes an object (an incremental update), rather than
2242    /// shifting every offset the table records.
2243    #[test]
2244    fn repair_declines_when_padding_would_move_objects() {
2245        let mut incremental = pdf_with_xref(false);
2246        incremental.extend_from_slice(b"6 0 obj<</Type/Whatever>>endobj\n");
2247        let declined = super::pad_short_xref_entries(&incremental).unwrap_err();
2248        assert!(
2249            declined.contains("object follows the xref"),
2250            "reason: {declined}"
2251        );
2252    }
2253}
2254
2255/// #187: standard-14 fonts referenced without an embedded program (and thus
2256/// usually without `/Widths` or a `/FontDescriptor`) must decode with the
2257/// built-in Adobe Core 14 metrics instead of collapsing every cell to zero
2258/// width — the failure mode where a valid text layer was silently dropped
2259/// while pdfium read the same file fine.
2260#[cfg(test)]
2261mod base14_fonts {
2262    /// A one-page PDF whose single `Tj` uses `fontdict` (no embedded program).
2263    fn pdf_with_font(fontdict: &[u8], text: &[u8]) -> Vec<u8> {
2264        let content = [b"BT /F1 12 Tf 72 700 Td (".as_slice(), text, b") Tj ET\n"].concat();
2265        let stream = format!("<</Length {}>>stream\n", content.len()).into_bytes();
2266        let objs: Vec<Vec<u8>> = vec![
2267            b"<</Type/Catalog/Pages 2 0 R>>".to_vec(),
2268            b"<</Type/Pages/Kids[3 0 R]/Count 1>>".to_vec(),
2269            b"<</Type/Page/Parent 2 0 R/MediaBox[0 0 595 842]/Contents 4 0 R\
2270               /Resources<</Font<</F1 5 0 R>>>>>>"
2271                .to_vec(),
2272            [stream.as_slice(), content.as_slice(), b"endstream"].concat(),
2273            fontdict.to_vec(),
2274        ];
2275        let mut out = b"%PDF-1.4\n".to_vec();
2276        let mut offsets = Vec::new();
2277        for (i, body) in objs.iter().enumerate() {
2278            offsets.push(out.len());
2279            out.extend_from_slice(format!("{} 0 obj", i + 1).as_bytes());
2280            out.extend_from_slice(body);
2281            out.extend_from_slice(b"endobj\n");
2282        }
2283        let xref_at = out.len();
2284        out.extend_from_slice(format!("xref\n0 {}\n", objs.len() + 1).as_bytes());
2285        out.extend_from_slice(b"0000000000 65535 f \n");
2286        for off in &offsets {
2287            out.extend_from_slice(format!("{off:010} 00000 n \n").as_bytes());
2288        }
2289        out.extend_from_slice(
2290            format!("trailer<</Size {}/Root 1 0 R>>\n", objs.len() + 1).as_bytes(),
2291        );
2292        out.extend_from_slice(format!("startxref\n{xref_at}\n%%EOF\n").as_bytes());
2293        out
2294    }
2295
2296    /// The parsed cells of the only page.
2297    fn cells(pdf: &[u8]) -> Vec<crate::pdfium_backend::TextCell> {
2298        super::pdf_textlines(pdf)
2299            .into_iter()
2300            .flat_map(|(_, _, c)| c)
2301            .collect()
2302    }
2303
2304    /// Every standard-14 alias/style decodes with real (positive-width) boxes.
2305    #[test]
2306    fn standard14_faces_get_builtin_widths() {
2307        for fontdict in [
2308            // ReportLab's default: base-14 Helvetica, WinAnsi, nothing else.
2309            b"<</Type/Font/Subtype/Type1/BaseFont/Helvetica/Encoding/WinAnsiEncoding>>".as_slice(),
2310            // No /Encoding at all (StandardEncoding-ish default).
2311            b"<</Type/Font/Subtype/Type1/BaseFont/Times-BoldItalic>>",
2312            // Substitution aliases + a subset prefix.
2313            b"<</Type/Font/Subtype/TrueType/BaseFont/Arial,Bold>>",
2314            b"<</Type/Font/Subtype/Type1/BaseFont/ABCDEF+Courier-Oblique>>",
2315        ] {
2316            let pdf = pdf_with_font(fontdict, b"Words have width now");
2317            let cs = cells(&pdf);
2318            let text: String = cs
2319                .iter()
2320                .map(|c| c.text.as_str())
2321                .collect::<Vec<_>>()
2322                .join(" ");
2323            assert!(
2324                text.contains("Words have width now"),
2325                "{}: text lost: {text:?}",
2326                String::from_utf8_lossy(fontdict)
2327            );
2328            assert!(
2329                cs.iter().all(|c| c.r > c.l),
2330                "{}: zero-width cells: {cs:?}",
2331                String::from_utf8_lossy(fontdict)
2332            );
2333        }
2334    }
2335
2336    /// An explicit `/Widths` array always wins over the built-in metrics, and a
2337    /// non-standard face without `/Widths` stays as before (no invented boxes).
2338    #[test]
2339    fn explicit_widths_win_and_unknown_faces_are_untouched() {
2340        // Helvetica with explicit 100/1000-em widths: the word's box must be
2341        // ~4×100 units at 12pt = 4.8pt wide — far narrower than the ~2.7×
2342        // wider built-in Helvetica advances would make it.
2343        let explicit = pdf_with_font(
2344            b"<</Type/Font/Subtype/Type1/BaseFont/Helvetica/FirstChar 65\
2345               /Widths[100 100 100 100]/Encoding/WinAnsiEncoding>>",
2346            b"ABBA",
2347        );
2348        let builtin = pdf_with_font(
2349            b"<</Type/Font/Subtype/Type1/BaseFont/Helvetica/Encoding/WinAnsiEncoding>>",
2350            b"ABBA",
2351        );
2352        let w = |pdf: &[u8]| {
2353            let cs = cells(pdf);
2354            assert_eq!(cs.len(), 1, "one word cell: {cs:?}");
2355            cs[0].r - cs[0].l
2356        };
2357        let (we, wb) = (w(&explicit), w(&builtin));
2358        assert!(
2359            (we - 4.8).abs() < 0.1,
2360            "explicit widths must win: got {we}, want 4×100×12/1000"
2361        );
2362        assert!(
2363            wb > 2.0 * we,
2364            "built-in Helvetica is much wider: {wb} vs {we}"
2365        );
2366
2367        // An unknown face with no /Widths: still parses (text kept), but no
2368        // built-in table applies — the old zero-width behavior is preserved
2369        // rather than inventing Helvetica metrics for an arbitrary font.
2370        let unknown = pdf_with_font(
2371            b"<</Type/Font/Subtype/Type1/BaseFont/FancyCorp-Display>>",
2372            b"Mystery",
2373        );
2374        let cs = cells(&unknown);
2375        let text: String = cs.iter().map(|c| c.text.as_str()).collect();
2376        assert!(text.contains("Mystery"), "text still decodes: {cs:?}");
2377    }
2378}
2379
2380#[cfg(test)]
2381mod overpainted {
2382    use crate::pdfium_backend::TextCell;
2383
2384    fn cell(text: &str, l: f32, t: f32, r: f32, b: f32) -> TextCell {
2385        TextCell {
2386            text: text.into(),
2387            l,
2388            t,
2389            r,
2390            b,
2391        }
2392    }
2393
2394    /// The reporting invoice's logo: a `"` painted inside a `==` on one band —
2395    /// artwork drawn with glyphs. Both cells go; the real text on the next
2396    /// band stays.
2397    #[test]
2398    fn stacked_logo_glyphs_are_dropped() {
2399        let mut cells = vec![
2400            cell("\"", 72.7, 21.5, 86.4, 31.5),
2401            cell("==", 59.4, 21.5, 99.6, 31.5),
2402            cell("Herr", 65.2, 151.3, 81.7, 161.3),
2403        ];
2404        super::drop_overpainted_cells(&mut cells);
2405        assert_eq!(cells.len(), 1, "cells: {cells:?}");
2406        assert_eq!(cells[0].text, "Herr");
2407    }
2408
2409    /// Adjacent words on a line touch but never contain each other — prose is
2410    /// untouched, and so is a same-text near-duplicate (double-drawn faux
2411    /// bold), which is not evidence of artwork.
2412    #[test]
2413    fn prose_and_double_draw_are_kept() {
2414        let mut cells = vec![
2415            cell("Telefon", 354.3, 133.2, 381.5, 143.2),
2416            cell("0676/2000", 387.3, 133.2, 428.7, 143.2),
2417            cell("Bold", 100.0, 50.0, 130.0, 60.0),
2418            cell("Bold", 100.3, 50.0, 130.3, 60.0),
2419        ];
2420        super::drop_overpainted_cells(&mut cells);
2421        assert_eq!(cells.len(), 4);
2422    }
2423}
2424
2425#[cfg(test)]
2426mod vestigial_layer {
2427    use crate::pdfium_backend::{PdfPage, TextCell};
2428
2429    fn page_with(texts: &[&str]) -> PdfPage {
2430        let cells = texts
2431            .iter()
2432            .enumerate()
2433            .map(|(i, t)| TextCell {
2434                text: t.to_string(),
2435                l: 10.0,
2436                t: 10.0 + 12.0 * i as f32,
2437                r: 90.0,
2438                b: 20.0 + 12.0 * i as f32,
2439            })
2440            .collect();
2441        PdfPage::from_cells(595.0, 842.0, 1.0, cells)
2442    }
2443
2444    /// The reported scanned form: three typed-in field values ("03", "05",
2445    /// "2025") over three image pages. That must read as *no usable layer*,
2446    /// so the browser routes the document to OCR instead of extracting
2447    /// thirteen characters and skipping the letter entirely.
2448    #[test]
2449    fn typed_in_form_fields_are_not_a_text_layer() {
2450        let pages = vec![
2451            page_with(&["03", "05", "2025"]),
2452            page_with(&[]),
2453            page_with(&[]),
2454        ];
2455        assert!(super::text_layer_is_vestigial(&pages));
2456        assert!(super::text_layer_is_vestigial(&[page_with(&[])]));
2457    }
2458
2459    /// A short but genuine digital document — one page, a few real lines —
2460    /// keeps the fast text path.
2461    #[test]
2462    fn sparse_but_real_documents_pass() {
2463        let one_pager = vec![page_with(&[
2464            "Confidential briefing",
2465            "Prepared for the board meeting",
2466            "Do not distribute",
2467        ])];
2468        assert!(!super::text_layer_is_vestigial(&one_pager));
2469    }
2470}