Skip to main content

docling_pdf/
textparse.rs

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