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::rc::Rc;
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>), Rc<Font>>,
34    forms: HashMap<lopdf::ObjectId, Rc<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/// Page size (width, height) in PDF points from the MediaBox.
646fn page_size(doc: &Document, page_id: lopdf::ObjectId) -> (f32, f32) {
647    let mb = doc
648        .get_object(page_id)
649        .ok()
650        .and_then(|o| o.as_dict().ok())
651        .and_then(|d| {
652            // MediaBox may be inherited; lopdf resolves via get_page... fall back to a guess.
653            d.get(b"MediaBox").ok().cloned()
654        })
655        .or_else(|| {
656            doc.get_dictionary(page_id)
657                .ok()
658                .and_then(|d| d.get(b"MediaBox").ok().cloned())
659        });
660    if let Some(Object::Array(a)) = mb {
661        let v: Vec<f32> = a.iter().filter_map(|o| num(o).map(|x| x as f32)).collect();
662        if v.len() == 4 {
663            return ((v[2] - v[0]).abs(), (v[3] - v[1]).abs());
664        }
665    }
666    (612.0, 792.0)
667}
668
669/// Localize where a page's text is lost, for the `text_layer` diagnostic.
670/// Extraction can come up empty at three different points — no content stream
671/// reached the parser, the stream did not decode into operators, or it ran but
672/// produced no glyphs (fonts/encodings) — and from the outside all three look
673/// the same. Report them per page.
674pub fn content_diagnosis(bytes: &[u8]) -> String {
675    let Some(doc) = load_document(bytes) else {
676        return "document does not load".into();
677    };
678    let mut pages: Vec<_> = doc.get_pages().into_iter().collect();
679    pages.sort_by_key(|(n, _)| *n);
680    let mut out = String::new();
681    let mut caches = DocCaches::default();
682    for (n, pid) in pages.into_iter().take(4) {
683        let content_bytes = doc.get_page_content(pid);
684        let ops = lopdf::content::Content::decode(&content_bytes)
685            .map(|c| c.operations.len())
686            .ok();
687        let res = page_res(&doc, pid);
688        let fonts = res.map(|r| fonts_from_res(&doc, r, &mut caches).len());
689        let glyphs = page_glyphs_cached(&doc, pid, &mut caches).len();
690        out.push_str(&format!(
691            "\n   page {n}: content {} B, ops {}, resources {}, fonts {}, glyphs {}",
692            content_bytes.len(),
693            ops.map_or("UNDECODABLE".to_string(), |n| n.to_string()),
694            if res.is_some() { "ok" } else { "MISSING" },
695            fonts.map_or("-".to_string(), |n| n.to_string()),
696            glyphs,
697        ));
698    }
699    out
700}
701
702/// Is this "text layer" a vestige rather than the document's text?
703///
704/// Scanned forms often carry a handful of typed-in strings — a date filled
705/// into three form fields, say — on top of pages that are otherwise images.
706/// Treating that as a real text layer is the worst of both worlds: the text
707/// path proudly extracts thirteen characters, and no OCR ever runs on the
708/// letter the pages actually show. The reported form did exactly this (3
709/// lines, 13 chars, 3 pages).
710///
711/// The rule is deliberately tight so genuinely sparse *digital* documents are
712/// not misrouted into OCR: only a document averaging at most one line per page
713/// **and** totalling fewer than 32 characters is called vestigial.
714pub fn text_layer_is_vestigial(pages: &[crate::pdfium_backend::PdfPage]) -> bool {
715    let lines: usize = pages.iter().map(|p| p.cells.len()).sum();
716    if lines == 0 {
717        return true;
718    }
719    let chars: usize = pages
720        .iter()
721        .flat_map(|p| &p.cells)
722        .map(|c| c.text.chars().count())
723        .sum();
724    lines <= pages.len() && chars < 32
725}
726
727/// Why the cross-reference repair did or did not fire, for the `text_layer`
728/// diagnostic. A PDF that will not load is indistinguishable from a scan in
729/// production (both convert to nothing), so the reason has to be askable.
730pub fn xref_repair_status(bytes: &[u8]) -> String {
731    if Document::load_mem(bytes).is_ok() {
732        return "loads unaided; no repair needed".into();
733    }
734    match pad_short_xref_entries(bytes) {
735        Ok(fixed) => match Document::load_mem(&fixed) {
736            Ok(_) => "repaired: cross-reference entries padded to 20 bytes".into(),
737            Err(e) => format!("padded the entries, but it still will not load: {e}"),
738        },
739        Err(why) => format!("repair declined — {why}"),
740    }
741}
742
743/// Load a PDF, repairing the one malformation that otherwise costs us the whole
744/// document: **19-byte cross-reference entries**.
745///
746/// The spec fixes an xref entry at 20 bytes — `nnnnnnnnnn ggggg n` plus a
747/// *two*-byte EOL. Some generators (an Austrian telecom's invoices, for one)
748/// emit a bare LF instead, making each entry 19 bytes. lopdf rejects the file
749/// outright (`invalid file trailer`) where pdfium reads it happily, so a
750/// perfectly good text layer looked to the browser exactly like a scan and cost
751/// ten seconds of OCR.
752///
753/// Padding is only attempted when it cannot move anything the xref points at:
754/// a single `xref` section that begins after the last object. The repair then
755/// has to prove itself — the padded bytes are used only if they load — so a
756/// mis-repair degrades to today's behaviour rather than to silent garbage.
757fn load_document(bytes: &[u8]) -> Option<Document> {
758    // Try progressively more repair, and accept a candidate only once the pages
759    // actually carry content — a document whose streams were dropped still
760    // "loads", so loading alone is not evidence the repair helped. A
761    // well-formed file returns on the first attempt and pays for nothing.
762    let mut fallback = None;
763    if let Some(doc) = best_effort_load(bytes, &mut fallback) {
764        return Some(doc);
765    }
766    let xref_fixed = pad_short_xref_entries(bytes).ok();
767    if let Some(fixed) = &xref_fixed {
768        if let Some(doc) = best_effort_load(fixed, &mut fallback) {
769            return Some(doc);
770        }
771    }
772    // Both defects can coexist, and the second only becomes visible once the
773    // first is repaired, so build on whatever the previous step produced.
774    let lengths_fixed = fix_stream_lengths(xref_fixed.as_deref().unwrap_or(bytes));
775    if let Some(doc) = best_effort_load(&lengths_fixed, &mut fallback) {
776        return Some(doc);
777    }
778    fallback
779}
780
781/// Load `data`, returning it only when its pages carry content; a document that
782/// merely parses is remembered as the fallback for when nothing does better.
783fn best_effort_load(data: &[u8], fallback: &mut Option<Document>) -> Option<Document> {
784    match Document::load_mem(data) {
785        Ok(doc) if has_page_content(&doc) => Some(doc),
786        Ok(doc) => {
787            fallback.get_or_insert(doc);
788            None
789        }
790        Err(_) => None,
791    }
792}
793
794/// Does any page actually hand us a content stream? A document whose streams
795/// were dropped still parses — it simply has nothing to read — so this is what
796/// tells a successful repair from a pointless one.
797fn has_page_content(doc: &Document) -> bool {
798    doc.get_pages()
799        .into_values()
800        .take(4)
801        .any(|pid| !doc.get_page_content(pid).is_empty())
802}
803
804/// Correct `/Length` values that disagree with where `endstream` actually is.
805///
806/// The same generator that writes short xref entries also overstates its
807/// content-stream lengths by a byte or two. lopdf trusts `/Length`, reads past
808/// the data, fails to find `endstream` there and drops the stream — the object
809/// comes back as a bare dictionary, so the page has no content at all and the
810/// document looks like a scan. pdfium instead trusts `endstream`, which is what
811/// this does.
812///
813/// The rewrite is length-preserving: the corrected number is written over the
814/// old digits and padded with spaces, so every byte offset in the file — and
815/// therefore the whole cross-reference table — stays valid.
816fn fix_stream_lengths(bytes: &[u8]) -> Vec<u8> {
817    let mut out = bytes.to_vec();
818    let mut i = 0;
819    while let Some(rel) = find(&out[i..], b"stream") {
820        let kw = i + rel;
821        i = kw + 6;
822        // Skip `endstream` (the keyword we are measuring *to*).
823        if kw >= 3 && &out[kw - 3..kw] == b"end" {
824            continue;
825        }
826        // The stream data starts after the EOL that follows the keyword.
827        let mut data = kw + 6;
828        if out.get(data..data + 2) == Some(b"\r\n".as_slice()) {
829            data += 2;
830        } else if matches!(out.get(data), Some(b'\n' | b'\r')) {
831            data += 1;
832        }
833        let Some(end) = find(&out[data..], b"endstream").map(|r| data + r) else {
834            continue;
835        };
836        // `/Length <digits>` in the dictionary just before the keyword.
837        let dict_start = out[..kw].iter().rposition(|&c| c == b'<').unwrap_or(0);
838        let Some(lrel) = find(&out[dict_start..kw], b"/Length") else {
839            continue;
840        };
841        let mut d = dict_start + lrel + 7;
842        while matches!(out.get(d), Some(b' ')) {
843            d += 1;
844        }
845        let digits = out[d..].iter().take_while(|c| c.is_ascii_digit()).count();
846        if digits == 0 {
847            continue;
848        }
849        let declared: usize = match std::str::from_utf8(&out[d..d + digits])
850            .ok()
851            .and_then(|s| s.parse().ok())
852        {
853            Some(v) => v,
854            None => continue,
855        };
856        let actual = end - data;
857        // Only shrink, and only when the new value fits the space the old one
858        // occupied — growing the number would move every following byte.
859        let replacement = actual.to_string();
860        if actual == declared || replacement.len() > digits {
861            continue;
862        }
863        out[d..d + digits].fill(b' ');
864        out[d..d + replacement.len()].copy_from_slice(replacement.as_bytes());
865    }
866    out
867}
868
869fn find(haystack: &[u8], needle: &[u8]) -> Option<usize> {
870    haystack.windows(needle.len()).position(|w| w == needle)
871}
872
873/// Rewrite a classic cross-reference table's entries to the spec's 20 bytes,
874/// or `None` when the file's shape makes that unsafe (see [`load_document`]).
875fn pad_short_xref_entries(bytes: &[u8]) -> Result<Vec<u8>, &'static str> {
876    // Exactly one xref section, and it must start after every object, so that
877    // growing it shifts nothing the table's offsets refer to.
878    let is_boundary = |i: usize| i == 0 || matches!(bytes[i - 1], b'\n' | b'\r');
879    let mut starts = (0..bytes.len().saturating_sub(4))
880        .filter(|&i| &bytes[i..i + 4] == b"xref" && is_boundary(i));
881    let xref_at = starts
882        .next()
883        .ok_or("no classic `xref` section (an xref stream?)")?;
884    if starts.next().is_some() {
885        return Err("more than one xref section (incremental update)");
886    }
887    let last_obj = bytes
888        .windows(3)
889        .rposition(|w| w == b"obj")
890        .ok_or("no objects found")?;
891    if last_obj > xref_at {
892        return Err("an object follows the xref — padding would move it");
893    }
894
895    let mut out = bytes[..xref_at].to_vec();
896    out.extend_from_slice(b"xref\n");
897    let mut i = xref_at + 4;
898    let skip_ws = |i: &mut usize| {
899        while matches!(bytes.get(*i), Some(b'\r' | b'\n' | b' ')) {
900            *i += 1;
901        }
902    };
903    loop {
904        skip_ws(&mut i);
905        // Either the next subsection header ("first count") or the trailer.
906        if bytes[i..].starts_with(b"trailer") {
907            out.extend_from_slice(&bytes[i..]);
908            return Ok(out);
909        }
910        let header_end = i + bytes[i..]
911            .iter()
912            .position(|c| matches!(c, b'\n' | b'\r'))
913            .ok_or("subsection header runs off the end")?;
914        let header = std::str::from_utf8(&bytes[i..header_end])
915            .map_err(|_| "subsection header is not text")?
916            .trim();
917        let mut parts = header.split_whitespace();
918        let count: usize = parts
919            .nth(1)
920            .and_then(|c| c.parse().ok())
921            .ok_or("unparseable subsection header")?;
922        if parts.next().is_some() || count == 0 {
923            return Err("unexpected subsection header shape");
924        }
925        out.extend_from_slice(header.as_bytes());
926        out.push(b'\n');
927        i = header_end;
928        for _ in 0..count {
929            skip_ws(&mut i);
930            // `nnnnnnnnnn ggggg n` — the 18 bytes before whatever EOL follows.
931            let entry = bytes.get(i..i + 18).ok_or("xref entry runs off the end")?;
932            let well_formed = entry[..10].iter().all(u8::is_ascii_digit)
933                && entry[10] == b' '
934                && entry[11..16].iter().all(u8::is_ascii_digit)
935                && entry[16] == b' '
936                && matches!(entry[17], b'n' | b'f');
937            if !well_formed {
938                return Err("xref entry is not `nnnnnnnnnn ggggg n`");
939            }
940            out.extend_from_slice(entry);
941            out.extend_from_slice(b" \n"); // the spec's 2-byte EOL -> 20 bytes
942            i += 18;
943        }
944    }
945}
946
947/// Debug: raw glyph stream `(ch, ll, lr, lb, lt)` (native coords) for page
948/// `index`, before the sanitizer. For comparing char cells to docling-parse.
949pub fn debug_glyphs(bytes: &[u8], index: usize) -> Vec<(char, f32, f32, f32, f32)> {
950    let Some(doc) = load_document(bytes) else {
951        return Vec::new();
952    };
953    let mut pages: Vec<_> = doc.get_pages().into_iter().collect();
954    pages.sort_by_key(|(n, _)| *n);
955    let Some((_, pid)) = pages.get(index) else {
956        return Vec::new();
957    };
958    page_glyphs(&doc, *pid)
959        .into_iter()
960        .map(|g| (g.ch, g.ll, g.lr, g.lb, g.lt))
961        .collect()
962}
963
964/// Public entry: per-page (width, height, line cells) for a PDF, via the Rust
965/// text parser + the docling-parse line sanitizer. Used by the pipeline and the
966/// `textparse_dump` example.
967pub fn pdf_textlines(bytes: &[u8]) -> Vec<(f32, f32, Vec<crate::pdfium_backend::TextCell>)> {
968    let Some(doc) = load_document(bytes) else {
969        return Vec::new();
970    };
971    let mut caches = DocCaches::default();
972    let mut pages: Vec<_> = doc.get_pages().into_iter().collect();
973    pages.sort_by_key(|(n, _)| *n);
974    pages
975        .into_iter()
976        .map(|(_, pid)| {
977            let (w, h) = page_size(&doc, pid);
978            let glyphs = page_glyphs_cached(&doc, pid, &mut caches);
979            let cells = crate::dp_lines::line_cells(&glyphs, h, true);
980            (w, h, cells)
981        })
982        .collect()
983}
984
985/// Debug/diagnostic entry: per-page (width, height, word cells) for a PDF, via
986/// the Rust parser glyphs run through the docling-parse word grouping. Used to
987/// compare parser word cells against docling-parse's `word_cells` oracle (roadmap
988/// item 6).
989pub fn pdf_words(bytes: &[u8]) -> Vec<(f32, f32, Vec<crate::pdfium_backend::TextCell>)> {
990    let Some(doc) = load_document(bytes) else {
991        return Vec::new();
992    };
993    let mut caches = DocCaches::default();
994    let mut pages: Vec<_> = doc.get_pages().into_iter().collect();
995    pages.sort_by_key(|(n, _)| *n);
996    pages
997        .into_iter()
998        .map(|(_, pid)| {
999            let (w, h) = page_size(&doc, pid);
1000            let glyphs = page_glyphs_cached(&doc, pid, &mut caches);
1001            let cells = crate::dp_lines::word_cells(&glyphs, h, true);
1002            (w, h, cells)
1003        })
1004        .collect()
1005}
1006
1007/// One page's text cells from the pure-Rust parser: prose line cells, per-word
1008/// cells, and code line cells — all from a single glyph parse. Replaces the
1009/// pdfium text path (roadmap item 6) when the parser drop is enabled.
1010#[derive(Default)]
1011pub struct PageParserCells {
1012    pub prose: Vec<crate::pdfium_backend::TextCell>,
1013    pub words: Vec<crate::pdfium_backend::TextCell>,
1014    pub code: Vec<crate::pdfium_backend::TextCell>,
1015}
1016
1017/// Full parser text layer: prose + word + code cells per page, glyphs parsed once.
1018/// `prose`/`words` come from the docling-parse contraction ([`crate::dp_lines`]);
1019/// `code` splits only at the parser's own space glyphs (monospace keeps its
1020/// source spacing). Used by the pipeline to retire pdfium's text path.
1021pub fn pdf_all_cells(bytes: &[u8]) -> Vec<PageParserCells> {
1022    let Some(doc) = load_document(bytes) else {
1023        return Vec::new();
1024    };
1025    let mut caches = DocCaches::default();
1026    let mut pages: Vec<_> = doc.get_pages().into_iter().collect();
1027    pages.sort_by_key(|(n, _)| *n);
1028    pages
1029        .into_iter()
1030        .map(|(_, pid)| {
1031            let (_w, h) = page_size(&doc, pid);
1032            let glyphs = page_glyphs_cached(&doc, pid, &mut caches);
1033            let (prose, words) = crate::dp_lines::line_and_word_cells(&glyphs, h, true);
1034            PageParserCells {
1035                prose,
1036                words,
1037                code: crate::pdfium_backend::code_cells_from_glyphs(&glyphs, h),
1038            }
1039        })
1040        .collect()
1041}
1042
1043/// Whole pages for the text-layer-only conversion ([`crate::convert_text_layer`]):
1044/// the parser's prose/word/code cells plus page geometry, assembled into
1045/// [`PdfPage`]s with no rendered image and no link annotations. Everything here
1046/// is pure Rust (lopdf), so it compiles without the `ml` feature — including on
1047/// wasm32. A page the parser can't read (no text layer) comes back with empty
1048/// cells; there is no pdfium fallback on this path.
1049pub fn pdf_text_pages(bytes: &[u8]) -> Vec<crate::pdfium_backend::PdfPage> {
1050    let Some(doc) = load_document(bytes) else {
1051        return Vec::new();
1052    };
1053    let mut caches = DocCaches::default();
1054    let mut pages: Vec<_> = doc.get_pages().into_iter().collect();
1055    pages.sort_by_key(|(n, _)| *n);
1056    pages
1057        .into_iter()
1058        .map(|(_, pid)| {
1059            let (w, h) = page_size(&doc, pid);
1060            let glyphs = page_glyphs_cached(&doc, pid, &mut caches);
1061            let (mut prose, mut words) = crate::dp_lines::line_and_word_cells(&glyphs, h, true);
1062            drop_overpainted_cells(&mut prose);
1063            drop_overpainted_cells(&mut words);
1064            crate::pdfium_backend::PdfPage {
1065                #[cfg(feature = "ocr-prep")]
1066                image_layout: None,
1067                width: w,
1068                height: h,
1069                // Cells are native PDF points; there is no rendered bitmap.
1070                scale: 1.0,
1071                cells: prose,
1072                code_cells: crate::pdfium_backend::code_cells_from_glyphs(&glyphs, h),
1073                word_cells: words,
1074                #[cfg(feature = "ocr-prep")]
1075                image: image::RgbImage::new(1, 1),
1076                links: Vec::new(),
1077                rotation: 0,
1078            }
1079        })
1080        .collect()
1081}
1082
1083/// Drop line cells that are *painted over each other* — glyphs used as artwork.
1084///
1085/// Some generators draw their logo with a symbol font: on the reporting
1086/// invoice, a `TeleLogo` Type1 paints the T-Mobile mark by stacking the glyphs
1087/// encoded as `"` and `==` on top of one another, and the flat text-layer
1088/// output opened with that garbage. Nothing in the font metadata gives it away
1089/// (the *text* fonts in the same file are also flagged symbolic, and the logo
1090/// font names its glyphs `quotedbl` &c.), but the geometry does: two cells with
1091/// different text where one lies inside the other on the same line is
1092/// physically impossible for prose — ink from two words never occupies the
1093/// same box. Both cells of such a pair are paint, not text.
1094///
1095/// Containment (not mere overlap) keeps this narrow: adjacent words touch but
1096/// never contain each other, and a same-text near-duplicate (double-draw faux
1097/// bold) is left alone for the sanitizer's usual handling. Applied on the
1098/// flat/browser path only — the ML pipeline's text layer is byte-pinned by the
1099/// PDF corpus, and there the layout model already sinks logo marks into
1100/// `picture` regions.
1101fn drop_overpainted_cells(cells: &mut Vec<crate::pdfium_backend::TextCell>) {
1102    let mut paint = vec![false; cells.len()];
1103    for i in 0..cells.len() {
1104        for j in 0..cells.len() {
1105            if i == j || cells[i].text == cells[j].text {
1106                continue;
1107            }
1108            let (a, b) = (&cells[i], &cells[j]);
1109            // Same line band: the vertical overlap covers most of the shorter.
1110            let vo = (a.b.min(b.b) - a.t.max(b.t)).max(0.0);
1111            if vo < 0.6 * (a.b - a.t).min(b.b - b.t) {
1112                continue;
1113            }
1114            // `a` horizontally inside `b` (with a small tolerance).
1115            let ho = (a.r.min(b.r) - a.l.max(b.l)).max(0.0);
1116            if ho >= 0.8 * (a.r - a.l) && (a.r - a.l) <= (b.r - b.l) {
1117                paint[i] = true;
1118                paint[j] = true;
1119            }
1120        }
1121    }
1122    let mut keep = paint.iter().map(|p| !p);
1123    cells.retain(|_| keep.next().unwrap());
1124}
1125
1126/// The text-state scalars inherited by a Form XObject when it is invoked via
1127/// `Do` (the PDF graphics state includes the text parameters, but not the text
1128/// matrices, which a form re-establishes inside its own `BT`/`ET`).
1129#[derive(Clone, Copy)]
1130struct TextState {
1131    tc: f64,
1132    tw: f64,
1133    th: f64,
1134    tl: f64,
1135    trise: f64,
1136    fsize: f64,
1137}
1138
1139impl TextState {
1140    const INIT: TextState = TextState {
1141        tc: 0.0,
1142        tw: 0.0,
1143        th: 1.0,
1144        tl: 0.0,
1145        trise: 0.0,
1146        fsize: 0.0,
1147    };
1148}
1149
1150/// The effective `/Resources` dictionary for a page (inline or via reference,
1151/// falling back to an inherited one from a `/Parent`).
1152fn page_res(doc: &Document, page_id: lopdf::ObjectId) -> Option<&Dictionary> {
1153    let (inline, ids) = doc.get_page_resources(page_id).ok()?;
1154    if let Some(d) = inline {
1155        return Some(d);
1156    }
1157    ids.into_iter().find_map(|id| doc.get_dictionary(id).ok())
1158}
1159
1160/// Build the code→[`Font`] map for a resources dictionary's `/Font` sub-dict,
1161/// reusing the per-document cache for fonts referenced indirectly (the common
1162/// case — the same font objects recur on every page).
1163fn fonts_from_res(
1164    doc: &Document,
1165    res: &Dictionary,
1166    caches: &mut DocCaches,
1167) -> HashMap<Vec<u8>, Rc<Font>> {
1168    let mut map = HashMap::new();
1169    let font_dict = res
1170        .get(b"Font")
1171        .ok()
1172        .and_then(|o| deref(doc, o))
1173        .and_then(|o| o.as_dict().ok());
1174    if let Some(fd) = font_dict {
1175        for (name, value) in fd.iter() {
1176            let font = match value {
1177                Object::Reference(id) => {
1178                    let key = (*id, name.clone());
1179                    if let Some(f) = caches.fonts.get(&key) {
1180                        Rc::clone(f)
1181                    } else if let Some(fdict) = deref(doc, value).and_then(|o| o.as_dict().ok()) {
1182                        let f = Rc::new(parse_font(doc, name, fdict));
1183                        caches.fonts.insert(key, Rc::clone(&f));
1184                        f
1185                    } else {
1186                        continue;
1187                    }
1188                }
1189                _ => {
1190                    if let Some(fdict) = deref(doc, value).and_then(|o| o.as_dict().ok()) {
1191                        Rc::new(parse_font(doc, name, fdict))
1192                    } else {
1193                        continue;
1194                    }
1195                }
1196            };
1197            map.insert(name.clone(), font);
1198        }
1199    }
1200    map
1201}
1202
1203/// Extract every glyph on a page as a native-coordinate [`Glyph`].
1204pub(crate) fn page_glyphs(doc: &Document, page_id: lopdf::ObjectId) -> Vec<Glyph> {
1205    page_glyphs_cached(doc, page_id, &mut DocCaches::default())
1206}
1207
1208/// [`page_glyphs`] with an explicit per-document cache, so a multi-page walk
1209/// parses each font / decodes each form once instead of once per page.
1210fn page_glyphs_cached(
1211    doc: &Document,
1212    page_id: lopdf::ObjectId,
1213    caches: &mut DocCaches,
1214) -> Vec<Glyph> {
1215    let mut out = Vec::new();
1216    // lopdf 0.44: get_page_content returns the assembled content-stream bytes
1217    // directly (an empty Vec when the page has none).
1218    let content_bytes = doc.get_page_content(page_id);
1219    let Ok(content) = lopdf::content::Content::decode(&content_bytes) else {
1220        return out;
1221    };
1222    if let Some(res) = page_res(doc, page_id) {
1223        run_content(
1224            doc,
1225            res,
1226            &content,
1227            Mat::ID,
1228            TextState::INIT,
1229            0,
1230            caches,
1231            &mut out,
1232        );
1233    }
1234    out
1235}
1236
1237/// Run a content stream's operators, emitting glyphs into `out`. Recurses into
1238/// Form XObjects on `Do` (bulk body text in heavy PDFs lives inside a form, not
1239/// the page content stream). `res` is the resources dict in scope (the page's,
1240/// or the form's own); `base_ctm` is the CTM at the point of invocation.
1241#[allow(clippy::too_many_arguments)]
1242fn run_content(
1243    doc: &Document,
1244    res: &Dictionary,
1245    content: &lopdf::content::Content,
1246    base_ctm: Mat,
1247    init: TextState,
1248    depth: u32,
1249    caches: &mut DocCaches,
1250    out: &mut Vec<Glyph>,
1251) {
1252    let fonts = fonts_from_res(doc, res, caches);
1253    let xobjects = res
1254        .get(b"XObject")
1255        .ok()
1256        .and_then(|o| deref(doc, o))
1257        .and_then(|o| o.as_dict().ok());
1258
1259    // Graphics + text state. `q`/`Q` save and restore the whole graphics state,
1260    // which includes the text parameters (Tc, Tw, Tz, TL, Tfs, Trise, font) —
1261    // *not* the text matrix (that is reset by BT). Saving only the CTM let a Tc
1262    // set inside a `q…Q` block leak out and drift every later glyph.
1263    #[allow(clippy::type_complexity)]
1264    let mut gstate_stack: Vec<(Mat, f64, f64, f64, f64, f64, f64, Option<&Rc<Font>>)> = Vec::new();
1265    let mut ctm = base_ctm;
1266    let mut tm = Mat::ID;
1267    let mut tlm = Mat::ID;
1268    let mut font: Option<&Rc<Font>> = None;
1269    let mut fsize = init.fsize;
1270    let mut tc = init.tc; // char spacing
1271    let mut tw = init.tw; // word spacing
1272    let mut th = init.th; // horizontal scale (Tz/100)
1273    let mut tl = init.tl; // leading
1274    let mut trise = init.trise;
1275
1276    let op_f = |operands: &[Object], i: usize| operands.get(i).and_then(num).unwrap_or(0.0);
1277
1278    for op in &content.operations {
1279        let operands = &op.operands;
1280        match op.operator.as_str() {
1281            "q" => gstate_stack.push((ctm, tc, tw, th, tl, trise, fsize, font)),
1282            "Q" => {
1283                if let Some((c, a, b, h, l, r, fs, f)) = gstate_stack.pop() {
1284                    ctm = c;
1285                    tc = a;
1286                    tw = b;
1287                    th = h;
1288                    tl = l;
1289                    trise = r;
1290                    fsize = fs;
1291                    font = f;
1292                }
1293            }
1294            "cm" => {
1295                let m = Mat {
1296                    a: op_f(operands, 0),
1297                    b: op_f(operands, 1),
1298                    c: op_f(operands, 2),
1299                    d: op_f(operands, 3),
1300                    e: op_f(operands, 4),
1301                    f: op_f(operands, 5),
1302                };
1303                ctm = m.then(ctm);
1304            }
1305            "BT" => {
1306                tm = Mat::ID;
1307                tlm = Mat::ID;
1308            }
1309            "ET" => {}
1310            "Tf" => {
1311                if let Some(Object::Name(n)) = operands.first() {
1312                    font = fonts.get(n.as_slice());
1313                }
1314                fsize = op_f(operands, 1);
1315            }
1316            "Td" => {
1317                tlm = Mat {
1318                    a: 1.0,
1319                    b: 0.0,
1320                    c: 0.0,
1321                    d: 1.0,
1322                    e: op_f(operands, 0),
1323                    f: op_f(operands, 1),
1324                }
1325                .then(tlm);
1326                tm = tlm;
1327            }
1328            "TD" => {
1329                tl = -op_f(operands, 1);
1330                tlm = Mat {
1331                    a: 1.0,
1332                    b: 0.0,
1333                    c: 0.0,
1334                    d: 1.0,
1335                    e: op_f(operands, 0),
1336                    f: op_f(operands, 1),
1337                }
1338                .then(tlm);
1339                tm = tlm;
1340            }
1341            "Tm" => {
1342                tlm = Mat {
1343                    a: op_f(operands, 0),
1344                    b: op_f(operands, 1),
1345                    c: op_f(operands, 2),
1346                    d: op_f(operands, 3),
1347                    e: op_f(operands, 4),
1348                    f: op_f(operands, 5),
1349                };
1350                tm = tlm;
1351            }
1352            "T*" => {
1353                tlm = Mat {
1354                    a: 1.0,
1355                    b: 0.0,
1356                    c: 0.0,
1357                    d: 1.0,
1358                    e: 0.0,
1359                    f: -tl,
1360                }
1361                .then(tlm);
1362                tm = tlm;
1363            }
1364            "Tc" => tc = op_f(operands, 0),
1365            "Tw" => tw = op_f(operands, 0),
1366            "Tz" => th = op_f(operands, 0) / 100.0,
1367            "TL" => tl = op_f(operands, 0),
1368            "Ts" => trise = op_f(operands, 0),
1369            "Tj" | "'" | "\"" => {
1370                if op.operator == "'" || op.operator == "\"" {
1371                    // move to next line first
1372                    tlm = Mat {
1373                        a: 1.0,
1374                        b: 0.0,
1375                        c: 0.0,
1376                        d: 1.0,
1377                        e: 0.0,
1378                        f: -tl,
1379                    }
1380                    .then(tlm);
1381                    tm = tlm;
1382                }
1383                if op.operator == "\"" {
1384                    // `aw ac string "` sets word- and char-spacing before
1385                    // showing the string (PDF 32000-1 §9.4.3), persisting after.
1386                    tw = op_f(operands, 0);
1387                    tc = op_f(operands, 1);
1388                }
1389                if let (Some(f), Some(Object::String(s, _))) = (font, operands.last()) {
1390                    show_text(f, s, fsize, tc, tw, th, trise, &mut tm, ctm, out);
1391                }
1392            }
1393            "TJ" => {
1394                if let (Some(f), Some(Object::Array(arr))) = (font, operands.first()) {
1395                    for el in arr {
1396                        match el {
1397                            Object::String(s, _) => {
1398                                show_text(f, s, fsize, tc, tw, th, trise, &mut tm, ctm, out)
1399                            }
1400                            other => {
1401                                if let Some(adj) = num(other) {
1402                                    // negative number moves text right (PDF: subtract)
1403                                    let tx = -adj / 1000.0 * fsize * th;
1404                                    tm = Mat {
1405                                        a: 1.0,
1406                                        b: 0.0,
1407                                        c: 0.0,
1408                                        d: 1.0,
1409                                        e: tx,
1410                                        f: 0.0,
1411                                    }
1412                                    .then(tm);
1413                                }
1414                            }
1415                        }
1416                    }
1417                }
1418            }
1419            "Do" => {
1420                // Invoke a Form XObject: bulk body text in many PDFs lives inside
1421                // a form, reached only here. Image XObjects are skipped (no text).
1422                if depth >= 8 {
1423                    continue;
1424                }
1425                let Some(Object::Name(n)) = operands.first() else {
1426                    continue;
1427                };
1428                let obj = xobjects.and_then(|d| d.get(n.as_slice()).ok());
1429                let form_id = match obj {
1430                    Some(Object::Reference(id)) => Some(*id),
1431                    _ => None,
1432                };
1433                let stream = obj
1434                    .and_then(|o| deref(doc, o))
1435                    .and_then(|o| o.as_stream().ok());
1436                let Some(stream) = stream else { continue };
1437                let is_form = stream
1438                    .dict
1439                    .get(b"Subtype")
1440                    .ok()
1441                    .and_then(|o| o.as_name().ok())
1442                    == Some(b"Form".as_slice());
1443                if !is_form {
1444                    continue;
1445                }
1446                // Decode the form's content once per document (headers/footers
1447                // and bulk body text invoke the same form on every page).
1448                let cached = form_id.and_then(|id| caches.forms.get(&id).cloned());
1449                let form_content = match cached {
1450                    Some(c) => c,
1451                    None => {
1452                        let Ok(data) = stream.decompressed_content() else {
1453                            continue;
1454                        };
1455                        let Ok(c) = lopdf::content::Content::decode(&data) else {
1456                            continue;
1457                        };
1458                        let c = Rc::new(c);
1459                        if let Some(id) = form_id {
1460                            caches.forms.insert(id, Rc::clone(&c));
1461                        }
1462                        c
1463                    }
1464                };
1465                // The form's /Matrix maps form space into the CTM at invocation.
1466                let form_mat = match stream.dict.get(b"Matrix").ok() {
1467                    Some(Object::Array(a)) if a.len() == 6 => {
1468                        let v: Vec<f64> = a.iter().filter_map(num).collect();
1469                        if v.len() == 6 {
1470                            Mat {
1471                                a: v[0],
1472                                b: v[1],
1473                                c: v[2],
1474                                d: v[3],
1475                                e: v[4],
1476                                f: v[5],
1477                            }
1478                        } else {
1479                            Mat::ID
1480                        }
1481                    }
1482                    _ => Mat::ID,
1483                };
1484                // The form's own /Resources, falling back to the inherited ones.
1485                let form_res = stream
1486                    .dict
1487                    .get(b"Resources")
1488                    .ok()
1489                    .and_then(|o| deref(doc, o))
1490                    .and_then(|o| o.as_dict().ok())
1491                    .unwrap_or(res);
1492                let state = TextState {
1493                    tc,
1494                    tw,
1495                    th,
1496                    tl,
1497                    trise,
1498                    fsize,
1499                };
1500                run_content(
1501                    doc,
1502                    form_res,
1503                    &form_content,
1504                    form_mat.then(ctm),
1505                    state,
1506                    depth + 1,
1507                    caches,
1508                    out,
1509                );
1510            }
1511            _ => {}
1512        }
1513    }
1514}
1515
1516#[allow(clippy::too_many_arguments)]
1517fn show_text(
1518    font: &Font,
1519    bytes: &[u8],
1520    fsize: f64,
1521    tc: f64,
1522    tw: f64,
1523    th: f64,
1524    trise: f64,
1525    tm: &mut Mat,
1526    ctm: Mat,
1527    out: &mut Vec<Glyph>,
1528) {
1529    for code in codes(font, bytes) {
1530        let (text, w) = font.decode_code(code);
1531        let w0 = w / 1000.0; // advance in text-space (em) units
1532                             // The glyph→user transform: scale glyph space by font size, then Tm, CTM.
1533        let scale = Mat {
1534            a: fsize * th,
1535            b: 0.0,
1536            c: 0.0,
1537            d: fsize,
1538            e: 0.0,
1539            f: trise,
1540        };
1541        let trm = scale.then(*tm).then(ctm);
1542        // Box in glyph space (1000-unit em): x 0..w, y descent..ascent.
1543        let (x0, y0) = trm.apply(0.0, font.descent / 1000.0);
1544        let (x1, _y1) = trm.apply(w0, font.descent / 1000.0);
1545        let (_x2, y2) = trm.apply(0.0, font.ascent / 1000.0);
1546        let (left, right) = (x0.min(x1), x0.max(x1));
1547        let (bot, top) = (y0.min(y2), y0.max(y2));
1548        if let Some(s) = text {
1549            // A run may map one code to multiple chars (ligature/fraction); share box.
1550            for ch in s.chars() {
1551                if ch != '\u{0}' {
1552                    out.push(Glyph {
1553                        ch,
1554                        l: left as f32,
1555                        b: bot as f32,
1556                        r: right as f32,
1557                        t: top as f32,
1558                        ll: left as f32,
1559                        lb: bot as f32,
1560                        lr: right as f32,
1561                        lt: top as f32,
1562                        font: font.hash,
1563                    });
1564                }
1565            }
1566        }
1567        // Advance the text matrix. Word spacing applies to single-byte code 32.
1568        let is_space = !font.two_byte && code == 32;
1569        let tx = (w0 * fsize + tc + if is_space { tw } else { 0.0 }) * th;
1570        *tm = Mat {
1571            a: 1.0,
1572            b: 0.0,
1573            c: 0.0,
1574            d: 1.0,
1575            e: tx,
1576            f: 0.0,
1577        }
1578        .then(*tm);
1579    }
1580}
1581
1582/// Build a simple font's code→char table from its `/Encoding`: the base
1583/// encoding (WinAnsi / MacRoman) plus any `/Differences` overrides (glyph names
1584/// resolved through a small Adobe-glyph-name subset).
1585fn simple_encoding_table(doc: &Document, fdict: &Dictionary) -> HashMap<u8, char> {
1586    let enc = fdict.get(b"Encoding").ok().and_then(|o| deref(doc, o));
1587    let base_name = match enc {
1588        Some(Object::Name(n)) => n.clone(),
1589        Some(Object::Dictionary(d)) => d
1590            .get(b"BaseEncoding")
1591            .ok()
1592            .and_then(|o| o.as_name().ok())
1593            .map(|n| n.to_vec())
1594            .unwrap_or_default(),
1595        _ => Vec::new(),
1596    };
1597    let mut m = if base_name == b"MacRomanEncoding" {
1598        macroman_table()
1599    } else if base_name.is_empty() {
1600        // No PDF /Encoding at all: the font's *built-in* encoding applies. For
1601        // the standard TeX math fonts that is their fixed TeX layout — falling
1602        // back to StandardEncoding read CMSY's braces as `f`/`g`, `→` as `!`,
1603        // `∈` as `2` (2203's `{ahn,…}` author line). The font program (often
1604        // CFF, which this parser does not read) carries the same mapping;
1605        // docling-parse decodes it from there.
1606        tex_math_builtin(fdict).unwrap_or_else(winansi_table)
1607    } else {
1608        winansi_table()
1609    };
1610    // Apply /Differences: `code /glyphname /glyphname ... code ...`.
1611    if let Some(Object::Dictionary(d)) = enc {
1612        if let Some(Object::Array(diffs)) = d.get(b"Differences").ok().and_then(|o| deref(doc, o)) {
1613            let mut code = 0u8;
1614            for el in diffs {
1615                match el {
1616                    Object::Integer(i) => code = *i as u8,
1617                    Object::Name(name) => {
1618                        if let Some(ch) = glyph_name_to_char(name) {
1619                            m.insert(code, ch);
1620                        }
1621                        code = code.wrapping_add(1);
1622                    }
1623                    _ => {}
1624                }
1625            }
1626        }
1627    }
1628    m
1629}
1630
1631/// The fixed built-in encodings of the standard TeX math fonts (TeXbook
1632/// Appendix F), keyed off the base font name: `CMSY*` (symbols; `CMBSY` is its
1633/// bold) and `CMMI*` (math italic). These fonts ship no PDF `/Encoding` and no
1634/// ToUnicode, and their program is usually CFF — without this table the codes
1635/// fell through to StandardEncoding and rendered as the wrong ASCII.
1636fn tex_math_builtin(fdict: &Dictionary) -> Option<HashMap<u8, char>> {
1637    const CMSY: [char; 128] = [
1638        '−', '·', '×', '∗', '÷', '⋄', '±', '∓', '⊕', '⊖', '⊗', '⊘', '⊙', '◯', '∘', '•', '≍', '≡',
1639        '⊆', '⊇', '≤', '≥', '≼', '≽', '∼', '≈', '⊂', '⊃', '≪', '≫', '≺', '≻', '←', '→', '↑', '↓',
1640        '↔', '↗', '↘', '≃', '⇐', '⇒', '⇑', '⇓', '⇔', '↖', '↙', '∝', '′', '∞', '∈', '∋', '△', '▽',
1641        '\u{338}', '↦', '∀', '∃', '¬', '∅', 'ℜ', 'ℑ', '⊤', '⊥', 'ℵ', 'A', 'B', 'C', 'D', 'E', 'F',
1642        'G', 'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X',
1643        'Y', 'Z', '∪', '∩', '⊎', '∧', '∨', '⊢', '⊣', '⌊', '⌋', '⌈', '⌉', '{', '}', '⟨', '⟩', '|',
1644        '∥', '↕', '⇕', '\\', '≀', '√', '∐', '∇', '∫', '⊔', '⊓', '⊑', '⊒', '§', '†', '‡', '¶', '♣',
1645        '♢', '♡', '♠',
1646    ];
1647    const CMMI: [char; 128] = [
1648        'Γ', 'Δ', 'Θ', 'Λ', 'Ξ', 'Π', 'Σ', 'Υ', 'Φ', 'Ψ', 'Ω', 'α', 'β', 'γ', 'δ', 'ε', 'ζ', 'η',
1649        'θ', 'ι', 'κ', 'λ', 'μ', 'ν', 'ξ', 'π', 'ρ', 'σ', 'τ', 'υ', 'φ', 'χ', 'ψ', 'ω', 'ϵ', 'ϑ',
1650        'ϖ', 'ϱ', 'ς', 'ϕ', '↼', '↽', '⇀', '⇁', '↩', '↪', '▷', '◁', '0', '1', '2', '3', '4', '5',
1651        '6', '7', '8', '9', '.', ',', '<', '/', '>', '⋆', '∂', 'A', 'B', 'C', 'D', 'E', 'F', 'G',
1652        'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', 'Y',
1653        'Z', '♭', '♮', '♯', '⌣', '⌢', 'ℓ', 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k',
1654        'l', 'm', 'n', 'o', 'p', 'q', 'r', 's', 't', 'u', 'v', 'w', 'x', 'y', 'z', 'ı', 'ȷ', '℘',
1655        '\u{20d7}', '⁀',
1656    ];
1657    let name = base_font_name(fdict)?;
1658    let up = name.to_ascii_uppercase();
1659    let table: &[char; 128] = if up.starts_with(b"CMSY") || up.starts_with(b"CMBSY") {
1660        &CMSY
1661    } else if up.starts_with(b"CMMI") {
1662        &CMMI
1663    } else {
1664        return None;
1665    };
1666    Some(
1667        table
1668            .iter()
1669            .enumerate()
1670            .map(|(i, &c)| (i as u8, c))
1671            .collect(),
1672    )
1673}
1674
1675/// Resolve an Adobe glyph name to Unicode: `uniXXXX`, single ASCII letters, the
1676/// digit/punctuation names from the Adobe Glyph List, and common typographic
1677/// names. A `.suffix` (`one.taboldstyle`, `a.sc`) is stripped and the base name
1678/// retried — docling renders these as the base character.
1679fn glyph_name_to_char(name: &[u8]) -> Option<char> {
1680    let s = std::str::from_utf8(name).ok()?;
1681    if let Some(hex) = s.strip_prefix("uni") {
1682        if let Ok(cp) = u32::from_str_radix(hex.get(0..4)?, 16) {
1683            return char::from_u32(cp);
1684        }
1685    }
1686    // Single ASCII letter names (`A`, `m`) map to themselves.
1687    if s.len() == 1 {
1688        let b = s.as_bytes()[0];
1689        if b.is_ascii_alphabetic() {
1690            return Some(b as char);
1691        }
1692    }
1693    let resolved = match s {
1694        "space" => ' ',
1695        "exclam" => '!',
1696        "quotedbl" => '"',
1697        "numbersign" => '#',
1698        "dollar" => '$',
1699        "percent" => '%',
1700        "ampersand" => '&',
1701        "quotesingle" => '\'',
1702        "parenleft" => '(',
1703        "parenright" => ')',
1704        "asterisk" => '*',
1705        "plus" => '+',
1706        "comma" => ',',
1707        "hyphen" => '-',
1708        "period" => '.',
1709        "slash" => '/',
1710        "zero" => '0',
1711        "one" => '1',
1712        "two" => '2',
1713        "three" => '3',
1714        "four" => '4',
1715        "five" => '5',
1716        "six" => '6',
1717        "seven" => '7',
1718        "eight" => '8',
1719        "nine" => '9',
1720        "colon" => ':',
1721        "semicolon" => ';',
1722        "less" => '<',
1723        "equal" => '=',
1724        "greater" => '>',
1725        "question" => '?',
1726        "at" => '@',
1727        "bracketleft" => '[',
1728        "backslash" => '\\',
1729        "bracketright" => ']',
1730        "asciicircum" => '^',
1731        "underscore" => '_',
1732        "grave" => '`',
1733        "braceleft" => '{',
1734        "bar" => '|',
1735        "braceright" => '}',
1736        "asciitilde" => '~',
1737        "bullet" => '\u{2022}',
1738        "periodcentered" => '\u{00B7}',
1739        "endash" => '\u{2013}',
1740        "emdash" => '\u{2014}',
1741        "quoteright" => '\u{2019}',
1742        "quoteleft" => '\u{2018}',
1743        "quotedblleft" => '\u{201C}',
1744        "quotedblright" => '\u{201D}',
1745        "quotedblbase" => '\u{201E}',
1746        "quotesinglbase" => '\u{201A}',
1747        // Latin f-ligatures named in `/Differences` (e.g. 2305's body font). These
1748        // map to the presentation-form code points, which `decompose_ligatures`
1749        // then spells back out (`ff`→"ff") — without them the glyph decodes to
1750        // nothing and the sanitizer fills the gap with a space (`di erences`).
1751        "ff" => '\u{FB00}',
1752        "fi" => '\u{FB01}',
1753        "fl" => '\u{FB02}',
1754        "ffi" => '\u{FB03}',
1755        "ffl" => '\u{FB04}',
1756        "ft" => '\u{FB05}',
1757        "st" => '\u{FB06}',
1758        "degree" => '\u{00B0}',
1759        "trademark" => '\u{2122}',
1760        "registered" => '\u{00AE}',
1761        "copyright" => '\u{00A9}',
1762        "ellipsis" => '\u{2026}',
1763        "minus" => '\u{2212}',
1764        "fraction" => '\u{2044}',
1765        "nbspace" => '\u{00A0}',
1766        // Greek + math glyph names (standard Adobe Glyph List). Standard TeX math
1767        // fonts (CMMI/CMSY/…) name their glyphs this way in the embedded font
1768        // program's `/Encoding`; without these a `λ`/`≤` decodes to nothing and is
1769        // dropped from body text (`and λ set to 0.5` → `and set to 0.5`).
1770        "alpha" => '\u{03B1}',
1771        "beta" => '\u{03B2}',
1772        "gamma" => '\u{03B3}',
1773        "delta" => '\u{03B4}',
1774        "epsilon" | "epsilon1" => '\u{03B5}',
1775        "zeta" => '\u{03B6}',
1776        "eta" => '\u{03B7}',
1777        "theta" | "theta1" => '\u{03B8}',
1778        "iota" => '\u{03B9}',
1779        "kappa" => '\u{03BA}',
1780        "lambda" => '\u{03BB}',
1781        "mu" => '\u{03BC}',
1782        "nu" => '\u{03BD}',
1783        "xi" => '\u{03BE}',
1784        "omicron" => '\u{03BF}',
1785        "pi" | "pi1" => '\u{03C0}',
1786        "rho" | "rho1" => '\u{03C1}',
1787        "sigma" => '\u{03C3}',
1788        "sigma1" => '\u{03C2}',
1789        "tau" => '\u{03C4}',
1790        "upsilon" => '\u{03C5}',
1791        "phi" | "phi1" => '\u{03C6}',
1792        "chi" => '\u{03C7}',
1793        "psi" => '\u{03C8}',
1794        "omega" | "omega1" => '\u{03C9}',
1795        "Gamma" => '\u{0393}',
1796        "Delta" => '\u{0394}',
1797        "Theta" => '\u{0398}',
1798        "Lambda" => '\u{039B}',
1799        "Xi" => '\u{039E}',
1800        "Pi" => '\u{03A0}',
1801        "Sigma" => '\u{03A3}',
1802        "Upsilon" => '\u{03A5}',
1803        "Phi" => '\u{03A6}',
1804        "Psi" => '\u{03A8}',
1805        "Omega" => '\u{03A9}',
1806        "lessequal" => '\u{2264}',
1807        "greaterequal" => '\u{2265}',
1808        "notequal" => '\u{2260}',
1809        "approxequal" => '\u{2248}',
1810        "equivalence" => '\u{2261}',
1811        "element" => '\u{2208}',
1812        "plusminus" => '\u{00B1}',
1813        "multiply" => '\u{00D7}',
1814        "divide" => '\u{00F7}',
1815        "infinity" => '\u{221E}',
1816        "partialdiff" => '\u{2202}',
1817        "gradient" => '\u{2207}',
1818        "summation" => '\u{2211}',
1819        "product" => '\u{220F}',
1820        "integral" => '\u{222B}',
1821        "radical" => '\u{221A}',
1822        "proportional" => '\u{221D}',
1823        "arrowright" => '\u{2192}',
1824        "arrowleft" => '\u{2190}',
1825        "arrowup" => '\u{2191}',
1826        "arrowdown" => '\u{2193}',
1827        "arrowboth" => '\u{2194}',
1828        "arrowdblright" => '\u{21D2}',
1829        "logicaland" => '\u{2227}',
1830        "logicalor" => '\u{2228}',
1831        "intersection" => '\u{2229}',
1832        "union" => '\u{222A}',
1833        "similar" => '\u{223C}',
1834        "congruent" => '\u{2245}',
1835        "dotmath" => '\u{22C5}',
1836        "asteriskmath" => '\u{2217}',
1837        _ => {
1838            // Strip an AGL `.suffix` (oldstyle/small-cap variant) and retry.
1839            if let Some((base, _)) = s.split_once('.') {
1840                if !base.is_empty() {
1841                    return glyph_name_to_char(base.as_bytes());
1842                }
1843            }
1844            return None;
1845        }
1846    };
1847    Some(resolved)
1848}
1849
1850/// Minimal WinAnsiEncoding (Latin-1-ish) for simple fonts lacking ToUnicode.
1851fn winansi_table() -> HashMap<u8, char> {
1852    let mut m = HashMap::new();
1853    for b in 0x20u8..=0x7e {
1854        m.insert(b, b as char);
1855    }
1856    // High range: Windows-1252 printable points that differ from Latin-1.
1857    let extra: &[(u8, char)] = &[
1858        (0x91, '\u{2018}'),
1859        (0x92, '\u{2019}'),
1860        (0x93, '\u{201C}'),
1861        (0x94, '\u{201D}'),
1862        (0x95, '\u{2022}'),
1863        (0x96, '\u{2013}'),
1864        (0x97, '\u{2014}'),
1865        (0x85, '\u{2026}'),
1866        (0xA0, '\u{00A0}'),
1867    ];
1868    for &(b, c) in extra {
1869        m.insert(b, c);
1870    }
1871    for b in 0xA1u8..=0xFF {
1872        m.entry(b).or_insert(b as char);
1873    }
1874    m
1875}
1876
1877/// Minimal MacRomanEncoding: ASCII plus the high-range points our corpus hits
1878/// (notably 0xA5 = bullet, used as a list marker).
1879fn macroman_table() -> HashMap<u8, char> {
1880    let mut m = HashMap::new();
1881    for b in 0x20u8..=0x7e {
1882        m.insert(b, b as char);
1883    }
1884    let high: &[(u8, char)] = &[
1885        (0xA5, '\u{2022}'), // bullet
1886        (0xD0, '\u{2013}'), // endash
1887        (0xD1, '\u{2014}'), // emdash
1888        (0xD2, '\u{201C}'),
1889        (0xD3, '\u{201D}'),
1890        (0xD4, '\u{2018}'),
1891        (0xD5, '\u{2019}'),
1892        (0xCA, '\u{00A0}'),
1893        (0xC9, '\u{2026}'),
1894        (0xDE, '\u{FB01}'),
1895        (0xDF, '\u{FB02}'),
1896    ];
1897    for &(b, c) in high {
1898        m.insert(b, c);
1899    }
1900    m
1901}
1902
1903#[cfg(test)]
1904mod xref_repair {
1905    /// Build a tiny one-page PDF whose cross-reference entries are either the
1906    /// spec's 20 bytes (`two_byte_eol`) or the 19-byte form some generators
1907    /// emit — everything else about the two files is identical.
1908    fn pdf_with_xref(two_byte_eol: bool) -> Vec<u8> {
1909        let content = b"BT /F1 12 Tf 72 700 Td (Invoice 922769430725) Tj ET\n";
1910        let stream = format!("<</Length {}>>stream\n", content.len()).into_bytes();
1911        let objs: Vec<Vec<u8>> = vec![
1912            b"<</Type/Catalog/Pages 2 0 R>>".to_vec(),
1913            b"<</Type/Pages/Kids[3 0 R]/Count 1>>".to_vec(),
1914            b"<</Type/Page/Parent 2 0 R/MediaBox[0 0 595 842]/Contents 4 0 R\
1915               /Resources<</Font<</F1 5 0 R>>>>>>"
1916                .to_vec(),
1917            [stream.as_slice(), content.as_slice(), b"endstream"].concat(),
1918            b"<</Type/Font/Subtype/Type1/BaseFont/Helvetica>>".to_vec(),
1919        ];
1920
1921        let mut out = b"%PDF-1.4\n".to_vec();
1922        let mut offsets = Vec::new();
1923        for (i, body) in objs.iter().enumerate() {
1924            offsets.push(out.len());
1925            out.extend_from_slice(format!("{} 0 obj", i + 1).as_bytes());
1926            out.extend_from_slice(body);
1927            out.extend_from_slice(b"endobj\n");
1928        }
1929        let xref_at = out.len();
1930        let eol: &[u8] = if two_byte_eol { b" \n" } else { b"\n" };
1931        out.extend_from_slice(format!("xref\n0 {}\n", objs.len() + 1).as_bytes());
1932        out.extend_from_slice(b"0000000000 65535 f");
1933        out.extend_from_slice(eol);
1934        for off in &offsets {
1935            out.extend_from_slice(format!("{off:010} 00000 n").as_bytes());
1936            out.extend_from_slice(eol);
1937        }
1938        out.extend_from_slice(
1939            format!("trailer<</Size {}/Root 1 0 R>>\n", objs.len() + 1).as_bytes(),
1940        );
1941        out.extend_from_slice(format!("startxref\n{xref_at}\n%%EOF\n").as_bytes());
1942        out
1943    }
1944
1945    /// A 19-byte cross-reference entry (a bare LF where the spec wants a
1946    /// two-byte EOL) makes lopdf reject the whole file, so a readable text
1947    /// layer used to look exactly like a scan — in the browser that meant ten
1948    /// seconds of OCR for nothing. The repair must recover *the same* parse the
1949    /// well-formed file gives.
1950    #[test]
1951    fn short_xref_entries_still_parse() {
1952        let good = pdf_with_xref(true);
1953        let broken = pdf_with_xref(false);
1954        assert!(
1955            broken.len() < good.len(),
1956            "the broken file is the shorter one"
1957        );
1958        assert!(
1959            lopdf::Document::load_mem(&good).is_ok(),
1960            "the control file must load unaided"
1961        );
1962        assert!(
1963            lopdf::Document::load_mem(&broken).is_err(),
1964            "lopdf rejects 19-byte entries — if this ever passes, drop the repair"
1965        );
1966
1967        let cells = |b: &[u8]| -> Vec<String> {
1968            super::pdf_textlines(b)
1969                .into_iter()
1970                .flat_map(|(_, _, c)| c.into_iter().map(|c| c.text))
1971                .collect()
1972        };
1973        let from_good = cells(&good);
1974        assert!(
1975            from_good.iter().any(|t| t.contains("922769430725")),
1976            "control text: {from_good:?}"
1977        );
1978        assert_eq!(
1979            cells(&broken),
1980            from_good,
1981            "repair must match the good parse"
1982        );
1983    }
1984
1985    /// The same generator overstates `/Length`, so lopdf reads past the data,
1986    /// misses `endstream` and drops the stream — the object comes back as a
1987    /// bare dictionary and the page has no content at all. Trust `endstream`
1988    /// instead, and do it without moving a single byte.
1989    #[test]
1990    fn overstated_stream_length_still_yields_content() {
1991        let good = pdf_with_xref(true);
1992        // Inflate the content stream's /Length by one, exactly as the invoice
1993        // that prompted this does.
1994        let broken = {
1995            let at = good
1996                .windows(8)
1997                .position(|w| w == b"/Length ")
1998                .expect("a /Length")
1999                + 8;
2000            let digits = good[at..].iter().take_while(|c| c.is_ascii_digit()).count();
2001            let n: usize = std::str::from_utf8(&good[at..at + digits])
2002                .unwrap()
2003                .parse()
2004                .unwrap();
2005            let inflated = (n + 1).to_string();
2006            assert_eq!(inflated.len(), digits, "keep the digit count");
2007            let mut b = good.clone();
2008            b[at..at + digits].copy_from_slice(inflated.as_bytes());
2009            b
2010        };
2011        assert_eq!(broken.len(), good.len(), "the defect must not move bytes");
2012        // lopdf alone loses the stream: the page parses but carries no content.
2013        let raw = lopdf::Document::load_mem(&broken).expect("still loads");
2014        assert!(
2015            raw.get_pages()
2016                .into_values()
2017                .all(|p| raw.get_page_content(p).is_empty()),
2018            "lopdf should drop the stream — if it stops, drop this repair"
2019        );
2020        // Ours recovers the same text the well-formed file gives.
2021        let text = |b: &[u8]| -> Vec<String> {
2022            super::pdf_textlines(b)
2023                .into_iter()
2024                .flat_map(|(_, _, c)| c.into_iter().map(|c| c.text))
2025                .collect()
2026        };
2027        let expected = text(&good);
2028        assert!(!expected.is_empty(), "control must produce text");
2029        assert_eq!(text(&broken), expected);
2030    }
2031
2032    /// The repair only fires where padding cannot move an object: it declines a
2033    /// file whose xref precedes an object (an incremental update), rather than
2034    /// shifting every offset the table records.
2035    #[test]
2036    fn repair_declines_when_padding_would_move_objects() {
2037        let mut incremental = pdf_with_xref(false);
2038        incremental.extend_from_slice(b"6 0 obj<</Type/Whatever>>endobj\n");
2039        let declined = super::pad_short_xref_entries(&incremental).unwrap_err();
2040        assert!(
2041            declined.contains("object follows the xref"),
2042            "reason: {declined}"
2043        );
2044    }
2045}
2046
2047/// #187: standard-14 fonts referenced without an embedded program (and thus
2048/// usually without `/Widths` or a `/FontDescriptor`) must decode with the
2049/// built-in Adobe Core 14 metrics instead of collapsing every cell to zero
2050/// width — the failure mode where a valid text layer was silently dropped
2051/// while pdfium read the same file fine.
2052#[cfg(test)]
2053mod base14_fonts {
2054    /// A one-page PDF whose single `Tj` uses `fontdict` (no embedded program).
2055    fn pdf_with_font(fontdict: &[u8], text: &[u8]) -> Vec<u8> {
2056        let content = [b"BT /F1 12 Tf 72 700 Td (".as_slice(), text, b") Tj ET\n"].concat();
2057        let stream = format!("<</Length {}>>stream\n", content.len()).into_bytes();
2058        let objs: Vec<Vec<u8>> = vec![
2059            b"<</Type/Catalog/Pages 2 0 R>>".to_vec(),
2060            b"<</Type/Pages/Kids[3 0 R]/Count 1>>".to_vec(),
2061            b"<</Type/Page/Parent 2 0 R/MediaBox[0 0 595 842]/Contents 4 0 R\
2062               /Resources<</Font<</F1 5 0 R>>>>>>"
2063                .to_vec(),
2064            [stream.as_slice(), content.as_slice(), b"endstream"].concat(),
2065            fontdict.to_vec(),
2066        ];
2067        let mut out = b"%PDF-1.4\n".to_vec();
2068        let mut offsets = Vec::new();
2069        for (i, body) in objs.iter().enumerate() {
2070            offsets.push(out.len());
2071            out.extend_from_slice(format!("{} 0 obj", i + 1).as_bytes());
2072            out.extend_from_slice(body);
2073            out.extend_from_slice(b"endobj\n");
2074        }
2075        let xref_at = out.len();
2076        out.extend_from_slice(format!("xref\n0 {}\n", objs.len() + 1).as_bytes());
2077        out.extend_from_slice(b"0000000000 65535 f \n");
2078        for off in &offsets {
2079            out.extend_from_slice(format!("{off:010} 00000 n \n").as_bytes());
2080        }
2081        out.extend_from_slice(
2082            format!("trailer<</Size {}/Root 1 0 R>>\n", objs.len() + 1).as_bytes(),
2083        );
2084        out.extend_from_slice(format!("startxref\n{xref_at}\n%%EOF\n").as_bytes());
2085        out
2086    }
2087
2088    /// The parsed cells of the only page.
2089    fn cells(pdf: &[u8]) -> Vec<crate::pdfium_backend::TextCell> {
2090        super::pdf_textlines(pdf)
2091            .into_iter()
2092            .flat_map(|(_, _, c)| c)
2093            .collect()
2094    }
2095
2096    /// Every standard-14 alias/style decodes with real (positive-width) boxes.
2097    #[test]
2098    fn standard14_faces_get_builtin_widths() {
2099        for fontdict in [
2100            // ReportLab's default: base-14 Helvetica, WinAnsi, nothing else.
2101            b"<</Type/Font/Subtype/Type1/BaseFont/Helvetica/Encoding/WinAnsiEncoding>>".as_slice(),
2102            // No /Encoding at all (StandardEncoding-ish default).
2103            b"<</Type/Font/Subtype/Type1/BaseFont/Times-BoldItalic>>",
2104            // Substitution aliases + a subset prefix.
2105            b"<</Type/Font/Subtype/TrueType/BaseFont/Arial,Bold>>",
2106            b"<</Type/Font/Subtype/Type1/BaseFont/ABCDEF+Courier-Oblique>>",
2107        ] {
2108            let pdf = pdf_with_font(fontdict, b"Words have width now");
2109            let cs = cells(&pdf);
2110            let text: String = cs
2111                .iter()
2112                .map(|c| c.text.as_str())
2113                .collect::<Vec<_>>()
2114                .join(" ");
2115            assert!(
2116                text.contains("Words have width now"),
2117                "{}: text lost: {text:?}",
2118                String::from_utf8_lossy(fontdict)
2119            );
2120            assert!(
2121                cs.iter().all(|c| c.r > c.l),
2122                "{}: zero-width cells: {cs:?}",
2123                String::from_utf8_lossy(fontdict)
2124            );
2125        }
2126    }
2127
2128    /// An explicit `/Widths` array always wins over the built-in metrics, and a
2129    /// non-standard face without `/Widths` stays as before (no invented boxes).
2130    #[test]
2131    fn explicit_widths_win_and_unknown_faces_are_untouched() {
2132        // Helvetica with explicit 100/1000-em widths: the word's box must be
2133        // ~4×100 units at 12pt = 4.8pt wide — far narrower than the ~2.7×
2134        // wider built-in Helvetica advances would make it.
2135        let explicit = pdf_with_font(
2136            b"<</Type/Font/Subtype/Type1/BaseFont/Helvetica/FirstChar 65\
2137               /Widths[100 100 100 100]/Encoding/WinAnsiEncoding>>",
2138            b"ABBA",
2139        );
2140        let builtin = pdf_with_font(
2141            b"<</Type/Font/Subtype/Type1/BaseFont/Helvetica/Encoding/WinAnsiEncoding>>",
2142            b"ABBA",
2143        );
2144        let w = |pdf: &[u8]| {
2145            let cs = cells(pdf);
2146            assert_eq!(cs.len(), 1, "one word cell: {cs:?}");
2147            cs[0].r - cs[0].l
2148        };
2149        let (we, wb) = (w(&explicit), w(&builtin));
2150        assert!(
2151            (we - 4.8).abs() < 0.1,
2152            "explicit widths must win: got {we}, want 4×100×12/1000"
2153        );
2154        assert!(
2155            wb > 2.0 * we,
2156            "built-in Helvetica is much wider: {wb} vs {we}"
2157        );
2158
2159        // An unknown face with no /Widths: still parses (text kept), but no
2160        // built-in table applies — the old zero-width behavior is preserved
2161        // rather than inventing Helvetica metrics for an arbitrary font.
2162        let unknown = pdf_with_font(
2163            b"<</Type/Font/Subtype/Type1/BaseFont/FancyCorp-Display>>",
2164            b"Mystery",
2165        );
2166        let cs = cells(&unknown);
2167        let text: String = cs.iter().map(|c| c.text.as_str()).collect();
2168        assert!(text.contains("Mystery"), "text still decodes: {cs:?}");
2169    }
2170}
2171
2172#[cfg(test)]
2173mod overpainted {
2174    use crate::pdfium_backend::TextCell;
2175
2176    fn cell(text: &str, l: f32, t: f32, r: f32, b: f32) -> TextCell {
2177        TextCell {
2178            text: text.into(),
2179            l,
2180            t,
2181            r,
2182            b,
2183        }
2184    }
2185
2186    /// The reporting invoice's logo: a `"` painted inside a `==` on one band —
2187    /// artwork drawn with glyphs. Both cells go; the real text on the next
2188    /// band stays.
2189    #[test]
2190    fn stacked_logo_glyphs_are_dropped() {
2191        let mut cells = vec![
2192            cell("\"", 72.7, 21.5, 86.4, 31.5),
2193            cell("==", 59.4, 21.5, 99.6, 31.5),
2194            cell("Herr", 65.2, 151.3, 81.7, 161.3),
2195        ];
2196        super::drop_overpainted_cells(&mut cells);
2197        assert_eq!(cells.len(), 1, "cells: {cells:?}");
2198        assert_eq!(cells[0].text, "Herr");
2199    }
2200
2201    /// Adjacent words on a line touch but never contain each other — prose is
2202    /// untouched, and so is a same-text near-duplicate (double-drawn faux
2203    /// bold), which is not evidence of artwork.
2204    #[test]
2205    fn prose_and_double_draw_are_kept() {
2206        let mut cells = vec![
2207            cell("Telefon", 354.3, 133.2, 381.5, 143.2),
2208            cell("0676/2000", 387.3, 133.2, 428.7, 143.2),
2209            cell("Bold", 100.0, 50.0, 130.0, 60.0),
2210            cell("Bold", 100.3, 50.0, 130.3, 60.0),
2211        ];
2212        super::drop_overpainted_cells(&mut cells);
2213        assert_eq!(cells.len(), 4);
2214    }
2215}
2216
2217#[cfg(test)]
2218mod vestigial_layer {
2219    use crate::pdfium_backend::{PdfPage, TextCell};
2220
2221    fn page_with(texts: &[&str]) -> PdfPage {
2222        let cells = texts
2223            .iter()
2224            .enumerate()
2225            .map(|(i, t)| TextCell {
2226                text: t.to_string(),
2227                l: 10.0,
2228                t: 10.0 + 12.0 * i as f32,
2229                r: 90.0,
2230                b: 20.0 + 12.0 * i as f32,
2231            })
2232            .collect();
2233        PdfPage::from_cells(595.0, 842.0, 1.0, cells)
2234    }
2235
2236    /// The reported scanned form: three typed-in field values ("03", "05",
2237    /// "2025") over three image pages. That must read as *no usable layer*,
2238    /// so the browser routes the document to OCR instead of extracting
2239    /// thirteen characters and skipping the letter entirely.
2240    #[test]
2241    fn typed_in_form_fields_are_not_a_text_layer() {
2242        let pages = vec![
2243            page_with(&["03", "05", "2025"]),
2244            page_with(&[]),
2245            page_with(&[]),
2246        ];
2247        assert!(super::text_layer_is_vestigial(&pages));
2248        assert!(super::text_layer_is_vestigial(&[page_with(&[])]));
2249    }
2250
2251    /// A short but genuine digital document — one page, a few real lines —
2252    /// keeps the fast text path.
2253    #[test]
2254    fn sparse_but_real_documents_pass() {
2255        let one_pager = vec![page_with(&[
2256            "Confidential briefing",
2257            "Prepared for the board meeting",
2258            "Do not distribute",
2259        ])];
2260        assert!(!super::text_layer_is_vestigial(&one_pager));
2261    }
2262}