Skip to main content

pdf_manip/
text_run.rs

1//! Text run identification from content streams with CMap-aware decoding.
2//!
3//! Walks content stream operations, tracks font/text state, and decodes
4//! text bytes to Unicode using ToUnicode CMaps extracted from font resources.
5
6// Copyright (c) 2026 Innovation Trigger B.V.
7//
8// PDFluent is available under two licences, at your option: the GNU AGPLv3, or
9// the PDFluent Commercial Licence. See the LICENSE file in this repository --
10// that file travels with the copy you received, which a URL does not.
11
12use crate::content_editor::{as_number, concat_cm, offset_text_line, ContentEditor};
13use crate::encoding_utils::build_font_encoding;
14use crate::error::{ManipError, Result};
15use lopdf::content::Operation;
16use lopdf::{Document, Object, ObjectId};
17use pdf_font::cmap::{BfString, CMap};
18use std::collections::HashMap;
19use std::ops::Range;
20
21const IDENTITY: [f64; 6] = [1.0, 0.0, 0.0, 1.0, 0.0, 0.0];
22
23/// A contiguous run of text extracted from a content stream.
24#[derive(Debug, Clone)]
25pub struct TextRun {
26    /// Decoded Unicode text.
27    pub text: String,
28    /// Range of operation indices in the content stream that produced this run.
29    pub ops_range: Range<usize>,
30    /// Font resource name (e.g. "F1").
31    pub font_name: String,
32    /// Font size in points.
33    pub font_size: f64,
34    /// X position in user space.
35    pub x: f64,
36    /// Y position in user space.
37    pub y: f64,
38    /// Estimated width of the text run in user space units.
39    pub width: f64,
40}
41
42/// Font encoding information extracted from a PDF document.
43///
44/// Maps font resource names to their ToUnicode CMap (if available),
45/// encoding type, and glyph widths.
46#[derive(Debug, Clone)]
47pub struct FontMap {
48    pub(crate) fonts: HashMap<String, FontInfo>,
49}
50
51#[derive(Debug, Clone)]
52pub(crate) struct FontInfo {
53    pub(crate) to_unicode: Option<CMap>,
54    pub(crate) encoding: FontEncoding,
55    pub(crate) widths: GlyphWidths,
56    /// Differences-based code→Unicode map for fonts without ToUnicode.
57    /// Empty when no Encoding/Differences information is available.
58    pub(crate) differences_encoding: HashMap<u8, char>,
59    /// True when the BaseFont name has a subset prefix (e.g. "ABCDEF+FontName").
60    /// Subset fonts only contain the glyphs used in the document; encoding
61    /// replacement text with arbitrary characters is unsafe.
62    pub(crate) is_subset: bool,
63    /// True when the FontDescriptor Flags field has the Symbolic bit set (bit 3,
64    /// value 4).  Symbolic fonts use the font's built-in encoding rather than
65    /// the PDF-level Encoding or StandardEncoding.  Without a ToUnicode CMap or
66    /// explicit Encoding/Differences we cannot determine the correct byte→char
67    /// mapping, so Latin-1 fallback encoding is unsafe for these fonts.
68    pub(crate) is_symbolic: bool,
69    /// Forward encoding from the font's Encoding/Differences dict: maps byte
70    /// code → Unicode char.  Built for ALL single-byte (Builtin) fonts that
71    /// have an Encoding entry, including symbolic fonts.  Used only for
72    /// decoding (bytes → text); the reverse map for text replacement still
73    /// skips symbolic fonts via the `is_symbolic` guard. Fixes #466.
74    pub(crate) forward_encoding: HashMap<u8, char>,
75    /// Inverse of the embedded TrueType cmap: maps glyph index → Unicode char.
76    /// Built from FontFile2 when ToUnicode is absent; empty otherwise.
77    /// Additional fallback after `forward_encoding` for TrueType fonts with
78    /// a cmap but no usable Encoding/Differences dict. Fixes #466.
79    pub(crate) cmap_inverse: HashMap<u32, char>,
80}
81
82/// Font encoding type.
83#[derive(Debug, Clone, PartialEq)]
84pub(crate) enum FontEncoding {
85    /// Standard built-in encoding or Differences-based.
86    Builtin,
87    /// Identity-H or Identity-V (2-byte CID).
88    IdentityH,
89    /// Custom CMap encoding.
90    CustomCMap,
91}
92
93/// Glyph width information for estimating text run widths.
94#[derive(Debug, Clone)]
95pub(crate) struct GlyphWidths {
96    first_char: u32,
97    widths: Vec<f64>,
98    default_width: f64,
99}
100
101impl GlyphWidths {
102    fn empty() -> Self {
103        Self {
104            first_char: 0,
105            widths: Vec::new(),
106            default_width: 1000.0,
107        }
108    }
109
110    /// Get the width of a character code in font units (typically 1/1000 em).
111    fn width_for_code(&self, code: u32) -> f64 {
112        let idx = code.checked_sub(self.first_char).and_then(|i| {
113            let i = i as usize;
114            if i < self.widths.len() {
115                Some(i)
116            } else {
117                None
118            }
119        });
120        match idx {
121            Some(i) => self.widths[i],
122            None => self.default_width,
123        }
124    }
125}
126
127impl FontMap {
128    /// Build a font map from a page's font resources in a lopdf Document.
129    pub fn from_page(doc: &Document, page_num: u32) -> Result<Self> {
130        let pages = doc.get_pages();
131        let total = pages.len() as u32;
132        let &page_id = pages.get(&page_num).ok_or(ManipError::PageOutOfRange(
133            page_num as usize,
134            total as usize,
135        ))?;
136
137        let mut fonts = HashMap::new();
138        let font_dict = get_page_font_dict(doc, page_id);
139
140        for (name, font_ref) in &font_dict {
141            let info = build_font_info(doc, font_ref);
142            fonts.insert(name.clone(), info);
143        }
144
145        Ok(Self { fonts })
146    }
147
148    /// Create an empty font map (no CMap decoding — raw byte fallback).
149    pub fn empty() -> Self {
150        Self {
151            fonts: HashMap::new(),
152        }
153    }
154
155    /// Build a FontMap from a Form XObject's (or AP stream's) own Resources/Font
156    /// dictionary.  The XObject's fonts take priority over `page_fonts` so that
157    /// character-code decoding uses the correct CMap for each stream.
158    ///
159    /// Falls back to `page_fonts` for any name not found in the stream's own
160    /// Resources, and uses `page_fonts` as-is when the stream has no Resources.
161    pub fn from_xobject_stream(doc: &Document, stream_id: ObjectId, page_fonts: &FontMap) -> Self {
162        // Start with page-level fonts as the base (fallback for any missing entries).
163        let mut fonts = page_fonts.fonts.clone();
164
165        let stream_dict = match doc.get_object(stream_id) {
166            Ok(Object::Stream(ref s)) => s.dict.clone(),
167            _ => return Self { fonts },
168        };
169
170        let resources = match stream_dict.get(b"Resources") {
171            Ok(Object::Dictionary(ref d)) => d.clone(),
172            Ok(Object::Reference(id)) => match doc.get_object(*id) {
173                Ok(Object::Dictionary(ref d)) => d.clone(),
174                _ => return Self { fonts },
175            },
176            _ => return Self { fonts },
177        };
178
179        let font_dict = match resources.get(b"Font") {
180            Ok(Object::Dictionary(ref d)) => d.clone(),
181            Ok(Object::Reference(id)) => match doc.get_object(*id) {
182                Ok(Object::Dictionary(ref d)) => d.clone(),
183                _ => return Self { fonts },
184            },
185            _ => return Self { fonts },
186        };
187
188        for (key, value) in font_dict.iter() {
189            let name = String::from_utf8_lossy(key).to_string();
190            if let Object::Reference(id) = value {
191                // XObject's own font definition overrides any page-level entry
192                // with the same name, ensuring correct CMap decoding.
193                let info = build_font_info(doc, id);
194                fonts.insert(name, info);
195            }
196        }
197
198        Self { fonts }
199    }
200
201    /// Decode a PDF string from a Tj/TJ operand using the font's ToUnicode CMap.
202    pub fn decode_string(&self, font_name: &str, bytes: &[u8]) -> String {
203        let info = match self.fonts.get(font_name) {
204            Some(info) => info,
205            None => return decode_pdf_string_fallback(bytes),
206        };
207
208        if let Some(ref cmap) = info.to_unicode {
209            return decode_with_cmap(bytes, cmap, &info.encoding);
210        }
211
212        // Fallback 1: use the font's Encoding/Differences dict for decoding.
213        // Handles symbolic TrueType subsets with explicit glyph-name Differences
214        // (e.g. GIYRNY+Arial,Bold with [1 /C /e /r /t ...]) that have no
215        // ToUnicode CMap. Unlike `differences_encoding`, `forward_encoding` is
216        // populated for symbolic fonts too. Fixes #466.
217        if !info.forward_encoding.is_empty() {
218            return bytes
219                .iter()
220                .map(|&b| info.forward_encoding.get(&b).copied().unwrap_or(b as char))
221                .collect();
222        }
223
224        // Fallback 2: use the inverse of the embedded TrueType cmap when ToUnicode
225        // is absent, no Encoding/Differences covers these codes, but the font
226        // program's cmap maps glyph indices back to Unicode. Fixes #466.
227        if !info.cmap_inverse.is_empty() {
228            return bytes
229                .iter()
230                .map(|&b| {
231                    info.cmap_inverse
232                        .get(&(b as u32))
233                        .copied()
234                        .unwrap_or(b as char)
235                })
236                .collect();
237        }
238
239        decode_pdf_string_fallback(bytes)
240    }
241
242    /// Get the width of a character code in font units.
243    fn char_width(&self, font_name: &str, code: u32) -> f64 {
244        self.fonts
245            .get(font_name)
246            .map(|info| info.widths.width_for_code(code))
247            .unwrap_or(1000.0)
248    }
249
250    /// Advance width of `text` in em units (1.0 = one font size) for `font`.
251    ///
252    /// Used by ShrinkToFit to compare a replacement against the space the
253    /// original occupied. Characters the font cannot encode fall back to a
254    /// nominal 0.5 em rather than being skipped: a missing character that
255    /// counts as zero would make an overrunning replacement look like it fits.
256    pub(crate) fn text_width_em(&self, font: &str, text: &str) -> f64 {
257        let Some(info) = self.fonts.get(font) else {
258            return text.chars().count() as f64 * 0.5;
259        };
260        let reverse = self.build_reverse_map(font);
261        text.chars()
262            .map(|ch| {
263                reverse
264                    .get(&ch)
265                    .map(|&code| info.widths.width_for_code(code) / 1000.0)
266                    .unwrap_or(0.5)
267            })
268            .sum()
269    }
270
271    /// Check if a font uses 2-byte CID encoding.
272    pub(crate) fn is_cid_font(&self, font_name: &str) -> bool {
273        self.fonts
274            .get(font_name)
275            .map(|info| {
276                matches!(
277                    info.encoding,
278                    FontEncoding::IdentityH | FontEncoding::CustomCMap
279                )
280            })
281            .unwrap_or(false)
282    }
283
284    /// Returns true when the font is a subset (BaseFont has an "ABCDEF+" prefix).
285    ///
286    /// Subset fonts only contain the glyphs used in the source document.
287    /// Encoding arbitrary replacement text is unsafe because the required
288    /// glyphs may not be present.
289    pub(crate) fn is_subset_font(&self, font_name: &str) -> bool {
290        self.fonts
291            .get(font_name)
292            .map(|info| info.is_subset)
293            .unwrap_or(false)
294    }
295
296    /// Returns true when the font has the Symbolic flag set in its FontDescriptor.
297    ///
298    /// Symbolic fonts use the font's built-in encoding rather than the PDF-level
299    /// Encoding entry or the StandardEncoding default.  Without a ToUnicode CMap
300    /// or explicit Differences we cannot reliably infer the byte→char mapping,
301    /// making Latin-1 fallback encoding unsafe.
302    pub(crate) fn is_symbolic_font(&self, font_name: &str) -> bool {
303        self.fonts
304            .get(font_name)
305            .map(|info| info.is_symbolic)
306            .unwrap_or(false)
307    }
308
309    /// For a CID font, return the byte offset in `orig_bytes` after the first
310    /// `char_count` decoded Unicode chars.
311    ///
312    /// Each CID code is 2 bytes. Some codes map to multiple Unicode chars via
313    /// `BfString::String` (e.g. ligatures). This function walks the bytes
314    /// two at a time, accumulating decoded char counts, and returns the byte
315    /// offset once `char_count` chars have been consumed.
316    ///
317    /// Falls back to `char_count * 2` when the ToUnicode CMap is unavailable,
318    /// assuming a 1:1 char-to-CID-code mapping.
319    pub(crate) fn cid_byte_offset_for_chars(
320        &self,
321        font_name: &str,
322        orig_bytes: &[u8],
323        char_count: usize,
324    ) -> usize {
325        if char_count == 0 {
326            return 0;
327        }
328        let info = match self.fonts.get(font_name) {
329            Some(info) => info,
330            None => return char_count * 2,
331        };
332        let cmap = match info.to_unicode {
333            Some(ref c) => c,
334            None => return char_count * 2,
335        };
336        let mut chars_seen = 0;
337        let mut i = 0;
338        while i + 1 < orig_bytes.len() {
339            if chars_seen >= char_count {
340                break;
341            }
342            let code = u32::from(orig_bytes[i]) << 8 | u32::from(orig_bytes[i + 1]);
343            let n = match cmap.lookup_bf_string(code) {
344                Some(BfString::Char(_)) => 1,
345                Some(BfString::String(ref s)) => s.chars().count().max(1),
346                None => 1,
347            };
348            chars_seen += n;
349            i += 2;
350        }
351        i
352    }
353
354    /// Build a reverse map (Unicode char → character code) for a font.
355    ///
356    /// Priority:
357    /// 1. ToUnicode CMap reverse map (most authoritative).
358    /// 2. Differences/Encoding-based reverse map (for fonts without ToUnicode
359    ///    but with explicit Encoding/Differences, e.g. TeX OT1 fonts). Only
360    ///    populated for single-byte (Builtin) fonts.
361    /// 3. Empty map — caller falls back to Latin-1.
362    pub fn build_reverse_map(&self, font_name: &str) -> std::collections::HashMap<char, u32> {
363        let mut reverse = std::collections::HashMap::new();
364        let info = match self.fonts.get(font_name) {
365            Some(info) => info,
366            None => return reverse,
367        };
368
369        if let Some(ref cmap) = info.to_unicode {
370            let max_code: u32 = if matches!(info.encoding, FontEncoding::Builtin) {
371                0xFF
372            } else {
373                0xFFFF
374            };
375            for code in 0..=max_code {
376                if let Some(BfString::Char(ch)) = cmap.lookup_bf_string(code) {
377                    reverse.entry(ch).or_insert(code);
378                }
379            }
380            return reverse;
381        }
382
383        // No ToUnicode: use Differences-based encoding if available.
384        // Build reverse from differences_encoding (char → code).
385        if !info.differences_encoding.is_empty() && matches!(info.encoding, FontEncoding::Builtin) {
386            for (&code, &ch) in &info.differences_encoding {
387                reverse.entry(ch).or_insert(u32::from(code));
388            }
389        }
390
391        reverse
392    }
393}
394
395/// Extract text runs from a content stream using font encoding information.
396pub fn extract_text_runs(editor: &ContentEditor, fonts: &FontMap) -> Vec<TextRun> {
397    let ops = editor.operations();
398    let mut runs = Vec::new();
399    let mut state = TextState::default();
400
401    for (idx, op) in ops.iter().enumerate() {
402        match op.operator.as_str() {
403            "q" => {
404                state.gs_stack.push(SavedGS { ctm: state.ctm });
405            }
406            "Q" => {
407                if let Some(saved) = state.gs_stack.pop() {
408                    state.ctm = saved.ctm;
409                }
410            }
411            "cm" => {
412                if let Some(m) = extract_matrix(&op.operands) {
413                    state.ctm = concat_cm(&m, &state.ctm);
414                }
415            }
416            "BT" => {
417                state.in_text = true;
418                state.tm = IDENTITY;
419                state.tlm = IDENTITY;
420            }
421            "ET" => {
422                state.in_text = false;
423            }
424            "Tf"
425                if op.operands.len() >= 2 => {
426                    if let Object::Name(ref name) = op.operands[0] {
427                        state.font_name = String::from_utf8_lossy(name).to_string();
428                    }
429                    if let Some(size) = as_number(&op.operands[1]) {
430                        state.font_size = size;
431                    }
432                }
433            "Tc" => {
434                if let Some(v) = op.operands.first().and_then(as_number) {
435                    state.tc = v;
436                }
437            }
438            "Tw" => {
439                if let Some(v) = op.operands.first().and_then(as_number) {
440                    state.tw = v;
441                }
442            }
443            "Tz" => {
444                if let Some(v) = op.operands.first().and_then(as_number) {
445                    state.th = v;
446                }
447            }
448            "TL" => {
449                if let Some(v) = op.operands.first().and_then(as_number) {
450                    state.tl = v;
451                }
452            }
453            "Ts" => {
454                if let Some(v) = op.operands.first().and_then(as_number) {
455                    state.ts = v;
456                }
457            }
458            "Td"
459                if op.operands.len() >= 2 => {
460                    let tx = as_number(&op.operands[0]).unwrap_or(0.0);
461                    let ty = as_number(&op.operands[1]).unwrap_or(0.0);
462                    let new_tlm = offset_text_line(&state.tlm, tx, ty);
463                    state.tlm = new_tlm;
464                    state.tm = new_tlm;
465                }
466            "TD"
467                if op.operands.len() >= 2 => {
468                    let tx = as_number(&op.operands[0]).unwrap_or(0.0);
469                    let ty = as_number(&op.operands[1]).unwrap_or(0.0);
470                    state.tl = -ty;
471                    let new_tlm = offset_text_line(&state.tlm, tx, ty);
472                    state.tlm = new_tlm;
473                    state.tm = new_tlm;
474                }
475            "Tm" => {
476                if let Some(m) = extract_matrix(&op.operands) {
477                    state.tm = m;
478                    state.tlm = m;
479                }
480            }
481            "T*" => {
482                let new_tlm = offset_text_line(&state.tlm, 0.0, -state.tl);
483                state.tlm = new_tlm;
484                state.tm = new_tlm;
485            }
486            "Tj" => {
487                if let Some(run) = process_tj(idx, op, &mut state, fonts) {
488                    runs.push(run);
489                }
490            }
491            "TJ" => {
492                if let Some(run) = process_tj_array(idx, op, &mut state, fonts) {
493                    runs.push(run);
494                }
495            }
496            "'" => {
497                // Move to next line, then show text.
498                let new_tlm = offset_text_line(&state.tlm, 0.0, -state.tl);
499                state.tlm = new_tlm;
500                state.tm = new_tlm;
501
502                if let Some(run) = process_tj(idx, op, &mut state, fonts) {
503                    runs.push(run);
504                }
505            }
506            "\""
507                // Set word/char spacing, move to next line, show text.
508                if op.operands.len() >= 3 => {
509                    if let Some(tw) = as_number(&op.operands[0]) {
510                        state.tw = tw;
511                    }
512                    if let Some(tc) = as_number(&op.operands[1]) {
513                        state.tc = tc;
514                    }
515                    let new_tlm =
516                        offset_text_line(&state.tlm, 0.0, -state.tl);
517                    state.tlm = new_tlm;
518                    state.tm = new_tlm;
519
520                    // The string operand is the third one.
521                    if let Some(Object::String(ref bytes, _)) = op.operands.get(2) {
522                        let text = fonts.decode_string(&state.font_name, bytes);
523                        if !text.is_empty() {
524                            let x = compute_x(&state);
525                            let y = compute_y(&state);
526                            let width = compute_text_width(bytes, &state, fonts);
527                            advance_text_position(bytes, &mut state, fonts);
528                            runs.push(TextRun {
529                                text,
530                                ops_range: idx..idx + 1,
531                                font_name: state.font_name.clone(),
532                                font_size: state.font_size,
533                                x,
534                                y,
535                                width,
536                            });
537                        }
538                    }
539                }
540            _ => {}
541        }
542    }
543
544    runs
545}
546
547/// Extract text runs from a page in a document.
548pub fn extract_page_text_runs(doc: &Document, page_num: u32) -> Result<Vec<TextRun>> {
549    let editor = crate::content_editor::editor_for_page(doc, page_num)?;
550    let fonts = FontMap::from_page(doc, page_num)?;
551    Ok(extract_text_runs(&editor, &fonts))
552}
553
554// ---------------------------------------------------------------------------
555// Internal state
556// ---------------------------------------------------------------------------
557
558#[derive(Debug, Clone)]
559struct TextState {
560    ctm: [f64; 6],
561    tm: [f64; 6],
562    tlm: [f64; 6],
563    font_name: String,
564    font_size: f64,
565    tc: f64,
566    tw: f64,
567    th: f64,
568    tl: f64,
569    ts: f64,
570    in_text: bool,
571    gs_stack: Vec<SavedGS>,
572}
573
574#[derive(Debug, Clone)]
575struct SavedGS {
576    ctm: [f64; 6],
577}
578
579impl Default for TextState {
580    fn default() -> Self {
581        Self {
582            ctm: IDENTITY,
583            tm: IDENTITY,
584            tlm: IDENTITY,
585            font_name: String::new(),
586            font_size: 12.0,
587            tc: 0.0,
588            tw: 0.0,
589            th: 100.0,
590            tl: 0.0,
591            ts: 0.0,
592            in_text: false,
593            gs_stack: Vec::new(),
594        }
595    }
596}
597
598// ---------------------------------------------------------------------------
599// Text showing operator processors
600// ---------------------------------------------------------------------------
601
602fn process_tj(
603    idx: usize,
604    op: &Operation,
605    state: &mut TextState,
606    fonts: &FontMap,
607) -> Option<TextRun> {
608    let bytes = match op.operands.first() {
609        Some(Object::String(ref b, _)) => b,
610        _ => return None,
611    };
612
613    let text = fonts.decode_string(&state.font_name, bytes);
614    if text.is_empty() {
615        return None;
616    }
617
618    let x = compute_x(state);
619    let y = compute_y(state);
620    let width = compute_text_width(bytes, state, fonts);
621    advance_text_position(bytes, state, fonts);
622
623    Some(TextRun {
624        text,
625        ops_range: idx..idx + 1,
626        font_name: state.font_name.clone(),
627        font_size: state.font_size,
628        x,
629        y,
630        width,
631    })
632}
633
634fn process_tj_array(
635    idx: usize,
636    op: &Operation,
637    state: &mut TextState,
638    fonts: &FontMap,
639) -> Option<TextRun> {
640    let arr = match op.operands.first() {
641        Some(Object::Array(ref a)) => a,
642        _ => return None,
643    };
644
645    let x_start = compute_x(state);
646    let y = compute_y(state);
647    let mut combined_text = String::new();
648
649    for item in arr {
650        match item {
651            Object::String(ref bytes, _) => {
652                let text = fonts.decode_string(&state.font_name, bytes);
653                combined_text.push_str(&text);
654                advance_text_position(bytes, state, fonts);
655            }
656            _ => {
657                if let Some(adj) = as_number(item) {
658                    // TJ spacing: negative = move right, positive = move left.
659                    advance_tm(state, -adj / 1000.0 * state.font_size * (state.th / 100.0));
660                }
661            }
662        }
663    }
664
665    if combined_text.is_empty() {
666        return None;
667    }
668
669    let x_end = compute_x(state);
670    let width = (x_end - x_start).abs();
671
672    Some(TextRun {
673        text: combined_text,
674        ops_range: idx..idx + 1,
675        font_name: state.font_name.clone(),
676        font_size: state.font_size,
677        x: x_start,
678        y,
679        width,
680    })
681}
682
683// ---------------------------------------------------------------------------
684// Position and width computation
685// ---------------------------------------------------------------------------
686
687fn compute_x(state: &TextState) -> f64 {
688    // Apply CTM to text matrix position.
689    state.ctm[0] * state.tm[4] + state.ctm[2] * state.tm[5] + state.ctm[4]
690}
691
692fn compute_y(state: &TextState) -> f64 {
693    state.ctm[1] * state.tm[4] + state.ctm[3] * state.tm[5] + state.ctm[5]
694}
695
696fn compute_text_width(bytes: &[u8], state: &TextState, fonts: &FontMap) -> f64 {
697    let scale = state.font_size * (state.th / 100.0);
698    if fonts.is_cid_font(&state.font_name) {
699        // 2-byte codes.
700        let mut width = 0.0;
701        let mut i = 0;
702        while i + 1 < bytes.len() {
703            let code = u32::from(bytes[i]) << 8 | u32::from(bytes[i + 1]);
704            let w = fonts.char_width(&state.font_name, code);
705            width += w / 1000.0 * scale + state.tc;
706            i += 2;
707        }
708        width
709    } else {
710        // Single-byte codes.
711        let mut width = 0.0;
712        for &b in bytes {
713            let w = fonts.char_width(&state.font_name, u32::from(b));
714            width += w / 1000.0 * scale + state.tc;
715            if b == b' ' {
716                width += state.tw;
717            }
718        }
719        width
720    }
721}
722
723/// Move the text matrix along the text line by `tx` text-space units:
724/// `Tm' = T(tx, 0) x Tm` (ISO 32000-2, 9.4.4), so a scaled or rotated `Tm`
725/// scales or rotates the advance.
726fn advance_tm(state: &mut TextState, tx: f64) {
727    state.tm = offset_text_line(&state.tm, tx, 0.0);
728}
729
730fn advance_text_position(bytes: &[u8], state: &mut TextState, fonts: &FontMap) {
731    let scale = state.font_size * (state.th / 100.0);
732    let mut tx = 0.0;
733    if fonts.is_cid_font(&state.font_name) {
734        let mut i = 0;
735        while i + 1 < bytes.len() {
736            let code = u32::from(bytes[i]) << 8 | u32::from(bytes[i + 1]);
737            let w = fonts.char_width(&state.font_name, code);
738            tx += w / 1000.0 * scale + state.tc;
739            i += 2;
740        }
741    } else {
742        for &b in bytes {
743            let w = fonts.char_width(&state.font_name, u32::from(b));
744            tx += w / 1000.0 * scale + state.tc;
745            if b == b' ' {
746                tx += state.tw;
747            }
748        }
749    }
750    advance_tm(state, tx);
751}
752
753// ---------------------------------------------------------------------------
754// Font resource extraction
755// ---------------------------------------------------------------------------
756
757/// Get font name → font object ID mapping from a page's Resources.
758fn get_page_font_dict(doc: &Document, page_id: ObjectId) -> HashMap<String, ObjectId> {
759    let mut result = HashMap::new();
760
761    let page_dict = match doc.get_object(page_id) {
762        Ok(Object::Dictionary(ref d)) => d.clone(),
763        _ => return result,
764    };
765
766    // Try Resources directly, or inherited from parent.
767    let resources = match page_dict.get(b"Resources") {
768        Ok(Object::Dictionary(ref d)) => d.clone(),
769        Ok(Object::Reference(id)) => match doc.get_object(*id) {
770            Ok(Object::Dictionary(ref d)) => d.clone(),
771            _ => return result,
772        },
773        _ => return result,
774    };
775
776    let font_dict = match resources.get(b"Font") {
777        Ok(Object::Dictionary(ref d)) => d.clone(),
778        Ok(Object::Reference(id)) => match doc.get_object(*id) {
779            Ok(Object::Dictionary(ref d)) => d.clone(),
780            _ => return result,
781        },
782        _ => return result,
783    };
784
785    for (key, value) in font_dict.iter() {
786        let name = String::from_utf8_lossy(key).to_string();
787        if let Object::Reference(id) = value {
788            result.insert(name, *id);
789        }
790    }
791
792    result
793}
794
795/// Extract the Flags integer from a font's FontDescriptor (0 if absent).
796fn get_font_descriptor_flags(doc: &Document, font_dict: &lopdf::Dictionary) -> u32 {
797    let desc_obj = match font_dict.get(b"FontDescriptor").ok() {
798        Some(o) => o.clone(),
799        None => return 0,
800    };
801    let desc_dict = match desc_obj {
802        Object::Reference(id) => match doc.get_object(id) {
803            Ok(Object::Dictionary(ref d)) => d.clone(),
804            _ => return 0,
805        },
806        Object::Dictionary(ref d) => d.clone(),
807        _ => return 0,
808    };
809    match desc_dict.get(b"Flags").ok() {
810        Some(Object::Integer(i)) => *i as u32,
811        _ => 0,
812    }
813}
814
815/// Build FontInfo from a font dictionary in the document.
816fn build_font_info(doc: &Document, font_id: &ObjectId) -> FontInfo {
817    let font_dict = match doc.get_object(*font_id) {
818        Ok(Object::Dictionary(ref d)) => d.clone(),
819        _ => {
820            return FontInfo {
821                to_unicode: None,
822                encoding: FontEncoding::Builtin,
823                widths: GlyphWidths::empty(),
824                differences_encoding: HashMap::new(),
825                is_subset: false,
826                is_symbolic: false,
827                forward_encoding: HashMap::new(),
828                cmap_inverse: HashMap::new(),
829            }
830        }
831    };
832
833    // Parse ToUnicode CMap.
834    let to_unicode = extract_to_unicode(doc, &font_dict);
835
836    // Determine encoding type.
837    let encoding = determine_encoding(doc, &font_dict);
838
839    // Extract glyph widths.
840    let widths = extract_widths(doc, &font_dict);
841
842    // Detect subset fonts: BaseFont name starts with 6 uppercase ASCII letters
843    // followed by '+', e.g. "ABCDEF+CMR10".  Subset fonts only contain the
844    // glyphs actually used in the document, so encoding arbitrary replacement
845    // text is unsafe (the glyph might not be present in the subset).
846    let is_subset = font_dict
847        .get(b"BaseFont")
848        .ok()
849        .and_then(|o| match o {
850            Object::Name(ref n) => Some(n.clone()),
851            _ => None,
852        })
853        .map(|name| {
854            name.len() > 7 && name[6] == b'+' && name[..6].iter().all(|b| b.is_ascii_uppercase())
855        })
856        .unwrap_or(false);
857
858    // Detect symbolic fonts via FontDescriptor.Flags bit 3 (value 4).
859    // Symbolic fonts use the font's built-in encoding from the embedded font
860    // program rather than the PDF-level /Encoding entry or StandardEncoding
861    // (the implicit default for non-symbolic fonts).  PDF readers such as
862    // pdf_engine therefore IGNORE any /Encoding /Differences array for
863    // symbolic fonts — so a reverse map built from that array would map
864    // characters to the wrong byte codes.  We set differences_encoding to
865    // empty for symbolic fonts (when there is no ToUnicode CMap) so that
866    // encode_text_for_font hits the is_symbolic_font guard and returns Err
867    // rather than producing a byte sequence that pdf_engine cannot round-trip.
868    // Fixes #455.
869    let flags = get_font_descriptor_flags(doc, &font_dict);
870    let is_symbolic = flags & 4 != 0; // bit 3 (1-indexed) = Symbolic
871
872    // Build Differences-based encoding (used when there is no ToUnicode CMap).
873    // Only meaningful for single-byte (Builtin) non-symbolic fonts: symbolic
874    // fonts rely on the font program's built-in encoding, not the PDF-level
875    // Encoding dict, so building a reverse map from it would be incorrect.
876    let differences_encoding =
877        if to_unicode.is_none() && matches!(encoding, FontEncoding::Builtin) && !is_symbolic {
878            build_font_encoding(doc, &font_dict)
879        } else {
880            HashMap::new()
881        };
882
883    // Build inverse-cmap fallback for TrueType fonts that lack a complete
884    // ToUnicode CMap.  Reads glyph-index→Unicode from the embedded font
885    // program (FontFile2) so that glyph-indexed subsets (e.g. GIYRNY+Calibri)
886    // can still be searched and redacted. Fixes #466.
887    let cmap_inverse = if to_unicode.is_none() && matches!(encoding, FontEncoding::Builtin) {
888        build_truetype_cmap_inverse(doc, &font_dict)
889    } else {
890        HashMap::new()
891    };
892
893    // Build forward encoding from the font's Encoding/Differences dict for all
894    // single-byte fonts, including symbolic ones (unlike `differences_encoding`
895    // which skips symbolic fonts to protect the text-replacement reverse map).
896    // This lets decode_string correctly decode fonts like GIYRNY+Arial,Bold that
897    // use glyph-name Differences ([1 /C /e /r /t ...]) without a ToUnicode CMap.
898    // Fixes #466.
899    let forward_encoding = if to_unicode.is_none() && matches!(encoding, FontEncoding::Builtin) {
900        build_font_encoding(doc, &font_dict)
901    } else {
902        HashMap::new()
903    };
904
905    FontInfo {
906        to_unicode,
907        encoding,
908        widths,
909        differences_encoding,
910        is_subset,
911        is_symbolic,
912        forward_encoding,
913        cmap_inverse,
914    }
915}
916
917/// Extract and parse the ToUnicode CMap from a font dictionary.
918fn extract_to_unicode(doc: &Document, font_dict: &lopdf::Dictionary) -> Option<CMap> {
919    let to_unicode_obj = font_dict.get(b"ToUnicode").ok()?;
920
921    let stream_data = match to_unicode_obj {
922        Object::Reference(id) => {
923            let stream = match doc.get_object(*id) {
924                Ok(Object::Stream(ref s)) => s.clone(),
925                _ => return None,
926            };
927            let mut s = stream;
928            let _ = s.decompress();
929            s.content
930        }
931        Object::Stream(ref s) => {
932            let mut s = s.clone();
933            let _ = s.decompress();
934            s.content
935        }
936        _ => return None,
937    };
938
939    CMap::parse(&stream_data, pdf_font::cmap::load_embedded)
940}
941
942/// Build a glyph-index→Unicode map from a TrueType font's embedded cmap table.
943///
944/// Called when a font has no ToUnicode CMap (or a minimal one) so that
945/// glyph-indexed subsets (ABCDEF+FontName) can be decoded for text search
946/// and redaction. Reads FontFile2 from the font's FontDescriptor, parses
947/// the TrueType font, and inverts the Unicode→glyph mapping. Fixes #466.
948fn build_truetype_cmap_inverse(
949    doc: &Document,
950    font_dict: &lopdf::Dictionary,
951) -> HashMap<u32, char> {
952    let font_data = match get_truetype_font_data(doc, font_dict) {
953        Some(d) => d,
954        None => return HashMap::new(),
955    };
956    let face = match ttf_parser::Face::parse(&font_data, 0) {
957        Ok(f) => f,
958        Err(_) => return HashMap::new(),
959    };
960    let cmap_table = match face.tables().cmap {
961        Some(c) => c,
962        None => return HashMap::new(),
963    };
964
965    let mut inverse: HashMap<u32, char> = HashMap::new();
966
967    // Try Windows Unicode (platform 3, encoding 1) first; fall back to any
968    // Unicode-flagged subtable.
969    let preferred = cmap_table
970        .subtables
971        .into_iter()
972        .find(|st| st.platform_id == ttf_parser::PlatformId::Windows && st.encoding_id == 1)
973        .or_else(|| cmap_table.subtables.into_iter().find(|st| st.is_unicode()));
974
975    if let Some(subtable) = preferred {
976        subtable.codepoints(|cp| {
977            if let Some(c) = char::from_u32(cp) {
978                if let Some(glyph_id) = subtable.glyph_index(cp) {
979                    if glyph_id.0 != 0 {
980                        inverse.entry(glyph_id.0 as u32).or_insert(c);
981                    }
982                }
983            }
984        });
985    }
986
987    inverse
988}
989
990/// Extract raw TrueType font data (FontFile2) from a font's FontDescriptor.
991fn get_truetype_font_data(doc: &Document, font_dict: &lopdf::Dictionary) -> Option<Vec<u8>> {
992    let desc_ref = font_dict.get(b"FontDescriptor").ok()?;
993    let desc_dict = match desc_ref {
994        Object::Reference(id) => match doc.get_object(*id) {
995            Ok(Object::Dictionary(ref d)) => d.clone(),
996            _ => return None,
997        },
998        Object::Dictionary(ref d) => d.clone(),
999        _ => return None,
1000    };
1001    let ff2_ref = desc_dict.get(b"FontFile2").ok()?;
1002    let stream_id = match ff2_ref {
1003        Object::Reference(id) => *id,
1004        _ => return None,
1005    };
1006    if let Ok(Object::Stream(ref s)) = doc.get_object(stream_id) {
1007        let mut s2 = s.clone();
1008        let _ = s2.decompress();
1009        Some(s2.content)
1010    } else {
1011        None
1012    }
1013}
1014
1015/// Determine the encoding type of a font.
1016fn determine_encoding(doc: &Document, font_dict: &lopdf::Dictionary) -> FontEncoding {
1017    // Check if it's a Type 0 (CID) font.
1018    let subtype = font_dict.get(b"Subtype").ok().and_then(|o| match o {
1019        Object::Name(ref n) => Some(String::from_utf8_lossy(n).to_string()),
1020        _ => None,
1021    });
1022
1023    if subtype.as_deref() == Some("Type0") {
1024        // Check Encoding.
1025        let encoding_name = font_dict.get(b"Encoding").ok().and_then(|o| match o {
1026            Object::Name(ref n) => Some(String::from_utf8_lossy(n).to_string()),
1027            Object::Reference(id) => doc.get_object(*id).ok().and_then(|o| match o {
1028                Object::Name(ref n) => Some(String::from_utf8_lossy(n).to_string()),
1029                _ => None,
1030            }),
1031            _ => None,
1032        });
1033
1034        return match encoding_name.as_deref() {
1035            Some("Identity-H") | Some("Identity-V") => FontEncoding::IdentityH,
1036            _ => FontEncoding::CustomCMap,
1037        };
1038    }
1039
1040    FontEncoding::Builtin
1041}
1042
1043/// Extract glyph widths from a font dictionary.
1044fn extract_widths(doc: &Document, font_dict: &lopdf::Dictionary) -> GlyphWidths {
1045    let first_char = font_dict
1046        .get(b"FirstChar")
1047        .ok()
1048        .and_then(|o| match o {
1049            Object::Integer(i) => Some(*i as u32),
1050            _ => None,
1051        })
1052        .unwrap_or(0);
1053
1054    let widths_array = font_dict.get(b"Widths").ok().and_then(|o| match o {
1055        Object::Array(ref a) => Some(a.clone()),
1056        Object::Reference(id) => match doc.get_object(*id).ok() {
1057            Some(Object::Array(ref a)) => Some(a.clone()),
1058            _ => None,
1059        },
1060        _ => None,
1061    });
1062
1063    let widths = match widths_array {
1064        Some(arr) => arr.iter().map(|o| as_number(o).unwrap_or(1000.0)).collect(),
1065        None => {
1066            // For CID fonts, check DescendantFonts for DW (default width).
1067            let dw = get_cid_default_width(doc, font_dict);
1068            return GlyphWidths {
1069                first_char: 0,
1070                widths: Vec::new(),
1071                default_width: dw,
1072            };
1073        }
1074    };
1075
1076    GlyphWidths {
1077        first_char,
1078        widths,
1079        default_width: 1000.0,
1080    }
1081}
1082
1083/// Get default width from CID font's DescendantFonts.
1084fn get_cid_default_width(doc: &Document, font_dict: &lopdf::Dictionary) -> f64 {
1085    let descendants = match font_dict.get(b"DescendantFonts").ok() {
1086        Some(Object::Array(ref arr)) => arr.clone(),
1087        Some(Object::Reference(id)) => match doc.get_object(*id).ok() {
1088            Some(Object::Array(ref arr)) => arr.clone(),
1089            _ => return 1000.0,
1090        },
1091        _ => return 1000.0,
1092    };
1093
1094    let desc_id = match descendants.first() {
1095        Some(Object::Reference(id)) => *id,
1096        _ => return 1000.0,
1097    };
1098
1099    let desc_dict = match doc.get_object(desc_id) {
1100        Ok(Object::Dictionary(ref d)) => d,
1101        _ => return 1000.0,
1102    };
1103
1104    desc_dict
1105        .get(b"DW")
1106        .ok()
1107        .and_then(as_number)
1108        .unwrap_or(1000.0)
1109}
1110
1111// ---------------------------------------------------------------------------
1112// CMap decoding
1113// ---------------------------------------------------------------------------
1114
1115/// Decode bytes to Unicode using a ToUnicode CMap.
1116fn decode_with_cmap(bytes: &[u8], cmap: &CMap, encoding: &FontEncoding) -> String {
1117    let mut result = String::new();
1118
1119    match encoding {
1120        FontEncoding::IdentityH | FontEncoding::CustomCMap => {
1121            // 2-byte codes.
1122            let mut i = 0;
1123            while i + 1 < bytes.len() {
1124                let code = u32::from(bytes[i]) << 8 | u32::from(bytes[i + 1]);
1125                match cmap.lookup_bf_string(code) {
1126                    Some(BfString::Char(c)) => result.push(c),
1127                    Some(BfString::String(s)) => result.push_str(&s),
1128                    None => result.push(char::REPLACEMENT_CHARACTER),
1129                }
1130                i += 2;
1131            }
1132        }
1133        FontEncoding::Builtin => {
1134            // Single-byte codes.
1135            for &b in bytes {
1136                match cmap.lookup_bf_string(u32::from(b)) {
1137                    Some(BfString::Char(c)) => result.push(c),
1138                    Some(BfString::String(s)) => result.push_str(&s),
1139                    None => result.push(b as char),
1140                }
1141            }
1142        }
1143    }
1144
1145    result
1146}
1147
1148/// Fallback: decode PDF string without CMap.
1149fn decode_pdf_string_fallback(bytes: &[u8]) -> String {
1150    // Check for UTF-16BE BOM.
1151    if bytes.len() >= 2 && bytes[0] == 0xFE && bytes[1] == 0xFF {
1152        let chars: Vec<u16> = bytes[2..]
1153            .chunks(2)
1154            .filter_map(|chunk| {
1155                if chunk.len() == 2 {
1156                    Some(u16::from_be_bytes([chunk[0], chunk[1]]))
1157                } else {
1158                    None
1159                }
1160            })
1161            .collect();
1162        String::from_utf16_lossy(&chars)
1163    } else {
1164        bytes.iter().map(|&b| b as char).collect()
1165    }
1166}
1167
1168/// Extract 6-element matrix from operands.
1169fn extract_matrix(operands: &[Object]) -> Option<[f64; 6]> {
1170    if operands.len() < 6 {
1171        return None;
1172    }
1173    Some([
1174        as_number(&operands[0])?,
1175        as_number(&operands[1])?,
1176        as_number(&operands[2])?,
1177        as_number(&operands[3])?,
1178        as_number(&operands[4])?,
1179        as_number(&operands[5])?,
1180    ])
1181}
1182
1183#[cfg(test)]
1184mod tests {
1185    use super::*;
1186    use lopdf::{dictionary, Document, Object, Stream};
1187
1188    fn make_doc_with_font_and_content(content: &[u8], to_unicode_data: Option<&[u8]>) -> Document {
1189        let mut doc = Document::with_version("1.7");
1190
1191        // Create font dictionary.
1192        let mut font = dictionary! {
1193            "Type" => "Font",
1194            "Subtype" => "Type1",
1195            "BaseFont" => "Helvetica",
1196        };
1197
1198        if let Some(tu_data) = to_unicode_data {
1199            let tu_stream = Stream::new(dictionary! {}, tu_data.to_vec());
1200            let tu_id = doc.add_object(Object::Stream(tu_stream));
1201            font.set("ToUnicode", Object::Reference(tu_id));
1202        }
1203
1204        let font_id = doc.add_object(Object::Dictionary(font));
1205
1206        let font_resources = dictionary! {
1207            "F1" => Object::Reference(font_id),
1208        };
1209        let resources = dictionary! {
1210            "Font" => Object::Dictionary(font_resources),
1211        };
1212
1213        let content_stream = Stream::new(dictionary! {}, content.to_vec());
1214        let content_id = doc.add_object(Object::Stream(content_stream));
1215
1216        let page_dict = dictionary! {
1217            "Type" => "Page",
1218            "MediaBox" => vec![0.into(), 0.into(), 612.into(), 792.into()],
1219            "Contents" => Object::Reference(content_id),
1220            "Resources" => Object::Dictionary(resources),
1221        };
1222        let page_id = doc.add_object(Object::Dictionary(page_dict));
1223
1224        let pages_dict = dictionary! {
1225            "Type" => "Pages",
1226            "Kids" => vec![Object::Reference(page_id)],
1227            "Count" => 1_i64,
1228        };
1229        let pages_id = doc.add_object(Object::Dictionary(pages_dict));
1230
1231        if let Ok(Object::Dictionary(ref mut d)) = doc.get_object_mut(page_id) {
1232            d.set("Parent", Object::Reference(pages_id));
1233        }
1234
1235        let catalog = dictionary! {
1236            "Type" => "Catalog",
1237            "Pages" => Object::Reference(pages_id),
1238        };
1239        let catalog_id = doc.add_object(Object::Dictionary(catalog));
1240        doc.trailer.set("Root", Object::Reference(catalog_id));
1241
1242        doc
1243    }
1244
1245    #[test]
1246    fn extract_simple_text_run() {
1247        let doc =
1248            make_doc_with_font_and_content(b"BT /F1 12 Tf 100 700 Td (Hello World) Tj ET", None);
1249        let runs = extract_page_text_runs(&doc, 1).unwrap();
1250        assert_eq!(runs.len(), 1);
1251        assert_eq!(runs[0].text, "Hello World");
1252        assert_eq!(runs[0].font_name, "F1");
1253        assert_eq!(runs[0].font_size, 12.0);
1254    }
1255
1256    #[test]
1257    fn extract_multiple_text_runs() {
1258        let doc = make_doc_with_font_and_content(
1259            b"BT /F1 12 Tf 100 700 Td (Hello) Tj 0 -20 Td (World) Tj ET",
1260            None,
1261        );
1262        let runs = extract_page_text_runs(&doc, 1).unwrap();
1263        assert_eq!(runs.len(), 2);
1264        assert_eq!(runs[0].text, "Hello");
1265        assert_eq!(runs[1].text, "World");
1266    }
1267
1268    #[test]
1269    fn extract_tj_array_run() {
1270        let doc = make_doc_with_font_and_content(
1271            b"BT /F1 12 Tf 100 700 Td [(He) -100 (llo)] TJ ET",
1272            None,
1273        );
1274        let runs = extract_page_text_runs(&doc, 1).unwrap();
1275        assert_eq!(runs.len(), 1);
1276        assert_eq!(runs[0].text, "Hello");
1277    }
1278
1279    #[test]
1280    fn text_run_position_tracking() {
1281        let doc =
1282            make_doc_with_font_and_content(b"BT /F1 12 Tf 1 0 0 1 100 700 Tm (Hello) Tj ET", None);
1283        let runs = extract_page_text_runs(&doc, 1).unwrap();
1284        assert_eq!(runs.len(), 1);
1285        assert!((runs[0].x - 100.0).abs() < 1e-6);
1286        assert!((runs[0].y - 700.0).abs() < 1e-6);
1287    }
1288
1289    #[test]
1290    fn text_run_with_to_unicode_cmap() {
1291        // A minimal ToUnicode CMap that maps 0x48→H, 0x69→i.
1292        let cmap = b"/CIDInit /ProcSet findresource begin
129312 dict begin
1294begincmap
1295/CIDSystemInfo << /Registry (Test) /Ordering (UCS) /Supplement 0 >> def
1296/CMapName /Test def
12971 begincodespacerange
1298<00> <FF>
1299endcodespacerange
13002 beginbfchar
1301<48> <0048>
1302<69> <0069>
1303endbfchar
1304endcmap
1305CMapName currentdict /CMap defineresource pop
1306end
1307end";
1308
1309        let doc = make_doc_with_font_and_content(b"BT /F1 12 Tf 100 700 Td (Hi) Tj ET", Some(cmap));
1310        let runs = extract_page_text_runs(&doc, 1).unwrap();
1311        assert_eq!(runs.len(), 1);
1312        assert_eq!(runs[0].text, "Hi");
1313    }
1314
1315    #[test]
1316    fn text_run_ops_range() {
1317        let doc = make_doc_with_font_and_content(b"BT /F1 12 Tf 100 700 Td (Hello) Tj ET", None);
1318        let runs = extract_page_text_runs(&doc, 1).unwrap();
1319        assert_eq!(runs.len(), 1);
1320        // Ops: BT(0), Tf(1), Td(2), Tj(3), ET(4)
1321        assert_eq!(runs[0].ops_range, 3..4);
1322    }
1323
1324    /// #764: the run position goes through nested `cm` as `M x CTM` and a
1325    /// `Td` offset is scaled by the text line matrix.
1326    #[test]
1327    fn run_position_follows_pdf_matrix_order() {
1328        // translate(100,100) outside, scale 2 inside: (5,7) -> (110,114).
1329        let doc = make_doc_with_font_and_content(
1330            b"q 1 0 0 1 100 100 cm 2 0 0 2 0 0 cm BT /F1 12 Tf 5 7 Td (Hi) Tj ET Q",
1331            None,
1332        );
1333        let runs = extract_page_text_runs(&doc, 1).unwrap();
1334        assert_eq!(runs.len(), 1);
1335        assert!((runs[0].x - 110.0).abs() < 1e-6, "x = {}", runs[0].x);
1336        assert!((runs[0].y - 114.0).abs() < 1e-6, "y = {}", runs[0].y);
1337
1338        // Tm scales by 2 at (10,10); Td (5,7) is in scaled units: (20,24).
1339        let doc = make_doc_with_font_and_content(
1340            b"BT /F1 12 Tf 2 0 0 2 10 10 Tm 5 7 Td (Hi) Tj ET",
1341            None,
1342        );
1343        let runs = extract_page_text_runs(&doc, 1).unwrap();
1344        assert!((runs[0].x - 20.0).abs() < 1e-6, "x = {}", runs[0].x);
1345        assert!((runs[0].y - 24.0).abs() < 1e-6, "y = {}", runs[0].y);
1346    }
1347
1348    /// The advance after a glyph is in text space, so a `Tm` that scales by 2
1349    /// doubles it: the second glyph starts twice as far from the origin as
1350    /// under an unscaled `Tm`.
1351    #[test]
1352    fn glyph_advance_is_scaled_by_the_text_matrix() {
1353        let second = |tm: &str| {
1354            let content = format!("BT /F1 10 Tf {tm} Tm (H) Tj (i) Tj ET");
1355            let doc = make_doc_with_font_and_content(content.as_bytes(), None);
1356            let runs = extract_page_text_runs(&doc, 1).unwrap();
1357            assert_eq!(runs.len(), 2);
1358            (runs[1].x, runs[1].y)
1359        };
1360        let unscaled = second("1 0 0 1 10 10").0 - 10.0;
1361        let scaled = second("2 0 0 2 10 10").0 - 10.0;
1362        // Rotated 90 degrees counter-clockwise, the text line runs along +y:
1363        // the advance lands on y and x stays at the origin.
1364        let (rx, ry) = second("0 1 -1 0 10 10");
1365        assert!((rx - 10.0).abs() < 1e-6, "x moved to {rx}");
1366        assert!(
1367            (ry - 10.0 - unscaled).abs() < 1e-6,
1368            "y = {ry}, expected {}",
1369            10.0 + unscaled
1370        );
1371        assert!(unscaled > 0.0, "the first glyph must advance");
1372        assert!(
1373            (scaled - 2.0 * unscaled).abs() < 1e-6,
1374            "{scaled} != 2 * {unscaled}"
1375        );
1376    }
1377
1378    #[test]
1379    fn font_map_empty_fallback() {
1380        let fonts = FontMap::empty();
1381        let text = fonts.decode_string("F1", b"Hello");
1382        assert_eq!(text, "Hello");
1383    }
1384
1385    #[test]
1386    fn text_run_font_change() {
1387        let mut doc = Document::with_version("1.7");
1388
1389        let font1 = dictionary! {
1390            "Type" => "Font",
1391            "Subtype" => "Type1",
1392            "BaseFont" => "Helvetica",
1393        };
1394        let font1_id = doc.add_object(Object::Dictionary(font1));
1395
1396        let font2 = dictionary! {
1397            "Type" => "Font",
1398            "Subtype" => "Type1",
1399            "BaseFont" => "Courier",
1400        };
1401        let font2_id = doc.add_object(Object::Dictionary(font2));
1402
1403        let font_resources = dictionary! {
1404            "F1" => Object::Reference(font1_id),
1405            "F2" => Object::Reference(font2_id),
1406        };
1407        let resources = dictionary! {
1408            "Font" => Object::Dictionary(font_resources),
1409        };
1410
1411        let content = b"BT /F1 12 Tf (Hello) Tj /F2 14 Tf (World) Tj ET";
1412        let content_stream = Stream::new(dictionary! {}, content.to_vec());
1413        let content_id = doc.add_object(Object::Stream(content_stream));
1414
1415        let page_dict = dictionary! {
1416            "Type" => "Page",
1417            "MediaBox" => vec![0.into(), 0.into(), 612.into(), 792.into()],
1418            "Contents" => Object::Reference(content_id),
1419            "Resources" => Object::Dictionary(resources),
1420        };
1421        let page_id = doc.add_object(Object::Dictionary(page_dict));
1422
1423        let pages_dict = dictionary! {
1424            "Type" => "Pages",
1425            "Kids" => vec![Object::Reference(page_id)],
1426            "Count" => 1_i64,
1427        };
1428        let pages_id = doc.add_object(Object::Dictionary(pages_dict));
1429
1430        if let Ok(Object::Dictionary(ref mut d)) = doc.get_object_mut(page_id) {
1431            d.set("Parent", Object::Reference(pages_id));
1432        }
1433
1434        let catalog = dictionary! {
1435            "Type" => "Catalog",
1436            "Pages" => Object::Reference(pages_id),
1437        };
1438        let catalog_id = doc.add_object(Object::Dictionary(catalog));
1439        doc.trailer.set("Root", Object::Reference(catalog_id));
1440
1441        let runs = extract_page_text_runs(&doc, 1).unwrap();
1442        assert_eq!(runs.len(), 2);
1443        assert_eq!(runs[0].font_name, "F1");
1444        assert_eq!(runs[0].font_size, 12.0);
1445        assert_eq!(runs[1].font_name, "F2");
1446        assert_eq!(runs[1].font_size, 14.0);
1447    }
1448
1449    #[test]
1450    fn empty_content_no_runs() {
1451        let doc = make_doc_with_font_and_content(b"q Q", None);
1452        let runs = extract_page_text_runs(&doc, 1).unwrap();
1453        assert!(runs.is_empty());
1454    }
1455}