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