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}