Skip to main content

oxidize_pdf/text/
cmap.rs

1//! CMap and ToUnicode support for text extraction
2//!
3//! This module implements CMap parsing and ToUnicode mappings according to
4//! ISO 32000-1:2008 Section 9.10 (Extraction of Text Content) and Section 9.7.5 (CMaps).
5//!
6//! CMaps define the mapping from character codes to character selectors (CIDs, character names, or Unicode values).
7
8use crate::parser::{ParseError, ParseResult};
9use std::collections::HashMap;
10
11/// CMap type enumeration
12#[derive(Debug, Clone, PartialEq)]
13pub enum CMapType {
14    /// Maps character codes to CIDs (Character IDs)
15    CIDMap,
16    /// Maps character codes to Unicode values
17    ToUnicode,
18    /// Predefined CMap (e.g., Identity-H, Identity-V)
19    Predefined(String),
20}
21
22/// Character code range mapping
23#[derive(Debug, Clone)]
24pub struct CodeRange {
25    /// Start of the code range
26    pub start: Vec<u8>,
27    /// End of the code range
28    pub end: Vec<u8>,
29}
30
31impl CodeRange {
32    /// Check if a code is within this range
33    pub fn contains(&self, code: &[u8]) -> bool {
34        if code.len() != self.start.len() || code.len() != self.end.len() {
35            return false;
36        }
37
38        code >= &self.start[..] && code <= &self.end[..]
39    }
40}
41
42/// CMap mapping entry
43#[derive(Debug, Clone)]
44pub enum CMapEntry {
45    /// Single character mapping
46    Single {
47        /// Source character code
48        src: Vec<u8>,
49        /// Destination (CID or Unicode)
50        dst: Vec<u8>,
51    },
52    /// Range mapping
53    Range {
54        /// Start of source range
55        src_start: Vec<u8>,
56        /// End of source range
57        src_end: Vec<u8>,
58        /// Start of destination range
59        dst_start: Vec<u8>,
60    },
61}
62
63/// CMap structure for character code mappings
64#[derive(Debug, Clone)]
65pub struct CMap {
66    /// CMap name
67    pub name: Option<String>,
68    /// CMap type
69    pub cmap_type: CMapType,
70    /// Writing mode (0 = horizontal, 1 = vertical)
71    pub wmode: u8,
72    /// Code space ranges
73    pub codespace_ranges: Vec<CodeRange>,
74    /// Character mappings
75    pub mappings: Vec<CMapEntry>,
76    /// Cached single mappings for fast lookup
77    single_mappings: HashMap<Vec<u8>, Vec<u8>>,
78    /// Predefined parent CMap inherited via `usecmap`. When set to
79    /// `"Identity-H"` or `"Identity-V"`, `map()` falls back to
80    /// returning the input code as-is for any code the child CMap
81    /// did not map explicitly, and `is_valid_code()` accepts the full
82    /// 2-byte (Identity-H) or 1-byte (Identity-V) space. External
83    /// CMap chaining (non-predefined parents) is recorded for
84    /// observability but does not enable any fallback.
85    pub inherited_predefined: Option<String>,
86}
87
88impl Default for CMap {
89    fn default() -> Self {
90        Self::new()
91    }
92}
93
94impl CMap {
95    /// Create a new empty CMap
96    pub fn new() -> Self {
97        Self {
98            name: None,
99            cmap_type: CMapType::ToUnicode,
100            wmode: 0,
101            codespace_ranges: Vec::new(),
102            mappings: Vec::new(),
103            single_mappings: HashMap::new(),
104            inherited_predefined: None,
105        }
106    }
107
108    /// Create a predefined Identity CMap
109    pub fn identity_h() -> Self {
110        Self {
111            name: Some("Identity-H".to_string()),
112            cmap_type: CMapType::Predefined("Identity-H".to_string()),
113            wmode: 0,
114            codespace_ranges: vec![CodeRange {
115                start: vec![0x00, 0x00],
116                end: vec![0xFF, 0xFF],
117            }],
118            mappings: Vec::new(),
119            single_mappings: HashMap::new(),
120            inherited_predefined: None,
121        }
122    }
123
124    /// Create a predefined Identity-V CMap
125    pub fn identity_v() -> Self {
126        Self {
127            name: Some("Identity-V".to_string()),
128            cmap_type: CMapType::Predefined("Identity-V".to_string()),
129            wmode: 1,
130            codespace_ranges: vec![CodeRange {
131                start: vec![0x00, 0x00],
132                end: vec![0xFF, 0xFF],
133            }],
134            mappings: Vec::new(),
135            single_mappings: HashMap::new(),
136            inherited_predefined: None,
137        }
138    }
139
140    /// Parse a CMap from data.
141    ///
142    /// Adobe CMaps are PostScript, not line-oriented. Same-line forms
143    /// like `1 begincodespacerange <0000><00D1> endcodespacerange` are
144    /// legal (and shipped by BOE / various other producers — see issue
145    /// #272). The previous line-based scanner could get stuck in a state
146    /// flag because `endcodespacerange` never appeared as its own line.
147    /// This implementation tokenises the input first and then consumes
148    /// tokens with a state machine that is whitespace-agnostic.
149    pub fn parse(data: &[u8]) -> ParseResult<Self> {
150        let mut cmap = Self::new();
151        let content =
152            std::str::from_utf8(data).map_err(|e| ParseError::CharacterEncodingError {
153                position: 0,
154                message: format!("Invalid UTF-8 in CMap: {e}"),
155            })?;
156
157        let tokens = tokenize_cmap(content);
158        let mut i = 0;
159
160        while i < tokens.len() {
161            match &tokens[i] {
162                Token::Name(n) if n == "CMapName" => {
163                    if let Some(Token::Name(name)) = tokens.get(i + 1) {
164                        cmap.name = Some(name.clone());
165                        i += 2;
166                    } else {
167                        i += 1;
168                    }
169                }
170                // `usecmap` directive: the immediately preceding Name token
171                // (e.g. `/Identity-H usecmap`) names the parent CMap whose
172                // mappings the child inherits. For the two predefined
173                // Identity CMaps the codebase can synthesise (Identity-H,
174                // Identity-V), this enables an identity fallback in `map()`
175                // for codes the child doesn't explicitly cover. External
176                // CMap names are recorded but produce no fallback (a real
177                // chain resolver would need access to the document's CMap
178                // resources, which this parser cannot reach).
179                Token::Keyword(k) if k == "usecmap" => {
180                    let mut j = i;
181                    while j > 0 {
182                        j -= 1;
183                        if let Token::Name(parent) = &tokens[j] {
184                            cmap.inherited_predefined = Some(parent.clone());
185                            break;
186                        }
187                    }
188                    i += 1;
189                }
190                Token::Name(n) if n == "WMode" => {
191                    if let Some(Token::Integer(w)) = tokens.get(i + 1) {
192                        cmap.wmode = *w as u8;
193                        i += 2;
194                    } else {
195                        i += 1;
196                    }
197                }
198                Token::Keyword(k) if k == "begincodespacerange" => {
199                    i += 1;
200                    while i < tokens.len() {
201                        match &tokens[i] {
202                            Token::Keyword(k) if k == "endcodespacerange" => {
203                                i += 1;
204                                break;
205                            }
206                            Token::Hex(start) => {
207                                if let Some(Token::Hex(end)) = tokens.get(i + 1) {
208                                    cmap.codespace_ranges.push(CodeRange {
209                                        start: start.clone(),
210                                        end: end.clone(),
211                                    });
212                                    i += 2;
213                                } else {
214                                    i += 1;
215                                }
216                            }
217                            _ => i += 1,
218                        }
219                    }
220                }
221                Token::Keyword(k) if k == "beginbfchar" => {
222                    i += 1;
223                    while i < tokens.len() {
224                        match &tokens[i] {
225                            Token::Keyword(k) if k == "endbfchar" => {
226                                i += 1;
227                                break;
228                            }
229                            Token::Hex(src) => {
230                                if let Some(Token::Hex(dst)) = tokens.get(i + 1) {
231                                    cmap.single_mappings.insert(src.clone(), dst.clone());
232                                    cmap.mappings.push(CMapEntry::Single {
233                                        src: src.clone(),
234                                        dst: dst.clone(),
235                                    });
236                                    i += 2;
237                                } else {
238                                    i += 1;
239                                }
240                            }
241                            _ => i += 1,
242                        }
243                    }
244                }
245                Token::Keyword(k) if k == "beginbfrange" => {
246                    i += 1;
247                    while i < tokens.len() {
248                        match &tokens[i] {
249                            Token::Keyword(k) if k == "endbfrange" => {
250                                i += 1;
251                                break;
252                            }
253                            Token::Hex(src_start) => {
254                                // Need src_end + (Hex dst_start | Array of dst)
255                                let src_end = match tokens.get(i + 1) {
256                                    Some(Token::Hex(e)) => e.clone(),
257                                    _ => {
258                                        i += 1;
259                                        continue;
260                                    }
261                                };
262                                match tokens.get(i + 2) {
263                                    Some(Token::Hex(dst_start)) => {
264                                        cmap.mappings.push(CMapEntry::Range {
265                                            src_start: src_start.clone(),
266                                            src_end,
267                                            dst_start: dst_start.clone(),
268                                        });
269                                        i += 3;
270                                    }
271                                    Some(Token::Array(dsts)) => {
272                                        // Array form: each dst replaces one src code,
273                                        // walking src_start..=src_end in lockstep. The
274                                        // increment is big-endian with carry — wrapping
275                                        // only the last byte (`<00FE> + 1 = <0000>`) would
276                                        // silently insert into the wrong slot when the
277                                        // range crosses a byte boundary.
278                                        let mut current_src = src_start.clone();
279                                        for dst in dsts {
280                                            cmap.single_mappings
281                                                .insert(current_src.clone(), dst.clone());
282                                            cmap.mappings.push(CMapEntry::Single {
283                                                src: current_src.clone(),
284                                                dst: dst.clone(),
285                                            });
286                                            if current_src.as_slice() >= src_end.as_slice() {
287                                                break;
288                                            }
289                                            increment_be(&mut current_src);
290                                        }
291                                        i += 3;
292                                    }
293                                    _ => {
294                                        i += 1;
295                                    }
296                                }
297                            }
298                            _ => i += 1,
299                        }
300                    }
301                }
302                // Top-level fall-through covers PostScript constructs the
303                // state machine deliberately ignores: the integer operand
304                // count before `begin*` keywords (`14 beginbfchar`), the
305                // `def` / `dict` / `findresource` / `begincmap` / `endcmap`
306                // boilerplate, and any unrecognised name token. Dropping
307                // these keeps the parser robust against producer-specific
308                // headers without coupling the state machine to them.
309                _ => i += 1,
310            }
311        }
312
313        Ok(cmap)
314    }
315
316    /// Map a character code to its destination
317    pub fn map(&self, code: &[u8]) -> Option<Vec<u8>> {
318        // Explicit bfchar/bfrange mappings take precedence and are matched by
319        // their own key length, independent of the (frequently sloppy)
320        // codespacerange. Producers routinely declare the generic 2-byte
321        // Identity codespace <0000><FFFF> for a *simple* font whose ToUnicode
322        // entries are 1-byte bfchars; gating these behind a strict
323        // length-equal codespace check made `map` return None for every such
324        // code, blanking the whole stream and forcing a wrong base-encoding
325        // fallback that turned typographic glyphs into U+FFFD (#302 symptom 3).
326
327        // Check single mappings first (cached)
328        if let Some(dst) = self.single_mappings.get(code) {
329            return Some(dst.clone());
330        }
331
332        // Check range mappings
333        for mapping in &self.mappings {
334            if let CMapEntry::Range {
335                src_start,
336                src_end,
337                dst_start,
338            } = mapping
339            {
340                if code.len() == src_start.len() && code >= &src_start[..] && code <= &src_end[..] {
341                    // Calculate offset within range
342                    let offset = calculate_offset(code, src_start);
343                    let mut result = dst_start.clone();
344
345                    // Add offset to the destination treating it as a
346                    // big-endian multi-byte integer, propagating carry.
347                    let mut carry = offset;
348                    for byte in result.iter_mut().rev() {
349                        let sum = *byte as usize + carry;
350                        *byte = (sum & 0xFF) as u8;
351                        carry = sum >> 8;
352                        if carry == 0 {
353                            break;
354                        }
355                    }
356
357                    return Some(result);
358                }
359            }
360        }
361
362        // Codespace-gated passthroughs (Identity). These synthesise a result
363        // from the code itself rather than an explicit table, so they must
364        // stay constrained to the declared codespace to avoid swallowing
365        // out-of-range bytes.
366        if !self.is_valid_code(code) {
367            return None;
368        }
369
370        // For predefined Identity CMaps
371        if let CMapType::Predefined(name) = &self.cmap_type {
372            if name.starts_with("Identity") {
373                return Some(code.to_vec());
374            }
375        }
376
377        // Identity fallback inherited via `usecmap`. If the child CMap
378        // didn't map this code explicitly and the parent is Identity-H
379        // or Identity-V (both 2-byte CID encodings), pass the code
380        // through unchanged. The downstream `to_unicode` then interprets
381        // the bytes as UTF-16BE.
382        if code.len() == 2 && self.identity_inherited() {
383            return Some(code.to_vec());
384        }
385
386        None
387    }
388
389    /// Check if a code is in valid codespace
390    pub fn is_valid_code(&self, code: &[u8]) -> bool {
391        for range in &self.codespace_ranges {
392            if range.contains(code) {
393                return true;
394            }
395        }
396        // No explicit codespace covers this code, but `usecmap`
397        // inheritance from a predefined Identity CMap means the full
398        // 2-byte space is valid.
399        if code.len() == 2
400            && (self.inherited_predefined_is("Identity-H")
401                || self.inherited_predefined_is("Identity-V"))
402        {
403            return true;
404        }
405        false
406    }
407
408    /// If this CMap inherits (via `usecmap`) from a predefined Adobe
409    /// `*-UCS2` CMap, return the matching CID collection ordering.
410    /// Used by ToUnicode decoding to resolve codes the child CMap did
411    /// not map explicitly (the code is treated as a CID into the table).
412    pub(crate) fn inherited_ordering(&self) -> Option<&'static str> {
413        match self.inherited_predefined.as_deref()? {
414            "Adobe-GB1-UCS2" => Some("GB1"),
415            "Adobe-CNS1-UCS2" => Some("CNS1"),
416            "Adobe-Japan1-UCS2" => Some("Japan1"),
417            // `Adobe-KR-UCS2` is an alias for the Korea1 collection used by some producers.
418            "Adobe-Korea1-UCS2" | "Adobe-KR-UCS2" => Some("Korea1"),
419            _ => None,
420        }
421    }
422
423    /// `true` iff this CMap inherits identity-mapping semantics from a
424    /// predefined parent via `usecmap`.
425    fn identity_inherited(&self) -> bool {
426        self.inherited_predefined_is("Identity-H") || self.inherited_predefined_is("Identity-V")
427    }
428
429    /// `true` iff the inherited parent (set by `usecmap`) matches the
430    /// given predefined CMap name exactly.
431    fn inherited_predefined_is(&self, name: &str) -> bool {
432        self.inherited_predefined
433            .as_deref()
434            .map(|p| p == name)
435            .unwrap_or(false)
436    }
437
438    /// Convert mapped value to Unicode string
439    pub fn to_unicode(&self, mapped: &[u8]) -> Option<String> {
440        match self.cmap_type {
441            CMapType::ToUnicode => {
442                // Interpret as UTF-16BE
443                if mapped.len() % 2 == 0 {
444                    let utf16_values: Vec<u16> = mapped
445                        .chunks(2)
446                        .map(|chunk| u16::from_be_bytes([chunk[0], chunk[1]]))
447                        .collect();
448                    String::from_utf16(&utf16_values).ok()
449                } else {
450                    // Try as UTF-8
451                    String::from_utf8(mapped.to_vec()).ok()
452                }
453            }
454            _ => None,
455        }
456    }
457}
458
459/// Increment a big-endian byte sequence by 1 in place, propagating carry
460/// across byte boundaries. Returns `true` on success, `false` on
461/// overflow (e.g. `<FFFF> + 1`). Used by the `bfrange` array form to
462/// walk `src_start..=src_end` in lockstep with the dst array.
463fn increment_be(bytes: &mut [u8]) -> bool {
464    let mut carry = 1u32;
465    for byte in bytes.iter_mut().rev() {
466        let sum = *byte as u32 + carry;
467        *byte = (sum & 0xFF) as u8;
468        carry = sum >> 8;
469        if carry == 0 {
470            return true;
471        }
472    }
473    carry == 0
474}
475
476/// Parse hex string `<...>` bytes into a `Vec<u8>`. Whitespace inside
477/// the angle brackets is permitted (PostScript allows it); odd-length
478/// strings or non-hex characters return `None`.
479fn parse_hex(s: &str) -> Option<Vec<u8>> {
480    let s = s.trim_start_matches('<').trim_end_matches('>');
481    let clean: String = s.chars().filter(|c| !c.is_whitespace()).collect();
482    if clean.len() % 2 != 0 {
483        return None;
484    }
485
486    let mut bytes = Vec::new();
487    for i in (0..clean.len()).step_by(2) {
488        if let Ok(byte) = u8::from_str_radix(&clean[i..i + 2], 16) {
489            bytes.push(byte);
490        } else {
491            return None;
492        }
493    }
494    Some(bytes)
495}
496
497/// A single PostScript token extracted from a CMap stream. Only the
498/// shapes the CMap state machine consumes are represented; everything
499/// else (dictionary `<<...>>` markers, literal strings, unknown
500/// keywords) is either skipped at tokenisation time or ignored by the
501/// state machine.
502#[derive(Debug, Clone)]
503pub(crate) enum Token {
504    /// Hex string `<00D1>` → `vec![0x00, 0xD1]`.
505    Hex(Vec<u8>),
506    /// Array `[ <abcd> <ef01> ... ]` of hex strings, used by the
507    /// `beginbfrange` array form `<srcStart> <srcEnd> [<dst0> <dst1> ...]`.
508    Array(Vec<Vec<u8>>),
509    /// PostScript name `/CMapName`, `/WMode`, etc. — the leading `/`
510    /// is stripped.
511    Name(String),
512    /// Decimal integer such as the operand count before `begin*` or the
513    /// `0` in `/WMode 0`.
514    Integer(i64),
515    /// Bare identifier such as `begincmap`, `endbfchar`, `def`. We treat
516    /// every non-delimited identifier as a keyword and let the parser
517    /// state machine pick the ones it cares about.
518    Keyword(String),
519}
520
521/// Tokenise a CMap PostScript stream into [`Token`]s. The scanner is
522/// whitespace-agnostic so that minified CMaps (`begin... <a><b> end...`
523/// all on one line, BOE-style) are parsed identically to the multi-line
524/// canonical form. Unknown PostScript constructs (literal strings,
525/// `<<` ... `>>` dictionaries, comments) are silently skipped.
526pub(crate) fn tokenize_cmap(content: &str) -> Vec<Token> {
527    let bytes = content.as_bytes();
528    let mut tokens = Vec::new();
529    let mut i = 0;
530
531    while i < bytes.len() {
532        let b = bytes[i];
533
534        // Whitespace
535        if b.is_ascii_whitespace() {
536            i += 1;
537            continue;
538        }
539
540        // Comments: `% ... \n`
541        if b == b'%' {
542            while i < bytes.len() && bytes[i] != b'\n' {
543                i += 1;
544            }
545            continue;
546        }
547
548        // Dictionary `<<` / `>>` — skip the markers, the state machine
549        // does not need dict contents.
550        if b == b'<' && bytes.get(i + 1) == Some(&b'<') {
551            i += 2;
552            continue;
553        }
554        if b == b'>' && bytes.get(i + 1) == Some(&b'>') {
555            i += 2;
556            continue;
557        }
558
559        // Hex string `<...>`. Bail out resiliently if the closing `>` is
560        // absent OR if a stray `<` appears before it (meaning the
561        // original `<` was unterminated and we are about to greedily
562        // consume the *next* valid hex string instead). Skipping just
563        // the lone byte preserves any following well-formed mappings.
564        if b == b'<' {
565            let start = i + 1;
566            let mut end_pos: Option<usize> = None;
567            for (off, &c) in bytes[start..].iter().enumerate() {
568                if c == b'>' {
569                    end_pos = Some(start + off);
570                    break;
571                }
572                if c == b'<' {
573                    // Another `<` arrived before `>` — original is malformed.
574                    break;
575                }
576            }
577            if let Some(end) = end_pos {
578                let inner: String = bytes[start..end].iter().map(|&c| c as char).collect();
579                if let Some(decoded) = parse_hex(&inner) {
580                    tokens.push(Token::Hex(decoded));
581                }
582                i = end + 1;
583                continue;
584            } else {
585                i += 1;
586                continue;
587            }
588        }
589
590        // Array of hex strings `[ <...> <...> ... ]`
591        if b == b'[' {
592            i += 1;
593            let mut values = Vec::new();
594            while i < bytes.len() {
595                let bb = bytes[i];
596                if bb.is_ascii_whitespace() {
597                    i += 1;
598                } else if bb == b']' {
599                    i += 1;
600                    break;
601                } else if bb == b'<' {
602                    let start = i + 1;
603                    if let Some(rel) = bytes[start..].iter().position(|&c| c == b'>') {
604                        let end = start + rel;
605                        let inner: String = bytes[start..end].iter().map(|&c| c as char).collect();
606                        if let Some(decoded) = parse_hex(&inner) {
607                            values.push(decoded);
608                        }
609                        i = end + 1;
610                    } else {
611                        break;
612                    }
613                } else {
614                    // Skip non-hex content inside arrays (defensive: PostScript
615                    // permits other token types but bfrange arrays in practice
616                    // only contain hex strings).
617                    i += 1;
618                }
619            }
620            tokens.push(Token::Array(values));
621            continue;
622        }
623
624        // Literal string `( ... )` — skipped. PostScript supports balanced
625        // parens and `\` escapes; CMaps only use these inside CIDSystemInfo
626        // which the state machine doesn't read.
627        if b == b'(' {
628            let mut depth = 1;
629            i += 1;
630            while i < bytes.len() && depth > 0 {
631                match bytes[i] {
632                    b'\\' if i + 1 < bytes.len() => i += 2,
633                    b'(' => {
634                        depth += 1;
635                        i += 1;
636                    }
637                    b')' => {
638                        depth -= 1;
639                        i += 1;
640                    }
641                    _ => i += 1,
642                }
643            }
644            continue;
645        }
646
647        // PostScript name `/ident`
648        if b == b'/' {
649            let start = i + 1;
650            let end = bytes[start..]
651                .iter()
652                .position(|&c| {
653                    c.is_ascii_whitespace()
654                        || c == b'<'
655                        || c == b'>'
656                        || c == b'/'
657                        || c == b'['
658                        || c == b']'
659                        || c == b'('
660                        || c == b')'
661                        || c == b'%'
662                })
663                .map(|p| start + p)
664                .unwrap_or(bytes.len());
665            if end > start {
666                let name: String = bytes[start..end].iter().map(|&c| c as char).collect();
667                tokens.push(Token::Name(name));
668            }
669            i = end;
670            continue;
671        }
672
673        // Integer (decimal). Negative numbers permitted with leading `-`.
674        if b.is_ascii_digit()
675            || (b == b'-'
676                && bytes
677                    .get(i + 1)
678                    .map(|c| c.is_ascii_digit())
679                    .unwrap_or(false))
680        {
681            let start = i;
682            i += 1;
683            while i < bytes.len() && bytes[i].is_ascii_digit() {
684                i += 1;
685            }
686            let s: String = bytes[start..i].iter().map(|&c| c as char).collect();
687            if let Ok(n) = s.parse::<i64>() {
688                tokens.push(Token::Integer(n));
689            }
690            continue;
691        }
692
693        // Keyword: bare identifier (everything until whitespace / delimiter).
694        let start = i;
695        while i < bytes.len() {
696            let c = bytes[i];
697            if c.is_ascii_whitespace()
698                || c == b'<'
699                || c == b'>'
700                || c == b'/'
701                || c == b'['
702                || c == b']'
703                || c == b'('
704                || c == b')'
705                || c == b'%'
706            {
707                break;
708            }
709            i += 1;
710        }
711        if i > start {
712            let kw: String = bytes[start..i].iter().map(|&c| c as char).collect();
713            tokens.push(Token::Keyword(kw));
714        } else {
715            // The byte at `i` is a stray close-delimiter that no earlier
716            // branch consumed: a lone `>` (not `>>`), or an unmatched `]`
717            // or `)`. The keyword loop above `break`s on it immediately
718            // without advancing, so we must skip it explicitly to
719            // guarantee forward progress. Without this, the tokeniser
720            // spins forever on such a byte (regression seen on
721            // pdf.js corpus issue11651.pdf).
722            i += 1;
723        }
724    }
725
726    tokens
727}
728
729/// Calculate the offset between two big-endian byte sequences of equal length.
730///
731/// Both inputs are interpreted as unsigned big-endian integers and the
732/// difference is returned as `usize`. Caller must ensure `code >= start`
733/// (checked in `map_code`); this function saturates to 0 if not, to avoid
734/// panicking on malformed input.
735///
736/// The naive byte-by-byte subtraction is wrong when any single byte
737/// position has `code[i] < start[i]` (which is legal as long as the overall
738/// big-endian value is still `>=`) — it underflows. Reducing each side to
739/// its integer value first avoids the issue.
740fn calculate_offset(code: &[u8], start: &[u8]) -> usize {
741    let code_val: usize = code.iter().fold(0, |acc, &b| acc * 256 + b as usize);
742    let start_val: usize = start.iter().fold(0, |acc, &b| acc * 256 + b as usize);
743    code_val.saturating_sub(start_val)
744}
745
746/// ToUnicode CMap builder for creating custom mappings
747#[derive(Debug, Clone)]
748pub struct ToUnicodeCMapBuilder {
749    /// Character to Unicode mappings
750    mappings: HashMap<Vec<u8>, String>,
751    /// Code length in bytes
752    code_length: usize,
753}
754
755impl ToUnicodeCMapBuilder {
756    /// Create a new ToUnicode CMap builder
757    pub fn new(code_length: usize) -> Self {
758        Self {
759            mappings: HashMap::new(),
760            code_length,
761        }
762    }
763
764    /// Add a character mapping
765    pub fn add_mapping(&mut self, char_code: Vec<u8>, unicode: &str) {
766        self.mappings.insert(char_code, unicode.to_string());
767    }
768
769    /// Add a mapping from a single byte code
770    pub fn add_single_byte_mapping(&mut self, char_code: u8, unicode: char) {
771        let code = if self.code_length == 1 {
772            vec![char_code]
773        } else {
774            // Pad with zeros for multi-byte codes
775            let mut code = vec![0; self.code_length - 1];
776            code.push(char_code);
777            code
778        };
779        self.mappings.insert(code, unicode.to_string());
780    }
781
782    /// Build the ToUnicode CMap content
783    pub fn build(&self) -> Vec<u8> {
784        let mut content = String::new();
785
786        // CMap header
787        content.push_str("/CIDInit /ProcSet findresource begin\n");
788        content.push_str("12 dict begin\n");
789        content.push_str("begincmap\n");
790        content.push_str("/CIDSystemInfo\n");
791        content.push_str("<< /Registry (Adobe)\n");
792        content.push_str("   /Ordering (UCS)\n");
793        content.push_str("   /Supplement 0\n");
794        content.push_str(">> def\n");
795        content.push_str("/CMapName /Adobe-Identity-UCS def\n");
796        content.push_str("/CMapType 2 def\n");
797
798        // Code space range
799        content.push_str("1 begincodespacerange\n");
800        if self.code_length == 1 {
801            content.push_str("<00> <FF>\n");
802        } else {
803            let start = vec![0x00; self.code_length];
804            let end = vec![0xFF; self.code_length];
805            content.push_str(&format!(
806                "<{}> <{}>\n",
807                hex_string(&start),
808                hex_string(&end)
809            ));
810        }
811        content.push_str("endcodespacerange\n");
812
813        // Character mappings
814        if !self.mappings.is_empty() {
815            // Group mappings by consecutive ranges
816            let mut sorted_mappings: Vec<_> = self.mappings.iter().collect();
817            sorted_mappings.sort_by_key(|(k, _)| *k);
818
819            // Output single character mappings
820            let mut single_mappings = Vec::new();
821            for (code, unicode) in &sorted_mappings {
822                let utf16_bytes = string_to_utf16_be_bytes(unicode);
823                single_mappings.push((code, utf16_bytes));
824            }
825
826            // Write bfchar mappings in chunks of 100
827            for chunk in single_mappings.chunks(100) {
828                content.push_str(&format!("{} beginbfchar\n", chunk.len()));
829                for (code, unicode_bytes) in chunk {
830                    content.push_str(&format!(
831                        "<{}> <{}>\n",
832                        hex_string(code),
833                        hex_string(unicode_bytes)
834                    ));
835                }
836                content.push_str("endbfchar\n");
837            }
838        }
839
840        // CMap footer
841        content.push_str("endcmap\n");
842        content.push_str("CMapName currentdict /CMap defineresource pop\n");
843        content.push_str("end\n");
844        content.push_str("end\n");
845
846        content.into_bytes()
847    }
848}
849
850/// Convert string to UTF-16BE bytes
851pub fn string_to_utf16_be_bytes(s: &str) -> Vec<u8> {
852    let mut bytes = Vec::new();
853    for ch in s.encode_utf16() {
854        bytes.extend(&ch.to_be_bytes());
855    }
856    bytes
857}
858
859/// Convert bytes to hex string
860pub fn hex_string(bytes: &[u8]) -> String {
861    bytes.iter().map(|b| format!("{b:02X}")).collect()
862}
863
864#[cfg(test)]
865mod tests {
866    use super::*;
867
868    #[test]
869    fn test_code_range() {
870        let range = CodeRange {
871            start: vec![0x00],
872            end: vec![0xFF],
873        };
874
875        assert!(range.contains(&[0x00]));
876        assert!(range.contains(&[0x80]));
877        assert!(range.contains(&[0xFF]));
878        assert!(!range.contains(&[0x00, 0x00])); // Wrong length
879    }
880
881    #[test]
882    fn test_identity_cmap() {
883        let cmap = CMap::identity_h();
884        assert_eq!(cmap.name, Some("Identity-H".to_string()));
885        assert_eq!(cmap.wmode, 0);
886
887        // Identity mapping returns the same code
888        let code = vec![0x00, 0x41];
889        assert_eq!(cmap.map(&code), Some(code.clone()));
890    }
891
892    #[test]
893    fn test_parse_hex() {
894        assert_eq!(parse_hex("<00>"), Some(vec![0x00]));
895        assert_eq!(parse_hex("<FF>"), Some(vec![0xFF]));
896        assert_eq!(parse_hex("<0041>"), Some(vec![0x00, 0x41]));
897        assert_eq!(parse_hex("<FEFF>"), Some(vec![0xFE, 0xFF]));
898        assert_eq!(parse_hex("invalid"), None);
899    }
900
901    #[test]
902    fn test_calculate_offset() {
903        assert_eq!(calculate_offset(&[0x00, 0x05], &[0x00, 0x00]), 5);
904        assert_eq!(calculate_offset(&[0x01, 0x00], &[0x00, 0x00]), 256);
905        assert_eq!(calculate_offset(&[0xFF], &[0x00]), 255);
906    }
907
908    /// Regression: the byte-by-byte subtraction underflowed whenever
909    /// code[i] < start[i] in any single byte position, even though
910    /// `code >= start` in the big-endian sense. This triggered panics
911    /// extracting text from PDFs with CJK punctuation (e.g. U+3001 `、`
912    /// in a ToUnicode bfrange spanning U+2FFF → U+3002).
913    #[test]
914    fn test_calculate_offset_with_byte_borrow() {
915        // 0x0100 − 0x00FF = 1 (individual byte 0x00 < 0xFF → borrow)
916        assert_eq!(calculate_offset(&[0x01, 0x00], &[0x00, 0xFF]), 1);
917        // 0x3001 − 0x2FFF = 2 (real-world CJK punctuation case)
918        assert_eq!(calculate_offset(&[0x30, 0x01], &[0x2F, 0xFF]), 2);
919        // 0xFF02 − 0xFEFF = 3 (high byte stays equal, low byte wraps)
920        assert_eq!(calculate_offset(&[0xFF, 0x02], &[0xFE, 0xFF]), 3);
921        // Same start and end → zero offset.
922        assert_eq!(calculate_offset(&[0x12, 0x34], &[0x12, 0x34]), 0);
923    }
924
925    #[test]
926    fn test_tounicode_builder() {
927        let mut builder = ToUnicodeCMapBuilder::new(1);
928        builder.add_single_byte_mapping(0x41, 'A');
929        builder.add_single_byte_mapping(0x42, 'B');
930
931        let content = builder.build();
932        let content_str = String::from_utf8(content).unwrap();
933
934        assert!(content_str.contains("/CMapName /Adobe-Identity-UCS def"));
935        assert!(content_str.contains("begincodespacerange"));
936        assert!(content_str.contains("<00> <FF>"));
937        assert!(content_str.contains("beginbfchar"));
938    }
939
940    #[test]
941    fn test_simple_cmap_parsing() {
942        let cmap_data = br#"
943%!PS-Adobe-3.0 Resource-CMap
944%%DocumentNeededResources: ProcSet (CIDInit)
945%%IncludeResource: ProcSet (CIDInit)
946%%BeginResource: CMap (Custom)
947%%Title: (Custom Adobe UCS 0)
948%%Version: 1.000
949%%EndComments
950
951/CIDInit /ProcSet findresource begin
95212 dict begin
953begincmap
954/CIDSystemInfo
955<< /Registry (Adobe)
956   /Ordering (UCS)
957   /Supplement 0
958>> def
959/CMapName /Custom def
960/CMapType 2 def
9611 begincodespacerange
962<00> <FF>
963endcodespacerange
9642 beginbfchar
965<20> <0020>
966<41> <0041>
967endbfchar
968endcmap
969"#;
970
971        let cmap = CMap::parse(cmap_data).unwrap();
972        assert_eq!(cmap.name, Some("Custom".to_string()));
973        assert_eq!(cmap.codespace_ranges.len(), 1);
974        assert_eq!(cmap.map(&[0x20]), Some(vec![0x00, 0x20]));
975        assert_eq!(cmap.map(&[0x41]), Some(vec![0x00, 0x41]));
976    }
977
978    #[test]
979    fn test_cmap_to_unicode() {
980        let mut cmap = CMap::new();
981        cmap.cmap_type = CMapType::ToUnicode;
982
983        // UTF-16BE for 'A'
984        let unicode_a = vec![0x00, 0x41];
985        assert_eq!(cmap.to_unicode(&unicode_a), Some("A".to_string()));
986
987        // UTF-16BE for '中' (U+4E2D)
988        let unicode_cjk = vec![0x4E, 0x2D];
989        assert_eq!(cmap.to_unicode(&unicode_cjk), Some("中".to_string()));
990    }
991
992    #[test]
993    fn test_bf_range_mapping() {
994        let mut cmap = CMap::new();
995        cmap.codespace_ranges.push(CodeRange {
996            start: vec![0x00],
997            end: vec![0xFF],
998        });
999        cmap.mappings.push(CMapEntry::Range {
1000            src_start: vec![0x20],
1001            src_end: vec![0x7E],
1002            dst_start: vec![0x00, 0x20],
1003        });
1004
1005        // Test range mapping
1006        assert_eq!(cmap.map(&[0x20]), Some(vec![0x00, 0x20])); // Space
1007        assert_eq!(cmap.map(&[0x41]), Some(vec![0x00, 0x41])); // 'A'
1008        assert_eq!(cmap.map(&[0x7E]), Some(vec![0x00, 0x7E])); // '~'
1009        assert_eq!(cmap.map(&[0x7F]), None); // Out of range
1010    }
1011
1012    #[test]
1013    fn test_one_byte_bfchar_under_two_byte_codespace() {
1014        // #302 symptom 3: producers frequently declare the generic Identity
1015        // codespace <0000><FFFF> (2-byte) for a *simple* font whose ToUnicode
1016        // entries are actually 1-byte bfchars. The strict length check in
1017        // `CodeRange::contains` rejected every 1-byte code, so `map` returned
1018        // None for the whole stream and the decoder fell back to the wrong
1019        // base encoding — turning typographic glyphs (curly quotes, U+2019)
1020        // into U+FFFD. A 1-byte code that has an explicit bfchar entry must
1021        // decode regardless of the (sloppy) 2-byte codespace declaration.
1022        let cmap_data = br#"/CIDInit /ProcSet findresource begin
102312 dict begin
1024begincmap
1025/CMapName /Adobe-Identity-UCS def
1026/CMapType 2 def
10271 begincodespacerange
1028<0000> <FFFF>
1029endcodespacerange
10303 beginbfchar
1031<2C> <002C>
1032<92> <2019>
1033<61> <0061>
1034endbfchar
1035endcmap
1036"#;
1037        let cmap = CMap::parse(cmap_data).unwrap();
1038        assert_eq!(cmap.map(&[0x92]), Some(vec![0x20, 0x19]), "rsquo 0x92");
1039        assert_eq!(cmap.map(&[0x2C]), Some(vec![0x00, 0x2C]), "comma 0x2C");
1040        assert_eq!(cmap.map(&[0x61]), Some(vec![0x00, 0x61]), "'a' 0x61");
1041    }
1042
1043    #[test]
1044    fn test_multibyte_mapping() {
1045        let mut builder = ToUnicodeCMapBuilder::new(2);
1046        builder.add_mapping(vec![0x00, 0x41], "A");
1047        builder.add_mapping(vec![0x00, 0x42], "B");
1048
1049        let content = builder.build();
1050        let content_str = String::from_utf8(content).unwrap();
1051
1052        assert!(content_str.contains("<0000> <FFFF>"));
1053        assert!(content_str.contains("<0041>"));
1054        assert!(content_str.contains("<0042>"));
1055    }
1056
1057    // ------------------------------------------------------------------
1058    // Issue #272 (Bug A) — minified / single-line CMap directives.
1059    //
1060    // BOE (Spanish official gazette) PDFs ship ToUnicode CMaps with
1061    // `begin*` and `end*` operators on the SAME line as the entries:
1062    //
1063    //   1 begincodespacerange <0000><00D1> endcodespacerange
1064    //
1065    // PostScript permits this (CMaps are tokens, not lines). The original
1066    // parser was line-based and got stuck in `in_codespace_range = true`
1067    // forever, so subsequent `beginbfchar` / `beginbfrange` lines were
1068    // discarded and the CMap produced 0 mappings. Encoding fallback
1069    // (PdfDocEncoding) then leaked each 2-byte CID as two ASCII bytes
1070    // ("M" → "\0" + "0" → " 0" after sanitization).
1071    // ------------------------------------------------------------------
1072
1073    /// The actual BOE F0 CMap, verbatim from
1074    /// `corpus_cache/6320a941c903a04f.pdf` (Boletín Oficial del Estado,
1075    /// sumario 2025-01-15). Single-line `begincodespacerange ...
1076    /// endcodespacerange`, then multi-line bfchar and bfrange blocks.
1077    /// Must parse to 1 codespace, 14 bfchar singles, and 8 bfrange
1078    /// entries (22 mappings total).
1079    #[test]
1080    fn boe_single_line_codespacerange_parses_full_cmap() {
1081        let cmap_data = b"/CIDInit /ProcSet findresource begin 12 dict begin begincmap \n\
1082/CIDSystemInfo <</Registry (F0+0) /Ordering (F0) /Supplement 0>> def\n\
1083/CMapName /F0+0 def\n\
1084/CMapType 2 def\n\
10851 begincodespacerange <0000><00D1> endcodespacerange\n\
108614 beginbfchar\n\
1087<0000><0000>\n\
1088<0003><0020>\n\
1089<005C><0079>\n\
1090<0066><00D1>\n\
1091<0069><00E1>\n\
1092<0070><00E9>\n\
1093<0074><00ED>\n\
1094<0078><00F1>\n\
1095<0079><00F3>\n\
1096<007E><00FA>\n\
1097<00C7><00C1>\n\
1098<00CA><00CD>\n\
1099<00CE><00D3>\n\
1100<00D1><00DA>\n\
1101endbfchar\n\
11028 beginbfrange\n\
1103<000F><001D><002C>\n\
1104<0024><002D><0041>\n\
1105<002F><0033><004C>\n\
1106<0035><0039><0052>\n\
1107<003B><003D><0058>\n\
1108<0044><004C><0061>\n\
1109<004F><0053><006C>\n\
1110<0055><005A><0072>\n\
1111endbfrange\n\
1112endcmap CMapName currentdict /CMap defineresource pop end end\n";
1113
1114        let cmap = CMap::parse(cmap_data).expect("CMap parse must succeed");
1115
1116        assert_eq!(
1117            cmap.codespace_ranges.len(),
1118            1,
1119            "single-line begincodespacerange must produce 1 codespace; got {} ({:?})",
1120            cmap.codespace_ranges.len(),
1121            cmap.codespace_ranges
1122        );
1123        let cs = &cmap.codespace_ranges[0];
1124        assert_eq!(cs.start, vec![0x00, 0x00]);
1125        assert_eq!(cs.end, vec![0x00, 0xD1]);
1126
1127        // 14 bfchar singles + 8 bfrange entries = 22 entries.
1128        assert_eq!(
1129            cmap.mappings.len(),
1130            22,
1131            "expected 22 entries (14 bfchar + 8 bfrange); got {}",
1132            cmap.mappings.len()
1133        );
1134
1135        // Concrete decoding probe: CID <0030> via bfrange <002F><0033><004C>
1136        // (the 'M' glyph in this font) → offset 1 → 0x004D → 'M'.
1137        let mapped = cmap.map(&[0x00, 0x30]).expect("CID 0030 must map");
1138        assert_eq!(mapped, vec![0x00, 0x4D]);
1139        assert_eq!(cmap.to_unicode(&mapped).as_deref(), Some("M"));
1140
1141        // bfchar single: CID <0003> → <0020> (space).
1142        let mapped = cmap.map(&[0x00, 0x03]).expect("CID 0003 must map");
1143        assert_eq!(mapped, vec![0x00, 0x20]);
1144    }
1145
1146    /// Defensive coverage for the same minification pattern on `beginbfchar`
1147    /// and `beginbfrange` blocks. Any of the three Adobe CMap operators
1148    /// may legally appear with the `end*` on the same line — the parser
1149    /// must handle all three uniformly.
1150    #[test]
1151    fn single_line_beginbfchar_parses() {
1152        let cmap_data = b"begincmap\n\
11531 begincodespacerange <00><FF> endcodespacerange\n\
11542 beginbfchar <20><0020> <41><0041> endbfchar\n\
1155endcmap\n";
1156
1157        let cmap = CMap::parse(cmap_data).expect("parse");
1158        assert_eq!(cmap.codespace_ranges.len(), 1);
1159        assert_eq!(cmap.mappings.len(), 2, "got {:?}", cmap.mappings);
1160        assert_eq!(cmap.map(&[0x20]), Some(vec![0x00, 0x20]));
1161        assert_eq!(cmap.map(&[0x41]), Some(vec![0x00, 0x41]));
1162    }
1163
1164    #[test]
1165    fn single_line_beginbfrange_parses() {
1166        let cmap_data = b"begincmap\n\
11671 begincodespacerange <0000><00FF> endcodespacerange\n\
11681 beginbfrange <0020><007E><0020> endbfrange\n\
1169endcmap\n";
1170
1171        let cmap = CMap::parse(cmap_data).expect("parse");
1172        assert_eq!(cmap.codespace_ranges.len(), 1);
1173        assert_eq!(cmap.mappings.len(), 1, "got {:?}", cmap.mappings);
1174        // CID 0x0041 should map via the range (offset 0x21) to 0x0041 (UTF-16BE 'A').
1175        let mapped = cmap.map(&[0x00, 0x41]).expect("CID 0041 must map");
1176        assert_eq!(mapped, vec![0x00, 0x41]);
1177    }
1178
1179    /// `bfrange` array form must use big-endian carry when incrementing
1180    /// `current_src` between dst entries. A naive last-byte-only increment
1181    /// (`<00FE> + 1 → <0000>`) would silently insert into the wrong slot
1182    /// whenever the range crosses a byte boundary, producing bogus
1183    /// mappings without any error.
1184    #[test]
1185    fn bfrange_array_form_increments_src_with_big_endian_carry() {
1186        // 4 dsts over <00FE>..=<0101>: the second increment crosses
1187        // from 0x00FF to 0x0100, which requires the carry to land in
1188        // the high byte.
1189        let cmap_data = b"begincmap\n\
11901 begincodespacerange <0000><FFFF> endcodespacerange\n\
11911 beginbfrange\n\
1192<00FE> <0101> [<0041> <0042> <0043> <0044>]\n\
1193endbfrange\n\
1194endcmap\n";
1195
1196        let cmap = CMap::parse(cmap_data).expect("parse");
1197
1198        // Expect exactly four Single entries at <00FE>, <00FF>, <0100>, <0101>.
1199        assert_eq!(
1200            cmap.mappings.len(),
1201            4,
1202            "expected 4 mappings across the byte boundary; got {:?}",
1203            cmap.mappings
1204        );
1205
1206        // Each src code must map to the matching dst from the array.
1207        assert_eq!(cmap.map(&[0x00, 0xFE]), Some(vec![0x00, 0x41]));
1208        assert_eq!(cmap.map(&[0x00, 0xFF]), Some(vec![0x00, 0x42]));
1209        assert_eq!(
1210            cmap.map(&[0x01, 0x00]),
1211            Some(vec![0x00, 0x43]),
1212            "carry across byte boundary: <0100> must map to dsts[2] (0x0043)"
1213        );
1214        assert_eq!(cmap.map(&[0x01, 0x01]), Some(vec![0x00, 0x44]));
1215
1216        // Crucially: <0000> (the would-be result of naive last-byte
1217        // wrap of <00FF>) must NOT have inherited dsts[2].
1218        assert_eq!(
1219            cmap.map(&[0x00, 0x00]),
1220            None,
1221            "<0000> must not be populated; naive wraparound would have put 0x0043 here"
1222        );
1223    }
1224
1225    /// `increment_be` direct unit test: a focused regression guard for
1226    /// the carry helper used by the bfrange array form.
1227    #[test]
1228    fn increment_be_carries_across_byte_boundary() {
1229        let mut bytes = vec![0x00, 0xFF];
1230        assert!(increment_be(&mut bytes));
1231        assert_eq!(bytes, vec![0x01, 0x00]);
1232
1233        let mut bytes = vec![0x00, 0x00, 0xFF, 0xFF];
1234        assert!(increment_be(&mut bytes));
1235        assert_eq!(bytes, vec![0x00, 0x01, 0x00, 0x00]);
1236
1237        // Overflow at the top: <FFFF> + 1 returns false and wraps.
1238        let mut bytes = vec![0xFF, 0xFF];
1239        assert!(!increment_be(&mut bytes));
1240        assert_eq!(bytes, vec![0x00, 0x00]);
1241    }
1242
1243    /// Mixed: some `begin*` directives on their own line, others combined.
1244    /// The parser must not let one form pollute the state of another.
1245    #[test]
1246    fn mixed_single_line_and_multi_line_directives_parse() {
1247        let cmap_data = b"begincmap\n\
12481 begincodespacerange\n\
1249<0000><FFFF>\n\
1250endcodespacerange\n\
12512 beginbfchar <0001><0041> <0002><0042> endbfchar\n\
12521 beginbfrange\n\
1253<0010> <0012> <0050>\n\
1254endbfrange\n\
1255endcmap\n";
1256
1257        let cmap = CMap::parse(cmap_data).expect("parse");
1258        assert_eq!(cmap.codespace_ranges.len(), 1);
1259        assert_eq!(cmap.mappings.len(), 3);
1260        assert_eq!(cmap.map(&[0x00, 0x01]), Some(vec![0x00, 0x41]));
1261        assert_eq!(cmap.map(&[0x00, 0x02]), Some(vec![0x00, 0x42]));
1262        assert_eq!(cmap.map(&[0x00, 0x10]), Some(vec![0x00, 0x50]));
1263        assert_eq!(cmap.map(&[0x00, 0x12]), Some(vec![0x00, 0x52]));
1264    }
1265
1266    /// `bfrange` array form with an empty `[]` is legal but degenerate:
1267    /// it should produce zero entries and must not panic. This locks in
1268    /// the early-exit behaviour of the `for dst in dsts` body when the
1269    /// array vector is empty.
1270    #[test]
1271    fn bfrange_array_form_with_empty_array_emits_zero_mappings_no_panic() {
1272        let cmap_data = b"begincmap\n\
12731 begincodespacerange <0000><FFFF> endcodespacerange\n\
12741 beginbfrange\n\
1275<0010> <0012> []\n\
1276endbfrange\n\
1277endcmap\n";
1278
1279        let cmap = CMap::parse(cmap_data).expect("parse must not panic on empty array");
1280        assert_eq!(
1281            cmap.mappings.len(),
1282            0,
1283            "empty bfrange array must yield zero mappings; got {:?}",
1284            cmap.mappings
1285        );
1286        assert_eq!(cmap.map(&[0x00, 0x10]), None);
1287    }
1288
1289    /// Regression for an infinite loop introduced by the token-based
1290    /// rewrite: a stray close-delimiter (`>` that is not part of `>>`,
1291    /// or a lone `]` / `)`) reached the keyword branch of the tokeniser,
1292    /// which `break`s immediately without advancing the cursor. The
1293    /// scanner then spun forever on that byte. issue11651.pdf from the
1294    /// pdf.js corpus contained exactly such a byte in a CMap-adjacent
1295    /// stream and hung text extraction. The tokeniser must always make
1296    /// forward progress; this test must complete (not hang) and parse
1297    /// the surrounding valid mappings.
1298    #[test]
1299    fn stray_close_delimiters_do_not_hang_tokenizer() {
1300        // A lone `>`, `]`, and `)` interspersed with valid content.
1301        let cmap_data = b"begincmap\n\
13021 begincodespacerange <0000><FFFF> endcodespacerange\n\
1303> ] )\n\
13042 beginbfchar\n\
1305<0041><0061>\n\
1306<0042><0062>\n\
1307endbfchar\n\
1308endcmap\n";
1309
1310        // The assertion that matters is that parse() RETURNS at all.
1311        let cmap = CMap::parse(cmap_data).expect("parse must terminate, not hang");
1312        assert_eq!(cmap.map(&[0x00, 0x41]), Some(vec![0x00, 0x61]));
1313        assert_eq!(cmap.map(&[0x00, 0x42]), Some(vec![0x00, 0x62]));
1314    }
1315
1316    /// A CMap consisting solely of a stray `>` must terminate (empty CMap).
1317    #[test]
1318    fn lone_gt_delimiter_terminates() {
1319        let cmap = CMap::parse(b">").expect("must terminate");
1320        assert!(cmap.mappings.is_empty());
1321    }
1322
1323    /// `usecmap` directive must inherit the codespace + identity fallback
1324    /// from a predefined parent (`Identity-H` / `Identity-V`). Codes
1325    /// that the child CMap doesn't explicitly map should pass through
1326    /// as their UTF-16BE code units (Identity behaviour), while
1327    /// explicit mappings still override.
1328    #[test]
1329    fn usecmap_identity_h_inheritance_provides_fallback() {
1330        let cmap_data = b"begincmap\n\
1331/CMapName /CustomChild def\n\
1332/CMapType 2 def\n\
1333/Identity-H usecmap\n\
13341 beginbfchar\n\
1335<0041><0061>\n\
1336endbfchar\n\
1337endcmap\n";
1338
1339        let cmap = CMap::parse(cmap_data).expect("parse");
1340
1341        // Explicit override survives.
1342        assert_eq!(cmap.map(&[0x00, 0x41]), Some(vec![0x00, 0x61]));
1343        assert_eq!(cmap.to_unicode(&[0x00, 0x61]).as_deref(), Some("a"));
1344
1345        // Unmapped code falls back to identity: <0042> → CID <0042>
1346        // → ToUnicode interprets as UTF-16BE 'B'.
1347        let mapped = cmap
1348            .map(&[0x00, 0x42])
1349            .expect("identity fallback must map <0042>");
1350        assert_eq!(mapped, vec![0x00, 0x42]);
1351        assert_eq!(cmap.to_unicode(&mapped).as_deref(), Some("B"));
1352
1353        // Inherited codespace covers the full 2-byte range even though
1354        // no explicit `begincodespacerange` appears in the child CMap.
1355        assert!(cmap.is_valid_code(&[0x12, 0x34]));
1356    }
1357
1358    /// `usecmap Identity-V` mirrors Identity-H semantics for vertical
1359    /// writing-mode CMaps. Same fallback behaviour.
1360    #[test]
1361    fn usecmap_identity_v_inheritance_provides_fallback() {
1362        let cmap_data = b"begincmap\n\
1363/Identity-V usecmap\n\
1364endcmap\n";
1365
1366        let cmap = CMap::parse(cmap_data).expect("parse");
1367
1368        // No explicit mappings, but identity fallback covers everything.
1369        assert_eq!(cmap.map(&[0x4E, 0x2D]), Some(vec![0x4E, 0x2D]));
1370        assert_eq!(
1371            cmap.to_unicode(&[0x4E, 0x2D]).as_deref(),
1372            Some("中"),
1373            "identity fallback should let CJK code <4E2D> decode to U+4E2D"
1374        );
1375    }
1376
1377    /// A non-Identity parent (`/Foo usecmap`) must NOT enable the
1378    /// identity fallback. This locks in that the inheritance is only
1379    /// honoured for the two predefined Identity CMaps the codebase
1380    /// can synthesise internally — external CMap chaining is out of
1381    /// scope and must remain absent (no resolver yet).
1382    #[test]
1383    fn usecmap_non_identity_parent_does_not_enable_identity_fallback() {
1384        let cmap_data = b"begincmap\n\
1385/SomeOtherCMap usecmap\n\
1386endcmap\n";
1387
1388        let cmap = CMap::parse(cmap_data).expect("parse");
1389        assert_eq!(
1390            cmap.map(&[0x00, 0x41]),
1391            None,
1392            "non-Identity usecmap must not provide identity fallback"
1393        );
1394    }
1395
1396    /// Unterminated hex string (`<00FF` without `>`) used to silently
1397    /// truncate the entire token stream. The resilience fix keeps the
1398    /// scanner advancing past the lone `<` so that subsequent valid
1399    /// mappings still land in the CMap.
1400    #[test]
1401    fn unterminated_hex_string_does_not_discard_following_mappings() {
1402        // The `<00FF` (no closing `>`) appears between two valid mappings.
1403        // Pre-fix the entire trailing `<0042><0043> endbfchar endcmap`
1404        // would have been silently dropped; post-fix only the lone `<` is
1405        // skipped and the second mapping survives.
1406        let cmap_data = b"begincmap\n\
14071 begincodespacerange <0000><FFFF> endcodespacerange\n\
14082 beginbfchar\n\
1409<0041><0061>\n\
1410<00FF\n\
1411<0042><0062>\n\
1412endbfchar\n\
1413endcmap\n";
1414
1415        let cmap = CMap::parse(cmap_data).expect("parse");
1416        // At least the two well-formed mappings must survive.
1417        assert!(
1418            cmap.single_mappings.contains_key(&vec![0x00, 0x41]),
1419            "first valid mapping (<0041>→<0061>) must survive unterminated hex"
1420        );
1421        assert!(
1422            cmap.single_mappings.contains_key(&vec![0x00, 0x42]),
1423            "second valid mapping after the malformed `<00FF` must survive"
1424        );
1425        assert_eq!(cmap.map(&[0x00, 0x41]), Some(vec![0x00, 0x61]));
1426        assert_eq!(cmap.map(&[0x00, 0x42]), Some(vec![0x00, 0x62]));
1427    }
1428
1429    #[test]
1430    fn usecmap_external_ucs2_parent_maps_to_ordering() {
1431        let data = b"begincmap\n/Adobe-Korea1-UCS2 usecmap\n\
14321 begincodespacerange <0000> <FFFF> endcodespacerange\n\
1433endcmap";
1434        let cmap = CMap::parse(data).expect("parse");
1435        assert_eq!(cmap.inherited_ordering(), Some("Korea1"));
1436    }
1437}