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 (widths, 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    let fallback_names = if two_byte {
215        HashMap::new()
216    } else {
217        differences_gid_names(doc, fdict)
218    };
219    let program_encoding = if two_byte {
220        HashMap::new()
221    } else {
222        type1_program_encoding(doc, fdict)
223    };
224
225    let (ascent, descent) = font_ascent_descent(doc, fdict, two_byte);
226
227    Font {
228        two_byte,
229        to_unicode,
230        widths,
231        default_width,
232        simple_encoding,
233        fallback_names,
234        program_encoding,
235        ascent,
236        descent,
237        hash: hash_name(name),
238    }
239}
240
241/// Collect `/Differences` entries whose glyph name is a GID placeholder
242/// (`g115`, `cid42`, `glyph7`, `index9`) with no Unicode mapping. docling-parse
243/// emits such glyphs as the literal name `/g115`; mapping them here keeps the
244/// text from being silently dropped (subsetted fonts with no ToUnicode). The
245/// GID-name restriction keeps real Adobe glyph names on the normal path so this
246/// never invents garbage on the clean files.
247fn differences_gid_names(doc: &Document, fdict: &Dictionary) -> HashMap<u8, String> {
248    let mut map = HashMap::new();
249    let Some(Object::Dictionary(enc)) = fdict.get(b"Encoding").ok().and_then(|o| deref(doc, o))
250    else {
251        return map;
252    };
253    let Some(Object::Array(diffs)) = enc.get(b"Differences").ok().and_then(|o| deref(doc, o))
254    else {
255        return map;
256    };
257    let mut code = 0u8;
258    for el in diffs {
259        match el {
260            Object::Integer(i) => code = *i as u8,
261            Object::Name(name) => {
262                if glyph_name_to_char(name).is_none() && is_gid_name(name) {
263                    map.insert(code, String::from_utf8_lossy(name).into_owned());
264                }
265                code = code.wrapping_add(1);
266            }
267            _ => {}
268        }
269    }
270    map
271}
272
273/// Parse the embedded Type1 font program's built-in `/Encoding` vector
274/// (`dup <code> /<glyphname> put` entries in the clear-text header before
275/// `eexec`) into `code → char`. This is how docling recovers glyphs from
276/// standard TeX math fonts (CMMI/CMSY/…) that carry no PDF `/Encoding` and no
277/// ToUnicode — e.g. CMMI's `dup 21 /lambda` or CMSY's `dup 20 /lessequal`.
278/// Only `FontFile` (Type1) is parsed; CFF (`FontFile3`) and TrueType
279/// (`FontFile2`) store their encoding in a binary table and are left alone.
280fn type1_program_encoding(doc: &Document, fdict: &Dictionary) -> HashMap<u8, char> {
281    let mut map = HashMap::new();
282    let Some(desc) = fdict
283        .get(b"FontDescriptor")
284        .ok()
285        .and_then(|o| deref(doc, o))
286        .and_then(|o| o.as_dict().ok())
287    else {
288        return map;
289    };
290    let Some(data) = desc
291        .get(b"FontFile")
292        .ok()
293        .and_then(|o| deref(doc, o))
294        .and_then(|o| o.as_stream().ok())
295        .and_then(|s| s.decompressed_content().ok())
296    else {
297        return map;
298    };
299    // The clear-text header (PostScript) ends at `eexec`; the rest is encrypted.
300    let head_end = data
301        .windows(5)
302        .position(|w| w == b"eexec")
303        .unwrap_or(data.len());
304    let head = String::from_utf8_lossy(&data[..head_end]);
305    // Scan for `dup <code> /<name> put` tokens.
306    let toks: Vec<&str> = head.split_whitespace().collect();
307    for w in toks.windows(4) {
308        if w[0] == "dup" && w[3] == "put" {
309            if let (Ok(code), Some(name)) = (w[1].parse::<u32>(), w[2].strip_prefix('/')) {
310                if code <= 255 {
311                    if let Some(ch) = glyph_name_to_char(name.as_bytes()) {
312                        map.insert(code as u8, ch);
313                    }
314                }
315            }
316        }
317    }
318    map
319}
320
321/// A glyph name that is a synthetic placeholder, not a real Adobe name:
322/// `g115`, `cid42`, `glyph7`, `index9`, `G12`, or a short-prefix code name like
323/// `SM590000` (IBM BookMaster). These carry no Unicode meaning, and docling-parse
324/// emits them verbatim (`/SM590000`). `afii####` / `uni####` are real Adobe names
325/// and excluded. The restriction keeps genuine glyph names on the Unicode path.
326fn is_gid_name(name: &[u8]) -> bool {
327    let Ok(s) = std::str::from_utf8(name) else {
328        return false;
329    };
330    if s.starts_with("afii") || s.starts_with("uni") {
331        return false;
332    }
333    for prefix in ["g", "G", "cid", "CID", "glyph", "index"] {
334        if let Some(rest) = s.strip_prefix(prefix) {
335            if !rest.is_empty() && rest.bytes().all(|b| b.is_ascii_digit()) {
336                return true;
337            }
338        }
339    }
340    // Short alpha prefix (≤3 letters) followed by a run of ≥3 digits — synthetic
341    // code names like `SM590000`, distinct from real Adobe names (whole words or
342    // letter+`.suffix` variants).
343    let alpha = s.bytes().take_while(|b| b.is_ascii_alphabetic()).count();
344    let digits = s.len() - alpha;
345    (1..=3).contains(&alpha)
346        && digits >= 3
347        && s.as_bytes()[alpha..].iter().all(|b| b.is_ascii_digit())
348}
349
350fn font_ascent_descent(doc: &Document, fdict: &Dictionary, two_byte: bool) -> (f64, f64) {
351    // For Type0, the descriptor lives on the descendant CIDFont.
352    let descr_owner = if two_byte {
353        fdict
354            .get(b"DescendantFonts")
355            .ok()
356            .and_then(|o| deref(doc, o))
357            .and_then(|o| match o {
358                Object::Array(a) => a.first(),
359                _ => None,
360            })
361            .and_then(|o| as_dict(doc, o))
362    } else {
363        Some(fdict)
364    };
365    let fd = descr_owner
366        .and_then(|d| d.get(b"FontDescriptor").ok())
367        .and_then(|o| as_dict(doc, o));
368    let asc = fd
369        .and_then(|d| d.get(b"Ascent").ok())
370        .and_then(|o| {
371            o.as_float()
372                .ok()
373                .or_else(|| o.as_i64().ok().map(|i| i as f32))
374        })
375        .unwrap_or(750.0) as f64;
376    let desc = fd
377        .and_then(|d| d.get(b"Descent").ok())
378        .and_then(|o| {
379            o.as_float()
380                .ok()
381                .or_else(|| o.as_i64().ok().map(|i| i as f32))
382        })
383        .unwrap_or(-250.0) as f64;
384    // Some subsetted fonts carry a degenerate FontDescriptor (`/Ascent 0
385    // /Descent 0`) — the real metrics live in the font program. That collapses
386    // the loose box to zero height, so the line cells get zero area and the
387    // layout's region/text assignment drops them (2305's References list lost
388    // every prose line, keeping only the URLs). Fall back to typical text metrics
389    // so the box has height.
390    if asc - desc <= 1.0 {
391        return (750.0, -250.0);
392    }
393    (asc, desc)
394}
395
396/// Simple-font widths: `/FirstChar` + `/Widths` array, `/MissingWidth` default.
397fn simple_widths(doc: &Document, fdict: &Dictionary) -> (HashMap<u32, f64>, f64) {
398    let mut map = HashMap::new();
399    let first = fdict
400        .get(b"FirstChar")
401        .ok()
402        .and_then(|o| o.as_i64().ok())
403        .unwrap_or(0) as u32;
404    if let Some(Object::Array(arr)) = fdict.get(b"Widths").ok().and_then(|o| deref(doc, o)) {
405        for (i, w) in arr.iter().enumerate() {
406            if let Some(w) = num(w) {
407                map.insert(first + i as u32, w);
408            }
409        }
410    }
411    let dw = fdict
412        .get(b"FontDescriptor")
413        .ok()
414        .and_then(|o| as_dict(doc, o))
415        .and_then(|d| d.get(b"MissingWidth").ok())
416        .and_then(num)
417        .unwrap_or(0.0);
418    (map, dw)
419}
420
421/// CIDFont widths: the `/W` array on the descendant font (`/DW` default = 1000).
422fn cid_widths(doc: &Document, fdict: &Dictionary) -> (HashMap<u32, f64>, f64) {
423    let mut map = HashMap::new();
424    let Some(desc) = fdict
425        .get(b"DescendantFonts")
426        .ok()
427        .and_then(|o| deref(doc, o))
428        .and_then(|o| match o {
429            Object::Array(a) => a.first(),
430            _ => None,
431        })
432        .and_then(|o| as_dict(doc, o))
433    else {
434        return (map, 1000.0);
435    };
436    let dw = desc.get(b"DW").ok().and_then(num).unwrap_or(1000.0);
437    if let Some(Object::Array(w)) = desc.get(b"W").ok().and_then(|o| deref(doc, o)) {
438        let mut i = 0;
439        while i < w.len() {
440            let c = w.get(i).and_then(num);
441            match (c, w.get(i + 1)) {
442                // `c [w1 w2 ...]`: consecutive CIDs starting at c.
443                (Some(c), Some(Object::Array(list))) => {
444                    for (k, wv) in list.iter().enumerate() {
445                        if let Some(wv) = num(wv) {
446                            map.insert(c as u32 + k as u32, wv);
447                        }
448                    }
449                    i += 2;
450                }
451                // `c_first c_last w`: a run all of width w.
452                (Some(c1), Some(o2)) => {
453                    if let (Some(c2), Some(wv)) = (num(o2), w.get(i + 2).and_then(num)) {
454                        for cid in c1 as u32..=c2 as u32 {
455                            map.insert(cid, wv);
456                        }
457                    }
458                    i += 3;
459                }
460                _ => break,
461            }
462        }
463    }
464    (map, dw)
465}
466
467fn num(o: &Object) -> Option<f64> {
468    match o {
469        Object::Integer(i) => Some(*i as f64),
470        Object::Real(r) => Some(*r as f64),
471        _ => None,
472    }
473}
474
475/// Parse a ToUnicode CMap's `bfchar` / `bfrange` sections into code→string.
476fn parse_tounicode(data: &[u8]) -> HashMap<u32, String> {
477    let text = String::from_utf8_lossy(data);
478    let mut map = HashMap::new();
479    let hex = |s: &str| -> Option<Vec<u16>> {
480        let s = s.trim();
481        if !s.starts_with('<') || !s.ends_with('>') {
482            return None;
483        }
484        let h = &s[1..s.len() - 1];
485        let bytes: Vec<u8> = (0..h.len())
486            .step_by(2)
487            .filter_map(|i| u8::from_str_radix(h.get(i..i + 2)?, 16).ok())
488            .collect();
489        Some(
490            bytes
491                .chunks(2)
492                .map(|c| {
493                    if c.len() == 2 {
494                        u16::from_be_bytes([c[0], c[1]])
495                    } else {
496                        c[0] as u16
497                    }
498                })
499                .collect(),
500        )
501    };
502    let u16s_to_string = |u: &[u16]| String::from_utf16_lossy(u);
503    let code_of = |u: &[u16]| u.iter().fold(0u32, |acc, &x| (acc << 16) | x as u32);
504
505    // Tokenize by structure, not whitespace: CMap hex groups are often written
506    // back-to-back with no separators (`<21><21><0054>`), so scan for `<…>`
507    // groups, `[`/`]` brackets, and bareword keywords.
508    let tokens: Vec<String> = {
509        let bytes = text.as_bytes();
510        let mut toks = Vec::new();
511        let mut i = 0;
512        while i < bytes.len() {
513            let c = bytes[i];
514            if c.is_ascii_whitespace() {
515                i += 1;
516            } else if c == b'<' {
517                let start = i;
518                while i < bytes.len() && bytes[i] != b'>' {
519                    i += 1;
520                }
521                i += 1; // include '>'
522                toks.push(String::from_utf8_lossy(&bytes[start..i.min(bytes.len())]).into_owned());
523            } else if c == b'[' || c == b']' {
524                toks.push((c as char).to_string());
525                i += 1;
526            } else {
527                let start = i;
528                while i < bytes.len()
529                    && !bytes[i].is_ascii_whitespace()
530                    && bytes[i] != b'<'
531                    && bytes[i] != b'['
532                    && bytes[i] != b']'
533                {
534                    i += 1;
535                }
536                toks.push(String::from_utf8_lossy(&bytes[start..i]).into_owned());
537            }
538        }
539        toks
540    };
541    let tokens: Vec<&str> = tokens.iter().map(|s| s.as_str()).collect();
542    let mut i = 0;
543    while i < tokens.len() {
544        match tokens[i] {
545            "beginbfchar" => {
546                i += 1;
547                while i + 1 < tokens.len() && tokens[i] != "endbfchar" {
548                    if let (Some(src), Some(dst)) = (hex(tokens[i]), hex(tokens[i + 1])) {
549                        map.insert(code_of(&src), u16s_to_string(&dst));
550                    }
551                    i += 2;
552                }
553            }
554            "beginbfrange" => {
555                i += 1;
556                while i + 2 < tokens.len() && tokens[i] != "endbfrange" {
557                    let (Some(lo), Some(hi)) = (hex(tokens[i]), hex(tokens[i + 1])) else {
558                        i += 1;
559                        continue;
560                    };
561                    let lo = code_of(&lo);
562                    let hi = code_of(&hi);
563                    if tokens[i + 2] == "[" {
564                        // `<lo> <hi> [ <d0> <d1> ... ]`: one dst per code in the range.
565                        let mut j = i + 3;
566                        let mut code = lo;
567                        while j < tokens.len() && tokens[j] != "]" {
568                            if let Some(dst) = hex(tokens[j]) {
569                                map.insert(code, u16s_to_string(&dst));
570                            }
571                            code += 1;
572                            j += 1;
573                        }
574                        i = j + 1;
575                    } else if let Some(dst) = hex(tokens[i + 2]) {
576                        // `<lo> <hi> <dst>`: consecutive Unicode from a base.
577                        let base = code_of(&dst);
578                        for (k, code) in (lo..=hi).enumerate() {
579                            if let Some(ch) = char::from_u32(base + k as u32) {
580                                map.insert(code, ch.to_string());
581                            }
582                        }
583                        i += 3;
584                    } else {
585                        i += 1;
586                    }
587                }
588            }
589            _ => i += 1,
590        }
591    }
592    map
593}
594
595/// Decode a PDF string literal in a Tj/TJ operand into raw code units.
596fn codes(font: &Font, bytes: &[u8]) -> Vec<u32> {
597    if font.two_byte {
598        bytes
599            .chunks(2)
600            .map(|c| {
601                if c.len() == 2 {
602                    ((c[0] as u32) << 8) | c[1] as u32
603                } else {
604                    c[0] as u32
605                }
606            })
607            .collect()
608    } else {
609        bytes.iter().map(|&b| b as u32).collect()
610    }
611}
612
613/// Page size (width, height) in PDF points from the MediaBox.
614fn page_size(doc: &Document, page_id: lopdf::ObjectId) -> (f32, f32) {
615    let mb = doc
616        .get_object(page_id)
617        .ok()
618        .and_then(|o| o.as_dict().ok())
619        .and_then(|d| {
620            // MediaBox may be inherited; lopdf resolves via get_page... fall back to a guess.
621            d.get(b"MediaBox").ok().cloned()
622        })
623        .or_else(|| {
624            doc.get_dictionary(page_id)
625                .ok()
626                .and_then(|d| d.get(b"MediaBox").ok().cloned())
627        });
628    if let Some(Object::Array(a)) = mb {
629        let v: Vec<f32> = a.iter().filter_map(|o| num(o).map(|x| x as f32)).collect();
630        if v.len() == 4 {
631            return ((v[2] - v[0]).abs(), (v[3] - v[1]).abs());
632        }
633    }
634    (612.0, 792.0)
635}
636
637/// Debug: raw glyph stream `(ch, ll, lr, lb, lt)` (native coords) for page
638/// `index`, before the sanitizer. For comparing char cells to docling-parse.
639pub fn debug_glyphs(bytes: &[u8], index: usize) -> Vec<(char, f32, f32, f32, f32)> {
640    let Ok(doc) = Document::load_mem(bytes) else {
641        return Vec::new();
642    };
643    let mut pages: Vec<_> = doc.get_pages().into_iter().collect();
644    pages.sort_by_key(|(n, _)| *n);
645    let Some((_, pid)) = pages.get(index) else {
646        return Vec::new();
647    };
648    page_glyphs(&doc, *pid)
649        .into_iter()
650        .map(|g| (g.ch, g.ll, g.lr, g.lb, g.lt))
651        .collect()
652}
653
654/// Public entry: per-page (width, height, line cells) for a PDF, via the Rust
655/// text parser + the docling-parse line sanitizer. Used by the pipeline and the
656/// `textparse_dump` example.
657pub fn pdf_textlines(bytes: &[u8]) -> Vec<(f32, f32, Vec<crate::pdfium_backend::TextCell>)> {
658    let Ok(doc) = Document::load_mem(bytes) else {
659        return Vec::new();
660    };
661    let mut caches = DocCaches::default();
662    let mut pages: Vec<_> = doc.get_pages().into_iter().collect();
663    pages.sort_by_key(|(n, _)| *n);
664    pages
665        .into_iter()
666        .map(|(_, pid)| {
667            let (w, h) = page_size(&doc, pid);
668            let glyphs = page_glyphs_cached(&doc, pid, &mut caches);
669            let cells = crate::dp_lines::line_cells(&glyphs, h, true);
670            (w, h, cells)
671        })
672        .collect()
673}
674
675/// Debug/diagnostic entry: per-page (width, height, word cells) for a PDF, via
676/// the Rust parser glyphs run through the docling-parse word grouping. Used to
677/// compare parser word cells against docling-parse's `word_cells` oracle (roadmap
678/// item 6).
679pub fn pdf_words(bytes: &[u8]) -> Vec<(f32, f32, Vec<crate::pdfium_backend::TextCell>)> {
680    let Ok(doc) = Document::load_mem(bytes) else {
681        return Vec::new();
682    };
683    let mut caches = DocCaches::default();
684    let mut pages: Vec<_> = doc.get_pages().into_iter().collect();
685    pages.sort_by_key(|(n, _)| *n);
686    pages
687        .into_iter()
688        .map(|(_, pid)| {
689            let (w, h) = page_size(&doc, pid);
690            let glyphs = page_glyphs_cached(&doc, pid, &mut caches);
691            let cells = crate::dp_lines::word_cells(&glyphs, h, true);
692            (w, h, cells)
693        })
694        .collect()
695}
696
697/// One page's text cells from the pure-Rust parser: prose line cells, per-word
698/// cells, and code line cells — all from a single glyph parse. Replaces the
699/// pdfium text path (roadmap item 6) when the parser drop is enabled.
700#[derive(Default)]
701pub struct PageParserCells {
702    pub prose: Vec<crate::pdfium_backend::TextCell>,
703    pub words: Vec<crate::pdfium_backend::TextCell>,
704    pub code: Vec<crate::pdfium_backend::TextCell>,
705}
706
707/// Full parser text layer: prose + word + code cells per page, glyphs parsed once.
708/// `prose`/`words` come from the docling-parse contraction ([`crate::dp_lines`]);
709/// `code` splits only at the parser's own space glyphs (monospace keeps its
710/// source spacing). Used by the pipeline to retire pdfium's text path.
711pub fn pdf_all_cells(bytes: &[u8]) -> Vec<PageParserCells> {
712    let Ok(doc) = Document::load_mem(bytes) else {
713        return Vec::new();
714    };
715    let mut caches = DocCaches::default();
716    let mut pages: Vec<_> = doc.get_pages().into_iter().collect();
717    pages.sort_by_key(|(n, _)| *n);
718    pages
719        .into_iter()
720        .map(|(_, pid)| {
721            let (_w, h) = page_size(&doc, pid);
722            let glyphs = page_glyphs_cached(&doc, pid, &mut caches);
723            let (prose, words) = crate::dp_lines::line_and_word_cells(&glyphs, h, true);
724            PageParserCells {
725                prose,
726                words,
727                code: crate::pdfium_backend::code_cells_from_glyphs(&glyphs, h),
728            }
729        })
730        .collect()
731}
732
733/// Whole pages for the text-layer-only conversion ([`crate::convert_text_layer`]):
734/// the parser's prose/word/code cells plus page geometry, assembled into
735/// [`PdfPage`]s with no rendered image and no link annotations. Everything here
736/// is pure Rust (lopdf), so it compiles without the `ml` feature — including on
737/// wasm32. A page the parser can't read (no text layer) comes back with empty
738/// cells; there is no pdfium fallback on this path.
739pub fn pdf_text_pages(bytes: &[u8]) -> Vec<crate::pdfium_backend::PdfPage> {
740    let Ok(doc) = Document::load_mem(bytes) else {
741        return Vec::new();
742    };
743    let mut caches = DocCaches::default();
744    let mut pages: Vec<_> = doc.get_pages().into_iter().collect();
745    pages.sort_by_key(|(n, _)| *n);
746    pages
747        .into_iter()
748        .map(|(_, pid)| {
749            let (w, h) = page_size(&doc, pid);
750            let glyphs = page_glyphs_cached(&doc, pid, &mut caches);
751            let (prose, words) = crate::dp_lines::line_and_word_cells(&glyphs, h, true);
752            crate::pdfium_backend::PdfPage {
753                width: w,
754                height: h,
755                // Cells are native PDF points; there is no rendered bitmap.
756                scale: 1.0,
757                cells: prose,
758                code_cells: crate::pdfium_backend::code_cells_from_glyphs(&glyphs, h),
759                word_cells: words,
760                #[cfg(feature = "ml")]
761                image: image::RgbImage::new(1, 1),
762                links: Vec::new(),
763            }
764        })
765        .collect()
766}
767
768/// The text-state scalars inherited by a Form XObject when it is invoked via
769/// `Do` (the PDF graphics state includes the text parameters, but not the text
770/// matrices, which a form re-establishes inside its own `BT`/`ET`).
771#[derive(Clone, Copy)]
772struct TextState {
773    tc: f64,
774    tw: f64,
775    th: f64,
776    tl: f64,
777    trise: f64,
778    fsize: f64,
779}
780
781impl TextState {
782    const INIT: TextState = TextState {
783        tc: 0.0,
784        tw: 0.0,
785        th: 1.0,
786        tl: 0.0,
787        trise: 0.0,
788        fsize: 0.0,
789    };
790}
791
792/// The effective `/Resources` dictionary for a page (inline or via reference,
793/// falling back to an inherited one from a `/Parent`).
794fn page_res(doc: &Document, page_id: lopdf::ObjectId) -> Option<&Dictionary> {
795    let (inline, ids) = doc.get_page_resources(page_id).ok()?;
796    if let Some(d) = inline {
797        return Some(d);
798    }
799    ids.into_iter().find_map(|id| doc.get_dictionary(id).ok())
800}
801
802/// Build the code→[`Font`] map for a resources dictionary's `/Font` sub-dict,
803/// reusing the per-document cache for fonts referenced indirectly (the common
804/// case — the same font objects recur on every page).
805fn fonts_from_res(
806    doc: &Document,
807    res: &Dictionary,
808    caches: &mut DocCaches,
809) -> HashMap<Vec<u8>, Rc<Font>> {
810    let mut map = HashMap::new();
811    let font_dict = res
812        .get(b"Font")
813        .ok()
814        .and_then(|o| deref(doc, o))
815        .and_then(|o| o.as_dict().ok());
816    if let Some(fd) = font_dict {
817        for (name, value) in fd.iter() {
818            let font = match value {
819                Object::Reference(id) => {
820                    let key = (*id, name.clone());
821                    if let Some(f) = caches.fonts.get(&key) {
822                        Rc::clone(f)
823                    } else if let Some(fdict) = deref(doc, value).and_then(|o| o.as_dict().ok()) {
824                        let f = Rc::new(parse_font(doc, name, fdict));
825                        caches.fonts.insert(key, Rc::clone(&f));
826                        f
827                    } else {
828                        continue;
829                    }
830                }
831                _ => {
832                    if let Some(fdict) = deref(doc, value).and_then(|o| o.as_dict().ok()) {
833                        Rc::new(parse_font(doc, name, fdict))
834                    } else {
835                        continue;
836                    }
837                }
838            };
839            map.insert(name.clone(), font);
840        }
841    }
842    map
843}
844
845/// Extract every glyph on a page as a native-coordinate [`Glyph`].
846pub(crate) fn page_glyphs(doc: &Document, page_id: lopdf::ObjectId) -> Vec<Glyph> {
847    page_glyphs_cached(doc, page_id, &mut DocCaches::default())
848}
849
850/// [`page_glyphs`] with an explicit per-document cache, so a multi-page walk
851/// parses each font / decodes each form once instead of once per page.
852fn page_glyphs_cached(
853    doc: &Document,
854    page_id: lopdf::ObjectId,
855    caches: &mut DocCaches,
856) -> Vec<Glyph> {
857    let mut out = Vec::new();
858    let Ok(content_bytes) = doc.get_page_content(page_id) else {
859        return out;
860    };
861    let Ok(content) = lopdf::content::Content::decode(&content_bytes) else {
862        return out;
863    };
864    if let Some(res) = page_res(doc, page_id) {
865        run_content(
866            doc,
867            res,
868            &content,
869            Mat::ID,
870            TextState::INIT,
871            0,
872            caches,
873            &mut out,
874        );
875    }
876    out
877}
878
879/// Run a content stream's operators, emitting glyphs into `out`. Recurses into
880/// Form XObjects on `Do` (bulk body text in heavy PDFs lives inside a form, not
881/// the page content stream). `res` is the resources dict in scope (the page's,
882/// or the form's own); `base_ctm` is the CTM at the point of invocation.
883#[allow(clippy::too_many_arguments)]
884fn run_content(
885    doc: &Document,
886    res: &Dictionary,
887    content: &lopdf::content::Content,
888    base_ctm: Mat,
889    init: TextState,
890    depth: u32,
891    caches: &mut DocCaches,
892    out: &mut Vec<Glyph>,
893) {
894    let fonts = fonts_from_res(doc, res, caches);
895    let xobjects = res
896        .get(b"XObject")
897        .ok()
898        .and_then(|o| deref(doc, o))
899        .and_then(|o| o.as_dict().ok());
900
901    // Graphics + text state. `q`/`Q` save and restore the whole graphics state,
902    // which includes the text parameters (Tc, Tw, Tz, TL, Tfs, Trise, font) —
903    // *not* the text matrix (that is reset by BT). Saving only the CTM let a Tc
904    // set inside a `q…Q` block leak out and drift every later glyph.
905    #[allow(clippy::type_complexity)]
906    let mut gstate_stack: Vec<(Mat, f64, f64, f64, f64, f64, f64, Option<&Rc<Font>>)> = Vec::new();
907    let mut ctm = base_ctm;
908    let mut tm = Mat::ID;
909    let mut tlm = Mat::ID;
910    let mut font: Option<&Rc<Font>> = None;
911    let mut fsize = init.fsize;
912    let mut tc = init.tc; // char spacing
913    let mut tw = init.tw; // word spacing
914    let mut th = init.th; // horizontal scale (Tz/100)
915    let mut tl = init.tl; // leading
916    let mut trise = init.trise;
917
918    let op_f = |operands: &[Object], i: usize| operands.get(i).and_then(num).unwrap_or(0.0);
919
920    for op in &content.operations {
921        let operands = &op.operands;
922        match op.operator.as_str() {
923            "q" => gstate_stack.push((ctm, tc, tw, th, tl, trise, fsize, font)),
924            "Q" => {
925                if let Some((c, a, b, h, l, r, fs, f)) = gstate_stack.pop() {
926                    ctm = c;
927                    tc = a;
928                    tw = b;
929                    th = h;
930                    tl = l;
931                    trise = r;
932                    fsize = fs;
933                    font = f;
934                }
935            }
936            "cm" => {
937                let m = Mat {
938                    a: op_f(operands, 0),
939                    b: op_f(operands, 1),
940                    c: op_f(operands, 2),
941                    d: op_f(operands, 3),
942                    e: op_f(operands, 4),
943                    f: op_f(operands, 5),
944                };
945                ctm = m.then(ctm);
946            }
947            "BT" => {
948                tm = Mat::ID;
949                tlm = Mat::ID;
950            }
951            "ET" => {}
952            "Tf" => {
953                if let Some(Object::Name(n)) = operands.first() {
954                    font = fonts.get(n.as_slice());
955                }
956                fsize = op_f(operands, 1);
957            }
958            "Td" => {
959                tlm = Mat {
960                    a: 1.0,
961                    b: 0.0,
962                    c: 0.0,
963                    d: 1.0,
964                    e: op_f(operands, 0),
965                    f: op_f(operands, 1),
966                }
967                .then(tlm);
968                tm = tlm;
969            }
970            "TD" => {
971                tl = -op_f(operands, 1);
972                tlm = Mat {
973                    a: 1.0,
974                    b: 0.0,
975                    c: 0.0,
976                    d: 1.0,
977                    e: op_f(operands, 0),
978                    f: op_f(operands, 1),
979                }
980                .then(tlm);
981                tm = tlm;
982            }
983            "Tm" => {
984                tlm = Mat {
985                    a: op_f(operands, 0),
986                    b: op_f(operands, 1),
987                    c: op_f(operands, 2),
988                    d: op_f(operands, 3),
989                    e: op_f(operands, 4),
990                    f: op_f(operands, 5),
991                };
992                tm = tlm;
993            }
994            "T*" => {
995                tlm = Mat {
996                    a: 1.0,
997                    b: 0.0,
998                    c: 0.0,
999                    d: 1.0,
1000                    e: 0.0,
1001                    f: -tl,
1002                }
1003                .then(tlm);
1004                tm = tlm;
1005            }
1006            "Tc" => tc = op_f(operands, 0),
1007            "Tw" => tw = op_f(operands, 0),
1008            "Tz" => th = op_f(operands, 0) / 100.0,
1009            "TL" => tl = op_f(operands, 0),
1010            "Ts" => trise = op_f(operands, 0),
1011            "Tj" | "'" | "\"" => {
1012                if op.operator == "'" || op.operator == "\"" {
1013                    // move to next line first
1014                    tlm = Mat {
1015                        a: 1.0,
1016                        b: 0.0,
1017                        c: 0.0,
1018                        d: 1.0,
1019                        e: 0.0,
1020                        f: -tl,
1021                    }
1022                    .then(tlm);
1023                    tm = tlm;
1024                }
1025                if op.operator == "\"" {
1026                    // `aw ac string "` sets word- and char-spacing before
1027                    // showing the string (PDF 32000-1 §9.4.3), persisting after.
1028                    tw = op_f(operands, 0);
1029                    tc = op_f(operands, 1);
1030                }
1031                if let (Some(f), Some(Object::String(s, _))) = (font, operands.last()) {
1032                    show_text(f, s, fsize, tc, tw, th, trise, &mut tm, ctm, out);
1033                }
1034            }
1035            "TJ" => {
1036                if let (Some(f), Some(Object::Array(arr))) = (font, operands.first()) {
1037                    for el in arr {
1038                        match el {
1039                            Object::String(s, _) => {
1040                                show_text(f, s, fsize, tc, tw, th, trise, &mut tm, ctm, out)
1041                            }
1042                            other => {
1043                                if let Some(adj) = num(other) {
1044                                    // negative number moves text right (PDF: subtract)
1045                                    let tx = -adj / 1000.0 * fsize * th;
1046                                    tm = Mat {
1047                                        a: 1.0,
1048                                        b: 0.0,
1049                                        c: 0.0,
1050                                        d: 1.0,
1051                                        e: tx,
1052                                        f: 0.0,
1053                                    }
1054                                    .then(tm);
1055                                }
1056                            }
1057                        }
1058                    }
1059                }
1060            }
1061            "Do" => {
1062                // Invoke a Form XObject: bulk body text in many PDFs lives inside
1063                // a form, reached only here. Image XObjects are skipped (no text).
1064                if depth >= 8 {
1065                    continue;
1066                }
1067                let Some(Object::Name(n)) = operands.first() else {
1068                    continue;
1069                };
1070                let obj = xobjects.and_then(|d| d.get(n.as_slice()).ok());
1071                let form_id = match obj {
1072                    Some(Object::Reference(id)) => Some(*id),
1073                    _ => None,
1074                };
1075                let stream = obj
1076                    .and_then(|o| deref(doc, o))
1077                    .and_then(|o| o.as_stream().ok());
1078                let Some(stream) = stream else { continue };
1079                let is_form = stream
1080                    .dict
1081                    .get(b"Subtype")
1082                    .ok()
1083                    .and_then(|o| o.as_name().ok())
1084                    == Some(b"Form".as_slice());
1085                if !is_form {
1086                    continue;
1087                }
1088                // Decode the form's content once per document (headers/footers
1089                // and bulk body text invoke the same form on every page).
1090                let cached = form_id.and_then(|id| caches.forms.get(&id).cloned());
1091                let form_content = match cached {
1092                    Some(c) => c,
1093                    None => {
1094                        let Ok(data) = stream.decompressed_content() else {
1095                            continue;
1096                        };
1097                        let Ok(c) = lopdf::content::Content::decode(&data) else {
1098                            continue;
1099                        };
1100                        let c = Rc::new(c);
1101                        if let Some(id) = form_id {
1102                            caches.forms.insert(id, Rc::clone(&c));
1103                        }
1104                        c
1105                    }
1106                };
1107                // The form's /Matrix maps form space into the CTM at invocation.
1108                let form_mat = match stream.dict.get(b"Matrix").ok() {
1109                    Some(Object::Array(a)) if a.len() == 6 => {
1110                        let v: Vec<f64> = a.iter().filter_map(num).collect();
1111                        if v.len() == 6 {
1112                            Mat {
1113                                a: v[0],
1114                                b: v[1],
1115                                c: v[2],
1116                                d: v[3],
1117                                e: v[4],
1118                                f: v[5],
1119                            }
1120                        } else {
1121                            Mat::ID
1122                        }
1123                    }
1124                    _ => Mat::ID,
1125                };
1126                // The form's own /Resources, falling back to the inherited ones.
1127                let form_res = stream
1128                    .dict
1129                    .get(b"Resources")
1130                    .ok()
1131                    .and_then(|o| deref(doc, o))
1132                    .and_then(|o| o.as_dict().ok())
1133                    .unwrap_or(res);
1134                let state = TextState {
1135                    tc,
1136                    tw,
1137                    th,
1138                    tl,
1139                    trise,
1140                    fsize,
1141                };
1142                run_content(
1143                    doc,
1144                    form_res,
1145                    &form_content,
1146                    form_mat.then(ctm),
1147                    state,
1148                    depth + 1,
1149                    caches,
1150                    out,
1151                );
1152            }
1153            _ => {}
1154        }
1155    }
1156}
1157
1158#[allow(clippy::too_many_arguments)]
1159fn show_text(
1160    font: &Font,
1161    bytes: &[u8],
1162    fsize: f64,
1163    tc: f64,
1164    tw: f64,
1165    th: f64,
1166    trise: f64,
1167    tm: &mut Mat,
1168    ctm: Mat,
1169    out: &mut Vec<Glyph>,
1170) {
1171    for code in codes(font, bytes) {
1172        let (text, w) = font.decode_code(code);
1173        let w0 = w / 1000.0; // advance in text-space (em) units
1174                             // The glyph→user transform: scale glyph space by font size, then Tm, CTM.
1175        let scale = Mat {
1176            a: fsize * th,
1177            b: 0.0,
1178            c: 0.0,
1179            d: fsize,
1180            e: 0.0,
1181            f: trise,
1182        };
1183        let trm = scale.then(*tm).then(ctm);
1184        // Box in glyph space (1000-unit em): x 0..w, y descent..ascent.
1185        let (x0, y0) = trm.apply(0.0, font.descent / 1000.0);
1186        let (x1, _y1) = trm.apply(w0, font.descent / 1000.0);
1187        let (_x2, y2) = trm.apply(0.0, font.ascent / 1000.0);
1188        let (left, right) = (x0.min(x1), x0.max(x1));
1189        let (bot, top) = (y0.min(y2), y0.max(y2));
1190        if let Some(s) = text {
1191            // A run may map one code to multiple chars (ligature/fraction); share box.
1192            for ch in s.chars() {
1193                if ch != '\u{0}' {
1194                    out.push(Glyph {
1195                        ch,
1196                        l: left as f32,
1197                        b: bot as f32,
1198                        r: right as f32,
1199                        t: top as f32,
1200                        ll: left as f32,
1201                        lb: bot as f32,
1202                        lr: right as f32,
1203                        lt: top as f32,
1204                        font: font.hash,
1205                    });
1206                }
1207            }
1208        }
1209        // Advance the text matrix. Word spacing applies to single-byte code 32.
1210        let is_space = !font.two_byte && code == 32;
1211        let tx = (w0 * fsize + tc + if is_space { tw } else { 0.0 }) * th;
1212        *tm = Mat {
1213            a: 1.0,
1214            b: 0.0,
1215            c: 0.0,
1216            d: 1.0,
1217            e: tx,
1218            f: 0.0,
1219        }
1220        .then(*tm);
1221    }
1222}
1223
1224/// Build a simple font's code→char table from its `/Encoding`: the base
1225/// encoding (WinAnsi / MacRoman) plus any `/Differences` overrides (glyph names
1226/// resolved through a small Adobe-glyph-name subset).
1227fn simple_encoding_table(doc: &Document, fdict: &Dictionary) -> HashMap<u8, char> {
1228    let enc = fdict.get(b"Encoding").ok().and_then(|o| deref(doc, o));
1229    let base_name = match enc {
1230        Some(Object::Name(n)) => n.clone(),
1231        Some(Object::Dictionary(d)) => d
1232            .get(b"BaseEncoding")
1233            .ok()
1234            .and_then(|o| o.as_name().ok())
1235            .map(|n| n.to_vec())
1236            .unwrap_or_default(),
1237        _ => Vec::new(),
1238    };
1239    let mut m = if base_name == b"MacRomanEncoding" {
1240        macroman_table()
1241    } else {
1242        winansi_table()
1243    };
1244    // Apply /Differences: `code /glyphname /glyphname ... code ...`.
1245    if let Some(Object::Dictionary(d)) = enc {
1246        if let Some(Object::Array(diffs)) = d.get(b"Differences").ok().and_then(|o| deref(doc, o)) {
1247            let mut code = 0u8;
1248            for el in diffs {
1249                match el {
1250                    Object::Integer(i) => code = *i as u8,
1251                    Object::Name(name) => {
1252                        if let Some(ch) = glyph_name_to_char(name) {
1253                            m.insert(code, ch);
1254                        }
1255                        code = code.wrapping_add(1);
1256                    }
1257                    _ => {}
1258                }
1259            }
1260        }
1261    }
1262    m
1263}
1264
1265/// Resolve an Adobe glyph name to Unicode: `uniXXXX`, single ASCII letters, the
1266/// digit/punctuation names from the Adobe Glyph List, and common typographic
1267/// names. A `.suffix` (`one.taboldstyle`, `a.sc`) is stripped and the base name
1268/// retried — docling renders these as the base character.
1269fn glyph_name_to_char(name: &[u8]) -> Option<char> {
1270    let s = std::str::from_utf8(name).ok()?;
1271    if let Some(hex) = s.strip_prefix("uni") {
1272        if let Ok(cp) = u32::from_str_radix(hex.get(0..4)?, 16) {
1273            return char::from_u32(cp);
1274        }
1275    }
1276    // Single ASCII letter names (`A`, `m`) map to themselves.
1277    if s.len() == 1 {
1278        let b = s.as_bytes()[0];
1279        if b.is_ascii_alphabetic() {
1280            return Some(b as char);
1281        }
1282    }
1283    let resolved = match s {
1284        "space" => ' ',
1285        "exclam" => '!',
1286        "quotedbl" => '"',
1287        "numbersign" => '#',
1288        "dollar" => '$',
1289        "percent" => '%',
1290        "ampersand" => '&',
1291        "quotesingle" => '\'',
1292        "parenleft" => '(',
1293        "parenright" => ')',
1294        "asterisk" => '*',
1295        "plus" => '+',
1296        "comma" => ',',
1297        "hyphen" => '-',
1298        "period" => '.',
1299        "slash" => '/',
1300        "zero" => '0',
1301        "one" => '1',
1302        "two" => '2',
1303        "three" => '3',
1304        "four" => '4',
1305        "five" => '5',
1306        "six" => '6',
1307        "seven" => '7',
1308        "eight" => '8',
1309        "nine" => '9',
1310        "colon" => ':',
1311        "semicolon" => ';',
1312        "less" => '<',
1313        "equal" => '=',
1314        "greater" => '>',
1315        "question" => '?',
1316        "at" => '@',
1317        "bracketleft" => '[',
1318        "backslash" => '\\',
1319        "bracketright" => ']',
1320        "asciicircum" => '^',
1321        "underscore" => '_',
1322        "grave" => '`',
1323        "braceleft" => '{',
1324        "bar" => '|',
1325        "braceright" => '}',
1326        "asciitilde" => '~',
1327        "bullet" => '\u{2022}',
1328        "periodcentered" => '\u{00B7}',
1329        "endash" => '\u{2013}',
1330        "emdash" => '\u{2014}',
1331        "quoteright" => '\u{2019}',
1332        "quoteleft" => '\u{2018}',
1333        "quotedblleft" => '\u{201C}',
1334        "quotedblright" => '\u{201D}',
1335        "quotedblbase" => '\u{201E}',
1336        "quotesinglbase" => '\u{201A}',
1337        // Latin f-ligatures named in `/Differences` (e.g. 2305's body font). These
1338        // map to the presentation-form code points, which `decompose_ligatures`
1339        // then spells back out (`ff`→"ff") — without them the glyph decodes to
1340        // nothing and the sanitizer fills the gap with a space (`di erences`).
1341        "ff" => '\u{FB00}',
1342        "fi" => '\u{FB01}',
1343        "fl" => '\u{FB02}',
1344        "ffi" => '\u{FB03}',
1345        "ffl" => '\u{FB04}',
1346        "ft" => '\u{FB05}',
1347        "st" => '\u{FB06}',
1348        "degree" => '\u{00B0}',
1349        "trademark" => '\u{2122}',
1350        "registered" => '\u{00AE}',
1351        "copyright" => '\u{00A9}',
1352        "ellipsis" => '\u{2026}',
1353        "minus" => '\u{2212}',
1354        "fraction" => '\u{2044}',
1355        "nbspace" => '\u{00A0}',
1356        // Greek + math glyph names (standard Adobe Glyph List). Standard TeX math
1357        // fonts (CMMI/CMSY/…) name their glyphs this way in the embedded font
1358        // program's `/Encoding`; without these a `λ`/`≤` decodes to nothing and is
1359        // dropped from body text (`and λ set to 0.5` → `and set to 0.5`).
1360        "alpha" => '\u{03B1}',
1361        "beta" => '\u{03B2}',
1362        "gamma" => '\u{03B3}',
1363        "delta" => '\u{03B4}',
1364        "epsilon" | "epsilon1" => '\u{03B5}',
1365        "zeta" => '\u{03B6}',
1366        "eta" => '\u{03B7}',
1367        "theta" | "theta1" => '\u{03B8}',
1368        "iota" => '\u{03B9}',
1369        "kappa" => '\u{03BA}',
1370        "lambda" => '\u{03BB}',
1371        "mu" => '\u{03BC}',
1372        "nu" => '\u{03BD}',
1373        "xi" => '\u{03BE}',
1374        "omicron" => '\u{03BF}',
1375        "pi" | "pi1" => '\u{03C0}',
1376        "rho" | "rho1" => '\u{03C1}',
1377        "sigma" => '\u{03C3}',
1378        "sigma1" => '\u{03C2}',
1379        "tau" => '\u{03C4}',
1380        "upsilon" => '\u{03C5}',
1381        "phi" | "phi1" => '\u{03C6}',
1382        "chi" => '\u{03C7}',
1383        "psi" => '\u{03C8}',
1384        "omega" | "omega1" => '\u{03C9}',
1385        "Gamma" => '\u{0393}',
1386        "Delta" => '\u{0394}',
1387        "Theta" => '\u{0398}',
1388        "Lambda" => '\u{039B}',
1389        "Xi" => '\u{039E}',
1390        "Pi" => '\u{03A0}',
1391        "Sigma" => '\u{03A3}',
1392        "Upsilon" => '\u{03A5}',
1393        "Phi" => '\u{03A6}',
1394        "Psi" => '\u{03A8}',
1395        "Omega" => '\u{03A9}',
1396        "lessequal" => '\u{2264}',
1397        "greaterequal" => '\u{2265}',
1398        "notequal" => '\u{2260}',
1399        "approxequal" => '\u{2248}',
1400        "equivalence" => '\u{2261}',
1401        "element" => '\u{2208}',
1402        "plusminus" => '\u{00B1}',
1403        "multiply" => '\u{00D7}',
1404        "divide" => '\u{00F7}',
1405        "infinity" => '\u{221E}',
1406        "partialdiff" => '\u{2202}',
1407        "gradient" => '\u{2207}',
1408        "summation" => '\u{2211}',
1409        "product" => '\u{220F}',
1410        "integral" => '\u{222B}',
1411        "radical" => '\u{221A}',
1412        "proportional" => '\u{221D}',
1413        "arrowright" => '\u{2192}',
1414        "arrowleft" => '\u{2190}',
1415        "arrowup" => '\u{2191}',
1416        "arrowdown" => '\u{2193}',
1417        "arrowboth" => '\u{2194}',
1418        "arrowdblright" => '\u{21D2}',
1419        "logicaland" => '\u{2227}',
1420        "logicalor" => '\u{2228}',
1421        "intersection" => '\u{2229}',
1422        "union" => '\u{222A}',
1423        "similar" => '\u{223C}',
1424        "congruent" => '\u{2245}',
1425        "dotmath" => '\u{22C5}',
1426        "asteriskmath" => '\u{2217}',
1427        _ => {
1428            // Strip an AGL `.suffix` (oldstyle/small-cap variant) and retry.
1429            if let Some((base, _)) = s.split_once('.') {
1430                if !base.is_empty() {
1431                    return glyph_name_to_char(base.as_bytes());
1432                }
1433            }
1434            return None;
1435        }
1436    };
1437    Some(resolved)
1438}
1439
1440/// Minimal WinAnsiEncoding (Latin-1-ish) for simple fonts lacking ToUnicode.
1441fn winansi_table() -> HashMap<u8, char> {
1442    let mut m = HashMap::new();
1443    for b in 0x20u8..=0x7e {
1444        m.insert(b, b as char);
1445    }
1446    // High range: Windows-1252 printable points that differ from Latin-1.
1447    let extra: &[(u8, char)] = &[
1448        (0x91, '\u{2018}'),
1449        (0x92, '\u{2019}'),
1450        (0x93, '\u{201C}'),
1451        (0x94, '\u{201D}'),
1452        (0x95, '\u{2022}'),
1453        (0x96, '\u{2013}'),
1454        (0x97, '\u{2014}'),
1455        (0x85, '\u{2026}'),
1456        (0xA0, '\u{00A0}'),
1457    ];
1458    for &(b, c) in extra {
1459        m.insert(b, c);
1460    }
1461    for b in 0xA1u8..=0xFF {
1462        m.entry(b).or_insert(b as char);
1463    }
1464    m
1465}
1466
1467/// Minimal MacRomanEncoding: ASCII plus the high-range points our corpus hits
1468/// (notably 0xA5 = bullet, used as a list marker).
1469fn macroman_table() -> HashMap<u8, char> {
1470    let mut m = HashMap::new();
1471    for b in 0x20u8..=0x7e {
1472        m.insert(b, b as char);
1473    }
1474    let high: &[(u8, char)] = &[
1475        (0xA5, '\u{2022}'), // bullet
1476        (0xD0, '\u{2013}'), // endash
1477        (0xD1, '\u{2014}'), // emdash
1478        (0xD2, '\u{201C}'),
1479        (0xD3, '\u{201D}'),
1480        (0xD4, '\u{2018}'),
1481        (0xD5, '\u{2019}'),
1482        (0xCA, '\u{00A0}'),
1483        (0xC9, '\u{2026}'),
1484        (0xDE, '\u{FB01}'),
1485        (0xDF, '\u{FB02}'),
1486    ];
1487    for &(b, c) in high {
1488        m.insert(b, c);
1489    }
1490    m
1491}