Skip to main content

docling_pdf/
textparse.rs

1//! Pure-Rust PDF text extraction (replacing pdfium's glyph layer).
2//!
3//! pdfium reports *rendered* glyph boxes, which diverge from docling's
4//! `docling-parse` C++ parser at exactly the points that drive conformance:
5//! generated spaces get a zero-width box, combining diacritics get a real-width
6//! box, and ligature/fraction glyphs land at different x. This module instead
7//! reconstructs each glyph's box from the **font's own advance widths** and the
8//! PDF text/graphics matrices — the same information docling-parse uses — so a
9//! space is as wide as the font says and a combining mark has zero advance.
10//!
11//! The output is the same [`Glyph`] stream pdfium produces (native PDF
12//! coordinates, y-up), fed straight into the existing docling-parse line
13//! sanitizer ([`crate::dp_lines`]). Only the digital text layer is handled here;
14//! pages without one still fall back to OCR upstream.
15
16use std::collections::HashMap;
17use std::rc::Rc;
18
19use lopdf::{Dictionary, Document, Object};
20
21use crate::pdfium_backend::Glyph;
22
23/// Per-document caches for the content-stream interpreter. Fonts are indirect
24/// objects shared by many pages, but were fully re-parsed — ToUnicode CMap
25/// decompression + tokenization, embedded Type1 program scan, width tables —
26/// for **every page and every Form XObject invocation**; decoded form content
27/// streams were likewise re-inflated on every `Do`. Cached per document,
28/// keyed by the referenced object id (fonts also by resource name, which
29/// feeds the docling-parse font hash). Inline (non-reference) dicts are rare
30/// and stay uncached.
31#[derive(Default)]
32struct DocCaches {
33    fonts: HashMap<(lopdf::ObjectId, Vec<u8>), Rc<Font>>,
34    forms: HashMap<lopdf::ObjectId, Rc<lopdf::content::Content>>,
35}
36
37/// A 2×3 affine matrix `[a b c d e f]`: maps `(x,y)` → `(a·x+c·y+e, b·x+d·y+f)`.
38#[derive(Clone, Copy)]
39struct Mat {
40    a: f64,
41    b: f64,
42    c: f64,
43    d: f64,
44    e: f64,
45    f: f64,
46}
47
48impl Mat {
49    const ID: Mat = Mat {
50        a: 1.0,
51        b: 0.0,
52        c: 0.0,
53        d: 1.0,
54        e: 0.0,
55        f: 0.0,
56    };
57
58    /// `self ∘ m`: the matrix that applies `self` first, then `m`.
59    fn then(self, m: Mat) -> Mat {
60        Mat {
61            a: self.a * m.a + self.b * m.c,
62            b: self.a * m.b + self.b * m.d,
63            c: self.c * m.a + self.d * m.c,
64            d: self.c * m.b + self.d * m.d,
65            e: self.e * m.a + self.f * m.c + m.e,
66            f: self.e * m.b + self.f * m.d + m.f,
67        }
68    }
69
70    fn apply(self, x: f64, y: f64) -> (f64, f64) {
71        (
72            self.a * x + self.c * y + self.e,
73            self.b * x + self.d * y + self.f,
74        )
75    }
76}
77
78/// A parsed font: how to turn raw string bytes into (unicode, advance) pairs.
79struct Font {
80    /// 2-byte codes (Type0 / Identity-H) vs 1-byte (simple fonts).
81    two_byte: bool,
82    /// code → Unicode string (from ToUnicode; may be multi-char, e.g. ligatures).
83    to_unicode: HashMap<u32, String>,
84    /// code → glyph advance, in 1000-unit glyph space.
85    widths: HashMap<u32, f64>,
86    default_width: f64,
87    /// 1-byte fallback decoding when ToUnicode lacks a code (WinAnsi-ish).
88    simple_encoding: Option<HashMap<u8, char>>,
89    /// code → raw `/Differences` glyph name, for GID-style names (`g115`) that
90    /// have no Unicode mapping. docling-parse emits these verbatim as `/g115`
91    /// (see the redp5110 bulleted list); matching it keeps no text skipped.
92    fallback_names: HashMap<u8, String>,
93    /// code → char from the embedded Type1 font program's own `/Encoding` vector,
94    /// used only as a last resort for glyphs the base encoding leaves unmapped
95    /// (standard TeX math fonts: `λ`, `≤`, …).
96    program_encoding: HashMap<u8, char>,
97    ascent: f64,
98    descent: f64,
99    hash: u64,
100}
101
102impl Font {
103    fn decode_code(&self, code: u32) -> (Option<String>, f64) {
104        let w = self
105            .widths
106            .get(&code)
107            .copied()
108            .unwrap_or(self.default_width);
109        if let Some(s) = self.to_unicode.get(&code) {
110            return (Some(decompose_ligatures(s)), w);
111        }
112        if !self.two_byte {
113            // A GID-style `/Differences` name (no Unicode) overrides the base
114            // encoding, matching docling's verbatim `/g115` fallback.
115            if let Some(name) = self.fallback_names.get(&(code as u8)) {
116                return (Some(format!("/{name}")), w);
117            }
118            if let Some(enc) = &self.simple_encoding {
119                if let Some(&ch) = enc.get(&(code as u8)) {
120                    return (Some(decompose_ligatures(&ch.to_string())), w);
121                }
122            }
123            // Last resort: the embedded Type1 font program's own `/Encoding`
124            // vector (`dup N /glyphname put`). Standard TeX math fonts (CMMI, CMSY,
125            // …) ship no PDF `/Encoding` and no ToUnicode, so a glyph like `λ`
126            // (CMMI code 21 → `/lambda`) or `≤` (CMSY code 20 → `/lessequal`) has
127            // no other mapping and would otherwise be silently dropped. docling
128            // recovers these from the same font program. This only fills codes the
129            // base encoding left unmapped, so it never changes an existing decode.
130            if let Some(&ch) = self.program_encoding.get(&(code as u8)) {
131                return (Some(decompose_ligatures(&ch.to_string())), w);
132            }
133        }
134        (None, w)
135    }
136}
137
138/// Spell out Latin presentation-form ligatures (`fi`→`fi`, `ffi`→`ffi`, …) the way
139/// docling does, so `configuration`/`difficult` don't keep the ligature glyph.
140/// The chars share the ligature's box, so the line sanitizer recomposes them.
141fn decompose_ligatures(s: &str) -> String {
142    if !s.chars().any(|c| ('\u{FB00}'..='\u{FB06}').contains(&c)) {
143        return s.to_string();
144    }
145    s.chars()
146        .map(|c| {
147            match c {
148                '\u{FB00}' => "ff",
149                '\u{FB01}' => "fi",
150                '\u{FB02}' => "fl",
151                '\u{FB03}' => "ffi",
152                '\u{FB04}' => "ffl",
153                '\u{FB05}' => "ft",
154                '\u{FB06}' => "st",
155                _ => return c.to_string(),
156            }
157            .to_string()
158        })
159        .collect()
160}
161
162fn hash_name(name: &[u8]) -> u64 {
163    use std::hash::{Hash, Hasher};
164    let mut h = std::collections::hash_map::DefaultHasher::new();
165    name.hash(&mut h);
166    h.finish()
167}
168
169/// Resolve a possibly-indirect object to a dictionary.
170fn as_dict<'a>(doc: &'a Document, obj: &'a Object) -> Option<&'a Dictionary> {
171    match obj {
172        Object::Dictionary(d) => Some(d),
173        Object::Reference(id) => doc.get_object(*id).ok().and_then(|o| o.as_dict().ok()),
174        _ => None,
175    }
176}
177
178fn deref<'a>(doc: &'a Document, obj: &'a Object) -> Option<&'a Object> {
179    match obj {
180        Object::Reference(id) => doc.get_object(*id).ok(),
181        other => Some(other),
182    }
183}
184
185/// Parse one font dictionary into a [`Font`].
186fn parse_font(doc: &Document, name: &[u8], fdict: &Dictionary) -> Font {
187    let subtype: &[u8] = fdict
188        .get(b"Subtype")
189        .ok()
190        .and_then(|o| o.as_name().ok())
191        .unwrap_or(&[]);
192    let two_byte = subtype == b"Type0".as_slice();
193
194    let to_unicode = fdict
195        .get(b"ToUnicode")
196        .ok()
197        .and_then(|o| deref(doc, o))
198        .and_then(|o| o.as_stream().ok())
199        .and_then(|s| s.decompressed_content().ok())
200        .map(|data| parse_tounicode(&data))
201        .unwrap_or_default();
202
203    let (widths, default_width) = if two_byte {
204        cid_widths(doc, fdict)
205    } else {
206        simple_widths(doc, fdict)
207    };
208
209    let simple_encoding = if two_byte {
210        None
211    } else {
212        Some(simple_encoding_table(doc, fdict))
213    };
214    let fallback_names = if two_byte {
215        HashMap::new()
216    } else {
217        differences_gid_names(doc, fdict)
218    };
219    let program_encoding = if two_byte {
220        HashMap::new()
221    } else {
222        type1_program_encoding(doc, fdict)
223    };
224
225    let (ascent, descent) = font_ascent_descent(doc, fdict, two_byte);
226
227    Font {
228        two_byte,
229        to_unicode,
230        widths,
231        default_width,
232        simple_encoding,
233        fallback_names,
234        program_encoding,
235        ascent,
236        descent,
237        hash: hash_name(name),
238    }
239}
240
241/// Collect `/Differences` entries whose glyph name is a GID placeholder
242/// (`g115`, `cid42`, `glyph7`, `index9`) with no Unicode mapping. docling-parse
243/// emits such glyphs as the literal name `/g115`; mapping them here keeps the
244/// text from being silently dropped (subsetted fonts with no ToUnicode). The
245/// GID-name restriction keeps real Adobe glyph names on the normal path so this
246/// never invents garbage on the clean files.
247fn differences_gid_names(doc: &Document, fdict: &Dictionary) -> HashMap<u8, String> {
248    let mut map = HashMap::new();
249    let Some(Object::Dictionary(enc)) = fdict.get(b"Encoding").ok().and_then(|o| deref(doc, o))
250    else {
251        return map;
252    };
253    let Some(Object::Array(diffs)) = enc.get(b"Differences").ok().and_then(|o| deref(doc, o))
254    else {
255        return map;
256    };
257    let mut code = 0u8;
258    for el in diffs {
259        match el {
260            Object::Integer(i) => code = *i as u8,
261            Object::Name(name) => {
262                if glyph_name_to_char(name).is_none() && is_gid_name(name) {
263                    map.insert(code, String::from_utf8_lossy(name).into_owned());
264                }
265                code = code.wrapping_add(1);
266            }
267            _ => {}
268        }
269    }
270    map
271}
272
273/// Parse the embedded Type1 font program's built-in `/Encoding` vector
274/// (`dup <code> /<glyphname> put` entries in the clear-text header before
275/// `eexec`) into `code → char`. This is how docling recovers glyphs from
276/// standard TeX math fonts (CMMI/CMSY/…) that carry no PDF `/Encoding` and no
277/// ToUnicode — e.g. CMMI's `dup 21 /lambda` or CMSY's `dup 20 /lessequal`.
278/// Only `FontFile` (Type1) is parsed; CFF (`FontFile3`) and TrueType
279/// (`FontFile2`) store their encoding in a binary table and are left alone.
280fn type1_program_encoding(doc: &Document, fdict: &Dictionary) -> HashMap<u8, char> {
281    let mut map = HashMap::new();
282    let Some(desc) = fdict
283        .get(b"FontDescriptor")
284        .ok()
285        .and_then(|o| deref(doc, o))
286        .and_then(|o| o.as_dict().ok())
287    else {
288        return map;
289    };
290    let Some(data) = desc
291        .get(b"FontFile")
292        .ok()
293        .and_then(|o| deref(doc, o))
294        .and_then(|o| o.as_stream().ok())
295        .and_then(|s| s.decompressed_content().ok())
296    else {
297        return map;
298    };
299    // The clear-text header (PostScript) ends at `eexec`; the rest is encrypted.
300    let head_end = data
301        .windows(5)
302        .position(|w| w == b"eexec")
303        .unwrap_or(data.len());
304    let head = String::from_utf8_lossy(&data[..head_end]);
305    // Scan for `dup <code> /<name> put` tokens.
306    let toks: Vec<&str> = head.split_whitespace().collect();
307    for w in toks.windows(4) {
308        if w[0] == "dup" && w[3] == "put" {
309            if let (Ok(code), Some(name)) = (w[1].parse::<u32>(), w[2].strip_prefix('/')) {
310                if code <= 255 {
311                    if let Some(ch) = glyph_name_to_char(name.as_bytes()) {
312                        map.insert(code as u8, ch);
313                    }
314                }
315            }
316        }
317    }
318    map
319}
320
321/// A glyph name that is a synthetic placeholder, not a real Adobe name:
322/// `g115`, `cid42`, `glyph7`, `index9`, `G12`, or a short-prefix code name like
323/// `SM590000` (IBM BookMaster). These carry no Unicode meaning, and docling-parse
324/// emits them verbatim (`/SM590000`). `afii####` / `uni####` are real Adobe names
325/// and excluded. The restriction keeps genuine glyph names on the Unicode path.
326fn is_gid_name(name: &[u8]) -> bool {
327    let Ok(s) = std::str::from_utf8(name) else {
328        return false;
329    };
330    if s.starts_with("afii") || s.starts_with("uni") {
331        return false;
332    }
333    for prefix in ["g", "G", "cid", "CID", "glyph", "index"] {
334        if let Some(rest) = s.strip_prefix(prefix) {
335            if !rest.is_empty() && rest.bytes().all(|b| b.is_ascii_digit()) {
336                return true;
337            }
338        }
339    }
340    // Short alpha prefix (≤3 letters) followed by a run of ≥3 digits — synthetic
341    // code names like `SM590000`, distinct from real Adobe names (whole words or
342    // letter+`.suffix` variants).
343    let alpha = s.bytes().take_while(|b| b.is_ascii_alphabetic()).count();
344    let digits = s.len() - alpha;
345    (1..=3).contains(&alpha)
346        && digits >= 3
347        && s.as_bytes()[alpha..].iter().all(|b| b.is_ascii_digit())
348}
349
350fn font_ascent_descent(doc: &Document, fdict: &Dictionary, two_byte: bool) -> (f64, f64) {
351    // For Type0, the descriptor lives on the descendant CIDFont.
352    let descr_owner = if two_byte {
353        fdict
354            .get(b"DescendantFonts")
355            .ok()
356            .and_then(|o| deref(doc, o))
357            .and_then(|o| match o {
358                Object::Array(a) => a.first(),
359                _ => None,
360            })
361            .and_then(|o| as_dict(doc, o))
362    } else {
363        Some(fdict)
364    };
365    let fd = descr_owner
366        .and_then(|d| d.get(b"FontDescriptor").ok())
367        .and_then(|o| as_dict(doc, o));
368    let asc = fd
369        .and_then(|d| d.get(b"Ascent").ok())
370        .and_then(|o| {
371            o.as_float()
372                .ok()
373                .or_else(|| o.as_i64().ok().map(|i| i as f32))
374        })
375        .unwrap_or(750.0) as f64;
376    let desc = fd
377        .and_then(|d| d.get(b"Descent").ok())
378        .and_then(|o| {
379            o.as_float()
380                .ok()
381                .or_else(|| o.as_i64().ok().map(|i| i as f32))
382        })
383        .unwrap_or(-250.0) as f64;
384    // Some subsetted fonts carry a degenerate FontDescriptor (`/Ascent 0
385    // /Descent 0`) — the real metrics live in the font program. That collapses
386    // the loose box to zero height, so the line cells get zero area and the
387    // layout's region/text assignment drops them (2305's References list lost
388    // every prose line, keeping only the URLs). Fall back to typical text metrics
389    // so the box has height.
390    if asc - desc <= 1.0 {
391        return (750.0, -250.0);
392    }
393    (asc, desc)
394}
395
396/// Simple-font widths: `/FirstChar` + `/Widths` array, `/MissingWidth` default.
397fn simple_widths(doc: &Document, fdict: &Dictionary) -> (HashMap<u32, f64>, f64) {
398    let mut map = HashMap::new();
399    let first = fdict
400        .get(b"FirstChar")
401        .ok()
402        .and_then(|o| o.as_i64().ok())
403        .unwrap_or(0) as u32;
404    if let Some(Object::Array(arr)) = fdict.get(b"Widths").ok().and_then(|o| deref(doc, o)) {
405        for (i, w) in arr.iter().enumerate() {
406            if let Some(w) = num(w) {
407                map.insert(first + i as u32, w);
408            }
409        }
410    }
411    let dw = fdict
412        .get(b"FontDescriptor")
413        .ok()
414        .and_then(|o| as_dict(doc, o))
415        .and_then(|d| d.get(b"MissingWidth").ok())
416        .and_then(num)
417        .unwrap_or(0.0);
418    (map, dw)
419}
420
421/// CIDFont widths: the `/W` array on the descendant font (`/DW` default = 1000).
422fn cid_widths(doc: &Document, fdict: &Dictionary) -> (HashMap<u32, f64>, f64) {
423    let mut map = HashMap::new();
424    let Some(desc) = fdict
425        .get(b"DescendantFonts")
426        .ok()
427        .and_then(|o| deref(doc, o))
428        .and_then(|o| match o {
429            Object::Array(a) => a.first(),
430            _ => None,
431        })
432        .and_then(|o| as_dict(doc, o))
433    else {
434        return (map, 1000.0);
435    };
436    let dw = desc.get(b"DW").ok().and_then(num).unwrap_or(1000.0);
437    if let Some(Object::Array(w)) = desc.get(b"W").ok().and_then(|o| deref(doc, o)) {
438        let mut i = 0;
439        while i < w.len() {
440            let c = w.get(i).and_then(num);
441            match (c, w.get(i + 1)) {
442                // `c [w1 w2 ...]`: consecutive CIDs starting at c.
443                (Some(c), Some(Object::Array(list))) => {
444                    for (k, wv) in list.iter().enumerate() {
445                        if let Some(wv) = num(wv) {
446                            map.insert(c as u32 + k as u32, wv);
447                        }
448                    }
449                    i += 2;
450                }
451                // `c_first c_last w`: a run all of width w.
452                (Some(c1), Some(o2)) => {
453                    if let (Some(c2), Some(wv)) = (num(o2), w.get(i + 2).and_then(num)) {
454                        for cid in c1 as u32..=c2 as u32 {
455                            map.insert(cid, wv);
456                        }
457                    }
458                    i += 3;
459                }
460                _ => break,
461            }
462        }
463    }
464    (map, dw)
465}
466
467fn num(o: &Object) -> Option<f64> {
468    match o {
469        Object::Integer(i) => Some(*i as f64),
470        Object::Real(r) => Some(*r as f64),
471        _ => None,
472    }
473}
474
475/// Parse a ToUnicode CMap's `bfchar` / `bfrange` sections into code→string.
476fn parse_tounicode(data: &[u8]) -> HashMap<u32, String> {
477    let text = String::from_utf8_lossy(data);
478    let mut map = HashMap::new();
479    let hex = |s: &str| -> Option<Vec<u16>> {
480        let s = s.trim();
481        if !s.starts_with('<') || !s.ends_with('>') {
482            return None;
483        }
484        let h = &s[1..s.len() - 1];
485        let bytes: Vec<u8> = (0..h.len())
486            .step_by(2)
487            .filter_map(|i| u8::from_str_radix(h.get(i..i + 2)?, 16).ok())
488            .collect();
489        Some(
490            bytes
491                .chunks(2)
492                .map(|c| {
493                    if c.len() == 2 {
494                        u16::from_be_bytes([c[0], c[1]])
495                    } else {
496                        c[0] as u16
497                    }
498                })
499                .collect(),
500        )
501    };
502    let u16s_to_string = |u: &[u16]| String::from_utf16_lossy(u);
503    let code_of = |u: &[u16]| u.iter().fold(0u32, |acc, &x| (acc << 16) | x as u32);
504
505    // Tokenize by structure, not whitespace: CMap hex groups are often written
506    // back-to-back with no separators (`<21><21><0054>`), so scan for `<…>`
507    // groups, `[`/`]` brackets, and bareword keywords.
508    let tokens: Vec<String> = {
509        let bytes = text.as_bytes();
510        let mut toks = Vec::new();
511        let mut i = 0;
512        while i < bytes.len() {
513            let c = bytes[i];
514            if c.is_ascii_whitespace() {
515                i += 1;
516            } else if c == b'<' {
517                let start = i;
518                while i < bytes.len() && bytes[i] != b'>' {
519                    i += 1;
520                }
521                i += 1; // include '>'
522                toks.push(String::from_utf8_lossy(&bytes[start..i.min(bytes.len())]).into_owned());
523            } else if c == b'[' || c == b']' {
524                toks.push((c as char).to_string());
525                i += 1;
526            } else {
527                let start = i;
528                while i < bytes.len()
529                    && !bytes[i].is_ascii_whitespace()
530                    && bytes[i] != b'<'
531                    && bytes[i] != b'['
532                    && bytes[i] != b']'
533                {
534                    i += 1;
535                }
536                toks.push(String::from_utf8_lossy(&bytes[start..i]).into_owned());
537            }
538        }
539        toks
540    };
541    let tokens: Vec<&str> = tokens.iter().map(|s| s.as_str()).collect();
542    let mut i = 0;
543    while i < tokens.len() {
544        match tokens[i] {
545            "beginbfchar" => {
546                i += 1;
547                while i + 1 < tokens.len() && tokens[i] != "endbfchar" {
548                    if let (Some(src), Some(dst)) = (hex(tokens[i]), hex(tokens[i + 1])) {
549                        map.insert(code_of(&src), u16s_to_string(&dst));
550                    }
551                    i += 2;
552                }
553            }
554            "beginbfrange" => {
555                i += 1;
556                while i + 2 < tokens.len() && tokens[i] != "endbfrange" {
557                    let (Some(lo), Some(hi)) = (hex(tokens[i]), hex(tokens[i + 1])) else {
558                        i += 1;
559                        continue;
560                    };
561                    let lo = code_of(&lo);
562                    let hi = code_of(&hi);
563                    if tokens[i + 2] == "[" {
564                        // `<lo> <hi> [ <d0> <d1> ... ]`: one dst per code in the range.
565                        let mut j = i + 3;
566                        let mut code = lo;
567                        while j < tokens.len() && tokens[j] != "]" {
568                            if let Some(dst) = hex(tokens[j]) {
569                                map.insert(code, u16s_to_string(&dst));
570                            }
571                            code += 1;
572                            j += 1;
573                        }
574                        i = j + 1;
575                    } else if let Some(dst) = hex(tokens[i + 2]) {
576                        // `<lo> <hi> <dst>`: consecutive Unicode from a base.
577                        let base = code_of(&dst);
578                        for (k, code) in (lo..=hi).enumerate() {
579                            if let Some(ch) = char::from_u32(base + k as u32) {
580                                map.insert(code, ch.to_string());
581                            }
582                        }
583                        i += 3;
584                    } else {
585                        i += 1;
586                    }
587                }
588            }
589            _ => i += 1,
590        }
591    }
592    map
593}
594
595/// Decode a PDF string literal in a Tj/TJ operand into raw code units.
596fn codes(font: &Font, bytes: &[u8]) -> Vec<u32> {
597    if font.two_byte {
598        bytes
599            .chunks(2)
600            .map(|c| {
601                if c.len() == 2 {
602                    ((c[0] as u32) << 8) | c[1] as u32
603                } else {
604                    c[0] as u32
605                }
606            })
607            .collect()
608    } else {
609        bytes.iter().map(|&b| b as u32).collect()
610    }
611}
612
613/// Page size (width, height) in PDF points from the MediaBox.
614fn page_size(doc: &Document, page_id: lopdf::ObjectId) -> (f32, f32) {
615    let mb = doc
616        .get_object(page_id)
617        .ok()
618        .and_then(|o| o.as_dict().ok())
619        .and_then(|d| {
620            // MediaBox may be inherited; lopdf resolves via get_page... fall back to a guess.
621            d.get(b"MediaBox").ok().cloned()
622        })
623        .or_else(|| {
624            doc.get_dictionary(page_id)
625                .ok()
626                .and_then(|d| d.get(b"MediaBox").ok().cloned())
627        });
628    if let Some(Object::Array(a)) = mb {
629        let v: Vec<f32> = a.iter().filter_map(|o| num(o).map(|x| x as f32)).collect();
630        if v.len() == 4 {
631            return ((v[2] - v[0]).abs(), (v[3] - v[1]).abs());
632        }
633    }
634    (612.0, 792.0)
635}
636
637/// Localize where a page's text is lost, for the `text_layer` diagnostic.
638/// Extraction can come up empty at three different points — no content stream
639/// reached the parser, the stream did not decode into operators, or it ran but
640/// produced no glyphs (fonts/encodings) — and from the outside all three look
641/// the same. Report them per page.
642pub fn content_diagnosis(bytes: &[u8]) -> String {
643    let Some(doc) = load_document(bytes) else {
644        return "document does not load".into();
645    };
646    let mut pages: Vec<_> = doc.get_pages().into_iter().collect();
647    pages.sort_by_key(|(n, _)| *n);
648    let mut out = String::new();
649    let mut caches = DocCaches::default();
650    for (n, pid) in pages.into_iter().take(4) {
651        let content_bytes = doc.get_page_content(pid);
652        let ops = lopdf::content::Content::decode(&content_bytes)
653            .map(|c| c.operations.len())
654            .ok();
655        let res = page_res(&doc, pid);
656        let fonts = res.map(|r| fonts_from_res(&doc, r, &mut caches).len());
657        let glyphs = page_glyphs_cached(&doc, pid, &mut caches).len();
658        out.push_str(&format!(
659            "\n   page {n}: content {} B, ops {}, resources {}, fonts {}, glyphs {}",
660            content_bytes.len(),
661            ops.map_or("UNDECODABLE".to_string(), |n| n.to_string()),
662            if res.is_some() { "ok" } else { "MISSING" },
663            fonts.map_or("-".to_string(), |n| n.to_string()),
664            glyphs,
665        ));
666    }
667    out
668}
669
670/// Is this "text layer" a vestige rather than the document's text?
671///
672/// Scanned forms often carry a handful of typed-in strings — a date filled
673/// into three form fields, say — on top of pages that are otherwise images.
674/// Treating that as a real text layer is the worst of both worlds: the text
675/// path proudly extracts thirteen characters, and no OCR ever runs on the
676/// letter the pages actually show. The reported form did exactly this (3
677/// lines, 13 chars, 3 pages).
678///
679/// The rule is deliberately tight so genuinely sparse *digital* documents are
680/// not misrouted into OCR: only a document averaging at most one line per page
681/// **and** totalling fewer than 32 characters is called vestigial.
682pub fn text_layer_is_vestigial(pages: &[crate::pdfium_backend::PdfPage]) -> bool {
683    let lines: usize = pages.iter().map(|p| p.cells.len()).sum();
684    if lines == 0 {
685        return true;
686    }
687    let chars: usize = pages
688        .iter()
689        .flat_map(|p| &p.cells)
690        .map(|c| c.text.chars().count())
691        .sum();
692    lines <= pages.len() && chars < 32
693}
694
695/// Why the cross-reference repair did or did not fire, for the `text_layer`
696/// diagnostic. A PDF that will not load is indistinguishable from a scan in
697/// production (both convert to nothing), so the reason has to be askable.
698pub fn xref_repair_status(bytes: &[u8]) -> String {
699    if Document::load_mem(bytes).is_ok() {
700        return "loads unaided; no repair needed".into();
701    }
702    match pad_short_xref_entries(bytes) {
703        Ok(fixed) => match Document::load_mem(&fixed) {
704            Ok(_) => "repaired: cross-reference entries padded to 20 bytes".into(),
705            Err(e) => format!("padded the entries, but it still will not load: {e}"),
706        },
707        Err(why) => format!("repair declined — {why}"),
708    }
709}
710
711/// Load a PDF, repairing the one malformation that otherwise costs us the whole
712/// document: **19-byte cross-reference entries**.
713///
714/// The spec fixes an xref entry at 20 bytes — `nnnnnnnnnn ggggg n` plus a
715/// *two*-byte EOL. Some generators (an Austrian telecom's invoices, for one)
716/// emit a bare LF instead, making each entry 19 bytes. lopdf rejects the file
717/// outright (`invalid file trailer`) where pdfium reads it happily, so a
718/// perfectly good text layer looked to the browser exactly like a scan and cost
719/// ten seconds of OCR.
720///
721/// Padding is only attempted when it cannot move anything the xref points at:
722/// a single `xref` section that begins after the last object. The repair then
723/// has to prove itself — the padded bytes are used only if they load — so a
724/// mis-repair degrades to today's behaviour rather than to silent garbage.
725fn load_document(bytes: &[u8]) -> Option<Document> {
726    // Try progressively more repair, and accept a candidate only once the pages
727    // actually carry content — a document whose streams were dropped still
728    // "loads", so loading alone is not evidence the repair helped. A
729    // well-formed file returns on the first attempt and pays for nothing.
730    let mut fallback = None;
731    if let Some(doc) = best_effort_load(bytes, &mut fallback) {
732        return Some(doc);
733    }
734    let xref_fixed = pad_short_xref_entries(bytes).ok();
735    if let Some(fixed) = &xref_fixed {
736        if let Some(doc) = best_effort_load(fixed, &mut fallback) {
737            return Some(doc);
738        }
739    }
740    // Both defects can coexist, and the second only becomes visible once the
741    // first is repaired, so build on whatever the previous step produced.
742    let lengths_fixed = fix_stream_lengths(xref_fixed.as_deref().unwrap_or(bytes));
743    if let Some(doc) = best_effort_load(&lengths_fixed, &mut fallback) {
744        return Some(doc);
745    }
746    fallback
747}
748
749/// Load `data`, returning it only when its pages carry content; a document that
750/// merely parses is remembered as the fallback for when nothing does better.
751fn best_effort_load(data: &[u8], fallback: &mut Option<Document>) -> Option<Document> {
752    match Document::load_mem(data) {
753        Ok(doc) if has_page_content(&doc) => Some(doc),
754        Ok(doc) => {
755            fallback.get_or_insert(doc);
756            None
757        }
758        Err(_) => None,
759    }
760}
761
762/// Does any page actually hand us a content stream? A document whose streams
763/// were dropped still parses — it simply has nothing to read — so this is what
764/// tells a successful repair from a pointless one.
765fn has_page_content(doc: &Document) -> bool {
766    doc.get_pages()
767        .into_values()
768        .take(4)
769        .any(|pid| !doc.get_page_content(pid).is_empty())
770}
771
772/// Correct `/Length` values that disagree with where `endstream` actually is.
773///
774/// The same generator that writes short xref entries also overstates its
775/// content-stream lengths by a byte or two. lopdf trusts `/Length`, reads past
776/// the data, fails to find `endstream` there and drops the stream — the object
777/// comes back as a bare dictionary, so the page has no content at all and the
778/// document looks like a scan. pdfium instead trusts `endstream`, which is what
779/// this does.
780///
781/// The rewrite is length-preserving: the corrected number is written over the
782/// old digits and padded with spaces, so every byte offset in the file — and
783/// therefore the whole cross-reference table — stays valid.
784fn fix_stream_lengths(bytes: &[u8]) -> Vec<u8> {
785    let mut out = bytes.to_vec();
786    let mut i = 0;
787    while let Some(rel) = find(&out[i..], b"stream") {
788        let kw = i + rel;
789        i = kw + 6;
790        // Skip `endstream` (the keyword we are measuring *to*).
791        if kw >= 3 && &out[kw - 3..kw] == b"end" {
792            continue;
793        }
794        // The stream data starts after the EOL that follows the keyword.
795        let mut data = kw + 6;
796        if out.get(data..data + 2) == Some(b"\r\n".as_slice()) {
797            data += 2;
798        } else if matches!(out.get(data), Some(b'\n' | b'\r')) {
799            data += 1;
800        }
801        let Some(end) = find(&out[data..], b"endstream").map(|r| data + r) else {
802            continue;
803        };
804        // `/Length <digits>` in the dictionary just before the keyword.
805        let dict_start = out[..kw].iter().rposition(|&c| c == b'<').unwrap_or(0);
806        let Some(lrel) = find(&out[dict_start..kw], b"/Length") else {
807            continue;
808        };
809        let mut d = dict_start + lrel + 7;
810        while matches!(out.get(d), Some(b' ')) {
811            d += 1;
812        }
813        let digits = out[d..].iter().take_while(|c| c.is_ascii_digit()).count();
814        if digits == 0 {
815            continue;
816        }
817        let declared: usize = match std::str::from_utf8(&out[d..d + digits])
818            .ok()
819            .and_then(|s| s.parse().ok())
820        {
821            Some(v) => v,
822            None => continue,
823        };
824        let actual = end - data;
825        // Only shrink, and only when the new value fits the space the old one
826        // occupied — growing the number would move every following byte.
827        let replacement = actual.to_string();
828        if actual == declared || replacement.len() > digits {
829            continue;
830        }
831        out[d..d + digits].fill(b' ');
832        out[d..d + replacement.len()].copy_from_slice(replacement.as_bytes());
833    }
834    out
835}
836
837fn find(haystack: &[u8], needle: &[u8]) -> Option<usize> {
838    haystack.windows(needle.len()).position(|w| w == needle)
839}
840
841/// Rewrite a classic cross-reference table's entries to the spec's 20 bytes,
842/// or `None` when the file's shape makes that unsafe (see [`load_document`]).
843fn pad_short_xref_entries(bytes: &[u8]) -> Result<Vec<u8>, &'static str> {
844    // Exactly one xref section, and it must start after every object, so that
845    // growing it shifts nothing the table's offsets refer to.
846    let is_boundary = |i: usize| i == 0 || matches!(bytes[i - 1], b'\n' | b'\r');
847    let mut starts = (0..bytes.len().saturating_sub(4))
848        .filter(|&i| &bytes[i..i + 4] == b"xref" && is_boundary(i));
849    let xref_at = starts
850        .next()
851        .ok_or("no classic `xref` section (an xref stream?)")?;
852    if starts.next().is_some() {
853        return Err("more than one xref section (incremental update)");
854    }
855    let last_obj = bytes
856        .windows(3)
857        .rposition(|w| w == b"obj")
858        .ok_or("no objects found")?;
859    if last_obj > xref_at {
860        return Err("an object follows the xref — padding would move it");
861    }
862
863    let mut out = bytes[..xref_at].to_vec();
864    out.extend_from_slice(b"xref\n");
865    let mut i = xref_at + 4;
866    let skip_ws = |i: &mut usize| {
867        while matches!(bytes.get(*i), Some(b'\r' | b'\n' | b' ')) {
868            *i += 1;
869        }
870    };
871    loop {
872        skip_ws(&mut i);
873        // Either the next subsection header ("first count") or the trailer.
874        if bytes[i..].starts_with(b"trailer") {
875            out.extend_from_slice(&bytes[i..]);
876            return Ok(out);
877        }
878        let header_end = i + bytes[i..]
879            .iter()
880            .position(|c| matches!(c, b'\n' | b'\r'))
881            .ok_or("subsection header runs off the end")?;
882        let header = std::str::from_utf8(&bytes[i..header_end])
883            .map_err(|_| "subsection header is not text")?
884            .trim();
885        let mut parts = header.split_whitespace();
886        let count: usize = parts
887            .nth(1)
888            .and_then(|c| c.parse().ok())
889            .ok_or("unparseable subsection header")?;
890        if parts.next().is_some() || count == 0 {
891            return Err("unexpected subsection header shape");
892        }
893        out.extend_from_slice(header.as_bytes());
894        out.push(b'\n');
895        i = header_end;
896        for _ in 0..count {
897            skip_ws(&mut i);
898            // `nnnnnnnnnn ggggg n` — the 18 bytes before whatever EOL follows.
899            let entry = bytes.get(i..i + 18).ok_or("xref entry runs off the end")?;
900            let well_formed = entry[..10].iter().all(u8::is_ascii_digit)
901                && entry[10] == b' '
902                && entry[11..16].iter().all(u8::is_ascii_digit)
903                && entry[16] == b' '
904                && matches!(entry[17], b'n' | b'f');
905            if !well_formed {
906                return Err("xref entry is not `nnnnnnnnnn ggggg n`");
907            }
908            out.extend_from_slice(entry);
909            out.extend_from_slice(b" \n"); // the spec's 2-byte EOL -> 20 bytes
910            i += 18;
911        }
912    }
913}
914
915/// Debug: raw glyph stream `(ch, ll, lr, lb, lt)` (native coords) for page
916/// `index`, before the sanitizer. For comparing char cells to docling-parse.
917pub fn debug_glyphs(bytes: &[u8], index: usize) -> Vec<(char, f32, f32, f32, f32)> {
918    let Some(doc) = load_document(bytes) else {
919        return Vec::new();
920    };
921    let mut pages: Vec<_> = doc.get_pages().into_iter().collect();
922    pages.sort_by_key(|(n, _)| *n);
923    let Some((_, pid)) = pages.get(index) else {
924        return Vec::new();
925    };
926    page_glyphs(&doc, *pid)
927        .into_iter()
928        .map(|g| (g.ch, g.ll, g.lr, g.lb, g.lt))
929        .collect()
930}
931
932/// Public entry: per-page (width, height, line cells) for a PDF, via the Rust
933/// text parser + the docling-parse line sanitizer. Used by the pipeline and the
934/// `textparse_dump` example.
935pub fn pdf_textlines(bytes: &[u8]) -> Vec<(f32, f32, Vec<crate::pdfium_backend::TextCell>)> {
936    let Some(doc) = load_document(bytes) else {
937        return Vec::new();
938    };
939    let mut caches = DocCaches::default();
940    let mut pages: Vec<_> = doc.get_pages().into_iter().collect();
941    pages.sort_by_key(|(n, _)| *n);
942    pages
943        .into_iter()
944        .map(|(_, pid)| {
945            let (w, h) = page_size(&doc, pid);
946            let glyphs = page_glyphs_cached(&doc, pid, &mut caches);
947            let cells = crate::dp_lines::line_cells(&glyphs, h, true);
948            (w, h, cells)
949        })
950        .collect()
951}
952
953/// Debug/diagnostic entry: per-page (width, height, word cells) for a PDF, via
954/// the Rust parser glyphs run through the docling-parse word grouping. Used to
955/// compare parser word cells against docling-parse's `word_cells` oracle (roadmap
956/// item 6).
957pub fn pdf_words(bytes: &[u8]) -> Vec<(f32, f32, Vec<crate::pdfium_backend::TextCell>)> {
958    let Some(doc) = load_document(bytes) else {
959        return Vec::new();
960    };
961    let mut caches = DocCaches::default();
962    let mut pages: Vec<_> = doc.get_pages().into_iter().collect();
963    pages.sort_by_key(|(n, _)| *n);
964    pages
965        .into_iter()
966        .map(|(_, pid)| {
967            let (w, h) = page_size(&doc, pid);
968            let glyphs = page_glyphs_cached(&doc, pid, &mut caches);
969            let cells = crate::dp_lines::word_cells(&glyphs, h, true);
970            (w, h, cells)
971        })
972        .collect()
973}
974
975/// One page's text cells from the pure-Rust parser: prose line cells, per-word
976/// cells, and code line cells — all from a single glyph parse. Replaces the
977/// pdfium text path (roadmap item 6) when the parser drop is enabled.
978#[derive(Default)]
979pub struct PageParserCells {
980    pub prose: Vec<crate::pdfium_backend::TextCell>,
981    pub words: Vec<crate::pdfium_backend::TextCell>,
982    pub code: Vec<crate::pdfium_backend::TextCell>,
983}
984
985/// Full parser text layer: prose + word + code cells per page, glyphs parsed once.
986/// `prose`/`words` come from the docling-parse contraction ([`crate::dp_lines`]);
987/// `code` splits only at the parser's own space glyphs (monospace keeps its
988/// source spacing). Used by the pipeline to retire pdfium's text path.
989pub fn pdf_all_cells(bytes: &[u8]) -> Vec<PageParserCells> {
990    let Some(doc) = load_document(bytes) else {
991        return Vec::new();
992    };
993    let mut caches = DocCaches::default();
994    let mut pages: Vec<_> = doc.get_pages().into_iter().collect();
995    pages.sort_by_key(|(n, _)| *n);
996    pages
997        .into_iter()
998        .map(|(_, pid)| {
999            let (_w, h) = page_size(&doc, pid);
1000            let glyphs = page_glyphs_cached(&doc, pid, &mut caches);
1001            let (prose, words) = crate::dp_lines::line_and_word_cells(&glyphs, h, true);
1002            PageParserCells {
1003                prose,
1004                words,
1005                code: crate::pdfium_backend::code_cells_from_glyphs(&glyphs, h),
1006            }
1007        })
1008        .collect()
1009}
1010
1011/// Whole pages for the text-layer-only conversion ([`crate::convert_text_layer`]):
1012/// the parser's prose/word/code cells plus page geometry, assembled into
1013/// [`PdfPage`]s with no rendered image and no link annotations. Everything here
1014/// is pure Rust (lopdf), so it compiles without the `ml` feature — including on
1015/// wasm32. A page the parser can't read (no text layer) comes back with empty
1016/// cells; there is no pdfium fallback on this path.
1017pub fn pdf_text_pages(bytes: &[u8]) -> Vec<crate::pdfium_backend::PdfPage> {
1018    let Some(doc) = load_document(bytes) else {
1019        return Vec::new();
1020    };
1021    let mut caches = DocCaches::default();
1022    let mut pages: Vec<_> = doc.get_pages().into_iter().collect();
1023    pages.sort_by_key(|(n, _)| *n);
1024    pages
1025        .into_iter()
1026        .map(|(_, pid)| {
1027            let (w, h) = page_size(&doc, pid);
1028            let glyphs = page_glyphs_cached(&doc, pid, &mut caches);
1029            let (mut prose, mut words) = crate::dp_lines::line_and_word_cells(&glyphs, h, true);
1030            drop_overpainted_cells(&mut prose);
1031            drop_overpainted_cells(&mut words);
1032            crate::pdfium_backend::PdfPage {
1033                width: w,
1034                height: h,
1035                // Cells are native PDF points; there is no rendered bitmap.
1036                scale: 1.0,
1037                cells: prose,
1038                code_cells: crate::pdfium_backend::code_cells_from_glyphs(&glyphs, h),
1039                word_cells: words,
1040                #[cfg(feature = "ocr-prep")]
1041                image: image::RgbImage::new(1, 1),
1042                links: Vec::new(),
1043            }
1044        })
1045        .collect()
1046}
1047
1048/// Drop line cells that are *painted over each other* — glyphs used as artwork.
1049///
1050/// Some generators draw their logo with a symbol font: on the reporting
1051/// invoice, a `TeleLogo` Type1 paints the T-Mobile mark by stacking the glyphs
1052/// encoded as `"` and `==` on top of one another, and the flat text-layer
1053/// output opened with that garbage. Nothing in the font metadata gives it away
1054/// (the *text* fonts in the same file are also flagged symbolic, and the logo
1055/// font names its glyphs `quotedbl` &c.), but the geometry does: two cells with
1056/// different text where one lies inside the other on the same line is
1057/// physically impossible for prose — ink from two words never occupies the
1058/// same box. Both cells of such a pair are paint, not text.
1059///
1060/// Containment (not mere overlap) keeps this narrow: adjacent words touch but
1061/// never contain each other, and a same-text near-duplicate (double-draw faux
1062/// bold) is left alone for the sanitizer's usual handling. Applied on the
1063/// flat/browser path only — the ML pipeline's text layer is byte-pinned by the
1064/// PDF corpus, and there the layout model already sinks logo marks into
1065/// `picture` regions.
1066fn drop_overpainted_cells(cells: &mut Vec<crate::pdfium_backend::TextCell>) {
1067    let mut paint = vec![false; cells.len()];
1068    for i in 0..cells.len() {
1069        for j in 0..cells.len() {
1070            if i == j || cells[i].text == cells[j].text {
1071                continue;
1072            }
1073            let (a, b) = (&cells[i], &cells[j]);
1074            // Same line band: the vertical overlap covers most of the shorter.
1075            let vo = (a.b.min(b.b) - a.t.max(b.t)).max(0.0);
1076            if vo < 0.6 * (a.b - a.t).min(b.b - b.t) {
1077                continue;
1078            }
1079            // `a` horizontally inside `b` (with a small tolerance).
1080            let ho = (a.r.min(b.r) - a.l.max(b.l)).max(0.0);
1081            if ho >= 0.8 * (a.r - a.l) && (a.r - a.l) <= (b.r - b.l) {
1082                paint[i] = true;
1083                paint[j] = true;
1084            }
1085        }
1086    }
1087    let mut keep = paint.iter().map(|p| !p);
1088    cells.retain(|_| keep.next().unwrap());
1089}
1090
1091/// The text-state scalars inherited by a Form XObject when it is invoked via
1092/// `Do` (the PDF graphics state includes the text parameters, but not the text
1093/// matrices, which a form re-establishes inside its own `BT`/`ET`).
1094#[derive(Clone, Copy)]
1095struct TextState {
1096    tc: f64,
1097    tw: f64,
1098    th: f64,
1099    tl: f64,
1100    trise: f64,
1101    fsize: f64,
1102}
1103
1104impl TextState {
1105    const INIT: TextState = TextState {
1106        tc: 0.0,
1107        tw: 0.0,
1108        th: 1.0,
1109        tl: 0.0,
1110        trise: 0.0,
1111        fsize: 0.0,
1112    };
1113}
1114
1115/// The effective `/Resources` dictionary for a page (inline or via reference,
1116/// falling back to an inherited one from a `/Parent`).
1117fn page_res(doc: &Document, page_id: lopdf::ObjectId) -> Option<&Dictionary> {
1118    let (inline, ids) = doc.get_page_resources(page_id).ok()?;
1119    if let Some(d) = inline {
1120        return Some(d);
1121    }
1122    ids.into_iter().find_map(|id| doc.get_dictionary(id).ok())
1123}
1124
1125/// Build the code→[`Font`] map for a resources dictionary's `/Font` sub-dict,
1126/// reusing the per-document cache for fonts referenced indirectly (the common
1127/// case — the same font objects recur on every page).
1128fn fonts_from_res(
1129    doc: &Document,
1130    res: &Dictionary,
1131    caches: &mut DocCaches,
1132) -> HashMap<Vec<u8>, Rc<Font>> {
1133    let mut map = HashMap::new();
1134    let font_dict = res
1135        .get(b"Font")
1136        .ok()
1137        .and_then(|o| deref(doc, o))
1138        .and_then(|o| o.as_dict().ok());
1139    if let Some(fd) = font_dict {
1140        for (name, value) in fd.iter() {
1141            let font = match value {
1142                Object::Reference(id) => {
1143                    let key = (*id, name.clone());
1144                    if let Some(f) = caches.fonts.get(&key) {
1145                        Rc::clone(f)
1146                    } else if let Some(fdict) = deref(doc, value).and_then(|o| o.as_dict().ok()) {
1147                        let f = Rc::new(parse_font(doc, name, fdict));
1148                        caches.fonts.insert(key, Rc::clone(&f));
1149                        f
1150                    } else {
1151                        continue;
1152                    }
1153                }
1154                _ => {
1155                    if let Some(fdict) = deref(doc, value).and_then(|o| o.as_dict().ok()) {
1156                        Rc::new(parse_font(doc, name, fdict))
1157                    } else {
1158                        continue;
1159                    }
1160                }
1161            };
1162            map.insert(name.clone(), font);
1163        }
1164    }
1165    map
1166}
1167
1168/// Extract every glyph on a page as a native-coordinate [`Glyph`].
1169pub(crate) fn page_glyphs(doc: &Document, page_id: lopdf::ObjectId) -> Vec<Glyph> {
1170    page_glyphs_cached(doc, page_id, &mut DocCaches::default())
1171}
1172
1173/// [`page_glyphs`] with an explicit per-document cache, so a multi-page walk
1174/// parses each font / decodes each form once instead of once per page.
1175fn page_glyphs_cached(
1176    doc: &Document,
1177    page_id: lopdf::ObjectId,
1178    caches: &mut DocCaches,
1179) -> Vec<Glyph> {
1180    let mut out = Vec::new();
1181    // lopdf 0.44: get_page_content returns the assembled content-stream bytes
1182    // directly (an empty Vec when the page has none).
1183    let content_bytes = doc.get_page_content(page_id);
1184    let Ok(content) = lopdf::content::Content::decode(&content_bytes) else {
1185        return out;
1186    };
1187    if let Some(res) = page_res(doc, page_id) {
1188        run_content(
1189            doc,
1190            res,
1191            &content,
1192            Mat::ID,
1193            TextState::INIT,
1194            0,
1195            caches,
1196            &mut out,
1197        );
1198    }
1199    out
1200}
1201
1202/// Run a content stream's operators, emitting glyphs into `out`. Recurses into
1203/// Form XObjects on `Do` (bulk body text in heavy PDFs lives inside a form, not
1204/// the page content stream). `res` is the resources dict in scope (the page's,
1205/// or the form's own); `base_ctm` is the CTM at the point of invocation.
1206#[allow(clippy::too_many_arguments)]
1207fn run_content(
1208    doc: &Document,
1209    res: &Dictionary,
1210    content: &lopdf::content::Content,
1211    base_ctm: Mat,
1212    init: TextState,
1213    depth: u32,
1214    caches: &mut DocCaches,
1215    out: &mut Vec<Glyph>,
1216) {
1217    let fonts = fonts_from_res(doc, res, caches);
1218    let xobjects = res
1219        .get(b"XObject")
1220        .ok()
1221        .and_then(|o| deref(doc, o))
1222        .and_then(|o| o.as_dict().ok());
1223
1224    // Graphics + text state. `q`/`Q` save and restore the whole graphics state,
1225    // which includes the text parameters (Tc, Tw, Tz, TL, Tfs, Trise, font) —
1226    // *not* the text matrix (that is reset by BT). Saving only the CTM let a Tc
1227    // set inside a `q…Q` block leak out and drift every later glyph.
1228    #[allow(clippy::type_complexity)]
1229    let mut gstate_stack: Vec<(Mat, f64, f64, f64, f64, f64, f64, Option<&Rc<Font>>)> = Vec::new();
1230    let mut ctm = base_ctm;
1231    let mut tm = Mat::ID;
1232    let mut tlm = Mat::ID;
1233    let mut font: Option<&Rc<Font>> = None;
1234    let mut fsize = init.fsize;
1235    let mut tc = init.tc; // char spacing
1236    let mut tw = init.tw; // word spacing
1237    let mut th = init.th; // horizontal scale (Tz/100)
1238    let mut tl = init.tl; // leading
1239    let mut trise = init.trise;
1240
1241    let op_f = |operands: &[Object], i: usize| operands.get(i).and_then(num).unwrap_or(0.0);
1242
1243    for op in &content.operations {
1244        let operands = &op.operands;
1245        match op.operator.as_str() {
1246            "q" => gstate_stack.push((ctm, tc, tw, th, tl, trise, fsize, font)),
1247            "Q" => {
1248                if let Some((c, a, b, h, l, r, fs, f)) = gstate_stack.pop() {
1249                    ctm = c;
1250                    tc = a;
1251                    tw = b;
1252                    th = h;
1253                    tl = l;
1254                    trise = r;
1255                    fsize = fs;
1256                    font = f;
1257                }
1258            }
1259            "cm" => {
1260                let m = Mat {
1261                    a: op_f(operands, 0),
1262                    b: op_f(operands, 1),
1263                    c: op_f(operands, 2),
1264                    d: op_f(operands, 3),
1265                    e: op_f(operands, 4),
1266                    f: op_f(operands, 5),
1267                };
1268                ctm = m.then(ctm);
1269            }
1270            "BT" => {
1271                tm = Mat::ID;
1272                tlm = Mat::ID;
1273            }
1274            "ET" => {}
1275            "Tf" => {
1276                if let Some(Object::Name(n)) = operands.first() {
1277                    font = fonts.get(n.as_slice());
1278                }
1279                fsize = op_f(operands, 1);
1280            }
1281            "Td" => {
1282                tlm = Mat {
1283                    a: 1.0,
1284                    b: 0.0,
1285                    c: 0.0,
1286                    d: 1.0,
1287                    e: op_f(operands, 0),
1288                    f: op_f(operands, 1),
1289                }
1290                .then(tlm);
1291                tm = tlm;
1292            }
1293            "TD" => {
1294                tl = -op_f(operands, 1);
1295                tlm = Mat {
1296                    a: 1.0,
1297                    b: 0.0,
1298                    c: 0.0,
1299                    d: 1.0,
1300                    e: op_f(operands, 0),
1301                    f: op_f(operands, 1),
1302                }
1303                .then(tlm);
1304                tm = tlm;
1305            }
1306            "Tm" => {
1307                tlm = Mat {
1308                    a: op_f(operands, 0),
1309                    b: op_f(operands, 1),
1310                    c: op_f(operands, 2),
1311                    d: op_f(operands, 3),
1312                    e: op_f(operands, 4),
1313                    f: op_f(operands, 5),
1314                };
1315                tm = tlm;
1316            }
1317            "T*" => {
1318                tlm = Mat {
1319                    a: 1.0,
1320                    b: 0.0,
1321                    c: 0.0,
1322                    d: 1.0,
1323                    e: 0.0,
1324                    f: -tl,
1325                }
1326                .then(tlm);
1327                tm = tlm;
1328            }
1329            "Tc" => tc = op_f(operands, 0),
1330            "Tw" => tw = op_f(operands, 0),
1331            "Tz" => th = op_f(operands, 0) / 100.0,
1332            "TL" => tl = op_f(operands, 0),
1333            "Ts" => trise = op_f(operands, 0),
1334            "Tj" | "'" | "\"" => {
1335                if op.operator == "'" || op.operator == "\"" {
1336                    // move to next line first
1337                    tlm = Mat {
1338                        a: 1.0,
1339                        b: 0.0,
1340                        c: 0.0,
1341                        d: 1.0,
1342                        e: 0.0,
1343                        f: -tl,
1344                    }
1345                    .then(tlm);
1346                    tm = tlm;
1347                }
1348                if op.operator == "\"" {
1349                    // `aw ac string "` sets word- and char-spacing before
1350                    // showing the string (PDF 32000-1 §9.4.3), persisting after.
1351                    tw = op_f(operands, 0);
1352                    tc = op_f(operands, 1);
1353                }
1354                if let (Some(f), Some(Object::String(s, _))) = (font, operands.last()) {
1355                    show_text(f, s, fsize, tc, tw, th, trise, &mut tm, ctm, out);
1356                }
1357            }
1358            "TJ" => {
1359                if let (Some(f), Some(Object::Array(arr))) = (font, operands.first()) {
1360                    for el in arr {
1361                        match el {
1362                            Object::String(s, _) => {
1363                                show_text(f, s, fsize, tc, tw, th, trise, &mut tm, ctm, out)
1364                            }
1365                            other => {
1366                                if let Some(adj) = num(other) {
1367                                    // negative number moves text right (PDF: subtract)
1368                                    let tx = -adj / 1000.0 * fsize * th;
1369                                    tm = Mat {
1370                                        a: 1.0,
1371                                        b: 0.0,
1372                                        c: 0.0,
1373                                        d: 1.0,
1374                                        e: tx,
1375                                        f: 0.0,
1376                                    }
1377                                    .then(tm);
1378                                }
1379                            }
1380                        }
1381                    }
1382                }
1383            }
1384            "Do" => {
1385                // Invoke a Form XObject: bulk body text in many PDFs lives inside
1386                // a form, reached only here. Image XObjects are skipped (no text).
1387                if depth >= 8 {
1388                    continue;
1389                }
1390                let Some(Object::Name(n)) = operands.first() else {
1391                    continue;
1392                };
1393                let obj = xobjects.and_then(|d| d.get(n.as_slice()).ok());
1394                let form_id = match obj {
1395                    Some(Object::Reference(id)) => Some(*id),
1396                    _ => None,
1397                };
1398                let stream = obj
1399                    .and_then(|o| deref(doc, o))
1400                    .and_then(|o| o.as_stream().ok());
1401                let Some(stream) = stream else { continue };
1402                let is_form = stream
1403                    .dict
1404                    .get(b"Subtype")
1405                    .ok()
1406                    .and_then(|o| o.as_name().ok())
1407                    == Some(b"Form".as_slice());
1408                if !is_form {
1409                    continue;
1410                }
1411                // Decode the form's content once per document (headers/footers
1412                // and bulk body text invoke the same form on every page).
1413                let cached = form_id.and_then(|id| caches.forms.get(&id).cloned());
1414                let form_content = match cached {
1415                    Some(c) => c,
1416                    None => {
1417                        let Ok(data) = stream.decompressed_content() else {
1418                            continue;
1419                        };
1420                        let Ok(c) = lopdf::content::Content::decode(&data) else {
1421                            continue;
1422                        };
1423                        let c = Rc::new(c);
1424                        if let Some(id) = form_id {
1425                            caches.forms.insert(id, Rc::clone(&c));
1426                        }
1427                        c
1428                    }
1429                };
1430                // The form's /Matrix maps form space into the CTM at invocation.
1431                let form_mat = match stream.dict.get(b"Matrix").ok() {
1432                    Some(Object::Array(a)) if a.len() == 6 => {
1433                        let v: Vec<f64> = a.iter().filter_map(num).collect();
1434                        if v.len() == 6 {
1435                            Mat {
1436                                a: v[0],
1437                                b: v[1],
1438                                c: v[2],
1439                                d: v[3],
1440                                e: v[4],
1441                                f: v[5],
1442                            }
1443                        } else {
1444                            Mat::ID
1445                        }
1446                    }
1447                    _ => Mat::ID,
1448                };
1449                // The form's own /Resources, falling back to the inherited ones.
1450                let form_res = stream
1451                    .dict
1452                    .get(b"Resources")
1453                    .ok()
1454                    .and_then(|o| deref(doc, o))
1455                    .and_then(|o| o.as_dict().ok())
1456                    .unwrap_or(res);
1457                let state = TextState {
1458                    tc,
1459                    tw,
1460                    th,
1461                    tl,
1462                    trise,
1463                    fsize,
1464                };
1465                run_content(
1466                    doc,
1467                    form_res,
1468                    &form_content,
1469                    form_mat.then(ctm),
1470                    state,
1471                    depth + 1,
1472                    caches,
1473                    out,
1474                );
1475            }
1476            _ => {}
1477        }
1478    }
1479}
1480
1481#[allow(clippy::too_many_arguments)]
1482fn show_text(
1483    font: &Font,
1484    bytes: &[u8],
1485    fsize: f64,
1486    tc: f64,
1487    tw: f64,
1488    th: f64,
1489    trise: f64,
1490    tm: &mut Mat,
1491    ctm: Mat,
1492    out: &mut Vec<Glyph>,
1493) {
1494    for code in codes(font, bytes) {
1495        let (text, w) = font.decode_code(code);
1496        let w0 = w / 1000.0; // advance in text-space (em) units
1497                             // The glyph→user transform: scale glyph space by font size, then Tm, CTM.
1498        let scale = Mat {
1499            a: fsize * th,
1500            b: 0.0,
1501            c: 0.0,
1502            d: fsize,
1503            e: 0.0,
1504            f: trise,
1505        };
1506        let trm = scale.then(*tm).then(ctm);
1507        // Box in glyph space (1000-unit em): x 0..w, y descent..ascent.
1508        let (x0, y0) = trm.apply(0.0, font.descent / 1000.0);
1509        let (x1, _y1) = trm.apply(w0, font.descent / 1000.0);
1510        let (_x2, y2) = trm.apply(0.0, font.ascent / 1000.0);
1511        let (left, right) = (x0.min(x1), x0.max(x1));
1512        let (bot, top) = (y0.min(y2), y0.max(y2));
1513        if let Some(s) = text {
1514            // A run may map one code to multiple chars (ligature/fraction); share box.
1515            for ch in s.chars() {
1516                if ch != '\u{0}' {
1517                    out.push(Glyph {
1518                        ch,
1519                        l: left as f32,
1520                        b: bot as f32,
1521                        r: right as f32,
1522                        t: top as f32,
1523                        ll: left as f32,
1524                        lb: bot as f32,
1525                        lr: right as f32,
1526                        lt: top as f32,
1527                        font: font.hash,
1528                    });
1529                }
1530            }
1531        }
1532        // Advance the text matrix. Word spacing applies to single-byte code 32.
1533        let is_space = !font.two_byte && code == 32;
1534        let tx = (w0 * fsize + tc + if is_space { tw } else { 0.0 }) * th;
1535        *tm = Mat {
1536            a: 1.0,
1537            b: 0.0,
1538            c: 0.0,
1539            d: 1.0,
1540            e: tx,
1541            f: 0.0,
1542        }
1543        .then(*tm);
1544    }
1545}
1546
1547/// Build a simple font's code→char table from its `/Encoding`: the base
1548/// encoding (WinAnsi / MacRoman) plus any `/Differences` overrides (glyph names
1549/// resolved through a small Adobe-glyph-name subset).
1550fn simple_encoding_table(doc: &Document, fdict: &Dictionary) -> HashMap<u8, char> {
1551    let enc = fdict.get(b"Encoding").ok().and_then(|o| deref(doc, o));
1552    let base_name = match enc {
1553        Some(Object::Name(n)) => n.clone(),
1554        Some(Object::Dictionary(d)) => d
1555            .get(b"BaseEncoding")
1556            .ok()
1557            .and_then(|o| o.as_name().ok())
1558            .map(|n| n.to_vec())
1559            .unwrap_or_default(),
1560        _ => Vec::new(),
1561    };
1562    let mut m = if base_name == b"MacRomanEncoding" {
1563        macroman_table()
1564    } else {
1565        winansi_table()
1566    };
1567    // Apply /Differences: `code /glyphname /glyphname ... code ...`.
1568    if let Some(Object::Dictionary(d)) = enc {
1569        if let Some(Object::Array(diffs)) = d.get(b"Differences").ok().and_then(|o| deref(doc, o)) {
1570            let mut code = 0u8;
1571            for el in diffs {
1572                match el {
1573                    Object::Integer(i) => code = *i as u8,
1574                    Object::Name(name) => {
1575                        if let Some(ch) = glyph_name_to_char(name) {
1576                            m.insert(code, ch);
1577                        }
1578                        code = code.wrapping_add(1);
1579                    }
1580                    _ => {}
1581                }
1582            }
1583        }
1584    }
1585    m
1586}
1587
1588/// Resolve an Adobe glyph name to Unicode: `uniXXXX`, single ASCII letters, the
1589/// digit/punctuation names from the Adobe Glyph List, and common typographic
1590/// names. A `.suffix` (`one.taboldstyle`, `a.sc`) is stripped and the base name
1591/// retried — docling renders these as the base character.
1592fn glyph_name_to_char(name: &[u8]) -> Option<char> {
1593    let s = std::str::from_utf8(name).ok()?;
1594    if let Some(hex) = s.strip_prefix("uni") {
1595        if let Ok(cp) = u32::from_str_radix(hex.get(0..4)?, 16) {
1596            return char::from_u32(cp);
1597        }
1598    }
1599    // Single ASCII letter names (`A`, `m`) map to themselves.
1600    if s.len() == 1 {
1601        let b = s.as_bytes()[0];
1602        if b.is_ascii_alphabetic() {
1603            return Some(b as char);
1604        }
1605    }
1606    let resolved = match s {
1607        "space" => ' ',
1608        "exclam" => '!',
1609        "quotedbl" => '"',
1610        "numbersign" => '#',
1611        "dollar" => '$',
1612        "percent" => '%',
1613        "ampersand" => '&',
1614        "quotesingle" => '\'',
1615        "parenleft" => '(',
1616        "parenright" => ')',
1617        "asterisk" => '*',
1618        "plus" => '+',
1619        "comma" => ',',
1620        "hyphen" => '-',
1621        "period" => '.',
1622        "slash" => '/',
1623        "zero" => '0',
1624        "one" => '1',
1625        "two" => '2',
1626        "three" => '3',
1627        "four" => '4',
1628        "five" => '5',
1629        "six" => '6',
1630        "seven" => '7',
1631        "eight" => '8',
1632        "nine" => '9',
1633        "colon" => ':',
1634        "semicolon" => ';',
1635        "less" => '<',
1636        "equal" => '=',
1637        "greater" => '>',
1638        "question" => '?',
1639        "at" => '@',
1640        "bracketleft" => '[',
1641        "backslash" => '\\',
1642        "bracketright" => ']',
1643        "asciicircum" => '^',
1644        "underscore" => '_',
1645        "grave" => '`',
1646        "braceleft" => '{',
1647        "bar" => '|',
1648        "braceright" => '}',
1649        "asciitilde" => '~',
1650        "bullet" => '\u{2022}',
1651        "periodcentered" => '\u{00B7}',
1652        "endash" => '\u{2013}',
1653        "emdash" => '\u{2014}',
1654        "quoteright" => '\u{2019}',
1655        "quoteleft" => '\u{2018}',
1656        "quotedblleft" => '\u{201C}',
1657        "quotedblright" => '\u{201D}',
1658        "quotedblbase" => '\u{201E}',
1659        "quotesinglbase" => '\u{201A}',
1660        // Latin f-ligatures named in `/Differences` (e.g. 2305's body font). These
1661        // map to the presentation-form code points, which `decompose_ligatures`
1662        // then spells back out (`ff`→"ff") — without them the glyph decodes to
1663        // nothing and the sanitizer fills the gap with a space (`di erences`).
1664        "ff" => '\u{FB00}',
1665        "fi" => '\u{FB01}',
1666        "fl" => '\u{FB02}',
1667        "ffi" => '\u{FB03}',
1668        "ffl" => '\u{FB04}',
1669        "ft" => '\u{FB05}',
1670        "st" => '\u{FB06}',
1671        "degree" => '\u{00B0}',
1672        "trademark" => '\u{2122}',
1673        "registered" => '\u{00AE}',
1674        "copyright" => '\u{00A9}',
1675        "ellipsis" => '\u{2026}',
1676        "minus" => '\u{2212}',
1677        "fraction" => '\u{2044}',
1678        "nbspace" => '\u{00A0}',
1679        // Greek + math glyph names (standard Adobe Glyph List). Standard TeX math
1680        // fonts (CMMI/CMSY/…) name their glyphs this way in the embedded font
1681        // program's `/Encoding`; without these a `λ`/`≤` decodes to nothing and is
1682        // dropped from body text (`and λ set to 0.5` → `and set to 0.5`).
1683        "alpha" => '\u{03B1}',
1684        "beta" => '\u{03B2}',
1685        "gamma" => '\u{03B3}',
1686        "delta" => '\u{03B4}',
1687        "epsilon" | "epsilon1" => '\u{03B5}',
1688        "zeta" => '\u{03B6}',
1689        "eta" => '\u{03B7}',
1690        "theta" | "theta1" => '\u{03B8}',
1691        "iota" => '\u{03B9}',
1692        "kappa" => '\u{03BA}',
1693        "lambda" => '\u{03BB}',
1694        "mu" => '\u{03BC}',
1695        "nu" => '\u{03BD}',
1696        "xi" => '\u{03BE}',
1697        "omicron" => '\u{03BF}',
1698        "pi" | "pi1" => '\u{03C0}',
1699        "rho" | "rho1" => '\u{03C1}',
1700        "sigma" => '\u{03C3}',
1701        "sigma1" => '\u{03C2}',
1702        "tau" => '\u{03C4}',
1703        "upsilon" => '\u{03C5}',
1704        "phi" | "phi1" => '\u{03C6}',
1705        "chi" => '\u{03C7}',
1706        "psi" => '\u{03C8}',
1707        "omega" | "omega1" => '\u{03C9}',
1708        "Gamma" => '\u{0393}',
1709        "Delta" => '\u{0394}',
1710        "Theta" => '\u{0398}',
1711        "Lambda" => '\u{039B}',
1712        "Xi" => '\u{039E}',
1713        "Pi" => '\u{03A0}',
1714        "Sigma" => '\u{03A3}',
1715        "Upsilon" => '\u{03A5}',
1716        "Phi" => '\u{03A6}',
1717        "Psi" => '\u{03A8}',
1718        "Omega" => '\u{03A9}',
1719        "lessequal" => '\u{2264}',
1720        "greaterequal" => '\u{2265}',
1721        "notequal" => '\u{2260}',
1722        "approxequal" => '\u{2248}',
1723        "equivalence" => '\u{2261}',
1724        "element" => '\u{2208}',
1725        "plusminus" => '\u{00B1}',
1726        "multiply" => '\u{00D7}',
1727        "divide" => '\u{00F7}',
1728        "infinity" => '\u{221E}',
1729        "partialdiff" => '\u{2202}',
1730        "gradient" => '\u{2207}',
1731        "summation" => '\u{2211}',
1732        "product" => '\u{220F}',
1733        "integral" => '\u{222B}',
1734        "radical" => '\u{221A}',
1735        "proportional" => '\u{221D}',
1736        "arrowright" => '\u{2192}',
1737        "arrowleft" => '\u{2190}',
1738        "arrowup" => '\u{2191}',
1739        "arrowdown" => '\u{2193}',
1740        "arrowboth" => '\u{2194}',
1741        "arrowdblright" => '\u{21D2}',
1742        "logicaland" => '\u{2227}',
1743        "logicalor" => '\u{2228}',
1744        "intersection" => '\u{2229}',
1745        "union" => '\u{222A}',
1746        "similar" => '\u{223C}',
1747        "congruent" => '\u{2245}',
1748        "dotmath" => '\u{22C5}',
1749        "asteriskmath" => '\u{2217}',
1750        _ => {
1751            // Strip an AGL `.suffix` (oldstyle/small-cap variant) and retry.
1752            if let Some((base, _)) = s.split_once('.') {
1753                if !base.is_empty() {
1754                    return glyph_name_to_char(base.as_bytes());
1755                }
1756            }
1757            return None;
1758        }
1759    };
1760    Some(resolved)
1761}
1762
1763/// Minimal WinAnsiEncoding (Latin-1-ish) for simple fonts lacking ToUnicode.
1764fn winansi_table() -> HashMap<u8, char> {
1765    let mut m = HashMap::new();
1766    for b in 0x20u8..=0x7e {
1767        m.insert(b, b as char);
1768    }
1769    // High range: Windows-1252 printable points that differ from Latin-1.
1770    let extra: &[(u8, char)] = &[
1771        (0x91, '\u{2018}'),
1772        (0x92, '\u{2019}'),
1773        (0x93, '\u{201C}'),
1774        (0x94, '\u{201D}'),
1775        (0x95, '\u{2022}'),
1776        (0x96, '\u{2013}'),
1777        (0x97, '\u{2014}'),
1778        (0x85, '\u{2026}'),
1779        (0xA0, '\u{00A0}'),
1780    ];
1781    for &(b, c) in extra {
1782        m.insert(b, c);
1783    }
1784    for b in 0xA1u8..=0xFF {
1785        m.entry(b).or_insert(b as char);
1786    }
1787    m
1788}
1789
1790/// Minimal MacRomanEncoding: ASCII plus the high-range points our corpus hits
1791/// (notably 0xA5 = bullet, used as a list marker).
1792fn macroman_table() -> HashMap<u8, char> {
1793    let mut m = HashMap::new();
1794    for b in 0x20u8..=0x7e {
1795        m.insert(b, b as char);
1796    }
1797    let high: &[(u8, char)] = &[
1798        (0xA5, '\u{2022}'), // bullet
1799        (0xD0, '\u{2013}'), // endash
1800        (0xD1, '\u{2014}'), // emdash
1801        (0xD2, '\u{201C}'),
1802        (0xD3, '\u{201D}'),
1803        (0xD4, '\u{2018}'),
1804        (0xD5, '\u{2019}'),
1805        (0xCA, '\u{00A0}'),
1806        (0xC9, '\u{2026}'),
1807        (0xDE, '\u{FB01}'),
1808        (0xDF, '\u{FB02}'),
1809    ];
1810    for &(b, c) in high {
1811        m.insert(b, c);
1812    }
1813    m
1814}
1815
1816#[cfg(test)]
1817mod xref_repair {
1818    /// Build a tiny one-page PDF whose cross-reference entries are either the
1819    /// spec's 20 bytes (`two_byte_eol`) or the 19-byte form some generators
1820    /// emit — everything else about the two files is identical.
1821    fn pdf_with_xref(two_byte_eol: bool) -> Vec<u8> {
1822        let content = b"BT /F1 12 Tf 72 700 Td (Invoice 922769430725) Tj ET\n";
1823        let stream = format!("<</Length {}>>stream\n", content.len()).into_bytes();
1824        let objs: Vec<Vec<u8>> = vec![
1825            b"<</Type/Catalog/Pages 2 0 R>>".to_vec(),
1826            b"<</Type/Pages/Kids[3 0 R]/Count 1>>".to_vec(),
1827            b"<</Type/Page/Parent 2 0 R/MediaBox[0 0 595 842]/Contents 4 0 R\
1828               /Resources<</Font<</F1 5 0 R>>>>>>"
1829                .to_vec(),
1830            [stream.as_slice(), content.as_slice(), b"endstream"].concat(),
1831            b"<</Type/Font/Subtype/Type1/BaseFont/Helvetica>>".to_vec(),
1832        ];
1833
1834        let mut out = b"%PDF-1.4\n".to_vec();
1835        let mut offsets = Vec::new();
1836        for (i, body) in objs.iter().enumerate() {
1837            offsets.push(out.len());
1838            out.extend_from_slice(format!("{} 0 obj", i + 1).as_bytes());
1839            out.extend_from_slice(body);
1840            out.extend_from_slice(b"endobj\n");
1841        }
1842        let xref_at = out.len();
1843        let eol: &[u8] = if two_byte_eol { b" \n" } else { b"\n" };
1844        out.extend_from_slice(format!("xref\n0 {}\n", objs.len() + 1).as_bytes());
1845        out.extend_from_slice(b"0000000000 65535 f");
1846        out.extend_from_slice(eol);
1847        for off in &offsets {
1848            out.extend_from_slice(format!("{off:010} 00000 n").as_bytes());
1849            out.extend_from_slice(eol);
1850        }
1851        out.extend_from_slice(
1852            format!("trailer<</Size {}/Root 1 0 R>>\n", objs.len() + 1).as_bytes(),
1853        );
1854        out.extend_from_slice(format!("startxref\n{xref_at}\n%%EOF\n").as_bytes());
1855        out
1856    }
1857
1858    /// A 19-byte cross-reference entry (a bare LF where the spec wants a
1859    /// two-byte EOL) makes lopdf reject the whole file, so a readable text
1860    /// layer used to look exactly like a scan — in the browser that meant ten
1861    /// seconds of OCR for nothing. The repair must recover *the same* parse the
1862    /// well-formed file gives.
1863    #[test]
1864    fn short_xref_entries_still_parse() {
1865        let good = pdf_with_xref(true);
1866        let broken = pdf_with_xref(false);
1867        assert!(
1868            broken.len() < good.len(),
1869            "the broken file is the shorter one"
1870        );
1871        assert!(
1872            lopdf::Document::load_mem(&good).is_ok(),
1873            "the control file must load unaided"
1874        );
1875        assert!(
1876            lopdf::Document::load_mem(&broken).is_err(),
1877            "lopdf rejects 19-byte entries — if this ever passes, drop the repair"
1878        );
1879
1880        let cells = |b: &[u8]| -> Vec<String> {
1881            super::pdf_textlines(b)
1882                .into_iter()
1883                .flat_map(|(_, _, c)| c.into_iter().map(|c| c.text))
1884                .collect()
1885        };
1886        let from_good = cells(&good);
1887        assert!(
1888            from_good.iter().any(|t| t.contains("922769430725")),
1889            "control text: {from_good:?}"
1890        );
1891        assert_eq!(
1892            cells(&broken),
1893            from_good,
1894            "repair must match the good parse"
1895        );
1896    }
1897
1898    /// The same generator overstates `/Length`, so lopdf reads past the data,
1899    /// misses `endstream` and drops the stream — the object comes back as a
1900    /// bare dictionary and the page has no content at all. Trust `endstream`
1901    /// instead, and do it without moving a single byte.
1902    #[test]
1903    fn overstated_stream_length_still_yields_content() {
1904        let good = pdf_with_xref(true);
1905        // Inflate the content stream's /Length by one, exactly as the invoice
1906        // that prompted this does.
1907        let broken = {
1908            let at = good
1909                .windows(8)
1910                .position(|w| w == b"/Length ")
1911                .expect("a /Length")
1912                + 8;
1913            let digits = good[at..].iter().take_while(|c| c.is_ascii_digit()).count();
1914            let n: usize = std::str::from_utf8(&good[at..at + digits])
1915                .unwrap()
1916                .parse()
1917                .unwrap();
1918            let inflated = (n + 1).to_string();
1919            assert_eq!(inflated.len(), digits, "keep the digit count");
1920            let mut b = good.clone();
1921            b[at..at + digits].copy_from_slice(inflated.as_bytes());
1922            b
1923        };
1924        assert_eq!(broken.len(), good.len(), "the defect must not move bytes");
1925        // lopdf alone loses the stream: the page parses but carries no content.
1926        let raw = lopdf::Document::load_mem(&broken).expect("still loads");
1927        assert!(
1928            raw.get_pages()
1929                .into_values()
1930                .all(|p| raw.get_page_content(p).is_empty()),
1931            "lopdf should drop the stream — if it stops, drop this repair"
1932        );
1933        // Ours recovers the same text the well-formed file gives.
1934        let text = |b: &[u8]| -> Vec<String> {
1935            super::pdf_textlines(b)
1936                .into_iter()
1937                .flat_map(|(_, _, c)| c.into_iter().map(|c| c.text))
1938                .collect()
1939        };
1940        let expected = text(&good);
1941        assert!(!expected.is_empty(), "control must produce text");
1942        assert_eq!(text(&broken), expected);
1943    }
1944
1945    /// The repair only fires where padding cannot move an object: it declines a
1946    /// file whose xref precedes an object (an incremental update), rather than
1947    /// shifting every offset the table records.
1948    #[test]
1949    fn repair_declines_when_padding_would_move_objects() {
1950        let mut incremental = pdf_with_xref(false);
1951        incremental.extend_from_slice(b"6 0 obj<</Type/Whatever>>endobj\n");
1952        let declined = super::pad_short_xref_entries(&incremental).unwrap_err();
1953        assert!(
1954            declined.contains("object follows the xref"),
1955            "reason: {declined}"
1956        );
1957    }
1958}
1959
1960#[cfg(test)]
1961mod overpainted {
1962    use crate::pdfium_backend::TextCell;
1963
1964    fn cell(text: &str, l: f32, t: f32, r: f32, b: f32) -> TextCell {
1965        TextCell {
1966            text: text.into(),
1967            l,
1968            t,
1969            r,
1970            b,
1971        }
1972    }
1973
1974    /// The reporting invoice's logo: a `"` painted inside a `==` on one band —
1975    /// artwork drawn with glyphs. Both cells go; the real text on the next
1976    /// band stays.
1977    #[test]
1978    fn stacked_logo_glyphs_are_dropped() {
1979        let mut cells = vec![
1980            cell("\"", 72.7, 21.5, 86.4, 31.5),
1981            cell("==", 59.4, 21.5, 99.6, 31.5),
1982            cell("Herr", 65.2, 151.3, 81.7, 161.3),
1983        ];
1984        super::drop_overpainted_cells(&mut cells);
1985        assert_eq!(cells.len(), 1, "cells: {cells:?}");
1986        assert_eq!(cells[0].text, "Herr");
1987    }
1988
1989    /// Adjacent words on a line touch but never contain each other — prose is
1990    /// untouched, and so is a same-text near-duplicate (double-drawn faux
1991    /// bold), which is not evidence of artwork.
1992    #[test]
1993    fn prose_and_double_draw_are_kept() {
1994        let mut cells = vec![
1995            cell("Telefon", 354.3, 133.2, 381.5, 143.2),
1996            cell("0676/2000", 387.3, 133.2, 428.7, 143.2),
1997            cell("Bold", 100.0, 50.0, 130.0, 60.0),
1998            cell("Bold", 100.3, 50.0, 130.3, 60.0),
1999        ];
2000        super::drop_overpainted_cells(&mut cells);
2001        assert_eq!(cells.len(), 4);
2002    }
2003}
2004
2005#[cfg(test)]
2006mod vestigial_layer {
2007    use crate::pdfium_backend::{PdfPage, TextCell};
2008
2009    fn page_with(texts: &[&str]) -> PdfPage {
2010        let cells = texts
2011            .iter()
2012            .enumerate()
2013            .map(|(i, t)| TextCell {
2014                text: t.to_string(),
2015                l: 10.0,
2016                t: 10.0 + 12.0 * i as f32,
2017                r: 90.0,
2018                b: 20.0 + 12.0 * i as f32,
2019            })
2020            .collect();
2021        PdfPage::from_cells(595.0, 842.0, 1.0, cells)
2022    }
2023
2024    /// The reported scanned form: three typed-in field values ("03", "05",
2025    /// "2025") over three image pages. That must read as *no usable layer*,
2026    /// so the browser routes the document to OCR instead of extracting
2027    /// thirteen characters and skipping the letter entirely.
2028    #[test]
2029    fn typed_in_form_fields_are_not_a_text_layer() {
2030        let pages = vec![
2031            page_with(&["03", "05", "2025"]),
2032            page_with(&[]),
2033            page_with(&[]),
2034        ];
2035        assert!(super::text_layer_is_vestigial(&pages));
2036        assert!(super::text_layer_is_vestigial(&[page_with(&[])]));
2037    }
2038
2039    /// A short but genuine digital document — one page, a few real lines —
2040    /// keeps the fast text path.
2041    #[test]
2042    fn sparse_but_real_documents_pass() {
2043        let one_pager = vec![page_with(&[
2044            "Confidential briefing",
2045            "Prepared for the board meeting",
2046            "Do not distribute",
2047        ])];
2048        assert!(!super::text_layer_is_vestigial(&one_pager));
2049    }
2050}