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}