Skip to main content

pdfluent_extract/
text.rs

1//! Text extraction with character-level position tracking.
2//!
3//! Parses content stream text operators (Tj, TJ, Tm, Td, TD, T*, Tc, Tw, Tz, TL, Ts, ', ")
4//! to extract text with positional information.
5
6// Copyright (c) 2026 Innovation Trigger B.V.
7//
8// PDFluent is available under two licences, at your option: the GNU AGPLv3, or
9// the PDFluent Commercial Licence. See the LICENSE file in this repository --
10// that file travels with the copy you received, which a URL does not.
11
12use crate::error::{ExtractError, Result};
13use lopdf::content::{Content, Operation};
14use lopdf::{Document, Object, ObjectId};
15use std::collections::HashMap;
16use std::sync::OnceLock;
17
18/// Approximate character width as a fraction of font size. Used when no font metric is available.
19const APPROX_CHAR_WIDTH: f64 = 0.5;
20
21/// Whether a text span's width was computed from real font metrics or estimated.
22#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
23pub enum WidthSource {
24    /// Width derived from the font's actual glyph advance data (`/Widths`, hmtx, CFF).
25    Metric,
26    /// Width estimated at `APPROX_CHAR_WIDTH × font_size` — no glyph metric available.
27    #[default]
28    Estimate,
29}
30
31/// A block of text extracted from a page.
32#[derive(Debug, Clone, Default)]
33pub struct TextBlock {
34    /// The extracted text content.
35    pub text: String,
36    /// The page number (1-based).
37    pub page: u32,
38    /// Bounding box [x0, y0, x1, y1] in PDF coordinates.
39    pub bbox: [f64; 4],
40    /// Font resource name (e.g. "F1") — preserved for backward compatibility.
41    pub font_name: String,
42    /// Font size in points.
43    pub font_size: f64,
44    /// `/ActualText` override from a surrounding marked-content sequence (BDC),
45    /// if any. PDF/UA-compliant authors use this to provide the canonical
46    /// reading-order text for ligatures or other glyph clusters whose visual
47    /// `text` does not match the intended characters.
48    pub actual_text: Option<String>,
49
50    // ---- G1 read-only metadata (added 2026-05; backward-compatible) ----
51    /// `BaseFont` of the active font dict with any 6-character subset prefix
52    /// stripped (e.g. `Helvetica-Bold`). `None` when the font dict has no
53    /// `BaseFont` entry or could not be resolved.
54    pub base_font: Option<String>,
55    /// Inferred bold style. Set when any of:
56    ///   - `FontDescriptor /FontWeight >= 700`
57    ///   - `FontDescriptor /Flags` ForceBold bit (bit 19) is set
58    ///   - the (subset-stripped) BaseFont name contains "bold", "demi",
59    ///     "semibold", "heavy", or "black"
60    pub is_bold: bool,
61    /// Inferred italic style. Set when any of:
62    ///   - `FontDescriptor /Flags` Italic bit (bit 7) is set
63    ///   - the BaseFont name contains "italic", "oblique", or "slant"
64    pub is_italic: bool,
65    /// Active non-stroking fill color as sRGB RGBA at the moment the show
66    /// operator emitted this block. `None` for color spaces we cannot map
67    /// without rendering (ICC, Lab, Pattern, Separation, DeviceN).
68    pub color: Option<[u8; 4]>,
69
70    // ---- G2 glyph-level metrics ----
71    /// Whether glyph widths were measured from real font advance data or estimated.
72    pub width_source: WidthSource,
73    /// Per-glyph bounding boxes in PDF user-space coordinates, one entry per
74    /// source glyph (not per Unicode scalar — ligatures count as one).
75    /// `[x0, y0, x1, y1]` where y0 < y1 (PDF coordinate system, y increasing up).
76    pub char_bounds: Vec<[f64; 4]>,
77}
78
79/// A single character with its position on the page.
80#[derive(Debug, Clone)]
81pub struct PositionedChar {
82    /// The character.
83    pub ch: char,
84    /// The page number (1-based).
85    pub page: u32,
86    /// Bounding box [x0, y0, x1, y1] in PDF coordinates.
87    pub bbox: [f64; 4],
88}
89
90/// Internal graphics state for save/restore (q/Q).
91#[derive(Debug, Clone)]
92struct GraphicsState {
93    ctm: [f64; 6],
94    /// G1: snapshot of the non-stroking fill color at `q`.
95    fill_color: Option<[u8; 4]>,
96}
97
98/// Internal text state tracker.
99#[derive(Debug, Clone)]
100struct TextState {
101    /// Text matrix.
102    tm: [f64; 6],
103    /// Text line matrix.
104    tlm: [f64; 6],
105    /// Current font name.
106    font_name: String,
107    /// Current font size.
108    font_size: f64,
109    /// Character spacing (Tc).
110    tc: f64,
111    /// Word spacing (Tw).
112    tw: f64,
113    /// Horizontal scaling (Tz), as a percentage.
114    th: f64,
115    /// Text leading (TL).
116    tl: f64,
117    /// Text rise (Ts).
118    ts: f64,
119    /// Graphics state stack.
120    gs_stack: Vec<GraphicsState>,
121    /// Current transformation matrix.
122    ctm: [f64; 6],
123    /// G1: active non-stroking fill color as sRGB RGBA.
124    /// PDF §8.6.5.3 initial value: DeviceGray 0.0 (black, fully opaque).
125    /// `None` once an unsupported color space takes over (ICC, Lab,
126    /// Pattern, Separation, DeviceN) until a supported setter re-establishes
127    /// a concrete value.
128    fill_color: Option<[u8; 4]>,
129}
130
131impl Default for TextState {
132    fn default() -> Self {
133        Self {
134            tm: [1.0, 0.0, 0.0, 1.0, 0.0, 0.0],
135            tlm: [1.0, 0.0, 0.0, 1.0, 0.0, 0.0],
136            font_name: String::new(),
137            font_size: 12.0,
138            tc: 0.0,
139            tw: 0.0,
140            th: 100.0,
141            tl: 0.0,
142            ts: 0.0,
143            gs_stack: Vec::new(),
144            ctm: [1.0, 0.0, 0.0, 1.0, 0.0, 0.0],
145            fill_color: Some([0, 0, 0, 255]),
146        }
147    }
148}
149
150/// Per-font information for text decoding.
151#[derive(Clone)]
152struct FontInfo {
153    /// True if the font uses 2-byte CID encoding (Identity-H/V or other CID CMaps).
154    is_cid: bool,
155    /// ToUnicode CMap: maps character code(s) to Unicode string.
156    /// For CID fonts the key is a 2-byte big-endian value; for simple fonts it's a 1-byte code.
157    to_unicode: HashMap<u32, String>,
158    /// Encoding-based code→char map for simple fonts (derived from BaseEncoding + Differences).
159    /// Index = byte code (0..255), value = Unicode char if known.
160    encoding_map: [Option<char>; 256],
161    /// Side-table marking codes whose `Differences` glyph name is `ct`.
162    /// The `ct` ligature has no precomposed Unicode codepoint, so it cannot
163    /// be expressed as a `char` in `encoding_map`. When this flag is set,
164    /// decoding emits one private-use marker scalar and records that marker's
165    /// origin so glyph advance remains one codepoint wide. The marker expands
166    /// to "ct" only in the ligature-decomposition layer, avoiding clobbering
167    /// legitimate U+E007 values that arrive through ToUnicode CMaps.
168    ct_codes: [bool; 256],
169    /// G1: `BaseFont` of the font dict with subset prefix stripped.
170    base_font: Option<String>,
171    /// G1: Inferred bold style — from FontDescriptor (Flags ForceBold bit 19,
172    /// FontWeight ≥ 700) or BaseFont name heuristics.
173    is_bold: bool,
174    /// G1: Inferred italic style — from FontDescriptor (Flags Italic bit 7)
175    /// or BaseFont name heuristics.
176    is_italic: bool,
177
178    // ---- G2: per-code advance widths (in 1/1000 em units) ----
179    /// For simple fonts: maps char_code (0–255) to advance width in glyph
180    /// units (1/1000 em). Built from `FirstChar` + `/Widths` array.
181    /// Empty when the font dict has no `/Widths` or the data is malformed.
182    simple_widths: Box<[Option<f32>; 256]>,
183    /// Default width for CID glyphs (PDF §9.7.4.3). Used when no explicit
184    /// entry is found in `cid_widths`. PDF default = 1000.
185    cid_default_width: f32,
186    /// For Type0 fonts: maps CID → advance width (glyph units).
187    /// Built from the `/W` array of the first DescendantFont.
188    cid_widths: HashMap<u32, f32>,
189}
190
191/// Strip a 6-character subset prefix (e.g. `AAAAAA+Helvetica` → `Helvetica`).
192fn strip_subset_prefix(name: &str) -> &str {
193    match name.split_once('+') {
194        Some((prefix, rest)) if prefix.len() == 6 => rest,
195        _ => name,
196    }
197}
198
199/// PDF Font Descriptor `/Flags` field. Per PDF 1.7 §9.8.2 the relevant bits
200/// for style inference are bit 7 (Italic) and bit 19 (ForceBold), 1-indexed —
201/// i.e. mask 0x40 and 0x40000 in a 0-indexed u32.
202const FONT_FLAG_ITALIC: u32 = 1 << 6;
203const FONT_FLAG_FORCE_BOLD: u32 = 1 << 18;
204
205/// Derive `(base_font, is_bold, is_italic)` from a font dict + its descriptor.
206/// All three values are best-effort; missing data is the documented fallback
207/// per ROUND1_API_DESIGN.md.
208fn derive_font_style(doc: &Document, font: &lopdf::Dictionary) -> (Option<String>, bool, bool) {
209    let base_font_raw = font.get(b"BaseFont").ok().and_then(|o| match o {
210        Object::Name(n) => Some(String::from_utf8_lossy(n).to_string()),
211        _ => None,
212    });
213    let base_font = base_font_raw
214        .as_deref()
215        .map(|s| strip_subset_prefix(s).to_string());
216
217    let (name_bold, name_italic) = base_font
218        .as_deref()
219        .map(name_style_hints)
220        .unwrap_or((false, false));
221
222    let descriptor = resolve_dict(doc, font, b"FontDescriptor");
223    let (desc_bold, desc_italic) = descriptor
224        .as_ref()
225        .map(|d| {
226            let weight_bold = d
227                .get(b"FontWeight")
228                .ok()
229                .and_then(|o| match o {
230                    Object::Integer(i) => Some(*i as u32),
231                    Object::Real(r) => Some(*r as u32),
232                    _ => None,
233                })
234                .is_some_and(|w| w >= 700);
235            let flags = d
236                .get(b"Flags")
237                .ok()
238                .and_then(|o| match o {
239                    Object::Integer(i) => Some(*i as u32),
240                    Object::Real(r) => Some(*r as u32),
241                    _ => None,
242                })
243                .unwrap_or(0);
244            let flag_italic = (flags & FONT_FLAG_ITALIC) != 0;
245            let flag_force_bold = (flags & FONT_FLAG_FORCE_BOLD) != 0;
246            (weight_bold || flag_force_bold, flag_italic)
247        })
248        .unwrap_or((false, false));
249
250    (
251        base_font,
252        desc_bold || name_bold,
253        desc_italic || name_italic,
254    )
255}
256
257/// Convert a [0.0, 1.0] PDF color component to a u8 channel, clamping.
258fn clamp_unit_to_u8(v: f64) -> u8 {
259    (v.clamp(0.0, 1.0) * 255.0).round() as u8
260}
261
262/// Convert DeviceCMYK to sRGB via the standard non-color-managed formula.
263/// Accurate enough for editor UI; full ICC-managed paths render through
264/// the engine and use `moxcms`.
265fn cmyk_to_rgba(c: f64, m: f64, y: f64, k: f64) -> [u8; 4] {
266    let c = c.clamp(0.0, 1.0);
267    let m = m.clamp(0.0, 1.0);
268    let y = y.clamp(0.0, 1.0);
269    let k = k.clamp(0.0, 1.0);
270    let r = (1.0 - c) * (1.0 - k);
271    let g = (1.0 - m) * (1.0 - k);
272    let b = (1.0 - y) * (1.0 - k);
273    [
274        clamp_unit_to_u8(r),
275        clamp_unit_to_u8(g),
276        clamp_unit_to_u8(b),
277        255,
278    ]
279}
280
281/// Heuristic style hints from a PostScript / BaseFont name. Matches
282/// `pdf-interpret::FallbackFontQuery::new` rules.
283fn name_style_hints(name: &str) -> (bool, bool) {
284    let lower = name.to_ascii_lowercase();
285    let italic = lower.contains("italic") || lower.contains("oblique") || lower.contains("slant");
286    let bold = lower.contains("bold")
287        || lower.contains("demi")
288        || lower.contains("semibold")
289        || lower.contains("heavy")
290        || lower.contains("black");
291    (bold, italic)
292}
293
294/// Look up the glyph advance width for `char_code` in `fi`, returning
295/// `Some(width_in_glyph_units)` (1/1000 em) when font metric data is
296/// present, or `None` when only the 50%-em estimate is available.
297///
298/// For CID fonts the 2-byte char code is passed as-is (big-endian u32).
299fn font_glyph_advance(fi: &FontInfo, char_code: u32) -> Option<f32> {
300    if fi.is_cid {
301        fi.cid_widths
302            .get(&char_code)
303            .copied()
304            .or(if fi.cid_default_width > 0.0 {
305                Some(fi.cid_default_width)
306            } else {
307                None
308            })
309    } else {
310        fi.simple_widths.get(char_code as usize).and_then(|w| *w)
311    }
312}
313
314/// Returns `true` when `fi` has at least one real glyph-width entry so we
315/// can set `WidthSource::Metric` on the resulting block.
316fn fi_has_metrics(fi: &FontInfo) -> bool {
317    if fi.is_cid {
318        // DW is always a real value from the PDF (default 1000 when missing).
319        true
320    } else {
321        fi.simple_widths.iter().any(|w| w.is_some())
322    }
323}
324
325/// Compute per-glyph advance widths (in user space) for a raw PDF byte string.
326///
327/// Returns `(advances, width_source)` where `advances` contains one entry per
328/// source glyph (1-byte for simple fonts, 2-byte pairs for CID).  Each entry
329/// already factors in `Tz` (horizontal scaling) and `Tc`/`Tw` spacing.
330///
331/// The caller is responsible for applying TJ kerning offsets *between* calls.
332fn compute_glyph_advances(
333    bytes: &[u8],
334    fi: &FontInfo,
335    font_size: f64,
336    tc: f64,
337    tw: f64,
338    th: f64,
339) -> (Vec<f64>, WidthSource) {
340    let has_metrics = fi_has_metrics(fi);
341    let width_source = if has_metrics {
342        WidthSource::Metric
343    } else {
344        WidthSource::Estimate
345    };
346    let approx = APPROX_CHAR_WIDTH * 1000.0; // fallback in glyph units
347
348    let mut advances = Vec::new();
349
350    if fi.is_cid {
351        let mut i = 0;
352        while i + 1 < bytes.len() {
353            let code = u16::from_be_bytes([bytes[i], bytes[i + 1]]) as u32;
354            i += 2;
355            let glyph_w = font_glyph_advance(fi, code).unwrap_or(approx as f32);
356            let adv = glyph_w as f64 / 1000.0 * font_size * (th / 100.0) + tc;
357            advances.push(adv);
358        }
359    } else {
360        for &b in bytes {
361            let glyph_w = if has_metrics {
362                font_glyph_advance(fi, b as u32).unwrap_or(approx as f32)
363            } else {
364                approx as f32
365            };
366            let extra_word = if b == 0x20 { tw } else { 0.0 };
367            let adv = glyph_w as f64 / 1000.0 * font_size * (th / 100.0) + tc + extra_word;
368            advances.push(adv);
369        }
370    }
371
372    (advances, width_source)
373}
374
375/// Horizontal scale of a text matrix: the length of the image of the x unit vector.
376fn hscale(tm: &[f64; 6]) -> f64 {
377    let s = tm[0].hypot(tm[1]);
378    if s.is_finite() && s > 0.0 {
379        s
380    } else {
381        1.0
382    }
383}
384
385/// Vertical scale of a text matrix: the length of the image of the y unit vector.
386fn vscale(tm: &[f64; 6]) -> f64 {
387    let s = tm[2].hypot(tm[3]);
388    if s.is_finite() && s > 0.0 {
389        s
390    } else {
391        1.0
392    }
393}
394
395/// Compact text-state parameters passed into the char-bounds helpers.
396struct GlyphCtx {
397    font_size: f64,
398    /// Horizontal and vertical scale of the text matrix. A glyph is `font_size`
399    /// in text space; on the page it is `font_size * hscale` wide and
400    /// `font_size * vscale` high. `Tf 1` with `Tm 12 0 0 12` is the usual case (#848).
401    hscale: f64,
402    vscale: f64,
403    tc: f64,
404    tw: f64,
405    th: f64,
406}
407
408/// Build char bounds for a Tj string. Returns `(char_bounds, width_source, total_advance)`.
409fn char_bounds_from_bytes(
410    bytes: &[u8],
411    fi: &FontInfo,
412    x: f64,
413    y: f64,
414    ctx: &GlyphCtx,
415) -> (Vec<[f64; 4]>, WidthSource, f64) {
416    let (advances, ws) = compute_glyph_advances(
417        bytes,
418        fi,
419        ctx.font_size * ctx.hscale,
420        ctx.tc,
421        ctx.tw,
422        ctx.th,
423    );
424    let mut cx = x;
425    let mut bounds = Vec::with_capacity(advances.len());
426    for adv in &advances {
427        bounds.push([cx, y, cx + adv, y + ctx.font_size * ctx.vscale]);
428        cx += adv;
429    }
430    (bounds, ws, cx - x)
431}
432
433/// Build char bounds for a TJ array, returning `(char_bounds, width_source, x_end)`.
434///
435/// TJ items are either strings (glyph runs) or numbers (kerning offsets in
436/// thousandths of an em).  Kerning offsets shift the current position and are
437/// NOT included as separate char_bounds entries.
438fn char_bounds_from_tj_array(
439    arr: &[Object],
440    fi: &FontInfo,
441    x_start: f64,
442    y: f64,
443    ctx: &GlyphCtx,
444) -> (Vec<[f64; 4]>, WidthSource, f64) {
445    let font_size = ctx.font_size * ctx.hscale;
446    let height = ctx.font_size * ctx.vscale;
447    let tc = ctx.tc;
448    let tw = ctx.tw;
449    let th = ctx.th;
450    let has_metrics = fi_has_metrics(fi);
451    let width_source = if has_metrics {
452        WidthSource::Metric
453    } else {
454        WidthSource::Estimate
455    };
456    let approx = APPROX_CHAR_WIDTH * 1000.0_f64;
457
458    let mut bounds = Vec::new();
459    let mut cx = x_start;
460
461    for item in arr {
462        match item {
463            Object::String(bytes, _) => {
464                if fi.is_cid {
465                    let mut i = 0;
466                    while i + 1 < bytes.len() {
467                        let code = u16::from_be_bytes([bytes[i], bytes[i + 1]]) as u32;
468                        i += 2;
469                        let glyph_w = font_glyph_advance(fi, code).unwrap_or(approx as f32) as f64;
470                        let adv = glyph_w / 1000.0 * font_size * (th / 100.0) + tc;
471                        bounds.push([cx, y, cx + adv, y + height]);
472                        cx += adv;
473                    }
474                } else {
475                    for &b in bytes.iter() {
476                        let glyph_w = if has_metrics {
477                            font_glyph_advance(fi, b as u32).unwrap_or(approx as f32) as f64
478                        } else {
479                            approx
480                        };
481                        let extra_word = if b == 0x20 { tw } else { 0.0 };
482                        let adv = glyph_w / 1000.0 * font_size * (th / 100.0) + tc + extra_word;
483                        bounds.push([cx, y, cx + adv, y + height]);
484                        cx += adv;
485                    }
486                }
487            }
488            _ => {
489                if let Some(adj) = as_number(item) {
490                    // Negative shifts right (standard kerning), positive shifts left.
491                    cx -= adj / 1000.0 * font_size * (th / 100.0);
492                }
493            }
494        }
495    }
496
497    (bounds, width_source, cx)
498}
499
500/// Build a map from font resource name (e.g. "F1") to FontInfo for a page.
501fn build_font_map(doc: &Document, page_id: ObjectId) -> HashMap<String, FontInfo> {
502    let mut map = HashMap::new();
503
504    // Get the page's Resources dictionary (may be inherited from parent Pages node).
505    let resources = get_page_resources(doc, page_id);
506    let font_dict = match resources.and_then(|res| match res.get(b"Font").ok()? {
507        Object::Dictionary(d) => Some(d.clone()),
508        Object::Reference(r) => match doc.get_object(*r).ok()? {
509            Object::Dictionary(d) => Some(d.clone()),
510            _ => None,
511        },
512        _ => None,
513    }) {
514        Some(d) => d,
515        None => return map,
516    };
517
518    for (name_bytes, value) in font_dict.iter() {
519        let font_name = String::from_utf8_lossy(name_bytes).to_string();
520
521        // Resolve the font dictionary.
522        let font = match value {
523            Object::Reference(r) => match doc.get_object(*r).ok() {
524                Some(Object::Dictionary(d)) => d.clone(),
525                _ => continue,
526            },
527            Object::Dictionary(d) => d.clone(),
528            _ => continue,
529        };
530
531        let subtype = font
532            .get(b"Subtype")
533            .ok()
534            .and_then(|o| match o {
535                Object::Name(n) => Some(String::from_utf8_lossy(n).to_string()),
536                _ => None,
537            })
538            .unwrap_or_default();
539
540        let is_cid = subtype == "Type0";
541
542        // Parse ToUnicode CMap if present.
543        let to_unicode = parse_to_unicode_from_font(doc, &font);
544
545        // For Type0 fonts, also check DescendantFonts for ToUnicode.
546        let to_unicode = if to_unicode.is_empty() && is_cid {
547            if let Ok(Object::Array(descendants)) = font.get(b"DescendantFonts") {
548                descendants
549                    .iter()
550                    .find_map(|d| {
551                        let desc_dict = match d {
552                            Object::Reference(r) => match doc.get_object(*r).ok()? {
553                                Object::Dictionary(d) => d,
554                                _ => return None,
555                            },
556                            Object::Dictionary(d) => d,
557                            _ => return None,
558                        };
559                        let tu = parse_to_unicode_from_font(doc, desc_dict);
560                        if tu.is_empty() {
561                            None
562                        } else {
563                            Some(tu)
564                        }
565                    })
566                    .unwrap_or_default()
567            } else {
568                HashMap::new()
569            }
570        } else {
571            to_unicode
572        };
573
574        // Build encoding map for simple fonts (from Encoding + Differences).
575        let (encoding_map, ct_codes) = if !is_cid {
576            build_encoding_map(doc, &font)
577        } else {
578            ([None; 256], [false; 256])
579        };
580
581        let (base_font, is_bold, is_italic) = derive_font_style(doc, &font);
582
583        let (simple_widths, cid_default_width, cid_widths) = parse_font_widths(doc, &font, is_cid);
584
585        map.insert(
586            font_name,
587            FontInfo {
588                is_cid,
589                to_unicode,
590                encoding_map,
591                ct_codes,
592                base_font,
593                is_bold,
594                is_italic,
595                simple_widths,
596                cid_default_width,
597                cid_widths,
598            },
599        );
600    }
601
602    map
603}
604
605/// Parse glyph advance widths from a font dictionary.
606///
607/// Returns `(simple_widths, cid_default_width, cid_widths)`.
608/// - `simple_widths`: per-code widths for simple fonts (1/1000 em; code 0–255).
609/// - `cid_default_width`: `/DW` from the DescendantFont (default 1000).
610/// - `cid_widths`: CID→width map from `/W` array of the DescendantFont.
611fn parse_font_widths(
612    doc: &Document,
613    font: &lopdf::Dictionary,
614    is_cid: bool,
615) -> (Box<[Option<f32>; 256]>, f32, HashMap<u32, f32>) {
616    if is_cid {
617        let (dw, cid_map) = parse_cid_widths(doc, font);
618        return (Box::new([None; 256]), dw, cid_map);
619    }
620
621    // Simple font: /FirstChar + /Widths
622    let first_char = match font.get(b"FirstChar").ok() {
623        Some(Object::Integer(n)) => *n as usize,
624        _ => return (Box::new([None; 256]), 1000.0, HashMap::new()),
625    };
626
627    let widths_arr = match font.get(b"Widths").ok() {
628        Some(Object::Array(a)) => a.clone(),
629        Some(Object::Reference(r)) => match doc.get_object(*r).ok() {
630            Some(Object::Array(a)) => a.clone(),
631            _ => return (Box::new([None; 256]), 1000.0, HashMap::new()),
632        },
633        _ => return (Box::new([None; 256]), 1000.0, HashMap::new()),
634    };
635
636    let mut simple_widths = Box::new([None::<f32>; 256]);
637    for (i, obj) in widths_arr.iter().enumerate() {
638        let code = first_char + i;
639        if code > 255 {
640            break;
641        }
642        let w = match obj {
643            Object::Integer(n) => *n as f32,
644            Object::Real(n) => *n,
645            _ => continue,
646        };
647        simple_widths[code] = Some(w);
648    }
649
650    (simple_widths, 1000.0, HashMap::new())
651}
652
653/// Parse the `/W` array (run-length CID width encoding) and `/DW` from a
654/// Type0 font's DescendantFonts array.
655fn parse_cid_widths(doc: &Document, font: &lopdf::Dictionary) -> (f32, HashMap<u32, f32>) {
656    let descendants = match font.get(b"DescendantFonts").ok() {
657        Some(Object::Array(a)) => a.clone(),
658        Some(Object::Reference(r)) => match doc.get_object(*r).ok() {
659            Some(Object::Array(a)) => a.clone(),
660            _ => return (1000.0, HashMap::new()),
661        },
662        _ => return (1000.0, HashMap::new()),
663    };
664
665    let desc_dict = match descendants.first() {
666        Some(Object::Reference(r)) => match doc.get_object(*r).ok() {
667            Some(Object::Dictionary(d)) => d.clone(),
668            _ => return (1000.0, HashMap::new()),
669        },
670        Some(Object::Dictionary(d)) => d.clone(),
671        _ => return (1000.0, HashMap::new()),
672    };
673
674    let dw = match desc_dict.get(b"DW").ok() {
675        Some(Object::Integer(n)) => *n as f32,
676        Some(Object::Real(n)) => *n,
677        _ => 1000.0,
678    };
679
680    let w_arr = match desc_dict.get(b"W").ok() {
681        Some(Object::Array(a)) => a.clone(),
682        Some(Object::Reference(r)) => match doc.get_object(*r).ok() {
683            Some(Object::Array(a)) => a.clone(),
684            _ => return (dw, HashMap::new()),
685        },
686        _ => return (dw, HashMap::new()),
687    };
688
689    // PDF §9.7.4.3  /W: [ c [w1 w2 …] | c1 c2 w | … ]
690    let mut map = HashMap::new();
691    let mut idx = 0;
692    while idx < w_arr.len() {
693        let cid_start = match &w_arr[idx] {
694            Object::Integer(n) => *n as u32,
695            _ => {
696                idx += 1;
697                continue;
698            }
699        };
700        idx += 1;
701        if idx >= w_arr.len() {
702            break;
703        }
704        match &w_arr[idx] {
705            Object::Array(widths) => {
706                // [ cid [w1 w2 …] ] — one width per consecutive CID
707                for (i, wobj) in widths.iter().enumerate() {
708                    let w = match wobj {
709                        Object::Integer(n) => *n as f32,
710                        Object::Real(n) => *n,
711                        _ => continue,
712                    };
713                    map.insert(cid_start + i as u32, w);
714                }
715                idx += 1;
716            }
717            Object::Integer(c2) => {
718                // [ cid_first cid_last w ] — same width for a range
719                let cid_end = *c2 as u32;
720                idx += 1;
721                if idx >= w_arr.len() {
722                    break;
723                }
724                let w = match &w_arr[idx] {
725                    Object::Integer(n) => *n as f32,
726                    Object::Real(n) => *n,
727                    _ => {
728                        idx += 1;
729                        continue;
730                    }
731                };
732                for cid in cid_start..=cid_end {
733                    map.insert(cid, w);
734                }
735                idx += 1;
736            }
737            _ => {
738                idx += 1;
739            }
740        }
741    }
742
743    (dw, map)
744}
745
746/// Parse the ToUnicode CMap from a font dictionary.
747fn parse_to_unicode_from_font(doc: &Document, font: &lopdf::Dictionary) -> HashMap<u32, String> {
748    let tu_obj = match font.get(b"ToUnicode").ok() {
749        Some(Object::Reference(r)) => doc.get_object(*r).ok(),
750        Some(obj) => Some(obj),
751        None => return HashMap::new(),
752    };
753
754    let stream_bytes = match tu_obj {
755        Some(Object::Stream(ref s)) => s
756            .decompressed_content()
757            .ok()
758            .unwrap_or_else(|| s.content.clone()),
759        _ => return HashMap::new(),
760    };
761
762    parse_to_unicode_cmap(&stream_bytes)
763}
764
765/// Parse a ToUnicode CMap stream into a code→Unicode mapping.
766///
767/// Handles both `beginbfchar` and `beginbfrange` sections.
768fn parse_to_unicode_cmap(data: &[u8]) -> HashMap<u32, String> {
769    let text = String::from_utf8_lossy(data);
770    let mut map = HashMap::new();
771
772    // Parse beginbfchar sections: <srcCode> <dstString>
773    for section in text.split("beginbfchar") {
774        let section = match section.split("endbfchar").next() {
775            Some(s) => s,
776            None => continue,
777        };
778        let tokens = extract_hex_tokens(section);
779        for pair in tokens.chunks(2) {
780            if pair.len() == 2 {
781                let code = parse_hex_u32(&pair[0]);
782                let unicode = hex_to_unicode_string(&pair[1]);
783                map.insert(code, unicode);
784            }
785        }
786    }
787
788    // Parse beginbfrange sections: <srcLo> <srcHi> <dstStart> or <srcLo> <srcHi> [<dst1> <dst2> ...]
789    for section in text.split("beginbfrange") {
790        let section = match section.split("endbfrange").next() {
791            Some(s) => s,
792            None => continue,
793        };
794
795        // Tokenize: extract hex tokens and array brackets
796        let mut chars = section.chars().peekable();
797        let mut tokens: Vec<String> = Vec::new();
798        let mut arrays: Vec<Vec<String>> = Vec::new();
799        let mut in_array = false;
800        let mut current_array: Vec<String> = Vec::new();
801
802        while let Some(&ch) = chars.peek() {
803            if ch == '<' {
804                chars.next();
805                let hex: String = chars
806                    .by_ref()
807                    .take_while(|&c| c != '>')
808                    .filter(|c| !c.is_whitespace())
809                    .collect();
810                if in_array {
811                    current_array.push(hex);
812                } else {
813                    tokens.push(hex);
814                }
815            } else if ch == '[' {
816                chars.next();
817                in_array = true;
818                current_array = Vec::new();
819            } else if ch == ']' {
820                chars.next();
821                in_array = false;
822                arrays.push(std::mem::take(&mut current_array));
823                tokens.push(String::new()); // placeholder for array position
824            } else {
825                chars.next();
826            }
827        }
828
829        // Process range entries: every 3 tokens = (lo, hi, dst_or_array)
830        let mut array_idx = 0;
831        let mut i = 0;
832        while i + 2 < tokens.len() {
833            let lo = parse_hex_u32(&tokens[i]);
834            let hi = parse_hex_u32(&tokens[i + 1]);
835
836            if tokens[i + 2].is_empty() {
837                // Array destination
838                if array_idx < arrays.len() {
839                    let arr = &arrays[array_idx];
840                    for (offset, dst) in arr.iter().enumerate() {
841                        let code = lo + offset as u32;
842                        if code <= hi {
843                            map.insert(code, hex_to_unicode_string(dst));
844                        }
845                    }
846                    array_idx += 1;
847                }
848            } else {
849                // Single start value — increment for each code in range
850                let dst_start = parse_hex_u32(&tokens[i + 2]);
851                let dst_len = tokens[i + 2].len();
852                for code in lo..=hi {
853                    let dst_val = dst_start + (code - lo);
854                    let s = if dst_len <= 4 {
855                        // BMP character
856                        char::from_u32(dst_val)
857                            .map(|c| c.to_string())
858                            .unwrap_or_default()
859                    } else {
860                        // Multi-byte: treat as UTF-16BE pairs
861                        let hex = format!("{:0>width$X}", dst_val, width = dst_len);
862                        hex_to_unicode_string(&hex)
863                    };
864                    map.insert(code, s);
865                }
866            }
867            i += 3;
868        }
869    }
870
871    map
872}
873
874/// Extract hex tokens (contents between < and >) from text.
875fn extract_hex_tokens(text: &str) -> Vec<String> {
876    let mut tokens = Vec::new();
877    let mut in_hex = false;
878    let mut current = String::new();
879    for ch in text.chars() {
880        if ch == '<' {
881            in_hex = true;
882            current.clear();
883        } else if ch == '>' && in_hex {
884            in_hex = false;
885            tokens.push(current.clone());
886        } else if in_hex && !ch.is_whitespace() {
887            current.push(ch);
888        }
889    }
890    tokens
891}
892
893/// Parse a hex string to a u32 (e.g., "0041" → 65).
894fn parse_hex_u32(hex: &str) -> u32 {
895    u32::from_str_radix(hex, 16).unwrap_or(0)
896}
897
898/// Convert a hex string to a Unicode string (interpreting as UTF-16BE pairs).
899fn hex_to_unicode_string(hex: &str) -> String {
900    let bytes: Vec<u8> = (0..hex.len())
901        .step_by(2)
902        .filter_map(|i| u8::from_str_radix(&hex[i..i + 2.min(hex.len() - i)], 16).ok())
903        .collect();
904
905    if bytes.len() >= 2 && bytes.len().is_multiple_of(2) {
906        // Interpret as UTF-16BE
907        let u16s: Vec<u16> = bytes
908            .chunks(2)
909            .map(|c| u16::from_be_bytes([c[0], c[1]]))
910            .collect();
911        String::from_utf16_lossy(&u16s)
912    } else if bytes.len() == 1 {
913        char::from_u32(bytes[0] as u32)
914            .map(|c| c.to_string())
915            .unwrap_or_default()
916    } else {
917        String::new()
918    }
919}
920
921/// Get the Resources dictionary for a page (resolves inheritance from parent Pages).
922fn get_page_resources(doc: &Document, page_id: ObjectId) -> Option<lopdf::Dictionary> {
923    let page = match doc.get_object(page_id).ok()? {
924        Object::Dictionary(d) => d.clone(),
925        _ => return None,
926    };
927
928    // Direct Resources on the page.
929    if let Some(res) = resolve_dict(doc, &page, b"Resources") {
930        return Some(res);
931    }
932
933    // Walk up the parent chain (Pages nodes) for inherited Resources.
934    let mut current = page;
935    for _ in 0..20 {
936        let parent_ref = match current.get(b"Parent").ok()? {
937            Object::Reference(r) => *r,
938            _ => break,
939        };
940        let parent = match doc.get_object(parent_ref).ok()? {
941            Object::Dictionary(d) => d.clone(),
942            _ => break,
943        };
944        if let Some(res) = resolve_dict(doc, &parent, b"Resources") {
945            return Some(res);
946        }
947        current = parent;
948    }
949
950    None
951}
952
953/// Resolve a dictionary entry that may be inline or an indirect reference.
954fn resolve_dict(doc: &Document, dict: &lopdf::Dictionary, key: &[u8]) -> Option<lopdf::Dictionary> {
955    match dict.get(key).ok()? {
956        Object::Dictionary(d) => Some(d.clone()),
957        Object::Reference(r) => match doc.get_object(*r).ok()? {
958            Object::Dictionary(d) => Some(d.clone()),
959            _ => None,
960        },
961        _ => None,
962    }
963}
964
965/// Build a 256-entry encoding map from a font's /Encoding dictionary.
966///
967/// Handles:
968/// - Named base encodings: WinAnsiEncoding, MacRomanEncoding, MacExpertEncoding
969/// - Differences arrays: `[code1 /name1 /name2 ... codeN /nameN ...]`
970/// - Glyph name → Unicode via AGL (Adobe Glyph List) lookup
971fn build_encoding_map(
972    doc: &Document,
973    font: &lopdf::Dictionary,
974) -> ([Option<char>; 256], [bool; 256]) {
975    let mut table = [None::<char>; 256];
976    let mut ct_codes = [false; 256];
977
978    let encoding = match font.get(b"Encoding").ok() {
979        Some(obj) => obj,
980        None => return (table, ct_codes),
981    };
982
983    match encoding {
984        Object::Name(name) => {
985            // Named encoding (e.g. "WinAnsiEncoding").
986            let name_str = String::from_utf8_lossy(name);
987            apply_base_encoding(&mut table, &name_str);
988        }
989        Object::Reference(r) => match doc.get_object(*r) {
990            Ok(Object::Dictionary(enc_dict)) => {
991                parse_encoding_dict(doc, enc_dict, &mut table, &mut ct_codes);
992            }
993            Ok(Object::Name(name)) => {
994                let name_str = String::from_utf8_lossy(name);
995                apply_base_encoding(&mut table, &name_str);
996            }
997            _ => {}
998        },
999        Object::Dictionary(enc_dict) => {
1000            parse_encoding_dict(doc, enc_dict, &mut table, &mut ct_codes);
1001        }
1002        _ => {}
1003    }
1004
1005    (table, ct_codes)
1006}
1007
1008/// Parse an Encoding dictionary with optional BaseEncoding and Differences.
1009fn parse_encoding_dict(
1010    doc: &Document,
1011    enc_dict: &lopdf::Dictionary,
1012    table: &mut [Option<char>; 256],
1013    ct_codes: &mut [bool; 256],
1014) {
1015    // Apply BaseEncoding first.
1016    if let Ok(Object::Name(base)) = enc_dict.get(b"BaseEncoding") {
1017        let base_str = String::from_utf8_lossy(base);
1018        apply_base_encoding(table, &base_str);
1019    }
1020
1021    // Apply Differences array: [code /name1 /name2 ... code /name3 ...]
1022    let diffs = match enc_dict.get(b"Differences").ok() {
1023        Some(Object::Array(arr)) => arr.clone(),
1024        Some(Object::Reference(r)) => match doc.get_object(*r).ok() {
1025            Some(Object::Array(arr)) => arr.clone(),
1026            _ => return,
1027        },
1028        _ => return,
1029    };
1030
1031    let mut code: Option<u32> = None;
1032    for item in &diffs {
1033        match item {
1034            Object::Integer(n) => {
1035                code = Some(*n as u32);
1036            }
1037            Object::Name(name) => {
1038                if let Some(c) = code {
1039                    if c < 256 {
1040                        let glyph = String::from_utf8_lossy(name);
1041                        apply_glyph_name(&glyph, c as usize, table, ct_codes);
1042                    }
1043                    code = Some(c + 1);
1044                }
1045            }
1046            Object::Reference(r) => {
1047                // Indirect name reference (rare).
1048                if let Ok(Object::Name(name)) = doc.get_object(*r) {
1049                    if let Some(c) = code {
1050                        if c < 256 {
1051                            let glyph = String::from_utf8_lossy(name);
1052                            apply_glyph_name(&glyph, c as usize, table, ct_codes);
1053                        }
1054                        code = Some(c + 1);
1055                    }
1056                }
1057            }
1058            _ => {}
1059        }
1060    }
1061}
1062
1063/// Resolve a glyph name to either a single Unicode codepoint (stored in
1064/// `table`) or the `ct` ligature marker (recorded in `ct_codes`). The `ct`
1065/// ligature has no precomposed Unicode codepoint, so it cannot live in
1066/// `table`; it is tracked separately so that decoding can emit one internal
1067/// marker and preserve one-glyph text advance before later decomposition.
1068fn apply_glyph_name(
1069    glyph: &str,
1070    code: usize,
1071    table: &mut [Option<char>; 256],
1072    ct_codes: &mut [bool; 256],
1073) {
1074    if glyph == "ct" {
1075        // Clear any prior char mapping at this slot — the ct flag is the
1076        // sole source of truth for this code.
1077        table[code] = None;
1078        ct_codes[code] = true;
1079        return;
1080    }
1081    if let Some(ch) = glyph_name_to_unicode(glyph) {
1082        table[code] = Some(ch);
1083        ct_codes[code] = false;
1084    }
1085}
1086
1087/// Apply a named base encoding to the table.
1088fn apply_base_encoding(table: &mut [Option<char>; 256], name: &str) {
1089    let source = match name {
1090        "WinAnsiEncoding" => winansi_encoding(),
1091        "MacRomanEncoding" => mac_roman_encoding(),
1092        _ => return,
1093    };
1094    for (i, &ch) in source.iter().enumerate() {
1095        if ch != '\0' {
1096            table[i] = Some(ch);
1097        }
1098    }
1099}
1100
1101/// WinAnsiEncoding table (cp1252).
1102fn winansi_encoding() -> &'static [char; 256] {
1103    static TABLE: OnceLock<[char; 256]> = OnceLock::new();
1104    TABLE.get_or_init(|| {
1105        let mut t = ['\0'; 256];
1106        // ASCII range is identity.
1107        for i in 0x20..=0x7Eu8 {
1108            t[i as usize] = i as char;
1109        }
1110        // Control chars mapped to common usage.
1111        t[0x09] = '\t';
1112        t[0x0A] = '\n';
1113        t[0x0D] = '\r';
1114        // cp1252 upper range (0x80-0xFF).
1115        let cp1252: [(u8, char); 27] = [
1116            (0x80, '\u{20AC}'), // Euro sign
1117            (0x82, '\u{201A}'), // Single low-9 quotation mark
1118            (0x83, '\u{0192}'), // Latin small letter f with hook
1119            (0x84, '\u{201E}'), // Double low-9 quotation mark
1120            (0x85, '\u{2026}'), // Horizontal ellipsis
1121            (0x86, '\u{2020}'), // Dagger
1122            (0x87, '\u{2021}'), // Double dagger
1123            (0x88, '\u{02C6}'), // Modifier letter circumflex accent
1124            (0x89, '\u{2030}'), // Per mille sign
1125            (0x8A, '\u{0160}'), // Latin capital letter S with caron
1126            (0x8B, '\u{2039}'), // Single left-pointing angle quotation mark
1127            (0x8C, '\u{0152}'), // Latin capital ligature OE
1128            (0x8E, '\u{017D}'), // Latin capital letter Z with caron
1129            (0x91, '\u{2018}'), // Left single quotation mark
1130            (0x92, '\u{2019}'), // Right single quotation mark
1131            (0x93, '\u{201C}'), // Left double quotation mark
1132            (0x94, '\u{201D}'), // Right double quotation mark
1133            (0x95, '\u{2022}'), // Bullet
1134            (0x96, '\u{2013}'), // En dash
1135            (0x97, '\u{2014}'), // Em dash
1136            (0x98, '\u{02DC}'), // Small tilde
1137            (0x99, '\u{2122}'), // Trade mark sign
1138            (0x9A, '\u{0161}'), // Latin small letter s with caron
1139            (0x9B, '\u{203A}'), // Single right-pointing angle quotation mark
1140            (0x9C, '\u{0153}'), // Latin small ligature oe
1141            (0x9E, '\u{017E}'), // Latin small letter z with caron
1142            (0x9F, '\u{0178}'), // Latin capital letter Y with diaeresis
1143        ];
1144        for (code, ch) in cp1252 {
1145            t[code as usize] = ch;
1146        }
1147        // 0xA0-0xFF: same as Unicode Latin-1 supplement.
1148        for i in 0xA0..=0xFFu16 {
1149            t[i as usize] = char::from_u32(i as u32).unwrap_or('\0');
1150        }
1151        t
1152    })
1153}
1154
1155/// MacRomanEncoding table.
1156fn mac_roman_encoding() -> &'static [char; 256] {
1157    static TABLE: OnceLock<[char; 256]> = OnceLock::new();
1158    TABLE.get_or_init(|| {
1159        let mut t = ['\0'; 256];
1160        // ASCII range is identity.
1161        for i in 0x20..=0x7Eu8 {
1162            t[i as usize] = i as char;
1163        }
1164        t[0x09] = '\t';
1165        t[0x0A] = '\n';
1166        t[0x0D] = '\r';
1167        // Mac Roman 0x80-0xFF mapping to Unicode.
1168        let mac_upper: [char; 128] = [
1169            '\u{00C4}', '\u{00C5}', '\u{00C7}', '\u{00C9}', '\u{00D1}', '\u{00D6}', '\u{00DC}',
1170            '\u{00E1}', '\u{00E0}', '\u{00E2}', '\u{00E4}', '\u{00E3}', '\u{00E5}', '\u{00E7}',
1171            '\u{00E9}', '\u{00E8}', '\u{00EA}', '\u{00EB}', '\u{00ED}', '\u{00EC}', '\u{00EE}',
1172            '\u{00EF}', '\u{00F1}', '\u{00F3}', '\u{00F2}', '\u{00F4}', '\u{00F6}', '\u{00F5}',
1173            '\u{00FA}', '\u{00F9}', '\u{00FB}', '\u{00FC}', '\u{2020}', '\u{00B0}', '\u{00A2}',
1174            '\u{00A3}', '\u{00A7}', '\u{2022}', '\u{00B6}', '\u{00DF}', '\u{00AE}', '\u{00A9}',
1175            '\u{2122}', '\u{00B4}', '\u{00A8}', '\u{2260}', '\u{00C6}', '\u{00D8}', '\u{221E}',
1176            '\u{00B1}', '\u{2264}', '\u{2265}', '\u{00A5}', '\u{00B5}', '\u{2202}', '\u{2211}',
1177            '\u{220F}', '\u{03C0}', '\u{222B}', '\u{00AA}', '\u{00BA}', '\u{03A9}', '\u{00E6}',
1178            '\u{00F8}', '\u{00BF}', '\u{00A1}', '\u{00AC}', '\u{221A}', '\u{0192}', '\u{2248}',
1179            '\u{2206}', '\u{00AB}', '\u{00BB}', '\u{2026}', '\u{00A0}', '\u{00C0}', '\u{00C3}',
1180            '\u{00D5}', '\u{0152}', '\u{0153}', '\u{2013}', '\u{2014}', '\u{201C}', '\u{201D}',
1181            '\u{2018}', '\u{2019}', '\u{00F7}', '\u{25CA}', '\u{00FF}', '\u{0178}', '\u{2044}',
1182            '\u{20AC}', '\u{2039}', '\u{203A}', '\u{FB01}', '\u{FB02}', '\u{2021}', '\u{00B7}',
1183            '\u{201A}', '\u{201E}', '\u{2030}', '\u{00C2}', '\u{00CA}', '\u{00C1}', '\u{00CB}',
1184            '\u{00C8}', '\u{00CD}', '\u{00CE}', '\u{00CF}', '\u{00CC}', '\u{00D3}', '\u{00D4}',
1185            '\u{F8FF}', '\u{00D2}', '\u{00DA}', '\u{00DB}', '\u{00D9}', '\u{0131}', '\u{02C6}',
1186            '\u{02DC}', '\u{00AF}', '\u{02D8}', '\u{02D9}', '\u{02DA}', '\u{00B8}', '\u{02DD}',
1187            '\u{02DB}', '\u{02C7}',
1188        ];
1189        for (i, &ch) in mac_upper.iter().enumerate() {
1190            t[0x80 + i] = ch;
1191        }
1192        t
1193    })
1194}
1195
1196/// Map an Adobe Glyph List (AGL) name to a Unicode character.
1197///
1198/// Handles:
1199/// - `uniXXXX` names (4+ hex digits after "uni")
1200/// - `uXXXX` / `uXXXXX` names
1201/// - Common AGL names (top ~250 entries covering >99% of real-world PDFs)
1202///
1203/// The `ct` ligature has no precomposed Unicode codepoint and is handled
1204/// separately via the `ct_codes` side-table on `FontInfo` — see
1205/// [`apply_glyph_name`] — rather than through any PUA sentinel here.
1206fn glyph_name_to_unicode(name: &str) -> Option<char> {
1207    // Handle uniXXXX / uXXXXX patterns.
1208    if name.starts_with("uni") && name.len() >= 7 {
1209        return u32::from_str_radix(&name[3..7], 16)
1210            .ok()
1211            .and_then(char::from_u32);
1212    }
1213    if name.starts_with('u') && name.len() >= 5 && name[1..].chars().all(|c| c.is_ascii_hexdigit())
1214    {
1215        return u32::from_str_radix(&name[1..], 16)
1216            .ok()
1217            .and_then(char::from_u32);
1218    }
1219
1220    if let Some(c) = agl_table().get(name).copied() {
1221        return Some(c);
1222    }
1223
1224    match name {
1225        "st" => Some('\u{FB06}'),
1226        "longst" => Some('\u{FB05}'),
1227        _ => None,
1228    }
1229}
1230
1231/// Built-in Adobe Glyph List table (most common ~250 entries).
1232fn agl_table() -> &'static HashMap<&'static str, char> {
1233    static TABLE: OnceLock<HashMap<&'static str, char>> = OnceLock::new();
1234    TABLE.get_or_init(|| {
1235        let entries: &[(&str, char)] = &[
1236            ("space", ' '),
1237            ("exclam", '!'),
1238            ("quotedbl", '"'),
1239            ("numbersign", '#'),
1240            ("dollar", '$'),
1241            ("percent", '%'),
1242            ("ampersand", '&'),
1243            ("quotesingle", '\''),
1244            ("parenleft", '('),
1245            ("parenright", ')'),
1246            ("asterisk", '*'),
1247            ("plus", '+'),
1248            ("comma", ','),
1249            ("hyphen", '-'),
1250            ("period", '.'),
1251            ("slash", '/'),
1252            ("zero", '0'),
1253            ("one", '1'),
1254            ("two", '2'),
1255            ("three", '3'),
1256            ("four", '4'),
1257            ("five", '5'),
1258            ("six", '6'),
1259            ("seven", '7'),
1260            ("eight", '8'),
1261            ("nine", '9'),
1262            ("colon", ':'),
1263            ("semicolon", ';'),
1264            ("less", '<'),
1265            ("equal", '='),
1266            ("greater", '>'),
1267            ("question", '?'),
1268            ("at", '@'),
1269            ("A", 'A'),
1270            ("B", 'B'),
1271            ("C", 'C'),
1272            ("D", 'D'),
1273            ("E", 'E'),
1274            ("F", 'F'),
1275            ("G", 'G'),
1276            ("H", 'H'),
1277            ("I", 'I'),
1278            ("J", 'J'),
1279            ("K", 'K'),
1280            ("L", 'L'),
1281            ("M", 'M'),
1282            ("N", 'N'),
1283            ("O", 'O'),
1284            ("P", 'P'),
1285            ("Q", 'Q'),
1286            ("R", 'R'),
1287            ("S", 'S'),
1288            ("T", 'T'),
1289            ("U", 'U'),
1290            ("V", 'V'),
1291            ("W", 'W'),
1292            ("X", 'X'),
1293            ("Y", 'Y'),
1294            ("Z", 'Z'),
1295            ("bracketleft", '['),
1296            ("backslash", '\\'),
1297            ("bracketright", ']'),
1298            ("asciicircum", '^'),
1299            ("underscore", '_'),
1300            ("grave", '`'),
1301            ("a", 'a'),
1302            ("b", 'b'),
1303            ("c", 'c'),
1304            ("d", 'd'),
1305            ("e", 'e'),
1306            ("f", 'f'),
1307            ("g", 'g'),
1308            ("h", 'h'),
1309            ("i", 'i'),
1310            ("j", 'j'),
1311            ("k", 'k'),
1312            ("l", 'l'),
1313            ("m", 'm'),
1314            ("n", 'n'),
1315            ("o", 'o'),
1316            ("p", 'p'),
1317            ("q", 'q'),
1318            ("r", 'r'),
1319            ("s", 's'),
1320            ("t", 't'),
1321            ("u", 'u'),
1322            ("v", 'v'),
1323            ("w", 'w'),
1324            ("x", 'x'),
1325            ("y", 'y'),
1326            ("z", 'z'),
1327            ("braceleft", '{'),
1328            ("bar", '|'),
1329            ("braceright", '}'),
1330            ("asciitilde", '~'),
1331            // Latin extended
1332            ("Agrave", '\u{00C0}'),
1333            ("Aacute", '\u{00C1}'),
1334            ("Acircumflex", '\u{00C2}'),
1335            ("Atilde", '\u{00C3}'),
1336            ("Adieresis", '\u{00C4}'),
1337            ("Aring", '\u{00C5}'),
1338            ("AE", '\u{00C6}'),
1339            ("Ccedilla", '\u{00C7}'),
1340            ("Egrave", '\u{00C8}'),
1341            ("Eacute", '\u{00C9}'),
1342            ("Ecircumflex", '\u{00CA}'),
1343            ("Edieresis", '\u{00CB}'),
1344            ("Igrave", '\u{00CC}'),
1345            ("Iacute", '\u{00CD}'),
1346            ("Icircumflex", '\u{00CE}'),
1347            ("Idieresis", '\u{00CF}'),
1348            ("Eth", '\u{00D0}'),
1349            ("Ntilde", '\u{00D1}'),
1350            ("Ograve", '\u{00D2}'),
1351            ("Oacute", '\u{00D3}'),
1352            ("Ocircumflex", '\u{00D4}'),
1353            ("Otilde", '\u{00D5}'),
1354            ("Odieresis", '\u{00D6}'),
1355            ("Ugrave", '\u{00D9}'),
1356            ("Uacute", '\u{00DA}'),
1357            ("Ucircumflex", '\u{00DB}'),
1358            ("Udieresis", '\u{00DC}'),
1359            ("Yacute", '\u{00DD}'),
1360            ("Thorn", '\u{00DE}'),
1361            ("germandbls", '\u{00DF}'),
1362            ("agrave", '\u{00E0}'),
1363            ("aacute", '\u{00E1}'),
1364            ("acircumflex", '\u{00E2}'),
1365            ("atilde", '\u{00E3}'),
1366            ("adieresis", '\u{00E4}'),
1367            ("aring", '\u{00E5}'),
1368            ("ae", '\u{00E6}'),
1369            ("ccedilla", '\u{00E7}'),
1370            ("egrave", '\u{00E8}'),
1371            ("eacute", '\u{00E9}'),
1372            ("ecircumflex", '\u{00EA}'),
1373            ("edieresis", '\u{00EB}'),
1374            ("igrave", '\u{00EC}'),
1375            ("iacute", '\u{00ED}'),
1376            ("icircumflex", '\u{00EE}'),
1377            ("idieresis", '\u{00EF}'),
1378            ("eth", '\u{00F0}'),
1379            ("ntilde", '\u{00F1}'),
1380            ("ograve", '\u{00F2}'),
1381            ("oacute", '\u{00F3}'),
1382            ("ocircumflex", '\u{00F4}'),
1383            ("otilde", '\u{00F5}'),
1384            ("odieresis", '\u{00F6}'),
1385            ("ugrave", '\u{00F9}'),
1386            ("uacute", '\u{00FA}'),
1387            ("ucircumflex", '\u{00FB}'),
1388            ("udieresis", '\u{00FC}'),
1389            ("yacute", '\u{00FD}'),
1390            ("thorn", '\u{00FE}'),
1391            ("ydieresis", '\u{00FF}'),
1392            // Ligatures and special
1393            ("fi", '\u{FB01}'),
1394            ("fl", '\u{FB02}'),
1395            ("ff", '\u{FB00}'),
1396            ("ffi", '\u{FB03}'),
1397            ("ffl", '\u{FB04}'),
1398            // Punctuation and symbols
1399            ("endash", '\u{2013}'),
1400            ("emdash", '\u{2014}'),
1401            ("bullet", '\u{2022}'),
1402            ("ellipsis", '\u{2026}'),
1403            ("quoteleft", '\u{2018}'),
1404            ("quoteright", '\u{2019}'),
1405            ("quotedblleft", '\u{201C}'),
1406            ("quotedblright", '\u{201D}'),
1407            ("quotesinglebase", '\u{201A}'),
1408            ("quotesinglbase", '\u{201A}'),
1409            ("quotedblbase", '\u{201E}'),
1410            ("dagger", '\u{2020}'),
1411            ("daggerdbl", '\u{2021}'),
1412            ("perthousand", '\u{2030}'),
1413            ("guilsinglleft", '\u{2039}'),
1414            ("guilsinglright", '\u{203A}'),
1415            ("guillemotleft", '\u{00AB}'),
1416            ("guillemotright", '\u{00BB}'),
1417            ("trademark", '\u{2122}'),
1418            ("copyright", '\u{00A9}'),
1419            ("registered", '\u{00AE}'),
1420            ("degree", '\u{00B0}'),
1421            ("plusminus", '\u{00B1}'),
1422            ("multiply", '\u{00D7}'),
1423            ("divide", '\u{00F7}'),
1424            ("fraction", '\u{2044}'),
1425            ("Euro", '\u{20AC}'),
1426            ("sterling", '\u{00A3}'),
1427            ("yen", '\u{00A5}'),
1428            ("cent", '\u{00A2}'),
1429            ("currency", '\u{00A4}'),
1430            ("section", '\u{00A7}'),
1431            ("paragraph", '\u{00B6}'),
1432            ("brokenbar", '\u{00A6}'),
1433            ("ordfeminine", '\u{00AA}'),
1434            ("ordmasculine", '\u{00BA}'),
1435            ("exclamdown", '\u{00A1}'),
1436            ("questiondown", '\u{00BF}'),
1437            ("logicalnot", '\u{00AC}'),
1438            ("mu", '\u{00B5}'),
1439            ("macron", '\u{00AF}'),
1440            ("acute", '\u{00B4}'),
1441            ("cedilla", '\u{00B8}'),
1442            ("dieresis", '\u{00A8}'),
1443            ("circumflex", '\u{02C6}'),
1444            ("tilde", '\u{02DC}'),
1445            ("caron", '\u{02C7}'),
1446            ("ring", '\u{02DA}'),
1447            ("breve", '\u{02D8}'),
1448            ("dotaccent", '\u{02D9}'),
1449            ("hungarumlaut", '\u{02DD}'),
1450            ("ogonek", '\u{02DB}'),
1451            ("nbspace", '\u{00A0}'),
1452            ("nonbreakingspace", '\u{00A0}'),
1453            ("softhyphen", '\u{00AD}'),
1454            ("periodcentered", '\u{00B7}'),
1455            ("middot", '\u{00B7}'),
1456            ("florin", '\u{0192}'),
1457            ("OE", '\u{0152}'),
1458            ("oe", '\u{0153}'),
1459            ("Scaron", '\u{0160}'),
1460            ("scaron", '\u{0161}'),
1461            ("Zcaron", '\u{017D}'),
1462            ("zcaron", '\u{017E}'),
1463            ("Ydieresis", '\u{0178}'),
1464            ("Lslash", '\u{0141}'),
1465            ("lslash", '\u{0142}'),
1466            ("Oslash", '\u{00D8}'),
1467            ("oslash", '\u{00F8}'),
1468            ("dotlessi", '\u{0131}'),
1469            // Superscripts / subscripts
1470            ("onesuperior", '\u{00B9}'),
1471            ("twosuperior", '\u{00B2}'),
1472            ("threesuperior", '\u{00B3}'),
1473            ("onequarter", '\u{00BC}'),
1474            ("onehalf", '\u{00BD}'),
1475            ("threequarters", '\u{00BE}'),
1476            // Math
1477            ("minus", '\u{2212}'),
1478            ("notequal", '\u{2260}'),
1479            ("lessequal", '\u{2264}'),
1480            ("greaterequal", '\u{2265}'),
1481            ("infinity", '\u{221E}'),
1482            ("partialdiff", '\u{2202}'),
1483            ("summation", '\u{2211}'),
1484            ("product", '\u{220F}'),
1485            ("integral", '\u{222B}'),
1486            ("radical", '\u{221A}'),
1487            ("approxequal", '\u{2248}'),
1488            ("Delta", '\u{0394}'),
1489            ("lozenge", '\u{25CA}'),
1490            ("pi", '\u{03C0}'),
1491            ("Omega", '\u{03A9}'),
1492        ];
1493        entries.iter().cloned().collect()
1494    })
1495}
1496
1497const CT_LIGATURE_MARKER: char = '\u{E007}';
1498
1499#[derive(Clone, Default)]
1500struct DecodedPdfString {
1501    text: String,
1502    ct_origins: Vec<bool>,
1503}
1504
1505impl DecodedPdfString {
1506    fn from_text(text: String) -> Self {
1507        let ct_origins = vec![false; text.chars().count()];
1508        Self { text, ct_origins }
1509    }
1510
1511    fn push_char(&mut self, ch: char, is_ct_origin: bool) {
1512        self.text.push(ch);
1513        self.ct_origins.push(is_ct_origin);
1514    }
1515
1516    fn push_str(&mut self, s: &str) {
1517        for ch in s.chars() {
1518            self.push_char(ch, false);
1519        }
1520    }
1521
1522    fn extend(&mut self, other: DecodedPdfString) {
1523        self.text.push_str(&other.text);
1524        self.ct_origins.extend(other.ct_origins);
1525    }
1526
1527    fn is_empty(&self) -> bool {
1528        self.text.is_empty()
1529    }
1530
1531    fn glyph_count(&self) -> usize {
1532        self.ct_origins.len()
1533    }
1534
1535    fn iter(&self) -> impl Iterator<Item = (char, bool)> + '_ {
1536        self.text.chars().zip(self.ct_origins.iter().copied())
1537    }
1538}
1539
1540/// Decode a PDF string using the font's ToUnicode CMap (if available),
1541/// preserving origin metadata for internal `ct` ligature markers.
1542fn decode_pdf_string_with_font_marked(
1543    bytes: &[u8],
1544    font_info: Option<&FontInfo>,
1545) -> DecodedPdfString {
1546    // Check for UTF-16BE BOM first — always takes priority.
1547    if bytes.len() >= 2 && bytes[0] == 0xFE && bytes[1] == 0xFF {
1548        let chars: Vec<u16> = bytes[2..]
1549            .chunks(2)
1550            .filter_map(|chunk| {
1551                if chunk.len() == 2 {
1552                    Some(u16::from_be_bytes([chunk[0], chunk[1]]))
1553                } else {
1554                    None
1555                }
1556            })
1557            .collect();
1558        return DecodedPdfString::from_text(String::from_utf16_lossy(&chars));
1559    }
1560
1561    if let Some(info) = font_info {
1562        if info.is_cid && !info.to_unicode.is_empty() {
1563            // CID font: decode 2-byte codes via ToUnicode.
1564            let mut result = DecodedPdfString::default();
1565            let mut i = 0;
1566            while i + 1 < bytes.len() {
1567                let code = u16::from_be_bytes([bytes[i], bytes[i + 1]]) as u32;
1568                if let Some(s) = info.to_unicode.get(&code) {
1569                    result.push_str(s);
1570                } else {
1571                    // Fallback: try direct Unicode interpretation.
1572                    if let Some(ch) = char::from_u32(code) {
1573                        if !ch.is_control() || ch == ' ' || ch == '\t' || ch == '\n' {
1574                            result.push_char(ch, false);
1575                        }
1576                    }
1577                }
1578                i += 2;
1579            }
1580            return result;
1581        }
1582
1583        if !info.is_cid && !info.to_unicode.is_empty() {
1584            // Simple font with ToUnicode: decode 1-byte codes.
1585            let mut result = DecodedPdfString::default();
1586            for &b in bytes {
1587                if let Some(s) = info.to_unicode.get(&(b as u32)) {
1588                    result.push_str(s);
1589                } else if info.ct_codes[b as usize] {
1590                    result.push_char(CT_LIGATURE_MARKER, true);
1591                } else if let Some(ch) = info.encoding_map[b as usize] {
1592                    result.push_char(ch, false);
1593                } else {
1594                    let ch = b as char;
1595                    if is_printable_or_space(ch) {
1596                        result.push_char(ch, false);
1597                    }
1598                }
1599            }
1600            return result;
1601        }
1602
1603        // Simple font with encoding map but no ToUnicode.
1604        if !info.is_cid
1605            && (info.encoding_map.iter().any(|c| c.is_some()) || info.ct_codes.iter().any(|f| *f))
1606        {
1607            let mut result = DecodedPdfString::default();
1608            for &b in bytes {
1609                if info.ct_codes[b as usize] {
1610                    result.push_char(CT_LIGATURE_MARKER, true);
1611                } else if let Some(ch) = info.encoding_map[b as usize] {
1612                    result.push_char(ch, false);
1613                } else {
1614                    let ch = b as char;
1615                    if is_printable_or_space(ch) {
1616                        result.push_char(ch, false);
1617                    }
1618                }
1619            }
1620            return result;
1621        }
1622    }
1623
1624    // Fallback: PDFDocEncoding (ASCII + Latin-1), skipping control chars.
1625    let mut result = DecodedPdfString::default();
1626    for &b in bytes {
1627        let ch = b as char;
1628        if is_printable_or_space(ch) {
1629            result.push_char(ch, false);
1630        }
1631    }
1632    result
1633}
1634
1635/// Returns true if a character is printable or common whitespace (tab, newline, CR, space).
1636/// Filters out NUL, BEL, and other control characters that corrupt XML output.
1637fn is_printable_or_space(ch: char) -> bool {
1638    let cp = ch as u32;
1639    cp >= 0x20 || cp == 0x09 || cp == 0x0A || cp == 0x0D
1640}
1641
1642/// Whether to expand precomposed-ligature characters and known ligature
1643/// glyph names into their constituent ASCII characters during text
1644/// extraction. Default-on so that text-search, copy-paste and accessibility
1645/// flows see the human-readable string ("office") rather than an opaque
1646/// glyph cluster ("o\u{FB03}ce"). Internal for now — the public surface
1647/// stays unchanged; future configuration would surface this on a higher-
1648/// level type without breaking the current API.
1649const LIGATURE_DECOMP: bool = true;
1650
1651/// Decompose a precomposed-ligature character (Alphabetic Presentation Forms,
1652/// U+FB00..U+FB06) into its constituent string. Returns `None` for any other
1653/// character — the caller keeps the original codepoint in that case.
1654///
1655/// The `ct` ligature uses an internal marker plus origin metadata because
1656/// it has no precomposed Unicode codepoint and must not collide with
1657/// legitimate PUA values from ToUnicode CMaps.
1658fn decompose_ligature_char(c: char) -> Option<&'static str> {
1659    Some(match c {
1660        '\u{FB00}' => "ff",
1661        '\u{FB01}' => "fi",
1662        '\u{FB02}' => "fl",
1663        '\u{FB03}' => "ffi",
1664        '\u{FB04}' => "ffl",
1665        // U+FB05 (long s + t) and U+FB06 (st) both decompose to "st" for
1666        // search/copy-paste purposes; the historical long-s spelling is
1667        // not preserved.
1668        '\u{FB05}' | '\u{FB06}' => "st",
1669        _ => return None,
1670    })
1671}
1672
1673fn decompose_ligature_char_with_origin(c: char, is_ct_origin: bool) -> Option<&'static str> {
1674    if is_ct_origin && c == CT_LIGATURE_MARKER {
1675        Some("ct")
1676    } else {
1677        decompose_ligature_char(c)
1678    }
1679}
1680
1681/// Decompose a single precomposed-ligature character into its constituent
1682/// string. Combines the hardcoded Latin set ([`decompose_ligature_char`])
1683/// with an NFKD fallback over the rest of the Alphabetic Presentation
1684/// Forms block (U+FB00..U+FB4F) — Hebrew, Arabic, Armenian forms — so
1685/// that string-level [`decompose_ligatures`] and the per-glyph
1686/// `push_glyph_positioned` path agree on what counts as a decomposable
1687/// glyph.
1688fn decompose_glyph_to_string(c: char) -> Option<String> {
1689    use unicode_normalization::UnicodeNormalization;
1690    if let Some(s) = decompose_ligature_char(c) {
1691        return Some(s.to_string());
1692    }
1693    if matches!(c, '\u{FB00}'..='\u{FB4F}') {
1694        let nfkd: String = c.nfkd().collect();
1695        // Some codepoints in this block have no compatibility decomposition
1696        // and would round-trip back as themselves; only treat them as
1697        // decomposable when NFKD actually maps them, including legitimate
1698        // single-codepoint mappings such as Hebrew presentation forms.
1699        if nfkd != c.to_string() {
1700            return Some(nfkd);
1701        }
1702    }
1703    None
1704}
1705
1706/// Apply ligature decomposition to a string. Hardcoded mappings cover the
1707/// six common Latin ligatures (fi, fl, ffi, ffl, ff, st). For any other
1708/// character in the Alphabetic Presentation Forms block (U+FB00..U+FB4F)
1709/// not covered above — Hebrew, Arabic, Armenian forms — apply NFKD locally
1710/// to that character only. NFKD is *not* applied globally to avoid breaking
1711/// legitimate composed accented characters elsewhere in the string.
1712fn decompose_ligatures(s: &str) -> String {
1713    let mut out = String::with_capacity(s.len());
1714    for c in s.chars() {
1715        if let Some(replacement) = decompose_glyph_to_string(c) {
1716            out.push_str(&replacement);
1717        } else {
1718            out.push(c);
1719        }
1720    }
1721    out
1722}
1723
1724fn decompose_decoded_ligatures(s: &DecodedPdfString) -> String {
1725    let mut out = String::with_capacity(s.text.len());
1726    for (c, is_ct_origin) in s.iter() {
1727        if let Some(replacement) = decompose_ligature_char_with_origin(c, is_ct_origin) {
1728            out.push_str(replacement);
1729        } else if let Some(replacement) = decompose_glyph_to_string(c) {
1730            out.push_str(&replacement);
1731        } else {
1732            out.push(c);
1733        }
1734    }
1735    out
1736}
1737
1738/// Apply [`decompose_ligatures`] when the feature is enabled; otherwise
1739/// return the input unchanged. Centralizes the gate so callers don't
1740/// repeat the `if LIGATURE_DECOMP` check at every emission site.
1741fn maybe_decompose_decoded(s: &DecodedPdfString) -> String {
1742    if LIGATURE_DECOMP {
1743        if s.ct_origins.iter().any(|origin| *origin) {
1744            decompose_decoded_ligatures(s)
1745        } else {
1746            decompose_ligatures(&s.text)
1747        }
1748    } else {
1749        s.text.clone()
1750    }
1751}
1752
1753/// Extract text blocks from a specific page.
1754pub fn extract_page_blocks(doc: &Document, page_num: u32) -> Vec<TextBlock> {
1755    let pages = doc.get_pages();
1756    let Some(&page_id) = pages.get(&page_num) else {
1757        return Vec::new();
1758    };
1759
1760    let font_map = build_font_map(doc, page_id);
1761    let resources = get_page_resources(doc, page_id).unwrap_or_default();
1762    if let Ok(content_bytes) = get_page_content_bytes(doc, page_id) {
1763        if let Ok(content) = Content::decode(&content_bytes) {
1764            return extract_blocks_from_ops_inner(
1765                &content.operations,
1766                page_num,
1767                &font_map,
1768                Some((doc, &resources)),
1769                0,
1770                None,
1771            );
1772        }
1773    }
1774
1775    Vec::new()
1776}
1777
1778/// Extract text blocks from a page identified by its object ID.
1779///
1780/// Unlike [`extract_page_blocks`], this function does not call `get_pages()`
1781/// internally. Use this when the caller already holds a page map from a
1782/// prior `doc.get_pages()` call to avoid redundant page-tree traversals.
1783pub fn extract_blocks_from_page_id(
1784    doc: &Document,
1785    page_id: lopdf::ObjectId,
1786    page_num: u32,
1787) -> Vec<TextBlock> {
1788    let font_map = build_font_map(doc, page_id);
1789    let resources = get_page_resources(doc, page_id).unwrap_or_default();
1790    if let Ok(content_bytes) = get_page_content_bytes(doc, page_id) {
1791        if let Ok(content) = Content::decode(&content_bytes) {
1792            return extract_blocks_from_ops_inner(
1793                &content.operations,
1794                page_num,
1795                &font_map,
1796                Some((doc, &resources)),
1797                0,
1798                None,
1799            );
1800        }
1801    }
1802    Vec::new()
1803}
1804
1805/// Extract text blocks from all pages of a document.
1806pub fn extract_text(doc: &Document) -> Vec<TextBlock> {
1807    let pages = doc.get_pages();
1808    let mut blocks = Vec::new();
1809
1810    for (&page_num, &page_id) in &pages {
1811        let font_map = build_font_map(doc, page_id);
1812        let resources = get_page_resources(doc, page_id).unwrap_or_default();
1813        if let Ok(content_bytes) = get_page_content_bytes(doc, page_id) {
1814            if let Ok(content) = Content::decode(&content_bytes) {
1815                let page_blocks = extract_blocks_from_ops_inner(
1816                    &content.operations,
1817                    page_num,
1818                    &font_map,
1819                    Some((doc, &resources)),
1820                    0,
1821                    None,
1822                );
1823                blocks.extend(page_blocks);
1824            }
1825        }
1826    }
1827
1828    blocks
1829}
1830
1831/// Extract text from a specific page as a plain string.
1832pub fn extract_page_text(doc: &Document, page_num: u32) -> Result<String> {
1833    let pages = doc.get_pages();
1834    let total = pages.len() as u32;
1835
1836    if page_num == 0 || page_num > total {
1837        return Err(ExtractError::PageOutOfRange(page_num, total));
1838    }
1839
1840    let page_id = *pages
1841        .get(&page_num)
1842        .ok_or(ExtractError::PageOutOfRange(page_num, total))?;
1843
1844    let font_map = build_font_map(doc, page_id);
1845    let resources = get_page_resources(doc, page_id).unwrap_or_default();
1846    let content_bytes = get_page_content_bytes(doc, page_id).unwrap_or_default();
1847    let content = match Content::decode(&content_bytes) {
1848        Ok(c) => c,
1849        Err(_) => return Ok(String::new()),
1850    };
1851
1852    let blocks = extract_blocks_from_ops_inner(
1853        &content.operations,
1854        page_num,
1855        &font_map,
1856        Some((doc, &resources)),
1857        0,
1858        None,
1859    );
1860    let text = blocks
1861        .iter()
1862        .map(|b| b.text.as_str())
1863        .collect::<Vec<_>>()
1864        .join("");
1865
1866    Ok(text)
1867}
1868
1869/// Extract positioned characters from a specific page.
1870pub fn extract_positioned_chars(doc: &Document, page_num: u32) -> Result<Vec<PositionedChar>> {
1871    let pages = doc.get_pages();
1872    let total = pages.len() as u32;
1873
1874    if page_num == 0 || page_num > total {
1875        return Err(ExtractError::PageOutOfRange(page_num, total));
1876    }
1877
1878    let page_id = *pages
1879        .get(&page_num)
1880        .ok_or(ExtractError::PageOutOfRange(page_num, total))?;
1881
1882    let font_map = build_font_map(doc, page_id);
1883    let content_bytes = get_page_content_bytes(doc, page_id).unwrap_or_default();
1884    let content = match Content::decode(&content_bytes) {
1885        Ok(c) => c,
1886        Err(_) => return Ok(Vec::new()),
1887    };
1888
1889    let chars = extract_chars_from_ops(&content.operations, page_num, &font_map);
1890    Ok(chars)
1891}
1892
1893/// Get content stream bytes for a page.
1894fn get_page_content_bytes(doc: &Document, page_id: ObjectId) -> std::result::Result<Vec<u8>, ()> {
1895    Ok(doc.get_page_content(page_id))
1896}
1897
1898/// One entry on the marked-content stack. PDF marked-content sequences nest
1899/// (e.g. `BDC … BDC … EMC … EMC`); each `BDC`/`BMC` pushes, `EMC` pops. The
1900/// innermost entry with `actual_text = Some(_)` wins for any glyph emitted
1901/// inside that range.
1902#[derive(Debug, Clone, Default)]
1903struct MarkedContentEntry {
1904    /// `/ActualText` value from the property dict, decoded as a PDF text
1905    /// string (UTF-16BE if BOM-prefixed, otherwise PDFDocEncoding/Latin-1).
1906    actual_text: Option<String>,
1907}
1908
1909/// Resolve the property dict for a `BDC` operator. The second operand is
1910/// either an inline dictionary or a `/Name` referring to an entry in
1911/// `Resources/Properties`.
1912fn resolve_bdc_properties(
1913    doc_and_resources: Option<(&Document, &lopdf::Dictionary)>,
1914    operand: &Object,
1915) -> Option<lopdf::Dictionary> {
1916    match operand {
1917        Object::Dictionary(d) => Some(d.clone()),
1918        Object::Reference(r) => doc_and_resources.and_then(|(doc, _)| {
1919            doc.get_object(*r).ok().and_then(|o| match o {
1920                Object::Dictionary(d) => Some(d.clone()),
1921                _ => None,
1922            })
1923        }),
1924        Object::Name(n) => {
1925            let (doc, resources) = doc_and_resources?;
1926            let props = resolve_dict(doc, resources, b"Properties")?;
1927            match props.get(n.as_slice()).ok()? {
1928                Object::Dictionary(d) => Some(d.clone()),
1929                Object::Reference(r) => match doc.get_object(*r).ok()? {
1930                    Object::Dictionary(d) => Some(d.clone()),
1931                    _ => None,
1932                },
1933                _ => None,
1934            }
1935        }
1936        _ => None,
1937    }
1938}
1939
1940/// Decode a PDF text string (BOM-detected UTF-16BE / UTF-8, otherwise
1941/// PDFDocEncoding-ish Latin-1). Used for the `/ActualText` value, which is
1942/// stored as a regular text string — not as a glyph-coded string.
1943fn decode_pdf_text_string(bytes: &[u8]) -> String {
1944    if bytes.len() >= 2 && bytes[0] == 0xFE && bytes[1] == 0xFF {
1945        let u16s: Vec<u16> = bytes[2..]
1946            .chunks_exact(2)
1947            .map(|c| u16::from_be_bytes([c[0], c[1]]))
1948            .collect();
1949        return String::from_utf16_lossy(&u16s);
1950    }
1951    if bytes.len() >= 3 && bytes[0] == 0xEF && bytes[1] == 0xBB && bytes[2] == 0xBF {
1952        return String::from_utf8_lossy(&bytes[3..]).into_owned();
1953    }
1954    bytes
1955        .iter()
1956        .filter_map(|&b| {
1957            let ch = b as char;
1958            if is_printable_or_space(ch) {
1959                Some(ch)
1960            } else {
1961                None
1962            }
1963        })
1964        .collect()
1965}
1966
1967/// Extract `/ActualText` from a BDC property dict, if present.
1968fn extract_actual_text(props: &lopdf::Dictionary) -> Option<String> {
1969    let obj = props.get(b"ActualText").ok()?;
1970    match obj {
1971        Object::String(bytes, _) => Some(decode_pdf_text_string(bytes)),
1972        _ => None,
1973    }
1974}
1975
1976/// Innermost `actual_text` from the stack, falling back to an inherited
1977/// value supplied by the caller. The inherited value carries the parent
1978/// invocation's top-of-stack `/ActualText` across `Do` recursion into a
1979/// Form XObject, so text emitted inside the XObject's `BT`...`ET` blocks
1980/// is tagged with the surrounding `BDC`/`EMC` pair's `/ActualText`.
1981fn current_actual_text(stack: &[MarkedContentEntry], inherited: Option<&str>) -> Option<String> {
1982    stack
1983        .iter()
1984        .rev()
1985        .find_map(|e| e.actual_text.clone())
1986        .or_else(|| inherited.map(|s| s.to_string()))
1987}
1988
1989/// Extract text blocks from a list of operations, handling Form XObject
1990/// recursion via `Do`. `inherited_actual_text` carries the parent's active
1991/// `/ActualText` binding into the recursion so a `BDC` ... `Do` ... `EMC`
1992/// sequence keeps tagging text emitted inside the invoked Form XObject.
1993/// Top-level callers pass `None`.
1994fn extract_blocks_from_ops_inner(
1995    ops: &[Operation],
1996    page: u32,
1997    font_map: &HashMap<String, FontInfo>,
1998    doc_and_resources: Option<(&Document, &lopdf::Dictionary)>,
1999    depth: u32,
2000    inherited_actual_text: Option<&str>,
2001) -> Vec<TextBlock> {
2002    let mut state = TextState::default();
2003    let mut blocks = Vec::new();
2004    let mut mc_stack: Vec<MarkedContentEntry> = Vec::new();
2005
2006    for op in ops {
2007        match op.operator.as_str() {
2008            "q" => {
2009                state.gs_stack.push(GraphicsState {
2010                    ctm: state.ctm,
2011                    fill_color: state.fill_color,
2012                });
2013            }
2014            "Q" => {
2015                if let Some(gs) = state.gs_stack.pop() {
2016                    state.ctm = gs.ctm;
2017                    state.fill_color = gs.fill_color;
2018                }
2019            }
2020            "cm" => {
2021                if let Some(m) = extract_matrix(&op.operands) {
2022                    state.ctm = concat_cm(&m, &state.ctm);
2023                }
2024            }
2025            // G1: non-stroking fill color setters. PDF §8.6.5: `rg` r g b;
2026            // `g` gray; `k` c m y k. Stroking variants (RG/G/K) don't
2027            // affect text fill so we accept and ignore them. `sc`/`scn` are
2028            // honored when operand count makes the device space unambiguous
2029            // (1 = Gray, 3 = RGB, 4 = CMYK). `cs`/`CS` mark color unknown
2030            // because ICC/Lab/Pattern can't be converted from operands alone.
2031            "rg" if op.operands.len() >= 3 => {
2032                if let (Some(r), Some(g), Some(b)) = (
2033                    op.operands.first().and_then(as_number),
2034                    op.operands.get(1).and_then(as_number),
2035                    op.operands.get(2).and_then(as_number),
2036                ) {
2037                    state.fill_color = Some([
2038                        clamp_unit_to_u8(r),
2039                        clamp_unit_to_u8(g),
2040                        clamp_unit_to_u8(b),
2041                        255,
2042                    ]);
2043                }
2044            }
2045            "g" => {
2046                if let Some(v) = op.operands.first().and_then(as_number) {
2047                    let byte = clamp_unit_to_u8(v);
2048                    state.fill_color = Some([byte, byte, byte, 255]);
2049                }
2050            }
2051            "k" if op.operands.len() >= 4 => {
2052                if let (Some(c), Some(m), Some(y), Some(kk)) = (
2053                    op.operands.first().and_then(as_number),
2054                    op.operands.get(1).and_then(as_number),
2055                    op.operands.get(2).and_then(as_number),
2056                    op.operands.get(3).and_then(as_number),
2057                ) {
2058                    state.fill_color = Some(cmyk_to_rgba(c, m, y, kk));
2059                }
2060            }
2061            "RG" | "G" | "K" => {}
2062            "sc" | "scn" => {
2063                state.fill_color = match op.operands.len() {
2064                    1 => op.operands.first().and_then(as_number).map(|v| {
2065                        let b = clamp_unit_to_u8(v);
2066                        [b, b, b, 255]
2067                    }),
2068                    3 => {
2069                        let r = op.operands.first().and_then(as_number);
2070                        let g = op.operands.get(1).and_then(as_number);
2071                        let b = op.operands.get(2).and_then(as_number);
2072                        match (r, g, b) {
2073                            (Some(r), Some(g), Some(b)) => Some([
2074                                clamp_unit_to_u8(r),
2075                                clamp_unit_to_u8(g),
2076                                clamp_unit_to_u8(b),
2077                                255,
2078                            ]),
2079                            _ => None,
2080                        }
2081                    }
2082                    4 => {
2083                        let c = op.operands.first().and_then(as_number);
2084                        let m = op.operands.get(1).and_then(as_number);
2085                        let y = op.operands.get(2).and_then(as_number);
2086                        let kk = op.operands.get(3).and_then(as_number);
2087                        match (c, m, y, kk) {
2088                            (Some(c), Some(m), Some(y), Some(kk)) => {
2089                                Some(cmyk_to_rgba(c, m, y, kk))
2090                            }
2091                            _ => None,
2092                        }
2093                    }
2094                    _ => None,
2095                };
2096            }
2097            "cs" | "CS" => {
2098                state.fill_color = None;
2099            }
2100            "BT" => {
2101                state.tm = [1.0, 0.0, 0.0, 1.0, 0.0, 0.0];
2102                state.tlm = [1.0, 0.0, 0.0, 1.0, 0.0, 0.0];
2103            }
2104            "Tf" if op.operands.len() >= 2 => {
2105                if let Object::Name(ref name) = op.operands[0] {
2106                    state.font_name = String::from_utf8_lossy(name).to_string();
2107                }
2108                if let Some(size) = as_number(&op.operands[1]) {
2109                    state.font_size = size;
2110                }
2111            }
2112            "Tc" => {
2113                if let Some(v) = op.operands.first().and_then(as_number) {
2114                    state.tc = v;
2115                }
2116            }
2117            "Tw" => {
2118                if let Some(v) = op.operands.first().and_then(as_number) {
2119                    state.tw = v;
2120                }
2121            }
2122            "Tz" => {
2123                if let Some(v) = op.operands.first().and_then(as_number) {
2124                    state.th = v;
2125                }
2126            }
2127            "TL" => {
2128                if let Some(v) = op.operands.first().and_then(as_number) {
2129                    state.tl = v;
2130                }
2131            }
2132            "Ts" => {
2133                if let Some(v) = op.operands.first().and_then(as_number) {
2134                    state.ts = v;
2135                }
2136            }
2137            "Td" if op.operands.len() >= 2 => {
2138                let tx = as_number(&op.operands[0]).unwrap_or(0.0);
2139                let ty = as_number(&op.operands[1]).unwrap_or(0.0);
2140                let new_tlm = offset_text_line(&state.tlm, tx, ty);
2141                state.tlm = new_tlm;
2142                state.tm = new_tlm;
2143            }
2144            "TD" if op.operands.len() >= 2 => {
2145                let tx = as_number(&op.operands[0]).unwrap_or(0.0);
2146                let ty = as_number(&op.operands[1]).unwrap_or(0.0);
2147                state.tl = -ty;
2148                let new_tlm = offset_text_line(&state.tlm, tx, ty);
2149                state.tlm = new_tlm;
2150                state.tm = new_tlm;
2151            }
2152            "Tm" => {
2153                if let Some(m) = extract_matrix(&op.operands) {
2154                    state.tm = m;
2155                    state.tlm = m;
2156                }
2157            }
2158            "T*" => {
2159                let new_tlm = offset_text_line(&state.tlm, 0.0, -state.tl);
2160                state.tlm = new_tlm;
2161                state.tm = new_tlm;
2162            }
2163            "Tj" => {
2164                let fi = font_map.get(&state.font_name);
2165                if let Some(text) = extract_decoded_string_operand_with_font(&op.operands, fi) {
2166                    let raw = op.operands.iter().find_map(|o| {
2167                        if let Object::String(b, _) = o {
2168                            Some(b.as_slice())
2169                        } else {
2170                            None
2171                        }
2172                    });
2173
2174                    // G2: compute char_bounds + width_source from raw bytes.
2175                    let x = state.tm[4];
2176                    let y = state.tm[5];
2177                    let gctx = GlyphCtx {
2178                        font_size: state.font_size,
2179                        tc: state.tc,
2180                        tw: state.tw,
2181                        th: state.th,
2182                        hscale: hscale(&state.tm),
2183                        vscale: vscale(&state.tm),
2184                    };
2185                    let (char_bounds, width_source, total_adv) = match (raw, fi) {
2186                        (Some(raw), Some(fi)) => {
2187                            let (cb, ws, adv) = char_bounds_from_bytes(raw, fi, x, y, &gctx);
2188                            (cb, ws, adv)
2189                        }
2190                        _ => {
2191                            let cw = state.font_size
2192                                * hscale(&state.tm)
2193                                * APPROX_CHAR_WIDTH
2194                                * (state.th / 100.0);
2195                            let n = text.glyph_count();
2196                            let cb = (0..n)
2197                                .map(|i| {
2198                                    let cx = x + i as f64 * cw;
2199                                    [cx, y, cx + cw, y + state.font_size * vscale(&state.tm)]
2200                                })
2201                                .collect();
2202                            (cb, WidthSource::Estimate, n as f64 * cw)
2203                        }
2204                    };
2205
2206                    // Skip empty text blocks — empty blocks cause phantom table
2207                    // detection with all-empty cells (#651).
2208                    if !text.is_empty() {
2209                        let display_text = maybe_decompose_decoded(&text);
2210                        blocks.push(TextBlock {
2211                            text: display_text,
2212                            page,
2213                            bbox: [x, y, x + total_adv, y + state.font_size * vscale(&state.tm)],
2214                            font_name: state.font_name.clone(),
2215                            font_size: state.font_size,
2216                            actual_text: current_actual_text(&mc_stack, inherited_actual_text),
2217                            base_font: font_map
2218                                .get(&state.font_name)
2219                                .and_then(|fi| fi.base_font.clone()),
2220                            is_bold: font_map.get(&state.font_name).is_some_and(|fi| fi.is_bold),
2221                            is_italic: font_map
2222                                .get(&state.font_name)
2223                                .is_some_and(|fi| fi.is_italic),
2224                            color: state.fill_color,
2225                            width_source,
2226                            char_bounds,
2227                        });
2228                    }
2229
2230                    state.tm[4] = x + total_adv;
2231                }
2232            }
2233            "TJ" => {
2234                if let Some(Object::Array(ref arr)) = op.operands.first() {
2235                    let x_start = state.tm[4];
2236                    let y = state.tm[5];
2237                    let fi = font_map.get(&state.font_name);
2238
2239                    // G2: compute char_bounds + width_source from the raw TJ array.
2240                    let gctx = GlyphCtx {
2241                        font_size: state.font_size,
2242                        tc: state.tc,
2243                        tw: state.tw,
2244                        th: state.th,
2245                        hscale: hscale(&state.tm),
2246                        vscale: vscale(&state.tm),
2247                    };
2248                    let (char_bounds, width_source, x_end) = match fi {
2249                        Some(fi) => char_bounds_from_tj_array(arr, fi, x_start, y, &gctx),
2250                        None => {
2251                            let cw = state.font_size
2252                                * hscale(&state.tm)
2253                                * APPROX_CHAR_WIDTH
2254                                * (state.th / 100.0);
2255                            // Estimate: count glyphs from string items only.
2256                            let mut cx = x_start;
2257                            let mut cb = Vec::new();
2258                            for item in arr {
2259                                if let Object::String(bytes, _) = item {
2260                                    let n = if fi.map(|f| f.is_cid).unwrap_or(false) {
2261                                        bytes.len() / 2
2262                                    } else {
2263                                        bytes.len()
2264                                    };
2265                                    for _ in 0..n {
2266                                        cb.push([
2267                                            cx,
2268                                            y,
2269                                            cx + cw,
2270                                            y + state.font_size * vscale(&state.tm),
2271                                        ]);
2272                                        cx += cw + state.tc;
2273                                    }
2274                                } else if let Some(adj) = as_number(item) {
2275                                    cx -= adj / 1000.0 * state.font_size;
2276                                }
2277                            }
2278                            (cb, WidthSource::Estimate, cx)
2279                        }
2280                    };
2281
2282                    // Decode combined text for the block.
2283                    let mut combined_text = DecodedPdfString::default();
2284                    for item in arr {
2285                        if let Object::String(bytes, _) = item {
2286                            combined_text.extend(decode_pdf_string_with_font_marked(bytes, fi));
2287                        }
2288                    }
2289
2290                    state.tm[4] = x_end;
2291
2292                    if !combined_text.is_empty() {
2293                        blocks.push(TextBlock {
2294                            text: maybe_decompose_decoded(&combined_text),
2295                            page,
2296                            bbox: [x_start, y, x_end, y + state.font_size * vscale(&state.tm)],
2297                            font_name: state.font_name.clone(),
2298                            font_size: state.font_size,
2299                            actual_text: current_actual_text(&mc_stack, inherited_actual_text),
2300                            base_font: font_map
2301                                .get(&state.font_name)
2302                                .and_then(|fi| fi.base_font.clone()),
2303                            is_bold: font_map.get(&state.font_name).is_some_and(|fi| fi.is_bold),
2304                            is_italic: font_map
2305                                .get(&state.font_name)
2306                                .is_some_and(|fi| fi.is_italic),
2307                            color: state.fill_color,
2308                            width_source,
2309                            char_bounds,
2310                        });
2311                    }
2312                }
2313            }
2314            "'" => {
2315                // Move to next line and show text.
2316                let new_tlm = offset_text_line(&state.tlm, 0.0, -state.tl);
2317                state.tlm = new_tlm;
2318                state.tm = new_tlm;
2319
2320                let fi = font_map.get(&state.font_name);
2321                if let Some(text) = extract_decoded_string_operand_with_font(&op.operands, fi) {
2322                    let raw = op.operands.iter().find_map(|o| {
2323                        if let Object::String(b, _) = o {
2324                            Some(b.as_slice())
2325                        } else {
2326                            None
2327                        }
2328                    });
2329                    let x = state.tm[4];
2330                    let y = state.tm[5];
2331                    let gctx = GlyphCtx {
2332                        font_size: state.font_size,
2333                        tc: state.tc,
2334                        tw: state.tw,
2335                        th: state.th,
2336                        hscale: hscale(&state.tm),
2337                        vscale: vscale(&state.tm),
2338                    };
2339                    let (char_bounds, width_source, total_adv) = match (raw, fi) {
2340                        (Some(raw), Some(fi)) => {
2341                            let (cb, ws, adv) = char_bounds_from_bytes(raw, fi, x, y, &gctx);
2342                            (cb, ws, adv)
2343                        }
2344                        _ => {
2345                            let cw = state.font_size
2346                                * hscale(&state.tm)
2347                                * APPROX_CHAR_WIDTH
2348                                * (state.th / 100.0);
2349                            let n = text.glyph_count();
2350                            let cb = (0..n)
2351                                .map(|i| {
2352                                    let cx = x + i as f64 * cw;
2353                                    [cx, y, cx + cw, y + state.font_size * vscale(&state.tm)]
2354                                })
2355                                .collect();
2356                            (cb, WidthSource::Estimate, n as f64 * cw)
2357                        }
2358                    };
2359
2360                    if !text.is_empty() {
2361                        let display_text = maybe_decompose_decoded(&text);
2362                        blocks.push(TextBlock {
2363                            text: display_text,
2364                            page,
2365                            bbox: [x, y, x + total_adv, y + state.font_size * vscale(&state.tm)],
2366                            font_name: state.font_name.clone(),
2367                            font_size: state.font_size,
2368                            actual_text: current_actual_text(&mc_stack, inherited_actual_text),
2369                            base_font: font_map
2370                                .get(&state.font_name)
2371                                .and_then(|fi| fi.base_font.clone()),
2372                            is_bold: font_map.get(&state.font_name).is_some_and(|fi| fi.is_bold),
2373                            is_italic: font_map
2374                                .get(&state.font_name)
2375                                .is_some_and(|fi| fi.is_italic),
2376                            color: state.fill_color,
2377                            width_source,
2378                            char_bounds,
2379                        });
2380                    }
2381
2382                    state.tm[4] = x + total_adv;
2383                }
2384            }
2385            "\"" if op.operands.len() >= 3 => {
2386                // Set word/char spacing, move to next line, show text.
2387                if let Some(tw) = as_number(&op.operands[0]) {
2388                    state.tw = tw;
2389                }
2390                if let Some(tc) = as_number(&op.operands[1]) {
2391                    state.tc = tc;
2392                }
2393
2394                let new_tlm = offset_text_line(&state.tlm, 0.0, -state.tl);
2395                state.tlm = new_tlm;
2396                state.tm = new_tlm;
2397
2398                let fi = font_map.get(&state.font_name);
2399                if let Some(text) = extract_decoded_string_operand_with_font(&op.operands[2..], fi)
2400                {
2401                    let raw = op.operands[2..].iter().find_map(|o| {
2402                        if let Object::String(b, _) = o {
2403                            Some(b.as_slice())
2404                        } else {
2405                            None
2406                        }
2407                    });
2408                    let x = state.tm[4];
2409                    let y = state.tm[5];
2410                    let gctx = GlyphCtx {
2411                        font_size: state.font_size,
2412                        tc: state.tc,
2413                        tw: state.tw,
2414                        th: state.th,
2415                        hscale: hscale(&state.tm),
2416                        vscale: vscale(&state.tm),
2417                    };
2418                    let (char_bounds, width_source, total_adv) = match (raw, fi) {
2419                        (Some(raw), Some(fi)) => {
2420                            let (cb, ws, adv) = char_bounds_from_bytes(raw, fi, x, y, &gctx);
2421                            (cb, ws, adv)
2422                        }
2423                        _ => {
2424                            let cw = state.font_size
2425                                * hscale(&state.tm)
2426                                * APPROX_CHAR_WIDTH
2427                                * (state.th / 100.0);
2428                            let n = text.glyph_count();
2429                            let cb = (0..n)
2430                                .map(|i| {
2431                                    let cx = x + i as f64 * cw;
2432                                    [cx, y, cx + cw, y + state.font_size * vscale(&state.tm)]
2433                                })
2434                                .collect();
2435                            (cb, WidthSource::Estimate, n as f64 * cw)
2436                        }
2437                    };
2438
2439                    if !text.is_empty() {
2440                        let display_text = maybe_decompose_decoded(&text);
2441                        blocks.push(TextBlock {
2442                            text: display_text,
2443                            page,
2444                            bbox: [x, y, x + total_adv, y + state.font_size * vscale(&state.tm)],
2445                            font_name: state.font_name.clone(),
2446                            font_size: state.font_size,
2447                            actual_text: current_actual_text(&mc_stack, inherited_actual_text),
2448                            base_font: font_map
2449                                .get(&state.font_name)
2450                                .and_then(|fi| fi.base_font.clone()),
2451                            is_bold: font_map.get(&state.font_name).is_some_and(|fi| fi.is_bold),
2452                            is_italic: font_map
2453                                .get(&state.font_name)
2454                                .is_some_and(|fi| fi.is_italic),
2455                            color: state.fill_color,
2456                            width_source,
2457                            char_bounds,
2458                        });
2459                    }
2460
2461                    state.tm[4] = x + total_adv;
2462                }
2463            }
2464            "BMC" => {
2465                // Begin marked-content (no property list). Push an empty entry
2466                // so EMC pops the right level — without this, nested BMC inside
2467                // a BDC/ActualText range would pop the BDC entry early.
2468                mc_stack.push(MarkedContentEntry::default());
2469            }
2470            "BDC" => {
2471                // Begin marked-content with property list. Operand 1 is the
2472                // tag (Name); operand 2 is either an inline dict or a Name
2473                // resolved via Resources/Properties.
2474                let actual_text = op
2475                    .operands
2476                    .get(1)
2477                    .and_then(|o| resolve_bdc_properties(doc_and_resources, o))
2478                    .as_ref()
2479                    .and_then(extract_actual_text);
2480                mc_stack.push(MarkedContentEntry { actual_text });
2481            }
2482            "EMC" => {
2483                // End marked-content. Use pop (not unwrap) so a malformed
2484                // stream with an unmatched EMC doesn't panic.
2485                mc_stack.pop();
2486            }
2487            "Do" if depth < 5 => {
2488                // Invoke Form XObject — recurse into its content stream.
2489                // Propagate the active /ActualText binding (top-of-stack, or
2490                // whatever this invocation itself inherited) so text inside
2491                // the XObject is tagged with the surrounding BDC/EMC pair.
2492                if let Some((doc, resources)) = doc_and_resources {
2493                    if let Some(Object::Name(ref xobj_name)) = op.operands.first() {
2494                        let xobj_name_str = String::from_utf8_lossy(xobj_name);
2495                        let inherited_for_child =
2496                            current_actual_text(&mc_stack, inherited_actual_text);
2497                        if let Some(xobj_blocks) = extract_form_xobject_text(
2498                            doc,
2499                            resources,
2500                            &xobj_name_str,
2501                            page,
2502                            font_map,
2503                            depth,
2504                            inherited_for_child.as_deref(),
2505                        ) {
2506                            blocks.extend(xobj_blocks);
2507                        }
2508                    }
2509                }
2510            }
2511            _ => {}
2512        }
2513    }
2514
2515    blocks
2516}
2517
2518/// Extract text from a Form XObject referenced by name in the page's
2519/// Resources/XObject dict. `inherited_actual_text` is the active
2520/// `/ActualText` binding from the calling context (parent stack +
2521/// whatever the parent itself inherited), so text emitted inside this
2522/// XObject's `BT`...`ET` blocks is tagged correctly when the `Do`
2523/// operator sits between a `BDC` and matching `EMC`.
2524fn extract_form_xobject_text(
2525    doc: &Document,
2526    resources: &lopdf::Dictionary,
2527    name: &str,
2528    page: u32,
2529    font_map: &HashMap<String, FontInfo>,
2530    depth: u32,
2531    inherited_actual_text: Option<&str>,
2532) -> Option<Vec<TextBlock>> {
2533    // Look up the XObject in the Resources dictionary.
2534    let xobj_dict = match resources.get(b"XObject").ok()? {
2535        Object::Dictionary(d) => d.clone(),
2536        Object::Reference(r) => match doc.get_object(*r).ok()? {
2537            Object::Dictionary(d) => d.clone(),
2538            _ => return None,
2539        },
2540        _ => return None,
2541    };
2542
2543    let xobj_ref = match xobj_dict.get(name.as_bytes()).ok()? {
2544        Object::Reference(r) => *r,
2545        _ => return None,
2546    };
2547
2548    let stream = match doc.get_object(xobj_ref).ok()? {
2549        Object::Stream(s) => s.clone(),
2550        _ => return None,
2551    };
2552
2553    // Verify it's a Form XObject (not Image).
2554    let subtype = stream
2555        .dict
2556        .get(b"Subtype")
2557        .ok()
2558        .and_then(|o| match o {
2559            Object::Name(n) => Some(String::from_utf8_lossy(n).to_string()),
2560            _ => None,
2561        })
2562        .unwrap_or_default();
2563    if subtype != "Form" {
2564        return None;
2565    }
2566
2567    // Decode the content stream.
2568    let content_bytes = stream
2569        .decompressed_content()
2570        .ok()
2571        .unwrap_or_else(|| stream.content.clone());
2572    let content = Content::decode(&content_bytes).ok()?;
2573
2574    // Build font map: XObject's own fonts take priority over inherited page fonts.
2575    // A Form XObject may reuse font names (e.g. /F1) with different encodings.
2576    let mut xobj_font_map = font_map.clone();
2577    if let Some(xobj_resources) = resolve_dict(doc, &stream.dict, b"Resources") {
2578        if let Some(xobj_fonts) = resolve_dict(doc, &xobj_resources, b"Font") {
2579            for (name_bytes, value) in xobj_fonts.iter() {
2580                let fname = String::from_utf8_lossy(name_bytes).to_string();
2581                if let Some(fi) = build_font_info_from_value(doc, value) {
2582                    xobj_font_map.insert(fname, fi);
2583                }
2584            }
2585        }
2586    }
2587
2588    // Use the XObject's own Resources dict for recursive Do lookups.
2589    let xobj_resources =
2590        resolve_dict(doc, &stream.dict, b"Resources").unwrap_or_else(|| resources.clone());
2591
2592    Some(extract_blocks_from_ops_inner(
2593        &content.operations,
2594        page,
2595        &xobj_font_map,
2596        Some((doc, &xobj_resources)),
2597        depth + 1,
2598        inherited_actual_text,
2599    ))
2600}
2601
2602/// Build a FontInfo from a font dictionary value (used for XObject font resolution).
2603fn build_font_info_from_value(doc: &Document, value: &Object) -> Option<FontInfo> {
2604    let font = match value {
2605        Object::Reference(r) => match doc.get_object(*r).ok()? {
2606            Object::Dictionary(d) => d.clone(),
2607            _ => return None,
2608        },
2609        Object::Dictionary(d) => d.clone(),
2610        _ => return None,
2611    };
2612
2613    let subtype = font
2614        .get(b"Subtype")
2615        .ok()
2616        .and_then(|o| match o {
2617            Object::Name(n) => Some(String::from_utf8_lossy(n).to_string()),
2618            _ => None,
2619        })
2620        .unwrap_or_default();
2621
2622    let is_cid = subtype == "Type0";
2623    let mut to_unicode = parse_to_unicode_from_font(doc, &font);
2624
2625    if to_unicode.is_empty() && is_cid {
2626        if let Ok(Object::Array(descendants)) = font.get(b"DescendantFonts") {
2627            for d in descendants {
2628                let desc_dict = match d {
2629                    Object::Reference(r) => {
2630                        // Don't use `?` here — a bad reference in one descendant
2631                        // should not abort the entire font info construction.
2632                        let Some(Object::Dictionary(d)) = doc.get_object(*r).ok() else {
2633                            continue;
2634                        };
2635                        d
2636                    }
2637                    Object::Dictionary(d) => d,
2638                    _ => continue,
2639                };
2640                let tu = parse_to_unicode_from_font(doc, desc_dict);
2641                if !tu.is_empty() {
2642                    to_unicode = tu;
2643                    break;
2644                }
2645            }
2646        }
2647    }
2648
2649    let (encoding_map, ct_codes) = if !is_cid {
2650        build_encoding_map(doc, &font)
2651    } else {
2652        ([None; 256], [false; 256])
2653    };
2654
2655    let (base_font, is_bold, is_italic) = derive_font_style(doc, &font);
2656    let (simple_widths, cid_default_width, cid_widths) = parse_font_widths(doc, &font, is_cid);
2657
2658    Some(FontInfo {
2659        is_cid,
2660        to_unicode,
2661        encoding_map,
2662        ct_codes,
2663        base_font,
2664        is_bold,
2665        is_italic,
2666        simple_widths,
2667        cid_default_width,
2668        cid_widths,
2669    })
2670}
2671
2672/// Emit one or more `PositionedChar` for a single glyph, decomposing
2673/// ligatures by splitting the glyph's `char_w` advance evenly across the
2674/// constituent characters. The text matrix advances exactly once (per
2675/// glyph), preserving the original layout — only the per-character bbox is
2676/// subdivided.
2677///
2678/// Uses the unified [`decompose_glyph_to_string`] helper so that the
2679/// per-glyph decomposition matches the string-level
2680/// [`decompose_ligatures`] used by `extract_page_text` / `TextBlock`.
2681/// Without this alignment, presentation-form codepoints outside the
2682/// hardcoded Latin set (e.g. Armenian U+FB13) would expand in extracted
2683/// text but stay as a single PositionedChar, leaving consumers that pair
2684/// text offsets with bounding boxes misaligned.
2685fn push_glyph_positioned(
2686    chars: &mut Vec<PositionedChar>,
2687    state: &mut TextState,
2688    page: u32,
2689    glyph: char,
2690    is_ct_origin: bool,
2691    char_w: f64,
2692) {
2693    let constituents: Option<String> = if LIGATURE_DECOMP {
2694        // Try ct-origin marker first (preserves U+E007 → "ct" only when it
2695        // came from a /ct glyph-name path), then fall back to the unified
2696        // decomposition helper which covers the Latin set + FB00–FB4F NFKD.
2697        if let Some(s) = decompose_ligature_char_with_origin(glyph, is_ct_origin) {
2698            Some(s.to_string())
2699        } else {
2700            decompose_glyph_to_string(glyph)
2701        }
2702    } else {
2703        None
2704    };
2705    match constituents {
2706        None => {
2707            chars.push(PositionedChar {
2708                ch: glyph,
2709                page,
2710                bbox: glyph_box(state, 0.0, char_w, state.font_size),
2711            });
2712        }
2713        Some(s) => {
2714            let n = s.chars().count() as f64;
2715            let part_w = char_w / n;
2716            for (i, c) in s.chars().enumerate() {
2717                let x0 = part_w * i as f64;
2718                chars.push(PositionedChar {
2719                    ch: c,
2720                    page,
2721                    bbox: glyph_box(state, x0, x0 + part_w, state.font_size),
2722                });
2723            }
2724        }
2725    }
2726    advance_text_origin(state, char_w + state.tc);
2727}
2728
2729/// Move the glyph origin by `d` along the text matrix's x axis. `d` is in text
2730/// space; on the page it is `d * (a, b)` of the matrix. Adding it to `e` alone
2731/// (the old code) is right only for an identity linear part, and put every glyph
2732/// after the first of a `Tf 1` + `Tm 12 0 0 12` line next to its predecessor (#848).
2733fn advance_text_origin(state: &mut TextState, d: f64) {
2734    state.tm[4] += d * state.tm[0];
2735    state.tm[5] += d * state.tm[1];
2736}
2737
2738/// Page-space box of a glyph occupying `[x0, x1] x [0, h]` in text space, through
2739/// the text matrix and the CTM. A rotated or skewed glyph gets the axis-aligned
2740/// box that contains it.
2741fn glyph_box(state: &TextState, x0: f64, x1: f64, h: f64) -> [f64; 4] {
2742    let tm = &state.tm;
2743    let c = &state.ctm;
2744    let mut lo = [f64::INFINITY; 2];
2745    let mut hi = [f64::NEG_INFINITY; 2];
2746    for (tx, ty) in [(x0, 0.0), (x1, 0.0), (x0, h), (x1, h)] {
2747        let ux = tx * tm[0] + ty * tm[2] + tm[4];
2748        let uy = tx * tm[1] + ty * tm[3] + tm[5];
2749        let px = c[0] * ux + c[2] * uy + c[4];
2750        let py = c[1] * ux + c[3] * uy + c[5];
2751        lo[0] = lo[0].min(px);
2752        lo[1] = lo[1].min(py);
2753        hi[0] = hi[0].max(px);
2754        hi[1] = hi[1].max(py);
2755    }
2756    [lo[0], lo[1], hi[0], hi[1]]
2757}
2758
2759/// Extract positioned characters from operations.
2760fn extract_chars_from_ops(
2761    ops: &[Operation],
2762    page: u32,
2763    font_map: &HashMap<String, FontInfo>,
2764) -> Vec<PositionedChar> {
2765    let mut state = TextState::default();
2766    let mut chars = Vec::new();
2767
2768    for op in ops {
2769        match op.operator.as_str() {
2770            "q" => {
2771                state.gs_stack.push(GraphicsState {
2772                    ctm: state.ctm,
2773                    fill_color: state.fill_color,
2774                });
2775            }
2776            "Q" => {
2777                if let Some(gs) = state.gs_stack.pop() {
2778                    state.ctm = gs.ctm;
2779                    state.fill_color = gs.fill_color;
2780                }
2781            }
2782            "cm" => {
2783                if let Some(m) = extract_matrix(&op.operands) {
2784                    state.ctm = concat_cm(&m, &state.ctm);
2785                }
2786            }
2787            // G1: non-stroking fill color setters. PDF §8.6.5: `rg` r g b;
2788            // `g` gray; `k` c m y k. Stroking variants (RG/G/K) don't
2789            // affect text fill so we accept and ignore them. `sc`/`scn` are
2790            // honored when operand count makes the device space unambiguous
2791            // (1 = Gray, 3 = RGB, 4 = CMYK). `cs`/`CS` mark color unknown
2792            // because ICC/Lab/Pattern can't be converted from operands alone.
2793            "rg" if op.operands.len() >= 3 => {
2794                if let (Some(r), Some(g), Some(b)) = (
2795                    op.operands.first().and_then(as_number),
2796                    op.operands.get(1).and_then(as_number),
2797                    op.operands.get(2).and_then(as_number),
2798                ) {
2799                    state.fill_color = Some([
2800                        clamp_unit_to_u8(r),
2801                        clamp_unit_to_u8(g),
2802                        clamp_unit_to_u8(b),
2803                        255,
2804                    ]);
2805                }
2806            }
2807            "g" => {
2808                if let Some(v) = op.operands.first().and_then(as_number) {
2809                    let byte = clamp_unit_to_u8(v);
2810                    state.fill_color = Some([byte, byte, byte, 255]);
2811                }
2812            }
2813            "k" if op.operands.len() >= 4 => {
2814                if let (Some(c), Some(m), Some(y), Some(kk)) = (
2815                    op.operands.first().and_then(as_number),
2816                    op.operands.get(1).and_then(as_number),
2817                    op.operands.get(2).and_then(as_number),
2818                    op.operands.get(3).and_then(as_number),
2819                ) {
2820                    state.fill_color = Some(cmyk_to_rgba(c, m, y, kk));
2821                }
2822            }
2823            "RG" | "G" | "K" => {}
2824            "sc" | "scn" => {
2825                state.fill_color = match op.operands.len() {
2826                    1 => op.operands.first().and_then(as_number).map(|v| {
2827                        let b = clamp_unit_to_u8(v);
2828                        [b, b, b, 255]
2829                    }),
2830                    3 => {
2831                        let r = op.operands.first().and_then(as_number);
2832                        let g = op.operands.get(1).and_then(as_number);
2833                        let b = op.operands.get(2).and_then(as_number);
2834                        match (r, g, b) {
2835                            (Some(r), Some(g), Some(b)) => Some([
2836                                clamp_unit_to_u8(r),
2837                                clamp_unit_to_u8(g),
2838                                clamp_unit_to_u8(b),
2839                                255,
2840                            ]),
2841                            _ => None,
2842                        }
2843                    }
2844                    4 => {
2845                        let c = op.operands.first().and_then(as_number);
2846                        let m = op.operands.get(1).and_then(as_number);
2847                        let y = op.operands.get(2).and_then(as_number);
2848                        let kk = op.operands.get(3).and_then(as_number);
2849                        match (c, m, y, kk) {
2850                            (Some(c), Some(m), Some(y), Some(kk)) => {
2851                                Some(cmyk_to_rgba(c, m, y, kk))
2852                            }
2853                            _ => None,
2854                        }
2855                    }
2856                    _ => None,
2857                };
2858            }
2859            "cs" | "CS" => {
2860                state.fill_color = None;
2861            }
2862            "BT" => {
2863                state.tm = [1.0, 0.0, 0.0, 1.0, 0.0, 0.0];
2864                state.tlm = [1.0, 0.0, 0.0, 1.0, 0.0, 0.0];
2865            }
2866            "Tf" if op.operands.len() >= 2 => {
2867                if let Object::Name(ref name) = op.operands[0] {
2868                    state.font_name = String::from_utf8_lossy(name).to_string();
2869                }
2870                if let Some(size) = as_number(&op.operands[1]) {
2871                    state.font_size = size;
2872                }
2873            }
2874            "Tc" => {
2875                if let Some(v) = op.operands.first().and_then(as_number) {
2876                    state.tc = v;
2877                }
2878            }
2879            "Tw" => {
2880                if let Some(v) = op.operands.first().and_then(as_number) {
2881                    state.tw = v;
2882                }
2883            }
2884            "Tz" => {
2885                if let Some(v) = op.operands.first().and_then(as_number) {
2886                    state.th = v;
2887                }
2888            }
2889            "TL" => {
2890                if let Some(v) = op.operands.first().and_then(as_number) {
2891                    state.tl = v;
2892                }
2893            }
2894            "Ts" => {
2895                if let Some(v) = op.operands.first().and_then(as_number) {
2896                    state.ts = v;
2897                }
2898            }
2899            "Td" if op.operands.len() >= 2 => {
2900                let tx = as_number(&op.operands[0]).unwrap_or(0.0);
2901                let ty = as_number(&op.operands[1]).unwrap_or(0.0);
2902                let new_tlm = offset_text_line(&state.tlm, tx, ty);
2903                state.tlm = new_tlm;
2904                state.tm = new_tlm;
2905            }
2906            "TD" if op.operands.len() >= 2 => {
2907                let tx = as_number(&op.operands[0]).unwrap_or(0.0);
2908                let ty = as_number(&op.operands[1]).unwrap_or(0.0);
2909                state.tl = -ty;
2910                let new_tlm = offset_text_line(&state.tlm, tx, ty);
2911                state.tlm = new_tlm;
2912                state.tm = new_tlm;
2913            }
2914            "Tm" => {
2915                if let Some(m) = extract_matrix(&op.operands) {
2916                    state.tm = m;
2917                    state.tlm = m;
2918                }
2919            }
2920            "T*" => {
2921                let new_tlm = offset_text_line(&state.tlm, 0.0, -state.tl);
2922                state.tlm = new_tlm;
2923                state.tm = new_tlm;
2924            }
2925            "Tj" => {
2926                let fi = font_map.get(&state.font_name);
2927                if let Some(text) = extract_decoded_string_operand_with_font(&op.operands, fi) {
2928                    let char_w = state.font_size * APPROX_CHAR_WIDTH * (state.th / 100.0);
2929                    for (ch, is_ct_origin) in text.iter() {
2930                        push_glyph_positioned(
2931                            &mut chars,
2932                            &mut state,
2933                            page,
2934                            ch,
2935                            is_ct_origin,
2936                            char_w,
2937                        );
2938                    }
2939                }
2940            }
2941            "TJ" => {
2942                if let Some(Object::Array(ref arr)) = op.operands.first() {
2943                    let char_w = state.font_size * APPROX_CHAR_WIDTH * (state.th / 100.0);
2944                    let fi = font_map.get(&state.font_name);
2945                    for item in arr {
2946                        match item {
2947                            Object::String(bytes, _) => {
2948                                let text = decode_pdf_string_with_font_marked(bytes, fi);
2949                                for (ch, is_ct_origin) in text.iter() {
2950                                    push_glyph_positioned(
2951                                        &mut chars,
2952                                        &mut state,
2953                                        page,
2954                                        ch,
2955                                        is_ct_origin,
2956                                        char_w,
2957                                    );
2958                                }
2959                            }
2960                            _ => {
2961                                if let Some(adj) = as_number(item) {
2962                                    let d = -(adj / 1000.0 * state.font_size);
2963                                    advance_text_origin(&mut state, d);
2964                                }
2965                            }
2966                        }
2967                    }
2968                }
2969            }
2970            "'" => {
2971                let new_tlm = offset_text_line(&state.tlm, 0.0, -state.tl);
2972                state.tlm = new_tlm;
2973                state.tm = new_tlm;
2974
2975                let fi = font_map.get(&state.font_name);
2976                if let Some(text) = extract_decoded_string_operand_with_font(&op.operands, fi) {
2977                    let char_w = state.font_size * APPROX_CHAR_WIDTH * (state.th / 100.0);
2978                    for (ch, is_ct_origin) in text.iter() {
2979                        push_glyph_positioned(
2980                            &mut chars,
2981                            &mut state,
2982                            page,
2983                            ch,
2984                            is_ct_origin,
2985                            char_w,
2986                        );
2987                    }
2988                }
2989            }
2990            "\"" if op.operands.len() >= 3 => {
2991                if let Some(tw) = as_number(&op.operands[0]) {
2992                    state.tw = tw;
2993                }
2994                if let Some(tc) = as_number(&op.operands[1]) {
2995                    state.tc = tc;
2996                }
2997
2998                let new_tlm = offset_text_line(&state.tlm, 0.0, -state.tl);
2999                state.tlm = new_tlm;
3000                state.tm = new_tlm;
3001
3002                let fi = font_map.get(&state.font_name);
3003                if let Some(text) = extract_decoded_string_operand_with_font(&op.operands[2..], fi)
3004                {
3005                    let char_w = state.font_size * APPROX_CHAR_WIDTH * (state.th / 100.0);
3006                    for (ch, is_ct_origin) in text.iter() {
3007                        push_glyph_positioned(
3008                            &mut chars,
3009                            &mut state,
3010                            page,
3011                            ch,
3012                            is_ct_origin,
3013                            char_w,
3014                        );
3015                    }
3016                }
3017            }
3018            _ => {}
3019        }
3020    }
3021
3022    chars
3023}
3024
3025/// Extract the first string operand, decoding via font's ToUnicode CMap if available.
3026fn extract_decoded_string_operand_with_font(
3027    operands: &[Object],
3028    font_info: Option<&FontInfo>,
3029) -> Option<DecodedPdfString> {
3030    for op in operands {
3031        if let Object::String(bytes, _) = op {
3032            return Some(decode_pdf_string_with_font_marked(bytes, font_info));
3033        }
3034    }
3035    None
3036}
3037
3038/// Convert a PDF object to a number (f64).
3039fn as_number(obj: &Object) -> Option<f64> {
3040    match obj {
3041        Object::Integer(i) => Some(*i as f64),
3042        Object::Real(f) => Some(*f as f64),
3043        _ => None,
3044    }
3045}
3046
3047/// Extract a 6-element transformation matrix from operands.
3048fn extract_matrix(operands: &[Object]) -> Option<[f64; 6]> {
3049    if operands.len() < 6 {
3050        return None;
3051    }
3052    let a = as_number(&operands[0])?;
3053    let b = as_number(&operands[1])?;
3054    let c = as_number(&operands[2])?;
3055    let d = as_number(&operands[3])?;
3056    let e = as_number(&operands[4])?;
3057    let f = as_number(&operands[5])?;
3058    Some([a, b, c, d, e, f])
3059}
3060
3061/// Multiply two 3x3 transformation matrices (stored as [a, b, c, d, e, f]).
3062/// `CTM' = M x CTM` (ISO 32000-2, 8.3.4): the matrix of a `cm` applies first.
3063/// The same rule as `pdf-manip`'s `concat_cm`.
3064fn concat_cm(m: &[f64; 6], ctm: &[f64; 6]) -> [f64; 6] {
3065    multiply_matrix(m, ctm)
3066}
3067
3068/// `Tlm' = T(tx, ty) x Tlm` (ISO 32000-2, 9.4.2): the offset of `Td`, `TD`, `T*`
3069/// is in the units of the current text line matrix, so a scaled `Tm` scales it.
3070fn offset_text_line(tlm: &[f64; 6], tx: f64, ty: f64) -> [f64; 6] {
3071    multiply_matrix(&[1.0, 0.0, 0.0, 1.0, tx, ty], tlm)
3072}
3073
3074fn multiply_matrix(m1: &[f64; 6], m2: &[f64; 6]) -> [f64; 6] {
3075    [
3076        m1[0] * m2[0] + m1[1] * m2[2],
3077        m1[0] * m2[1] + m1[1] * m2[3],
3078        m1[2] * m2[0] + m1[3] * m2[2],
3079        m1[2] * m2[1] + m1[3] * m2[3],
3080        m1[4] * m2[0] + m1[5] * m2[2] + m2[4],
3081        m1[4] * m2[1] + m1[5] * m2[3] + m2[5],
3082    ]
3083}
3084
3085#[cfg(test)]
3086mod tests {
3087    use super::*;
3088    use lopdf::{dictionary, Document, Object, Stream};
3089
3090    /// Helper: create a minimal doc with text content.
3091    fn make_doc_with_text(content: &[u8]) -> Document {
3092        let mut doc = Document::with_version("1.7");
3093
3094        let content_stream = Stream::new(dictionary! {}, content.to_vec());
3095        let content_id = doc.add_object(Object::Stream(content_stream));
3096
3097        let page_dict = dictionary! {
3098            "Type" => "Page",
3099            "MediaBox" => vec![0.into(), 0.into(), 612.into(), 792.into()],
3100            "Contents" => Object::Reference(content_id),
3101        };
3102        let page_id = doc.add_object(Object::Dictionary(page_dict));
3103
3104        let pages_dict = dictionary! {
3105            "Type" => "Pages",
3106            "Kids" => vec![Object::Reference(page_id)],
3107            "Count" => 1_i64,
3108        };
3109        let pages_id = doc.add_object(Object::Dictionary(pages_dict));
3110
3111        if let Ok(Object::Dictionary(ref mut d)) = doc.get_object_mut(page_id) {
3112            d.set("Parent", Object::Reference(pages_id));
3113        }
3114
3115        let catalog = dictionary! {
3116            "Type" => "Catalog",
3117            "Pages" => Object::Reference(pages_id),
3118        };
3119        let catalog_id = doc.add_object(Object::Dictionary(catalog));
3120        doc.trailer.set("Root", Object::Reference(catalog_id));
3121
3122        doc
3123    }
3124
3125    #[test]
3126    fn extract_simple_text() {
3127        let doc = make_doc_with_text(b"BT /F1 12 Tf (Hello World) Tj ET");
3128        let blocks = extract_text(&doc);
3129        assert_eq!(blocks.len(), 1);
3130        assert_eq!(blocks[0].text, "Hello World");
3131        assert_eq!(blocks[0].page, 1);
3132        assert_eq!(blocks[0].font_size, 12.0);
3133    }
3134
3135    #[test]
3136    fn extract_page_text_single() {
3137        let doc = make_doc_with_text(b"BT /F1 12 Tf (Hello) Tj ET");
3138        let text = extract_page_text(&doc, 1).unwrap();
3139        assert_eq!(text, "Hello");
3140    }
3141
3142    #[test]
3143    fn extract_page_text_out_of_range() {
3144        let doc = make_doc_with_text(b"BT /F1 12 Tf (Hello) Tj ET");
3145        let result = extract_page_text(&doc, 5);
3146        assert!(result.is_err());
3147    }
3148
3149    #[test]
3150    fn extract_positioned_chars_basic() {
3151        let doc = make_doc_with_text(b"BT /F1 12 Tf (AB) Tj ET");
3152        let chars = extract_positioned_chars(&doc, 1).unwrap();
3153        assert_eq!(chars.len(), 2);
3154        assert_eq!(chars[0].ch, 'A');
3155        assert_eq!(chars[1].ch, 'B');
3156        assert_eq!(chars[0].page, 1);
3157        // Second char should be positioned after the first.
3158        assert!(chars[1].bbox[0] > chars[0].bbox[0]);
3159    }
3160
3161    #[test]
3162    fn extract_tj_array() {
3163        let doc = make_doc_with_text(b"BT /F1 12 Tf [(He) -100 (llo)] TJ ET");
3164        let blocks = extract_text(&doc);
3165        assert_eq!(blocks.len(), 1);
3166        assert_eq!(blocks[0].text, "Hello");
3167    }
3168
3169    #[test]
3170    fn empty_page_extracts_no_text() {
3171        let doc = make_doc_with_text(b"q Q");
3172        let blocks = extract_text(&doc);
3173        assert!(blocks.is_empty());
3174    }
3175
3176    #[test]
3177    fn multiline_text_extraction() {
3178        let doc = make_doc_with_text(b"BT /F1 12 Tf 12 TL (Line1) Tj T* (Line2) Tj ET");
3179        let blocks = extract_text(&doc);
3180        assert_eq!(blocks.len(), 2);
3181        assert_eq!(blocks[0].text, "Line1");
3182        assert_eq!(blocks[1].text, "Line2");
3183    }
3184
3185    /// `/ActualText` on a BDC marked-content sequence overrides the glyph
3186    /// reading of the inner Tj. Glyph text is preserved on `text`; the
3187    /// canonical reading lives on `actual_text`.
3188    #[test]
3189    fn actual_text_basic_bdc_override() {
3190        let doc = make_doc_with_text(b"BT /F1 12 Tf /Span <</ActualText (fi)>> BDC (X) Tj EMC ET");
3191        let blocks = extract_text(&doc);
3192        assert_eq!(blocks.len(), 1);
3193        assert_eq!(blocks[0].text, "X");
3194        assert_eq!(blocks[0].actual_text.as_deref(), Some("fi"));
3195    }
3196
3197    /// Nested BDCs: the innermost `/ActualText` wins inside its range, and
3198    /// the outer override is restored after the inner EMC. A flat flag would
3199    /// leak the inner value past its scope; the stack must pop one level.
3200    #[test]
3201    fn actual_text_nested_bdc_inner_overrides_outer() {
3202        let doc = make_doc_with_text(
3203            b"BT /F1 12 Tf \
3204              /Span <</ActualText (outer)>> BDC \
3205                (A) Tj \
3206                /Span <</ActualText (inner)>> BDC \
3207                  (B) Tj \
3208                EMC \
3209                (C) Tj \
3210              EMC ET",
3211        );
3212        let blocks = extract_text(&doc);
3213        assert_eq!(blocks.len(), 3);
3214        assert_eq!(blocks[0].text, "A");
3215        assert_eq!(blocks[0].actual_text.as_deref(), Some("outer"));
3216        assert_eq!(blocks[1].text, "B");
3217        assert_eq!(blocks[1].actual_text.as_deref(), Some("inner"));
3218        assert_eq!(blocks[2].text, "C");
3219        assert_eq!(blocks[2].actual_text.as_deref(), Some("outer"));
3220    }
3221
3222    /// BMC (no property list) still pushes a stack entry so the matching EMC
3223    /// pops the right level — otherwise an EMC inside a BDC range would leak.
3224    #[test]
3225    fn actual_text_bmc_does_not_leak_emc() {
3226        let doc = make_doc_with_text(
3227            b"BT /F1 12 Tf \
3228              /Span <</ActualText (X)>> BDC \
3229                /Artifact BMC (in) Tj EMC \
3230                (out) Tj \
3231              EMC \
3232              (after) Tj ET",
3233        );
3234        let blocks = extract_text(&doc);
3235        assert_eq!(blocks.len(), 3);
3236        assert_eq!(blocks[0].actual_text.as_deref(), Some("X"));
3237        assert_eq!(blocks[1].actual_text.as_deref(), Some("X"));
3238        assert_eq!(blocks[2].actual_text, None);
3239    }
3240
3241    /// Build a one-page Document whose page invokes a Form XObject (via
3242    /// `/Fm0 Do`) wrapped in a `BDC` / `EMC` pair. `xobj_content` is the
3243    /// content stream placed inside the Form XObject; `page_pre` runs
3244    /// before the `Do`, and `page_post` runs after it (still inside the
3245    /// surrounding `BDC` / `EMC`). Used to drive issue #1358 regression.
3246    fn make_doc_with_xobj_inside_bdc(
3247        page_pre: &[u8],
3248        xobj_content: &[u8],
3249        page_post: &[u8],
3250        actual_text: &str,
3251    ) -> Document {
3252        let mut doc = Document::with_version("1.7");
3253
3254        let xobj_dict = dictionary! {
3255            "Type" => "XObject",
3256            "Subtype" => "Form",
3257            "BBox" => vec![0.into(), 0.into(), 100.into(), 100.into()],
3258        };
3259        let xobj_stream = Stream::new(xobj_dict, xobj_content.to_vec());
3260        let xobj_id = doc.add_object(Object::Stream(xobj_stream));
3261
3262        // Page content: BT /F1 12 Tf /Span <</ActualText(...)>> BDC <pre> /Fm0 Do <post> EMC ET
3263        let mut page_content = Vec::<u8>::new();
3264        page_content.extend_from_slice(b"BT /F1 12 Tf /Span <</ActualText (");
3265        page_content.extend_from_slice(actual_text.as_bytes());
3266        page_content.extend_from_slice(b")>> BDC ");
3267        page_content.extend_from_slice(page_pre);
3268        page_content.extend_from_slice(b" /Fm0 Do ");
3269        page_content.extend_from_slice(page_post);
3270        page_content.extend_from_slice(b" EMC ET");
3271
3272        let content_stream = Stream::new(dictionary! {}, page_content);
3273        let content_id = doc.add_object(Object::Stream(content_stream));
3274
3275        let resources = dictionary! {
3276            "XObject" => dictionary! { "Fm0" => Object::Reference(xobj_id) },
3277        };
3278        let resources_id = doc.add_object(Object::Dictionary(resources));
3279
3280        let page_dict = dictionary! {
3281            "Type" => "Page",
3282            "MediaBox" => vec![0.into(), 0.into(), 612.into(), 792.into()],
3283            "Contents" => Object::Reference(content_id),
3284            "Resources" => Object::Reference(resources_id),
3285        };
3286        let page_id = doc.add_object(Object::Dictionary(page_dict));
3287
3288        let pages_dict = dictionary! {
3289            "Type" => "Pages",
3290            "Kids" => vec![Object::Reference(page_id)],
3291            "Count" => 1_i64,
3292        };
3293        let pages_id = doc.add_object(Object::Dictionary(pages_dict));
3294
3295        if let Ok(Object::Dictionary(ref mut d)) = doc.get_object_mut(page_id) {
3296            d.set("Parent", Object::Reference(pages_id));
3297        }
3298
3299        let catalog = dictionary! {
3300            "Type" => "Catalog",
3301            "Pages" => Object::Reference(pages_id),
3302        };
3303        let catalog_id = doc.add_object(Object::Dictionary(catalog));
3304        doc.trailer.set("Root", Object::Reference(catalog_id));
3305
3306        doc
3307    }
3308
3309    /// Regression guard for issue #1358: when a `Do` operator invoking a
3310    /// Form XObject sits inside a `BDC` / `EMC` pair carrying
3311    /// `/ActualText`, text emitted by the XObject's own `Tj`/`TJ`
3312    /// operators must inherit that `/ActualText` value. Previously the
3313    /// recursive `extract_blocks_from_ops_inner` reset the marked-content
3314    /// stack and lost the surrounding context.
3315    #[test]
3316    fn actual_text_propagates_into_form_xobject_recursion() {
3317        let doc = make_doc_with_xobj_inside_bdc(
3318            b"(pre) Tj",
3319            b"BT /F1 12 Tf (inside) Tj ET",
3320            b"(post) Tj",
3321            "wrapped",
3322        );
3323        let blocks = extract_text(&doc);
3324        assert_eq!(blocks.len(), 3);
3325        assert_eq!(blocks[0].text, "pre");
3326        assert_eq!(blocks[0].actual_text.as_deref(), Some("wrapped"));
3327        // The block emitted inside the Form XObject's BT/ET must inherit
3328        // the surrounding /ActualText — not silently become None.
3329        assert_eq!(blocks[1].text, "inside");
3330        assert_eq!(
3331            blocks[1].actual_text.as_deref(),
3332            Some("wrapped"),
3333            "Form XObject text lost surrounding /ActualText (issue #1358)"
3334        );
3335        assert_eq!(blocks[2].text, "post");
3336        assert_eq!(blocks[2].actual_text.as_deref(), Some("wrapped"));
3337    }
3338
3339    /// Inverse regression: when a `Do` invokes a Form XObject whose own
3340    /// content has its own `BDC` ... `EMC` pair with a different
3341    /// `/ActualText`, the inner pair takes precedence inside the XObject
3342    /// and the outer pair resumes after the inner `EMC`. This guards
3343    /// against the inherited binding clobbering legitimate inner
3344    /// marked-content state.
3345    #[test]
3346    fn actual_text_inner_xobj_bdc_overrides_inherited() {
3347        let doc = make_doc_with_xobj_inside_bdc(
3348            b"",
3349            b"BT /F1 12 Tf \
3350              /Span <</ActualText (inner)>> BDC (B) Tj EMC \
3351              (after) Tj ET",
3352            b"",
3353            "outer",
3354        );
3355        let blocks = extract_text(&doc);
3356        assert_eq!(blocks.len(), 2);
3357        assert_eq!(blocks[0].text, "B");
3358        assert_eq!(blocks[0].actual_text.as_deref(), Some("inner"));
3359        assert_eq!(blocks[1].text, "after");
3360        assert_eq!(blocks[1].actual_text.as_deref(), Some("outer"));
3361    }
3362
3363    /// `/ActualText` encoded as UTF-16BE (with BOM) decodes to the proper
3364    /// Unicode string. Real PDFs almost always use this form.
3365    #[test]
3366    fn actual_text_utf16be_bom_decodes() {
3367        // <FEFF00660069> = UTF-16BE BOM + "fi"
3368        let doc = make_doc_with_text(
3369            b"BT /F1 12 Tf /Span <</ActualText <FEFF00660069> >> BDC (X) Tj EMC ET",
3370        );
3371        let blocks = extract_text(&doc);
3372        assert_eq!(blocks.len(), 1);
3373        assert_eq!(blocks[0].actual_text.as_deref(), Some("fi"));
3374    }
3375
3376    // -- M4-LIG-01: ligature decomposition -------------------------------
3377
3378    #[test]
3379    fn ligature_ff_decomposes() {
3380        assert_eq!(decompose_ligature_char('\u{FB00}'), Some("ff"));
3381        assert_eq!(decompose_ligatures("\u{FB00}"), "ff");
3382        assert_eq!(decompose_ligatures("o\u{FB00}ice"), "office");
3383    }
3384
3385    #[test]
3386    fn ligature_fi_decomposes() {
3387        assert_eq!(decompose_ligature_char('\u{FB01}'), Some("fi"));
3388        assert_eq!(decompose_ligatures("\u{FB01}"), "fi");
3389        assert_eq!(decompose_ligatures("of\u{FB01}ce"), "office");
3390    }
3391
3392    #[test]
3393    fn ligature_fl_decomposes() {
3394        assert_eq!(decompose_ligature_char('\u{FB02}'), Some("fl"));
3395        assert_eq!(decompose_ligatures("\u{FB02}ame"), "flame");
3396    }
3397
3398    #[test]
3399    fn ligature_ffi_decomposes() {
3400        assert_eq!(decompose_ligature_char('\u{FB03}'), Some("ffi"));
3401        assert_eq!(decompose_ligatures("o\u{FB03}ce"), "office");
3402    }
3403
3404    #[test]
3405    fn ligature_ffl_decomposes() {
3406        assert_eq!(decompose_ligature_char('\u{FB04}'), Some("ffl"));
3407        assert_eq!(decompose_ligatures("ba\u{FB04}e"), "baffle");
3408    }
3409
3410    /// `st` has both U+FB05 (long-s + t, archaic) and U+FB06 (regular st);
3411    /// both decompose to "st" — historical spelling is not preserved since
3412    /// the goal is text-search/copy-paste, not typographic round-trip.
3413    /// Glyph name `st` also routes through this codepoint.
3414    #[test]
3415    fn ligature_st_decomposes() {
3416        assert_eq!(decompose_ligature_char('\u{FB06}'), Some("st"));
3417        assert_eq!(decompose_ligature_char('\u{FB05}'), Some("st"));
3418        assert_eq!(decompose_ligatures("fa\u{FB06}"), "fast");
3419        // Glyph name path: `st` → U+FB06 → "st"
3420        assert_eq!(glyph_name_to_unicode("st"), Some('\u{FB06}'));
3421    }
3422
3423    /// `ct` has no precomposed Unicode codepoint, so `glyph_name_to_unicode`
3424    /// reports `None`; the encoding-map builder records the slot in the
3425    /// `ct_codes` side-table instead. Decoding emits one internal marker
3426    /// scalar for that code, then the ligature layer expands only markers
3427    /// whose origin came from `ct_codes`. This avoids clobbering legitimate
3428    /// U+E007 values arriving through ToUnicode CMaps.
3429    #[test]
3430    fn ligature_ct_via_glyph_name_emits_string() {
3431        assert_eq!(glyph_name_to_unicode("ct"), None);
3432        // End-to-end: byte 1 → /ct → "ct" in extracted text.
3433        let doc = make_doc_with_ligature_font(b"BT /F1 12 Tf <6101> Tj ET", "ct");
3434        let blocks = extract_text(&doc);
3435        assert_eq!(blocks.len(), 1);
3436        assert_eq!(blocks[0].text, "act");
3437    }
3438
3439    /// Regression guard for issue #1357: a ToUnicode CMap that legitimately
3440    /// maps a code to U+E007 must NOT be silently rewritten to "ct". The
3441    /// previous PUA-sentinel implementation clobbered every U+E007 it saw,
3442    /// regardless of origin.
3443    #[test]
3444    fn tounicode_pua_e007_is_preserved() {
3445        let doc = make_doc_with_tounicode_cmap(b"BT /F1 12 Tf <01> Tj ET", 0x01, 0xE007);
3446        let blocks = extract_text(&doc);
3447        assert_eq!(blocks.len(), 1);
3448        assert_eq!(blocks[0].text, "\u{E007}");
3449        assert_ne!(blocks[0].text, "ct");
3450    }
3451
3452    /// A `/ct` glyph must advance the text matrix as one glyph even though
3453    /// it expands to two extracted characters. Otherwise following glyphs in
3454    /// the same show operator drift right by one extra glyph width.
3455    #[test]
3456    fn ligature_ct_positioned_chars_advance_as_single_glyph() {
3457        // <610162>: a (0x61), ct (0x01 via /Differences), b (0x62)
3458        let doc = make_doc_with_ligature_font(b"BT /F1 12 Tf <610162> Tj ET", "ct");
3459        let chars = extract_positioned_chars(&doc, 1).unwrap();
3460        let extracted: String = chars.iter().map(|c| c.ch).collect();
3461        assert_eq!(extracted, "actb");
3462        assert_eq!(chars.len(), 4);
3463
3464        let char_w = 12.0 * APPROX_CHAR_WIDTH;
3465        assert!((chars[1].bbox[0] - char_w).abs() < 1e-6);
3466        assert!((chars[3].bbox[0] - (2.0 * char_w)).abs() < 1e-6);
3467    }
3468
3469    /// Build a one-page Document with a font whose /Encoding /Differences
3470    /// remaps byte 1 to the given ligature glyph name. Used by the golden
3471    /// "office" fixture below to drive ligature decomposition end-to-end
3472    /// through the text extractor.
3473    fn make_doc_with_ligature_font(content: &[u8], glyph_name: &str) -> Document {
3474        let mut doc = Document::with_version("1.7");
3475
3476        let content_stream = Stream::new(dictionary! {}, content.to_vec());
3477        let content_id = doc.add_object(Object::Stream(content_stream));
3478
3479        let encoding = dictionary! {
3480            "Type" => "Encoding",
3481            "BaseEncoding" => "WinAnsiEncoding",
3482            "Differences" => vec![
3483                1_i64.into(),
3484                Object::Name(glyph_name.as_bytes().to_vec()),
3485            ],
3486        };
3487        let encoding_id = doc.add_object(Object::Dictionary(encoding));
3488
3489        let font = dictionary! {
3490            "Type" => "Font",
3491            "Subtype" => "Type1",
3492            "BaseFont" => "Helvetica",
3493            "Encoding" => Object::Reference(encoding_id),
3494        };
3495        let font_id = doc.add_object(Object::Dictionary(font));
3496
3497        let resources = dictionary! {
3498            "Font" => dictionary! { "F1" => Object::Reference(font_id) },
3499        };
3500        let resources_id = doc.add_object(Object::Dictionary(resources));
3501
3502        let page_dict = dictionary! {
3503            "Type" => "Page",
3504            "MediaBox" => vec![0.into(), 0.into(), 612.into(), 792.into()],
3505            "Contents" => Object::Reference(content_id),
3506            "Resources" => Object::Reference(resources_id),
3507        };
3508        let page_id = doc.add_object(Object::Dictionary(page_dict));
3509
3510        let pages_dict = dictionary! {
3511            "Type" => "Pages",
3512            "Kids" => vec![Object::Reference(page_id)],
3513            "Count" => 1_i64,
3514        };
3515        let pages_id = doc.add_object(Object::Dictionary(pages_dict));
3516
3517        if let Ok(Object::Dictionary(ref mut d)) = doc.get_object_mut(page_id) {
3518            d.set("Parent", Object::Reference(pages_id));
3519        }
3520
3521        let catalog = dictionary! {
3522            "Type" => "Catalog",
3523            "Pages" => Object::Reference(pages_id),
3524        };
3525        let catalog_id = doc.add_object(Object::Dictionary(catalog));
3526        doc.trailer.set("Root", Object::Reference(catalog_id));
3527
3528        doc
3529    }
3530
3531    /// Golden fixture: byte sequence `o + ffi + c + e` rendered through a
3532    /// font whose /Differences remaps byte 1 → /ffi → U+FB03. End-to-end
3533    /// the extractor should produce "office", not "o\u{FB03}ce". This is
3534    /// the single test that exercises the full pipeline: glyph→unicode →
3535    /// encoding map → decode → ligature decomposition.
3536    #[test]
3537    fn ligature_office_golden_ffi() {
3538        // Hex string <6F016365>: o (0x6F), ffi (0x01), c (0x63), e (0x65)
3539        let doc = make_doc_with_ligature_font(b"BT /F1 12 Tf <6F016365> Tj ET", "ffi");
3540        let blocks = extract_text(&doc);
3541        assert_eq!(blocks.len(), 1);
3542        assert_eq!(blocks[0].text, "office");
3543    }
3544
3545    /// Build a one-page Document whose font has a ToUnicode CMap mapping a
3546    /// single 1-byte source code to an explicit Unicode scalar. Used to
3547    /// drive the regression guard for issue #1357: a ToUnicode-supplied
3548    /// codepoint must round-trip through the extractor unchanged.
3549    fn make_doc_with_tounicode_cmap(content: &[u8], src: u8, dst: u32) -> Document {
3550        let mut doc = Document::with_version("1.7");
3551
3552        let content_stream = Stream::new(dictionary! {}, content.to_vec());
3553        let content_id = doc.add_object(Object::Stream(content_stream));
3554
3555        // Minimal ToUnicode CMap with a single bfchar mapping.
3556        let cmap_text = format!(
3557            "/CIDInit /ProcSet findresource begin\n\
3558             12 dict begin\n\
3559             begincmap\n\
3560             /CMapType 2 def\n\
3561             1 beginbfchar\n\
3562             <{:02X}> <{:04X}>\n\
3563             endbfchar\n\
3564             endcmap CMapName currentdict /CMap defineresource pop end end",
3565            src, dst
3566        );
3567        let cmap_stream = Stream::new(dictionary! {}, cmap_text.into_bytes());
3568        let cmap_id = doc.add_object(Object::Stream(cmap_stream));
3569
3570        let font = dictionary! {
3571            "Type" => "Font",
3572            "Subtype" => "Type1",
3573            "BaseFont" => "Helvetica",
3574            "Encoding" => "WinAnsiEncoding",
3575            "ToUnicode" => Object::Reference(cmap_id),
3576        };
3577        let font_id = doc.add_object(Object::Dictionary(font));
3578
3579        let resources = dictionary! {
3580            "Font" => dictionary! { "F1" => Object::Reference(font_id) },
3581        };
3582        let resources_id = doc.add_object(Object::Dictionary(resources));
3583
3584        let page_dict = dictionary! {
3585            "Type" => "Page",
3586            "MediaBox" => vec![0.into(), 0.into(), 612.into(), 792.into()],
3587            "Contents" => Object::Reference(content_id),
3588            "Resources" => Object::Reference(resources_id),
3589        };
3590        let page_id = doc.add_object(Object::Dictionary(page_dict));
3591
3592        let pages_dict = dictionary! {
3593            "Type" => "Pages",
3594            "Kids" => vec![Object::Reference(page_id)],
3595            "Count" => 1_i64,
3596        };
3597        let pages_id = doc.add_object(Object::Dictionary(pages_dict));
3598
3599        if let Ok(Object::Dictionary(ref mut d)) = doc.get_object_mut(page_id) {
3600            d.set("Parent", Object::Reference(pages_id));
3601        }
3602
3603        let catalog = dictionary! {
3604            "Type" => "Catalog",
3605            "Pages" => Object::Reference(pages_id),
3606        };
3607        let catalog_id = doc.add_object(Object::Dictionary(catalog));
3608        doc.trailer.set("Root", Object::Reference(catalog_id));
3609
3610        doc
3611    }
3612
3613    /// PositionedChar: a ligature glyph splits its bbox evenly across the
3614    /// constituent characters so character offsets stay within the glyph's
3615    /// horizontal footprint. The matrix advances exactly once per glyph,
3616    /// not once per constituent — so layout is preserved.
3617    #[test]
3618    fn ligature_positioned_chars_split_bbox() {
3619        let doc = make_doc_with_ligature_font(b"BT /F1 12 Tf <01> Tj ET", "ffi");
3620        let chars = extract_positioned_chars(&doc, 1).unwrap();
3621        assert_eq!(chars.len(), 3, "ffi → 3 chars");
3622        assert_eq!(chars[0].ch, 'f');
3623        assert_eq!(chars[1].ch, 'f');
3624        assert_eq!(chars[2].ch, 'i');
3625        // Bboxes should tile horizontally.
3626        assert!(chars[0].bbox[2] <= chars[1].bbox[0] + 1e-9);
3627        assert!(chars[1].bbox[2] <= chars[2].bbox[0] + 1e-9);
3628        // And total width should equal one glyph's char_w.
3629        let total = chars[2].bbox[2] - chars[0].bbox[0];
3630        let expected = 12.0 * APPROX_CHAR_WIDTH;
3631        assert!((total - expected).abs() < 1e-6);
3632    }
3633
3634    // -- M4-FOLLOWUP-03: PositionedChar decomposition + Tj bbox geometry ---
3635
3636    /// Regression guard for issue #1359 (problem 1): ligatures outside the
3637    /// hardcoded Latin set must decompose in PositionedChar output the same
3638    /// way they decompose in extracted text. Previously
3639    /// `push_glyph_positioned` only ran the hardcoded Latin map while
3640    /// extracted text additionally applied an NFKD fallback over
3641    /// FB00..FB4F, leaving counts misaligned for Armenian/Hebrew/Arabic
3642    /// presentation forms.
3643    #[test]
3644    fn positioned_chars_match_extracted_text_for_armenian_ligature() {
3645        // U+FB13 (Armenian small ligature men now) NFKDs to U+0574 U+0576.
3646        // The font's /Differences slot 1 maps to glyph name uniFB13.
3647        let doc = make_doc_with_ligature_font(b"BT /F1 12 Tf <01> Tj ET", "uniFB13");
3648        let blocks = extract_text(&doc);
3649        let chars = extract_positioned_chars(&doc, 1).unwrap();
3650        assert_eq!(blocks.len(), 1);
3651        // Both paths must agree on the constituent count.
3652        assert_eq!(
3653            blocks[0].text.chars().count(),
3654            chars.len(),
3655            "PositionedChar count drifted from extract_text() char count \
3656             (issue #1359 problem 1): blocks[0].text = {:?}, chars = {:?}",
3657            blocks[0].text,
3658            chars.iter().map(|c| c.ch).collect::<String>(),
3659        );
3660        // And on the actual characters.
3661        let chars_str: String = chars.iter().map(|c| c.ch).collect();
3662        assert_eq!(blocks[0].text, chars_str);
3663        // Total bbox span equals one glyph's char_w (the matrix advanced once).
3664        let total = chars.last().unwrap().bbox[2] - chars.first().unwrap().bbox[0];
3665        let expected = 12.0 * APPROX_CHAR_WIDTH;
3666        assert!((total - expected).abs() < 1e-6);
3667    }
3668
3669    /// Hebrew presentation forms may NFKD-map to a single base Hebrew
3670    /// codepoint. Those mappings are still semantic decompositions and must
3671    /// not be dropped just because the output has one scalar.
3672    #[test]
3673    fn hebrew_presentation_form_extracts_single_codepoint_nfkd_mapping() {
3674        // U+FB21 (Hebrew letter wide alef) NFKDs to U+05D0.
3675        let doc = make_doc_with_ligature_font(b"BT /F1 12 Tf <01> Tj ET", "uniFB21");
3676        let blocks = extract_text(&doc);
3677        assert_eq!(blocks.len(), 1);
3678        assert_eq!(blocks[0].text, "\u{05D0}");
3679        assert_eq!(
3680            decompose_glyph_to_string('\u{FB21}').as_deref(),
3681            Some("\u{05D0}")
3682        );
3683    }
3684
3685    /// The positioned-character stream must expose the same text characters
3686    /// as extract_text for single-codepoint Hebrew presentation-form
3687    /// decompositions.
3688    #[test]
3689    fn positioned_chars_match_extracted_text_for_hebrew_presentation_form() {
3690        let doc = make_doc_with_ligature_font(b"BT /F1 12 Tf <01> Tj ET", "uniFB21");
3691        let blocks = extract_text(&doc);
3692        let chars = extract_positioned_chars(&doc, 1).unwrap();
3693        let chars_str: String = chars.iter().map(|c| c.ch).collect();
3694
3695        assert_eq!(blocks.len(), 1);
3696        assert_eq!(blocks[0].text, "\u{05D0}");
3697        assert_eq!(chars_str, blocks[0].text);
3698        assert_eq!(blocks[0].text.chars().count(), chars.len());
3699    }
3700
3701    /// Regression guard for issue #1359 (problem 2): a Tj operator that
3702    /// emits a single ligature glyph must produce a TextBlock whose bbox
3703    /// width equals one rendered glyph's footprint, not the byte length
3704    /// of the decomposed display string. Previously `display_text.len()`
3705    /// over-counted because ligature expansion grows the string while
3706    /// the text matrix advances exactly once per source glyph.
3707    #[test]
3708    fn tj_block_width_matches_rendered_glyph_count() {
3709        let doc = make_doc_with_ligature_font(b"BT /F1 12 Tf <01> Tj ET", "ffi");
3710        let blocks = extract_text(&doc);
3711        assert_eq!(blocks.len(), 1);
3712        assert_eq!(blocks[0].text, "ffi");
3713        let width = blocks[0].bbox[2] - blocks[0].bbox[0];
3714        let expected = 12.0 * APPROX_CHAR_WIDTH;
3715        assert!(
3716            (width - expected).abs() < 1e-6,
3717            "Tj bbox width {} != one glyph's char_w {} (issue #1359 problem 2)",
3718            width,
3719            expected,
3720        );
3721    }
3722
3723    /// Cross-operator consistency: a `Tj` block and a `TJ` block emitting
3724    /// the same single-glyph ligature must produce TextBlocks with equal
3725    /// bbox widths. Pre-fix, Tj used `display_text.len()` (3 for "ffi")
3726    /// and TJ used `x_end - x_start` (1 glyph advance) — so the same input
3727    /// rendered as ~3× wider in Tj than in TJ.
3728    #[test]
3729    fn tj_and_tj_array_bbox_widths_agree_for_ligature() {
3730        let tj_doc = make_doc_with_ligature_font(b"BT /F1 12 Tf <01> Tj ET", "ffi");
3731        let tj_blocks = extract_text(&tj_doc);
3732        let tj_doc_arr = make_doc_with_ligature_font(b"BT /F1 12 Tf [<01>] TJ ET", "ffi");
3733        let tj_arr_blocks = extract_text(&tj_doc_arr);
3734        assert_eq!(tj_blocks.len(), 1);
3735        assert_eq!(tj_arr_blocks.len(), 1);
3736        let tj_w = tj_blocks[0].bbox[2] - tj_blocks[0].bbox[0];
3737        let tj_arr_w = tj_arr_blocks[0].bbox[2] - tj_arr_blocks[0].bbox[0];
3738        assert!(
3739            (tj_w - tj_arr_w).abs() < 1e-6,
3740            "Tj bbox width {} disagrees with TJ bbox width {} for the same \
3741             single-glyph ligature input (issue #1359 problem 2)",
3742            tj_w,
3743            tj_arr_w,
3744        );
3745    }
3746
3747    // ---- G1: text-run metadata extraction tests ----
3748
3749    /// Build a doc with a single /F1 font resource carrying the given
3750    /// BaseFont and an optional FontDescriptor /Flags value.
3751    fn make_doc_with_font(content: &[u8], base_font: &str, desc_flags: Option<u32>) -> Document {
3752        let mut doc = Document::with_version("1.7");
3753
3754        let mut font_dict = dictionary! {
3755            "Type" => "Font",
3756            "Subtype" => "Type1",
3757            "BaseFont" => Object::Name(base_font.as_bytes().to_vec()),
3758        };
3759        if let Some(flags) = desc_flags {
3760            let descriptor = dictionary! {
3761                "Type" => "FontDescriptor",
3762                "FontName" => Object::Name(base_font.as_bytes().to_vec()),
3763                "Flags" => Object::Integer(flags as i64),
3764            };
3765            let desc_id = doc.add_object(Object::Dictionary(descriptor));
3766            font_dict.set("FontDescriptor", Object::Reference(desc_id));
3767        }
3768        let font_id = doc.add_object(Object::Dictionary(font_dict));
3769
3770        let resources = dictionary! {
3771            "Font" => dictionary! { "F1" => Object::Reference(font_id) },
3772        };
3773        let resources_id = doc.add_object(Object::Dictionary(resources));
3774
3775        let content_stream = Stream::new(dictionary! {}, content.to_vec());
3776        let content_id = doc.add_object(Object::Stream(content_stream));
3777
3778        let page_dict = dictionary! {
3779            "Type" => "Page",
3780            "MediaBox" => vec![0.into(), 0.into(), 612.into(), 792.into()],
3781            "Contents" => Object::Reference(content_id),
3782            "Resources" => Object::Reference(resources_id),
3783        };
3784        let page_id = doc.add_object(Object::Dictionary(page_dict));
3785
3786        let pages_dict = dictionary! {
3787            "Type" => "Pages",
3788            "Kids" => vec![Object::Reference(page_id)],
3789            "Count" => 1_i64,
3790        };
3791        let pages_id = doc.add_object(Object::Dictionary(pages_dict));
3792
3793        if let Ok(Object::Dictionary(ref mut d)) = doc.get_object_mut(page_id) {
3794            d.set("Parent", Object::Reference(pages_id));
3795        }
3796
3797        let catalog = dictionary! {
3798            "Type" => "Catalog",
3799            "Pages" => Object::Reference(pages_id),
3800        };
3801        let catalog_id = doc.add_object(Object::Dictionary(catalog));
3802        doc.trailer.set("Root", Object::Reference(catalog_id));
3803
3804        doc
3805    }
3806
3807    #[test]
3808    fn g1_tj_carries_basefont_metadata() {
3809        let doc = make_doc_with_font(b"BT /F1 12 Tf (Hello) Tj ET", "Helvetica", None);
3810        let blocks = extract_text(&doc);
3811        assert_eq!(blocks.len(), 1);
3812        assert_eq!(blocks[0].text, "Hello");
3813        assert_eq!(blocks[0].base_font.as_deref(), Some("Helvetica"));
3814        assert!(!blocks[0].is_bold);
3815        assert!(!blocks[0].is_italic);
3816    }
3817
3818    #[test]
3819    fn g1_tj_array_carries_basefont_metadata() {
3820        let doc = make_doc_with_font(b"BT /F1 12 Tf [(He) -100 (llo)] TJ ET", "Helvetica", None);
3821        let blocks = extract_text(&doc);
3822        assert_eq!(blocks.len(), 1);
3823        assert_eq!(blocks[0].text, "Hello");
3824        assert_eq!(blocks[0].base_font.as_deref(), Some("Helvetica"));
3825    }
3826
3827    #[test]
3828    fn g1_bold_inferred_from_basefont_name() {
3829        let doc = make_doc_with_font(b"BT /F1 12 Tf (Bold) Tj ET", "Helvetica-Bold", None);
3830        let blocks = extract_text(&doc);
3831        assert_eq!(blocks.len(), 1);
3832        assert!(blocks[0].is_bold);
3833        assert!(!blocks[0].is_italic);
3834        assert_eq!(blocks[0].base_font.as_deref(), Some("Helvetica-Bold"));
3835    }
3836
3837    #[test]
3838    fn g1_italic_inferred_from_basefont_name() {
3839        let doc = make_doc_with_font(b"BT /F1 12 Tf (Slanted) Tj ET", "Times-Italic", None);
3840        let blocks = extract_text(&doc);
3841        assert_eq!(blocks.len(), 1);
3842        assert!(blocks[0].is_italic);
3843        assert!(!blocks[0].is_bold);
3844    }
3845
3846    #[test]
3847    fn g1_subset_prefix_stripped_from_basefont() {
3848        let doc = make_doc_with_font(
3849            b"BT /F1 12 Tf (Subset) Tj ET",
3850            "ABCDEF+Helvetica-Bold",
3851            None,
3852        );
3853        let blocks = extract_text(&doc);
3854        assert_eq!(blocks.len(), 1);
3855        assert_eq!(blocks[0].base_font.as_deref(), Some("Helvetica-Bold"));
3856        assert!(blocks[0].is_bold);
3857    }
3858
3859    #[test]
3860    fn g1_italic_inferred_from_descriptor_flag() {
3861        // Flags bit 7 (1<<6 = 0x40) = Italic.
3862        let doc = make_doc_with_font(b"BT /F1 12 Tf (X) Tj ET", "Custom-Roman", Some(0x40));
3863        let blocks = extract_text(&doc);
3864        assert_eq!(blocks.len(), 1);
3865        assert!(blocks[0].is_italic);
3866        // Name has no "italic" hint; only the flag signals it.
3867        assert_eq!(blocks[0].base_font.as_deref(), Some("Custom-Roman"));
3868    }
3869
3870    #[test]
3871    fn g1_rg_color_propagates_to_block() {
3872        // 1 0 0 rg → solid red. Blocks emitted after the setter carry it.
3873        let doc = make_doc_with_font(b"1 0 0 rg BT /F1 12 Tf (Red) Tj ET", "Helvetica", None);
3874        let blocks = extract_text(&doc);
3875        assert_eq!(blocks.len(), 1);
3876        assert_eq!(blocks[0].color, Some([255, 0, 0, 255]));
3877    }
3878
3879    #[test]
3880    fn g1_gray_color_via_g_operator() {
3881        // 0.5 g → mid-gray broadcast to RGBA.
3882        let doc = make_doc_with_font(b"0.5 g BT /F1 12 Tf (Gray) Tj ET", "Helvetica", None);
3883        let blocks = extract_text(&doc);
3884        assert_eq!(blocks.len(), 1);
3885        let c = blocks[0].color.expect("expected gray color");
3886        // Round-trip tolerance: 0.5 * 255 ≈ 128.
3887        assert_eq!(c[3], 255);
3888        assert_eq!(c[0], c[1]);
3889        assert_eq!(c[1], c[2]);
3890        assert!((c[0] as i32 - 128).abs() <= 1);
3891    }
3892
3893    #[test]
3894    fn g1_default_color_is_black_for_compliant_initial_state() {
3895        // No color setter — PDF §8.6.5.3 initial fill = DeviceGray 0 = black.
3896        let doc = make_doc_with_font(b"BT /F1 12 Tf (Default) Tj ET", "Helvetica", None);
3897        let blocks = extract_text(&doc);
3898        assert_eq!(blocks.len(), 1);
3899        assert_eq!(blocks[0].color, Some([0, 0, 0, 255]));
3900    }
3901
3902    #[test]
3903    fn g1_cmyk_color_via_k_operator() {
3904        // Pure cyan: 1 0 0 0 k → (1-c)*(1-k)=0, (1-m)*(1-k)=1, (1-y)*(1-k)=1
3905        let doc = make_doc_with_font(b"1 0 0 0 k BT /F1 12 Tf (Cyan) Tj ET", "Helvetica", None);
3906        let blocks = extract_text(&doc);
3907        assert_eq!(blocks.len(), 1);
3908        let c = blocks[0].color.expect("k should set color");
3909        assert_eq!(c, [0, 255, 255, 255]);
3910    }
3911
3912    #[test]
3913    fn g1_tj_and_tj_array_emit_same_metadata() {
3914        // Identical font/color setup; differ only in show operator.
3915        let tj_doc =
3916            make_doc_with_font(b"1 0 0 rg BT /F1 12 Tf (Hi) Tj ET", "Helvetica-Bold", None);
3917        let tj_array_doc = make_doc_with_font(
3918            b"1 0 0 rg BT /F1 12 Tf [(Hi)] TJ ET",
3919            "Helvetica-Bold",
3920            None,
3921        );
3922        let a = extract_text(&tj_doc);
3923        let b = extract_text(&tj_array_doc);
3924        assert_eq!(a.len(), 1);
3925        assert_eq!(b.len(), 1);
3926        assert_eq!(a[0].base_font, b[0].base_font);
3927        assert_eq!(a[0].is_bold, b[0].is_bold);
3928        assert_eq!(a[0].is_italic, b[0].is_italic);
3929        assert_eq!(a[0].color, b[0].color);
3930    }
3931
3932    #[test]
3933    fn g1_color_restored_across_q_capital_q() {
3934        // Color set inside q/Q must not leak out.
3935        let doc = make_doc_with_font(
3936            b"q 1 0 0 rg Q BT /F1 12 Tf (Outside) Tj ET",
3937            "Helvetica",
3938            None,
3939        );
3940        let blocks = extract_text(&doc);
3941        assert_eq!(blocks.len(), 1);
3942        // Outside the saved scope, the initial black is what we see.
3943        assert_eq!(blocks[0].color, Some([0, 0, 0, 255]));
3944    }
3945
3946    #[test]
3947    fn g1_no_font_resource_means_no_base_font() {
3948        // Existing helper doesn't add a Font resource — confirms the
3949        // documented fallback path: base_font None, flags false.
3950        let doc = make_doc_with_text(b"BT /F1 12 Tf (NoFont) Tj ET");
3951        let blocks = extract_text(&doc);
3952        assert_eq!(blocks.len(), 1);
3953        assert_eq!(blocks[0].base_font, None);
3954        assert!(!blocks[0].is_bold);
3955        assert!(!blocks[0].is_italic);
3956        // Color still tracks: default initial black.
3957        assert_eq!(blocks[0].color, Some([0, 0, 0, 255]));
3958        // Legacy resource alias preserved.
3959        assert_eq!(blocks[0].font_name, "F1");
3960    }
3961
3962    #[test]
3963    fn g1_unknown_color_space_marks_color_none() {
3964        // /DeviceN cs followed by scn with 5 operands → can't safely map.
3965        let doc = make_doc_with_font(
3966            b"/Cs1 cs 0.5 0.5 0.5 0.5 0.5 scn BT /F1 12 Tf (X) Tj ET",
3967            "Helvetica",
3968            None,
3969        );
3970        let blocks = extract_text(&doc);
3971        assert_eq!(blocks.len(), 1);
3972        assert_eq!(blocks[0].color, None);
3973    }
3974
3975    // ---- G2: widths and char_bounds tests ----
3976
3977    /// Build a doc where font F1 has a /Widths array starting at FirstChar 65 ('A').
3978    fn make_doc_with_widths(content: &[u8], first_char: i64, widths: Vec<i64>) -> Document {
3979        let mut doc = Document::with_version("1.7");
3980
3981        let widths_obj = Object::Array(widths.iter().map(|w| Object::Integer(*w)).collect());
3982
3983        let font_dict = dictionary! {
3984            "Type" => "Font",
3985            "Subtype" => "Type1",
3986            "BaseFont" => Object::Name(b"TestFont".to_vec()),
3987            "FirstChar" => Object::Integer(first_char),
3988            "LastChar" => Object::Integer(first_char + widths.len() as i64 - 1),
3989            "Widths" => widths_obj,
3990        };
3991        let font_id = doc.add_object(Object::Dictionary(font_dict));
3992
3993        let resources = dictionary! {
3994            "Font" => dictionary! { "F1" => Object::Reference(font_id) },
3995        };
3996        let resources_id = doc.add_object(Object::Dictionary(resources));
3997
3998        let content_stream = Stream::new(dictionary! {}, content.to_vec());
3999        let content_id = doc.add_object(Object::Stream(content_stream));
4000
4001        let page_dict = dictionary! {
4002            "Type" => "Page",
4003            "MediaBox" => vec![0.into(), 0.into(), 612.into(), 792.into()],
4004            "Contents" => Object::Reference(content_id),
4005            "Resources" => Object::Reference(resources_id),
4006        };
4007        let page_id = doc.add_object(Object::Dictionary(page_dict));
4008
4009        let pages_dict = dictionary! {
4010            "Type" => "Pages",
4011            "Kids" => vec![Object::Reference(page_id)],
4012            "Count" => 1_i64,
4013        };
4014        let pages_id = doc.add_object(Object::Dictionary(pages_dict));
4015
4016        if let Ok(Object::Dictionary(ref mut d)) = doc.get_object_mut(page_id) {
4017            d.set("Parent", Object::Reference(pages_id));
4018        }
4019
4020        let catalog = dictionary! {
4021            "Type" => "Catalog",
4022            "Pages" => Object::Reference(pages_id),
4023        };
4024        let catalog_id = doc.add_object(Object::Dictionary(catalog));
4025        doc.trailer.set("Root", Object::Reference(catalog_id));
4026
4027        doc
4028    }
4029
4030    #[test]
4031    fn g2_no_widths_gives_estimate_source() {
4032        // Font dict with no /Widths → WidthSource::Estimate.
4033        let doc = make_doc_with_font(b"BT /F1 10 Tf (A) Tj ET", "Helvetica", None);
4034        let blocks = extract_text(&doc);
4035        assert_eq!(blocks.len(), 1);
4036        assert_eq!(blocks[0].width_source, WidthSource::Estimate);
4037    }
4038
4039    #[test]
4040    fn g2_widths_array_gives_metric_source() {
4041        // 'A' = 0x41 = 65; single-char string 'A', width 722 glyph units.
4042        // advance = 722/1000 * 10pt = 7.22 user units.
4043        let doc = make_doc_with_widths(b"BT /F1 10 Tf (A) Tj ET", 65, vec![722]);
4044        let blocks = extract_text(&doc);
4045        assert_eq!(blocks.len(), 1);
4046        assert_eq!(blocks[0].width_source, WidthSource::Metric);
4047        // char_bounds should have exactly one entry.
4048        assert_eq!(blocks[0].char_bounds.len(), 1);
4049        let [x0, _y0, x1, _y1] = blocks[0].char_bounds[0];
4050        // advance = 722/1000 * 10 = 7.22; x0 should be at origin (0).
4051        let advance = x1 - x0;
4052        assert!((advance - 7.22).abs() < 0.01, "advance={advance:.4}");
4053    }
4054
4055    #[test]
4056    fn g2_monospace_uniform_char_bounds() {
4057        // Courier-like: all glyphs width 600. Five chars → five equal-width bounds.
4058        // 'A'=65,'B'=66,'C'=67,'D'=68,'E'=69 — widths all 600.
4059        let widths: Vec<i64> = vec![600, 600, 600, 600, 600];
4060        let doc = make_doc_with_widths(b"BT /F1 12 Tf (ABCDE) Tj ET", 65, widths);
4061        let blocks = extract_text(&doc);
4062        assert_eq!(blocks.len(), 1);
4063        assert_eq!(blocks[0].width_source, WidthSource::Metric);
4064        assert_eq!(blocks[0].char_bounds.len(), 5);
4065        let expected_adv = 600.0 / 1000.0 * 12.0; // 7.2
4066        for i in 0..5 {
4067            let [x0, _, x1, _] = blocks[0].char_bounds[i];
4068            let adv = x1 - x0;
4069            assert!(
4070                (adv - expected_adv).abs() < 0.01,
4071                "char {i}: advance={adv:.4} expected={expected_adv:.4}"
4072            );
4073        }
4074    }
4075
4076    #[test]
4077    fn g2_proportional_char_bounds_are_contiguous() {
4078        // Proportional font: 'A'=722, 'B'=667, 'C'=667.
4079        let widths: Vec<i64> = vec![722, 667, 667];
4080        let doc = make_doc_with_widths(b"BT /F1 10 Tf (ABC) Tj ET", 65, widths);
4081        let blocks = extract_text(&doc);
4082        assert_eq!(blocks.len(), 1);
4083        assert_eq!(blocks[0].char_bounds.len(), 3);
4084        // Bounds must be contiguous: x1 of char[i] == x0 of char[i+1]
4085        // (tc=0, tw=0 default → no spacing gaps).
4086        for i in 0..2 {
4087            let x1_prev = blocks[0].char_bounds[i][2];
4088            let x0_next = blocks[0].char_bounds[i + 1][0];
4089            assert!(
4090                (x1_prev - x0_next).abs() < 0.001,
4091                "gap between char {i} and {}: {:.4}",
4092                i + 1,
4093                x0_next - x1_prev
4094            );
4095        }
4096    }
4097
4098    #[test]
4099    fn g2_tj_kerning_shifts_subsequent_bounds() {
4100        // TJ: [(A) -200 (B)] — 'A' then 200 units kern then 'B'.
4101        // 'A'=722, 'B'=667 (font size 10 → /1000).
4102        // After 'A': x = 722/1000*10 = 7.22.
4103        // Kerning: -200/1000*10 = +2.0 (negative kern shifts right: x -= -2 = x += 2).
4104        // Wait: TJ negative = move RIGHT. adj = -200 → x -= -200/1000*10 = x += 2.0.
4105        // 'B' starts at 7.22 + 2.0 = 9.22.
4106        let widths: Vec<i64> = vec![722, 667];
4107        let doc = make_doc_with_widths(b"BT /F1 10 Tf [(A) -200 (B)] TJ ET", 65, widths);
4108        let blocks = extract_text(&doc);
4109        assert_eq!(blocks.len(), 1);
4110        assert_eq!(blocks[0].char_bounds.len(), 2);
4111        let [x0_a, _, x1_a, _] = blocks[0].char_bounds[0];
4112        let [x0_b, _, _, _] = blocks[0].char_bounds[1];
4113        // 'A' advance
4114        assert!(
4115            (x1_a - x0_a - 7.22).abs() < 0.01,
4116            "A advance={:.4}",
4117            x1_a - x0_a
4118        );
4119        // Gap includes kerning: x0_b should be further right than x1_a.
4120        assert!(x0_b > x1_a, "B should start after A ends");
4121        let gap = x0_b - x1_a;
4122        assert!(
4123            (gap - 2.0).abs() < 0.01,
4124            "kerning gap={gap:.4} expected=2.0"
4125        );
4126    }
4127
4128    #[test]
4129    fn g2_subset_font_prefix_does_not_affect_widths() {
4130        // Subset prefix on BaseFont should not prevent width lookup.
4131        let widths: Vec<i64> = vec![722];
4132        let doc = make_doc_with_widths(b"BT /F1 10 Tf (A) Tj ET", 65, widths);
4133        let blocks = extract_text(&doc);
4134        assert_eq!(blocks.len(), 1);
4135        // With widths present, source is always Metric.
4136        assert_eq!(blocks[0].width_source, WidthSource::Metric);
4137        assert_eq!(blocks[0].char_bounds.len(), 1);
4138    }
4139
4140    #[test]
4141    fn g2_char_bounds_count_equals_glyph_count_not_unicode_len() {
4142        // A ligature encoded as a single byte but decomposed to 2 Unicode chars.
4143        // char_bounds must have ONE entry (source glyph), not two.
4144        // We rely on the APPROX path for this test (no /Widths → Estimate).
4145        let doc = make_doc_with_font(b"BT /F1 12 Tf (A) Tj ET", "TestFont", None);
4146        let blocks = extract_text(&doc);
4147        assert_eq!(blocks.len(), 1);
4148        // Single source byte → exactly one char_bound.
4149        assert_eq!(blocks[0].char_bounds.len(), 1);
4150    }
4151}