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