Skip to main content

edgeparse_core/pdf/
font.rs

1//! PDF font handling — font resolution, glyph widths, and Unicode mapping.
2
3use std::collections::HashMap;
4use std::sync::LazyLock;
5
6/// Adobe Glyph List data, embedded at compile time.
7static AGL_DATA: &str = include_str!("data/glyphlist.txt");
8
9/// Lazily-initialized AGL lookup: glyph name → Unicode string.
10static AGL_MAP: LazyLock<HashMap<&'static str, String>> = LazyLock::new(|| {
11    let mut map = HashMap::with_capacity(4300);
12    for line in AGL_DATA.lines() {
13        let line = line.trim();
14        if line.is_empty() || line.starts_with('#') {
15            continue;
16        }
17        if let Some((name, hex_part)) = line.split_once(';') {
18            // hex_part may be a single value "0041" or multi-codepoint "05D3 05B2"
19            let mut s = String::new();
20            for hex_str in hex_part.split_whitespace() {
21                if let Ok(cp) = u32::from_str_radix(hex_str, 16) {
22                    if let Some(c) = char::from_u32(cp) {
23                        s.push(c);
24                    }
25                }
26            }
27            if !s.is_empty() {
28                map.insert(name, s);
29            }
30        }
31    }
32    map
33});
34
35/// Extra glyph names used by TeX Computer Modern fonts (CMSY, CMEX, CMMI, etc.)
36/// that are not in the Adobe Glyph List.
37static TEX_GLYPH_MAP: LazyLock<HashMap<&'static str, &'static str>> = LazyLock::new(|| {
38    let mut m = HashMap::new();
39    // CMSY (Computer Modern Symbol) extras
40    m.insert("asteriskmath", "\u{2217}"); // ∗
41    m.insert("diamondmath", "\u{22C4}"); // ⋄
42    m.insert("minusplus", "\u{2213}"); // ∓
43    m.insert("circleminus", "\u{2296}"); // ⊖
44    m.insert("circledivide", "\u{2298}"); // ⊘
45    m.insert("circledot", "\u{2299}"); // ⊙
46    m.insert("circlecopyrt", "\u{00A9}"); // ©
47    m.insert("equivasymptotic", "\u{224D}"); // ≍
48    m.insert("precedesequal", "\u{227C}"); // ≼
49    m.insert("followsequal", "\u{227D}"); // ≽
50    m.insert("similarequal", "\u{2243}"); // ≃
51    m.insert("lessmuch", "\u{226A}"); // ≪
52    m.insert("greatermuch", "\u{226B}"); // ≫
53    m.insert("follows", "\u{227B}"); // ≻
54    m.insert("arrownortheast", "\u{2197}"); // ↗
55    m.insert("arrowsoutheast", "\u{2198}"); // ↘
56    m.insert("arrownorthwest", "\u{2196}"); // ↖
57    m.insert("arrowsouthwest", "\u{2199}"); // ↙
58    m.insert("negationslash", "\u{0338}"); // combining long solidus
59    m.insert("owner", "\u{220B}"); // ∋
60    m.insert("triangleinv", "\u{25BD}"); // ▽
61    m.insert("latticetop", "\u{22A4}"); // ⊤
62                                        // CMMI (Computer Modern Math Italic) extras
63    m.insert("tie", "\u{2040}"); // ⁀  (character tie)
64    m.insert("dotlessj", "\u{0237}"); // ȷ
65    m.insert("vector", "\u{20D7}"); // combining right arrow above
66                                    // CMEX (Computer Modern Extension) and CMR extras
67    m.insert("bardbl", "\u{2016}"); // ‖
68    m.insert("mapsto", "\u{21A6}"); // ↦
69    m.insert("lscript", "\u{2113}"); // ℓ
70    m.insert("weierstrass", "\u{2118}"); // ℘
71    m.insert("visiblespace", "\u{2423}"); // ␣
72    m
73});
74
75/// Represents a resolved PDF font with metrics and encoding info.
76///
77/// # Invariants
78///
79/// `widths`, `default_width`, `ascent`, `descent`, and `font_bbox` are always
80/// stored in **per-mille text-space units** (1000 = 1 em). Callers convert to
81/// user space with `value / 1000.0 * font_size`. Type 3 fonts, whose native
82/// `/Widths` live in glyph space scaled by `/FontMatrix`, are normalized at
83/// resolve time by [`crate::pdf::font_type3`] so this invariant holds for every
84/// subtype.
85#[derive(Debug, Clone)]
86pub struct PdfFont {
87    /// Resource name (e.g., "F1")
88    pub name: String,
89    /// Base font name (e.g., "Helvetica", "ArialMT")
90    pub base_font: String,
91    /// Font subtype (Type1, TrueType, Type0, Type3)
92    pub subtype: String,
93    /// Glyph widths indexed by character code (per-mille text space)
94    pub widths: HashMap<u32, f64>,
95    /// Default width for missing glyphs (per-mille text space)
96    pub default_width: f64,
97    /// ToUnicode mapping: character code → Unicode string
98    pub to_unicode: HashMap<u32, String>,
99    /// Encoding name
100    pub encoding: String,
101    /// Whether this is a standard 14 font
102    pub is_standard: bool,
103    /// Font descriptor flags
104    pub flags: u32,
105    /// Italic angle
106    pub italic_angle: f64,
107    /// Font weight (estimated)
108    pub weight: f64,
109    /// Bytes per character code (1 for Type1/TrueType, 2 for Type0/CID)
110    pub bytes_per_code: u8,
111    /// Font ascent in per-mille text-space units (1000 = 1 em)
112    pub ascent: f64,
113    /// Font descent in per-mille text-space units (negative)
114    pub descent: f64,
115    /// Font bounding box [llx, lly, urx, ury] in per-mille text-space units
116    pub font_bbox: [f64; 4],
117}
118
119impl PdfFont {
120    /// Create a default font with standard metrics.
121    pub fn default_font(name: &str) -> Self {
122        Self {
123            name: name.to_string(),
124            base_font: "Unknown".to_string(),
125            subtype: "Type1".to_string(),
126            widths: HashMap::new(),
127            default_width: 600.0, // Monospace default
128            to_unicode: HashMap::new(),
129            encoding: "WinAnsiEncoding".to_string(),
130            is_standard: false,
131            flags: 0,
132            italic_angle: 0.0,
133            weight: 400.0,
134            bytes_per_code: 1,
135            ascent: 800.0,
136            descent: -200.0,
137            font_bbox: [0.0, -200.0, 1000.0, 800.0],
138        }
139    }
140
141    /// Get glyph width for a character code (in glyph space, typically 1/1000 of text space).
142    pub fn glyph_width(&self, char_code: u32) -> f64 {
143        *self.widths.get(&char_code).unwrap_or(&self.default_width)
144    }
145
146    /// Get Unicode string for a character code.
147    ///
148    /// Fallback order (ISO 32000 text extraction practice):
149    /// 1. `/ToUnicode` CMap (and Differences / embedded cmap filled at resolve time)
150    /// 2. Declared single-byte encoding (WinAnsi / MacRoman)
151    /// 3. For Type0 / Identity-H: ASCII-range CID → Latin-1; else replacement `.`
152    pub fn decode_char(&self, char_code: u32) -> String {
153        if let Some(unicode) = self.to_unicode.get(&char_code) {
154            return unicode.clone();
155        }
156        if self.bytes_per_code == 2 {
157            // Identity-H without mapping: ASCII printable CIDs are common for
158            // simple embedded fonts; avoid WinAnsi on high CIDs (mojibake).
159            if (0x20..=0x7E).contains(&char_code) {
160                return char::from_u32(char_code).unwrap_or('.').to_string();
161            }
162            // Private-use / unmapped — leave a visible placeholder so layout
163            // still advances; callers may OCR glyphs when enabled.
164            return "\u{FFFD}".to_string();
165        }
166        match self.encoding.as_str() {
167            "MacRomanEncoding" => decode_macroman(char_code),
168            _ => decode_winansi(char_code),
169        }
170    }
171
172    /// Whether the font is bold (estimated from name or flags).
173    pub fn is_bold(&self) -> bool {
174        let name_lower = self.base_font.to_lowercase();
175        name_lower.contains("bold")
176            || name_lower.contains("black")
177            || name_lower.contains("heavy")
178            || (self.flags & 0x40000) != 0
179            || self.weight >= 700.0
180    }
181
182    /// Whether the font is italic (estimated from name or italic angle).
183    pub fn is_italic(&self) -> bool {
184        let name_lower = self.base_font.to_lowercase();
185        name_lower.contains("italic")
186            || name_lower.contains("oblique")
187            || (self.flags & 0x40) != 0
188            || self.italic_angle.abs() > 0.5
189    }
190}
191
192/// Font cache for resolved fonts per page.
193#[derive(Debug, Default, Clone)]
194pub struct FontCache {
195    fonts: HashMap<String, PdfFont>,
196}
197
198impl FontCache {
199    /// Get a font by resource name, or create a default placeholder.
200    pub fn get_or_default(&mut self, name: &str) -> &PdfFont {
201        if !self.fonts.contains_key(name) {
202            self.fonts
203                .insert(name.to_string(), PdfFont::default_font(name));
204        }
205        &self.fonts[name]
206    }
207
208    /// Insert a resolved font.
209    pub fn insert(&mut self, name: String, font: PdfFont) {
210        self.fonts.insert(name, font);
211    }
212
213    /// Get a font by name.
214    pub fn get(&self, name: &str) -> Option<&PdfFont> {
215        self.fonts.get(name)
216    }
217
218    /// Iterate over all fonts.
219    pub fn iter(&self) -> impl Iterator<Item = (&String, &PdfFont)> {
220        self.fonts.iter()
221    }
222}
223
224/// Resolve fonts from a page's /Resources /Font dictionary.
225pub fn resolve_page_fonts(doc: &lopdf::Document, page_id: lopdf::ObjectId) -> FontCache {
226    let mut cache = FontCache::default();
227
228    // Get page dictionary
229    let page_dict = match doc.get_object(page_id).and_then(|o| o.as_dict()) {
230        Ok(d) => d,
231        Err(_) => return cache,
232    };
233
234    // Get Resources dict
235    let resources = match page_dict.get(b"Resources") {
236        Ok(r) => resolve_object(doc, r),
237        Err(_) => return cache,
238    };
239
240    let resources_dict = match resources.as_dict() {
241        Ok(d) => d,
242        Err(_) => return cache,
243    };
244
245    // Get Font dict from resources
246    let font_dict = match resources_dict.get(b"Font") {
247        Ok(f) => resolve_object(doc, f),
248        Err(_) => return cache,
249    };
250
251    let font_dict = match font_dict.as_dict() {
252        Ok(d) => d,
253        Err(_) => return cache,
254    };
255
256    // Resolve each font
257    for (name_bytes, font_ref) in font_dict.iter() {
258        let name = String::from_utf8_lossy(name_bytes).to_string();
259        let font_obj = resolve_object(doc, font_ref);
260
261        if let Ok(fd) = font_obj.as_dict() {
262            let pdf_font = resolve_font_dict(doc, &name, fd);
263            cache.insert(name, pdf_font);
264        }
265    }
266
267    cache
268}
269
270/// Resolve a font dictionary into a PdfFont.
271pub(crate) fn resolve_font_dict(
272    doc: &lopdf::Document,
273    name: &str,
274    dict: &lopdf::Dictionary,
275) -> PdfFont {
276    let subtype = dict
277        .get(b"Subtype")
278        .ok()
279        .and_then(|o| {
280            if let lopdf::Object::Name(n) = o {
281                Some(String::from_utf8_lossy(n).to_string())
282            } else {
283                None
284            }
285        })
286        .unwrap_or_else(|| "Type1".to_string());
287
288    let base_font_raw = dict.get(b"BaseFont").ok().and_then(|o| {
289        if let lopdf::Object::Name(n) = o {
290            Some(String::from_utf8_lossy(n).to_string())
291        } else {
292            None
293        }
294    });
295
296    let encoding = dict
297        .get(b"Encoding")
298        .ok()
299        .and_then(|o| {
300            let resolved = resolve_object(doc, o);
301            match resolved {
302                lopdf::Object::Name(n) => Some(String::from_utf8_lossy(&n).to_string()),
303                lopdf::Object::Dictionary(ref d) => d.get(b"BaseEncoding").ok().and_then(|be| {
304                    if let lopdf::Object::Name(n) = be {
305                        Some(String::from_utf8_lossy(n).to_string())
306                    } else {
307                        None
308                    }
309                }),
310                _ => None,
311            }
312        })
313        .unwrap_or_else(|| "WinAnsiEncoding".to_string());
314
315    let is_type0 = subtype == "Type0";
316    let is_type3 = subtype == "Type3";
317    let bytes_per_code: u8 = if is_type0 { 2 } else { 1 };
318
319    let mut widths = resolve_widths(doc, dict);
320    let mut to_unicode = resolve_tounicode(doc, dict);
321
322    let mut flags = 0u32;
323    let mut italic_angle = 0.0f64;
324    let mut stem_v = 0.0f64;
325    let mut descriptor_font_weight: Option<f64> = None;
326    let mut descriptor_font_name: Option<String> = None;
327    let mut ascent = 800.0f64;
328    let mut descent = -200.0f64;
329    let mut font_bbox = [0.0f64, -200.0, 1000.0, 800.0];
330    let mut default_width = 1000.0f64;
331
332    if is_type0 {
333        if let Ok(desc_ref) = dict.get(b"DescendantFonts") {
334            let desc_obj = resolve_object(doc, desc_ref);
335            if let Ok(desc_arr) = desc_obj.as_array() {
336                if let Some(first) = desc_arr.first() {
337                    let desc_font_obj = resolve_object(doc, first);
338                    if let Ok(desc_dict) = desc_font_obj.as_dict() {
339                        if let Ok(dw) = desc_dict.get(b"DW") {
340                            if let Some(dw_val) = obj_to_f64(resolve_object(doc, dw)) {
341                                default_width = dw_val;
342                            }
343                        }
344                        resolve_cid_widths(doc, desc_dict, &mut widths);
345                        let desc = resolve_font_descriptor(doc, desc_dict);
346                        flags = desc.flags;
347                        italic_angle = desc.italic_angle;
348                        stem_v = desc.stem_v;
349                        descriptor_font_weight = desc.font_weight;
350                        descriptor_font_name = desc.font_name;
351                        ascent = desc.ascent;
352                        descent = desc.descent;
353                        font_bbox = desc.font_bbox;
354
355                        // Layered Unicode fallback when ToUnicode is missing/sparse:
356                        // Encoding/Differences on Type0 (rare) → embedded TTF cmap.
357                        resolve_encoding_differences(doc, dict, &mut to_unicode);
358                        resolve_cid_font_cmap_fallback(doc, desc_dict, &mut to_unicode);
359                    }
360                }
361            }
362        }
363    } else {
364        resolve_encoding_differences(doc, dict, &mut to_unicode);
365        if subtype == "Type1" {
366            resolve_type1_font_program_encoding(doc, dict, &mut to_unicode);
367        }
368        let desc = resolve_font_descriptor(doc, dict);
369        flags = desc.flags;
370        italic_angle = desc.italic_angle;
371        stem_v = desc.stem_v;
372        descriptor_font_weight = desc.font_weight;
373        descriptor_font_name = desc.font_name;
374        ascent = desc.ascent;
375        descent = desc.descent;
376        font_bbox = desc.font_bbox;
377
378        // Type 3: FontBBox often lives on the font dict itself (Skia).
379        if is_type3 {
380            if let Some(bbox) = read_number_array(doc, dict, b"FontBBox") {
381                if bbox.len() >= 4 {
382                    font_bbox = [bbox[0], bbox[1], bbox[2], bbox[3]];
383                }
384            }
385        }
386    }
387
388    let base_font = crate::pdf::font_style::resolve_base_font_name(
389        base_font_raw.as_deref(),
390        descriptor_font_name.as_deref(),
391    );
392
393    let is_standard = is_standard_font(&base_font);
394    if is_standard {
395        default_width = standard_font_default_width(&base_font);
396    }
397
398    // Type 3: normalize glyph-space widths / bbox through FontMatrix into the
399    // per-mille text-space invariant documented on PdfFont.
400    if is_type3 {
401        let matrix_vals = read_number_array(doc, dict, b"FontMatrix").unwrap_or_default();
402        let matrix = crate::pdf::font_type3::font_matrix_from_array(&matrix_vals);
403        let scale = matrix.em_scale();
404        crate::pdf::font_type3::normalize_widths(&mut widths, scale);
405        default_width *= scale;
406        if desc_missing_ascent_descent(doc, dict) {
407            let (a, d) = crate::pdf::font_type3::vertical_extent(font_bbox, matrix);
408            ascent = a;
409            descent = d;
410        }
411        font_bbox = crate::pdf::font_type3::normalize_font_bbox(font_bbox, matrix);
412    }
413
414    let weight =
415        crate::pdf::font_style::resolve_font_weight(&base_font, descriptor_font_weight, stem_v);
416
417    PdfFont {
418        name: name.to_string(),
419        base_font,
420        subtype,
421        widths,
422        default_width,
423        to_unicode,
424        encoding,
425        is_standard,
426        flags,
427        italic_angle,
428        weight,
429        bytes_per_code,
430        ascent,
431        descent,
432        font_bbox,
433    }
434}
435
436/// True when the font descriptor lacks explicit /Ascent and /Descent.
437fn desc_missing_ascent_descent(doc: &lopdf::Document, dict: &lopdf::Dictionary) -> bool {
438    let Ok(fd_ref) = dict.get(b"FontDescriptor") else {
439        return true;
440    };
441    let fd_obj = resolve_object(doc, fd_ref);
442    let Ok(fd) = fd_obj.as_dict() else {
443        return true;
444    };
445    fd.get(b"Ascent").is_err() && fd.get(b"Descent").is_err()
446}
447
448/// Read a numeric array from a dictionary key (resolving references).
449fn read_number_array(
450    doc: &lopdf::Document,
451    dict: &lopdf::Dictionary,
452    key: &[u8],
453) -> Option<Vec<f64>> {
454    let obj = resolve_object(doc, dict.get(key).ok()?);
455    let arr = obj.as_array().ok()?;
456    let vals: Vec<f64> = arr
457        .iter()
458        .filter_map(|o| obj_to_f64(resolve_object(doc, o)))
459        .collect();
460    if vals.is_empty() {
461        None
462    } else {
463        Some(vals)
464    }
465}
466
467/// Resolve font widths from /Widths array.
468fn resolve_widths(doc: &lopdf::Document, dict: &lopdf::Dictionary) -> HashMap<u32, f64> {
469    let mut widths = HashMap::new();
470
471    let first_char = dict
472        .get(b"FirstChar")
473        .ok()
474        .and_then(|o| obj_to_i64(resolve_object(doc, o)))
475        .unwrap_or(0) as u32;
476
477    if let Ok(widths_ref) = dict.get(b"Widths") {
478        let widths_obj = resolve_object(doc, widths_ref);
479        if let Ok(arr) = widths_obj.as_array() {
480            for (i, w) in arr.iter().enumerate() {
481                if let Some(width) = obj_to_f64(resolve_object(doc, w)) {
482                    widths.insert(first_char + i as u32, width);
483                }
484            }
485        }
486    }
487
488    widths
489}
490
491/// Resolve CID font widths from /W array.
492///
493/// The /W array format: [cid [w1 w2 ...] cid_start cid_end w ...]
494/// Two forms:
495/// - `cid [w1, w2, w3, ...]` — consecutive CIDs starting at cid
496/// - `cid_start cid_end w` — range of CIDs all with same width
497fn resolve_cid_widths(
498    doc: &lopdf::Document,
499    dict: &lopdf::Dictionary,
500    widths: &mut HashMap<u32, f64>,
501) {
502    let w_obj = match dict.get(b"W") {
503        Ok(o) => resolve_object(doc, o),
504        Err(_) => return,
505    };
506    let w_arr = match w_obj.as_array() {
507        Ok(a) => a,
508        Err(_) => return,
509    };
510
511    let mut i = 0;
512    while i < w_arr.len() {
513        let first = resolve_object(doc, &w_arr[i]);
514        if let Some(cid_start) = obj_to_i64(first) {
515            let cid_start = cid_start as u32;
516            i += 1;
517            if i >= w_arr.len() {
518                break;
519            }
520            let next = resolve_object(doc, &w_arr[i]);
521            if let Ok(arr) = next.as_array() {
522                // Form 1: cid [w1 w2 w3 ...]
523                for (j, w) in arr.iter().enumerate() {
524                    if let Some(width) = obj_to_f64(resolve_object(doc, w)) {
525                        widths.insert(cid_start + j as u32, width);
526                    }
527                }
528                i += 1;
529            } else if let Some(cid_end) = obj_to_i64(next) {
530                // Form 2: cid_start cid_end w
531                let cid_end = cid_end as u32;
532                i += 1;
533                if i >= w_arr.len() {
534                    break;
535                }
536                let w_val = resolve_object(doc, &w_arr[i]);
537                if let Some(width) = obj_to_f64(w_val) {
538                    for cid in cid_start..=cid_end {
539                        widths.insert(cid, width);
540                    }
541                }
542                i += 1;
543            } else {
544                i += 1;
545            }
546        } else {
547            i += 1;
548        }
549    }
550}
551
552/// Resolve ToUnicode CMap.
553fn resolve_tounicode(doc: &lopdf::Document, dict: &lopdf::Dictionary) -> HashMap<u32, String> {
554    let mut mapping = HashMap::new();
555
556    if let Ok(tounicode_ref) = dict.get(b"ToUnicode") {
557        let tounicode_ref = match tounicode_ref {
558            lopdf::Object::Reference(r) => *r,
559            _ => return mapping,
560        };
561
562        if let Ok(stream) = doc.get_object(tounicode_ref) {
563            if let Ok(stream) = stream.as_stream() {
564                if let Ok(data) = stream.decompressed_content() {
565                    parse_cmap(&data, &mut mapping);
566                }
567            }
568        }
569    }
570
571    mapping
572}
573
574/// Parse /Encoding dictionary's /Differences array and add mappings to to_unicode.
575///
576/// The /Differences array format is: [code, name, name, ..., code, name, ...]
577/// Numbers reset the current character code; names are applied sequentially.
578fn resolve_encoding_differences(
579    doc: &lopdf::Document,
580    dict: &lopdf::Dictionary,
581    to_unicode: &mut HashMap<u32, String>,
582) {
583    let enc_obj = match dict.get(b"Encoding") {
584        Ok(o) => resolve_object(doc, o),
585        Err(_) => return,
586    };
587
588    let enc_dict = match enc_obj.as_dict() {
589        Ok(d) => d,
590        Err(_) => return, // Not a dictionary (just a name like "WinAnsiEncoding")
591    };
592
593    let diffs_obj = match enc_dict.get(b"Differences") {
594        Ok(o) => resolve_object(doc, o),
595        Err(_) => return,
596    };
597
598    let diffs = match diffs_obj.as_array() {
599        Ok(a) => a,
600        Err(_) => return,
601    };
602
603    let mut current_code: u32 = 0;
604    for item in diffs {
605        let resolved = resolve_object(doc, item);
606        match resolved {
607            lopdf::Object::Integer(i) => {
608                current_code = i as u32;
609            }
610            lopdf::Object::Name(ref name_bytes) => {
611                let glyph_name = String::from_utf8_lossy(name_bytes).to_string();
612                // Only add if not already mapped by ToUnicode (ToUnicode takes priority)
613                if let std::collections::hash_map::Entry::Vacant(e) = to_unicode.entry(current_code)
614                {
615                    if let Some(unicode) = glyph_name_to_unicode(&glyph_name) {
616                        e.insert(unicode);
617                    }
618                }
619                current_code += 1;
620            }
621            _ => {}
622        }
623    }
624}
625
626/// Extract encoding from an embedded Type1 font program (/FontFile stream).
627///
628/// Type1 font programs contain an Encoding array defined in PostScript.
629/// The pattern is: `dup <code> /<glyphname> put`
630/// This function parses those lines and maps glyph names to Unicode.
631fn resolve_type1_font_program_encoding(
632    doc: &lopdf::Document,
633    dict: &lopdf::Dictionary,
634    to_unicode: &mut HashMap<u32, String>,
635) {
636    // Get FontDescriptor
637    let fd_obj = match dict.get(b"FontDescriptor") {
638        Ok(o) => resolve_object(doc, o),
639        Err(_) => return,
640    };
641    let fd = match fd_obj.as_dict() {
642        Ok(d) => d,
643        Err(_) => return,
644    };
645
646    // Try /FontFile (Type1), /FontFile2 (TrueType), /FontFile3 (CFF/OpenType)
647    let stream_data = None
648        .or_else(|| get_font_file_data(doc, fd, b"FontFile"))
649        .or_else(|| get_font_file_data(doc, fd, b"FontFile3"));
650
651    let data = match stream_data {
652        Some(d) => d,
653        None => return,
654    };
655
656    // Parse the Type1 font program's Encoding vector
657    // Look for patterns: dup <number> /<name> put
658    let text = String::from_utf8_lossy(&data);
659
660    // Check if this font uses StandardEncoding (no custom encoding)
661    if text.contains("/Encoding StandardEncoding def") {
662        return; // Standard encoding, handled by WinAnsi fallback
663    }
664
665    for line in text.lines() {
666        let trimmed = line.trim();
667        // Pattern: "dup 121 /dagger put" or "dup 3 /asteriskmath put"
668        if !trimmed.starts_with("dup ") || !trimmed.ends_with(" put") {
669            continue;
670        }
671        let inner = &trimmed[4..trimmed.len() - 4].trim();
672        // Split: "121 /dagger"
673        let parts: Vec<&str> = inner.splitn(2, ' ').collect();
674        if parts.len() != 2 {
675            continue;
676        }
677        let code: u32 = match parts[0].trim().parse() {
678            Ok(c) => c,
679            Err(_) => continue,
680        };
681        let glyph = parts[1].trim().trim_start_matches('/');
682        if glyph.is_empty() || glyph == ".notdef" {
683            continue;
684        }
685        // Only add if not already mapped by ToUnicode or Encoding/Differences
686        if let std::collections::hash_map::Entry::Vacant(e) = to_unicode.entry(code) {
687            if let Some(unicode) = glyph_name_to_unicode(glyph) {
688                e.insert(unicode);
689            }
690        }
691    }
692}
693
694/// Get decompressed data from a font file stream.
695fn get_font_file_data(
696    doc: &lopdf::Document,
697    fd: &lopdf::Dictionary,
698    key: &[u8],
699) -> Option<Vec<u8>> {
700    let ff_ref = fd.get(key).ok()?;
701    let ff_id = match ff_ref {
702        lopdf::Object::Reference(r) => *r,
703        _ => return None,
704    };
705    let ff_obj = doc.get_object(ff_id).ok()?;
706    let stream = ff_obj.as_stream().ok()?;
707    stream.decompressed_content().ok()
708}
709
710/// Identity-H / CIDFont Unicode fallback from an embedded TrueType/OpenType cmap.
711///
712/// When `/ToUnicode` is absent, many Type0 fonts still embed a Unicode cmap in
713/// `/FontFile2`. For Identity `CIDToGIDMap`, CID == glyph id.
714fn resolve_cid_font_cmap_fallback(
715    doc: &lopdf::Document,
716    cid_font_dict: &lopdf::Dictionary,
717    to_unicode: &mut HashMap<u32, String>,
718) {
719    // Prefer existing ToUnicode coverage — only fill vacant codes.
720    let fd_obj = match cid_font_dict.get(b"FontDescriptor") {
721        Ok(o) => resolve_object(doc, o),
722        Err(_) => return,
723    };
724    let fd = match fd_obj.as_dict() {
725        Ok(d) => d,
726        Err(_) => return,
727    };
728
729    let data = None
730        .or_else(|| get_font_file_data(doc, fd, b"FontFile2"))
731        .or_else(|| get_font_file_data(doc, fd, b"FontFile3"));
732    let Some(font_bytes) = data else {
733        return;
734    };
735
736    // CIDToGIDMap: Identity (default for Identity-H) or a stream of 2-byte GIDs.
737    let cid_to_gid = resolve_cid_to_gid_map(doc, cid_font_dict);
738
739    let Ok(face) = ttf_parser::Face::parse(&font_bytes, 0) else {
740        return;
741    };
742
743    // Build glyph_id → Unicode from the best Unicode cmap.
744    let mut gid_to_unicode: HashMap<u16, String> = HashMap::new();
745    if let Some(subtable) = face
746        .tables()
747        .cmap
748        .and_then(|c| c.subtables.into_iter().find(|s| s.is_unicode()))
749    {
750        subtable.codepoints(|cp| {
751            if let Some(gid) = subtable.glyph_index(cp) {
752                if let Some(ch) = char::from_u32(cp) {
753                    gid_to_unicode
754                        .entry(gid.0)
755                        .or_insert_with(|| ch.to_string());
756                }
757            }
758        });
759    }
760
761    if gid_to_unicode.is_empty() {
762        return;
763    }
764
765    let max_cid = cid_to_gid
766        .as_ref()
767        .map(|m| m.len() as u32)
768        .unwrap_or_else(|| face.number_of_glyphs() as u32);
769
770    for cid in 0..max_cid {
771        if to_unicode.contains_key(&cid) {
772            continue;
773        }
774        let gid = match &cid_to_gid {
775            Some(map) => map.get(cid as usize).copied().unwrap_or(cid as u16),
776            None => cid as u16,
777        };
778        if let Some(uni) = gid_to_unicode.get(&gid) {
779            to_unicode.insert(cid, uni.clone());
780        }
781    }
782}
783
784/// Parse `/CIDToGIDMap`. `None` means Identity (CID == GID).
785fn resolve_cid_to_gid_map(doc: &lopdf::Document, cid_font_dict: &lopdf::Dictionary) -> Option<Vec<u16>> {
786    let obj = cid_font_dict.get(b"CIDToGIDMap").ok()?;
787    let resolved = resolve_object(doc, obj);
788    match resolved {
789        lopdf::Object::Name(n) if n.as_slice() == b"Identity" => None,
790        lopdf::Object::Stream(stream) => {
791            let data = stream.decompressed_content().ok()?;
792            if data.len() < 2 {
793                return None;
794            }
795            let mut map = Vec::with_capacity(data.len() / 2);
796            for chunk in data.chunks_exact(2) {
797                map.push(u16::from_be_bytes([chunk[0], chunk[1]]));
798            }
799            Some(map)
800        }
801        _ => None,
802    }
803}
804
805/// Map a PostScript/PDF glyph name to its Unicode string representation.
806///
807/// Uses the full Adobe Glyph List (4281 entries) with fallbacks for
808/// uniXXXX, uXXXXXX, and single-character names.
809/// Ligatures are decomposed to plain text (e.g., "fi" → "fi" not U+FB01).
810fn glyph_name_to_unicode(name: &str) -> Option<String> {
811    // Override: decompose ligatures to plain text for readability
812    match name {
813        "fi" => return Some("fi".to_string()),
814        "fl" => return Some("fl".to_string()),
815        "ff" => return Some("ff".to_string()),
816        "ffi" => return Some("ffi".to_string()),
817        "ffl" => return Some("ffl".to_string()),
818        "IJ" => return Some("IJ".to_string()),
819        "ij" => return Some("ij".to_string()),
820        _ => {}
821    }
822
823    // 1. Try direct lookup (AGL, TeX, uniXXXX, uXXXXXX, single-char)
824    if let Some(s) = resolve_glyph_component(name) {
825        return Some(s);
826    }
827
828    // 2. AGL Specification: strip variant suffix after first period
829    //    e.g. "c.sc" → "c", "seven.oldstyle" → "seven", "A.swash" → "A"
830    let base = if let Some(dot_pos) = name.find('.') {
831        &name[..dot_pos]
832    } else {
833        name
834    };
835
836    if base != name {
837        if let Some(s) = resolve_glyph_component(base) {
838            return Some(s);
839        }
840    }
841
842    // 3. AGL Specification: underscore ligature decomposition
843    //    e.g. "f_l" → "fl", "T_h" → "Th", "f_f_i" → "ffi"
844    if base.contains('_') {
845        let mut result = String::new();
846        for component in base.split('_') {
847            if let Some(s) = resolve_glyph_component(component) {
848                result.push_str(&s);
849            } else {
850                return None; // If any component fails, give up
851            }
852        }
853        if !result.is_empty() {
854            return Some(result);
855        }
856    }
857
858    None
859}
860
861/// Resolve a single glyph name component to Unicode (no period/underscore decomposition).
862fn resolve_glyph_component(name: &str) -> Option<String> {
863    if name.is_empty() {
864        return None;
865    }
866
867    // Fast path: look up in the full AGL
868    if let Some(s) = AGL_MAP.get(name) {
869        return Some(s.clone());
870    }
871
872    // TeX-specific glyph names not in AGL (CMSY, CMMI, CMEX, etc.)
873    if let Some(s) = TEX_GLYPH_MAP.get(name) {
874        return Some((*s).to_string());
875    }
876
877    // Single-character glyph names (letter names match directly)
878    if name.len() == 1 {
879        return Some(name.to_string());
880    }
881
882    // "uniXXXX" format (Adobe convention for BMP codepoints)
883    if let Some(hex) = name.strip_prefix("uni") {
884        if hex.len() == 4 {
885            if let Ok(cp) = u32::from_str_radix(hex, 16) {
886                if let Some(c) = char::from_u32(cp) {
887                    return Some(c.to_string());
888                }
889            }
890        }
891        // uniXXXXYYYY... — sequence of BMP codepoints in groups of 4
892        if hex.len() > 4 && hex.len() % 4 == 0 {
893            let mut s = String::new();
894            for chunk in hex.as_bytes().chunks(4) {
895                if let Ok(h) = std::str::from_utf8(chunk) {
896                    if let Ok(cp) = u32::from_str_radix(h, 16) {
897                        if let Some(c) = char::from_u32(cp) {
898                            s.push(c);
899                        }
900                    }
901                }
902            }
903            if !s.is_empty() {
904                return Some(s);
905            }
906        }
907    }
908
909    // "uXXXXXX" format (Adobe convention for supplementary codepoints)
910    if let Some(hex) = name.strip_prefix('u') {
911        if (4..=6).contains(&hex.len()) && hex.chars().all(|c| c.is_ascii_hexdigit()) {
912            if let Ok(cp) = u32::from_str_radix(hex, 16) {
913                if let Some(c) = char::from_u32(cp) {
914                    return Some(c.to_string());
915                }
916            }
917        }
918    }
919
920    None
921}
922
923/// Parse a basic CMap for bfchar/bfrange mappings.
924fn parse_cmap(data: &[u8], mapping: &mut HashMap<u32, String>) {
925    let text = String::from_utf8_lossy(data);
926
927    // Parse beginbfchar ... endbfchar blocks
928    let mut in_bfchar = false;
929    for line in text.lines() {
930        let trimmed = line.trim();
931        if trimmed.contains("beginbfchar") {
932            in_bfchar = true;
933            continue;
934        }
935        if trimmed.contains("endbfchar") {
936            in_bfchar = false;
937            continue;
938        }
939        if in_bfchar {
940            // Format: <XXXX> <YYYY>
941            let parts: Vec<&str> = trimmed.split('>').collect();
942            if parts.len() >= 2 {
943                if let (Some(src), Some(dst)) =
944                    (parse_hex_value(parts[0]), parse_hex_unicode(parts[1]))
945                {
946                    mapping.insert(src, dst);
947                }
948            }
949        }
950    }
951
952    // Parse beginbfrange ... endbfrange blocks
953    // Supports two formats:
954    //   <start> <end> <dst>       — single hex value, incremented across range
955    //   <start> <end> [<v1> <v2> ...] — array of Unicode values, one per code
956    let mut in_bfrange = false;
957    for line in text.lines() {
958        let trimmed = line.trim();
959        if trimmed.contains("beginbfrange") {
960            in_bfrange = true;
961            continue;
962        }
963        if trimmed.contains("endbfrange") {
964            in_bfrange = false;
965            continue;
966        }
967        if in_bfrange {
968            // Check if this line has an array format: <start> <end> [<v1> <v2> ...]
969            if let Some(bracket_start) = trimmed.find('[') {
970                // Array format
971                let before_bracket = &trimmed[..bracket_start];
972                let parts: Vec<&str> = before_bracket.split('>').collect();
973                if parts.len() >= 2 {
974                    if let (Some(start), Some(end)) =
975                        (parse_hex_value(parts[0]), parse_hex_value(parts[1]))
976                    {
977                        // Extract values inside brackets [ ... ]
978                        let bracket_end = trimmed.rfind(']').unwrap_or(trimmed.len());
979                        let inside = &trimmed[bracket_start + 1..bracket_end];
980                        let values: Vec<String> = inside
981                            .split('>')
982                            .filter_map(|s| {
983                                let s = s.trim().trim_start_matches('<');
984                                if s.is_empty() {
985                                    None
986                                } else {
987                                    parse_hex_unicode_str(s)
988                                }
989                            })
990                            .collect();
991                        for (i, val) in values.iter().enumerate() {
992                            let code = start + i as u32;
993                            if code > end {
994                                break;
995                            }
996                            mapping.insert(code, val.clone());
997                        }
998                    }
999                }
1000            } else {
1001                // Standard format: <XXXX> <YYYY> <ZZZZ>
1002                let parts: Vec<&str> = trimmed.split('>').collect();
1003                if parts.len() >= 3 {
1004                    if let (Some(start), Some(end), Some(dst_start)) = (
1005                        parse_hex_value(parts[0]),
1006                        parse_hex_value(parts[1]),
1007                        parse_hex_value(parts[2]),
1008                    ) {
1009                        for code in start..=end {
1010                            let unicode_point = dst_start + (code - start);
1011                            if let Some(c) = char::from_u32(unicode_point) {
1012                                mapping.insert(code, c.to_string());
1013                            }
1014                        }
1015                    }
1016                }
1017            }
1018        }
1019    }
1020}
1021
1022/// Parse a hex value from a CMap entry like "<0041".
1023fn parse_hex_value(s: &str) -> Option<u32> {
1024    let cleaned = s.trim().trim_start_matches('<').trim();
1025    if cleaned.is_empty() {
1026        return None;
1027    }
1028    u32::from_str_radix(cleaned, 16).ok()
1029}
1030
1031/// Parse a hex Unicode value from a CMap entry like " <0041".
1032fn parse_hex_unicode(s: &str) -> Option<String> {
1033    let cleaned = s
1034        .trim()
1035        .trim_start_matches('<')
1036        .trim_end_matches('>')
1037        .trim();
1038    parse_hex_unicode_str(cleaned)
1039}
1040
1041/// Parse a hex Unicode string from pre-cleaned hex digits like "0041" or "D800DC00".
1042fn parse_hex_unicode_str(cleaned: &str) -> Option<String> {
1043    if cleaned.is_empty() {
1044        return None;
1045    }
1046
1047    // May be multi-byte Unicode
1048    let mut result = String::new();
1049    let bytes: Vec<&str> = cleaned
1050        .as_bytes()
1051        .chunks(4)
1052        .map(|c| std::str::from_utf8(c).unwrap_or(""))
1053        .collect();
1054
1055    for hex_str in bytes {
1056        if let Ok(code_point) = u32::from_str_radix(hex_str, 16) {
1057            if let Some(c) = char::from_u32(code_point) {
1058                result.push(c);
1059            }
1060        }
1061    }
1062
1063    if result.is_empty() {
1064        None
1065    } else {
1066        Some(result)
1067    }
1068}
1069
1070/// Fields extracted from a `/FontDescriptor` dictionary.
1071struct FontDescriptorFields {
1072    flags: u32,
1073    italic_angle: f64,
1074    stem_v: f64,
1075    font_weight: Option<f64>,
1076    font_name: Option<String>,
1077    ascent: f64,
1078    descent: f64,
1079    font_bbox: [f64; 4],
1080}
1081
1082/// Resolve font descriptor fields.
1083fn resolve_font_descriptor(
1084    doc: &lopdf::Document,
1085    dict: &lopdf::Dictionary,
1086) -> FontDescriptorFields {
1087    let mut fields = FontDescriptorFields {
1088        flags: 0,
1089        italic_angle: 0.0,
1090        stem_v: 0.0,
1091        font_weight: None,
1092        font_name: None,
1093        ascent: 800.0,
1094        descent: -200.0,
1095        font_bbox: [0.0, -200.0, 1000.0, 800.0],
1096    };
1097
1098    if let Ok(fd_ref) = dict.get(b"FontDescriptor") {
1099        let fd_obj = resolve_object(doc, fd_ref);
1100        if let Ok(fd) = fd_obj.as_dict() {
1101            fields.flags = fd
1102                .get(b"Flags")
1103                .ok()
1104                .and_then(|o| obj_to_i64(resolve_object(doc, o)))
1105                .unwrap_or(0) as u32;
1106
1107            fields.italic_angle = fd
1108                .get(b"ItalicAngle")
1109                .ok()
1110                .and_then(|o| obj_to_f64(resolve_object(doc, o)))
1111                .unwrap_or(0.0);
1112
1113            fields.stem_v = fd
1114                .get(b"StemV")
1115                .ok()
1116                .and_then(|o| obj_to_f64(resolve_object(doc, o)))
1117                .unwrap_or(0.0);
1118
1119            fields.font_weight = fd
1120                .get(b"FontWeight")
1121                .ok()
1122                .and_then(|o| obj_to_f64(resolve_object(doc, o)));
1123
1124            fields.font_name = fd.get(b"FontName").ok().and_then(|o| {
1125                let resolved = resolve_object(doc, o);
1126                if let lopdf::Object::Name(n) = resolved {
1127                    Some(String::from_utf8_lossy(&n).to_string())
1128                } else {
1129                    None
1130                }
1131            });
1132
1133            if let Some(vals) = read_number_array(doc, fd, b"FontBBox") {
1134                if vals.len() >= 4 {
1135                    fields.font_bbox = [vals[0], vals[1], vals[2], vals[3]];
1136                }
1137            }
1138
1139            if let Ok(a_ref) = fd.get(b"Ascent") {
1140                if let Some(a) = obj_to_f64(resolve_object(doc, a_ref)) {
1141                    fields.ascent = a;
1142                }
1143            } else {
1144                fields.ascent = fields.font_bbox[3];
1145            }
1146
1147            if let Ok(d_ref) = fd.get(b"Descent") {
1148                if let Some(d) = obj_to_f64(resolve_object(doc, d_ref)) {
1149                    fields.descent = d;
1150                }
1151            } else {
1152                fields.descent = fields.font_bbox[1];
1153            }
1154        }
1155    }
1156
1157    fields
1158}
1159
1160/// Resolve a PDF object reference.
1161fn resolve_object<'a>(doc: &'a lopdf::Document, obj: &'a lopdf::Object) -> lopdf::Object {
1162    match obj {
1163        lopdf::Object::Reference(id) => doc.get_object(*id).cloned().unwrap_or(lopdf::Object::Null),
1164        other => other.clone(),
1165    }
1166}
1167
1168/// Convert a PDF object to f64.
1169fn obj_to_f64(obj: lopdf::Object) -> Option<f64> {
1170    match obj {
1171        lopdf::Object::Integer(i) => Some(i as f64),
1172        lopdf::Object::Real(f) => Some(f),
1173        _ => None,
1174    }
1175}
1176
1177/// Convert a PDF object to i64.
1178fn obj_to_i64(obj: lopdf::Object) -> Option<i64> {
1179    match obj {
1180        lopdf::Object::Integer(i) => Some(i),
1181        lopdf::Object::Real(f) => Some(f as i64),
1182        _ => None,
1183    }
1184}
1185
1186/// Check if a font name is one of the standard 14 fonts.
1187fn is_standard_font(name: &str) -> bool {
1188    matches!(
1189        name,
1190        "Courier"
1191            | "Courier-Bold"
1192            | "Courier-Oblique"
1193            | "Courier-BoldOblique"
1194            | "Helvetica"
1195            | "Helvetica-Bold"
1196            | "Helvetica-Oblique"
1197            | "Helvetica-BoldOblique"
1198            | "Times-Roman"
1199            | "Times-Bold"
1200            | "Times-Italic"
1201            | "Times-BoldItalic"
1202            | "Symbol"
1203            | "ZapfDingbats"
1204    )
1205}
1206
1207/// Get default glyph width for standard fonts.
1208fn standard_font_default_width(name: &str) -> f64 {
1209    if name.starts_with("Courier") {
1210        600.0
1211    } else {
1212        // Variable-width fonts — use a reasonable default
1213        500.0
1214    }
1215}
1216
1217/// Decode a character code using MacRomanEncoding (Mac OS Roman).
1218///
1219/// Maps byte values 0x80-0xFF to the Mac OS Roman Unicode equivalents.
1220/// Used when a font's /Encoding is explicitly set to MacRomanEncoding.
1221fn decode_macroman(code: u32) -> String {
1222    if code < 128 {
1223        if let Some(c) = char::from_u32(code) {
1224            return c.to_string();
1225        }
1226    }
1227    let mapped = match code {
1228        0x80 => '\u{00C4}', // Ä
1229        0x81 => '\u{00C5}', // Å
1230        0x82 => '\u{00C7}', // Ç
1231        0x83 => '\u{00C9}', // É
1232        0x84 => '\u{00D1}', // Ñ
1233        0x85 => '\u{00D6}', // Ö
1234        0x86 => '\u{00DC}', // Ü
1235        0x87 => '\u{00E1}', // á
1236        0x88 => '\u{00E0}', // à
1237        0x89 => '\u{00E2}', // â
1238        0x8A => '\u{00E4}', // ä
1239        0x8B => '\u{00E3}', // ã
1240        0x8C => '\u{00E5}', // å
1241        0x8D => '\u{00E7}', // ç
1242        0x8E => '\u{00E9}', // é
1243        0x8F => '\u{00E8}', // è
1244        0x90 => '\u{00EA}', // ê
1245        0x91 => '\u{00EB}', // ë
1246        0x92 => '\u{00ED}', // í
1247        0x93 => '\u{00EC}', // ì
1248        0x94 => '\u{00EE}', // î
1249        0x95 => '\u{00EF}', // ï
1250        0x96 => '\u{00F1}', // ñ
1251        0x97 => '\u{00F3}', // ó
1252        0x98 => '\u{00F2}', // ò
1253        0x99 => '\u{00F4}', // ô
1254        0x9A => '\u{00F6}', // ö
1255        0x9B => '\u{00F5}', // õ
1256        0x9C => '\u{00FA}', // ú
1257        0x9D => '\u{00F9}', // ù
1258        0x9E => '\u{00FB}', // û
1259        0x9F => '\u{00FC}', // ü
1260        0xA0 => '\u{2020}', // †
1261        0xA1 => '\u{00B0}', // °
1262        0xA2 => '\u{00A2}', // ¢
1263        0xA3 => '\u{00A3}', // £
1264        0xA4 => '\u{00A7}', // §
1265        0xA5 => '\u{2022}', // •
1266        0xA6 => '\u{00B6}', // ¶
1267        0xA7 => '\u{00DF}', // ß
1268        0xA8 => '\u{00AE}', // ®
1269        0xA9 => '\u{00A9}', // ©
1270        0xAA => '\u{2122}', // ™
1271        0xAB => '\u{00B4}', // ´
1272        0xAC => '\u{00A8}', // ¨
1273        0xAD => '\u{2260}', // ≠
1274        0xAE => '\u{00C6}', // Æ
1275        0xAF => '\u{00D8}', // Ø
1276        0xB0 => '\u{221E}', // ∞
1277        0xB1 => '\u{00B1}', // ±
1278        0xB2 => '\u{2264}', // ≤
1279        0xB3 => '\u{2265}', // ≥
1280        0xB4 => '\u{00A5}', // ¥
1281        0xB5 => '\u{00B5}', // µ
1282        0xB6 => '\u{2202}', // ∂
1283        0xB7 => '\u{2211}', // ∑
1284        0xB8 => '\u{220F}', // ∏
1285        0xB9 => '\u{03C0}', // π
1286        0xBA => '\u{222B}', // ∫
1287        0xBB => '\u{00AA}', // ª
1288        0xBC => '\u{00BA}', // º
1289        0xBD => '\u{03A9}', // Ω
1290        0xBE => '\u{00E6}', // æ
1291        0xBF => '\u{00F8}', // ø
1292        0xC0 => '\u{00BF}', // ¿
1293        0xC1 => '\u{00A1}', // ¡
1294        0xC2 => '\u{00AC}', // ¬
1295        0xC3 => '\u{221A}', // √
1296        0xC4 => '\u{0192}', // ƒ
1297        0xC5 => '\u{2248}', // ≈
1298        0xC6 => '\u{2206}', // ∆
1299        0xC7 => '\u{00AB}', // «
1300        0xC8 => '\u{00BB}', // »
1301        0xC9 => '\u{2026}', // …
1302        0xCA => '\u{00A0}', // non-breaking space
1303        0xCB => '\u{00C0}', // À
1304        0xCC => '\u{00C3}', // Ã
1305        0xCD => '\u{00D5}', // Õ
1306        0xCE => '\u{0152}', // Œ
1307        0xCF => '\u{0153}', // œ
1308        0xD0 => '\u{2013}', // –
1309        0xD1 => '\u{2014}', // —
1310        0xD2 => '\u{201C}', // "
1311        0xD3 => '\u{201D}', // "
1312        0xD4 => '\u{2018}', // '
1313        0xD5 => '\u{2019}', // '
1314        0xD6 => '\u{00F7}', // ÷
1315        0xD7 => '\u{25CA}', // ◊
1316        0xD8 => '\u{00FF}', // ÿ
1317        0xD9 => '\u{0178}', // Ÿ
1318        0xDA => '\u{2044}', // ⁄
1319        0xDB => '\u{20AC}', // €
1320        0xDC => '\u{2039}', // ‹
1321        0xDD => '\u{203A}', // ›
1322        0xDE => '\u{FB01}', // fi ligature
1323        0xDF => '\u{FB02}', // fl ligature
1324        0xE0 => '\u{2021}', // ‡
1325        0xE1 => '\u{00B7}', // ·
1326        0xE2 => '\u{201A}', // ‚
1327        0xE3 => '\u{201E}', // „
1328        0xE4 => '\u{2030}', // ‰
1329        0xE5 => '\u{00C2}', // Â
1330        0xE6 => '\u{00CA}', // Ê
1331        0xE7 => '\u{00C1}', // Á
1332        0xE8 => '\u{00CB}', // Ë
1333        0xE9 => '\u{00C8}', // È
1334        0xEA => '\u{00CD}', // Í
1335        0xEB => '\u{00CE}', // Î
1336        0xEC => '\u{00CF}', // Ï
1337        0xED => '\u{00CC}', // Ì
1338        0xEE => '\u{00D3}', // Ó
1339        0xEF => '\u{00D4}', // Ô
1340        0xF0 => '\u{F8FF}', // Apple logo
1341        0xF1 => '\u{00D2}', // Ò
1342        0xF2 => '\u{00DA}', // Ú
1343        0xF3 => '\u{00DB}', // Û
1344        0xF4 => '\u{00D9}', // Ù
1345        0xF5 => '\u{0131}', // ı
1346        0xF6 => '\u{02C6}', // ˆ
1347        0xF7 => '\u{02DC}', // ˜
1348        0xF8 => '\u{00AF}', // ¯
1349        0xF9 => '\u{02D8}', // ˘
1350        0xFA => '\u{02D9}', // ˙
1351        0xFB => '\u{02DA}', // ˚
1352        0xFC => '\u{00B8}', // ¸
1353        0xFD => '\u{02DD}', // ˝
1354        0xFE => '\u{02DB}', // ˛
1355        0xFF => '\u{02C7}', // ˇ
1356        _ => {
1357            return char::from_u32(code)
1358                .map(|c| c.to_string())
1359                .unwrap_or_default();
1360        }
1361    };
1362    mapped.to_string()
1363}
1364
1365/// Decode a character code using WinAnsiEncoding (Windows-1252 superset).
1366fn decode_winansi(code: u32) -> String {
1367    if code < 128 {
1368        // ASCII range
1369        if let Some(c) = char::from_u32(code) {
1370            return c.to_string();
1371        }
1372    }
1373    // Windows-1252 special range 0x80-0x9F
1374    let mapped = match code {
1375        0x80 => '\u{20AC}', // Euro sign
1376        0x82 => '\u{201A}', // Single low-9 quotation mark
1377        0x83 => '\u{0192}', // Latin small letter f with hook
1378        0x84 => '\u{201E}', // Double low-9 quotation mark
1379        0x85 => '\u{2026}', // Horizontal ellipsis
1380        0x86 => '\u{2020}', // Dagger
1381        0x87 => '\u{2021}', // Double dagger
1382        0x88 => '\u{02C6}', // Modifier letter circumflex accent
1383        0x89 => '\u{2030}', // Per mille sign
1384        0x8A => '\u{0160}', // Latin capital letter S with caron
1385        0x8B => '\u{2039}', // Single left-pointing angle quotation mark
1386        0x8C => '\u{0152}', // Latin capital ligature OE
1387        0x8E => '\u{017D}', // Latin capital letter Z with caron
1388        0x91 => '\u{2018}', // Left single quotation mark
1389        0x92 => '\u{2019}', // Right single quotation mark
1390        0x93 => '\u{201C}', // Left double quotation mark
1391        0x94 => '\u{201D}', // Right double quotation mark
1392        0x95 => '\u{2022}', // Bullet
1393        0x96 => '\u{2013}', // En dash
1394        0x97 => '\u{2014}', // Em dash
1395        0x98 => '\u{02DC}', // Small tilde
1396        0x99 => '\u{2122}', // Trade mark sign
1397        0x9A => '\u{0161}', // Latin small letter s with caron
1398        0x9B => '\u{203A}', // Single right-pointing angle quotation mark
1399        0x9C => '\u{0153}', // Latin small ligature oe
1400        0x9E => '\u{017E}', // Latin small letter z with caron
1401        0x9F => '\u{0178}', // Latin capital letter Y with diaeresis
1402        // 0xA0-0xFF: direct Unicode mapping (Latin-1 Supplement)
1403        c @ 0xA0..=0xFF => {
1404            return char::from_u32(c)
1405                .map(|ch| ch.to_string())
1406                .unwrap_or_default();
1407        }
1408        _ => {
1409            return char::from_u32(code)
1410                .map(|c| c.to_string())
1411                .unwrap_or_default();
1412        }
1413    };
1414    mapped.to_string()
1415}
1416
1417#[cfg(test)]
1418mod tests {
1419    use super::*;
1420
1421    #[test]
1422    fn test_default_font() {
1423        let font = PdfFont::default_font("F1");
1424        assert_eq!(font.name, "F1");
1425        assert!((font.default_width - 600.0).abs() < 1e-10);
1426    }
1427
1428    #[test]
1429    fn test_bold_detection() {
1430        let mut font = PdfFont::default_font("F1");
1431        font.base_font = "Helvetica-Bold".to_string();
1432        assert!(font.is_bold());
1433
1434        font.base_font = "Helvetica".to_string();
1435        assert!(!font.is_bold());
1436    }
1437
1438    #[test]
1439    fn test_decode_winansi() {
1440        assert_eq!(decode_winansi(65), "A");
1441        assert_eq!(decode_winansi(0x93), "\u{201C}");
1442    }
1443
1444    #[test]
1445    fn test_parse_hex_value() {
1446        assert_eq!(parse_hex_value("<0041"), Some(0x41));
1447        assert_eq!(parse_hex_value("<00FF"), Some(0xFF));
1448    }
1449
1450    #[test]
1451    fn test_standard_font() {
1452        assert!(is_standard_font("Helvetica"));
1453        assert!(is_standard_font("Courier-Bold"));
1454        assert!(!is_standard_font("ArialMT"));
1455    }
1456
1457    #[test]
1458    fn type3_skia_widths_normalized_to_per_mille() {
1459        use lopdf::{dictionary, Document, Object, Stream};
1460
1461        let mut doc = Document::with_version("1.5");
1462        // Minimal empty CharProc (glyph drawing not needed for metrics).
1463        let proc_id = doc.add_object(Stream::new(dictionary! {}, Vec::new()));
1464        let fd_id = doc.add_object(dictionary! {
1465            "Type" => "FontDescriptor",
1466            "FontName" => "AAAA+.SFNS-Regular_wght2580000",
1467            "FontWeight" => 600,
1468            "StemV" => 262,
1469            "Flags" => 4,
1470            "ItalicAngle" => 0,
1471        });
1472        let font_id = doc.add_object(dictionary! {
1473            "Type" => "Font",
1474            "Subtype" => "Type3",
1475            "FontMatrix" => vec![
1476                Object::Real(1.0 / 2048.0),
1477                Object::Integer(0),
1478                Object::Integer(0),
1479                Object::Real(-1.0 / 2048.0),
1480                Object::Integer(0),
1481                Object::Integer(0),
1482            ],
1483            "FirstChar" => 0,
1484            "LastChar" => 3,
1485            "Widths" => vec![
1486                Object::Integer(2048),       // em-wide glyph → 1000 per-mille
1487                Object::Real(544.00195),     // space-like
1488                Object::Integer(1334),       // ~651 per-mille
1489                Object::Integer(0),
1490            ],
1491            "FontBBox" => vec![91.into(), 495.into(), 1949.into(), (-1951).into()],
1492            "FontDescriptor" => fd_id,
1493            "CharProcs" => dictionary! { "g0" => proc_id },
1494            "Encoding" => dictionary! {
1495                "Type" => "Encoding",
1496                "Differences" => vec![Object::Integer(0), Object::Name(b"g0".to_vec())],
1497            },
1498        });
1499
1500        let font_obj = doc.get_object(font_id).unwrap().as_dict().unwrap();
1501        // Clone dict because resolve needs owned-style access through doc refs
1502        let font = resolve_font_dict(&doc, "F4", font_obj);
1503
1504        assert_eq!(font.subtype, "Type3");
1505        assert!(
1506            font.base_font.contains("SFNS"),
1507            "expected descriptor FontName fallback, got {}",
1508            font.base_font
1509        );
1510        assert!(
1511            (font.weight - 600.0).abs() < 1e-6,
1512            "expected FontWeight=600 over StemV, got {}",
1513            font.weight
1514        );
1515        let w0 = font.glyph_width(0);
1516        assert!(
1517            (w0 - 1000.0).abs() < 1.0,
1518            "2048 glyph-space units → 1000 per-mille, got {w0}"
1519        );
1520        let w1 = font.glyph_width(1);
1521        let expected_space = 544.00195 * 1000.0 / 2048.0;
1522        assert!(
1523            (w1 - expected_space).abs() < 0.1,
1524            "space width got {w1}, expected ~{expected_space}"
1525        );
1526    }
1527
1528    /// End-to-end: Skia-like Type3 PDF must preserve the word space in "Hi there".
1529    #[test]
1530    fn type3_skia_pdf_preserves_word_space() {
1531        use crate::api::config::ProcessingConfig;
1532        use crate::convert_bytes;
1533        use crate::output::markdown::to_markdown;
1534        use lopdf::content::{Content, Operation};
1535        use lopdf::{dictionary, Document, Object, Stream};
1536
1537        let mut doc = Document::with_version("1.5");
1538        let pages_id = doc.new_object_id();
1539        let proc_id = doc.add_object(Stream::new(dictionary! {}, Vec::new()));
1540
1541        let fd_id = doc.add_object(dictionary! {
1542            "Type" => "FontDescriptor",
1543            "FontName" => "AAAA+.SFNS-Regular",
1544            "FontWeight" => 400,
1545            "StemV" => 80,
1546            "Flags" => 32,
1547        });
1548
1549        // Widths in 1/2048 glyph space (Skia). After normalize: *1000/2048.
1550        // Codes: H i space t h e r e
1551        let widths: Vec<Object> = [1200, 500, 544, 600, 700, 650, 550, 650]
1552            .into_iter()
1553            .map(Object::Integer)
1554            .collect();
1555
1556        let font_id = doc.add_object(dictionary! {
1557            "Type" => "Font",
1558            "Subtype" => "Type3",
1559            "FontMatrix" => vec![
1560                Object::Real(1.0 / 2048.0),
1561                0.into(),
1562                0.into(),
1563                Object::Real(-1.0 / 2048.0),
1564                0.into(),
1565                0.into(),
1566            ],
1567            "FirstChar" => 0,
1568            "LastChar" => 7,
1569            "Widths" => widths,
1570            "FontBBox" => vec![0.into(), 0.into(), 2048.into(), (-2048).into()],
1571            "FontDescriptor" => fd_id,
1572            "CharProcs" => dictionary! { "g0" => proc_id },
1573            // AGL glyph names so Differences → Unicode without ToUnicode.
1574            "Encoding" => dictionary! {
1575                "Type" => "Encoding",
1576                "Differences" => vec![
1577                    Object::Integer(0),
1578                    Object::Name(b"H".to_vec()),
1579                    Object::Name(b"i".to_vec()),
1580                    Object::Name(b"space".to_vec()),
1581                    Object::Name(b"t".to_vec()),
1582                    Object::Name(b"h".to_vec()),
1583                    Object::Name(b"e".to_vec()),
1584                    Object::Name(b"r".to_vec()),
1585                    Object::Name(b"e".to_vec()),
1586                ],
1587            },
1588        });
1589
1590        let resources_id = doc.add_object(dictionary! {
1591            "Font" => dictionary! { "F1" => font_id },
1592        });
1593
1594        // One Tj per glyph (Skia style). Space glyph is filtered by
1595        // TextLine::value(); needs_space must re-insert it from the gap,
1596        // which only works when Type3 widths are normalized.
1597        let mut ops = vec![
1598            Operation::new("BT", vec![]),
1599            Operation::new("Tf", vec!["F1".into(), Object::Real(24.0)]),
1600            Operation::new("Td", vec![72.into(), 700.into()]),
1601        ];
1602        for code in [0u8, 1, 2, 3, 4, 5, 6, 7] {
1603            ops.push(Operation::new(
1604                "Tj",
1605                vec![Object::String(vec![code], lopdf::StringFormat::Hexadecimal)],
1606            ));
1607        }
1608        ops.push(Operation::new("ET", vec![]));
1609
1610        let content_id = doc.add_object(Stream::new(
1611            dictionary! {},
1612            Content { operations: ops }.encode().unwrap(),
1613        ));
1614        let page_id = doc.add_object(dictionary! {
1615            "Type" => "Page",
1616            "Parent" => pages_id,
1617            "Contents" => content_id,
1618            "Resources" => resources_id,
1619            "MediaBox" => vec![0.into(), 0.into(), 612.into(), 792.into()],
1620        });
1621        doc.objects.insert(
1622            pages_id,
1623            Object::Dictionary(dictionary! {
1624                "Type" => "Pages",
1625                "Kids" => vec![page_id.into()],
1626                "Count" => 1,
1627            }),
1628        );
1629        let catalog_id = doc.add_object(dictionary! {
1630            "Type" => "Catalog",
1631            "Pages" => pages_id,
1632        });
1633        doc.trailer.set("Root", catalog_id);
1634
1635        let mut bytes = Vec::new();
1636        doc.save_to(&mut bytes).unwrap();
1637
1638        let mut config = ProcessingConfig::default();
1639        config.raster_table_ocr = false;
1640        config.image_output = crate::api::config::ImageOutput::Off;
1641        let parsed = convert_bytes(&bytes, "skia-type3.pdf", &config).unwrap();
1642        let md = to_markdown(&parsed).unwrap();
1643        assert!(
1644            md.contains("Hi there"),
1645            "Type3 width normalization must preserve word space; got: {md:?}"
1646        );
1647    }
1648
1649    #[test]
1650    fn identity_h_ascii_cid_fallback_without_tounicode() {
1651        // Type0 Identity-H, no ToUnicode: ASCII-range CIDs must still decode.
1652        use lopdf::{dictionary, Document, Object};
1653
1654        let mut doc = Document::with_version("1.5");
1655        let desc_id = doc.add_object(dictionary! {
1656            "Type" => "Font",
1657            "Subtype" => "CIDFontType2",
1658            "BaseFont" => "Dummy",
1659            "CIDSystemInfo" => dictionary! {
1660                "Registry" => Object::string_literal("Adobe"),
1661                "Ordering" => Object::string_literal("Identity"),
1662                "Supplement" => 0,
1663            },
1664            "DW" => 1000,
1665        });
1666        let font_id = doc.add_object(dictionary! {
1667            "Type" => "Font",
1668            "Subtype" => "Type0",
1669            "BaseFont" => "Dummy",
1670            "Encoding" => "Identity-H",
1671            "DescendantFonts" => vec![desc_id.into()],
1672        });
1673        let font_obj = doc.get_object(font_id).unwrap().as_dict().unwrap().clone();
1674        let font = resolve_font_dict(&doc, "F1", &font_obj);
1675        assert_eq!(font.bytes_per_code, 2);
1676        assert_eq!(font.decode_char(0x0041), "A");
1677        assert_eq!(font.decode_char(0x0020), " ");
1678        // High unmapped CID must not WinAnsi-mojibake.
1679        assert_eq!(font.decode_char(0x1234), "\u{FFFD}");
1680    }
1681
1682    /// Optional local-only check against a Skia Chrome PDF on disk.
1683    #[test]
1684    fn type3_real_skia_pdf_word_ratio_env_gated() {
1685        let path = match std::env::var("EDGEPARSE_SKIA_PDF") {
1686            Ok(p) => std::path::PathBuf::from(p),
1687            Err(_) => return, // skip quietly when unset
1688        };
1689        if !path.exists() {
1690            return;
1691        }
1692        use crate::api::config::ProcessingConfig;
1693        use crate::convert;
1694        use crate::output::markdown::to_markdown;
1695
1696        let mut config = ProcessingConfig::default();
1697        config.raster_table_ocr = false;
1698        config.image_output = crate::api::config::ImageOutput::Off;
1699        let doc = convert(&path, &config).unwrap();
1700        let md = to_markdown(&doc).unwrap();
1701        let words = md.split_whitespace().count();
1702        assert!(
1703            words > 2000,
1704            "expected dense word-spaced markdown, got {words} words"
1705        );
1706        assert!(
1707            !md.contains("W e ") && !md.contains("W ikipedia"),
1708            "stray W fragments still present"
1709        );
1710    }
1711
1712    #[test]
1713    fn test_glyph_name_to_unicode_ligatures() {
1714        assert_eq!(glyph_name_to_unicode("fi"), Some("fi".to_string()));
1715        assert_eq!(glyph_name_to_unicode("fl"), Some("fl".to_string()));
1716        assert_eq!(glyph_name_to_unicode("ff"), Some("ff".to_string()));
1717        assert_eq!(glyph_name_to_unicode("ffi"), Some("ffi".to_string()));
1718        assert_eq!(glyph_name_to_unicode("ffl"), Some("ffl".to_string()));
1719    }
1720
1721    #[test]
1722    fn test_glyph_name_to_unicode_common() {
1723        assert_eq!(glyph_name_to_unicode("percent"), Some("%".to_string()));
1724        assert_eq!(glyph_name_to_unicode("ampersand"), Some("&".to_string()));
1725        assert_eq!(glyph_name_to_unicode("parenleft"), Some("(".to_string()));
1726        assert_eq!(
1727            glyph_name_to_unicode("endash"),
1728            Some("\u{2013}".to_string())
1729        );
1730        assert_eq!(glyph_name_to_unicode("A"), Some("A".to_string()));
1731        assert_eq!(glyph_name_to_unicode("uni0041"), Some("A".to_string()));
1732    }
1733
1734    #[test]
1735    fn test_glyph_name_to_unicode_unknown() {
1736        assert_eq!(glyph_name_to_unicode("nonexistent_glyph_xyz"), None);
1737    }
1738
1739    #[test]
1740    fn test_glyph_name_to_unicode_agl_extended() {
1741        // Characters only available via the full AGL, not the old hand-coded list
1742        assert_eq!(
1743            glyph_name_to_unicode("Dcroat"),
1744            Some("\u{0110}".to_string())
1745        );
1746        assert_eq!(
1747            glyph_name_to_unicode("dcroat"),
1748            Some("\u{0111}".to_string())
1749        );
1750        assert_eq!(
1751            glyph_name_to_unicode("Emacron"),
1752            Some("\u{0112}".to_string())
1753        );
1754        assert_eq!(
1755            glyph_name_to_unicode("afii10017"),
1756            Some("\u{0410}".to_string())
1757        ); // Cyrillic А
1758        assert_eq!(
1759            glyph_name_to_unicode("afii57636"),
1760            Some("\u{20AA}".to_string())
1761        ); // New Sheqel sign
1762           // Multi-codepoint entry (Hebrew dalet + hataf patah)
1763        assert_eq!(
1764            glyph_name_to_unicode("dalethatafpatah"),
1765            Some("\u{05D3}\u{05B2}".to_string())
1766        );
1767    }
1768
1769    #[test]
1770    fn test_glyph_name_to_unicode_uni_formats() {
1771        // uniXXXX format
1772        assert_eq!(glyph_name_to_unicode("uni0041"), Some("A".to_string()));
1773        assert_eq!(glyph_name_to_unicode("uni00E9"), Some("é".to_string()));
1774        // uniXXXXYYYY (sequence of BMP codepoints)
1775        assert_eq!(glyph_name_to_unicode("uni00410042"), Some("AB".to_string()));
1776        // uXXXXXX format (supplementary)
1777        assert_eq!(
1778            glyph_name_to_unicode("u1F600"),
1779            Some("\u{1F600}".to_string())
1780        );
1781    }
1782
1783    #[test]
1784    fn test_parse_cmap_bfrange_array() {
1785        // bfrange with array format: <start> <end> [<v1> <v2> <v3>]
1786        let cmap_data = b"beginbfrange\n<0001> <0003> [<0041> <0042> <0043>]\nendbfrange\n";
1787        let mut mapping = HashMap::new();
1788        parse_cmap(cmap_data, &mut mapping);
1789        assert_eq!(mapping.get(&1), Some(&"A".to_string()));
1790        assert_eq!(mapping.get(&2), Some(&"B".to_string()));
1791        assert_eq!(mapping.get(&3), Some(&"C".to_string()));
1792    }
1793
1794    #[test]
1795    fn test_parse_cmap_bfrange_incremented() {
1796        // bfrange with incrementing hex: <start> <end> <dst>
1797        let cmap_data = b"beginbfrange\n<0041> <0043> <0061>\nendbfrange\n";
1798        let mut mapping = HashMap::new();
1799        parse_cmap(cmap_data, &mut mapping);
1800        assert_eq!(mapping.get(&0x41), Some(&"a".to_string()));
1801        assert_eq!(mapping.get(&0x42), Some(&"b".to_string()));
1802        assert_eq!(mapping.get(&0x43), Some(&"c".to_string()));
1803    }
1804
1805    #[test]
1806    fn test_decode_winansi_extended() {
1807        assert_eq!(decode_winansi(0x82), "\u{201A}"); // Single low-9 quotation
1808        assert_eq!(decode_winansi(0x83), "\u{0192}"); // f with hook
1809        assert_eq!(decode_winansi(0x8A), "\u{0160}"); // S with caron
1810        assert_eq!(decode_winansi(0x8C), "\u{0152}"); // OE ligature
1811        assert_eq!(decode_winansi(0x99), "\u{2122}"); // Trademark
1812        assert_eq!(decode_winansi(0x9C), "\u{0153}"); // oe ligature
1813        assert_eq!(decode_winansi(0xA0), "\u{00A0}"); // NBSP
1814        assert_eq!(decode_winansi(0xE9), "\u{00E9}"); // é
1815    }
1816
1817    #[test]
1818    fn test_tex_glyph_names() {
1819        // CMSY font glyph names (TeX-specific, not in AGL)
1820        assert_eq!(
1821            glyph_name_to_unicode("asteriskmath"),
1822            Some("\u{2217}".to_string())
1823        ); // ∗
1824        assert_eq!(
1825            glyph_name_to_unicode("diamondmath"),
1826            Some("\u{22C4}".to_string())
1827        ); // ⋄
1828        assert_eq!(
1829            glyph_name_to_unicode("minusplus"),
1830            Some("\u{2213}".to_string())
1831        ); // ∓
1832        assert_eq!(
1833            glyph_name_to_unicode("circleminus"),
1834            Some("\u{2296}".to_string())
1835        ); // ⊖
1836        assert_eq!(
1837            glyph_name_to_unicode("circledot"),
1838            Some("\u{2299}".to_string())
1839        ); // ⊙
1840        assert_eq!(
1841            glyph_name_to_unicode("follows"),
1842            Some("\u{227B}".to_string())
1843        ); // ≻
1844        assert_eq!(
1845            glyph_name_to_unicode("lessmuch"),
1846            Some("\u{226A}".to_string())
1847        ); // ≪
1848        assert_eq!(
1849            glyph_name_to_unicode("greatermuch"),
1850            Some("\u{226B}".to_string())
1851        ); // ≫
1852        assert_eq!(
1853            glyph_name_to_unicode("latticetop"),
1854            Some("\u{22A4}".to_string())
1855        ); // ⊤
1856        assert_eq!(
1857            glyph_name_to_unicode("mapsto"),
1858            Some("\u{21A6}".to_string())
1859        ); // ↦
1860           // These should still resolve via AGL
1861        assert_eq!(
1862            glyph_name_to_unicode("dagger"),
1863            Some("\u{2020}".to_string())
1864        ); // †
1865        assert_eq!(
1866            glyph_name_to_unicode("daggerdbl"),
1867            Some("\u{2021}".to_string())
1868        ); // ‡
1869        assert_eq!(
1870            glyph_name_to_unicode("braceleft"),
1871            Some("\u{007B}".to_string())
1872        ); // {
1873        assert_eq!(
1874            glyph_name_to_unicode("braceright"),
1875            Some("\u{007D}".to_string())
1876        ); // }
1877    }
1878}