Skip to main content

oxideav_pdf/reader/
text.rs

1//! PDF text extraction — content-stream walker that emits text runs
2//! with font + position resolved.
3//!
4//! Round 22 implementation. The walker is the read-side complement to
5//! the (still-deferred) writer-side text emission path. It scans every
6//! page's `/Contents` stream, tracks the text matrix `Tm` per ISO
7//! 32000-1 §9.4.4, decodes `Tj` / `TJ` / `'` / `"` operands, and emits
8//! one [`TextRun`] per show operator. The decoded string is reconstructed
9//! by mapping the encoded bytes back to Unicode through whichever route
10//! the page-level `/Font` resource describes:
11//!
12//! 1. **Type 0 / CIDFontType0 / CIDFontType2 with `/ToUnicode`** — parse
13//!    the CMap stream's `bfchar` / `bfrange` mappings (ISO 32000-1
14//!    §9.10.3) and look each 2-byte CID up in the result map.
15//! 2. **Identity-H / Identity-V without `/ToUnicode`** — interpret each
16//!    CID as the equivalent BMP code point (lossy fallback; matches what
17//!    `pdftotext --raw` does for fonts with no `/ToUnicode` slice).
18//! 3. **Simple fonts (Type1, TrueType) with `/Encoding /WinAnsiEncoding`**
19//!    — apply the WinAnsi byte-to-Unicode table.
20//! 4. **Simple fonts with no recognised encoding** — return raw bytes as
21//!    Latin-1 (the writer never emits this shape; included so the round
22//!    is robust against hand-laid PDFs from older tooling).
23//!
24//! `TJ` numeric position adjustments are read per ISO 32000-1 §9.4.3
25//! (Table 109 + Figure 46): a rightward gap wider than a quarter-em is
26//! recovered as an inter-word space, so words a producer separated with
27//! a bare displacement (no literal space glyph) extract correctly while
28//! tight intra-word kerning stays joined. See [`TextWalker::emit_show_tj`].
29//!
30//! Reading-order reconstruction (column / paragraph segmentation) is a
31//! future-round followup. The runs come out in stream order — exactly
32//! the way the page's painter would have laid them down.
33//!
34//! ## Provenance
35//!
36//! ISO 32000-1:2008 §9 (Text), §9.4 (Text Objects), §9.6 (Simple Fonts),
37//! §9.7 (Composite Fonts), §9.10 (Extraction of Text Content), Adobe
38//! Tech Note #5014 (CMap & CIDFont Files Specification). No third-party
39//! PDF library was consulted.
40
41use std::collections::HashMap;
42use std::str;
43
44use crate::error::PdfError;
45use crate::objects::{Dict, Object, ObjectId};
46use crate::reader::document::{decode_stream, DocumentReader};
47use crate::reader::encoding::{
48    apply_encoding_differences, parse_encoding_differences, BaseEncoding, EncodingMap,
49};
50
51// ────────────────────────── public surface ──────────────────────────
52
53/// One contiguous text-show output by the content-stream walker.
54///
55/// Position is the text-space origin of the run at the moment the show
56/// operator fired (text-matrix `e` / `f`). Font size carries the `Tf`
57/// argument verbatim — note that PDF text-space is multiplied by the
58/// CTM scaling, so the rendered glyph size on paper is `font_size *
59/// CTM_scale`. Round-22 callers that only need the raw `Tf` value
60/// (e.g. for keyword search) can ignore the CTM; renderers that want
61/// physical size should multiply by the CTM extracted from the
62/// reader's group walker.
63#[derive(Clone, Debug, PartialEq)]
64pub struct TextRun {
65    /// Decoded Unicode payload — the result of mapping every encoded
66    /// byte / CID through the font's `/ToUnicode` CMap (or the
67    /// identity / WinAnsi fallbacks documented above).
68    pub text: String,
69    /// `(x, y)` in PDF user space — the origin at which the run's
70    /// glyphs begin. This is the text-matrix origin adjusted by the
71    /// text rise `Ts` (ISO 32000-1 §9.4.4): per the text-rendering
72    /// matrix the rise translates the rendering origin by `Trise`
73    /// along the text matrix's vertical basis, so a `4 Ts` superscript
74    /// reports a `position` shifted up from the surrounding baseline
75    /// rather than colliding with it. When no `Ts` is in force the
76    /// rise is `0` and the position is the bare text-matrix origin.
77    pub position: (f32, f32),
78    /// The PDF resource name of the font (`/F0`, `/F12`, etc.) — the
79    /// `/Tf` operand, with the leading `/` stripped. Empty when the
80    /// content stream issues a show without a preceding `Tf`
81    /// (malformed but tolerated).
82    pub font_name: String,
83    /// Font size as supplied to the `Tf` operator.
84    pub font_size: f32,
85    /// Text rendering mode in force at the moment of the show — the
86    /// most recent `Tr` operand (ISO 32000-1 §9.3.6, Table 106),
87    /// defaulting to [`TextRenderMode::Fill`] when no `Tr` preceded
88    /// the show. The load-bearing case for extraction consumers is
89    /// [`TextRenderMode::Invisible`] (`3 Tr`): the unpainted OCR text
90    /// layer scanners stack behind a page image. A keyword-search
91    /// consumer keeps it; a "what the human sees" consumer drops it.
92    pub render_mode: TextRenderMode,
93    /// Text rise in force at the moment of the show — the most recent
94    /// `Ts` operand (ISO 32000-1 §9.4.4 + §9.3.7, Table 105),
95    /// expressed in unscaled text-space units, defaulting to `0.0`
96    /// when no `Ts` preceded the show. A positive rise raises the
97    /// baseline (superscript); a negative rise lowers it (subscript).
98    /// The geometric effect is already folded into [`Self::position`];
99    /// this raw value lets a layout / accessibility consumer classify
100    /// a run as super/subscript without reverse-engineering the offset
101    /// from the position delta.
102    pub text_rise: f32,
103}
104
105/// Text rendering mode — the integer argument to the `Tr` operator
106/// (ISO 32000-1 §9.3.6, Table 106). Determines whether the glyphs of a
107/// text run are filled, stroked, used as a clipping boundary, or left
108/// unpainted entirely. Surfaced on every [`TextRun`] so an extraction
109/// consumer can distinguish visible body text from the invisible
110/// (`3 Tr`) OCR layer that scanned PDFs hide behind a page image, and
111/// from the clip-only (`7 Tr`) modes that paint no marks at all.
112#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
113pub enum TextRenderMode {
114    /// `0` — fill text (the default when no `Tr` is issued).
115    #[default]
116    Fill,
117    /// `1` — stroke text.
118    Stroke,
119    /// `2` — fill, then stroke text.
120    FillStroke,
121    /// `3` — neither fill nor stroke (invisible). The OCR text layer
122    /// behind a scanned-page image uses this so the glyphs are
123    /// searchable / selectable but never painted.
124    Invisible,
125    /// `4` — fill text and add to the path for clipping.
126    FillClip,
127    /// `5` — stroke text and add to the path for clipping.
128    StrokeClip,
129    /// `6` — fill, then stroke text and add to the path for clipping.
130    FillStrokeClip,
131    /// `7` — add text to the path for clipping (no fill, no stroke).
132    Clip,
133}
134
135impl TextRenderMode {
136    /// Resolve a `Tr` operand integer to its typed mode. Table 106
137    /// enumerates exactly `0..=7`; out-of-range values are tolerated by
138    /// mapping back to [`TextRenderMode::Fill`] (the §9.3.1 default
139    /// text state), matching the reader's lenient stance elsewhere.
140    pub fn from_operand(n: i64) -> Self {
141        match n {
142            0 => Self::Fill,
143            1 => Self::Stroke,
144            2 => Self::FillStroke,
145            3 => Self::Invisible,
146            4 => Self::FillClip,
147            5 => Self::StrokeClip,
148            6 => Self::FillStrokeClip,
149            7 => Self::Clip,
150            _ => Self::Fill,
151        }
152    }
153
154    /// Whether glyphs in this mode paint any visible marks. `false`
155    /// only for [`TextRenderMode::Invisible`] (`3`) and
156    /// [`TextRenderMode::Clip`] (`7`) — the two modes that add nothing
157    /// to the page raster. Lets a "visible text only" consumer filter
158    /// the OCR layer out in one call.
159    pub fn paints_glyphs(self) -> bool {
160        !matches!(self, Self::Invisible | Self::Clip)
161    }
162}
163
164/// One [`TextRun`] together with the marked-content tag stack it was
165/// emitted under (ISO 32000-1 §14.6 — Tagged PDF). Round-29 piggybacks
166/// on the same content-stream walker as [`extract_text`]; the only
167/// difference is that this variant records the most-recently-opened
168/// `BDC` block's `/MCID` (if any) and the indirect-object number of
169/// the page the run came from. The reading-order layout pass under
170/// [`crate::reader::layout`] consumes these to assemble runs in the
171/// order the StructTreeRoot's `/K` tree dictates (rather than the
172/// raster x/y order [`extract_text`] returns).
173#[derive(Clone, Debug, PartialEq)]
174pub struct MarkedTextRun {
175    /// The visual text run — same shape as [`TextRun`].
176    pub run: TextRun,
177    /// Most recently opened marked-content `/MCID` integer at the moment
178    /// of the show. `None` when no enclosing `BDC` block declared
179    /// `/MCID` (e.g. plain `BMC … EMC` decorative groupings).
180    pub mcid: Option<u32>,
181    /// PDF object number of the page the run came from. The reading-
182    /// order layout pass keys MCID lookups by `(page_obj_num, mcid)`
183    /// because a Tagged PDF may emit MCID 0 on every page.
184    pub page_obj_num: u32,
185    /// Zero-based page index in walk order (0 for the first page found).
186    pub page_index: u32,
187}
188
189/// All text runs collected from one page (or one whole document — the
190/// caller decides whether to merge across pages).
191#[derive(Clone, Debug, Default, PartialEq)]
192pub struct PdfTextExtraction {
193    pub runs: Vec<TextRun>,
194}
195
196/// All [`MarkedTextRun`]s collected from every page in walk order.
197/// Round-29 helper that the layout pass consumes; the runs themselves
198/// are still emitted in raster (content-stream) order — it's the
199/// `mcid` tag that lets the layout pass reorder them.
200#[derive(Clone, Debug, Default, PartialEq)]
201pub struct PdfMarkedTextExtraction {
202    pub runs: Vec<MarkedTextRun>,
203}
204
205impl PdfTextExtraction {
206    /// Concatenate every run's text with a single space between them.
207    /// Convenience for callers that only need a flat document-level
208    /// blob (e.g. keyword search). A real layout engine would walk the
209    /// individual runs + positions to reconstruct lines / paragraphs.
210    pub fn flat_text(&self) -> String {
211        self.runs
212            .iter()
213            .map(|r| r.text.as_str())
214            .collect::<Vec<_>>()
215            .join(" ")
216    }
217}
218
219impl<'a> DocumentReader<'a> {
220    /// Extract every text run from every page in stream order.
221    ///
222    /// See [`PdfTextExtraction`]. This is a thin wrapper around
223    /// [`extract_text`] that walks the catalog → /Pages tree, resolves
224    /// each page's `/Resources /Font` dict, and feeds the page's
225    /// concatenated `/Contents` stream into the walker.
226    pub fn text_extraction(&mut self) -> Result<PdfTextExtraction, PdfError> {
227        extract_text(self)
228    }
229
230    /// Round-29: extract every text run alongside the marked-content
231    /// `/MCID` tag the show was issued under (ISO 32000-1 §14.6 + §14.8).
232    /// Pair with [`crate::reader::layout::read_in_logical_order`] to
233    /// reorder the resulting runs by the StructTreeRoot's logical
234    /// `/K` tree.
235    pub fn marked_text_extraction(&mut self) -> Result<PdfMarkedTextExtraction, PdfError> {
236        extract_text_marked(self)
237    }
238}
239
240// ────────────────────────── walker entry point ──────────────────────────
241
242/// Walk every page in `reader`'s catalog and collect text runs in
243/// stream order.
244pub fn extract_text(reader: &mut DocumentReader<'_>) -> Result<PdfTextExtraction, PdfError> {
245    let leaves = collect_page_leaves(reader)?;
246    let mut out = PdfTextExtraction::default();
247    for leaf in leaves {
248        extract_page(reader, leaf, &mut out)?;
249    }
250    Ok(out)
251}
252
253/// Round-29: walk every page in `reader`'s catalog and collect
254/// marked-content-tagged text runs in stream order. The `mcid` slot
255/// reflects the most-recently-opened `BDC … EMC` block's `/MCID`
256/// integer; runs outside any `BDC` block (or inside a `BMC` block,
257/// which has no MCID) get `mcid = None`.
258pub fn extract_text_marked(
259    reader: &mut DocumentReader<'_>,
260) -> Result<PdfMarkedTextExtraction, PdfError> {
261    let leaves = collect_page_leaves(reader)?;
262    let mut out = PdfMarkedTextExtraction::default();
263    for (page_index, leaf) in leaves.into_iter().enumerate() {
264        extract_page_marked(reader, leaf, page_index as u32, &mut out)?;
265    }
266    Ok(out)
267}
268
269/// Walk catalog → /Pages tree and collect every leaf page's
270/// [`ObjectId`] in document order. Shared between [`extract_text`] and
271/// [`extract_text_marked`].
272/// Concatenate the `/Contents` stream(s) of a page leaf into a single
273/// byte buffer (an inter-stream separator is added so a stream that
274/// ends without a trailing newline can't accidentally fuse the start
275/// of the next stream's first operator into the end of its own last).
276/// Returns `Ok(None)` when the page has no `/Contents` (a perfectly
277/// valid blank page).
278///
279/// Exposed `pub` for sibling reader modules that need the same content
280/// stream view (e.g. inline-image extraction) without rebuilding the
281/// `/Resources /Font` walker the round-22 text extractor needs.
282pub fn concatenate_page_contents(
283    reader: &mut DocumentReader<'_>,
284    page_id: ObjectId,
285) -> Result<Option<Vec<u8>>, PdfError> {
286    let page_obj = reader.resolve(page_id)?;
287    let Object::Dict(page_dict) = page_obj else {
288        return Ok(None);
289    };
290    let contents_obj = page_dict
291        .entries()
292        .iter()
293        .find(|(k, _)| k == "Contents")
294        .map(|(_, v)| v.clone());
295    let bytes = match contents_obj {
296        Some(Object::Reference(id)) => extract_stream_data(reader, id)?,
297        Some(Object::Array(items)) => {
298            let mut all = Vec::new();
299            for item in items {
300                if let Object::Reference(id) = item {
301                    all.extend_from_slice(&extract_stream_data(reader, id)?);
302                    all.push(b'\n');
303                }
304            }
305            all
306        }
307        _ => return Ok(None),
308    };
309    Ok(Some(bytes))
310}
311
312pub(crate) fn collect_page_leaves(
313    reader: &mut DocumentReader<'_>,
314) -> Result<Vec<ObjectId>, PdfError> {
315    let root_id = reader.xref().root()?;
316    let catalog_obj = reader.resolve(root_id)?;
317    let Object::Dict(catalog) = catalog_obj else {
318        return Err(PdfError::other(format!(
319            "PDF text extraction: /Root must be a dictionary (got {catalog_obj:?})"
320        )));
321    };
322    let pages_ref = catalog
323        .entries()
324        .iter()
325        .find(|(k, _)| k == "Pages")
326        .map(|(_, v)| v.clone())
327        .ok_or_else(|| PdfError::other("PDF text extraction: catalog missing /Pages"))?;
328    let Object::Reference(pages_root_id) = pages_ref else {
329        return Err(PdfError::other(format!(
330            "PDF text extraction: catalog /Pages must be a reference (got {pages_ref:?})"
331        )));
332    };
333    let mut leaves = Vec::new();
334    walk_pages(reader, pages_root_id, &mut leaves)?;
335    Ok(leaves)
336}
337
338fn walk_pages(
339    reader: &mut DocumentReader<'_>,
340    node_id: ObjectId,
341    out: &mut Vec<ObjectId>,
342) -> Result<(), PdfError> {
343    let node = reader.resolve(node_id)?;
344    let Object::Dict(d) = node else {
345        return Err(PdfError::other(format!(
346            "PDF text extraction: /Pages node {node_id:?} is not a dict"
347        )));
348    };
349    let kind = d
350        .entries()
351        .iter()
352        .find(|(k, _)| k == "Type")
353        .and_then(|(_, v)| match v {
354            Object::Name(s) => Some(s.as_str()),
355            _ => None,
356        });
357    match kind {
358        Some("Page") => {
359            out.push(node_id);
360            Ok(())
361        }
362        Some("Pages") => {
363            let kids = d
364                .entries()
365                .iter()
366                .find(|(k, _)| k == "Kids")
367                .map(|(_, v)| v.clone())
368                .ok_or_else(|| {
369                    PdfError::other(format!(
370                        "PDF text extraction: /Pages node {node_id:?} missing /Kids"
371                    ))
372                })?;
373            let Object::Array(items) = kids else {
374                return Err(PdfError::other(format!(
375                    "PDF text extraction: /Kids must be an array on {node_id:?}"
376                )));
377            };
378            for item in items {
379                if let Object::Reference(id) = item {
380                    walk_pages(reader, id, out)?;
381                }
382            }
383            Ok(())
384        }
385        _ => {
386            // Unknown — skip silently. Avoids breaking on hand-laid
387            // PDFs whose intermediate /Pages nodes omit /Type.
388            Ok(())
389        }
390    }
391}
392
393/// Per-font byte→Unicode decoders keyed by `/Resources /Font` slot.
394type PageFonts = HashMap<String, FontDecoder>;
395
396/// Per-font horizontal advance metrics (glyph-space thousandths),
397/// used by the extraction walker to apply the §9.4.4 text-space
398/// displacement and so report a distinct origin for each text run on a
399/// line that lacks explicit `Td` / `Tm` repositioning.
400///
401/// Resolved by [`build_font_advance`] from the font dictionary at
402/// page-load time (where the [`DocumentReader`] is available to
403/// dereference indirect `/Widths` / `/W` arrays).
404#[derive(Clone, Debug)]
405enum FontAdvance {
406    /// Simple font (one byte per code, §9.6): `widths[code − first]`,
407    /// falling back to `missing` outside the array's range. `text_scale`
408    /// converts a stored width into text-space units (§9.2.4): `0.001`
409    /// for Type1 / TrueType, or the horizontal `/FontMatrix` component
410    /// for a Type 3 font, whose `/Widths` are in glyph space (§9.6.5).
411    Simple {
412        first: i64,
413        widths: Vec<f32>,
414        missing: f32,
415        text_scale: f32,
416    },
417    /// Composite Identity font (two bytes per code, CID = code,
418    /// §9.7.4.3): `/W` runs over `default` (the `/DW`).
419    Cid {
420        default: f32,
421        ranges: Vec<(i64, Vec<f32>)>,
422    },
423    /// No resolvable widths — every glyph advances 0 (prior behaviour).
424    None,
425}
426
427impl FontAdvance {
428    fn width(&self, code: i64) -> f32 {
429        match self {
430            FontAdvance::Simple {
431                first,
432                widths,
433                missing,
434                ..
435            } => {
436                let idx = code - first;
437                if idx >= 0 && (idx as usize) < widths.len() {
438                    widths[idx as usize]
439                } else {
440                    *missing
441                }
442            }
443            FontAdvance::Cid { default, ranges } => {
444                for (start, run) in ranges {
445                    let off = code - start;
446                    if off >= 0 && (off as usize) < run.len() {
447                        return run[off as usize];
448                    }
449                }
450                *default
451            }
452            FontAdvance::None => 0.0,
453        }
454    }
455
456    fn is_cid(&self) -> bool {
457        matches!(self, FontAdvance::Cid { .. })
458    }
459
460    /// Factor converting a [`Self::width`] result into text-space units
461    /// (§9.2.4). Type1 / TrueType and composite fonts use `0.001`; a
462    /// Type 3 font carries its `/FontMatrix` horizontal scale, since its
463    /// widths are in glyph space (§9.6.5).
464    fn text_scale(&self) -> f32 {
465        match self {
466            FontAdvance::Simple { text_scale, .. } => *text_scale,
467            _ => 0.001,
468        }
469    }
470}
471
472/// Read a font dictionary's horizontal advance metrics into a
473/// [`FontAdvance`] (§9.6.2.1 simple `/Widths`; §9.7.4.3 composite
474/// `/W` + `/DW`). Indirect `/Widths`, `/FontDescriptor`,
475/// `/DescendantFonts` and `/W` references are dereferenced through
476/// `reader`.
477fn build_font_advance(reader: &mut DocumentReader<'_>, font: &Dict) -> FontAdvance {
478    let subtype = font
479        .entries()
480        .iter()
481        .find_map(|(k, v)| match (k.as_str(), v) {
482            ("Subtype", Object::Name(s)) => Some(s.as_str()),
483            _ => None,
484        });
485    if subtype == Some("Type0") {
486        return build_cid_advance(reader, font);
487    }
488    let first = font
489        .entries()
490        .iter()
491        .find(|(k, _)| k == "FirstChar")
492        .and_then(|(_, v)| obj_as_i64(v))
493        .unwrap_or(0);
494    let widths_obj = font
495        .entries()
496        .iter()
497        .find(|(k, _)| k == "Widths")
498        .map(|(_, v)| v.clone());
499    let widths_obj = match widths_obj {
500        Some(Object::Reference(id)) => reader.resolve(id).ok(),
501        other => other,
502    };
503    let widths: Vec<f32> = match widths_obj {
504        Some(Object::Array(items)) => items.iter().map(|o| obj_as_f32(o).unwrap_or(0.0)).collect(),
505        _ => Vec::new(),
506    };
507    if widths.is_empty() {
508        return FontAdvance::None;
509    }
510    // /MissingWidth lives in the /FontDescriptor (§9.8.1 Table 122).
511    let descr = font
512        .entries()
513        .iter()
514        .find(|(k, _)| k == "FontDescriptor")
515        .map(|(_, v)| v.clone());
516    let descr = match descr {
517        Some(Object::Reference(id)) => reader.resolve(id).ok(),
518        other => other,
519    };
520    let missing = match descr {
521        Some(Object::Dict(d)) => d
522            .entries()
523            .iter()
524            .find(|(k, _)| k == "MissingWidth")
525            .and_then(|(_, v)| obj_as_f32(v))
526            .unwrap_or(0.0),
527        _ => 0.0,
528    };
529    // §9.6.5: a Type 3 font's /Widths are in glyph space and scaled to
530    // text space by the /FontMatrix horizontal component. Type1 /
531    // TrueType widths are already in thousandths of text space.
532    let text_scale = if subtype == Some("Type3") {
533        font.entries()
534            .iter()
535            .find(|(k, _)| k == "FontMatrix")
536            .and_then(|(_, v)| match v {
537                Object::Array(items) if items.len() == 6 => obj_as_f32(&items[0]),
538                _ => None,
539            })
540            .filter(|s| s.is_finite())
541            .unwrap_or(0.001)
542    } else {
543        0.001
544    };
545    FontAdvance::Simple {
546        first,
547        widths,
548        missing,
549        text_scale,
550    }
551}
552
553/// Resolve a Type0 font's descendant CIDFont advance metrics
554/// (§9.7.4.3): `/DW` default + `/W` per-CID runs.
555fn build_cid_advance(reader: &mut DocumentReader<'_>, font: &Dict) -> FontAdvance {
556    let desc_obj = font
557        .entries()
558        .iter()
559        .find(|(k, _)| k == "DescendantFonts")
560        .map(|(_, v)| v.clone());
561    let desc_obj = match desc_obj {
562        Some(Object::Reference(id)) => reader.resolve(id).ok(),
563        other => other,
564    };
565    // Pull the sole CIDFont dict out of the (usually one-element) array.
566    let cid_obj = match desc_obj {
567        Some(Object::Array(items)) => items.into_iter().next(),
568        Some(Object::Dict(d)) => Some(Object::Dict(d)),
569        _ => None,
570    };
571    let cid_obj = match cid_obj {
572        Some(Object::Reference(id)) => reader.resolve(id).ok(),
573        other => other,
574    };
575    let Some(Object::Dict(cid_font)) = cid_obj else {
576        return FontAdvance::Cid {
577            default: 1000.0,
578            ranges: Vec::new(),
579        };
580    };
581    let default = cid_font
582        .entries()
583        .iter()
584        .find(|(k, _)| k == "DW")
585        .and_then(|(_, v)| obj_as_f32(v))
586        .unwrap_or(1000.0);
587    let w_obj = cid_font
588        .entries()
589        .iter()
590        .find(|(k, _)| k == "W")
591        .map(|(_, v)| v.clone());
592    let w_obj = match w_obj {
593        Some(Object::Reference(id)) => reader.resolve(id).ok(),
594        other => other,
595    };
596    let ranges = match w_obj {
597        Some(Object::Array(items)) => parse_w_array(&items),
598        _ => Vec::new(),
599    };
600    FontAdvance::Cid { default, ranges }
601}
602
603/// Parse a CIDFont `/W` array (§9.7.4.3) into `(start_cid, widths)`
604/// runs. Groups are `c [w1 … wn]` or `cfirst clast w`.
605fn parse_w_array(items: &[Object]) -> Vec<(i64, Vec<f32>)> {
606    let mut out = Vec::new();
607    let mut i = 0;
608    while i < items.len() {
609        let Some(c) = obj_as_i64(&items[i]) else {
610            i += 1;
611            continue;
612        };
613        match items.get(i + 1) {
614            Some(Object::Array(ws)) => {
615                let run: Vec<f32> = ws.iter().map(|o| obj_as_f32(o).unwrap_or(0.0)).collect();
616                out.push((c, run));
617                i += 2;
618            }
619            Some(obj) => {
620                let clast = obj_as_i64(obj);
621                let w = items.get(i + 2).and_then(obj_as_f32);
622                match (clast, w) {
623                    (Some(clast), Some(w)) if clast >= c => {
624                        let count = (clast - c + 1).min(1 << 20) as usize;
625                        out.push((c, vec![w; count]));
626                        i += 3;
627                    }
628                    _ => i += 1,
629                }
630            }
631            None => break,
632        }
633    }
634    out
635}
636
637fn obj_as_f32(o: &Object) -> Option<f32> {
638    match o {
639        Object::Integer(i) => Some(*i as f32),
640        Object::Real(r) => Some(*r as f32),
641        _ => None,
642    }
643}
644
645fn obj_as_i64(o: &Object) -> Option<i64> {
646    match o {
647        Object::Integer(i) => Some(*i),
648        Object::Real(r) => Some(*r as i64),
649        _ => None,
650    }
651}
652
653/// Resolve a page leaf into the pieces the text walker needs:
654/// per-font byte→Unicode decoders + the concatenated content stream.
655/// Returns `None` when the page has no `/Contents` (a perfectly valid
656/// blank page — emit nothing).
657type PageAdvances = HashMap<String, FontAdvance>;
658
659fn load_page_for_text(
660    reader: &mut DocumentReader<'_>,
661    page_id: ObjectId,
662) -> Result<Option<(PageFonts, PageAdvances, Vec<u8>)>, PdfError> {
663    let page_obj = reader.resolve(page_id)?;
664    let Object::Dict(page_dict) = page_obj else {
665        return Ok(None);
666    };
667
668    // Resolve the page's /Resources /Font subdict — each entry maps a
669    // resource name (`F0`) to a font dictionary. Inheritance from
670    // /Pages parents is round-22 deferred; the writer always attaches
671    // /Resources directly to the leaf page.
672    let resources = page_dict
673        .entries()
674        .iter()
675        .find(|(k, _)| k == "Resources")
676        .map(|(_, v)| v.clone());
677    let resources = match resources {
678        Some(Object::Reference(id)) => reader.resolve(id)?,
679        Some(other) => other,
680        None => return Ok(None),
681    };
682    let mut fonts: HashMap<String, FontDecoder> = HashMap::new();
683    let mut advances: HashMap<String, FontAdvance> = HashMap::new();
684    if let Object::Dict(rdict) = resources {
685        let font_dict = rdict
686            .entries()
687            .iter()
688            .find(|(k, _)| k == "Font")
689            .map(|(_, v)| v.clone());
690        if let Some(font_obj) = font_dict {
691            let font_obj = match font_obj {
692                Object::Reference(id) => reader.resolve(id)?,
693                other => other,
694            };
695            if let Object::Dict(fd) = font_obj {
696                for (name, val) in fd.entries() {
697                    let resolved = match val {
698                        Object::Reference(id) => reader.resolve(*id)?,
699                        other => other.clone(),
700                    };
701                    if let Object::Dict(font_d) = resolved {
702                        let decoder = FontDecoder::from_dict(reader, &font_d)?;
703                        fonts.insert(name.clone(), decoder);
704                        let advance = build_font_advance(reader, &font_d);
705                        advances.insert(name.clone(), advance);
706                    }
707                }
708            }
709        }
710    }
711
712    // Concatenate /Contents.
713    let contents_obj = page_dict
714        .entries()
715        .iter()
716        .find(|(k, _)| k == "Contents")
717        .map(|(_, v)| v.clone());
718    let content_bytes = match contents_obj {
719        Some(Object::Reference(id)) => extract_stream_data(reader, id)?,
720        Some(Object::Array(items)) => {
721            let mut all = Vec::new();
722            for item in items {
723                if let Object::Reference(id) = item {
724                    all.extend_from_slice(&extract_stream_data(reader, id)?);
725                    all.push(b'\n');
726                }
727            }
728            all
729        }
730        _ => return Ok(None),
731    };
732
733    Ok(Some((fonts, advances, content_bytes)))
734}
735
736fn extract_page(
737    reader: &mut DocumentReader<'_>,
738    page_id: ObjectId,
739    out: &mut PdfTextExtraction,
740) -> Result<(), PdfError> {
741    let Some((fonts, advances, content_bytes)) = load_page_for_text(reader, page_id)? else {
742        return Ok(());
743    };
744    let mut walker = TextWalker::new(fonts, advances);
745    walker.parse(&content_bytes)?;
746    out.runs.extend(walker.into_runs());
747    Ok(())
748}
749
750fn extract_page_marked(
751    reader: &mut DocumentReader<'_>,
752    page_id: ObjectId,
753    page_index: u32,
754    out: &mut PdfMarkedTextExtraction,
755) -> Result<(), PdfError> {
756    let Some((fonts, advances, content_bytes)) = load_page_for_text(reader, page_id)? else {
757        return Ok(());
758    };
759    let mut walker = TextWalker::new(fonts, advances);
760    walker.track_mcid = true;
761    walker.parse(&content_bytes)?;
762    let runs = walker.into_runs_with_mcid();
763    for (run, mcid) in runs {
764        out.runs.push(MarkedTextRun {
765            run,
766            mcid,
767            page_obj_num: page_id.number,
768            page_index,
769        });
770    }
771    Ok(())
772}
773
774fn extract_stream_data(reader: &mut DocumentReader<'_>, id: ObjectId) -> Result<Vec<u8>, PdfError> {
775    let obj = reader.resolve(id)?;
776    let Object::Stream(s) = obj else {
777        return Err(PdfError::other(format!(
778            "PDF text extraction: object {id:?} expected to be a Stream"
779        )));
780    };
781    decode_stream(&s)
782}
783
784// ────────────────────────── font decoder ──────────────────────────
785
786/// Per-font byte-to-Unicode decoder. The variant is picked at
787/// `/Resources /Font /Fx` resolution time and reused for every
788/// subsequent `Tj` / `TJ` / `'` / `"` operand against that font.
789#[derive(Clone, Debug)]
790enum FontDecoder {
791    /// `/ToUnicode` CMap supplied — every show operand is split into
792    /// 2-byte CIDs (or 1-byte codes for simple fonts whose CMap also
793    /// uses the 2-byte path) and looked up.
794    ToUnicode { map: CMap, cid_width: u8 },
795    /// Identity-H / Identity-V without /ToUnicode — interpret each
796    /// 2-byte CID as the equivalent BMP code point.
797    IdentityNoCMap,
798    /// Simple font (Type1 / TrueType / Type3) with a resolved
799    /// 256-entry byte → Unicode table. Captures every variant of
800    /// ISO 32000-1 §9.6.6.1 — named base encodings, encoding-dict
801    /// `/BaseEncoding` + `/Differences`, and the implicit
802    /// StandardEncoding default. Replaces the old `WinAnsi` /
803    /// `MacRoman` enum tags so a single code path handles every
804    /// simple-font encoding variant (round 28).
805    ///
806    /// Boxed because the 256-entry table dwarfs the other variants
807    /// and `clippy::large_enum_variant` flags the unboxed form.
808    SimpleMap(Box<EncodingMap>),
809    /// No discernible encoding — fall back to Latin-1 byte → code
810    /// point (identity for ASCII; reasonable for CP1252 punctuation).
811    Latin1,
812}
813
814impl FontDecoder {
815    fn from_dict(reader: &mut DocumentReader<'_>, font: &Dict) -> Result<FontDecoder, PdfError> {
816        // 1. /ToUnicode wins regardless of the /Subtype — even simple
817        // fonts may carry one (PDF/UA mandates it for text extraction).
818        let to_unicode = font
819            .entries()
820            .iter()
821            .find(|(k, _)| k == "ToUnicode")
822            .map(|(_, v)| v.clone());
823        if let Some(tu) = to_unicode {
824            let stream_obj = match tu {
825                Object::Reference(id) => reader.resolve(id)?,
826                other => other,
827            };
828            if let Object::Stream(s) = stream_obj {
829                let bytes = decode_stream(&s)?;
830                let map = CMap::parse(&bytes)?;
831                let cid_width = map.byte_width;
832                return Ok(FontDecoder::ToUnicode { map, cid_width });
833            }
834        }
835
836        // 2. Composite font (Type0) without /ToUnicode — Identity-H/V.
837        let subtype = font
838            .entries()
839            .iter()
840            .find(|(k, _)| k == "Subtype")
841            .and_then(|(_, v)| match v {
842                Object::Name(s) => Some(s.as_str()),
843                _ => None,
844            })
845            .unwrap_or("");
846        if subtype == "Type0" {
847            // /Encoding tells us Identity-H / Identity-V vs a named
848            // CMap. Round-22 supports the identities (the only ones
849            // the writer would ever emit).
850            let enc = font
851                .entries()
852                .iter()
853                .find(|(k, _)| k == "Encoding")
854                .map(|(_, v)| v.clone());
855            if let Some(Object::Name(name)) = enc {
856                if name == "Identity-H" || name == "Identity-V" {
857                    return Ok(FontDecoder::IdentityNoCMap);
858                }
859            }
860            // Unknown composite — Identity is the safest default.
861            return Ok(FontDecoder::IdentityNoCMap);
862        }
863
864        // 3. Simple font with named /Encoding.
865        let enc = font
866            .entries()
867            .iter()
868            .find(|(k, _)| k == "Encoding")
869            .map(|(_, v)| v.clone());
870        if let Some(Object::Name(name)) = enc {
871            if let Some(base) = BaseEncoding::from_name(name.as_str()) {
872                return Ok(FontDecoder::SimpleMap(Box::new(EncodingMap::from_base(
873                    base,
874                ))));
875            }
876            // Unknown base name — fall back to Latin-1.
877            return Ok(FontDecoder::Latin1);
878        }
879        // /Encoding may also be a dict — `/BaseEncoding` + `/Differences`
880        // per ISO 32000-1 §9.6.6.1. Round 28 honours both: the
881        // /Differences array overrides specific byte slots from the
882        // base map, and each glyph name is resolved through the AGL.
883        if let Some(Object::Dict(enc_d)) = enc {
884            // Resolve the base map. Per the spec, when /BaseEncoding
885            // is absent the default depends on the font subtype — for
886            // Type1 / Type3 it's the font's built-in encoding (which
887            // we don't have access to here, so we use Standard as the
888            // closest documented fallback); for TrueType it's the
889            // implementation-defined platform encoding (we use
890            // WinAnsi because Acrobat / Distiller default to it).
891            let base_name = enc_d
892                .entries()
893                .iter()
894                .find(|(k, _)| k == "BaseEncoding")
895                .and_then(|(_, v)| match v {
896                    Object::Name(s) => Some(s.clone()),
897                    _ => None,
898                });
899            let base_map = match base_name.as_deref().and_then(BaseEncoding::from_name) {
900                Some(b) => EncodingMap::from_base(b),
901                None => {
902                    // No (recognised) /BaseEncoding — pick a sensible
903                    // default per the font subtype.
904                    let default = match subtype {
905                        "TrueType" => BaseEncoding::WinAnsi,
906                        _ => BaseEncoding::Standard,
907                    };
908                    EncodingMap::from_base(default)
909                }
910            };
911            // Overlay /Differences if present.
912            let diffs_obj = enc_d
913                .entries()
914                .iter()
915                .find(|(k, _)| k == "Differences")
916                .map(|(_, v)| v.clone());
917            let final_map = match diffs_obj {
918                Some(arr @ Object::Array(_)) => {
919                    let diffs = parse_encoding_differences(&arr)?;
920                    apply_encoding_differences(&base_map, &diffs)
921                }
922                _ => base_map,
923            };
924            return Ok(FontDecoder::SimpleMap(Box::new(final_map)));
925        }
926        // Default for Type1 / Type3 with no /Encoding is
927        // StandardEncoding (§9.6.6.1). TrueType with no /Encoding is
928        // implementation-dependent — use WinAnsi.
929        let default = match subtype {
930            "TrueType" => BaseEncoding::WinAnsi,
931            "Type1" | "Type3" | "MMType1" => BaseEncoding::Standard,
932            _ => return Ok(FontDecoder::Latin1),
933        };
934        Ok(FontDecoder::SimpleMap(Box::new(EncodingMap::from_base(
935            default,
936        ))))
937    }
938
939    /// Decode a `Tj` / `TJ` operand byte-string into Unicode.
940    fn decode(&self, bytes: &[u8]) -> String {
941        match self {
942            FontDecoder::ToUnicode { map, cid_width } => {
943                let mut out = String::new();
944                // When the CMap declared `codespacerange` entries, walk
945                // bytes left-to-right per Adobe Tech Note #5411 §2 +
946                // Tech Note #5014 §3.1: at each position, try each
947                // codespace in declaration order and pick the first
948                // whose `lo..=hi` byte-component bounds cover the
949                // candidate input prefix. Unmatched input advances by
950                // one byte and emits U+FFFD. This is what makes
951                // mixed-width CMaps (1-byte ASCII passthrough alongside
952                // a 2-byte CJK territory) decode correctly.
953                if !map.codespaces.is_empty() {
954                    let mut i = 0;
955                    while i < bytes.len() {
956                        if let Some(w) = map.match_codespace_width(&bytes[i..]) {
957                            let cid = bytes_to_u32(&bytes[i..i + w]);
958                            if let Some(s) = map.lookup(cid) {
959                                out.push_str(s);
960                            } else {
961                                out.push('\u{FFFD}');
962                            }
963                            i += w;
964                        } else {
965                            // No codespace covered this position. Adobe
966                            // Tech Note #5411 §2 says the decoder
967                            // should emit U+FFFD for the unmatched
968                            // prefix and resume scanning; we resume at
969                            // the next byte (the conservative choice
970                            // that doesn't drop subsequent in-codespace
971                            // input).
972                            out.push('\u{FFFD}');
973                            i += 1;
974                        }
975                    }
976                    return out;
977                }
978                // No codespacerange declared — fall back to the legacy
979                // single-width decode using the width inferred from the
980                // first bfchar / bfrange source operand. Handles hand-
981                // crafted CMaps that omit the §9.10.3 mandatory header.
982                let w = *cid_width as usize;
983                let mut i = 0;
984                while i + w <= bytes.len() {
985                    let cid = match w {
986                        1 => bytes[i] as u32,
987                        2 => ((bytes[i] as u32) << 8) | (bytes[i + 1] as u32),
988                        _ => {
989                            // Unsupported width — skip the rest.
990                            break;
991                        }
992                    };
993                    if let Some(s) = map.lookup(cid) {
994                        out.push_str(s);
995                    } else {
996                        // Unmapped CID — emit U+FFFD as a marker so
997                        // callers know decoding was lossy at that
998                        // offset.
999                        out.push('\u{FFFD}');
1000                    }
1001                    i += w;
1002                }
1003                out
1004            }
1005            FontDecoder::IdentityNoCMap => {
1006                // 2-byte CID → BMP code point.
1007                let mut out = String::new();
1008                let mut i = 0;
1009                while i + 2 <= bytes.len() {
1010                    let cp = ((bytes[i] as u32) << 8) | (bytes[i + 1] as u32);
1011                    if let Some(c) = char::from_u32(cp) {
1012                        out.push(c);
1013                    }
1014                    i += 2;
1015                }
1016                out
1017            }
1018            FontDecoder::SimpleMap(map) => map.decode(bytes),
1019            FontDecoder::Latin1 => bytes.iter().map(|&b| b as char).collect(),
1020        }
1021    }
1022}
1023
1024// ────────────────────────── CMap parser ──────────────────────────
1025
1026/// One `<lo> <hi>` pair declared inside a `begincodespacerange` /
1027/// `endcodespacerange` block. The codespace's byte width is the length
1028/// of `lo` (and `hi`), and `lo` / `hi` carry the inclusive bounds of
1029/// the in-codespace input byte sequences for that width.
1030///
1031/// Adobe Tech Note #5411 ("ToUnicode CMap File Tutorial") §2 + Adobe
1032/// Tech Note #5014 §3.1 spell out the per-byte hierarchical match: a
1033/// codespace `<8140>..<FCFC>` accepts the 2-byte input `8175` if and
1034/// only if **each byte** falls inside the corresponding `lo[i]..hi[i]`
1035/// slot — *not* the linear u32 interval `bytes_to_u32(lo)..bytes_to_u32(hi)`.
1036/// (That hierarchical rule is what lets a CJK CMap declare
1037/// `<00> <80>` for ASCII passthrough and `<8140> <FCFC>` for the
1038/// Shift-JIS-shaped two-byte territory without the two-byte range
1039/// implicitly covering `<8181>..<8189>` etc. that the linear u32
1040/// interval would imply.)
1041#[derive(Clone, Debug)]
1042pub(crate) struct CodespaceRange {
1043    pub lo: Vec<u8>,
1044    pub hi: Vec<u8>,
1045}
1046
1047impl CodespaceRange {
1048    fn width(&self) -> usize {
1049        self.lo.len()
1050    }
1051
1052    /// True iff `bytes[..self.width()]` is component-wise inside
1053    /// `lo..=hi` per Adobe Tech Note #5014 §3.1.
1054    fn matches(&self, bytes: &[u8]) -> bool {
1055        let w = self.width();
1056        if bytes.len() < w {
1057            return false;
1058        }
1059        bytes[..w]
1060            .iter()
1061            .zip(self.lo.iter().zip(self.hi.iter()))
1062            .all(|(b, (lo, hi))| b >= lo && b <= hi)
1063    }
1064}
1065
1066/// A parsed `/ToUnicode` CMap (ISO 32000-1 §9.10.3 + Adobe Tech Note
1067/// #5411 "ToUnicode CMap File Tutorial" + Adobe Tech Note #5014
1068/// "CMap & CIDFont Files Specification"). The parser covers the slice
1069/// the spec mandates for text-extraction CMaps:
1070///
1071/// * `begincodespacerange ... endcodespacerange` — the per-width input
1072///   byte territory the CMap is defined over. Mixed widths (e.g. a
1073///   1-byte ASCII passthrough alongside a 2-byte CJK territory) are
1074///   captured per range, not collapsed to a single global width.
1075/// * `beginbfchar ... endbfchar` — explicit `<src> -> <dst>` Unicode
1076///   mappings.
1077/// * `beginbfrange ... endbfrange` — `<lo> <hi> <dst>` (scalar) /
1078///   `<lo> <hi> [<dst0> <dst1> …]` (per-source array) Unicode
1079///   mappings.
1080#[derive(Clone, Debug, Default)]
1081pub(crate) struct CMap {
1082    /// CID (interpreted as u32) → UTF-8 string. Multi-character target
1083    /// strings (ligatures, combining marks) are common — `<FB01>` for
1084    /// `fi` is the canonical example.
1085    table: HashMap<u32, String>,
1086    /// Inferred from the first bfchar/bfrange source operand. 1 for
1087    /// simple fonts (rare — usually accompanied by a tiny WinAnsi-ish
1088    /// table), 2 for the standard CIDFont case. Used as the fallback
1089    /// width when no `codespacerange` block was declared (legacy / hand-
1090    /// crafted CMaps that omit the §9.10.3 mandatory header).
1091    pub(crate) byte_width: u8,
1092    /// Declared `codespacerange` entries, in declaration order. When
1093    /// non-empty, the decoder walks input bytes left-to-right, trying
1094    /// each codespace's width in declaration order at every position
1095    /// and selecting the first whose `lo..=hi` byte-component bounds
1096    /// cover the candidate input prefix. This is what makes mixed-width
1097    /// CMaps (the Adobe-Japan1 / Adobe-GB1 family) decode correctly —
1098    /// a 1-byte ASCII range and a 2-byte CJK range coexist in the same
1099    /// CMap and the per-codespace width selection picks the right one
1100    /// per input byte position.
1101    pub(crate) codespaces: Vec<CodespaceRange>,
1102}
1103
1104impl CMap {
1105    pub(crate) fn parse(bytes: &[u8]) -> Result<CMap, PdfError> {
1106        let mut cm = CMap {
1107            byte_width: 2, // canonical default; bfchar/bfrange may override
1108            ..CMap::default()
1109        };
1110        let mut i = 0;
1111        while i < bytes.len() {
1112            // Skip whitespace + comments.
1113            i = skip_ws_and_comments(bytes, i);
1114            if i >= bytes.len() {
1115                break;
1116            }
1117            if let Some(rest) = peek_keyword(bytes, i, b"begincodespacerange") {
1118                i = rest;
1119                i = parse_codespacerange(bytes, i, &mut cm)?;
1120                continue;
1121            }
1122            // bfchar block: `N beginbfchar … endbfchar`.
1123            if let Some(rest) = peek_keyword(bytes, i, b"beginbfchar") {
1124                i = rest;
1125                i = parse_bfchar(bytes, i, &mut cm)?;
1126                continue;
1127            }
1128            if let Some(rest) = peek_keyword(bytes, i, b"beginbfrange") {
1129                i = rest;
1130                i = parse_bfrange(bytes, i, &mut cm)?;
1131                continue;
1132            }
1133            // Skip any other token — the CMap header (`CMapName`,
1134            // `CIDSystemInfo`, etc.) and any non-bf / non-codespace
1135            // blocks (`cidchar`, `cidrange`, `notdefchar`, `notdefrange`,
1136            // …) are ignored: only the bf / codespace surface is
1137            // load-bearing for Unicode extraction.
1138            i = skip_token(bytes, i);
1139        }
1140        Ok(cm)
1141    }
1142
1143    fn lookup(&self, cid: u32) -> Option<&str> {
1144        self.table.get(&cid).map(|s| s.as_str())
1145    }
1146
1147    /// Find the codespace whose width-prefix of `bytes` matches per the
1148    /// Adobe Tech Note #5014 §3.1 byte-component rule, returning the
1149    /// matched width (1..=4). `None` when no codespace matches at this
1150    /// position. Codespaces are walked in declaration order so a CMap
1151    /// that lists `<00><7F>` (1 byte) before `<8140><FCFC>` (2 bytes)
1152    /// picks 1 byte for `0x41` and 2 bytes for `0x81 0x40`, matching
1153    /// what the §9.10.3 decoder is required to do.
1154    fn match_codespace_width(&self, bytes: &[u8]) -> Option<usize> {
1155        for cs in &self.codespaces {
1156            if cs.matches(bytes) {
1157                return Some(cs.width());
1158            }
1159        }
1160        None
1161    }
1162}
1163
1164fn parse_codespacerange(bytes: &[u8], mut i: usize, cm: &mut CMap) -> Result<usize, PdfError> {
1165    loop {
1166        i = skip_ws_and_comments(bytes, i);
1167        if i >= bytes.len() {
1168            return Err(PdfError::other(
1169                "PDF CMap: unterminated begincodespacerange block",
1170            ));
1171        }
1172        if let Some(rest) = peek_keyword(bytes, i, b"endcodespacerange") {
1173            return Ok(rest);
1174        }
1175        // One codespace entry: `<lo> <hi>`. Both hex strings must share
1176        // the same byte width (the codespace width) per Adobe Tech Note
1177        // #5014 §3.1 / Tech Note #5411 §2. A `lo`/`hi` pair whose
1178        // widths diverge is ill-formed; we skip it tolerantly so a
1179        // malformed CMap doesn't deny the rest of the document.
1180        let (lo, after_lo) = read_hex_string_payload(bytes, i)?;
1181        i = after_lo;
1182        i = skip_ws_and_comments(bytes, i);
1183        let (hi, after_hi) = read_hex_string_payload(bytes, i)?;
1184        i = after_hi;
1185        if lo.is_empty() || hi.is_empty() || lo.len() != hi.len() {
1186            continue;
1187        }
1188        // Cap width at 4 (the Adobe Tech Note #5014 §3.1 ceiling: PS
1189        // CMaps allow 1..=4 byte codespaces). Anything wider is an
1190        // out-of-spec CMap; ignore the entry rather than risk an
1191        // unbounded width that the decoder can't handle anyway.
1192        if lo.len() > 4 {
1193            continue;
1194        }
1195        cm.codespaces.push(CodespaceRange { lo, hi });
1196    }
1197}
1198
1199fn parse_bfchar(bytes: &[u8], mut i: usize, cm: &mut CMap) -> Result<usize, PdfError> {
1200    loop {
1201        i = skip_ws_and_comments(bytes, i);
1202        if i >= bytes.len() {
1203            return Err(PdfError::other("PDF CMap: unterminated beginbfchar block"));
1204        }
1205        if let Some(rest) = peek_keyword(bytes, i, b"endbfchar") {
1206            return Ok(rest);
1207        }
1208        // Two hex strings: <src> <dst>
1209        let (src_bytes, after_src) = read_hex_string_payload(bytes, i)?;
1210        i = after_src;
1211        i = skip_ws_and_comments(bytes, i);
1212        let (dst_bytes, after_dst) = read_hex_string_payload(bytes, i)?;
1213        i = after_dst;
1214        // Capture byte_width from the first src — only used as a
1215        // fallback when no codespacerange block was declared.
1216        if !src_bytes.is_empty() {
1217            cm.byte_width = src_bytes.len() as u8;
1218        }
1219        let cid = bytes_to_u32(&src_bytes);
1220        let s = utf16be_to_string(&dst_bytes);
1221        cm.table.insert(cid, s);
1222    }
1223}
1224
1225fn parse_bfrange(bytes: &[u8], mut i: usize, cm: &mut CMap) -> Result<usize, PdfError> {
1226    loop {
1227        i = skip_ws_and_comments(bytes, i);
1228        if i >= bytes.len() {
1229            return Err(PdfError::other("PDF CMap: unterminated beginbfrange block"));
1230        }
1231        if let Some(rest) = peek_keyword(bytes, i, b"endbfrange") {
1232            return Ok(rest);
1233        }
1234        let (lo_bytes, after_lo) = read_hex_string_payload(bytes, i)?;
1235        i = after_lo;
1236        i = skip_ws_and_comments(bytes, i);
1237        let (hi_bytes, after_hi) = read_hex_string_payload(bytes, i)?;
1238        i = after_hi;
1239        if !lo_bytes.is_empty() {
1240            cm.byte_width = lo_bytes.len() as u8;
1241        }
1242        let lo = bytes_to_u32(&lo_bytes);
1243        let hi = bytes_to_u32(&hi_bytes);
1244        i = skip_ws_and_comments(bytes, i);
1245        // Two shapes per ISO 32000-1 §9.10.3:
1246        //   <lo> <hi> <dst-start>      -- consecutive scalar dst
1247        //   <lo> <hi> [ <dst0> <dst1> ... ] -- per-source explicit list
1248        if i < bytes.len() && bytes[i] == b'[' {
1249            // Array form.
1250            i += 1;
1251            let mut dst_idx = 0u32;
1252            loop {
1253                i = skip_ws_and_comments(bytes, i);
1254                if i >= bytes.len() {
1255                    return Err(PdfError::other("PDF CMap: unterminated bfrange array"));
1256                }
1257                if bytes[i] == b']' {
1258                    i += 1;
1259                    break;
1260                }
1261                let (dst_bytes, after) = read_hex_string_payload(bytes, i)?;
1262                i = after;
1263                let s = utf16be_to_string(&dst_bytes);
1264                let cid = lo + dst_idx;
1265                if cid > hi {
1266                    // More entries than the range — PDF generators
1267                    // sometimes do this; ignore extras.
1268                    continue;
1269                }
1270                cm.table.insert(cid, s);
1271                dst_idx += 1;
1272            }
1273        } else {
1274            // Scalar form — (hi - lo + 1) consecutive dst code points.
1275            let (dst_bytes, after) = read_hex_string_payload(bytes, i)?;
1276            i = after;
1277            // Treat the dst as a UTF-16BE string; only the LAST code unit
1278            // increments per the PDF spec ("if the range is N codes long,
1279            // the destinations are <dst_start>, <dst_start+1>, …"). We
1280            // implement the simplified rule: increment the trailing
1281            // 16-bit unit (or 8-bit if the dst is a single byte).
1282            let dst_str = utf16be_to_string(&dst_bytes);
1283            let count = hi.saturating_sub(lo) + 1;
1284            // Decompose the dst string into chars; for the
1285            // single-char-target case (the common one), iterate chars.
1286            if dst_str.chars().count() == 1 {
1287                let base = dst_str.chars().next().unwrap() as u32;
1288                for k in 0..count {
1289                    let cid = lo + k;
1290                    if let Some(c) = char::from_u32(base + k) {
1291                        cm.table.insert(cid, String::from(c));
1292                    }
1293                }
1294            } else {
1295                // Multi-char dst (ligature etc.) — only the first source
1296                // gets the explicit string; the rest get the
1297                // last-char-incremented form.
1298                let mut chars: Vec<char> = dst_str.chars().collect();
1299                for k in 0..count {
1300                    let cid = lo + k;
1301                    cm.table.insert(cid, chars.iter().collect::<String>());
1302                    if let Some(last) = chars.last_mut() {
1303                        if let Some(next) = char::from_u32(*last as u32 + 1) {
1304                            *last = next;
1305                        }
1306                    }
1307                }
1308            }
1309        }
1310    }
1311}
1312
1313fn read_hex_string_payload(bytes: &[u8], start: usize) -> Result<(Vec<u8>, usize), PdfError> {
1314    if start >= bytes.len() || bytes[start] != b'<' {
1315        return Err(PdfError::other(format!(
1316            "PDF CMap: expected hex string at byte {start}"
1317        )));
1318    }
1319    let mut nibbles = Vec::new();
1320    let mut i = start + 1;
1321    while i < bytes.len() && bytes[i] != b'>' {
1322        let b = bytes[i];
1323        if let Some(v) = hex_nibble(b) {
1324            nibbles.push(v);
1325        } else if !is_ws(b) {
1326            // Some CMap producers embed `,` or other separators —
1327            // skip them per the spec's "ignore non-hex" guidance.
1328        }
1329        i += 1;
1330    }
1331    if i >= bytes.len() {
1332        return Err(PdfError::other(
1333            "PDF CMap: unterminated hex string in bfchar/bfrange",
1334        ));
1335    }
1336    // Skip the closing `>`.
1337    i += 1;
1338    // Pad odd-length to even with a trailing 0 (PDF §7.3.4.3).
1339    if nibbles.len() % 2 == 1 {
1340        nibbles.push(0);
1341    }
1342    let mut out = Vec::with_capacity(nibbles.len() / 2);
1343    for pair in nibbles.chunks_exact(2) {
1344        out.push((pair[0] << 4) | pair[1]);
1345    }
1346    Ok((out, i))
1347}
1348
1349fn bytes_to_u32(b: &[u8]) -> u32 {
1350    let mut v = 0u32;
1351    for &x in b {
1352        v = (v << 8) | (x as u32);
1353    }
1354    v
1355}
1356
1357fn utf16be_to_string(b: &[u8]) -> String {
1358    // PDF /ToUnicode dst is UTF-16BE per ISO 32000-1 §9.10.3. A single
1359    // byte is treated as one Latin-1 char (some hand-crafted CMaps for
1360    // simple fonts do this).
1361    if b.len() == 1 {
1362        return String::from(b[0] as char);
1363    }
1364    let mut units: Vec<u16> = Vec::with_capacity(b.len() / 2);
1365    for chunk in b.chunks_exact(2) {
1366        units.push(u16::from_be_bytes([chunk[0], chunk[1]]));
1367    }
1368    String::from_utf16_lossy(&units)
1369}
1370
1371fn hex_nibble(b: u8) -> Option<u8> {
1372    match b {
1373        b'0'..=b'9' => Some(b - b'0'),
1374        b'a'..=b'f' => Some(10 + (b - b'a')),
1375        b'A'..=b'F' => Some(10 + (b - b'A')),
1376        _ => None,
1377    }
1378}
1379
1380fn is_ws(b: u8) -> bool {
1381    matches!(b, 0x00 | b'\t' | b'\n' | 0x0C | b'\r' | b' ')
1382}
1383
1384fn skip_ws_and_comments(bytes: &[u8], mut i: usize) -> usize {
1385    loop {
1386        while i < bytes.len() && is_ws(bytes[i]) {
1387            i += 1;
1388        }
1389        if i < bytes.len() && bytes[i] == b'%' {
1390            // PostScript-style comment to EOL.
1391            while i < bytes.len() && bytes[i] != b'\n' && bytes[i] != b'\r' {
1392                i += 1;
1393            }
1394            continue;
1395        }
1396        return i;
1397    }
1398}
1399
1400fn peek_keyword(bytes: &[u8], i: usize, kw: &[u8]) -> Option<usize> {
1401    if i + kw.len() > bytes.len() {
1402        return None;
1403    }
1404    if &bytes[i..i + kw.len()] != kw {
1405        return None;
1406    }
1407    let after = i + kw.len();
1408    // Word boundary — next char must be ws / EOF / delim.
1409    if after < bytes.len() {
1410        let b = bytes[after];
1411        if !is_ws(b) && !matches!(b, b'(' | b')' | b'<' | b'>' | b'[' | b']' | b'/' | b'%') {
1412            return None;
1413        }
1414    }
1415    Some(after)
1416}
1417
1418/// Skip exactly one CMap "thing" — a hex string, literal string, array,
1419/// dict, name, number, or bare keyword — and return the index PAST it.
1420/// **Always advances at least one byte** so callers using this in a loop
1421/// can't spin forever, even on input shapes the function doesn't
1422/// recognise.
1423fn skip_token(bytes: &[u8], i: usize) -> usize {
1424    if i >= bytes.len() {
1425        return i;
1426    }
1427    let b = bytes[i];
1428    // `<<` dict — must be checked BEFORE the bare `<` hex string.
1429    if b == b'<' && bytes.get(i + 1) == Some(&b'<') {
1430        let mut depth = 1u32;
1431        let mut j = i + 2;
1432        while j + 1 < bytes.len() && depth > 0 {
1433            if bytes[j] == b'<' && bytes[j + 1] == b'<' {
1434                depth += 1;
1435                j += 2;
1436                continue;
1437            }
1438            if bytes[j] == b'>' && bytes[j + 1] == b'>' {
1439                depth -= 1;
1440                j += 2;
1441                continue;
1442            }
1443            j += 1;
1444        }
1445        return j;
1446    }
1447    if b == b'<' {
1448        // Hex string.
1449        let mut j = i + 1;
1450        while j < bytes.len() && bytes[j] != b'>' {
1451            j += 1;
1452        }
1453        return j.saturating_add(1).min(bytes.len());
1454    }
1455    if b == b'(' {
1456        // Literal string — track depth.
1457        let mut depth = 1u32;
1458        let mut j = i + 1;
1459        while j < bytes.len() && depth > 0 {
1460            let c = bytes[j];
1461            if c == b'\\' && j + 1 < bytes.len() {
1462                j += 2;
1463                continue;
1464            }
1465            if c == b'(' {
1466                depth += 1;
1467            }
1468            if c == b')' {
1469                depth -= 1;
1470            }
1471            j += 1;
1472        }
1473        return j;
1474    }
1475    if b == b'[' {
1476        let mut depth = 1u32;
1477        let mut j = i + 1;
1478        while j < bytes.len() && depth > 0 {
1479            let c = bytes[j];
1480            if c == b'[' {
1481                depth += 1;
1482            } else if c == b']' {
1483                depth -= 1;
1484            }
1485            j += 1;
1486        }
1487        return j;
1488    }
1489    // Name `/foo`, number, or bare keyword — read until whitespace OR
1490    // a structural delimiter. Always consume the leading byte first so
1491    // we make forward progress on a single delimiter (`>`, `]`, `}`,
1492    // `%`) the parser doesn't otherwise recognise.
1493    let mut j = i + 1;
1494    while j < bytes.len()
1495        && !is_ws(bytes[j])
1496        && !matches!(
1497            bytes[j],
1498            b'(' | b')' | b'<' | b'>' | b'[' | b']' | b'{' | b'}' | b'/' | b'%'
1499        )
1500    {
1501        j += 1;
1502    }
1503    j
1504}
1505
1506// ────────────────────────── content-stream walker ──────────────────────────
1507
1508/// Per-page text-state walker. PDF interleaves text-matrix updates
1509/// (`Td`, `TD`, `Tm`, `T*`) with show operators (`Tj`, `TJ`, `'`, `"`)
1510/// inside a `BT` / `ET` block. We accumulate the current text-matrix +
1511/// font + size and emit one [`TextRun`] per show.
1512struct TextWalker {
1513    fonts: HashMap<String, FontDecoder>,
1514    /// Per-font horizontal advance metrics (§9.4.4), keyed like
1515    /// `fonts`. Drives the post-show text-matrix advance.
1516    advances: HashMap<String, FontAdvance>,
1517    runs: Vec<TextRun>,
1518    /// Parallel to `runs`: the MCID in scope at the moment of each
1519    /// emit. Populated even when `track_mcid` is false (it's free —
1520    /// just a `Vec<Option<u32>>` of `None`s). Round-29 marked-text
1521    /// extraction reads this; the round-22 raster path ignores it.
1522    run_mcids: Vec<Option<u32>>,
1523
1524    // Operand stack — same shape as the path walker but text-flavoured
1525    // (we accept hex strings + literal strings as "string" operands and
1526    // arrays-of-stringy-stuff for `TJ`).
1527    operands: Vec<TextOperand>,
1528
1529    // ── text state ──────────────────────────────────────────────────
1530    /// `true` between BT and ET.
1531    in_text: bool,
1532    /// Most recent /Tf operand (resource name without leading '/').
1533    cur_font: String,
1534    /// Most recent /Tf size.
1535    cur_size: f32,
1536    /// Text matrix `Tm`. Represented by its 2D affine components
1537    /// `[a b c d e f]`. Reset to identity at every BT.
1538    tm: [f32; 6],
1539    /// Text-line matrix — Td / TD / T* operate against this; Tm /
1540    /// '/" reset it. Same shape as `tm`.
1541    tlm: [f32; 6],
1542    /// Leading (Tl) — distance between baselines. Used by T* and "/'.
1543    leading: f32,
1544    /// Text rendering mode (`Tr`) — §9.3.6 Table 106. Persists across
1545    /// show operators and is reset to the §9.3.1 default
1546    /// ([`TextRenderMode::Fill`]) only by an explicit `0 Tr`, never by
1547    /// `BT` (Table 105: `Tr` is a graphics-state text parameter, not a
1548    /// text-object parameter). Saved / restored by `q` / `Q`.
1549    render_mode: TextRenderMode,
1550    /// Text rise (`Ts`) — §9.4.4 + §9.3.7 Table 105, in unscaled
1551    /// text-space units. Persists across show operators and is reset
1552    /// to the §9.3.1 default `0.0` only by an explicit `0 Ts`, never by
1553    /// `BT` (Table 105: `Ts` is a graphics-state text parameter, not a
1554    /// text-object parameter). Saved / restored by `q` / `Q`.
1555    text_rise: f32,
1556    /// Character spacing `Tc` (§9.3.2), unscaled text-space units.
1557    /// Added to every glyph's §9.4.4 advance. Default 0.0.
1558    char_spacing: f32,
1559    /// Word spacing `Tw` (§9.3.3), unscaled text-space units. Added to
1560    /// single-byte code-32 glyphs in the §9.4.4 advance. Default 0.0.
1561    word_spacing: f32,
1562    /// Horizontal scaling `Th` (§9.3.4) as a fraction (`scale ÷ 100`).
1563    /// Scales the §9.4.4 horizontal advance. Default 1.0.
1564    horiz_scale: f32,
1565    /// Saved text states — one entry per `q`. We don't push the whole
1566    /// graphics state (paint, transform, etc.) since the path walker
1567    /// already covers those; just the text-relevant slots.
1568    saved: Vec<SavedTextState>,
1569
1570    // ── marked-content state ────────────────────────────────────────
1571    /// Round-29 toggle: when `true`, `BDC`/`BMC`/`EMC` push and pop
1572    /// onto `mcid_stack` and emitted runs carry the top of the stack
1573    /// in `run_mcids`. When `false`, BDC/BMC/EMC are still tolerated
1574    /// (operands are dropped) but no per-run MCID is recorded.
1575    track_mcid: bool,
1576    /// Stack of `/MCID` integers (or `None` for `BDC` blocks whose
1577    /// property dict has no MCID, and for `BMC` blocks). The top of
1578    /// the stack is what `emit_show` stamps into `run_mcids`.
1579    mcid_stack: Vec<Option<u32>>,
1580}
1581
1582#[derive(Clone, Debug)]
1583enum TextOperand {
1584    Number(f32),
1585    String(Vec<u8>),
1586    /// `TJ` array — alternating strings and numeric kern offsets.
1587    Array(Vec<TJItem>),
1588    Name(String),
1589    /// Inline dict literal `<<...>>`. We don't keep the full dict —
1590    /// only the `/MCID` hint we scanned out of it (None when the dict
1591    /// has no MCID slot). Used by `BDC` to push a marked-content frame.
1592    Dict {
1593        mcid: Option<u32>,
1594    },
1595}
1596
1597#[derive(Clone, Debug)]
1598enum TJItem {
1599    Str(Vec<u8>),
1600    /// Numeric `TJ` position adjustment, in thousandths of a text-space
1601    /// unit (ISO 32000-1 §9.4.3, Table 109). The value is *subtracted*
1602    /// from the horizontal coordinate: a negative number opens a
1603    /// rightward gap before the next glyph (Figure 46), a positive one
1604    /// pulls it leftward. `emit_show_tj` turns a gap wider than
1605    /// [`TextWalker::WORD_BREAK_GAP`] into an inter-word space.
1606    Kern(f32),
1607}
1608
1609#[derive(Clone, Debug)]
1610struct SavedTextState {
1611    font: String,
1612    size: f32,
1613    tm: [f32; 6],
1614    tlm: [f32; 6],
1615    leading: f32,
1616    render_mode: TextRenderMode,
1617    text_rise: f32,
1618    char_spacing: f32,
1619    word_spacing: f32,
1620    horiz_scale: f32,
1621}
1622
1623impl TextWalker {
1624    fn new(fonts: HashMap<String, FontDecoder>, advances: HashMap<String, FontAdvance>) -> Self {
1625        Self {
1626            fonts,
1627            advances,
1628            runs: Vec::new(),
1629            run_mcids: Vec::new(),
1630            operands: Vec::new(),
1631            in_text: false,
1632            cur_font: String::new(),
1633            cur_size: 0.0,
1634            tm: identity(),
1635            tlm: identity(),
1636            leading: 0.0,
1637            render_mode: TextRenderMode::Fill,
1638            text_rise: 0.0,
1639            char_spacing: 0.0,
1640            word_spacing: 0.0,
1641            horiz_scale: 1.0,
1642            saved: Vec::new(),
1643            track_mcid: false,
1644            mcid_stack: Vec::new(),
1645        }
1646    }
1647
1648    fn into_runs(self) -> Vec<TextRun> {
1649        self.runs
1650    }
1651
1652    fn into_runs_with_mcid(self) -> Vec<(TextRun, Option<u32>)> {
1653        self.runs.into_iter().zip(self.run_mcids).collect()
1654    }
1655
1656    fn parse(&mut self, input: &[u8]) -> Result<(), PdfError> {
1657        let mut i = 0;
1658        while i < input.len() {
1659            let b = input[i];
1660            if is_ws(b) {
1661                i += 1;
1662                continue;
1663            }
1664            if b == b'%' {
1665                while i < input.len() && input[i] != b'\n' && input[i] != b'\r' {
1666                    i += 1;
1667                }
1668                continue;
1669            }
1670            if b == b'(' {
1671                let (end, payload) = read_literal_string(input, i)?;
1672                self.operands.push(TextOperand::String(payload));
1673                i = end;
1674                continue;
1675            }
1676            if b == b'<' && input.get(i + 1) != Some(&b'<') {
1677                let (payload, end) = read_hex_string_payload(input, i)?;
1678                self.operands.push(TextOperand::String(payload));
1679                i = end;
1680                continue;
1681            }
1682            if b == b'<' && input.get(i + 1) == Some(&b'<') {
1683                // Dict literal. We scan it for an `/MCID <int>` slot
1684                // (so `BDC` operands can recover the marked-content
1685                // identifier) but do NOT keep arbitrary dict payload
1686                // on the operand stack — only the MCID hint, encoded
1687                // as a special `TextOperand::Dict { mcid }` value.
1688                let start = i;
1689                let mut depth = 1u32;
1690                i += 2;
1691                while i + 1 < input.len() && depth > 0 {
1692                    if input[i] == b'<' && input[i + 1] == b'<' {
1693                        depth += 1;
1694                        i += 2;
1695                    } else if input[i] == b'>' && input[i + 1] == b'>' {
1696                        depth -= 1;
1697                        i += 2;
1698                    } else {
1699                        i += 1;
1700                    }
1701                }
1702                let mcid = scan_inline_mcid(&input[start..i]);
1703                self.operands.push(TextOperand::Dict { mcid });
1704                continue;
1705            }
1706            if b == b'[' {
1707                let (end, items) = read_tj_array(input, i)?;
1708                self.operands.push(TextOperand::Array(items));
1709                i = end;
1710                continue;
1711            }
1712            if b == b'/' {
1713                let mut end = i + 1;
1714                while end < input.len() && !is_ws(input[end]) && !is_delim(input[end]) {
1715                    end += 1;
1716                }
1717                let name = String::from_utf8_lossy(&input[i + 1..end]).into_owned();
1718                self.operands.push(TextOperand::Name(name));
1719                i = end;
1720                continue;
1721            }
1722            if matches!(b, b'+' | b'-' | b'.' | b'0'..=b'9') {
1723                let mut end = i;
1724                if matches!(input[end], b'+' | b'-') {
1725                    end += 1;
1726                }
1727                let mut saw_digit = false;
1728                let mut saw_dot = false;
1729                while end < input.len() {
1730                    let c = input[end];
1731                    if c.is_ascii_digit() {
1732                        end += 1;
1733                        saw_digit = true;
1734                    } else if c == b'.' && !saw_dot {
1735                        end += 1;
1736                        saw_dot = true;
1737                    } else {
1738                        break;
1739                    }
1740                }
1741                if !saw_digit {
1742                    let kw_end = scan_kw_end(input, i);
1743                    self.dispatch(&input[i..kw_end])?;
1744                    i = kw_end;
1745                    continue;
1746                }
1747                let s = str::from_utf8(&input[i..end]).map_err(|_| {
1748                    PdfError::other(format!("PDF text walker: non-UTF-8 number at byte {i}"))
1749                })?;
1750                let f: f32 = s.parse().map_err(|_| {
1751                    PdfError::other(format!("PDF text walker: invalid number `{s}` at byte {i}"))
1752                })?;
1753                self.operands.push(TextOperand::Number(f));
1754                i = end;
1755                continue;
1756            }
1757            // Keyword.
1758            let kw_end = scan_kw_end(input, i);
1759            if kw_end == i {
1760                i += 1;
1761                continue;
1762            }
1763            self.dispatch(&input[i..kw_end])?;
1764            i = kw_end;
1765        }
1766        Ok(())
1767    }
1768
1769    fn dispatch(&mut self, op: &[u8]) -> Result<(), PdfError> {
1770        match op {
1771            b"q" => {
1772                self.saved.push(SavedTextState {
1773                    font: self.cur_font.clone(),
1774                    size: self.cur_size,
1775                    tm: self.tm,
1776                    tlm: self.tlm,
1777                    leading: self.leading,
1778                    render_mode: self.render_mode,
1779                    text_rise: self.text_rise,
1780                    char_spacing: self.char_spacing,
1781                    word_spacing: self.word_spacing,
1782                    horiz_scale: self.horiz_scale,
1783                });
1784                self.operands.clear();
1785            }
1786            b"Q" => {
1787                if let Some(s) = self.saved.pop() {
1788                    self.cur_font = s.font;
1789                    self.cur_size = s.size;
1790                    self.tm = s.tm;
1791                    self.tlm = s.tlm;
1792                    self.leading = s.leading;
1793                    self.render_mode = s.render_mode;
1794                    self.text_rise = s.text_rise;
1795                    self.char_spacing = s.char_spacing;
1796                    self.word_spacing = s.word_spacing;
1797                    self.horiz_scale = s.horiz_scale;
1798                }
1799                self.operands.clear();
1800            }
1801            b"BT" => {
1802                self.in_text = true;
1803                self.tm = identity();
1804                self.tlm = identity();
1805                self.operands.clear();
1806            }
1807            b"ET" => {
1808                self.in_text = false;
1809                self.operands.clear();
1810            }
1811            b"Tf" => {
1812                // /Name size Tf
1813                let size = self.pop_num().unwrap_or(0.0);
1814                let name = self.pop_name().unwrap_or_default();
1815                self.cur_font = name;
1816                self.cur_size = size;
1817                self.operands.clear();
1818            }
1819            b"Tm" => {
1820                // a b c d e f Tm — set both Tm and Tlm.
1821                let nums = self.take_n(6);
1822                if let Some(n) = nums {
1823                    self.tm = n;
1824                    self.tlm = n;
1825                }
1826            }
1827            b"Td" => {
1828                // tx ty Td — Tlm = translate(tx,ty) * Tlm; Tm = Tlm.
1829                let nums = self.take_n(2);
1830                if let Some(n) = nums {
1831                    let tx = n[0];
1832                    let ty = n[1];
1833                    let translate = [1.0, 0.0, 0.0, 1.0, tx, ty];
1834                    self.tlm = mul(translate, self.tlm);
1835                    self.tm = self.tlm;
1836                }
1837            }
1838            b"TD" => {
1839                // tx ty TD — like Td, but also sets leading = -ty.
1840                let nums = self.take_n(2);
1841                if let Some(n) = nums {
1842                    let tx = n[0];
1843                    let ty = n[1];
1844                    self.leading = -ty;
1845                    let translate = [1.0, 0.0, 0.0, 1.0, tx, ty];
1846                    self.tlm = mul(translate, self.tlm);
1847                    self.tm = self.tlm;
1848                }
1849            }
1850            b"TL" => {
1851                if let Some(n) = self.pop_num() {
1852                    self.leading = n;
1853                }
1854            }
1855            b"T*" => {
1856                // Move to next line: Td(0, -leading).
1857                let translate = [1.0, 0.0, 0.0, 1.0, 0.0, -self.leading];
1858                self.tlm = mul(translate, self.tlm);
1859                self.tm = self.tlm;
1860                self.operands.clear();
1861            }
1862            b"Tj" => {
1863                let s = self.pop_string().unwrap_or_default();
1864                self.emit_show(&s);
1865            }
1866            b"TJ" => {
1867                let arr = self.pop_array().unwrap_or_default();
1868                self.emit_show_tj(&arr);
1869            }
1870            b"'" => {
1871                // Move-and-show: T*, then Tj.
1872                let translate = [1.0, 0.0, 0.0, 1.0, 0.0, -self.leading];
1873                self.tlm = mul(translate, self.tlm);
1874                self.tm = self.tlm;
1875                let s = self.pop_string().unwrap_or_default();
1876                self.emit_show(&s);
1877            }
1878            b"\"" => {
1879                // aw ac string " — set Tw, Tc, T*, Tj (§9.4.3 /
1880                // Table 109).
1881                let s = self.pop_string().unwrap_or_default();
1882                let ac = self.pop_num().unwrap_or(0.0);
1883                let aw = self.pop_num().unwrap_or(0.0);
1884                self.word_spacing = aw;
1885                self.char_spacing = ac;
1886                let translate = [1.0, 0.0, 0.0, 1.0, 0.0, -self.leading];
1887                self.tlm = mul(translate, self.tlm);
1888                self.tm = self.tlm;
1889                self.emit_show(&s);
1890            }
1891            b"Tr" => {
1892                // render mode Tr — §9.3.6 Table 106. The single integer
1893                // operand selects fill / stroke / clip / invisible. We
1894                // record it so each emitted run carries the mode in force
1895                // (extraction consumers filter the `3 Tr` OCR layer on
1896                // it); the actual fill/stroke/clip painting it implies is
1897                // a renderer concern this extraction walker doesn't reach.
1898                if let Some(n) = self.pop_num() {
1899                    self.render_mode = TextRenderMode::from_operand(n as i64);
1900                }
1901                self.operands.clear();
1902            }
1903            b"Ts" => {
1904                // rise Ts — §9.4.4 + §9.3.7 Table 105. The single
1905                // number operand shifts the text-rendering origin
1906                // vertically (superscript / subscript) in unscaled
1907                // text-space units. We record it so each emitted run's
1908                // origin reflects the rise (see `push_run`); a `0 Ts`
1909                // restores the baseline per the §9.3.1 default.
1910                if let Some(n) = self.pop_num() {
1911                    self.text_rise = n;
1912                }
1913                self.operands.clear();
1914            }
1915            // Char / word spacing and horizontal scale feed the §9.4.4
1916            // advance so a following run's origin reflects them.
1917            b"Tc" => {
1918                if let Some(n) = self.pop_num() {
1919                    self.char_spacing = n;
1920                }
1921                self.operands.clear();
1922            }
1923            b"Tw" => {
1924                if let Some(n) = self.pop_num() {
1925                    self.word_spacing = n;
1926                }
1927                self.operands.clear();
1928            }
1929            b"Tz" => {
1930                if let Some(n) = self.pop_num() {
1931                    self.horiz_scale = n / 100.0;
1932                }
1933                self.operands.clear();
1934            }
1935            // Marked-content operators (ISO 32000-1 §14.6).
1936            b"BDC" => {
1937                // tag properties BDC. Pop properties, then tag.
1938                let mcid = match self.operands.pop() {
1939                    Some(TextOperand::Dict { mcid }) => mcid,
1940                    Some(TextOperand::Name(_)) => {
1941                        // /Properties resource ref — round-29 doesn't
1942                        // resolve indirect property dicts (the writer
1943                        // never emits them; pdftotext likewise treats
1944                        // unresolved property refs as MCID-less).
1945                        None
1946                    }
1947                    Some(other) => {
1948                        self.operands.push(other);
1949                        None
1950                    }
1951                    None => None,
1952                };
1953                let _tag = self.pop_name();
1954                if self.track_mcid {
1955                    self.mcid_stack.push(mcid);
1956                }
1957                self.operands.clear();
1958            }
1959            b"BMC" => {
1960                // tag BMC — no properties dict, no MCID.
1961                let _tag = self.pop_name();
1962                if self.track_mcid {
1963                    self.mcid_stack.push(None);
1964                }
1965                self.operands.clear();
1966            }
1967            b"EMC" => {
1968                if self.track_mcid {
1969                    self.mcid_stack.pop();
1970                }
1971                self.operands.clear();
1972            }
1973            b"MP" => {
1974                // tag MP — marked-point with no properties.
1975                self.operands.clear();
1976            }
1977            b"DP" => {
1978                // tag properties DP — marked-point with properties.
1979                self.operands.clear();
1980            }
1981            // Anything else (path / colour / state operators) — drop the
1982            // operands and continue.
1983            _ => {
1984                self.operands.clear();
1985            }
1986        }
1987        Ok(())
1988    }
1989
1990    fn pop_num(&mut self) -> Option<f32> {
1991        match self.operands.pop()? {
1992            TextOperand::Number(n) => Some(n),
1993            other => {
1994                self.operands.push(other);
1995                None
1996            }
1997        }
1998    }
1999
2000    fn pop_name(&mut self) -> Option<String> {
2001        match self.operands.pop()? {
2002            TextOperand::Name(s) => Some(s),
2003            other => {
2004                self.operands.push(other);
2005                None
2006            }
2007        }
2008    }
2009
2010    fn pop_string(&mut self) -> Option<Vec<u8>> {
2011        match self.operands.pop()? {
2012            TextOperand::String(s) => Some(s),
2013            other => {
2014                self.operands.push(other);
2015                None
2016            }
2017        }
2018    }
2019
2020    fn pop_array(&mut self) -> Option<Vec<TJItem>> {
2021        match self.operands.pop()? {
2022            TextOperand::Array(a) => Some(a),
2023            other => {
2024                self.operands.push(other);
2025                None
2026            }
2027        }
2028    }
2029
2030    fn take_n(&mut self, n: usize) -> Option<[f32; 6]> {
2031        if self.operands.len() < n {
2032            self.operands.clear();
2033            return None;
2034        }
2035        let split = self.operands.len() - n;
2036        let tail: Vec<TextOperand> = self.operands.drain(split..).collect();
2037        let mut nums = [0.0f32; 6];
2038        for (i, op) in tail.into_iter().enumerate() {
2039            match op {
2040                TextOperand::Number(f) => nums[i] = f,
2041                _ => return None,
2042            }
2043        }
2044        Some(nums)
2045    }
2046
2047    /// Decode a single show-operand byte string through the current
2048    /// font's decoder (Latin-1 fallback when no font resolved).
2049    fn decode_bytes(&self, bytes: &[u8]) -> String {
2050        match self.fonts.get(&self.cur_font) {
2051            Some(d) => d.decode(bytes),
2052            // No font resolved — fall back to Latin-1 bytes so the run
2053            // isn't dropped silently.
2054            None => bytes.iter().map(|&b| b as char).collect(),
2055        }
2056    }
2057
2058    fn emit_show(&mut self, bytes: &[u8]) {
2059        if !self.in_text {
2060            // Show outside BT/ET — malformed but tolerate; the writer
2061            // wouldn't emit this.
2062            self.operands.clear();
2063            return;
2064        }
2065        let text = self.decode_bytes(bytes);
2066        self.push_run(text);
2067        // §9.4.4 — advance Tm past the shown glyphs so a following show
2068        // on the same line starts at the correct origin.
2069        self.advance_bytes(bytes);
2070        self.operands.clear();
2071    }
2072
2073    /// `TJ` show: decode each string element and translate the numeric
2074    /// position adjustments between them into word breaks.
2075    ///
2076    /// ISO 32000-1 §9.4.3 (Table 109, `TJ`): a numeric array element is
2077    /// expressed in thousandths of a text-space unit and is *subtracted*
2078    /// from the current horizontal coordinate, so a **negative** number
2079    /// opens a rightward gap before the next glyph (Figure 46). Small
2080    /// negative kerns (the figure's −120 / −95 between letters of "AWAY")
2081    /// are intra-word micro-spacing and must not split a word; a gap that
2082    /// exceeds [`Self::WORD_BREAK_GAP`] of an em is the unglyphed
2083    /// inter-word space many producers emit in place of a literal space
2084    /// character. A single U+0020 is inserted there so text extracted
2085    /// from such streams reads `hello world`, not `helloworld`.
2086    ///
2087    /// The threshold is an extraction-layer heuristic (the spec defines
2088    /// the geometry, not a word-break rule). It is intentionally above
2089    /// the figure's −120 kern so spec EXAMPLE-class kerning stays joined.
2090    fn emit_show_tj(&mut self, arr: &[TJItem]) {
2091        if !self.in_text {
2092            self.operands.clear();
2093            return;
2094        }
2095        let mut text = String::new();
2096        // Pending rightward gap (in thousandths of an em) accumulated by
2097        // numeric elements since the last string element. Applied as a
2098        // word break only when the next string element arrives, so a
2099        // trailing adjustment doesn't append a dangling space.
2100        let mut pending_gap = 0.0f32;
2101        for item in arr {
2102            match item {
2103                TJItem::Str(b) => {
2104                    if pending_gap >= Self::WORD_BREAK_GAP
2105                        && !text.is_empty()
2106                        && !text.ends_with(' ')
2107                    {
2108                        text.push(' ');
2109                    }
2110                    pending_gap = 0.0;
2111                    text.push_str(&self.decode_bytes(b));
2112                }
2113                // Numeric adjustment: subtracted from the horizontal
2114                // coordinate, so negate to get the rightward gap. Positive
2115                // numbers pull the next glyph leftward (overlap / negative
2116                // kern) and never open a word break.
2117                TJItem::Kern(adj) => pending_gap += -adj,
2118            }
2119        }
2120        // Record the run at the array's start origin, then advance Tm
2121        // through every element (glyph widths + per-element kerns,
2122        // §9.4.3 / §9.4.4) so a following show is correctly positioned.
2123        self.push_run(text);
2124        for item in arr {
2125            match item {
2126                TJItem::Str(b) => self.advance_bytes(b),
2127                TJItem::Kern(adj) => self.advance_kern(*adj),
2128            }
2129        }
2130        self.operands.clear();
2131    }
2132
2133    /// Append a decoded run at the current text position + font, stamping
2134    /// the in-scope MCID.
2135    fn push_run(&mut self, text: String) {
2136        // §9.4.4 — the text-rendering origin is the text-space point
2137        // `(0, Trise)` mapped through the text matrix `Tm`. With
2138        // `tm = [a b c d e f]` the bare origin `(0,0)` maps to
2139        // `(e, f)`; adding the rise along `Tm`'s vertical basis gives
2140        // `(c·Trise + e, d·Trise + f)`. For the common axis-aligned
2141        // `Tm` (`c == 0`, `d == 1`) this is simply `(e, f + Trise)`.
2142        let rise = self.text_rise;
2143        let x = self.tm[2] * rise + self.tm[4];
2144        let y = self.tm[3] * rise + self.tm[5];
2145        self.runs.push(TextRun {
2146            text,
2147            position: (x, y),
2148            font_name: self.cur_font.clone(),
2149            font_size: self.cur_size,
2150            render_mode: self.render_mode,
2151            text_rise: rise,
2152        });
2153        // Stamp the current MCID (top of stack) onto the run.
2154        let cur_mcid = self.mcid_stack.last().copied().unwrap_or(None);
2155        self.run_mcids.push(cur_mcid);
2156    }
2157
2158    /// Advance the text matrix `Tm` by the §9.4.4 displacement of every
2159    /// glyph in `bytes`:
2160    ///
2161    /// ```text
2162    /// tx = ((w0 − Tj/1000)·Tfs + Tc + Tw)·Th
2163    /// ```
2164    ///
2165    /// (with `Tj = 0`; `TJ` kerns go through [`Self::advance_kern`]).
2166    /// `w0` is the current font's per-glyph advance (glyph-space
2167    /// thousandths); `Tw` applies only to single-byte code 32. Composite
2168    /// Identity fonts step two bytes per CID. A zero-advance font
2169    /// (`FontAdvance::None`) still moves by the `Tc`/`Tw`/`Th` spacing.
2170    fn advance_bytes(&mut self, bytes: &[u8]) {
2171        let tfs = self.cur_size;
2172        let th = self.horiz_scale;
2173        let tc = self.char_spacing;
2174        let adv = self.advances.get(&self.cur_font).cloned();
2175        let adv = adv.unwrap_or(FontAdvance::None);
2176        let scale = adv.text_scale();
2177        if adv.is_cid() {
2178            let mut i = 0;
2179            while i + 1 < bytes.len() {
2180                let cid = ((bytes[i] as i64) << 8) | bytes[i + 1] as i64;
2181                let w0 = adv.width(cid) * scale;
2182                let tx = (w0 * tfs + tc) * th;
2183                self.translate_tm(tx);
2184                i += 2;
2185            }
2186        } else {
2187            for &b in bytes {
2188                let w0 = adv.width(b as i64) * scale;
2189                let tw = if b == 32 { self.word_spacing } else { 0.0 };
2190                let tx = (w0 * tfs + tc + tw) * th;
2191                self.translate_tm(tx);
2192            }
2193        }
2194    }
2195
2196    /// Apply a `TJ` numeric kern (§9.4.3): translate `Tm` by
2197    /// `−adj/1000 × Tfs × Th`.
2198    fn advance_kern(&mut self, adj: f32) {
2199        let tx = -adj / 1000.0 * self.cur_size * self.horiz_scale;
2200        self.translate_tm(tx);
2201    }
2202
2203    /// Translate `Tm` by `(tx, 0)` in text space
2204    /// (`Tm = [1 0 0 1 tx 0] × Tm`).
2205    fn translate_tm(&mut self, tx: f32) {
2206        let translate = [1.0, 0.0, 0.0, 1.0, tx, 0.0];
2207        self.tm = mul(translate, self.tm);
2208    }
2209
2210    /// Minimum rightward `TJ` gap, in thousandths of an em, that the
2211    /// text extractor treats as an inter-word space rather than
2212    /// intra-word kerning. 0.25 em (= 250) is comfortably above the
2213    /// ISO 32000-1 Figure 46 kerns (−120 / −95) yet below a real space
2214    /// advance (~0.25–0.35 em for most fonts), so word boundaries that a
2215    /// producer encoded purely as a `TJ` displacement are recovered
2216    /// without false-splitting tightly-kerned text.
2217    const WORD_BREAK_GAP: f32 = 250.0;
2218}
2219
2220// ────────────────────────── helpers ──────────────────────────
2221
2222fn identity() -> [f32; 6] {
2223    [1.0, 0.0, 0.0, 1.0, 0.0, 0.0]
2224}
2225
2226/// 2D affine matrix multiply: result = a * b. Both are
2227/// `[a b c d e f]` PDF text matrices interpreted as
2228/// `[ a b 0 ; c d 0 ; e f 1 ]` per ISO 32000-1 §8.3.4.
2229fn mul(a: [f32; 6], b: [f32; 6]) -> [f32; 6] {
2230    [
2231        a[0] * b[0] + a[1] * b[2],
2232        a[0] * b[1] + a[1] * b[3],
2233        a[2] * b[0] + a[3] * b[2],
2234        a[2] * b[1] + a[3] * b[3],
2235        a[4] * b[0] + a[5] * b[2] + b[4],
2236        a[4] * b[1] + a[5] * b[3] + b[5],
2237    ]
2238}
2239
2240fn is_delim(b: u8) -> bool {
2241    matches!(
2242        b,
2243        b'(' | b')' | b'<' | b'>' | b'[' | b']' | b'{' | b'}' | b'/' | b'%'
2244    )
2245}
2246
2247fn scan_kw_end(input: &[u8], start: usize) -> usize {
2248    let mut end = start;
2249    while end < input.len() && !is_ws(input[end]) && !is_delim(input[end]) {
2250        end += 1;
2251    }
2252    end
2253}
2254
2255/// Best-effort scan of a top-level inline-dict slice (`<<...>>`) for
2256/// `/MCID <integer>`. We only care about the MCID at the dict's top
2257/// level — nested dicts are skipped wholesale. Whitespace tolerant;
2258/// returns `None` if no `/MCID` key is present.
2259fn scan_inline_mcid(bytes: &[u8]) -> Option<u32> {
2260    if bytes.len() < 4 || &bytes[..2] != b"<<" {
2261        return None;
2262    }
2263    let body = &bytes[2..bytes.len().saturating_sub(2)];
2264    let mut i = 0;
2265    let mut depth = 0u32;
2266    while i < body.len() {
2267        let b = body[i];
2268        if is_ws(b) {
2269            i += 1;
2270            continue;
2271        }
2272        if b == b'<' && body.get(i + 1) == Some(&b'<') {
2273            depth += 1;
2274            i += 2;
2275            continue;
2276        }
2277        if b == b'>' && body.get(i + 1) == Some(&b'>') {
2278            depth = depth.saturating_sub(1);
2279            i += 2;
2280            continue;
2281        }
2282        if depth > 0 {
2283            // Inside a nested dict — skip.
2284            i += 1;
2285            continue;
2286        }
2287        if b == b'/' {
2288            // Read name.
2289            let mut end = i + 1;
2290            while end < body.len() && !is_ws(body[end]) && !is_delim(body[end]) {
2291                end += 1;
2292            }
2293            let name = &body[i + 1..end];
2294            i = end;
2295            if name == b"MCID" {
2296                // Skip ws.
2297                while i < body.len() && is_ws(body[i]) {
2298                    i += 1;
2299                }
2300                // Read integer.
2301                let mut e = i;
2302                while e < body.len() && (body[e].is_ascii_digit() || body[e] == b'-') {
2303                    e += 1;
2304                }
2305                if e == i {
2306                    return None;
2307                }
2308                let s = std::str::from_utf8(&body[i..e]).ok()?;
2309                return s.parse::<u32>().ok();
2310            }
2311            // Else: skip the value that follows.
2312            continue;
2313        }
2314        // Skip any other top-level token.
2315        i += 1;
2316    }
2317    None
2318}
2319
2320fn read_literal_string(input: &[u8], start: usize) -> Result<(usize, Vec<u8>), PdfError> {
2321    let mut end = start + 1;
2322    let mut depth = 1u32;
2323    let mut decoded = Vec::new();
2324    while end < input.len() {
2325        let b = input[end];
2326        if b == b'\\' {
2327            end += 1;
2328            if end >= input.len() {
2329                break;
2330            }
2331            // Handle escapes per ISO 32000-1 §7.3.4.2.
2332            let c = input[end];
2333            match c {
2334                b'n' => {
2335                    decoded.push(b'\n');
2336                    end += 1;
2337                }
2338                b'r' => {
2339                    decoded.push(b'\r');
2340                    end += 1;
2341                }
2342                b't' => {
2343                    decoded.push(b'\t');
2344                    end += 1;
2345                }
2346                b'b' => {
2347                    decoded.push(0x08);
2348                    end += 1;
2349                }
2350                b'f' => {
2351                    decoded.push(0x0C);
2352                    end += 1;
2353                }
2354                b'(' | b')' | b'\\' => {
2355                    decoded.push(c);
2356                    end += 1;
2357                }
2358                b'\n' | b'\r' => {
2359                    // Line continuation — skip CR/LF/CRLF.
2360                    end += 1;
2361                    if c == b'\r' && end < input.len() && input[end] == b'\n' {
2362                        end += 1;
2363                    }
2364                }
2365                b'0'..=b'7' => {
2366                    // Octal escape — up to 3 digits.
2367                    let mut v = 0u32;
2368                    let mut k = 0;
2369                    while k < 3 && end < input.len() && matches!(input[end], b'0'..=b'7') {
2370                        v = v * 8 + (input[end] - b'0') as u32;
2371                        end += 1;
2372                        k += 1;
2373                    }
2374                    decoded.push((v & 0xFF) as u8);
2375                }
2376                _ => {
2377                    decoded.push(c);
2378                    end += 1;
2379                }
2380            }
2381            continue;
2382        }
2383        if b == b'(' {
2384            depth += 1;
2385            decoded.push(b);
2386            end += 1;
2387            continue;
2388        }
2389        if b == b')' {
2390            depth -= 1;
2391            if depth == 0 {
2392                end += 1;
2393                return Ok((end, decoded));
2394            }
2395            decoded.push(b);
2396            end += 1;
2397            continue;
2398        }
2399        decoded.push(b);
2400        end += 1;
2401    }
2402    Err(PdfError::other(
2403        "PDF text walker: unterminated literal string",
2404    ))
2405}
2406
2407fn read_tj_array(input: &[u8], start: usize) -> Result<(usize, Vec<TJItem>), PdfError> {
2408    let mut i = start + 1;
2409    let mut items = Vec::new();
2410    loop {
2411        i = {
2412            let mut k = i;
2413            while k < input.len() && (is_ws(input[k]) || input[k] == b'\n') {
2414                k += 1;
2415            }
2416            k
2417        };
2418        if i >= input.len() {
2419            return Err(PdfError::other("PDF text walker: unterminated TJ array"));
2420        }
2421        if input[i] == b']' {
2422            return Ok((i + 1, items));
2423        }
2424        if input[i] == b'(' {
2425            let (end, payload) = read_literal_string(input, i)?;
2426            items.push(TJItem::Str(payload));
2427            i = end;
2428            continue;
2429        }
2430        if input[i] == b'<' && input.get(i + 1) != Some(&b'<') {
2431            let (payload, end) = read_hex_string_payload(input, i)?;
2432            items.push(TJItem::Str(payload));
2433            i = end;
2434            continue;
2435        }
2436        if matches!(input[i], b'+' | b'-' | b'.' | b'0'..=b'9') {
2437            let mut end = i;
2438            if matches!(input[end], b'+' | b'-') {
2439                end += 1;
2440            }
2441            let mut saw_dot = false;
2442            while end < input.len()
2443                && (input[end].is_ascii_digit() || (input[end] == b'.' && !saw_dot))
2444            {
2445                if input[end] == b'.' {
2446                    saw_dot = true;
2447                }
2448                end += 1;
2449            }
2450            if let Ok(s) = str::from_utf8(&input[i..end]) {
2451                if let Ok(f) = s.parse::<f32>() {
2452                    items.push(TJItem::Kern(f));
2453                }
2454            }
2455            i = end;
2456            continue;
2457        }
2458        // Skip unknown bytes inside the array.
2459        i += 1;
2460    }
2461}
2462
2463// Encoding tables have moved to `crate::reader::encoding` — round 28
2464// replaced the inline match-based helpers with a 256-entry
2465// `EncodingMap` that also accommodates `/Differences` overlays and
2466// multi-character ligature glyphs.
2467
2468#[cfg(test)]
2469mod tests {
2470    use super::*;
2471
2472    #[test]
2473    fn cmap_bfchar_simple() {
2474        let cmap = b"
2475        /CIDInit /ProcSet findresource begin
2476        12 dict begin
2477        beginbfchar
2478        <0001> <0041>
2479        <0002> <0042>
2480        <0003> <0043>
2481        endbfchar
2482        ";
2483        let parsed = CMap::parse(cmap).unwrap();
2484        assert_eq!(parsed.byte_width, 2);
2485        assert_eq!(parsed.lookup(1), Some("A"));
2486        assert_eq!(parsed.lookup(2), Some("B"));
2487        assert_eq!(parsed.lookup(3), Some("C"));
2488    }
2489
2490    #[test]
2491    fn cmap_bfrange_scalar_form() {
2492        // <0010> <0012> <0041> → 0x10→A, 0x11→B, 0x12→C
2493        let cmap = b"beginbfrange <0010> <0012> <0041> endbfrange";
2494        let parsed = CMap::parse(cmap).unwrap();
2495        assert_eq!(parsed.lookup(0x10), Some("A"));
2496        assert_eq!(parsed.lookup(0x11), Some("B"));
2497        assert_eq!(parsed.lookup(0x12), Some("C"));
2498    }
2499
2500    #[test]
2501    fn cmap_bfrange_array_form() {
2502        // <0001> <0003> [ <0041> <0042> <0043> ]
2503        let cmap = b"beginbfrange <0001> <0003> [ <0041> <0042> <0043> ] endbfrange";
2504        let parsed = CMap::parse(cmap).unwrap();
2505        assert_eq!(parsed.lookup(1), Some("A"));
2506        assert_eq!(parsed.lookup(2), Some("B"));
2507        assert_eq!(parsed.lookup(3), Some("C"));
2508    }
2509
2510    #[test]
2511    fn winansi_smart_quote_via_encoding_map() {
2512        // 0x93 = U+201C left double smart quote — verifies the
2513        // round-28 `EncodingMap` path produces the same bytes the
2514        // old inline `winansi_to_char` match did.
2515        let m = EncodingMap::from_base(BaseEncoding::WinAnsi);
2516        assert_eq!(m.decode(&[0x93]), "\u{201C}");
2517        assert_eq!(m.decode(b"A"), "A");
2518    }
2519
2520    #[test]
2521    fn flat_text_joins_runs_with_spaces() {
2522        let pe = PdfTextExtraction {
2523            runs: vec![
2524                TextRun {
2525                    text: "Hello".into(),
2526                    position: (0.0, 0.0),
2527                    font_name: "F0".into(),
2528                    font_size: 12.0,
2529                    render_mode: TextRenderMode::Fill,
2530                    text_rise: 0.0,
2531                },
2532                TextRun {
2533                    text: "World".into(),
2534                    position: (40.0, 0.0),
2535                    font_name: "F0".into(),
2536                    font_size: 12.0,
2537                    render_mode: TextRenderMode::Fill,
2538                    text_rise: 0.0,
2539                },
2540            ],
2541        };
2542        assert_eq!(pe.flat_text(), "Hello World");
2543    }
2544
2545    #[test]
2546    fn tm_matrix_multiply_translates() {
2547        let id = identity();
2548        let trans = [1.0, 0.0, 0.0, 1.0, 100.0, 200.0];
2549        let r = mul(trans, id);
2550        assert_eq!(r[4], 100.0);
2551        assert_eq!(r[5], 200.0);
2552    }
2553
2554    #[test]
2555    fn cmap_bfchar_multichar_target() {
2556        // <0001> <00660069> → "fi" ligature (U+0066 U+0069)
2557        let cmap = b"beginbfchar <0001> <00660069> endbfchar";
2558        let parsed = CMap::parse(cmap).unwrap();
2559        assert_eq!(parsed.lookup(1), Some("fi"));
2560    }
2561
2562    #[test]
2563    fn cmap_codespacerange_single_width_parses() {
2564        let cmap = b"\
25651 begincodespacerange
2566<0000> <FFFF>
2567endcodespacerange
25682 beginbfchar
2569<0041> <0041>
2570<0042> <0042>
2571endbfchar
2572";
2573        let parsed = CMap::parse(cmap).unwrap();
2574        assert_eq!(parsed.codespaces.len(), 1);
2575        assert_eq!(parsed.codespaces[0].width(), 2);
2576        assert_eq!(parsed.codespaces[0].lo, vec![0x00, 0x00]);
2577        assert_eq!(parsed.codespaces[0].hi, vec![0xFF, 0xFF]);
2578    }
2579
2580    #[test]
2581    fn cmap_codespacerange_mixed_width_parses_and_selects() {
2582        // 1-byte territory <00>..<7F> (ASCII) plus a 2-byte territory
2583        // <8140>..<FCFC> (Shift-JIS-shaped).
2584        let cmap = b"\
25852 begincodespacerange
2586<00> <7F>
2587<8140> <FCFC>
2588endcodespacerange
2589";
2590        let parsed = CMap::parse(cmap).unwrap();
2591        assert_eq!(parsed.codespaces.len(), 2);
2592        // 0x41 in the 1-byte range.
2593        assert_eq!(parsed.match_codespace_width(&[0x41]), Some(1));
2594        // 0x81 0x40 in the 2-byte range — first byte 0x81 is outside
2595        // [0x00..=0x7F], so the 1-byte codespace doesn't match; the
2596        // 2-byte one does.
2597        assert_eq!(parsed.match_codespace_width(&[0x81, 0x40]), Some(2));
2598        // 0x81 0x39: first byte in 2-byte's [0x81..=0xFC], second byte
2599        // 0x39 BELOW 2-byte's [0x40..=0xFC] — Tech Note #5014 §3.1
2600        // component-wise rule says NO MATCH. (The linear u32 interval
2601        // 0x8140..=0xFCFC would match — exactly the bug we're closing.)
2602        assert_eq!(parsed.match_codespace_width(&[0x81, 0x39]), None);
2603        // 0xFD outside both ranges.
2604        assert_eq!(parsed.match_codespace_width(&[0xFD]), None);
2605    }
2606
2607    #[test]
2608    fn cmap_codespacerange_component_wise_match() {
2609        // The §3.1 rule: the canonical Shift-JIS-ish range
2610        // <8140>..<FCFC> excludes <8130> (low byte below 0x40) and
2611        // <FD00> (high byte above 0xFC). This is the test that nails
2612        // the difference between byte-component bounds and a linear
2613        // u32 interval.
2614        let cmap = b"1 begincodespacerange <8140> <FCFC> endcodespacerange";
2615        let parsed = CMap::parse(cmap).unwrap();
2616        assert_eq!(parsed.match_codespace_width(&[0x81, 0x40]), Some(2));
2617        assert_eq!(parsed.match_codespace_width(&[0xFC, 0xFC]), Some(2));
2618        assert_eq!(parsed.match_codespace_width(&[0x81, 0x39]), None);
2619        assert_eq!(parsed.match_codespace_width(&[0xFD, 0x00]), None);
2620    }
2621
2622    #[test]
2623    fn cmap_codespacerange_skips_mismatched_widths() {
2624        // A malformed entry whose <lo> and <hi> widths diverge is
2625        // dropped tolerantly; the well-formed entry that follows is
2626        // still captured.
2627        let cmap = b"\
26282 begincodespacerange
2629<00> <FFFF>
2630<0000> <FFFF>
2631endcodespacerange
2632";
2633        let parsed = CMap::parse(cmap).unwrap();
2634        assert_eq!(parsed.codespaces.len(), 1);
2635        assert_eq!(parsed.codespaces[0].width(), 2);
2636    }
2637
2638    #[test]
2639    fn cmap_decode_mixed_width_picks_per_position() {
2640        // 1-byte ASCII <00>..<7F> alongside 2-byte <8140>..<FCFC>.
2641        // <00>..<7F> maps each byte to itself (handled by a bfchar
2642        // entry for <41>); <8140> maps to U+4E00 (the canonical CJK
2643        // "one"). Input bytes: 0x41 0x81 0x40 → "A" + U+4E00.
2644        let cmap = b"\
26452 begincodespacerange
2646<00> <7F>
2647<8140> <FCFC>
2648endcodespacerange
26491 beginbfchar
2650<41> <0041>
2651endbfchar
26521 beginbfchar
2653<8140> <4E00>
2654endbfchar
2655";
2656        let parsed = CMap::parse(cmap).unwrap();
2657        let decoder = FontDecoder::ToUnicode {
2658            map: parsed,
2659            cid_width: 1, // ignored when codespaces are present
2660        };
2661        let s = decoder.decode(&[0x41, 0x81, 0x40]);
2662        assert_eq!(s, "A\u{4E00}");
2663    }
2664
2665    #[test]
2666    fn cmap_decode_unmapped_in_codespace_emits_replacement() {
2667        // <00>..<FF> 1-byte territory, no bfchar entries. Every input
2668        // byte is in-codespace but unmapped — each must surface as
2669        // U+FFFD per Adobe Tech Note #5411 §2.
2670        let cmap = b"1 begincodespacerange <00> <FF> endcodespacerange";
2671        let parsed = CMap::parse(cmap).unwrap();
2672        let decoder = FontDecoder::ToUnicode {
2673            map: parsed,
2674            cid_width: 1,
2675        };
2676        let s = decoder.decode(&[0x41, 0x42]);
2677        assert_eq!(s, "\u{FFFD}\u{FFFD}");
2678    }
2679
2680    #[test]
2681    fn cmap_decode_out_of_codespace_emits_replacement_and_advances() {
2682        // <00>..<7F> 1-byte codespace only. Input 0xFF is OUT of every
2683        // declared codespace; the decoder emits U+FFFD and advances
2684        // one byte so a following in-codespace byte still resolves.
2685        let cmap = b"\
26861 begincodespacerange
2687<00> <7F>
2688endcodespacerange
26891 beginbfchar
2690<41> <0041>
2691endbfchar
2692";
2693        let parsed = CMap::parse(cmap).unwrap();
2694        let decoder = FontDecoder::ToUnicode {
2695            map: parsed,
2696            cid_width: 1,
2697        };
2698        let s = decoder.decode(&[0xFF, 0x41]);
2699        assert_eq!(s, "\u{FFFD}A");
2700    }
2701
2702    #[test]
2703    fn cmap_decode_legacy_no_codespacerange_uses_byte_width_fallback() {
2704        // No codespacerange — the decoder falls back to the legacy
2705        // single-width path that uses `byte_width` inferred from the
2706        // first bfchar source operand. Hand-crafted CMaps that omit
2707        // the §9.10.3 mandatory header still decode.
2708        let cmap = b"beginbfchar <0041> <0048> <0042> <0069> endbfchar";
2709        let parsed = CMap::parse(cmap).unwrap();
2710        assert!(parsed.codespaces.is_empty());
2711        assert_eq!(parsed.byte_width, 2);
2712        let decoder = FontDecoder::ToUnicode {
2713            map: parsed,
2714            cid_width: 2,
2715        };
2716        let s = decoder.decode(&[0x00, 0x41, 0x00, 0x42]);
2717        assert_eq!(s, "Hi");
2718    }
2719
2720    #[test]
2721    fn read_literal_string_handles_escapes() {
2722        let input = b"(Hello\\nWorld)";
2723        let (end, payload) = read_literal_string(input, 0).unwrap();
2724        assert_eq!(end, input.len());
2725        assert_eq!(payload, b"Hello\nWorld");
2726    }
2727
2728    #[test]
2729    fn read_literal_string_handles_octal() {
2730        // \101 = 'A'.
2731        let input = b"(\\101BC)";
2732        let (_, payload) = read_literal_string(input, 0).unwrap();
2733        assert_eq!(payload, b"ABC");
2734    }
2735
2736    #[test]
2737    fn read_tj_array_alternates_strings_and_kerns() {
2738        let input = b"[(Hi) -120 (World)]";
2739        let (_, items) = read_tj_array(input, 0).unwrap();
2740        assert_eq!(items.len(), 3);
2741        assert!(matches!(&items[0], TJItem::Str(s) if s == b"Hi"));
2742        assert!(matches!(&items[1], TJItem::Kern(k) if (*k - -120.0).abs() < 1e-3));
2743        assert!(matches!(&items[2], TJItem::Str(s) if s == b"World"));
2744    }
2745}