Skip to main content

pdf_text/
text.rs

1use std::collections::{BTreeMap, BTreeSet};
2
3use pdf_content::{Operation, ParsedPageContent, parse_content_stream, parse_page_contents};
4use pdf_graphics::{Matrix, Quad, Rect};
5use pdf_objects::{
6    ObjectRef, PageInfo, PdfDictionary, PdfError, PdfFile, PdfResult, PdfValue, decode_stream,
7    document::get_stream,
8};
9use serde::{Deserialize, Serialize};
10
11/// Maximum Form XObject recursion depth. Prevents pathological or adversarial
12/// PDFs from driving the interpreter into unbounded recursion.
13const MAX_FORM_XOBJECT_DEPTH: usize = 16;
14
15#[derive(Debug, Clone, Serialize, Deserialize)]
16pub struct TextItem {
17    pub text: String,
18    pub bbox: Rect,
19    pub quad: Option<Quad>,
20    pub char_start: Option<usize>,
21    pub char_end: Option<usize>,
22}
23
24#[derive(Debug, Clone, Serialize, Deserialize)]
25pub struct TextMatch {
26    pub text: String,
27    pub page_index: usize,
28    pub quads: Vec<Quad>,
29}
30
31#[derive(Debug, Clone)]
32pub enum GlyphLocation {
33    Direct {
34        operand_index: usize,
35        byte_start: usize,
36        byte_end: usize,
37    },
38    Array {
39        operand_index: usize,
40        element_index: usize,
41        byte_start: usize,
42        byte_end: usize,
43    },
44}
45
46#[derive(Debug, Clone)]
47pub struct Glyph {
48    pub text: char,
49    pub bbox: Rect,
50    pub quad: Quad,
51    pub page_char_index: usize,
52    pub operation_index: usize,
53    pub location: GlyphLocation,
54    /// False when the glyph was rendered with Tr=3 (invisible mode).
55    /// Invisible glyphs are still included for redaction but excluded from
56    /// search results and extracted text items.
57    pub visible: bool,
58    /// Raw advance width in 1/1000 em font units. Used by kern-compensating
59    /// redaction modes to preserve text positioning after byte removal.
60    pub width_units: f64,
61    /// When `Some`, the glyph originated inside a Form XObject invoked by
62    /// `Do` on the page's content stream. `operation_index` and `location`
63    /// then refer to positions inside that Form's content stream, not the
64    /// page's — callers that want to rewrite the bytes must look up the
65    /// corresponding Form object, decode its stream, and mutate the
66    /// operations of the *Form*, not the page.
67    pub source_form: Option<ObjectRef>,
68}
69
70#[derive(Debug, Clone)]
71pub struct ExtractedPageText {
72    pub page_index: usize,
73    pub text: String,
74    pub items: Vec<TextItem>,
75    pub glyphs: Vec<Glyph>,
76}
77
78#[derive(Debug, Clone)]
79pub struct PageSearchIndex {
80    normalized_text: String,
81    normalized_to_display: Vec<usize>,
82    display_chars: Vec<char>,
83    display_to_glyph: Vec<Option<usize>>,
84}
85
86pub fn analyze_page_text(
87    file: &PdfFile,
88    page_index: usize,
89    page: &PageInfo,
90) -> PdfResult<ExtractedPageText> {
91    let parsed = parse_page_contents(file, page)?;
92    let (fonts, extgstate_fonts) = load_fonts(file, &page.resources)?;
93    interpret_page_text(file, page_index, page, &parsed, &fonts, &extgstate_fonts)
94}
95
96pub fn search_page_text(page: &ExtractedPageText, query: &str) -> Vec<TextMatch> {
97    if query.is_empty() {
98        return Vec::new();
99    }
100    let index = build_search_index(page);
101    let normalized_query = normalize_search_text(query);
102    if normalized_query.is_empty() {
103        return Vec::new();
104    }
105
106    let mut matches = Vec::new();
107    let mut search_offset = 0usize;
108    while let Some(position) = index.normalized_text[search_offset..].find(&normalized_query) {
109        let normalized_start = search_offset + position;
110        let normalized_end = normalized_start + normalized_query.len();
111        let display_start = *index
112            .normalized_to_display
113            .get(normalized_start)
114            .unwrap_or(&0);
115        let display_end = index
116            .normalized_to_display
117            .get(normalized_end.saturating_sub(1))
118            .copied()
119            .unwrap_or(display_start)
120            + 1;
121        let glyph_indices = (display_start..display_end)
122            .filter_map(|display_index| index.normalized_to_glyph(display_index))
123            .collect::<BTreeSet<_>>()
124            .into_iter()
125            .collect::<Vec<_>>();
126        let quads = coalesce_match_quads(
127            &glyph_indices
128                .iter()
129                .filter_map(|glyph_index| page.glyphs.get(*glyph_index))
130                .map(|glyph| glyph.quad)
131                .collect::<Vec<_>>(),
132        );
133        if !quads.is_empty() {
134            matches.push(TextMatch {
135                text: index
136                    .display_chars
137                    .iter()
138                    .skip(display_start)
139                    .take(display_end.saturating_sub(display_start))
140                    .collect::<String>()
141                    .trim()
142                    .to_string(),
143                page_index: page.page_index,
144                quads,
145            });
146        }
147        search_offset = normalized_end;
148        if search_offset >= index.normalized_text.len() {
149            break;
150        }
151    }
152    matches
153}
154
155fn coalesce_match_quads(quads: &[Quad]) -> Vec<Quad> {
156    let mut rects = quads
157        .iter()
158        .map(|quad| quad.bounding_rect())
159        .collect::<Vec<_>>();
160    rects.sort_by(|left, right| {
161        let y_delta = (left.y - right.y).abs();
162        if y_delta > 1.5 {
163            right
164                .y
165                .partial_cmp(&left.y)
166                .unwrap_or(std::cmp::Ordering::Equal)
167        } else {
168            left.x
169                .partial_cmp(&right.x)
170                .unwrap_or(std::cmp::Ordering::Equal)
171        }
172    });
173
174    let mut merged = Vec::<Rect>::new();
175    for rect in rects {
176        let Some(previous) = merged.last_mut() else {
177            merged.push(rect);
178            continue;
179        };
180        if should_merge_match_rects(*previous, rect) {
181            let next_x = previous.x.min(rect.x);
182            let next_y = previous.y.min(rect.y);
183            let next_max_x = previous.max_x().max(rect.max_x());
184            let next_max_y = previous.max_y().max(rect.max_y());
185            *previous = Rect {
186                x: next_x,
187                y: next_y,
188                width: next_max_x - next_x,
189                height: next_max_y - next_y,
190            };
191        } else {
192            merged.push(rect);
193        }
194    }
195
196    merged
197        .into_iter()
198        .map(expand_match_rect)
199        .map(Rect::to_quad)
200        .collect()
201}
202
203fn should_merge_match_rects(left: Rect, right: Rect) -> bool {
204    let vertical_overlap = left.max_y().min(right.max_y()) - left.y.max(right.y);
205    let minimum_height = left.height.min(right.height).max(1.0);
206    let horizontal_gap = right.x - left.max_x();
207    vertical_overlap >= minimum_height * 0.45 && horizontal_gap <= minimum_height * 0.8
208}
209
210fn expand_match_rect(rect: Rect) -> Rect {
211    // Return the rect as-is without padding. Padding for visual highlighting
212    // should be applied at the UI layer. Using padded geometry for redaction
213    // causes over-selection in densely-packed CJK text.
214    rect
215}
216
217fn build_search_index(page: &ExtractedPageText) -> PageSearchIndex {
218    let (display_chars, display_to_glyph) = build_visual_display(page);
219    let mut normalized_text = String::new();
220    let mut normalized_to_display = Vec::new();
221    let mut previous_was_whitespace = false;
222    for (display_index, character) in display_chars.iter().copied().enumerate() {
223        if character.is_whitespace() {
224            if !previous_was_whitespace {
225                normalized_text.push(' ');
226                // One entry per UTF-8 byte so byte offsets from str::find() map correctly
227                for _ in 0..' '.len_utf8() {
228                    normalized_to_display.push(display_index);
229                }
230                previous_was_whitespace = true;
231            }
232        } else {
233            for folded in character.to_lowercase() {
234                normalized_text.push(folded);
235                for _ in 0..folded.len_utf8() {
236                    normalized_to_display.push(display_index);
237                }
238            }
239            previous_was_whitespace = false;
240        }
241    }
242    PageSearchIndex {
243        normalized_text,
244        normalized_to_display,
245        display_chars,
246        display_to_glyph,
247    }
248}
249
250impl PageSearchIndex {
251    fn normalized_to_glyph(&self, display_index: usize) -> Option<usize> {
252        self.display_to_glyph.get(display_index).copied().flatten()
253    }
254}
255
256fn build_visual_display(page: &ExtractedPageText) -> (Vec<char>, Vec<Option<usize>>) {
257    let mut lines = build_visual_lines(page);
258    for line in &mut lines {
259        line.sort_by(|left, right| {
260            page.glyphs[*left]
261                .bbox
262                .x
263                .partial_cmp(&page.glyphs[*right].bbox.x)
264                .unwrap_or(std::cmp::Ordering::Equal)
265        });
266    }
267    lines.sort_by(|left, right| {
268        let left_y = average_line_center_y(page, left);
269        let right_y = average_line_center_y(page, right);
270        right_y
271            .partial_cmp(&left_y)
272            .unwrap_or(std::cmp::Ordering::Equal)
273    });
274
275    let mut display_chars = Vec::new();
276    let mut display_to_glyph = Vec::new();
277    for (line_index, line) in lines.iter().enumerate() {
278        if line_index > 0 {
279            display_chars.push('\n');
280            display_to_glyph.push(None);
281        }
282        let mut previous_rect: Option<Rect> = None;
283        for glyph_index in line {
284            let glyph = &page.glyphs[*glyph_index];
285            if let Some(previous) = previous_rect {
286                let gap = glyph.bbox.x - previous.max_x();
287                let threshold = previous.height.min(glyph.bbox.height).max(1.0) * 0.3;
288                if gap > threshold {
289                    display_chars.push(' ');
290                    display_to_glyph.push(None);
291                }
292            }
293            display_chars.push(glyph.text);
294            display_to_glyph.push(Some(*glyph_index));
295            previous_rect = Some(glyph.bbox);
296        }
297    }
298    (display_chars, display_to_glyph)
299}
300
301/// Per-line aggregates maintained during the greedy grouping pass.
302/// `anchor_y` is the y-centre of the first glyph placed on the line
303/// and never changes — using a fixed reference instead of a running
304/// mean prevents accumulated drift letting near-boundary glyphs from
305/// adjacent rows creep in. `height_ref` is the maximum glyph bbox
306/// height seen so far; updates are O(1) on each join.
307struct LineCluster {
308    indices: Vec<usize>,
309    anchor_y: f64,
310    height_ref: f64,
311}
312
313fn build_visual_lines(page: &ExtractedPageText) -> Vec<Vec<usize>> {
314    let mut indices = (0..page.glyphs.len())
315        .filter(|i| page.glyphs[*i].visible)
316        .collect::<Vec<_>>();
317    // Deterministic sort: primary y descending, secondary x ascending,
318    // with no threshold-based bucketing. A tolerant bucket (e.g. 1.5 pt)
319    // produced an intransitive comparator — two close-but-distinct lines
320    // would interleave in the sorted output, scrambling their characters
321    // once per-line x-sorting ran. Line grouping itself is handled by
322    // the anchor-based check below, which tolerates intra-line y jitter
323    // from subscripts or baseline adjustments via the proportional
324    // `height_ref * 0.10` window.
325    indices.sort_by(|left, right| {
326        let left_center = glyph_center_y(&page.glyphs[*left]);
327        let right_center = glyph_center_y(&page.glyphs[*right]);
328        right_center
329            .partial_cmp(&left_center)
330            .unwrap_or(std::cmp::Ordering::Equal)
331            .then_with(|| {
332                page.glyphs[*left]
333                    .bbox
334                    .x
335                    .partial_cmp(&page.glyphs[*right].bbox.x)
336                    .unwrap_or(std::cmp::Ordering::Equal)
337            })
338    });
339
340    let mut clusters: Vec<LineCluster> = Vec::new();
341    for glyph_index in indices {
342        let glyph = &page.glyphs[glyph_index];
343        let glyph_center = glyph_center_y(glyph);
344        let glyph_height = glyph.bbox.height;
345        let glyph_x = glyph.bbox.x;
346
347        let target = clusters.iter_mut().find(|cluster| {
348            let last_x = cluster.indices.last().map(|i| page.glyphs[*i].bbox.x);
349            glyph_fits_line(cluster, glyph_center, glyph_height, glyph_x, last_x)
350        });
351
352        match target {
353            Some(cluster) => {
354                cluster.indices.push(glyph_index);
355                if glyph_height > cluster.height_ref {
356                    cluster.height_ref = glyph_height;
357                }
358            }
359            None => clusters.push(LineCluster {
360                indices: vec![glyph_index],
361                anchor_y: glyph_center,
362                // Floor at 1pt to keep the tolerance non-degenerate when
363                // the first glyph happens to be zero-height (defensive
364                // only; real glyphs always have positive bbox height).
365                height_ref: glyph_height.max(1.0),
366            }),
367        }
368    }
369    clusters.into_iter().map(|c| c.indices).collect()
370}
371
372/// Anchor-based line membership check. A glyph fits the line iff its
373/// y-centre is within `height_ref * 0.10` of the line's fixed anchor,
374/// AND its bbox.x does not fall meaningfully behind the most-recently
375/// placed glyph's bbox.x (the x-monotonicity guard, unchanged from the
376/// pre-existing logic). The `1e-6` epsilon keeps rows exactly at the
377/// boundary distance on the split side.
378fn glyph_fits_line(
379    cluster: &LineCluster,
380    glyph_center: f64,
381    glyph_height: f64,
382    glyph_bbox_x: f64,
383    last_bbox_x: Option<f64>,
384) -> bool {
385    let new_height_ref = cluster.height_ref.max(glyph_height);
386    let y_tolerance = new_height_ref * 0.10;
387    if (glyph_center - cluster.anchor_y).abs() + 1e-6 >= y_tolerance {
388        return false;
389    }
390    // X-monotonicity guard. A row of text has glyphs whose starting
391    // x coordinates ascend monotonically; a new glyph whose start x
392    // sits meaningfully before the last-encountered glyph's start x
393    // is almost certainly on a different visual row that happens to
394    // be only a point or two apart in y. Use `bbox.x` of each glyph
395    // (not `bbox.max_x()`) because aggressive TJ-array kerning in
396    // compressed text blocks makes successive glyph bboxes overlap
397    // heavily; the start-x of the most-recently appended glyph is a
398    // reliable "line watermark" thanks to the (y-desc, x-asc) feeder
399    // sort.
400    if let Some(last_x) = last_bbox_x {
401        let kerning_slack = 1.0;
402        if glyph_bbox_x + kerning_slack < last_x {
403            return false;
404        }
405    }
406    true
407}
408
409fn average_line_center_y(page: &ExtractedPageText, line: &[usize]) -> f64 {
410    let total = line
411        .iter()
412        .map(|glyph_index| glyph_center_y(&page.glyphs[*glyph_index]))
413        .sum::<f64>();
414    total / line.len().max(1) as f64
415}
416
417fn glyph_center_y(glyph: &Glyph) -> f64 {
418    glyph.bbox.y + glyph.bbox.height / 2.0
419}
420
421fn normalize_search_text(input: &str) -> String {
422    let mut output = String::new();
423    let mut previous_was_whitespace = false;
424    for character in input.chars() {
425        if character.is_whitespace() {
426            if !previous_was_whitespace {
427                output.push(' ');
428                previous_was_whitespace = true;
429            }
430        } else {
431            for folded in character.to_lowercase() {
432                output.push(folded);
433            }
434            previous_was_whitespace = false;
435        }
436    }
437    output.trim().to_string()
438}
439
440fn interpret_page_text(
441    file: &PdfFile,
442    page_index: usize,
443    page: &PageInfo,
444    parsed: &ParsedPageContent,
445    fonts: &BTreeMap<String, LoadedFont>,
446    extgstate_fonts: &ExtGStateFontMap,
447) -> PdfResult<ExtractedPageText> {
448    let page_transform = page.page_box.normalized_transform();
449    let mut context = TextContext::new(page_index);
450    let mut ctm = Matrix::identity();
451    let mut ctm_stack: Vec<(Matrix, RuntimeTextState)> = Vec::new();
452    let mut text_state = RuntimeTextState::default();
453    let mut xobject_stack: BTreeSet<ObjectRef> = BTreeSet::new();
454
455    run_operations(
456        file,
457        &parsed.operations,
458        fonts,
459        extgstate_fonts,
460        &page.resources,
461        page_transform,
462        &mut ctm,
463        &mut ctm_stack,
464        &mut text_state,
465        &mut context,
466        &mut xobject_stack,
467        0,
468    )?;
469
470    Ok(ExtractedPageText {
471        page_index,
472        text: context.text,
473        items: context.items,
474        glyphs: context.glyphs,
475    })
476}
477
478#[allow(clippy::too_many_arguments)]
479fn run_operations(
480    file: &PdfFile,
481    operations: &[Operation],
482    fonts: &BTreeMap<String, LoadedFont>,
483    extgstate_fonts: &ExtGStateFontMap,
484    resources: &PdfDictionary,
485    page_transform: Matrix,
486    ctm: &mut Matrix,
487    ctm_stack: &mut Vec<(Matrix, RuntimeTextState)>,
488    text_state: &mut RuntimeTextState,
489    context: &mut TextContext,
490    xobject_stack: &mut BTreeSet<ObjectRef>,
491    depth: usize,
492) -> PdfResult<()> {
493    for (operation_index, operation) in operations.iter().enumerate() {
494        match operation.operator.as_str() {
495            "q" => ctm_stack.push((*ctm, text_state.clone())),
496            "Q" => {
497                let (saved_ctm, saved_text_state) = ctm_stack
498                    .pop()
499                    .unwrap_or((Matrix::identity(), RuntimeTextState::default()));
500                *ctm = saved_ctm;
501                *text_state = saved_text_state;
502            }
503            "gs" => {
504                let gs_name = operand_name(operation, 0)?;
505                if let Some((font_key, font_size)) = extgstate_fonts.get(gs_name) {
506                    text_state.font = Some(font_key.clone());
507                    text_state.font_size = *font_size;
508                }
509            }
510            "cm" => {
511                let matrix = matrix_from_operands(&operation.operands)?;
512                *ctm = matrix.multiply(*ctm);
513            }
514            "BT" => {
515                text_state.text_matrix = Matrix::identity();
516                text_state.line_matrix = Matrix::identity();
517            }
518            "ET" => {}
519            "Tf" => {
520                let resource_name = operand_name(operation, 0)?;
521                text_state.font = Some(resource_name.to_string());
522                text_state.font_size = operand_number(operation, 1)?;
523            }
524            "Tm" => {
525                text_state.text_matrix = matrix_from_operands(&operation.operands)?;
526                text_state.line_matrix = text_state.text_matrix;
527            }
528            "Td" => {
529                let tx = operand_number(operation, 0)?;
530                let ty = operand_number(operation, 1)?;
531                // Text/line matrix updates compose a text-space translation
532                // BEFORE the current matrix (row-vector: `translate * Tm`),
533                // not after. With scaled text matrices (Tm like 9.5 0 0 9.5 x y)
534                // the previous `Tm * translate` form added tx/ty directly to
535                // page coordinates instead of applying them in text space.
536                text_state.line_matrix = Matrix::translate(tx, ty).multiply(text_state.line_matrix);
537                text_state.text_matrix = text_state.line_matrix;
538                if ty.abs() > f64::EPSILON {
539                    context.pending_line_break = true;
540                }
541            }
542            "TD" => {
543                let tx = operand_number(operation, 0)?;
544                let ty = operand_number(operation, 1)?;
545                text_state.leading = -ty;
546                text_state.line_matrix = Matrix::translate(tx, ty).multiply(text_state.line_matrix);
547                text_state.text_matrix = text_state.line_matrix;
548                context.pending_line_break = true;
549            }
550            "T*" => {
551                text_state.line_matrix =
552                    Matrix::translate(0.0, -text_state.leading).multiply(text_state.line_matrix);
553                text_state.text_matrix = text_state.line_matrix;
554                context.pending_line_break = true;
555            }
556            "Tc" => text_state.character_spacing = operand_number(operation, 0)?,
557            "Tw" => text_state.word_spacing = operand_number(operation, 0)?,
558            "TL" => text_state.leading = operand_number(operation, 0)?,
559            "Tr" => {
560                text_state.text_render_mode = operation
561                    .operands
562                    .first()
563                    .and_then(PdfValue::as_integer)
564                    .unwrap_or(0);
565            }
566            "Ts" => text_state.text_rise = operand_number(operation, 0)?,
567            "Tz" => text_state.horizontal_scaling = operand_number(operation, 0)?,
568            "Tj" => {
569                let string = operand_string(operation, 0)?;
570                show_text(
571                    context,
572                    operation_index,
573                    ShowOperand::Direct { operand_index: 0 },
574                    string,
575                    text_state,
576                    fonts,
577                    *ctm,
578                    page_transform,
579                )?;
580            }
581            "'" => {
582                text_state.line_matrix =
583                    Matrix::translate(0.0, -text_state.leading).multiply(text_state.line_matrix);
584                text_state.text_matrix = text_state.line_matrix;
585                context.pending_line_break = true;
586                let string = operand_string(operation, 0)?;
587                show_text(
588                    context,
589                    operation_index,
590                    ShowOperand::Direct { operand_index: 0 },
591                    string,
592                    text_state,
593                    fonts,
594                    *ctm,
595                    page_transform,
596                )?;
597            }
598            "\"" => {
599                text_state.word_spacing = operand_number(operation, 0)?;
600                text_state.character_spacing = operand_number(operation, 1)?;
601                text_state.line_matrix =
602                    Matrix::translate(0.0, -text_state.leading).multiply(text_state.line_matrix);
603                text_state.text_matrix = text_state.line_matrix;
604                context.pending_line_break = true;
605                let string = operand_string(operation, 2)?;
606                show_text(
607                    context,
608                    operation_index,
609                    ShowOperand::Direct { operand_index: 2 },
610                    string,
611                    text_state,
612                    fonts,
613                    *ctm,
614                    page_transform,
615                )?;
616            }
617            "TJ" => {
618                let segments = operation
619                    .operands
620                    .first()
621                    .and_then(PdfValue::as_array)
622                    .ok_or_else(|| PdfError::Corrupt("TJ expects an array operand".to_string()))?;
623                for (element_index, segment) in segments.iter().enumerate() {
624                    match segment {
625                        PdfValue::String(string) => show_text(
626                            context,
627                            operation_index,
628                            ShowOperand::Array {
629                                operand_index: 0,
630                                element_index,
631                            },
632                            string,
633                            text_state,
634                            fonts,
635                            *ctm,
636                            page_transform,
637                        )?,
638                        value => {
639                            let adjustment = value.as_number().ok_or_else(|| {
640                                PdfError::Corrupt("TJ array contains unsupported value".to_string())
641                            })?;
642                            let scaled = -(adjustment / 1000.0)
643                                * text_state.font_size
644                                * (text_state.horizontal_scaling / 100.0);
645                            text_state.text_matrix =
646                                Matrix::translate(scaled, 0.0).multiply(text_state.text_matrix);
647                        }
648                    }
649                }
650            }
651            "Do" => {
652                let name = operand_name(operation, 0)?;
653                enter_form_xobject(
654                    file,
655                    name,
656                    fonts,
657                    extgstate_fonts,
658                    resources,
659                    page_transform,
660                    ctm,
661                    ctm_stack,
662                    text_state,
663                    context,
664                    xobject_stack,
665                    depth,
666                )?;
667            }
668            _ => {}
669        }
670    }
671    Ok(())
672}
673
674#[allow(clippy::too_many_arguments)]
675fn enter_form_xobject(
676    file: &PdfFile,
677    name: &str,
678    outer_fonts: &BTreeMap<String, LoadedFont>,
679    outer_extgstate: &ExtGStateFontMap,
680    outer_resources: &PdfDictionary,
681    page_transform: Matrix,
682    ctm: &mut Matrix,
683    ctm_stack: &mut Vec<(Matrix, RuntimeTextState)>,
684    text_state: &mut RuntimeTextState,
685    context: &mut TextContext,
686    xobject_stack: &mut BTreeSet<ObjectRef>,
687    depth: usize,
688) -> PdfResult<()> {
689    if depth + 1 > MAX_FORM_XOBJECT_DEPTH {
690        return Ok(());
691    }
692    let Some(xobject_ref) = lookup_xobject_ref(outer_resources, name) else {
693        return Ok(());
694    };
695    if !xobject_stack.insert(xobject_ref) {
696        // Cycle detected — bail out of this branch without an error.
697        return Ok(());
698    }
699
700    let result = (|| -> PdfResult<()> {
701        let stream = get_stream(file, xobject_ref)?;
702        if stream.dict.get("Subtype").and_then(PdfValue::as_name) != Some("Form") {
703            // Image XObjects (or unknown subtypes) carry no text — skip silently.
704            return Ok(());
705        }
706
707        let form_matrix = stream
708            .dict
709            .get("Matrix")
710            .and_then(PdfValue::as_array)
711            .map(matrix_from_pdf_values)
712            .transpose()?
713            .unwrap_or_else(Matrix::identity);
714
715        let form_resources_owned: PdfDictionary = stream
716            .dict
717            .get("Resources")
718            .map(|value| file.resolve_dict(value).cloned())
719            .transpose()?
720            .unwrap_or_else(|| outer_resources.clone());
721
722        let (form_fonts, form_extgstate) = load_fonts(file, &form_resources_owned)?;
723        // Inherit from the caller's maps when the Form's own Resources did not
724        // declare a given resource name. This matches PDF 32000-1 § 7.8.3: the
725        // Form's Resources are preferred, but the parent scope is a fall-back
726        // when the Form omits an entry.
727        let mut effective_fonts: BTreeMap<String, LoadedFont> = outer_fonts.clone();
728        for (key, value) in form_fonts {
729            effective_fonts.insert(key, value);
730        }
731        let mut effective_extgstate: ExtGStateFontMap = outer_extgstate.clone();
732        for (key, value) in form_extgstate {
733            effective_extgstate.insert(key, value);
734        }
735
736        let decoded = decode_stream(stream)?;
737        let form_operations = parse_content_stream(&decoded)?.operations;
738
739        // Form invocation is bracketed by an implicit q/Q. Save CTM and
740        // text_state, pre-multiply the Form's /Matrix into the CTM, and
741        // restore both on exit. The current_form marker on the context lets
742        // callers (redact) tell the difference between glyphs produced by
743        // the page's own content stream and glyphs produced inside this
744        // Form — their operation_index and location refer to different
745        // byte streams.
746        let saved_ctm = *ctm;
747        let saved_text_state = text_state.clone();
748        let saved_form = context.current_form;
749        *ctm = form_matrix.multiply(saved_ctm);
750        context.current_form = Some(xobject_ref);
751
752        run_operations(
753            file,
754            &form_operations,
755            &effective_fonts,
756            &effective_extgstate,
757            &form_resources_owned,
758            page_transform,
759            ctm,
760            ctm_stack,
761            text_state,
762            context,
763            xobject_stack,
764            depth + 1,
765        )?;
766
767        *ctm = saved_ctm;
768        *text_state = saved_text_state;
769        context.current_form = saved_form;
770        Ok(())
771    })();
772
773    xobject_stack.remove(&xobject_ref);
774    result
775}
776
777fn lookup_xobject_ref(resources: &PdfDictionary, name: &str) -> Option<ObjectRef> {
778    let xobjects = match resources.get("XObject")? {
779        PdfValue::Dictionary(dict) => dict,
780        _ => return None,
781    };
782    match xobjects.get(name)? {
783        PdfValue::Reference(object_ref) => Some(*object_ref),
784        _ => None,
785    }
786}
787
788fn matrix_from_pdf_values(values: &[PdfValue]) -> PdfResult<Matrix> {
789    if values.len() != 6 {
790        return Err(PdfError::Corrupt(
791            "Matrix array must have six numeric entries".to_string(),
792        ));
793    }
794    let mut numbers = [0.0; 6];
795    for (slot, value) in numbers.iter_mut().zip(values.iter()) {
796        *slot = value
797            .as_number()
798            .ok_or_else(|| PdfError::Corrupt("Matrix entry is not a number".to_string()))?;
799    }
800    Ok(Matrix {
801        a: numbers[0],
802        b: numbers[1],
803        c: numbers[2],
804        d: numbers[3],
805        e: numbers[4],
806        f: numbers[5],
807    })
808}
809
810#[derive(Debug, Clone, Copy)]
811enum ShowOperand {
812    Direct {
813        operand_index: usize,
814    },
815    Array {
816        operand_index: usize,
817        element_index: usize,
818    },
819}
820
821#[derive(Debug, Clone)]
822struct SimpleFont {
823    widths: Vec<f64>,
824    first_char: u16,
825    unicode_map: BTreeMap<u16, String>,
826    encoding: SimpleEncoding,
827}
828
829#[derive(Debug, Clone, Default, PartialEq)]
830struct SimpleEncoding {
831    /// Base named encoding. When unset the fallback is the identity path
832    /// (ASCII only).
833    base: SimpleEncodingBase,
834    /// Byte-level overrides from an `/Encoding` dictionary's `/Differences`
835    /// array. Keys are byte codes; values are PDF glyph names (e.g. `"Adieresis"`).
836    differences: BTreeMap<u8, String>,
837}
838
839#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
840enum SimpleEncodingBase {
841    /// Fallback: treat bytes as identity if they map to ASCII, otherwise
842    /// emit the Unicode replacement character.
843    #[default]
844    Identity,
845    /// PDF `WinAnsiEncoding` — a superset of ISO-8859-1 with Windows-1252
846    /// punctuation and symbols in the `0x80..=0x9F` range.
847    WinAnsi,
848    /// PDF `MacRomanEncoding` — Apple's 8-bit Roman encoding used by
849    /// PostScript Type1 fonts that were generated on classic Mac systems.
850    /// Codes `0x20..=0x7E` are ASCII; `0x80..=0xFF` carry the Mac Roman
851    /// punctuation and accented-letter repertoire (e.g. `0xD2` = U+201C).
852    MacRoman,
853    /// PDF `StandardEncoding` — Adobe's original 8-bit PostScript encoding.
854    /// Matches ASCII through `0x7E` except for a couple of historical
855    /// punctuation choices (`0x27` = `quoteright` = U+2019, `0x60` =
856    /// `quoteleft` = U+2018) and continues with a sparse high-bit table of
857    /// Latin-1 supplement and ligature glyphs.
858    Standard,
859}
860
861#[derive(Debug, Clone, Copy, PartialEq, Eq)]
862enum CompositeEncoding {
863    /// `Identity-H`: the two raw bytes ARE the CID; Unicode comes from
864    /// the `ToUnicode` CMap (or a CID ≤ 0x7F ASCII fallback).
865    IdentityH,
866    /// Adobe predefined `*-UCS2-H` CMaps (`UniGB-UCS2-H`, `UniKS-UCS2-H`):
867    /// every two bytes encode a BMP Unicode scalar directly. Surrogate
868    /// halves are invalid in UCS-2 and decode to `U+FFFD`.
869    Ucs2H,
870    /// Adobe predefined `*-UTF16-H` CMaps (`UniJIS-UTF16-H`,
871    /// `UniCNS-UTF16-H`): the byte stream is UTF-16BE — two bytes per BMP
872    /// glyph, four bytes per surrogate pair. The `ToUnicode` map is keyed
873    /// by 16-bit codes, so it is consulted for BMP code units only.
874    Utf16H,
875}
876
877#[derive(Debug, Clone)]
878struct CompositeFont {
879    encoding: CompositeEncoding,
880    default_width: f64,
881    widths: BTreeMap<u16, f64>,
882    unicode_map: BTreeMap<u16, String>,
883}
884
885#[derive(Debug, Clone)]
886enum LoadedFont {
887    Simple(SimpleFont),
888    Composite(CompositeFont),
889}
890
891#[derive(Debug, Clone)]
892struct DecodedGlyph {
893    text: String,
894    width_units: f64,
895    byte_start: usize,
896    byte_end: usize,
897}
898
899#[derive(Debug, Clone)]
900struct RuntimeTextState {
901    text_matrix: Matrix,
902    line_matrix: Matrix,
903    font_size: f64,
904    character_spacing: f64,
905    word_spacing: f64,
906    text_rise: f64,
907    horizontal_scaling: f64,
908    leading: f64,
909    font: Option<String>,
910    /// PDF text rendering mode. Mode 3 = invisible (used in OCR/PDF-A).
911    text_render_mode: i64,
912}
913
914impl Default for RuntimeTextState {
915    fn default() -> Self {
916        Self {
917            text_matrix: Matrix::identity(),
918            line_matrix: Matrix::identity(),
919            font_size: 12.0,
920            character_spacing: 0.0,
921            word_spacing: 0.0,
922            text_rise: 0.0,
923            horizontal_scaling: 100.0,
924            leading: 0.0,
925            font: None,
926            text_render_mode: 0,
927        }
928    }
929}
930
931struct TextContext {
932    text: String,
933    items: Vec<TextItem>,
934    glyphs: Vec<Glyph>,
935    pending_line_break: bool,
936    /// The Form XObject reference whose content is currently being
937    /// interpreted. `None` while the page's own content stream is active.
938    /// Glyphs pushed while this is `Some(_)` carry the same reference in
939    /// their `source_form` field so downstream consumers (redact, in
940    /// particular) can identify which stream holds the glyph's bytes.
941    current_form: Option<ObjectRef>,
942}
943
944impl TextContext {
945    fn new(page_index: usize) -> Self {
946        let _ = page_index;
947        Self {
948            text: String::new(),
949            items: Vec::new(),
950            glyphs: Vec::new(),
951            pending_line_break: false,
952            current_form: None,
953        }
954    }
955}
956
957/// Maps ExtGState resource name → (synthetic font key in the fonts map, font size).
958type ExtGStateFontMap = BTreeMap<String, (String, f64)>;
959
960fn load_single_font(
961    file: &PdfFile,
962    font_dict: &pdf_objects::PdfDictionary,
963) -> PdfResult<LoadedFont> {
964    let subtype = font_dict
965        .get("Subtype")
966        .and_then(PdfValue::as_name)
967        .unwrap_or("");
968    match subtype {
969        "Type1" | "TrueType" => {
970            let first_char = font_dict
971                .get("FirstChar")
972                .and_then(PdfValue::as_integer)
973                .unwrap_or(0) as u16;
974            let widths = font_dict
975                .get("Widths")
976                .and_then(PdfValue::as_array)
977                .map(|widths| {
978                    widths
979                        .iter()
980                        .map(|value| value.as_number().unwrap_or(600.0))
981                        .collect::<Vec<_>>()
982                })
983                .unwrap_or_default();
984            let unicode_map = load_to_unicode_map(file, font_dict)?;
985            let encoding = parse_simple_encoding(file, font_dict);
986            Ok(LoadedFont::Simple(SimpleFont {
987                widths,
988                first_char,
989                unicode_map,
990                encoding,
991            }))
992        }
993        "Type0" => Ok(LoadedFont::Composite(load_composite_font(file, font_dict)?)),
994        other => Err(PdfError::Unsupported(format!(
995            "font subtype {other} is not supported"
996        ))),
997    }
998}
999
1000fn load_fonts(
1001    file: &PdfFile,
1002    resources: &pdf_objects::PdfDictionary,
1003) -> PdfResult<(BTreeMap<String, LoadedFont>, ExtGStateFontMap)> {
1004    let mut fonts = BTreeMap::new();
1005    let mut extgstate_fonts = BTreeMap::new();
1006
1007    // Load fonts from the Font resource dictionary
1008    if let Some(fonts_value) = resources.get("Font") {
1009        let fonts_dict = file.resolve_dict(fonts_value)?;
1010        for (name, font_value) in fonts_dict {
1011            let font_dict = file.resolve_dict(font_value)?;
1012            let font = load_single_font(file, font_dict)?;
1013            fonts.insert(name.clone(), font);
1014        }
1015    }
1016
1017    // Scan ExtGState entries for Font arrays
1018    if let Some(extgstate_value) = resources.get("ExtGState") {
1019        if let Ok(extgstate_dict) = file.resolve_dict(extgstate_value) {
1020            for (gs_name, gs_value) in extgstate_dict {
1021                let Ok(gs_dict) = file.resolve_dict(gs_value) else {
1022                    continue;
1023                };
1024                let Some(font_array) = gs_dict.get("Font").and_then(PdfValue::as_array) else {
1025                    continue;
1026                };
1027                if font_array.len() < 2 {
1028                    continue;
1029                }
1030                let font_size = font_array[1].as_number().unwrap_or(12.0);
1031                let Ok(font_dict) = file.resolve_dict(&font_array[0]) else {
1032                    continue;
1033                };
1034                let Ok(font) = load_single_font(file, font_dict) else {
1035                    continue;
1036                };
1037                let synthetic_key = format!("__gs:{gs_name}");
1038                fonts.insert(synthetic_key.clone(), font);
1039                extgstate_fonts.insert(gs_name.clone(), (synthetic_key, font_size));
1040            }
1041        }
1042    }
1043
1044    Ok((fonts, extgstate_fonts))
1045}
1046
1047#[allow(clippy::too_many_arguments)]
1048fn show_text(
1049    context: &mut TextContext,
1050    operation_index: usize,
1051    show_operand: ShowOperand,
1052    string: &pdf_objects::PdfString,
1053    text_state: &mut RuntimeTextState,
1054    fonts: &BTreeMap<String, LoadedFont>,
1055    ctm: Matrix,
1056    page_transform: Matrix,
1057) -> PdfResult<()> {
1058    if context.pending_line_break && !context.text.is_empty() {
1059        context.text.push('\n');
1060        context.pending_line_break = false;
1061    }
1062
1063    let font_name = text_state.font.clone().ok_or_else(|| {
1064        PdfError::Unsupported("text-showing operator used without selected font".to_string())
1065    })?;
1066    let font = fonts.get(&font_name).ok_or_else(|| {
1067        PdfError::Unsupported(format!("font resource /{font_name} could not be resolved"))
1068    })?;
1069    let scaling = text_state.horizontal_scaling / 100.0;
1070    let item_start = context.text.chars().count();
1071    let mut item_quad: Option<Rect> = None;
1072    let mut item_text = String::new();
1073    let decoded_glyphs = decode_font_glyphs(font, &string.0)?;
1074
1075    for decoded in decoded_glyphs {
1076        let advance = ((decoded.width_units / 1000.0) * text_state.font_size
1077            + text_state.character_spacing
1078            + if decoded.text == " " {
1079                text_state.word_spacing
1080            } else {
1081                0.0
1082            })
1083            * scaling;
1084        let text_to_page = text_state
1085            .text_matrix
1086            .multiply(ctm)
1087            .multiply(page_transform);
1088        // Use heuristic ascent/descent (80%/12% of em-square) instead of the
1089        // full font_size to avoid bboxes that span into adjacent text lines.
1090        let font_size = text_state.font_size.max(0.0);
1091        let local_rect = Rect {
1092            x: 0.0,
1093            y: text_state.text_rise - font_size * 0.12,
1094            width: advance.max(0.0),
1095            height: font_size * 0.8,
1096        };
1097        let quad = local_rect.to_quad().transform(text_to_page);
1098        let bbox = quad.bounding_rect();
1099        item_quad = Some(match item_quad {
1100            Some(existing) => existing.union(&bbox),
1101            None => bbox,
1102        });
1103        for character in decoded.text.chars() {
1104            let page_char_index = context.text.chars().count();
1105            let visible = text_state.text_render_mode != 3;
1106            context.glyphs.push(Glyph {
1107                text: character,
1108                bbox,
1109                quad,
1110                page_char_index,
1111                operation_index,
1112                source_form: context.current_form,
1113                location: match show_operand {
1114                    ShowOperand::Direct { operand_index } => GlyphLocation::Direct {
1115                        operand_index,
1116                        byte_start: decoded.byte_start,
1117                        byte_end: decoded.byte_end,
1118                    },
1119                    ShowOperand::Array {
1120                        operand_index,
1121                        element_index,
1122                    } => GlyphLocation::Array {
1123                        operand_index,
1124                        element_index,
1125                        byte_start: decoded.byte_start,
1126                        byte_end: decoded.byte_end,
1127                    },
1128                },
1129                visible,
1130                width_units: decoded.width_units,
1131            });
1132            if visible {
1133                context.text.push(character);
1134                item_text.push(character);
1135            }
1136        }
1137        text_state.text_matrix = Matrix::translate(advance, 0.0).multiply(text_state.text_matrix);
1138    }
1139
1140    if !item_text.is_empty() {
1141        let item_end = context.text.chars().count();
1142        context.items.push(TextItem {
1143            text: item_text,
1144            bbox: item_quad.unwrap_or(Rect {
1145                x: 0.0,
1146                y: 0.0,
1147                width: 0.0,
1148                height: 0.0,
1149            }),
1150            quad: item_quad.map(Rect::to_quad),
1151            char_start: Some(item_start),
1152            char_end: Some(item_end),
1153        });
1154    }
1155    Ok(())
1156}
1157
1158fn decode_simple_byte(font: &SimpleFont, byte: u8) -> String {
1159    if let Some(mapped) = font.unicode_map.get(&u16::from(byte)) {
1160        if !mapped.is_empty() {
1161            return mapped.clone();
1162        }
1163    }
1164    if let Some(glyph_name) = font.encoding.differences.get(&byte) {
1165        if let Some(character) = glyph_name_to_char(glyph_name) {
1166            return character.to_string();
1167        }
1168        return '\u{FFFD}'.to_string();
1169    }
1170    let character = match font.encoding.base {
1171        SimpleEncodingBase::WinAnsi => winansi_byte_to_char(byte),
1172        SimpleEncodingBase::MacRoman => mac_roman_byte_to_char(byte),
1173        SimpleEncodingBase::Standard => standard_byte_to_char(byte),
1174        SimpleEncodingBase::Identity => identity_byte_to_char(byte),
1175    };
1176    character
1177        .map(|character| character.to_string())
1178        .unwrap_or_else(|| '\u{FFFD}'.to_string())
1179}
1180
1181fn identity_byte_to_char(byte: u8) -> Option<char> {
1182    if byte.is_ascii() {
1183        Some(byte as char)
1184    } else {
1185        None
1186    }
1187}
1188
1189/// Minimal Adobe Glyph List subset: maps PDF glyph names commonly appearing
1190/// in `/Encoding` `Differences` arrays to their Unicode equivalents. Covers
1191/// the WinAnsi repertoire plus common additions (Latin supplement, Latin
1192/// Extended-A, a few math and punctuation glyphs). Names not present here
1193/// fall through to `U+FFFD` — the caller handles that.
1194fn glyph_name_to_char(name: &str) -> Option<char> {
1195    let mapped = match name {
1196        "space" => ' ',
1197        "exclam" => '!',
1198        "quotedbl" => '"',
1199        "numbersign" => '#',
1200        "dollar" => '$',
1201        "percent" => '%',
1202        "ampersand" => '&',
1203        "quotesingle" => '\'',
1204        "parenleft" => '(',
1205        "parenright" => ')',
1206        "asterisk" => '*',
1207        "plus" => '+',
1208        "comma" => ',',
1209        "hyphen" => '-',
1210        "period" => '.',
1211        "slash" => '/',
1212        "zero" => '0',
1213        "one" => '1',
1214        "two" => '2',
1215        "three" => '3',
1216        "four" => '4',
1217        "five" => '5',
1218        "six" => '6',
1219        "seven" => '7',
1220        "eight" => '8',
1221        "nine" => '9',
1222        "colon" => ':',
1223        "semicolon" => ';',
1224        "less" => '<',
1225        "equal" => '=',
1226        "greater" => '>',
1227        "question" => '?',
1228        "at" => '@',
1229        "bracketleft" => '[',
1230        "backslash" => '\\',
1231        "bracketright" => ']',
1232        "asciicircum" => '^',
1233        "underscore" => '_',
1234        "grave" => '`',
1235        "braceleft" => '{',
1236        "bar" => '|',
1237        "braceright" => '}',
1238        "asciitilde" => '~',
1239        // Common uppercase Latin
1240        name if name.len() == 1 && name.chars().next().unwrap().is_ascii_alphabetic() => {
1241            name.chars().next().unwrap()
1242        }
1243        // Windows-1252 / WinAnsi punctuation
1244        "bullet" => '\u{2022}',
1245        "Euro" => '\u{20AC}',
1246        "endash" => '\u{2013}',
1247        "emdash" => '\u{2014}',
1248        "quoteleft" => '\u{2018}',
1249        "quoteright" => '\u{2019}',
1250        "quotesinglbase" => '\u{201A}',
1251        "quotedblleft" => '\u{201C}',
1252        "quotedblright" => '\u{201D}',
1253        "quotedblbase" => '\u{201E}',
1254        "ellipsis" => '\u{2026}',
1255        "dagger" => '\u{2020}',
1256        "daggerdbl" => '\u{2021}',
1257        "perthousand" => '\u{2030}',
1258        "guilsinglleft" => '\u{2039}',
1259        "guilsinglright" => '\u{203A}',
1260        "florin" => '\u{0192}',
1261        "trademark" => '\u{2122}',
1262        "circumflex" => '\u{02C6}',
1263        "tilde" => '\u{02DC}',
1264        "macron" => '\u{00AF}',
1265        "breve" => '\u{02D8}',
1266        "dotaccent" => '\u{02D9}',
1267        "ring" => '\u{02DA}',
1268        "ogonek" => '\u{02DB}',
1269        "caron" => '\u{02C7}',
1270        "cedilla" => '\u{00B8}',
1271        "dieresis" => '\u{00A8}',
1272        "acute" => '\u{00B4}',
1273        // Latin-1 supplement
1274        "exclamdown" => '\u{00A1}',
1275        "cent" => '\u{00A2}',
1276        "sterling" => '\u{00A3}',
1277        "currency" => '\u{00A4}',
1278        "yen" => '\u{00A5}',
1279        "brokenbar" => '\u{00A6}',
1280        "section" => '\u{00A7}',
1281        "copyright" => '\u{00A9}',
1282        "ordfeminine" => '\u{00AA}',
1283        "guillemotleft" => '\u{00AB}',
1284        "logicalnot" => '\u{00AC}',
1285        "registered" => '\u{00AE}',
1286        "degree" => '\u{00B0}',
1287        "plusminus" => '\u{00B1}',
1288        "twosuperior" => '\u{00B2}',
1289        "threesuperior" => '\u{00B3}',
1290        "mu" => '\u{00B5}',
1291        "paragraph" => '\u{00B6}',
1292        "periodcentered" => '\u{00B7}',
1293        "onesuperior" => '\u{00B9}',
1294        "ordmasculine" => '\u{00BA}',
1295        "guillemotright" => '\u{00BB}',
1296        "onequarter" => '\u{00BC}',
1297        "onehalf" => '\u{00BD}',
1298        "threequarters" => '\u{00BE}',
1299        "questiondown" => '\u{00BF}',
1300        // Latin-1 letters
1301        "Agrave" => '\u{00C0}',
1302        "Aacute" => '\u{00C1}',
1303        "Acircumflex" => '\u{00C2}',
1304        "Atilde" => '\u{00C3}',
1305        "Adieresis" => '\u{00C4}',
1306        "Aring" => '\u{00C5}',
1307        "AE" => '\u{00C6}',
1308        "Ccedilla" => '\u{00C7}',
1309        "Egrave" => '\u{00C8}',
1310        "Eacute" => '\u{00C9}',
1311        "Ecircumflex" => '\u{00CA}',
1312        "Edieresis" => '\u{00CB}',
1313        "Igrave" => '\u{00CC}',
1314        "Iacute" => '\u{00CD}',
1315        "Icircumflex" => '\u{00CE}',
1316        "Idieresis" => '\u{00CF}',
1317        "Eth" => '\u{00D0}',
1318        "Ntilde" => '\u{00D1}',
1319        "Ograve" => '\u{00D2}',
1320        "Oacute" => '\u{00D3}',
1321        "Ocircumflex" => '\u{00D4}',
1322        "Otilde" => '\u{00D5}',
1323        "Odieresis" => '\u{00D6}',
1324        "multiply" => '\u{00D7}',
1325        "Oslash" => '\u{00D8}',
1326        "Ugrave" => '\u{00D9}',
1327        "Uacute" => '\u{00DA}',
1328        "Ucircumflex" => '\u{00DB}',
1329        "Udieresis" => '\u{00DC}',
1330        "Yacute" => '\u{00DD}',
1331        "Thorn" => '\u{00DE}',
1332        "germandbls" => '\u{00DF}',
1333        "agrave" => '\u{00E0}',
1334        "aacute" => '\u{00E1}',
1335        "acircumflex" => '\u{00E2}',
1336        "atilde" => '\u{00E3}',
1337        "adieresis" => '\u{00E4}',
1338        "aring" => '\u{00E5}',
1339        "ae" => '\u{00E6}',
1340        "ccedilla" => '\u{00E7}',
1341        "egrave" => '\u{00E8}',
1342        "eacute" => '\u{00E9}',
1343        "ecircumflex" => '\u{00EA}',
1344        "edieresis" => '\u{00EB}',
1345        "igrave" => '\u{00EC}',
1346        "iacute" => '\u{00ED}',
1347        "icircumflex" => '\u{00EE}',
1348        "idieresis" => '\u{00EF}',
1349        "eth" => '\u{00F0}',
1350        "ntilde" => '\u{00F1}',
1351        "ograve" => '\u{00F2}',
1352        "oacute" => '\u{00F3}',
1353        "ocircumflex" => '\u{00F4}',
1354        "otilde" => '\u{00F5}',
1355        "odieresis" => '\u{00F6}',
1356        "divide" => '\u{00F7}',
1357        "oslash" => '\u{00F8}',
1358        "ugrave" => '\u{00F9}',
1359        "uacute" => '\u{00FA}',
1360        "ucircumflex" => '\u{00FB}',
1361        "udieresis" => '\u{00FC}',
1362        "yacute" => '\u{00FD}',
1363        "thorn" => '\u{00FE}',
1364        "ydieresis" => '\u{00FF}',
1365        // Latin Extended-A common entries
1366        "OE" => '\u{0152}',
1367        "oe" => '\u{0153}',
1368        "Scaron" => '\u{0160}',
1369        "scaron" => '\u{0161}',
1370        "Ydieresis" => '\u{0178}',
1371        "Zcaron" => '\u{017D}',
1372        "zcaron" => '\u{017E}',
1373        "Lslash" => '\u{0141}',
1374        "lslash" => '\u{0142}',
1375        "Idot" | "Idotaccent" => '\u{0130}',
1376        "dotlessi" => '\u{0131}',
1377        "fi" => '\u{FB01}',
1378        "fl" => '\u{FB02}',
1379        "ff" => '\u{FB00}',
1380        "ffi" => '\u{FB03}',
1381        "ffl" => '\u{FB04}',
1382        _ => return None,
1383    };
1384    Some(mapped)
1385}
1386
1387/// Decode a single byte under the PDF `WinAnsiEncoding` table.
1388///
1389/// Codes `0x20..=0x7E` are plain ASCII. Codes `0x80..=0x9F` carry the
1390/// Windows-1252 punctuation repertoire (smart quotes, the Euro sign, the
1391/// em/en dashes, and so on). Codes `0xA0..=0xFF` are ISO-8859-1.
1392/// Undefined codes return `None`, which callers render as `U+FFFD`.
1393fn winansi_byte_to_char(byte: u8) -> Option<char> {
1394    match byte {
1395        0x20..=0x7E => Some(byte as char),
1396        0x80 => Some('\u{20AC}'),
1397        0x82 => Some('\u{201A}'),
1398        0x83 => Some('\u{0192}'),
1399        0x84 => Some('\u{201E}'),
1400        0x85 => Some('\u{2026}'),
1401        0x86 => Some('\u{2020}'),
1402        0x87 => Some('\u{2021}'),
1403        0x88 => Some('\u{02C6}'),
1404        0x89 => Some('\u{2030}'),
1405        0x8A => Some('\u{0160}'),
1406        0x8B => Some('\u{2039}'),
1407        0x8C => Some('\u{0152}'),
1408        0x8E => Some('\u{017D}'),
1409        0x91 => Some('\u{2018}'),
1410        0x92 => Some('\u{2019}'),
1411        0x93 => Some('\u{201C}'),
1412        0x94 => Some('\u{201D}'),
1413        0x95 => Some('\u{2022}'),
1414        0x96 => Some('\u{2013}'),
1415        0x97 => Some('\u{2014}'),
1416        0x98 => Some('\u{02DC}'),
1417        0x99 => Some('\u{2122}'),
1418        0x9A => Some('\u{0161}'),
1419        0x9B => Some('\u{203A}'),
1420        0x9C => Some('\u{0153}'),
1421        0x9E => Some('\u{017E}'),
1422        0x9F => Some('\u{0178}'),
1423        0xA0..=0xFF => Some(byte as char),
1424        _ => None,
1425    }
1426}
1427
1428/// Decode a single byte under the PDF `StandardEncoding` table (PDF Annex D,
1429/// table E.5 — "Adobe Standard Encoding"). ASCII codes pass through
1430/// except for `0x27` (`quoteright` = U+2019) and `0x60` (`quoteleft` =
1431/// U+2018), which are Adobe's historical typographic-quote choice. The
1432/// high-bit range is sparse and carries Latin-1 supplement glyphs,
1433/// currency, ligatures, and accented-letter bases. Codes not defined in
1434/// the encoding return `None`.
1435fn standard_byte_to_char(byte: u8) -> Option<char> {
1436    match byte {
1437        0x27 => Some('\u{2019}'),
1438        0x60 => Some('\u{2018}'),
1439        0x20..=0x7E => Some(byte as char),
1440        0xA1 => Some('\u{00A1}'),
1441        0xA2 => Some('\u{00A2}'),
1442        0xA3 => Some('\u{00A3}'),
1443        0xA4 => Some('\u{2044}'),
1444        0xA5 => Some('\u{00A5}'),
1445        0xA6 => Some('\u{0192}'),
1446        0xA7 => Some('\u{00A7}'),
1447        0xA8 => Some('\u{00A4}'),
1448        0xA9 => Some('\u{0027}'),
1449        0xAA => Some('\u{201C}'),
1450        0xAB => Some('\u{00AB}'),
1451        0xAC => Some('\u{2039}'),
1452        0xAD => Some('\u{203A}'),
1453        0xAE => Some('\u{FB01}'),
1454        0xAF => Some('\u{FB02}'),
1455        0xB1 => Some('\u{2013}'),
1456        0xB2 => Some('\u{2020}'),
1457        0xB3 => Some('\u{2021}'),
1458        0xB4 => Some('\u{00B7}'),
1459        0xB6 => Some('\u{00B6}'),
1460        0xB7 => Some('\u{2022}'),
1461        0xB8 => Some('\u{201A}'),
1462        0xB9 => Some('\u{201E}'),
1463        0xBA => Some('\u{201D}'),
1464        0xBB => Some('\u{00BB}'),
1465        0xBC => Some('\u{2026}'),
1466        0xBD => Some('\u{2030}'),
1467        0xBF => Some('\u{00BF}'),
1468        0xC1 => Some('\u{0060}'),
1469        0xC2 => Some('\u{00B4}'),
1470        0xC3 => Some('\u{02C6}'),
1471        0xC4 => Some('\u{02DC}'),
1472        0xC5 => Some('\u{00AF}'),
1473        0xC6 => Some('\u{02D8}'),
1474        0xC7 => Some('\u{02D9}'),
1475        0xC8 => Some('\u{00A8}'),
1476        0xCA => Some('\u{02DA}'),
1477        0xCB => Some('\u{00B8}'),
1478        0xCD => Some('\u{02DD}'),
1479        0xCE => Some('\u{02DB}'),
1480        0xCF => Some('\u{02C7}'),
1481        0xE1 => Some('\u{00C6}'),
1482        0xE3 => Some('\u{00AA}'),
1483        0xE8 => Some('\u{0141}'),
1484        0xE9 => Some('\u{00D8}'),
1485        0xEA => Some('\u{0152}'),
1486        0xEB => Some('\u{00BA}'),
1487        0xF1 => Some('\u{00E6}'),
1488        0xF5 => Some('\u{0131}'),
1489        0xF8 => Some('\u{0142}'),
1490        0xF9 => Some('\u{00F8}'),
1491        0xFA => Some('\u{0153}'),
1492        0xFB => Some('\u{00DF}'),
1493        _ => None,
1494    }
1495}
1496
1497/// Decode a single byte under the PDF `MacRomanEncoding` table (PDF Annex D).
1498///
1499/// Codes `0x20..=0x7E` are plain ASCII. Codes `0x80..=0xFF` carry the Mac
1500/// Roman repertoire: accented Latin letters, math symbols, punctuation,
1501/// ligatures, and (at `0xF0`) the Apple logo mapped to the Corporate Use
1502/// Area `U+F8FF`. Undefined codes return `None`, which callers render as
1503/// `U+FFFD`.
1504fn mac_roman_byte_to_char(byte: u8) -> Option<char> {
1505    match byte {
1506        0x20..=0x7E => Some(byte as char),
1507        0x80 => Some('\u{00C4}'), // Ä
1508        0x81 => Some('\u{00C5}'), // Å
1509        0x82 => Some('\u{00C7}'), // Ç
1510        0x83 => Some('\u{00C9}'), // É
1511        0x84 => Some('\u{00D1}'), // Ñ
1512        0x85 => Some('\u{00D6}'), // Ö
1513        0x86 => Some('\u{00DC}'), // Ü
1514        0x87 => Some('\u{00E1}'), // á
1515        0x88 => Some('\u{00E0}'), // à
1516        0x89 => Some('\u{00E2}'), // â
1517        0x8A => Some('\u{00E4}'), // ä
1518        0x8B => Some('\u{00E3}'), // ã
1519        0x8C => Some('\u{00E5}'), // å
1520        0x8D => Some('\u{00E7}'), // ç
1521        0x8E => Some('\u{00E9}'), // é
1522        0x8F => Some('\u{00E8}'), // è
1523        0x90 => Some('\u{00EA}'), // ê
1524        0x91 => Some('\u{00EB}'), // ë
1525        0x92 => Some('\u{00ED}'), // í
1526        0x93 => Some('\u{00EC}'), // ì
1527        0x94 => Some('\u{00EE}'), // î
1528        0x95 => Some('\u{00EF}'), // ï
1529        0x96 => Some('\u{00F1}'), // ñ
1530        0x97 => Some('\u{00F3}'), // ó
1531        0x98 => Some('\u{00F2}'), // ò
1532        0x99 => Some('\u{00F4}'), // ô
1533        0x9A => Some('\u{00F6}'), // ö
1534        0x9B => Some('\u{00F5}'), // õ
1535        0x9C => Some('\u{00FA}'), // ú
1536        0x9D => Some('\u{00F9}'), // ù
1537        0x9E => Some('\u{00FB}'), // û
1538        0x9F => Some('\u{00FC}'), // ü
1539        0xA0 => Some('\u{2020}'), // †
1540        0xA1 => Some('\u{00B0}'), // °
1541        0xA2 => Some('\u{00A2}'), // ¢
1542        0xA3 => Some('\u{00A3}'), // £
1543        0xA4 => Some('\u{00A7}'), // §
1544        0xA5 => Some('\u{2022}'), // •
1545        0xA6 => Some('\u{00B6}'), // ¶
1546        0xA7 => Some('\u{00DF}'), // ß
1547        0xA8 => Some('\u{00AE}'), // ®
1548        0xA9 => Some('\u{00A9}'), // ©
1549        0xAA => Some('\u{2122}'), // ™
1550        0xAB => Some('\u{00B4}'), // ´
1551        0xAC => Some('\u{00A8}'), // ¨
1552        0xAD => Some('\u{2260}'), // ≠
1553        0xAE => Some('\u{00C6}'), // Æ
1554        0xAF => Some('\u{00D8}'), // Ø
1555        0xB0 => Some('\u{221E}'), // ∞
1556        0xB1 => Some('\u{00B1}'), // ±
1557        0xB2 => Some('\u{2264}'), // ≤
1558        0xB3 => Some('\u{2265}'), // ≥
1559        0xB4 => Some('\u{00A5}'), // ¥
1560        0xB5 => Some('\u{00B5}'), // µ
1561        0xB6 => Some('\u{2202}'), // ∂
1562        0xB7 => Some('\u{2211}'), // ∑
1563        0xB8 => Some('\u{220F}'), // ∏
1564        0xB9 => Some('\u{03C0}'), // π
1565        0xBA => Some('\u{222B}'), // ∫
1566        0xBB => Some('\u{00AA}'), // ª
1567        0xBC => Some('\u{00BA}'), // º
1568        0xBD => Some('\u{03A9}'), // Ω
1569        0xBE => Some('\u{00E6}'), // æ
1570        0xBF => Some('\u{00F8}'), // ø
1571        0xC0 => Some('\u{00BF}'), // ¿
1572        0xC1 => Some('\u{00A1}'), // ¡
1573        0xC2 => Some('\u{00AC}'), // ¬
1574        0xC3 => Some('\u{221A}'), // √
1575        0xC4 => Some('\u{0192}'), // ƒ
1576        0xC5 => Some('\u{2248}'), // ≈
1577        0xC6 => Some('\u{2206}'), // ∆
1578        0xC7 => Some('\u{00AB}'), // «
1579        0xC8 => Some('\u{00BB}'), // »
1580        0xC9 => Some('\u{2026}'), // …
1581        0xCA => Some('\u{00A0}'), // non-breaking space
1582        0xCB => Some('\u{00C0}'), // À
1583        0xCC => Some('\u{00C3}'), // Ã
1584        0xCD => Some('\u{00D5}'), // Õ
1585        0xCE => Some('\u{0152}'), // Œ
1586        0xCF => Some('\u{0153}'), // œ
1587        0xD0 => Some('\u{2013}'), // –
1588        0xD1 => Some('\u{2014}'), // —
1589        0xD2 => Some('\u{201C}'), // "
1590        0xD3 => Some('\u{201D}'), // "
1591        0xD4 => Some('\u{2018}'), // '
1592        0xD5 => Some('\u{2019}'), // '
1593        0xD6 => Some('\u{00F7}'), // ÷
1594        0xD7 => Some('\u{25CA}'), // ◊
1595        0xD8 => Some('\u{00FF}'), // ÿ
1596        0xD9 => Some('\u{0178}'), // Ÿ
1597        0xDA => Some('\u{2044}'), // ⁄
1598        0xDB => Some('\u{20AC}'), // €
1599        0xDC => Some('\u{2039}'), // ‹
1600        0xDD => Some('\u{203A}'), // ›
1601        0xDE => Some('\u{FB01}'), // fi
1602        0xDF => Some('\u{FB02}'), // fl
1603        0xE0 => Some('\u{2021}'), // ‡
1604        0xE1 => Some('\u{00B7}'), // ·
1605        0xE2 => Some('\u{201A}'), // ‚
1606        0xE3 => Some('\u{201E}'), // „
1607        0xE4 => Some('\u{2030}'), // ‰
1608        0xE5 => Some('\u{00C2}'), // Â
1609        0xE6 => Some('\u{00CA}'), // Ê
1610        0xE7 => Some('\u{00C1}'), // Á
1611        0xE8 => Some('\u{00CB}'), // Ë
1612        0xE9 => Some('\u{00C8}'), // È
1613        0xEA => Some('\u{00CD}'), // Í
1614        0xEB => Some('\u{00CE}'), // Î
1615        0xEC => Some('\u{00CF}'), // Ï
1616        0xED => Some('\u{00CC}'), // Ì
1617        0xEE => Some('\u{00D3}'), // Ó
1618        0xEF => Some('\u{00D4}'), // Ô
1619        0xF0 => Some('\u{F8FF}'), // Apple logo (Corporate Use Area)
1620        0xF1 => Some('\u{00D2}'), // Ò
1621        0xF2 => Some('\u{00DA}'), // Ú
1622        0xF3 => Some('\u{00DB}'), // Û
1623        0xF4 => Some('\u{00D9}'), // Ù
1624        0xF5 => Some('\u{0131}'), // ı
1625        0xF6 => Some('\u{02C6}'), // ˆ
1626        0xF7 => Some('\u{02DC}'), // ˜
1627        0xF8 => Some('\u{00AF}'), // ¯
1628        0xF9 => Some('\u{02D8}'), // ˘
1629        0xFA => Some('\u{02D9}'), // ˙
1630        0xFB => Some('\u{02DA}'), // ˚
1631        0xFC => Some('\u{00B8}'), // ¸
1632        0xFD => Some('\u{02DD}'), // ˝
1633        0xFE => Some('\u{02DB}'), // ˛
1634        0xFF => Some('\u{02C7}'), // ˇ
1635        _ => None,
1636    }
1637}
1638
1639fn parse_simple_encoding(file: &PdfFile, font_dict: &pdf_objects::PdfDictionary) -> SimpleEncoding {
1640    match font_dict.get("Encoding") {
1641        Some(PdfValue::Name(name)) => SimpleEncoding {
1642            base: simple_encoding_base_from_name(name),
1643            differences: BTreeMap::new(),
1644        },
1645        Some(value @ PdfValue::Reference(_)) | Some(value @ PdfValue::Dictionary(_)) => {
1646            match file.resolve_dict(value) {
1647                Ok(dict) => {
1648                    let base = dict
1649                        .get("BaseEncoding")
1650                        .and_then(PdfValue::as_name)
1651                        .map(simple_encoding_base_from_name)
1652                        .unwrap_or_default();
1653                    let differences = dict
1654                        .get("Differences")
1655                        .and_then(PdfValue::as_array)
1656                        .map(parse_differences_array)
1657                        .unwrap_or_default();
1658                    SimpleEncoding { base, differences }
1659                }
1660                Err(_) => SimpleEncoding::default(),
1661            }
1662        }
1663        _ => SimpleEncoding::default(),
1664    }
1665}
1666
1667fn simple_encoding_base_from_name(name: &str) -> SimpleEncodingBase {
1668    match name {
1669        "WinAnsiEncoding" => SimpleEncodingBase::WinAnsi,
1670        "MacRomanEncoding" => SimpleEncodingBase::MacRoman,
1671        "StandardEncoding" => SimpleEncodingBase::Standard,
1672        _ => SimpleEncodingBase::Identity,
1673    }
1674}
1675
1676fn parse_differences_array(entries: &[PdfValue]) -> BTreeMap<u8, String> {
1677    let mut output = BTreeMap::new();
1678    let mut code: u16 = 0;
1679    let mut have_code = false;
1680    for entry in entries {
1681        match entry {
1682            PdfValue::Integer(value) => {
1683                if *value >= 0 && *value <= 255 {
1684                    code = *value as u16;
1685                    have_code = true;
1686                } else {
1687                    have_code = false;
1688                }
1689            }
1690            PdfValue::Number(value) => {
1691                let rounded = value.round() as i64;
1692                if (0..=255).contains(&rounded) {
1693                    code = rounded as u16;
1694                    have_code = true;
1695                } else {
1696                    have_code = false;
1697                }
1698            }
1699            PdfValue::Name(name) => {
1700                if have_code && code <= 255 {
1701                    output.insert(code as u8, name.clone());
1702                    code += 1;
1703                    if code > 255 {
1704                        have_code = false;
1705                    }
1706                }
1707            }
1708            _ => {}
1709        }
1710    }
1711    output
1712}
1713
1714fn decode_font_glyphs(font: &LoadedFont, bytes: &[u8]) -> PdfResult<Vec<DecodedGlyph>> {
1715    match font {
1716        LoadedFont::Simple(font) => Ok(bytes
1717            .iter()
1718            .copied()
1719            .enumerate()
1720            .map(|(byte_index, byte)| {
1721                let width_units = font
1722                    .widths
1723                    .get(u16::from(byte).saturating_sub(font.first_char) as usize)
1724                    .copied()
1725                    .unwrap_or(600.0);
1726                DecodedGlyph {
1727                    text: decode_simple_byte(font, byte),
1728                    width_units,
1729                    byte_start: byte_index,
1730                    byte_end: byte_index + 1,
1731                }
1732            })
1733            .collect()),
1734        LoadedFont::Composite(font) => decode_composite_glyphs(font, bytes),
1735    }
1736}
1737
1738/// Reject Type 0 fonts whose descendant `/W` array would silently change
1739/// glyph geometry that we cannot resolve. For `Identity-H` the width
1740/// table is keyed by the same two-byte code that decode_composite_glyphs
1741/// already uses, so any `/W` is supported. For the predefined Unicode-
1742/// keyed CMaps (`Ucs2H` / `Utf16H`) the engine decodes bytes directly to
1743/// Unicode without computing real CIDs, so `/W` cannot be looked up during
1744/// glyph decoding. Rejecting any non-empty `/W` is honest about this
1745/// limitation and avoids silent geometry drift (AGENTS.md fail-explicit rule).
1746fn reject_unsupported_widths(
1747    encoding: CompositeEncoding,
1748    widths: &BTreeMap<u16, f64>,
1749) -> PdfResult<()> {
1750    if matches!(encoding, CompositeEncoding::IdentityH) {
1751        return Ok(());
1752    }
1753    if !widths.is_empty() {
1754        return Err(PdfError::Unsupported(
1755            "Type0 font with a predefined Unicode CMap and a /W width array is not supported \
1756             (per-CID widths cannot be resolved without real CID computation)"
1757                .to_string(),
1758        ));
1759    }
1760    Ok(())
1761}
1762
1763/// Resolve the `/Encoding` name on a Type 0 font dictionary into a
1764/// `CompositeEncoding`. Vertical CMaps (`-V`), CMaps for registries we
1765/// don't decode (e.g. `90ms-RKSJ-H`), and unknown names return
1766/// `PdfError::Unsupported`.
1767fn composite_encoding_from_name(name: &str) -> PdfResult<CompositeEncoding> {
1768    match name {
1769        "Identity-H" => Ok(CompositeEncoding::IdentityH),
1770        "UniGB-UCS2-H" | "UniKS-UCS2-H" => Ok(CompositeEncoding::Ucs2H),
1771        "UniJIS-UTF16-H" | "UniCNS-UTF16-H" => Ok(CompositeEncoding::Utf16H),
1772        other => Err(PdfError::Unsupported(format!(
1773            "Type0 font encoding {other} is not supported"
1774        ))),
1775    }
1776}
1777
1778fn load_composite_font(
1779    file: &PdfFile,
1780    font_dict: &pdf_objects::PdfDictionary,
1781) -> PdfResult<CompositeFont> {
1782    let encoding_name = font_dict
1783        .get("Encoding")
1784        .and_then(PdfValue::as_name)
1785        .unwrap_or("Identity-H");
1786    let encoding = composite_encoding_from_name(encoding_name)?;
1787
1788    let descendant = font_dict
1789        .get("DescendantFonts")
1790        .and_then(PdfValue::as_array)
1791        .and_then(|fonts| fonts.first())
1792        .ok_or_else(|| PdfError::Corrupt("Type0 font is missing DescendantFonts".to_string()))?;
1793    let descendant_dict = file.resolve_dict(descendant)?;
1794    let descendant_subtype = descendant_dict
1795        .get("Subtype")
1796        .and_then(PdfValue::as_name)
1797        .unwrap_or("");
1798    if !matches!(descendant_subtype, "CIDFontType0" | "CIDFontType2") {
1799        return Err(PdfError::Unsupported(format!(
1800            "descendant font subtype {descendant_subtype} is not supported"
1801        )));
1802    }
1803
1804    let default_width = descendant_dict
1805        .get("DW")
1806        .and_then(PdfValue::as_number)
1807        .unwrap_or(1000.0);
1808    let widths = descendant_dict
1809        .get("W")
1810        .map(parse_cid_widths)
1811        .transpose()?
1812        .unwrap_or_default();
1813    reject_unsupported_widths(encoding, &widths)?;
1814    let unicode_map = load_to_unicode_map(file, font_dict)?;
1815
1816    Ok(CompositeFont {
1817        encoding,
1818        default_width,
1819        widths,
1820        unicode_map,
1821    })
1822}
1823
1824fn parse_cid_widths(value: &PdfValue) -> PdfResult<BTreeMap<u16, f64>> {
1825    let array = value
1826        .as_array()
1827        .ok_or_else(|| PdfError::Corrupt("CID font W entry must be an array".to_string()))?;
1828    let mut widths = BTreeMap::new();
1829    let mut index = 0usize;
1830    while index < array.len() {
1831        let start_cid = array[index]
1832            .as_integer()
1833            .ok_or_else(|| PdfError::Corrupt("CID width entry is invalid".to_string()))?
1834            as u16;
1835        let next = array
1836            .get(index + 1)
1837            .ok_or_else(|| PdfError::Corrupt("CID width entry is truncated".to_string()))?;
1838        if let Some(width_array) = next.as_array() {
1839            for (offset, width) in width_array.iter().enumerate() {
1840                let cid = start_cid
1841                    .checked_add(offset as u16)
1842                    .ok_or_else(|| PdfError::Corrupt("CID width index overflow".to_string()))?;
1843                widths.insert(
1844                    cid,
1845                    width.as_number().ok_or_else(|| {
1846                        PdfError::Corrupt("CID width array contains a non-number".to_string())
1847                    })?,
1848                );
1849            }
1850            index += 2;
1851        } else {
1852            let end_cid = next
1853                .as_integer()
1854                .ok_or_else(|| PdfError::Corrupt("CID width range is invalid".to_string()))?
1855                as u16;
1856            let width = array
1857                .get(index + 2)
1858                .and_then(PdfValue::as_number)
1859                .ok_or_else(|| PdfError::Corrupt("CID width range is truncated".to_string()))?;
1860            for cid in start_cid..=end_cid {
1861                widths.insert(cid, width);
1862            }
1863            index += 3;
1864        }
1865    }
1866    Ok(widths)
1867}
1868
1869fn load_to_unicode_map(
1870    file: &PdfFile,
1871    font_dict: &pdf_objects::PdfDictionary,
1872) -> PdfResult<BTreeMap<u16, String>> {
1873    let Some(to_unicode_value) = font_dict.get("ToUnicode") else {
1874        return Ok(BTreeMap::new());
1875    };
1876    let to_unicode_ref = match to_unicode_value {
1877        PdfValue::Reference(reference) => *reference,
1878        _ => {
1879            return Err(PdfError::Unsupported(
1880                "direct ToUnicode streams are not supported".to_string(),
1881            ));
1882        }
1883    };
1884    let stream = get_stream(file, to_unicode_ref)?;
1885    let decoded = decode_stream(stream)?;
1886    parse_to_unicode_cmap(&decoded)
1887}
1888
1889fn parse_to_unicode_cmap(data: &[u8]) -> PdfResult<BTreeMap<u16, String>> {
1890    let text = String::from_utf8_lossy(data);
1891    let mut mapping = BTreeMap::new();
1892    enum Mode {
1893        BfChar,
1894        BfRange,
1895    }
1896    let mut mode = None;
1897    for raw_line in text.lines() {
1898        let line = raw_line.trim();
1899        if line.is_empty() {
1900            continue;
1901        }
1902        if line.ends_with("beginbfchar") {
1903            mode = Some(Mode::BfChar);
1904            continue;
1905        }
1906        if line.ends_with("endbfchar") {
1907            mode = None;
1908            continue;
1909        }
1910        if line.ends_with("beginbfrange") {
1911            mode = Some(Mode::BfRange);
1912            continue;
1913        }
1914        if line.ends_with("endbfrange") {
1915            mode = None;
1916            continue;
1917        }
1918        match mode {
1919            Some(Mode::BfChar) => parse_bfchar_line(line, &mut mapping)?,
1920            Some(Mode::BfRange) => parse_bfrange_line(line, &mut mapping)?,
1921            None => {}
1922        }
1923    }
1924    Ok(mapping)
1925}
1926
1927fn parse_bfchar_line(line: &str, mapping: &mut BTreeMap<u16, String>) -> PdfResult<()> {
1928    let tokens = extract_hex_tokens(line);
1929    if tokens.len() < 2 {
1930        return Ok(());
1931    }
1932    // Source codes longer than two bytes (e.g. UTF-16 surrogate pairs in
1933    // `*-UTF16-H` ToUnicode maps) cannot be represented in our u16-keyed
1934    // map. Skip the entry rather than failing the entire ToUnicode parse;
1935    // the composite-font decoder falls back to direct UTF-16 decoding for
1936    // those code points anyway.
1937    let Ok(cid) = parse_cid_token(&tokens[0]) else {
1938        return Ok(());
1939    };
1940    mapping.insert(cid, decode_utf16be_lossy(&tokens[1]));
1941    Ok(())
1942}
1943
1944fn parse_bfrange_line(line: &str, mapping: &mut BTreeMap<u16, String>) -> PdfResult<()> {
1945    let tokens = extract_hex_tokens(line);
1946    if tokens.len() < 3 {
1947        return Ok(());
1948    }
1949    // Mirror parse_bfchar_line's tolerance for source codes that exceed
1950    // our u16 key space (4-byte UTF-16 surrogate pairs).
1951    let (Ok(start), Ok(end)) = (parse_cid_token(&tokens[0]), parse_cid_token(&tokens[1])) else {
1952        return Ok(());
1953    };
1954    if line.contains('[') {
1955        for (offset, destination) in tokens.iter().skip(2).enumerate() {
1956            let cid = start.saturating_add(offset as u16);
1957            if cid > end {
1958                break;
1959            }
1960            mapping.insert(cid, decode_utf16be_lossy(destination));
1961        }
1962        return Ok(());
1963    }
1964
1965    let base = parse_unicode_scalar(&tokens[2])?;
1966    for cid in start..=end {
1967        let scalar = base + u32::from(cid - start);
1968        mapping.insert(
1969            cid,
1970            char::from_u32(scalar).unwrap_or('\u{FFFD}').to_string(),
1971        );
1972    }
1973    Ok(())
1974}
1975
1976fn extract_hex_tokens(line: &str) -> Vec<Vec<u8>> {
1977    let mut tokens = Vec::new();
1978    let mut current = String::new();
1979    let mut in_hex = false;
1980    for character in line.chars() {
1981        match character {
1982            '<' => {
1983                current.clear();
1984                in_hex = true;
1985            }
1986            '>' if in_hex => {
1987                if let Ok(bytes) = parse_hex_string_token(&current) {
1988                    tokens.push(bytes);
1989                }
1990                in_hex = false;
1991            }
1992            _ if in_hex => current.push(character),
1993            _ => {}
1994        }
1995    }
1996    tokens
1997}
1998
1999fn parse_hex_string_token(token: &str) -> PdfResult<Vec<u8>> {
2000    let filtered = token
2001        .chars()
2002        .filter(|character| !character.is_whitespace())
2003        .collect::<String>();
2004    let mut chars = filtered.chars().collect::<Vec<_>>();
2005    if chars.len() % 2 != 0 {
2006        chars.push('0');
2007    }
2008    let mut bytes = Vec::with_capacity(chars.len() / 2);
2009    for pair in chars.chunks(2) {
2010        let byte = u8::from_str_radix(&pair.iter().collect::<String>(), 16)
2011            .map_err(|_| PdfError::Corrupt("invalid ToUnicode hex token".to_string()))?;
2012        bytes.push(byte);
2013    }
2014    Ok(bytes)
2015}
2016
2017fn parse_cid_token(bytes: &[u8]) -> PdfResult<u16> {
2018    match bytes {
2019        [single] => Ok(u16::from(*single)),
2020        [high, low] => Ok(u16::from_be_bytes([*high, *low])),
2021        _ => Err(PdfError::Unsupported(
2022            "only one-byte and two-byte CIDs are supported".to_string(),
2023        )),
2024    }
2025}
2026
2027fn parse_unicode_scalar(bytes: &[u8]) -> PdfResult<u32> {
2028    match bytes {
2029        [high, low] => Ok(u32::from(u16::from_be_bytes([*high, *low]))),
2030        [0, 0, high, low] => Ok(u32::from(u16::from_be_bytes([*high, *low]))),
2031        _ => Err(PdfError::Unsupported(
2032            "sequential ToUnicode ranges must use a single UTF-16 code unit".to_string(),
2033        )),
2034    }
2035}
2036
2037fn decode_utf16be_lossy(bytes: &[u8]) -> String {
2038    let mut units = Vec::new();
2039    let mut index = 0usize;
2040    while index < bytes.len() {
2041        let high = bytes[index];
2042        let low = *bytes.get(index + 1).unwrap_or(&0);
2043        units.push(u16::from_be_bytes([high, low]));
2044        index += 2;
2045    }
2046    String::from_utf16_lossy(&units)
2047}
2048
2049fn decode_composite_glyphs(font: &CompositeFont, bytes: &[u8]) -> PdfResult<Vec<DecodedGlyph>> {
2050    if bytes.len() % 2 != 0 {
2051        return Err(PdfError::Corrupt(
2052            "composite font string must contain an even number of bytes".to_string(),
2053        ));
2054    }
2055    let mut glyphs = Vec::new();
2056    let mut byte_index = 0usize;
2057    while byte_index < bytes.len() {
2058        match font.encoding {
2059            CompositeEncoding::IdentityH => {
2060                let cid = u16::from_be_bytes([bytes[byte_index], bytes[byte_index + 1]]);
2061                let text = font
2062                    .unicode_map
2063                    .get(&cid)
2064                    .cloned()
2065                    .unwrap_or_else(|| decode_fallback_cid(cid));
2066                let width_units = font.widths.get(&cid).copied().unwrap_or(font.default_width);
2067                glyphs.push(DecodedGlyph {
2068                    text,
2069                    width_units,
2070                    byte_start: byte_index,
2071                    byte_end: byte_index + 2,
2072                });
2073                byte_index += 2;
2074            }
2075            CompositeEncoding::Ucs2H => {
2076                let code = u16::from_be_bytes([bytes[byte_index], bytes[byte_index + 1]]);
2077                // ToUnicode override (PDF 9.10.2) takes precedence when present.
2078                let text = font.unicode_map.get(&code).cloned().unwrap_or_else(|| {
2079                    if (0xD800..=0xDFFF).contains(&code) {
2080                        // Surrogate halves are not valid UCS-2 scalars.
2081                        '\u{FFFD}'.to_string()
2082                    } else {
2083                        char::from_u32(u32::from(code))
2084                            .unwrap_or('\u{FFFD}')
2085                            .to_string()
2086                    }
2087                });
2088                glyphs.push(DecodedGlyph {
2089                    text,
2090                    width_units: font.default_width,
2091                    byte_start: byte_index,
2092                    byte_end: byte_index + 2,
2093                });
2094                byte_index += 2;
2095            }
2096            CompositeEncoding::Utf16H => {
2097                let code = u16::from_be_bytes([bytes[byte_index], bytes[byte_index + 1]]);
2098                if (0xD800..=0xDBFF).contains(&code) {
2099                    // High surrogate: must be followed by a low surrogate.
2100                    if byte_index + 4 > bytes.len() {
2101                        return Err(PdfError::Corrupt(
2102                            "truncated UTF-16 surrogate pair in composite font string".to_string(),
2103                        ));
2104                    }
2105                    let low = u16::from_be_bytes([bytes[byte_index + 2], bytes[byte_index + 3]]);
2106                    if !(0xDC00..=0xDFFF).contains(&low) {
2107                        return Err(PdfError::Corrupt(
2108                            "invalid UTF-16 surrogate pair in composite font string".to_string(),
2109                        ));
2110                    }
2111                    let scalar =
2112                        0x10000u32 + ((u32::from(code) - 0xD800) << 10) + (u32::from(low) - 0xDC00);
2113                    let text = char::from_u32(scalar).unwrap_or('\u{FFFD}').to_string();
2114                    glyphs.push(DecodedGlyph {
2115                        text,
2116                        width_units: font.default_width,
2117                        byte_start: byte_index,
2118                        byte_end: byte_index + 4,
2119                    });
2120                    byte_index += 4;
2121                } else if (0xDC00..=0xDFFF).contains(&code) {
2122                    // Orphan low surrogate.
2123                    glyphs.push(DecodedGlyph {
2124                        text: '\u{FFFD}'.to_string(),
2125                        width_units: font.default_width,
2126                        byte_start: byte_index,
2127                        byte_end: byte_index + 2,
2128                    });
2129                    byte_index += 2;
2130                } else {
2131                    let text = font.unicode_map.get(&code).cloned().unwrap_or_else(|| {
2132                        char::from_u32(u32::from(code))
2133                            .unwrap_or('\u{FFFD}')
2134                            .to_string()
2135                    });
2136                    glyphs.push(DecodedGlyph {
2137                        text,
2138                        width_units: font.default_width,
2139                        byte_start: byte_index,
2140                        byte_end: byte_index + 2,
2141                    });
2142                    byte_index += 2;
2143                }
2144            }
2145        }
2146    }
2147    Ok(glyphs)
2148}
2149
2150fn decode_fallback_cid(cid: u16) -> String {
2151    if cid <= 0x7f {
2152        char::from_u32(u32::from(cid))
2153            .unwrap_or('\u{FFFD}')
2154            .to_string()
2155    } else {
2156        '\u{FFFD}'.to_string()
2157    }
2158}
2159
2160fn matrix_from_operands(operands: &[PdfValue]) -> PdfResult<Matrix> {
2161    if operands.len() != 6 {
2162        return Err(PdfError::Corrupt(
2163            "matrix operator expects six operands".to_string(),
2164        ));
2165    }
2166    Ok(Matrix {
2167        a: operands[0]
2168            .as_number()
2169            .ok_or_else(|| PdfError::Corrupt("matrix operand is not numeric".to_string()))?,
2170        b: operands[1]
2171            .as_number()
2172            .ok_or_else(|| PdfError::Corrupt("matrix operand is not numeric".to_string()))?,
2173        c: operands[2]
2174            .as_number()
2175            .ok_or_else(|| PdfError::Corrupt("matrix operand is not numeric".to_string()))?,
2176        d: operands[3]
2177            .as_number()
2178            .ok_or_else(|| PdfError::Corrupt("matrix operand is not numeric".to_string()))?,
2179        e: operands[4]
2180            .as_number()
2181            .ok_or_else(|| PdfError::Corrupt("matrix operand is not numeric".to_string()))?,
2182        f: operands[5]
2183            .as_number()
2184            .ok_or_else(|| PdfError::Corrupt("matrix operand is not numeric".to_string()))?,
2185    })
2186}
2187
2188fn operand_name(operation: &Operation, index: usize) -> PdfResult<&str> {
2189    operation
2190        .operands
2191        .get(index)
2192        .and_then(PdfValue::as_name)
2193        .ok_or_else(|| PdfError::Corrupt(format!("operand {index} is not a name")))
2194}
2195
2196fn operand_number(operation: &Operation, index: usize) -> PdfResult<f64> {
2197    operation
2198        .operands
2199        .get(index)
2200        .and_then(PdfValue::as_number)
2201        .ok_or_else(|| PdfError::Corrupt(format!("operand {index} is not numeric")))
2202}
2203
2204fn operand_string(operation: &Operation, index: usize) -> PdfResult<&pdf_objects::PdfString> {
2205    match operation.operands.get(index) {
2206        Some(PdfValue::String(string)) => Ok(string),
2207        _ => Err(PdfError::Corrupt(format!(
2208            "operand {index} is not a string"
2209        ))),
2210    }
2211}
2212
2213#[cfg(test)]
2214mod tests {
2215    use super::{
2216        CompositeEncoding, CompositeFont, DecodedGlyph, ExtractedPageText, Glyph,
2217        build_search_index, coalesce_match_quads, decode_composite_glyphs, search_page_text,
2218    };
2219    use pdf_graphics::{Point, Quad, Rect};
2220    use std::collections::BTreeMap;
2221
2222    fn empty_composite(encoding: CompositeEncoding) -> CompositeFont {
2223        CompositeFont {
2224            encoding,
2225            default_width: 1000.0,
2226            widths: BTreeMap::new(),
2227            unicode_map: BTreeMap::new(),
2228        }
2229    }
2230
2231    fn glyph_text(g: &DecodedGlyph) -> &str {
2232        g.text.as_str()
2233    }
2234
2235    #[test]
2236    fn ucs2_h_decodes_bmp_codes_directly_to_unicode() {
2237        // "中文" as UCS-2 BE = [0x4E,0x2D, 0x65,0x87]. No ToUnicode map.
2238        let font = empty_composite(CompositeEncoding::Ucs2H);
2239        let bytes = [0x4E, 0x2D, 0x65, 0x87];
2240        let glyphs = decode_composite_glyphs(&font, &bytes).expect("decode");
2241        assert_eq!(glyphs.len(), 2, "two glyphs");
2242        assert_eq!(glyph_text(&glyphs[0]), "中");
2243        assert_eq!(glyph_text(&glyphs[1]), "文");
2244        assert_eq!(glyphs[0].byte_start, 0);
2245        assert_eq!(glyphs[0].byte_end, 2);
2246        assert_eq!(glyphs[1].byte_start, 2);
2247        assert_eq!(glyphs[1].byte_end, 4);
2248        assert_eq!(glyphs[0].width_units, 1000.0);
2249        assert_eq!(glyphs[1].width_units, 1000.0);
2250    }
2251
2252    #[test]
2253    fn ucs2_h_emits_replacement_for_surrogate_halves() {
2254        // 0xD800 is a high-surrogate code unit; invalid in UCS-2.
2255        let font = empty_composite(CompositeEncoding::Ucs2H);
2256        let bytes = [0xD8, 0x00];
2257        let glyphs = decode_composite_glyphs(&font, &bytes).expect("decode");
2258        assert_eq!(glyphs.len(), 1);
2259        assert_eq!(glyph_text(&glyphs[0]), "\u{FFFD}");
2260    }
2261
2262    #[test]
2263    fn ucs2_h_to_unicode_overrides_direct_decode() {
2264        let mut font = empty_composite(CompositeEncoding::Ucs2H);
2265        // Override the BMP code for U+4E2D ("中") with a Latin string from
2266        // a ToUnicode CMap; this is rare but legal per PDF 9.10.2.
2267        font.unicode_map.insert(0x4E2D, "Z".to_string());
2268        let bytes = [0x4E, 0x2D];
2269        let glyphs = decode_composite_glyphs(&font, &bytes).expect("decode");
2270        assert_eq!(glyph_text(&glyphs[0]), "Z");
2271    }
2272
2273    #[test]
2274    fn utf16_h_decodes_surrogate_pair_then_bmp() {
2275        // [0xD840 0xDC00] = U+20000 ("𠀀"); [0x4E 0x2D] = "中".
2276        let font = empty_composite(CompositeEncoding::Utf16H);
2277        let bytes = [0xD8, 0x40, 0xDC, 0x00, 0x4E, 0x2D];
2278        let glyphs = decode_composite_glyphs(&font, &bytes).expect("decode");
2279        assert_eq!(glyphs.len(), 2, "two glyphs");
2280        assert_eq!(glyph_text(&glyphs[0]), "\u{20000}");
2281        assert_eq!(glyphs[0].byte_start, 0);
2282        assert_eq!(glyphs[0].byte_end, 4);
2283        assert_eq!(glyph_text(&glyphs[1]), "中");
2284        assert_eq!(glyphs[1].byte_start, 4);
2285        assert_eq!(glyphs[1].byte_end, 6);
2286        assert_eq!(glyphs[0].width_units, 1000.0);
2287        assert_eq!(glyphs[1].width_units, 1000.0);
2288    }
2289
2290    #[test]
2291    fn utf16_h_orphan_low_surrogate_emits_replacement() {
2292        let font = empty_composite(CompositeEncoding::Utf16H);
2293        let bytes = [0xDC, 0x00];
2294        let glyphs = decode_composite_glyphs(&font, &bytes).expect("decode");
2295        assert_eq!(glyphs.len(), 1);
2296        assert_eq!(glyph_text(&glyphs[0]), "\u{FFFD}");
2297        assert_eq!(glyphs[0].byte_end - glyphs[0].byte_start, 2);
2298    }
2299
2300    #[test]
2301    fn utf16_h_truncated_surrogate_pair_is_corrupt() {
2302        let font = empty_composite(CompositeEncoding::Utf16H);
2303        // High surrogate at the very end with no low half to follow.
2304        let bytes = [0xD8, 0x40];
2305        let err = decode_composite_glyphs(&font, &bytes).expect_err("must fail");
2306        let msg = format!("{err:?}");
2307        assert!(
2308            msg.contains("truncated UTF-16 surrogate pair"),
2309            "unexpected error: {msg}"
2310        );
2311    }
2312
2313    #[test]
2314    fn composite_encoding_from_name_accepts_known_cmaps() {
2315        use super::composite_encoding_from_name;
2316        assert_eq!(
2317            composite_encoding_from_name("Identity-H").expect("identity-h"),
2318            CompositeEncoding::IdentityH
2319        );
2320        assert_eq!(
2321            composite_encoding_from_name("UniGB-UCS2-H").expect("gb"),
2322            CompositeEncoding::Ucs2H
2323        );
2324        assert_eq!(
2325            composite_encoding_from_name("UniKS-UCS2-H").expect("ks"),
2326            CompositeEncoding::Ucs2H
2327        );
2328        assert_eq!(
2329            composite_encoding_from_name("UniJIS-UTF16-H").expect("jis"),
2330            CompositeEncoding::Utf16H
2331        );
2332        assert_eq!(
2333            composite_encoding_from_name("UniCNS-UTF16-H").expect("cns"),
2334            CompositeEncoding::Utf16H
2335        );
2336    }
2337
2338    #[test]
2339    fn tounicode_skips_oversize_bfchar_keys_but_keeps_bmp_entries() {
2340        use super::parse_to_unicode_cmap;
2341        // The 4-byte source code <D840DC00> is the UTF-16 surrogate pair
2342        // for U+20000. Our u16-keyed map cannot represent it, but a
2343        // surrounding BMP entry MUST still survive the parse.
2344        let cmap = b"/CIDInit /ProcSet findresource begin\n\
2345            12 dict begin\n\
2346            begincmap\n\
2347            2 beginbfchar\n\
2348            <D840DC00> <D840DC00>\n\
2349            <4E2D> <4E2D>\n\
2350            endbfchar\n\
2351            endcmap\n";
2352        let map = parse_to_unicode_cmap(cmap).expect("parse must succeed");
2353        assert_eq!(map.get(&0x4E2D).map(String::as_str), Some("中"));
2354        assert!(
2355            !map.contains_key(&0xD840),
2356            "surrogate-pair source must not leak into the BMP map"
2357        );
2358    }
2359
2360    #[test]
2361    fn tounicode_skips_oversize_bfrange_keys_but_keeps_bmp_entries() {
2362        use super::parse_to_unicode_cmap;
2363        let cmap = b"begincmap\n\
2364            2 beginbfrange\n\
2365            <D840DC00> <D840DC02> <D840DC00>\n\
2366            <4E2D> <4E2E> <4E2D>\n\
2367            endbfrange\n\
2368            endcmap\n";
2369        let map = parse_to_unicode_cmap(cmap).expect("parse must succeed");
2370        assert_eq!(map.get(&0x4E2D).map(String::as_str), Some("中"));
2371        assert_eq!(map.get(&0x4E2E).map(String::as_str), Some("丮"));
2372        assert!(!map.contains_key(&0xD840));
2373    }
2374
2375    #[test]
2376    fn predefined_cmap_without_widths_is_accepted() {
2377        use super::reject_unsupported_widths;
2378        // Identity-H always allowed: per-CID widths are looked up directly.
2379        reject_unsupported_widths(CompositeEncoding::IdentityH, &{
2380            let mut w = BTreeMap::new();
2381            w.insert(1, 1000.0);
2382            w.insert(2, 500.0);
2383            w
2384        })
2385        .expect("identity-h with /W is supported");
2386        // Ucs2-H and Utf16H allowed only with empty /W: no way to look up
2387        // widths for non-CID-keyed encodings.
2388        reject_unsupported_widths(CompositeEncoding::Ucs2H, &BTreeMap::new())
2389            .expect("ucs2-h without /W is supported");
2390        reject_unsupported_widths(CompositeEncoding::Utf16H, &BTreeMap::new())
2391            .expect("utf16-h without /W is supported");
2392    }
2393
2394    #[test]
2395    fn predefined_cmap_with_any_widths_is_rejected() {
2396        use super::reject_unsupported_widths;
2397        let mut widths = BTreeMap::new();
2398        widths.insert(1, 1000.0);
2399        for encoding in [CompositeEncoding::Ucs2H, CompositeEncoding::Utf16H] {
2400            let err = reject_unsupported_widths(encoding, &widths)
2401                .expect_err("any /W must be rejected for predefined CMaps");
2402            let msg = format!("{err:?}");
2403            assert!(
2404                msg.contains("not supported"),
2405                "expected unsupported error for {encoding:?}, got: {msg}"
2406            );
2407        }
2408        // Identity-H still OK — it uses /W per CID.
2409        reject_unsupported_widths(CompositeEncoding::IdentityH, &widths)
2410            .expect("identity-h still uses /W per CID");
2411    }
2412
2413    #[test]
2414    fn composite_encoding_from_name_rejects_vertical_and_unknown() {
2415        use super::composite_encoding_from_name;
2416        for name in [
2417            "Identity-V",
2418            "UniJIS-UTF16-V",
2419            "UniGB-UCS2-V",
2420            "90ms-RKSJ-H",
2421            "GBK-EUC-H",
2422            "",
2423        ] {
2424            let err = composite_encoding_from_name(name)
2425                .err()
2426                .unwrap_or_else(|| panic!("expected error for {name}"));
2427            let msg = format!("{err:?}");
2428            assert!(
2429                msg.contains("not supported"),
2430                "expected unsupported error for {name}, got: {msg}"
2431            );
2432        }
2433    }
2434
2435    #[test]
2436    fn utf16_h_to_unicode_consulted_for_bmp_only() {
2437        let mut font = empty_composite(CompositeEncoding::Utf16H);
2438        // ToUnicode entry for the BMP code "中" overrides direct decode.
2439        font.unicode_map.insert(0x4E2D, "Z".to_string());
2440        // ToUnicode entry for a high surrogate must NOT be consulted —
2441        // the SMP scalar is composed from the four raw bytes instead.
2442        font.unicode_map.insert(0xD840, "WRONG".to_string());
2443        let bytes = [0xD8, 0x40, 0xDC, 0x00, 0x4E, 0x2D];
2444        let glyphs = decode_composite_glyphs(&font, &bytes).expect("decode");
2445        assert_eq!(
2446            glyph_text(&glyphs[0]),
2447            "\u{20000}",
2448            "SMP must skip ToUnicode"
2449        );
2450        assert_eq!(glyph_text(&glyphs[1]), "Z", "BMP must use ToUnicode");
2451    }
2452
2453    fn make_glyph(text: char, x: f64, y: f64) -> Glyph {
2454        make_glyph_with_height(text, x, y, 12.0)
2455    }
2456
2457    fn make_glyph_with_height(text: char, x: f64, y: f64, height: f64) -> Glyph {
2458        let rect = Rect {
2459            x,
2460            y,
2461            width: 8.0,
2462            height,
2463        };
2464        Glyph {
2465            text,
2466            bbox: rect,
2467            quad: rect.to_quad(),
2468            page_char_index: 0,
2469            operation_index: 0,
2470            location: super::GlyphLocation::Direct {
2471                operand_index: 0,
2472                byte_start: 0,
2473                byte_end: 1,
2474            },
2475            visible: true,
2476            width_units: 600.0,
2477            source_form: None,
2478        }
2479    }
2480
2481    #[test]
2482    fn search_handles_multibyte_utf8_characters() {
2483        // Text: "café and tea" — 'é' is 2 bytes in UTF-8, which used to cause
2484        // byte-vs-char offset mismatch in normalized_to_display.
2485        let glyphs: Vec<Glyph> = "café and tea"
2486            .chars()
2487            .enumerate()
2488            .map(|(i, c)| make_glyph(c, i as f64 * 10.0, 100.0))
2489            .collect();
2490        let page = ExtractedPageText {
2491            page_index: 0,
2492            text: "café and tea".to_string(),
2493            items: Vec::new(),
2494            glyphs,
2495        };
2496        let matches = search_page_text(&page, "and");
2497        assert_eq!(matches.len(), 1, "should find exactly one 'and'");
2498        assert_eq!(
2499            matches[0].text, "and",
2500            "match text should be 'and', not a shifted substring"
2501        );
2502    }
2503
2504    #[test]
2505    fn search_index_byte_alignment() {
2506        // Verify that normalized_to_display has exactly one entry per byte
2507        let glyphs: Vec<Glyph> = "aé b"
2508            .chars()
2509            .enumerate()
2510            .map(|(i, c)| make_glyph(c, i as f64 * 10.0, 100.0))
2511            .collect();
2512        let page = ExtractedPageText {
2513            page_index: 0,
2514            text: "aé b".to_string(),
2515            items: Vec::new(),
2516            glyphs,
2517        };
2518        let index = build_search_index(&page);
2519        assert_eq!(
2520            index.normalized_to_display.len(),
2521            index.normalized_text.len(),
2522            "normalized_to_display should have one entry per byte of normalized_text"
2523        );
2524    }
2525
2526    #[test]
2527    fn coalesces_adjacent_glyph_quads_into_match_regions() {
2528        let quads = vec![
2529            Quad {
2530                points: [
2531                    Point { x: 10.0, y: 20.0 },
2532                    Point { x: 14.0, y: 20.0 },
2533                    Point { x: 14.0, y: 30.0 },
2534                    Point { x: 10.0, y: 30.0 },
2535                ],
2536            },
2537            Quad {
2538                points: [
2539                    Point { x: 14.3, y: 20.0 },
2540                    Point { x: 18.5, y: 20.0 },
2541                    Point { x: 18.5, y: 30.0 },
2542                    Point { x: 14.3, y: 30.0 },
2543                ],
2544            },
2545            Quad {
2546                points: [
2547                    Point { x: 50.0, y: 5.0 },
2548                    Point { x: 54.0, y: 5.0 },
2549                    Point { x: 54.0, y: 15.0 },
2550                    Point { x: 50.0, y: 15.0 },
2551                ],
2552            },
2553        ];
2554
2555        let merged = coalesce_match_quads(&quads);
2556        assert_eq!(merged.len(), 2);
2557        let first = merged[0].bounding_rect();
2558        assert_eq!(
2559            first.x, 10.0,
2560            "without padding, rect should start at exact glyph boundary"
2561        );
2562        assert_eq!(first.max_x(), 18.5, "first two glyphs should be merged");
2563    }
2564
2565    #[test]
2566    fn sub_pt_rows_are_not_merged_by_line_grouper() {
2567        // Three rows of 6pt Helvetica (bbox.height ≈ 4.8) sitting only
2568        // 0.5pt apart in y. The previous heuristic capped y-tolerance at
2569        // 1.0pt and merged all three into a single visual line. The
2570        // anchor-based check (height_ref * 0.10 = 0.48pt for 6pt glyphs)
2571        // keeps each row on its own line, so search for "AB" finds zero
2572        // matches (no adjacency) but each single-letter search hits once.
2573        let h = 4.8_f64;
2574        // bbox.y = baseline - 6 * 0.12 = baseline - 0.72
2575        let row_a_y = 700.0 - 0.72;
2576        let row_b_y = 699.5 - 0.72;
2577        let row_c_y = 699.0 - 0.72;
2578        let glyphs = vec![
2579            make_glyph_with_height('A', 72.0, row_a_y, h),
2580            make_glyph_with_height('B', 72.0, row_b_y, h),
2581            make_glyph_with_height('C', 72.0, row_c_y, h),
2582        ];
2583        let page = ExtractedPageText {
2584            page_index: 0,
2585            text: "ABC".to_string(),
2586            items: Vec::new(),
2587            glyphs,
2588        };
2589        let matches_ab = search_page_text(&page, "AB");
2590        assert!(
2591            matches_ab.is_empty(),
2592            "rows A and B must be on separate lines (no 'AB' adjacency); got {:?}",
2593            matches_ab
2594        );
2595        for needle in ["A", "B", "C"] {
2596            let hits = search_page_text(&page, needle);
2597            assert_eq!(
2598                hits.len(),
2599                1,
2600                "row {needle} must yield exactly one match; got {hits:?}"
2601            );
2602        }
2603    }
2604
2605    #[test]
2606    fn mixed_font_same_baseline_glyphs_merge_into_one_line() {
2607        // 10pt 'H' and 8pt 'W' on the same baseline. Their y-centres
2608        // differ by 0.56pt (10pt centre = baseline + 2.8, 8pt centre =
2609        // baseline + 2.24). With height_ref = 8.0 (10pt bbox.height)
2610        // the tolerance is 0.80pt, so the 8pt glyph merges into the
2611        // 10pt-anchored line. The two letters appear consecutively in
2612        // the visual display string so "HW" matches once.
2613        let glyphs = vec![
2614            // 10pt: bbox.y = baseline - 1.2 = -1.2, height = 8.0,
2615            // centre = -1.2 + 4.0 = 2.8 (baseline = 0).
2616            make_glyph_with_height('H', 10.0, -1.2, 8.0),
2617            // 8pt: bbox.y = baseline - 0.96 = -0.96, height = 6.4,
2618            // centre = -0.96 + 3.2 = 2.24. Place W flush against H's
2619            // right edge (gap 0) so no synthetic space is injected
2620            // between them in the display string.
2621            make_glyph_with_height('W', 18.0, -0.96, 6.4),
2622        ];
2623        let page = ExtractedPageText {
2624            page_index: 0,
2625            text: "HW".to_string(),
2626            items: Vec::new(),
2627            glyphs,
2628        };
2629        let matches = search_page_text(&page, "HW");
2630        assert_eq!(
2631            matches.len(),
2632            1,
2633            "10pt + 8pt same-baseline glyphs must share a visual line; got {matches:?}"
2634        );
2635    }
2636}