Skip to main content

stet_pdf_reader/content/
cmap.rs

1// stet-pdf-reader
2// Copyright (c) 2026 Scott Bowman
3// SPDX-License-Identifier: Apache-2.0 OR MIT
4
5//! CMap parser for Type 0 (composite) font encoding.
6
7use std::collections::HashMap;
8
9/// Parsed CMap: maps character codes to CIDs.
10pub struct CMap {
11    /// Code-to-CID mapping (character code → CID).
12    pub code_to_cid: HashMap<u32, u32>,
13    /// Precomputed first-byte → code length table.
14    /// 0 = not in any codespace range (treat as 2-byte default).
15    pub code_lengths: [u8; 256],
16    /// Writing mode: 0 = horizontal, 1 = vertical.
17    pub wmode: u8,
18}
19
20impl CMap {
21    /// Create an Identity CMap (code == CID, all 2-byte).
22    pub fn identity() -> Self {
23        Self {
24            code_to_cid: HashMap::new(),
25            code_lengths: [2; 256],
26            wmode: 0,
27        }
28    }
29
30    /// Decode a character code to a CID.
31    pub fn decode(&self, code: u32) -> u32 {
32        // Identity mapping: code == CID
33        self.code_to_cid.get(&code).copied().unwrap_or(code)
34    }
35
36    /// Get the byte width of a character code starting with the given byte.
37    pub fn code_width(&self, first_byte: u8) -> usize {
38        let w = self.code_lengths[first_byte as usize];
39        if w == 0 { 2 } else { w as usize }
40    }
41
42    /// Parse a CMap from stream data.
43    pub fn parse(data: &[u8]) -> Self {
44        Self::parse_with_loader(data, None)
45    }
46
47    /// Parse a CMap, optionally resolving `usecmap` with a loader function.
48    pub fn parse_with_loader(
49        data: &[u8],
50        loader: Option<&dyn Fn(&[u8]) -> Option<Vec<u8>>>,
51    ) -> Self {
52        let mut code_to_cid = HashMap::new();
53        let mut codespace_ranges: Vec<(Vec<u8>, Vec<u8>)> = Vec::new();
54        let mut wmode: u8 = 0;
55
56        let text = String::from_utf8_lossy(data);
57
58        #[allow(clippy::while_let_on_iterator)]
59        let mut lines = text.lines();
60
61        while let Some(line) = lines.next() {
62            let line = line.trim();
63
64            // Handle usecmap: inherit mappings from the referenced CMap
65            if line.ends_with("usecmap") {
66                let name = line.strip_suffix("usecmap").unwrap_or("").trim();
67                let name = name.strip_prefix('/').unwrap_or(name);
68                if !name.is_empty() {
69                    if let Some(load_fn) = loader {
70                        if let Some(base_data) = load_fn(name.as_bytes()) {
71                            let base = Self::parse_with_loader(&base_data, loader);
72                            // Inherit base mappings (current entries override)
73                            for (k, v) in base.code_to_cid {
74                                code_to_cid.entry(k).or_insert(v);
75                            }
76                            if codespace_ranges.is_empty() {
77                                // Inherit codespace from base if not defined yet
78                                for fb in 0..256u16 {
79                                    let w = base.code_lengths[fb as usize];
80                                    if w > 0 {
81                                        let low = if w == 1 {
82                                            vec![fb as u8]
83                                        } else {
84                                            vec![fb as u8, 0x00]
85                                        };
86                                        let high = if w == 1 {
87                                            vec![fb as u8]
88                                        } else {
89                                            vec![fb as u8, 0xFF]
90                                        };
91                                        codespace_ranges.push((low, high));
92                                    }
93                                }
94                            }
95                            if wmode == 0 {
96                                wmode = base.wmode;
97                            }
98                        }
99                    }
100                }
101            }
102
103            // Parse /WMode
104            if let Some(rest) = line.strip_prefix("/WMode") {
105                let rest = rest.trim();
106                if let Some(rest) = rest.strip_prefix("def").or(Some(rest)) {
107                    if let Ok(v) = rest.trim().parse::<u8>() {
108                        wmode = v;
109                    }
110                }
111            }
112            // Also handle "N /WMode def" pattern
113            if line.ends_with("/WMode def") {
114                let parts: Vec<&str> = line.split_whitespace().collect();
115                if let Some(v) = parts.first().and_then(|s| s.parse::<u8>().ok()) {
116                    wmode = v;
117                }
118            }
119
120            // Parse codespace ranges
121            if line.ends_with("begincodespacerange") {
122                while let Some(range_line) = lines.next() {
123                    let range_line = range_line.trim();
124                    if range_line == "endcodespacerange" {
125                        break;
126                    }
127                    if let Some((low, high)) = parse_codespace_range(range_line) {
128                        codespace_ranges.push((low, high));
129                    }
130                }
131            }
132
133            // Parse cidchar mappings: <code> cid
134            // Handles both multi-line format (data on subsequent lines) and
135            // inline format where data appears between begincidchar/endcidchar
136            // on the same line (e.g. "1 begincidchar <0020> 1 endcidchar").
137            if line.contains("begincidchar") {
138                // Check for inline format: both begin and end on same line
139                if let Some(inline) = extract_inline_data(line, "begincidchar", "endcidchar") {
140                    if let Some((code, cid)) = parse_cidchar_line(inline) {
141                        code_to_cid.insert(code, cid);
142                    }
143                } else if line.ends_with("begincidchar") {
144                    while let Some(char_line) = lines.next() {
145                        let char_line = char_line.trim();
146                        if char_line == "endcidchar" {
147                            break;
148                        }
149                        if let Some((code, cid)) = parse_cidchar_line(char_line) {
150                            code_to_cid.insert(code, cid);
151                        }
152                    }
153                }
154            }
155
156            // Parse cidrange mappings: <start> <end> cid_start
157            if line.contains("begincidrange") {
158                if let Some(inline) = extract_inline_data(line, "begincidrange", "endcidrange") {
159                    if let Some((start, end, cid_start)) = parse_cidrange_line(inline) {
160                        for code in start..=end {
161                            code_to_cid.insert(code, cid_start + (code - start));
162                        }
163                    }
164                } else if line.ends_with("begincidrange") {
165                    while let Some(range_line) = lines.next() {
166                        let range_line = range_line.trim();
167                        if range_line == "endcidrange" {
168                            break;
169                        }
170                        if let Some((start, end, cid_start)) = parse_cidrange_line(range_line) {
171                            for code in start..=end {
172                                code_to_cid.insert(code, cid_start + (code - start));
173                            }
174                        }
175                    }
176                }
177            }
178
179            // Also parse bfchar/bfrange (some CMaps use these)
180            if line.contains("beginbfchar") {
181                if let Some(inline) = extract_inline_data(line, "beginbfchar", "endbfchar") {
182                    if let Some((code, unicode)) = parse_bfchar_line(inline) {
183                        code_to_cid.insert(code, unicode);
184                    }
185                } else if line.ends_with("beginbfchar") {
186                    while let Some(char_line) = lines.next() {
187                        let char_line = char_line.trim();
188                        if char_line == "endbfchar" {
189                            break;
190                        }
191                        if let Some((code, unicode)) = parse_bfchar_line(char_line) {
192                            code_to_cid.insert(code, unicode);
193                        }
194                    }
195                }
196            }
197
198            if line.contains("beginbfrange") {
199                if let Some(inline) = extract_inline_data(line, "beginbfrange", "endbfrange") {
200                    if let Some((start, end, cid_start)) = parse_cidrange_line(inline) {
201                        for code in start..=end {
202                            code_to_cid.insert(code, cid_start + (code - start));
203                        }
204                    }
205                } else if line.ends_with("beginbfrange") {
206                    while let Some(range_line) = lines.next() {
207                        let range_line = range_line.trim();
208                        if range_line == "endbfrange" {
209                            break;
210                        }
211                        if let Some((start, end, cid_start)) = parse_cidrange_line(range_line) {
212                            for code in start..=end {
213                                code_to_cid.insert(code, cid_start + (code - start));
214                            }
215                        }
216                    }
217                }
218            }
219        }
220
221        // Build first-byte → code-length table from codespace ranges.
222        // For each first byte, find the shortest matching codespace range.
223        let mut code_lengths = [0u8; 256];
224        if codespace_ranges.is_empty() {
225            // No codespace ranges → default all to 2-byte
226            code_lengths = [2; 256];
227        } else {
228            for (low, high) in &codespace_ranges {
229                let width = low.len() as u8;
230                let first_lo = low[0];
231                let first_hi = high[0];
232                for byte in first_lo..=first_hi {
233                    let cur = code_lengths[byte as usize];
234                    // Prefer shorter (1-byte over 2-byte) or fill if unset
235                    if cur == 0 || width < cur {
236                        code_lengths[byte as usize] = width;
237                    }
238                }
239            }
240        }
241
242        CMap {
243            code_to_cid,
244            code_lengths,
245            wmode,
246        }
247    }
248}
249
250/// Extract inline data between a begin/end keyword pair on the same line.
251/// For example, from `"1 begincidchar  <0020>  1 endcidchar"`,
252/// returns `Some("<0020>  1")`.
253fn extract_inline_data<'a>(line: &'a str, begin_kw: &str, end_kw: &str) -> Option<&'a str> {
254    let begin_pos = line.find(begin_kw)?;
255    let end_pos = line.find(end_kw)?;
256    if end_pos <= begin_pos {
257        return None;
258    }
259    let data_start = begin_pos + begin_kw.len();
260    if data_start >= end_pos {
261        return None;
262    }
263    let data = line[data_start..end_pos].trim();
264    if data.is_empty() { None } else { Some(data) }
265}
266
267/// Parse a codespace range line like `<20> <20>` or `<0000> <19FF>`.
268/// Returns (low_bytes, high_bytes).
269fn parse_codespace_range(line: &str) -> Option<(Vec<u8>, Vec<u8>)> {
270    let tokens = split_cmap_tokens(line);
271    if tokens.len() >= 2 {
272        let low = parse_hex_bytes(&tokens[0])?;
273        let high = parse_hex_bytes(&tokens[1])?;
274        if low.len() == high.len() && !low.is_empty() {
275            Some((low, high))
276        } else {
277            None
278        }
279    } else {
280        None
281    }
282}
283
284/// Parse a hex string like `<0041>` into raw bytes.
285fn parse_hex_bytes(s: &str) -> Option<Vec<u8>> {
286    let s = s.trim();
287    if s.starts_with('<') && s.ends_with('>') {
288        let hex = &s[1..s.len() - 1];
289        let mut bytes = Vec::new();
290        let mut i = 0;
291        while i + 1 < hex.len() {
292            bytes.push(u8::from_str_radix(&hex[i..i + 2], 16).ok()?);
293            i += 2;
294        }
295        // Odd-length hex: pad last nibble
296        if i < hex.len() {
297            bytes.push(u8::from_str_radix(&format!("{}0", &hex[i..]), 16).ok()?);
298        }
299        Some(bytes)
300    } else {
301        None
302    }
303}
304
305/// Split a CMap line into tokens at `>` boundaries and whitespace.
306/// Handles concatenated tokens like `<e0>151` or `<20><5b>1`.
307fn split_cmap_tokens(line: &str) -> Vec<String> {
308    let mut tokens = Vec::new();
309    let mut i = 0;
310    let bytes = line.as_bytes();
311    while i < bytes.len() {
312        // Skip whitespace
313        while i < bytes.len() && bytes[i].is_ascii_whitespace() {
314            i += 1;
315        }
316        if i >= bytes.len() {
317            break;
318        }
319        if bytes[i] == b'<' {
320            // Hex token: consume until '>'
321            let start = i;
322            while i < bytes.len() && bytes[i] != b'>' {
323                i += 1;
324            }
325            if i < bytes.len() {
326                i += 1; // consume '>'
327            }
328            tokens.push(line[start..i].to_string());
329        } else {
330            // Non-hex token: consume until whitespace or '<'
331            let start = i;
332            while i < bytes.len() && !bytes[i].is_ascii_whitespace() && bytes[i] != b'<' {
333                i += 1;
334            }
335            tokens.push(line[start..i].to_string());
336        }
337    }
338    tokens
339}
340
341/// Parse a hex string like `<0041>` into a u32.
342fn parse_hex(s: &str) -> Option<u32> {
343    let s = s.trim();
344    if s.starts_with('<') && s.ends_with('>') {
345        u32::from_str_radix(&s[1..s.len() - 1], 16).ok()
346    } else {
347        None
348    }
349}
350
351/// Parse a cidchar line: `<code> cid` or `<code>cid` (no space).
352fn parse_cidchar_line(line: &str) -> Option<(u32, u32)> {
353    let tokens = split_cmap_tokens(line);
354    if tokens.len() >= 2 {
355        let code = parse_hex(&tokens[0])?;
356        let cid = tokens[1].parse::<u32>().ok()?;
357        Some((code, cid))
358    } else {
359        None
360    }
361}
362
363/// Parse a cidrange line: `<start> <end> cid_start` or `<start><end>cid_start`.
364fn parse_cidrange_line(line: &str) -> Option<(u32, u32, u32)> {
365    let tokens = split_cmap_tokens(line);
366    if tokens.len() >= 3 {
367        let start = parse_hex(&tokens[0])?;
368        let end = parse_hex(&tokens[1])?;
369        let cid_start = if tokens[2].starts_with('<') {
370            parse_hex(&tokens[2])?
371        } else {
372            tokens[2].parse::<u32>().ok()?
373        };
374        Some((start, end, cid_start))
375    } else {
376        None
377    }
378}
379
380/// Parse a bfchar line: `<code> <unicode>` or `<code><unicode>`.
381fn parse_bfchar_line(line: &str) -> Option<(u32, u32)> {
382    let tokens = split_cmap_tokens(line);
383    if tokens.len() >= 2 {
384        let code = parse_hex(&tokens[0])?;
385        let unicode = parse_hex(&tokens[1])?;
386        Some((code, unicode))
387    } else {
388        None
389    }
390}