Skip to main content

stet_pdf_reader/content/
font.rs

1// stet-pdf-reader
2// Copyright (c) 2026 Scott Bowman
3// SPDX-License-Identifier: Apache-2.0 OR MIT
4
5//! PDF font resolution and glyph rendering.
6
7use std::collections::HashMap;
8use std::sync::Arc;
9
10use skrifa::MetadataProvider;
11use stet_fonts::cff_parser::{CffFont, parse_cff};
12use stet_fonts::charstring::{execute_charstring, execute_charstring_mm};
13use stet_fonts::encoding::{MACROMAN_ENCODING, STANDARD_ENCODING, WINANSI_ENCODING};
14use stet_fonts::geometry::PathSegment;
15use stet_fonts::geometry::{Matrix, PsPath};
16use stet_fonts::truetype::{
17    get_glyf_data, get_units_per_em, parse_cmap, parse_cmap_with_info, parse_glyf_to_path,
18};
19use stet_fonts::type1_parser::parse_type1;
20use stet_fonts::type2_charstring::execute_type2_charstring;
21
22use crate::FontProvider;
23use crate::error::PdfError;
24use crate::objects::{PdfDict, PdfObj};
25use crate::resolver::Resolver;
26
27/// Resolved PDF font, ready for glyph rendering.
28pub enum PdfFont {
29    Type1(Type1PdfFont),
30    TrueType(TrueTypePdfFont),
31    Cff(CffPdfFont),
32    /// Type 0 composite font (CIDFontType2 with TrueType outlines)
33    CidTrueType(CidTrueTypePdfFont),
34    /// Type 0 composite font (CIDFontType0 with CFF outlines)
35    CidCff(CidCffPdfFont),
36    /// Type 3 font: glyphs defined as content streams.
37    Type3(Type3PdfFont),
38}
39
40pub struct Type1PdfFont {
41    pub font: stet_fonts::type1_parser::Type1Font,
42    pub encoding: [Option<String>; 256],
43    pub widths: [f64; 256],
44    pub font_matrix: Matrix,
45    /// Multiple Master weight vector (for blend OtherSubrs 14-17).
46    pub weight_vector: Option<Vec<f64>>,
47    /// When true, glyph lookup falls back to the font's built-in encoding
48    /// if the PDF encoding's glyph name isn't in CharStrings. Only set for
49    /// symbolic fonts where the naming convention is completely incompatible
50    /// (e.g. StandardEncoding "A" vs font's custom "G41").
51    pub builtin_fallback: bool,
52    /// When true, per-character width scaling adjusts each glyph horizontally
53    /// to match the PDF's /Widths. Set for non-metric-compatible substitutes
54    /// (e.g. NimbusSans for LucidaSans) where the average width mismatch
55    /// exceeds 3%. NOT set for metric-compatible substitutes (e.g. NimbusRoman
56    /// for TimesNewRoman) where widths already match.
57    pub per_char_width_scale: bool,
58}
59
60pub struct TrueTypePdfFont {
61    pub data: Vec<u8>,
62    pub encoding: [Option<String>; 256],
63    pub widths: [f64; 256],
64    pub cmap: HashMap<u32, u16>,
65    /// Whether the cmap maps Unicode values (true) or re-encoded char codes (false).
66    /// Non-Unicode cmaps come from (1,0) Mac Roman or (3,0) Symbol subtables in
67    /// subset fonts — the encoding→unicode→cmap lookup path must be skipped.
68    pub cmap_is_unicode: bool,
69    /// Glyph name → GID mapping from the `post` table (for ligatures and
70    /// other glyphs not reachable via Unicode cmap lookup).
71    pub post_name_to_gid: HashMap<String, u16>,
72    pub units_per_em: f64,
73    /// Char code → Unicode mapping from /ToUnicode CMap (for gNNNN glyph names
74    /// in substituted fonts where AGL lookup fails).
75    pub to_unicode: HashMap<u16, u32>,
76    /// When true, char codes map directly to GIDs (identity mapping).
77    /// Set for symbolic TrueType fonts without an explicit /Encoding, where the
78    /// cmap subtable maps to misleading Unicode values (re-encoded fonts).
79    pub identity_gid: bool,
80    /// When true, gNNNN glyph names in the encoding use hexadecimal GIDs
81    /// (e.g. g003a = GID 58). Set when any gNNNN name contains hex letters (a-f).
82    /// When false, gNNNN names use decimal (e.g. g1863 = GID 1863).
83    pub gid_hex: bool,
84}
85
86pub struct CffPdfFont {
87    pub font: CffFont,
88    pub encoding: [Option<String>; 256],
89    pub widths: [f64; 256],
90    pub font_matrix: Matrix,
91}
92
93/// CIDFontType2: TrueType outlines accessed by CID (2-byte char codes).
94pub struct CidTrueTypePdfFont {
95    pub data: Vec<u8>,
96    /// Default glyph width (from /DW, in text space ÷1000).
97    pub default_width: f64,
98    /// CID → width mapping (from /W array, in text space ÷1000).
99    pub cid_widths: HashMap<u16, f64>,
100    pub cmap: HashMap<u32, u16>,
101    pub units_per_em: f64,
102    /// If true, CID maps directly to GID (Identity CIDToGIDMap).
103    pub identity_cid_to_gid: bool,
104    /// If true, font data was loaded from the system (not embedded in PDF).
105    /// For substituted fonts, CIDs are treated as Unicode and mapped via cmap.
106    pub substituted: bool,
107    /// Explicit CID-to-GID mapping from a CIDToGIDMap stream.
108    /// Index = CID, value = GID. Takes priority over identity mapping.
109    pub cid_to_gid_map: Option<Vec<u16>>,
110    /// CID → Unicode mapping from the Type 0 font's /ToUnicode CMap.
111    /// Used for substituted fonts to convert CID → Unicode → GID via cmap.
112    pub to_unicode: HashMap<u16, u32>,
113    /// CIDSystemInfo /Ordering (e.g. b"Japan1", b"GB1") for CID→Unicode fallback.
114    pub ordering: Vec<u8>,
115    /// If true, the encoding is UCS2-based (e.g. UniJIS-UCS2-H) and character
116    /// codes are Unicode values that need mapping to CIDs for width lookup.
117    pub ucs2_encoding: bool,
118    /// First-byte → code length table from the encoding CMap's codespace ranges.
119    /// Supports mixed-width encodings (e.g. 1-byte space + 2-byte CIDs).
120    pub code_lengths: [u8; 256],
121    /// Code → CID mapping from the encoding CMap (empty = identity).
122    pub code_to_cid: HashMap<u32, u32>,
123    /// Writing mode: 0 = horizontal, 1 = vertical.
124    pub wmode: u8,
125    /// Default vertical metrics [v_y, w1] from /DW2 (default: [880, -1000]).
126    /// v_y = vertical origin offset, w1 = vertical advance width.
127    pub dw2: [f64; 2],
128    /// Per-CID vertical metrics from /W2: CID → (w1, v_x, v_y).
129    /// w1 = vertical advance, v_x/v_y = position vector components (in 1/1000 em).
130    pub w2: HashMap<u16, [f64; 3]>,
131}
132
133/// CIDFontType0: CFF outlines accessed by CID (2-byte char codes).
134pub struct CidCffPdfFont {
135    pub font: CffFont,
136    /// Default glyph width (from /DW, in text space ÷1000).
137    pub default_width: f64,
138    /// CID → width mapping (from /W array, in text space ÷1000).
139    pub cid_widths: HashMap<u16, f64>,
140    /// Optional Unicode→GID cmap (from OpenType substitute fonts).
141    /// When present, used for UCS2 glyph lookup instead of CFF's CID mapping.
142    pub cmap: Option<HashMap<u32, u16>>,
143    /// CID → GID mapping from the PDF's /CIDToGIDMap stream.
144    /// Used for embedded OpenType/CFF fonts stored as FontFile2.
145    pub pdf_cid_to_gid: Option<Vec<u16>>,
146    /// When true, CID maps directly to charstring index (GID = CID).
147    /// Set when CIDToGIDMap is /Identity or absent in CIDFontType2 fonts.
148    pub identity_cid_to_gid: bool,
149    /// CIDSystemInfo ordering for Unicode→CID width lookup.
150    pub ordering: Vec<u8>,
151    pub font_matrix: Matrix,
152    /// First-byte → code length table from the encoding CMap's codespace ranges.
153    pub code_lengths: [u8; 256],
154    /// Code → CID mapping from the encoding CMap (empty = identity).
155    pub code_to_cid: HashMap<u32, u32>,
156    /// Writing mode: 0 = horizontal, 1 = vertical.
157    pub wmode: u8,
158    /// Default vertical metrics [v_y, w1] from /DW2 (default: [880, -1000]).
159    pub dw2: [f64; 2],
160    /// Per-CID vertical metrics from /W2: CID → (w1, v_x, v_y).
161    pub w2: HashMap<u16, [f64; 3]>,
162    /// Pre-computed glyph paths for Type 1 fonts used as CIDFontType0.
163    /// When set, glyph_path_cid uses this cache instead of CFF charstrings.
164    pub type1_paths: Option<HashMap<u16, PsPath>>,
165}
166
167/// Type 3 font: glyphs defined as content streams (CharProcs).
168pub struct Type3PdfFont {
169    /// Char code → decoded content stream bytes for the glyph.
170    pub char_procs: HashMap<u8, Vec<u8>>,
171    /// Char code → resources dict for the glyph stream (from font dict).
172    pub resources: PdfDict,
173    pub widths: [f64; 256],
174    pub font_matrix: Matrix,
175    pub font_bbox: [f64; 4],
176}
177
178/// Font cache: font resource name → resolved font.
179pub type FontCache = HashMap<Vec<u8>, Arc<PdfFont>>;
180
181/// Resolve a PDF font dict into a PdfFont ready for rendering.
182pub fn resolve_font(
183    resolver: &Resolver,
184    font_ref: &PdfObj,
185    font_provider: Option<&FontProvider>,
186) -> Result<PdfFont, PdfError> {
187    let font_obj = resolver.deref(font_ref)?;
188    let font_dict = font_obj
189        .as_dict()
190        .ok_or(PdfError::Other("Font is not a dict".into()))?;
191
192    let subtype = font_dict.get_name(b"Subtype").unwrap_or(b"Type1");
193    // Handle Type 0 composite fonts (CID fonts)
194    if subtype == b"Type0" {
195        return resolve_type0(resolver, font_dict);
196    }
197
198    // Handle Type 3 fonts (glyph content streams)
199    if subtype == b"Type3" {
200        return resolve_type3(resolver, font_dict);
201    }
202
203    let first_char = font_dict.get_int(b"FirstChar").unwrap_or(0) as usize;
204    let last_char = font_dict.get_int(b"LastChar").unwrap_or(255) as usize;
205
206    // Parse widths array from font dict.
207    // /Widths may be a direct array or an indirect reference — resolve if needed.
208    let mut widths = [0.0f64; 256];
209    let mut has_pdf_widths = false;
210    let widths_obj = font_dict.get(b"Widths").and_then(|obj| {
211        if obj.as_array().is_some() {
212            Some(obj.clone())
213        } else {
214            resolver.deref(obj).ok()
215        }
216    });
217    if let Some(PdfObj::Array(w_arr)) = &widths_obj {
218        for (i, obj) in w_arr.iter().enumerate() {
219            let code = first_char + i;
220            if code < 256 {
221                // Width entries may be indirect references (e.g. `9 0 R`)
222                let val = if obj.as_f64().is_some() {
223                    obj.as_f64().unwrap()
224                } else if let Ok(resolved) = resolver.deref(obj) {
225                    resolved.as_f64().unwrap_or(0.0)
226                } else {
227                    0.0
228                };
229                widths[code] = val / 1000.0;
230            }
231        }
232        has_pdf_widths = true;
233
234        // Apply /MissingWidth from FontDescriptor to charcodes outside [FirstChar, LastChar].
235        // Per PDF spec, charcodes not covered by /Widths use /MissingWidth (default 0).
236        let descriptor = get_font_descriptor(font_dict, resolver)?;
237        if let Some(ref desc) = descriptor {
238            let missing_w = desc.get_f64(b"MissingWidth").unwrap_or(0.0) / 1000.0;
239            if missing_w != 0.0 {
240                for (code, width) in widths.iter_mut().enumerate() {
241                    if code < first_char || code > last_char {
242                        *width = missing_w;
243                    }
244                }
245            }
246        }
247    }
248
249    // Resolve encoding.  Track whether the PDF dict had a valid /Encoding —
250    // embedded CFF fonts that lack one should use the CFF's built-in encoding.
251    // Invalid encoding names (e.g. /NULL) are treated as absent.
252    let (encoding, has_valid_encoding, differences, no_base_encoding) =
253        resolve_encoding(font_dict, resolver)?;
254    let has_explicit_encoding = has_valid_encoding;
255
256    // Get FontDescriptor
257    let descriptor = get_font_descriptor(font_dict, resolver)?;
258
259    // Extract font descriptor Flags for serif/sans-serif fallback selection
260    let desc_flags = descriptor
261        .as_ref()
262        .and_then(|d| d.get_int(b"Flags"))
263        .unwrap_or(0) as u32;
264
265    let base_font_name = font_dict
266        .get_name(b"BaseFont")
267        .map(|n| String::from_utf8_lossy(n).to_string())
268        .unwrap_or_default();
269
270    // Route based on what font program is actually available in FontDescriptor,
271    // not just the /Subtype (which says "Type1" even for CFF-embedded fonts).
272    if let Some(ref desc) = descriptor {
273        if desc.get(b"FontFile3").is_some() {
274            // Try embedded CFF; fall back to substitution if decompression/parsing fails
275            match resolve_cff(
276                resolver,
277                &descriptor,
278                encoding.clone(),
279                widths,
280                has_explicit_encoding,
281                has_pdf_widths,
282                &differences,
283                no_base_encoding,
284            ) {
285                Ok(font) => return Ok(font),
286                Err(_) => {
287                    if let Some(font) = substitute_font(
288                        &base_font_name,
289                        encoding.clone(),
290                        widths,
291                        has_pdf_widths,
292                        font_provider,
293                        desc_flags,
294                        first_char,
295                        last_char,
296                    ) {
297                        return Ok(font);
298                    }
299                }
300            }
301        }
302        if desc.get(b"FontFile2").is_some() {
303            // Try embedded TrueType (falls back to CFF internally if data is OTTO/CFF)
304            match resolve_truetype(resolver, &descriptor, encoding.clone(), widths, font_dict) {
305                Ok(font) => return Ok(font),
306                Err(_) => {
307                    if let Some(font) = substitute_font(
308                        &base_font_name,
309                        encoding.clone(),
310                        widths,
311                        has_pdf_widths,
312                        font_provider,
313                        desc_flags,
314                        first_char,
315                        last_char,
316                    ) {
317                        return Ok(font);
318                    }
319                }
320            }
321        }
322        if desc.get(b"FontFile").is_some() {
323            match resolve_type1(
324                resolver,
325                &descriptor,
326                encoding.clone(),
327                widths,
328                has_explicit_encoding,
329                has_pdf_widths,
330                &differences,
331                no_base_encoding,
332            ) {
333                Ok(font) => return Ok(font),
334                Err(_) => {
335                    if let Some(font) = substitute_font(
336                        &base_font_name,
337                        encoding.clone(),
338                        widths,
339                        has_pdf_widths,
340                        font_provider,
341                        desc_flags,
342                        first_char,
343                        last_char,
344                    ) {
345                        return Ok(font);
346                    }
347                }
348            }
349        }
350    }
351    // No embedded font program — try font substitution
352    if let Some(font) = substitute_font(
353        &base_font_name,
354        encoding.clone(),
355        widths,
356        has_pdf_widths,
357        font_provider,
358        desc_flags,
359        first_char,
360        last_char,
361    ) {
362        return Ok(font);
363    }
364    // For TrueType fonts, try loading from system fonts before giving up
365    if subtype == b"TrueType"
366        && let Ok(data) = load_system_truetype_font(&base_font_name)
367    {
368        let units_per_em = get_units_per_em(&data) as f64;
369        let (cmap, cmap_is_unicode) = parse_cmap_with_info(&data);
370        let post_name_to_gid = stet_fonts::system_fonts::parse_post_table(&data)
371            .map(|gid_to_name| {
372                gid_to_name
373                    .into_iter()
374                    .map(|(gid, name)| (name, gid))
375                    .collect()
376            })
377            .unwrap_or_default();
378        let to_unicode = if let Some(tu_obj) = font_dict.get(b"ToUnicode") {
379            resolver
380                .stream_data_from_obj(tu_obj)
381                .map(|d| parse_to_unicode(&d))
382                .unwrap_or_default()
383        } else {
384            HashMap::new()
385        };
386        let gid_hex = TrueTypePdfFont::detect_gid_hex(&encoding);
387        return Ok(PdfFont::TrueType(TrueTypePdfFont {
388            data,
389            encoding,
390            widths,
391            cmap,
392            cmap_is_unicode,
393            post_name_to_gid,
394            units_per_em,
395            to_unicode,
396            identity_gid: false, // system font substitutes use normal cmap
397            gid_hex,
398        }));
399    }
400
401    // Final fallback based on subtype (will likely fail)
402    match subtype {
403        b"TrueType" => resolve_truetype(resolver, &descriptor, encoding, widths, font_dict),
404        _ => resolve_type1(
405            resolver,
406            &descriptor,
407            encoding,
408            widths,
409            has_explicit_encoding,
410            has_pdf_widths,
411            &differences,
412            no_base_encoding,
413        ),
414    }
415}
416
417/// Get the FontDescriptor dict if present.
418fn get_font_descriptor(
419    font_dict: &PdfDict,
420    resolver: &Resolver,
421) -> Result<Option<PdfDict>, PdfError> {
422    if let Some(fd_ref) = font_dict.get(b"FontDescriptor") {
423        let fd_obj = resolver.deref(fd_ref)?;
424        if let Some(d) = fd_obj.as_dict() {
425            return Ok(Some(d.clone()));
426        }
427    }
428    Ok(None)
429}
430
431/// Resolve encoding from font dict.
432///
433/// Priority: /Encoding dict with /Differences overlay > /Encoding name > StandardEncoding.
434/// For symbolic fonts (ZapfDingbats, Symbol) with no explicit /BaseEncoding,
435/// the font's built-in encoding is used instead of StandardEncoding.
436///
437/// Returns (encoding, has_valid_encoding, differences):
438/// - `encoding`: fully resolved encoding (base + differences applied)
439/// - `has_valid_encoding`: false when /Encoding is missing or unrecognized
440/// - `differences`: raw (code, name) pairs from /Differences, populated ONLY when
441///   the Encoding is a dict without /BaseEncoding (and not a symbol font). When
442///   non-empty, embedded font resolvers should re-apply these on top of the font's
443///   built-in encoding instead of using `encoding` directly (PDF spec 9.6.6.1).
444fn resolve_encoding(
445    font_dict: &PdfDict,
446    resolver: &Resolver,
447) -> Result<([Option<String>; 256], bool, Vec<(usize, String)>, bool), PdfError> {
448    let mut encoding: [Option<String>; 256] = std::array::from_fn(|_| None);
449    let mut differences: Vec<(usize, String)> = Vec::new();
450
451    // Start with a base encoding — use the font's built-in encoding for
452    // symbolic fonts (PDF spec 9.6.6.1: when no BaseEncoding, symbolic fonts
453    // use their built-in encoding, not StandardEncoding).
454    let base_font = font_dict.get_name(b"BaseFont").unwrap_or(b"");
455    // Strip subset prefix for font name matching
456    let clean_base = if base_font.len() > 7 && base_font.get(6) == Some(&b'+') {
457        &base_font[7..]
458    } else {
459        base_font
460    };
461    let is_symbol_font = clean_base == b"ZapfDingbats" || clean_base == b"Symbol";
462    let mut base_table: &[&str; 256] = if clean_base == b"ZapfDingbats" {
463        &stet_fonts::encoding::ZAPFDINGBATS_ENCODING
464    } else if clean_base == b"Symbol" {
465        &stet_fonts::encoding::SYMBOL_ENCODING
466    } else {
467        &STANDARD_ENCODING
468    };
469
470    let mut has_valid_encoding = is_symbol_font; // symbol fonts always have valid built-in encoding
471    if let Some(enc_obj) = font_dict.get(b"Encoding") {
472        let enc_resolved = resolver.deref(enc_obj)?;
473        match &enc_resolved {
474            PdfObj::Name(name) => {
475                // Symbol/ZapfDingbats: keep their fixed encoding, ignore overrides.
476                // Unknown encoding names (e.g. /NULL): skip, keep the default base.
477                if !is_symbol_font {
478                    if let Some(table) = encoding_table_by_name(name) {
479                        base_table = table;
480                        has_valid_encoding = true;
481                    }
482                }
483            }
484            PdfObj::Dict(enc_dict) => {
485                // Dict encoding: optional BaseEncoding + Differences.
486                // Symbol/ZapfDingbats keep their fixed base encoding.
487                has_valid_encoding = true;
488                let mut has_base_encoding = false;
489                if !is_symbol_font {
490                    if let Some(base_name) = enc_dict.get_name(b"BaseEncoding") {
491                        if let Some(table) = encoding_table_by_name(base_name) {
492                            base_table = table;
493                            has_base_encoding = true;
494                        }
495                    }
496                }
497                for (i, &name) in base_table.iter().enumerate() {
498                    if name != ".notdef" {
499                        encoding[i] = Some(name.to_string());
500                    }
501                }
502                // Parse Differences array (may be an indirect reference)
503                if let Some(diffs_obj) = enc_dict.get(b"Differences") {
504                    let diffs_resolved = resolver.deref(diffs_obj)?;
505                    if let Some(diffs) = diffs_resolved.as_array() {
506                        let mut code = 0usize;
507                        for obj in diffs {
508                            let obj = resolver.deref(obj).unwrap_or(obj.clone());
509                            match &obj {
510                                PdfObj::Int(n) => code = *n as usize,
511                                PdfObj::Name(name) => {
512                                    if code < 256 {
513                                        let name_str = String::from_utf8_lossy(name).to_string();
514                                        encoding[code] = Some(name_str.clone());
515                                        // Collect differences when no BaseEncoding was
516                                        // specified — embedded fonts need to re-apply
517                                        // these on their built-in encoding (PDF 9.6.6.1).
518                                        if !has_base_encoding && !is_symbol_font {
519                                            differences.push((code, name_str));
520                                        }
521                                        code += 1;
522                                    }
523                                }
524                                _ => {}
525                            }
526                        }
527                    }
528                }
529                // Signal "no base encoding" only for non-symbol fonts.
530                // Symbol fonts (ZapfDingbats, Symbol) always use their fixed
531                // built-in encoding, so their has_base_encoding is never set.
532                return Ok((
533                    encoding,
534                    has_valid_encoding,
535                    differences,
536                    !has_base_encoding && !is_symbol_font,
537                ));
538            }
539            _ => {}
540        }
541    }
542
543    // Apply base table
544    for (i, &name) in base_table.iter().enumerate() {
545        if name != ".notdef" {
546            encoding[i] = Some(name.to_string());
547        }
548    }
549
550    Ok((encoding, has_valid_encoding, differences, false))
551}
552
553fn encoding_table_by_name(name: &[u8]) -> Option<&'static [&'static str; 256]> {
554    match name {
555        b"WinAnsiEncoding" => Some(&WINANSI_ENCODING),
556        b"MacRomanEncoding" => Some(&MACROMAN_ENCODING),
557        b"StandardEncoding" => Some(&STANDARD_ENCODING),
558        _ => None,
559    }
560}
561
562/// Load a fallback font (Helvetica/NimbusSans) for when no font resource exists.
563pub fn fallback_font(font_provider: Option<&FontProvider>) -> Option<PdfFont> {
564    let encoding: [Option<String>; 256] = std::array::from_fn(|i| {
565        WINANSI_ENCODING.get(i).and_then(|&s| {
566            if s.is_empty() {
567                None
568            } else {
569                Some(s.to_string())
570            }
571        })
572    });
573    let widths = super::standard_fonts::standard_font_widths(b"Helvetica").unwrap_or([0.0f64; 256]);
574    substitute_font(
575        "Helvetica",
576        encoding,
577        widths,
578        false,
579        font_provider,
580        0,
581        0,
582        255,
583    )
584}
585
586/// Try to load a substitute font for a non-embedded font.
587/// Load a predefined CMap file by searching multiple locations.
588///
589/// Search order:
590/// 1. `STET_CMAP_DIR` environment variable (flat directory of CMap files)
591/// 2. `~/.local/share/stet/CMap/` (user-local conventional location)
592/// 3. System poppler-data directories (per-collection subdirs)
593/// 4. System GhostScript directories
594fn load_predefined_cmap(name: &[u8]) -> Option<Vec<u8>> {
595    let name_str = std::str::from_utf8(name).ok()?;
596
597    // 1. User-specified directory via environment variable
598    if let Ok(dir) = std::env::var("STET_CMAP_DIR") {
599        let path = format!("{}/{}", dir, name_str);
600        if let Ok(data) = std::fs::read(&path) {
601            return Some(data);
602        }
603    }
604
605    // 2. User-local conventional location (~/.local/share/stet/CMap/)
606    if let Some(home) = std::env::var_os("HOME").or_else(|| std::env::var_os("USERPROFILE")) {
607        let path = std::path::Path::new(&home)
608            .join(".local/share/stet/CMap")
609            .join(name_str);
610        if let Ok(data) = std::fs::read(&path) {
611            return Some(data);
612        }
613    }
614
615    // 3. System poppler-data directories (Linux/macOS)
616    // poppler organizes CMaps in per-collection subdirs (Adobe-GB1/, Adobe-Japan1/, etc.)
617    let poppler_dirs = [
618        "/usr/share/poppler/cMap",
619        "/usr/local/share/poppler/cMap",
620        "/opt/homebrew/share/poppler/cMap", // macOS Homebrew ARM
621        "/usr/local/opt/poppler-data/share/poppler/cMap", // macOS Homebrew Intel
622    ];
623    let collections = [
624        "Adobe-GB1",
625        "Adobe-CNS1",
626        "Adobe-Japan1",
627        "Adobe-Japan2",
628        "Adobe-Korea1",
629        "Adobe-KR",
630    ];
631    for base in &poppler_dirs {
632        for collection in &collections {
633            let path = format!("{}/{}/{}", base, collection, name_str);
634            if let Ok(data) = std::fs::read(&path) {
635                return Some(data);
636            }
637        }
638    }
639
640    // 4. GhostScript directories (flat CMap dirs)
641    let gs_dirs = [
642        "/var/lib/ghostscript/CMap",
643        "/usr/share/ghostscript/Resource/CMap",
644        "/usr/local/share/ghostscript/Resource/CMap",
645    ];
646    for dir in &gs_dirs {
647        let path = format!("{}/{}", dir, name_str);
648        if let Ok(data) = std::fs::read(&path) {
649            return Some(data);
650        }
651    }
652
653    None
654}
655
656fn substitute_font(
657    base_font: &str,
658    encoding: [Option<String>; 256],
659    widths: [f64; 256],
660    has_pdf_widths: bool,
661    font_provider: Option<&FontProvider>,
662    descriptor_flags: u32,
663    first_char: usize,
664    last_char: usize,
665) -> Option<PdfFont> {
666    use stet_fonts::FONT_SUBSTITUTIONS;
667
668    // Strip subset prefix (e.g. "ABCDEF+Times-Roman" → "Times-Roman")
669    let mut clean_name: &str = base_font;
670    if clean_name.len() > 7 && clean_name.as_bytes().get(6) == Some(&b'+') {
671        clean_name = &clean_name[7..];
672    }
673    // Strip trailing "*N" suffix (e.g. "ArialMT*1" → "ArialMT")
674    if let Some(star_pos) = clean_name.rfind('*') {
675        clean_name = &clean_name[..star_pos];
676    }
677
678    // Look up substitution (exact match first, then fuzzy family match)
679    let urw_name = FONT_SUBSTITUTIONS
680        .iter()
681        .find(|&&(ps, _)| ps == clean_name)
682        .map(|&(_, urw)| urw)
683        .or_else(|| fuzzy_font_match(clean_name));
684
685    let font_file_name = urw_name.unwrap_or(clean_name);
686
687    // Try the font provider first (for WASM and other non-filesystem environments)
688    let font_data = if let Some(provider) = font_provider {
689        provider(font_file_name)
690    } else {
691        None
692    };
693
694    // Try system font (full glyph set) before bundled subset
695    let font_data = font_data.or_else(|| {
696        let cache = stet_fonts::system_fonts::get_system_font_cache();
697        let path = cache.get_font_path(font_file_name)?;
698        read_font_file(path, font_file_name).ok()
699    });
700
701    // Fall back to bundled subset font
702    let font_data = font_data.or_else(|| embedded_font(font_file_name));
703
704    // If the named font wasn't found, use a default substitute based on the
705    // font descriptor Flags (serif bit) and weight/style from the font name.
706    let font_data = font_data.or_else(|| {
707        let lower = clean_name.to_ascii_lowercase();
708        let is_bold = lower.contains("bold")
709            || lower.contains("demi")
710            || lower.contains("black")
711            || lower.contains("heavy");
712        let is_italic = lower.contains("italic") || lower.contains("oblique");
713        let is_serif = descriptor_flags & 2 != 0; // PDF flag bit 2 = Serif
714        let default_name = if is_serif {
715            match (is_bold, is_italic) {
716                (true, true) => "NimbusRoman-BoldItalic",
717                (true, false) => "NimbusRoman-Bold",
718                (false, true) => "NimbusRoman-Italic",
719                (false, false) => "NimbusRoman-Regular",
720            }
721        } else {
722            match (is_bold, is_italic) {
723                (true, true) => "NimbusSans-BoldItalic",
724                (true, false) => "NimbusSans-Bold",
725                (false, true) => "NimbusSans-Italic",
726                (false, false) => "NimbusSans-Regular",
727            }
728        };
729        if let Some(provider) = font_provider {
730            if let Some(data) = provider(default_name) {
731                return Some(data);
732            }
733        }
734        embedded_font(default_name)
735    })?;
736
737    let font = parse_type1(&font_data).ok()?;
738    let fm = font.font_matrix;
739    let mut font_matrix = Matrix::new(fm[0], fm[1], fm[2], fm[3], fm[4], fm[5]);
740    let mut per_char_scale = false;
741
742    // If the PDF didn't provide an explicit /Widths array, derive widths from
743    // the substitute font's charstrings. Standard 14 font fallback tables are
744    // indexed by StandardEncoding and give wrong widths for other encodings
745    // (WinAnsiEncoding, MacRomanEncoding, or custom /Differences).
746    let widths = if !has_pdf_widths {
747        let mut derived = [0.0f64; 256];
748        // Get .notdef width as fallback for unmapped codes
749        let notdef_width = font
750            .charstrings
751            .get(".notdef")
752            .and_then(|cs| execute_charstring(cs, &font.subrs, font.len_iv, false).ok())
753            .map(|r| r.width_x * fm[0])
754            .unwrap_or(0.0);
755        for code in 0..256usize {
756            let glyph_name = encoding[code].as_deref().unwrap_or(".notdef");
757            if let Some(cs) = font.charstrings.get(glyph_name) {
758                if let Ok(result) = execute_charstring(cs, &font.subrs, font.len_iv, false) {
759                    // Width is in glyph space; scale by font matrix to get text space
760                    derived[code] = result.width_x * fm[0];
761                }
762            } else {
763                // Glyph not found — use .notdef width
764                derived[code] = notdef_width;
765            }
766        }
767        derived
768    } else {
769        // When the substitute font's glyph widths differ significantly from the
770        // PDF's expected widths, scale glyph outlines horizontally to match.
771        // This handles narrow/condensed variants AND unembedded decorative fonts
772        // (e.g. Spumoni) where the substitute (NimbusSans) has wider glyphs.
773        //
774        // Skip for symbol/dingbat fonts — their glyph shapes are completely
775        // unrelated to the text glyphs in the substitute, so a global width
776        // ratio would just stretch the wrong glyphs.
777        let is_symbol_font = {
778            let lower = clean_name.to_ascii_lowercase();
779            lower.contains("wingding") || lower.contains("webding") || lower.contains("dingbat")
780        };
781        if !is_symbol_font {
782            let mut pdf_sum = 0.0;
783            let mut sub_sum = 0.0;
784            let mut count = 0;
785            // Only compare widths within the PDF's /Widths range [FirstChar, LastChar].
786            // Characters outside this range may have /MissingWidth values that don't
787            // represent real glyph usage and would skew the scaling ratio.
788            for code in first_char..=last_char.min(255) {
789                let pdf_w = widths[code];
790                if pdf_w <= 0.0 {
791                    continue;
792                }
793                let glyph_name = match encoding[code].as_deref() {
794                    Some(n) if n != ".notdef" && n != "space" => n,
795                    _ => continue,
796                };
797                if let Some(cs) = font.charstrings.get(glyph_name)
798                    && let Ok(result) = execute_charstring(cs, &font.subrs, font.len_iv, false)
799                {
800                    let sub_w = result.width_x * fm[0];
801                    if sub_w > 0.0 {
802                        let ratio = pdf_w / sub_w;
803                        // Skip mismatched entries (likely unused encoding slots)
804                        if ratio > 0.5 && ratio < 2.0 {
805                            pdf_sum += pdf_w;
806                            sub_sum += sub_w;
807                            count += 1;
808                        }
809                    }
810                }
811            }
812            if count >= 3 && sub_sum > 0.0 && !is_standard_14_alias(clean_name) {
813                // Standard 14 fonts (and their `,Bold`/`,Italic` aliases) use
814                // metric-compatible URW Nimbus substitutes by design — the
815                // substitute's natural glyph widths already track the original
816                // closely. Skipping the rescale here matches Acrobat / PDF.js
817                // behavior (PDF.js bug 1671312 / PR #12725): PDFs that author
818                // `Tc` and `TJ` kerning against the original font's natural
819                // widths render correctly only when the substitute is left at
820                // its natural metrics. Forcing PDF `/Widths` onto the glyph,
821                // either globally via `font_matrix.a` or per-glyph via
822                // `per_char_scale`, breaks both the bug-1671312 case (overlaps
823                // and gaps in "Purposes") and PDFs whose `/Widths` array is
824                // skewed by placeholder values for unused code points.
825                let ratio = pdf_sum / sub_sum;
826                if (ratio - 1.0).abs() > 0.03 {
827                    font_matrix.a *= ratio;
828                    // Non-metric-compatible substitute: enable per-character
829                    // width scaling (e.g. NimbusSans for LucidaSans).
830                    per_char_scale = true;
831                }
832            }
833        }
834        widths
835    };
836
837    // Substitute fonts don't use Multiple Master blending
838    let weight_vector = font.weight_vector.clone();
839
840    Some(PdfFont::Type1(Type1PdfFont {
841        font,
842        encoding,
843        widths,
844        font_matrix,
845        builtin_fallback: false,
846        weight_vector,
847        per_char_width_scale: per_char_scale,
848    }))
849}
850
851/// Returns true if `name` (after normalizing commas to hyphens) is one of the
852/// PDF standard 14 base fonts. Used to suppress per-glyph horizontal squashing
853/// for substitute fonts (PDF.js bug 1671312 / PR #12725): the standard 14
854/// substitutes (URW Nimbus family) are metric-compatible by design, so
855/// rescaling glyphs to the PDF's `/Widths` corrupts text whose `Tc` values
856/// were calibrated against the original font's natural widths.
857///
858/// "Narrow" family members (e.g. ArialNarrow) are deliberately excluded —
859/// they aren't standard 14, so they retain the squashing path because their
860/// substitute (regular-width Arial/Helvetica) genuinely needs to be
861/// horizontally compressed.
862fn is_standard_14_alias(name: &str) -> bool {
863    let normalized = name.replace(',', "-");
864    matches!(
865        normalized.as_str(),
866        "Times-Roman"
867            | "Times-Bold"
868            | "Times-Italic"
869            | "Times-BoldItalic"
870            | "Helvetica"
871            | "Helvetica-Bold"
872            | "Helvetica-Oblique"
873            | "Helvetica-BoldOblique"
874            | "Courier"
875            | "Courier-Bold"
876            | "Courier-Oblique"
877            | "Courier-BoldOblique"
878            | "Symbol"
879            | "ZapfDingbats"
880    )
881}
882
883/// Fuzzy font family matching for names not in the substitution table.
884/// Detects common family name patterns and maps to URW equivalents.
885fn fuzzy_font_match(name: &str) -> Option<&'static str> {
886    let lower = name.to_ascii_lowercase();
887    let is_bold = lower.contains("bold") || lower.contains("demi");
888    let is_italic = lower.contains("italic") || lower.contains("oblique");
889
890    // Strip trailing PostScript style suffixes so we match the *family*
891    // name, not a style word. Without this "MetaPlusMedium-Roman" (a
892    // sans-serif) would match the "roman" serif cue below and end up
893    // substituted with NimbusRoman. "Roman" in a PS font name normally
894    // means "regular upright", not "serif".
895    let family = strip_style_suffix(&lower);
896
897    if family.contains("times") || family.contains("serif") {
898        return Some(match (is_bold, is_italic) {
899            (true, true) => "NimbusRoman-BoldItalic",
900            (true, false) => "NimbusRoman-Bold",
901            (false, true) => "NimbusRoman-Italic",
902            (false, false) => "NimbusRoman-Regular",
903        });
904    }
905    if family.contains("helvetica")
906        || family.contains("arial")
907        || family.contains("sans")
908        || family.contains("calibri")
909        || family.contains("verdana")
910        || family.contains("tahoma")
911    {
912        return Some(match (is_bold, is_italic) {
913            (true, true) => "NimbusSans-BoldItalic",
914            (true, false) => "NimbusSans-Bold",
915            (false, true) => "NimbusSans-Italic",
916            (false, false) => "NimbusSans-Regular",
917        });
918    }
919    if family.contains("courier") || family.contains("mono") {
920        return Some(match (is_bold, is_italic) {
921            (true, true) => "NimbusMonoPS-BoldItalic",
922            (true, false) => "NimbusMonoPS-Bold",
923            (false, true) => "NimbusMonoPS-Italic",
924            (false, false) => "NimbusMonoPS-Regular",
925        });
926    }
927    None
928}
929
930/// Strip trailing style words (`-Roman`, `-Regular`, etc.) so family-name
931/// pattern matching sees the family, not the style. Works on an
932/// already-lowercased string. Only touches the very end of the name.
933fn strip_style_suffix(lower: &str) -> &str {
934    // Order matters: longer variants first so "-bookitalic" doesn't match "italic".
935    const SUFFIXES: &[&str] = &[
936        "-roman", " roman", "-regular", " regular", "-medium", " medium", "-book", " book",
937        "-normal", " normal", "-light", " light",
938    ];
939    for suffix in SUFFIXES {
940        if let Some(prefix) = lower.strip_suffix(suffix) {
941            return prefix;
942        }
943    }
944    lower
945}
946
947/// Known CID font substitutions for fonts commonly missing on Linux.
948const CID_FONT_SUBSTITUTIONS: &[(&str, &str)] = &[
949    ("ArialUnicodeMS", "DejaVuSans"),
950    ("Arial", "LiberationSans"),
951    ("Arial,Bold", "LiberationSans-Bold"),
952    ("Arial,BoldItalic", "LiberationSans-BoldItalic"),
953    ("Arial,Italic", "LiberationSans-Italic"),
954    ("Arial-BoldMT", "LiberationSans-Bold"),
955    ("Arial-BoldItalicMT", "LiberationSans-BoldItalic"),
956    ("Arial-ItalicMT", "LiberationSans-Italic"),
957    ("Arial-ItalicMT,Italic", "LiberationSans-Italic"),
958    ("ArialMT", "LiberationSans"),
959    // Arial Black is a heavy-weight sans-serif; Liberation Sans Bold is the
960    // closest substitute with compatible TrueType glyph ordering.
961    ("ArialBlack", "LiberationSans-Bold"),
962    ("ArialBlack,Bold", "LiberationSans-Bold"),
963    ("ArialBlack,Italic", "LiberationSans-BoldItalic"),
964    ("ArialBlack,BoldItalic", "LiberationSans-BoldItalic"),
965    ("Arial-BlackMT", "LiberationSans-Bold"),
966    ("CourierNew", "LiberationMono"),
967    ("CourierNew,Bold", "LiberationMono-Bold"),
968    ("CourierNew,BoldItalic", "LiberationMono-BoldItalic"),
969    ("CourierNew,Italic", "LiberationMono-Italic"),
970    ("CourierNewPS-BoldMT", "LiberationMono-Bold"),
971    ("CourierNewPS-BoldItalicMT", "LiberationMono-BoldItalic"),
972    ("CourierNewPS-ItalicMT", "LiberationMono-Italic"),
973    ("CourierNewPSMT", "LiberationMono"),
974    ("LucidaConsole", "LiberationMono"),
975    ("LucidaConsole,Bold", "LiberationMono-Bold"),
976    ("Calibri", "LiberationSans"),
977    ("Calibri,Bold", "LiberationSans-Bold"),
978    ("Calibri,BoldItalic", "LiberationSans-BoldItalic"),
979    ("Calibri,Italic", "LiberationSans-Italic"),
980    ("CenturyGothic", "LiberationSans"),
981    ("CenturyGothic,Bold", "LiberationSans-Bold"),
982    ("CenturyGothic,BoldItalic", "LiberationSans-BoldItalic"),
983    ("CenturyGothic,Italic", "LiberationSans-Italic"),
984    ("TimesNewRoman", "LiberationSerif"),
985    ("TimesNewRoman,Bold", "LiberationSerif-Bold"),
986    ("TimesNewRoman,BoldItalic", "LiberationSerif-BoldItalic"),
987    ("TimesNewRoman,Italic", "LiberationSerif-Italic"),
988    ("TimesNewRomanPS-BoldMT", "LiberationSerif-Bold"),
989    ("TimesNewRomanPS-BoldItalicMT", "LiberationSerif-BoldItalic"),
990    ("TimesNewRomanPS-ItalicMT", "LiberationSerif-Italic"),
991    ("TimesNewRomanPSMT", "LiberationSerif"),
992    // Japanese CJK fonts → NotoSansCJK (OpenType/CFF, has both ASCII and CJK)
993    ("HeiseiMin-W3", "NotoSansCJKjp-Regular"),
994    ("HeiseiKakuGo-W5", "NotoSansCJKjp-Regular"),
995    ("KozMinPr6N-Regular", "NotoSansCJKjp-Regular"),
996    ("KozGoPr6N-Medium", "NotoSansCJKjp-Regular"),
997    ("MS-Gothic", "NotoSansCJKjp-Regular"),
998    ("MS-Gothic,Bold", "NotoSansCJKjp-Bold"),
999    ("MS-Gothic,Italic", "NotoSansCJKjp-Regular"),
1000    ("MS-Gothic,BoldItalic", "NotoSansCJKjp-Bold"),
1001    ("MS-PGothic", "NotoSansCJKjp-Regular"),
1002    ("MS-PGothic,Bold", "NotoSansCJKjp-Bold"),
1003    ("MS-PGothic,Italic", "NotoSansCJKjp-Regular"),
1004    ("MS-PGothic,BoldItalic", "NotoSansCJKjp-Bold"),
1005    ("MS-Mincho", "NotoSansCJKjp-Regular"),
1006    ("MS-Mincho,Bold", "NotoSansCJKjp-Bold"),
1007    ("MS-Mincho,Italic", "NotoSansCJKjp-Regular"),
1008    ("MS-Mincho,BoldItalic", "NotoSansCJKjp-Bold"),
1009    ("MS-PMincho", "NotoSansCJKjp-Regular"),
1010    ("MS-PMincho,Bold", "NotoSansCJKjp-Bold"),
1011    ("MS-PMincho,Italic", "NotoSansCJKjp-Regular"),
1012    ("MS-PMincho,BoldItalic", "NotoSansCJKjp-Bold"),
1013    ("MSGothic", "NotoSansCJKjp-Regular"),
1014    ("MSPGothic", "NotoSansCJKjp-Regular"),
1015    ("MSMincho", "NotoSansCJKjp-Regular"),
1016    ("MSPMincho", "NotoSansCJKjp-Regular"),
1017    // Korean CJK fonts
1018    ("Batang", "NotoSansCJKkr-Regular"),
1019    ("BatangChe", "NotoSansCJKkr-Regular"),
1020    ("Dotum", "NotoSansCJKkr-Regular"),
1021    ("DotumChe", "NotoSansCJKkr-Regular"),
1022    ("Gulim", "NotoSansCJKkr-Regular"),
1023    ("GulimChe", "NotoSansCJKkr-Regular"),
1024    // Chinese Simplified CJK fonts (Adobe standard CID fonts)
1025    // NotoSerifCJKjp contains all CJK glyphs including SC/TC
1026    ("STSongStd-Light", "NotoSerifCJKjp-Regular"),
1027    ("STSong-Light", "NotoSerifCJKjp-Regular"),
1028    ("AdobeSongStd-Light", "NotoSerifCJKjp-Regular"),
1029    ("STFangsong-Light", "NotoSerifCJKjp-Regular"),
1030    ("STHeiti-Regular", "NotoSansCJKjp-Regular"),
1031    ("STKaiti-Regular", "NotoSansCJKjp-Regular"),
1032    ("SimSun", "NotoSerifCJKjp-Regular"),
1033    ("SimSunBold", "NotoSerifCJKjp-Bold"),
1034    ("SimHei", "NotoSansCJKjp-Regular"),
1035    ("FangSong", "NotoSerifCJKjp-Regular"),
1036    ("KaiTi", "NotoSansCJKjp-Regular"),
1037    // Chinese Traditional CJK fonts (Adobe standard CID fonts)
1038    ("MSungStd-Light", "NotoSerifCJKjp-Regular"),
1039    ("MSung-Light", "NotoSerifCJKjp-Regular"),
1040    ("AdobeMingStd-Light", "NotoSerifCJKjp-Regular"),
1041    ("MHei-Medium", "NotoSansCJKjp-Regular"),
1042    ("MingLiU", "NotoSerifCJKjp-Regular"),
1043    ("PMingLiU", "NotoSerifCJKjp-Regular"),
1044];
1045
1046/// Map proportional Unicode code points to CJK full-width variants.
1047///
1048/// Substitute fonts (e.g. NotoSansCJK) may render certain characters as
1049/// proportional (narrow) glyphs, but the original CJK font used full-width
1050/// versions. Return the full-width alternative if one exists.
1051fn cjk_fullwidth_alternative(unicode: u32) -> Option<u32> {
1052    match unicode {
1053        // MIDDLE DOT → KATAKANA MIDDLE DOT (full-width, centered in em square)
1054        0x00B7 => Some(0x30FB),
1055        _ => None,
1056    }
1057}
1058
1059/// Check if an OpenType/CFF font contains a CID-keyed CFF (has ROS operator).
1060fn is_cff_cid_keyed(otf_data: &[u8]) -> bool {
1061    use stet_fonts::truetype::find_table;
1062    let Some((cff_off, cff_len)) = find_table(otf_data, b"CFF ") else {
1063        return false;
1064    };
1065    let cff_data = &otf_data[cff_off..cff_off + cff_len];
1066    match parse_cff(cff_data) {
1067        Ok(fonts) => fonts.first().map_or(false, |f| f.is_cid),
1068        Err(_) => false,
1069    }
1070}
1071
1072/// Create a CidCff font from an OpenType/CFF system font (OTTO magic).
1073/// Extracts the CFF table and builds a CidCffPdfFont.
1074fn create_cid_cff_from_otf(
1075    otf_data: &[u8],
1076    default_width: f64,
1077    cid_widths: HashMap<u16, f64>,
1078    ordering: &[u8],
1079    pdf_cid_to_gid: Option<Vec<u16>>,
1080    identity_cid_to_gid: bool,
1081    code_lengths: [u8; 256],
1082    code_to_cid: HashMap<u32, u32>,
1083    wmode: u8,
1084    dw2: [f64; 2],
1085    w2: HashMap<u16, [f64; 3]>,
1086) -> Result<PdfFont, PdfError> {
1087    use stet_fonts::truetype::find_table;
1088
1089    // Extract CFF table from OpenType font
1090    let (cff_off, cff_len) = find_table(otf_data, b"CFF ")
1091        .ok_or(PdfError::Other("OpenType font has no CFF table".into()))?;
1092    let cff_data = &otf_data[cff_off..cff_off + cff_len];
1093    let fonts =
1094        parse_cff(cff_data).map_err(|e| PdfError::Other(format!("CFF parse error: {e}")))?;
1095    let font = fonts
1096        .into_iter()
1097        .next()
1098        .ok_or(PdfError::Other("CFF contains no fonts".into()))?;
1099    let fm = font.font_matrix;
1100    let font_matrix = Matrix::new(fm[0], fm[1], fm[2], fm[3], fm[4], fm[5]);
1101
1102    // Parse cmap from OTF for Unicode→GID lookup (substitute fonts)
1103    let otf_cmap = parse_cmap(otf_data);
1104    let cmap = if otf_cmap.is_empty() {
1105        None
1106    } else {
1107        Some(otf_cmap)
1108    };
1109
1110    Ok(PdfFont::CidCff(CidCffPdfFont {
1111        font,
1112        default_width,
1113        cid_widths,
1114        font_matrix,
1115        cmap,
1116        pdf_cid_to_gid,
1117        identity_cid_to_gid,
1118        ordering: ordering.to_vec(),
1119        code_lengths,
1120        code_to_cid,
1121        wmode,
1122        dw2,
1123        w2,
1124        type1_paths: None,
1125    }))
1126}
1127
1128/// Detect raw CFF font data (not wrapped in an OpenType container).
1129/// CFF starts with: major=1, minor=0, hdrSize>=4, offSize in 1..=4.
1130fn is_raw_cff(data: &[u8]) -> bool {
1131    data.len() > 4 && data[0] == 1 && data[1] == 0 && data[2] >= 4 && (1..=4).contains(&data[3])
1132}
1133
1134/// Create a CidCff font from raw CFF data (no OpenType wrapper).
1135fn create_cid_cff_from_raw(
1136    cff_data: &[u8],
1137    default_width: f64,
1138    cid_widths: HashMap<u16, f64>,
1139    ordering: &[u8],
1140    pdf_cid_to_gid: Option<Vec<u16>>,
1141    identity_cid_to_gid: bool,
1142    code_lengths: [u8; 256],
1143    code_to_cid: HashMap<u32, u32>,
1144    wmode: u8,
1145    dw2: [f64; 2],
1146    w2: HashMap<u16, [f64; 3]>,
1147) -> Result<PdfFont, PdfError> {
1148    let fonts =
1149        parse_cff(cff_data).map_err(|e| PdfError::Other(format!("CFF parse error: {e}")))?;
1150    let font = fonts
1151        .into_iter()
1152        .next()
1153        .ok_or(PdfError::Other("CFF contains no fonts".into()))?;
1154    let fm = font.font_matrix;
1155    let font_matrix = Matrix::new(fm[0], fm[1], fm[2], fm[3], fm[4], fm[5]);
1156
1157    Ok(PdfFont::CidCff(CidCffPdfFont {
1158        font,
1159        default_width,
1160        cid_widths,
1161        font_matrix,
1162        cmap: None, // Raw CFF has no OTF cmap table
1163        pdf_cid_to_gid,
1164        identity_cid_to_gid,
1165        ordering: ordering.to_vec(),
1166        code_lengths,
1167        code_to_cid,
1168        wmode,
1169        dw2,
1170        w2,
1171        type1_paths: None,
1172    }))
1173}
1174
1175/// Create a CID font from a PostScript CIDFont program (Resource-CIDFont).
1176///
1177/// These contain CIDFontType 0 definitions with binary charstring data after
1178/// `StartData`. The binary data layout: CID map (GDBytes×CIDCount) followed
1179/// by subroutines and charstring data indexed by offsets in the CID map.
1180fn create_cid_from_ps_cidfont(
1181    font_data: &[u8],
1182    default_width: f64,
1183    cid_widths: HashMap<u16, f64>,
1184    code_lengths: [u8; 256],
1185    code_to_cid: HashMap<u32, u32>,
1186    wmode: u8,
1187    dw2: [f64; 2],
1188    w2: HashMap<u16, [f64; 3]>,
1189) -> Result<PdfFont, PdfError> {
1190    let text = String::from_utf8_lossy(font_data);
1191
1192    // Extract key parameters from the PS header
1193    let get_int = |key: &str| -> Option<usize> {
1194        let pat = format!("/{key}");
1195        let idx = text.find(&pat)?;
1196        let rest = &text[idx + pat.len()..];
1197        rest.split_whitespace().next()?.parse().ok()
1198    };
1199
1200    let cid_count = get_int("CIDCount").unwrap_or(0);
1201    let fd_bytes = get_int("FDBytes").unwrap_or(0);
1202    let gd_bytes = get_int("GDBytes").unwrap_or(4);
1203    let subr_map_offset = get_int("SubrMapOffset").unwrap_or(0);
1204    let sd_bytes = get_int("SDBytes").unwrap_or(4);
1205    let subr_count = get_int("SubrCount").unwrap_or(0);
1206    let len_iv = get_int("lenIV").unwrap_or(4) as u16;
1207
1208    // Extract FontMatrix from the FDArray Private dict
1209    let font_matrix = if let Some(fm_idx) = text.find("/FontMatrix") {
1210        let rest = &text[fm_idx..];
1211        if let Some(start) = rest.find('[') {
1212            let end_bracket = rest[start..].find(']').unwrap_or(50) + start;
1213            let vals: Vec<f64> = rest[start + 1..end_bracket]
1214                .split_whitespace()
1215                .filter_map(|s| s.parse().ok())
1216                .collect();
1217            if vals.len() == 6 {
1218                Matrix::new(vals[0], vals[1], vals[2], vals[3], vals[4], vals[5])
1219            } else {
1220                Matrix::new(0.001, 0.0, 0.0, 0.001, 0.0, 0.0)
1221            }
1222        } else {
1223            Matrix::new(0.001, 0.0, 0.0, 0.001, 0.0, 0.0)
1224        }
1225    } else {
1226        Matrix::new(0.001, 0.0, 0.0, 0.001, 0.0, 0.0)
1227    };
1228
1229    // Extract subrs from the Private dict (OtherSubrs / Subrs style)
1230    // For PS CIDFonts, subroutines are in the binary data, not in the PS text.
1231
1232    // Find the binary data after "StartData"
1233    // Format: "(Binary) NNNN StartData<whitespace><binary data>"
1234    // The binary data begins after "StartData" + one whitespace byte.
1235    // We must NOT skip \x00 bytes — they are part of the binary CID map.
1236    let binary_data = {
1237        let sd_marker = b"StartData";
1238        let pos = font_data
1239            .windows(sd_marker.len())
1240            .position(|w| w == sd_marker)
1241            .ok_or(PdfError::Other("PS CIDFont: no StartData found".into()))?;
1242        let after = &font_data[pos + sd_marker.len()..];
1243        // Skip only ASCII whitespace (space, tab, CR, LF) — NOT null bytes
1244        let skip = after
1245            .iter()
1246            .position(|&b| !matches!(b, b' ' | b'\t' | b'\r' | b'\n'))
1247            .unwrap_or(0);
1248        &font_data[pos + sd_marker.len() + skip..]
1249    };
1250
1251    // Parse CID map: CIDCount × (FDBytes + GDBytes) bytes
1252    let entry_size = fd_bytes + gd_bytes;
1253    let cid_map_size = cid_count * entry_size;
1254    if binary_data.len() < cid_map_size {
1255        return Err(PdfError::Other(
1256            "PS CIDFont: binary data too short for CID map".into(),
1257        ));
1258    }
1259
1260    // Read charstring offsets for each CID
1261    let read_be = |data: &[u8], off: usize, n: usize| -> usize {
1262        let mut val = 0usize;
1263        for i in 0..n {
1264            if off + i < data.len() {
1265                val = (val << 8) | data[off + i] as usize;
1266            }
1267        }
1268        val
1269    };
1270
1271    let mut cid_offsets: Vec<usize> = Vec::with_capacity(cid_count + 1);
1272    for c in 0..cid_count {
1273        let entry_off = c * entry_size + fd_bytes;
1274        let offset = read_be(binary_data, entry_off, gd_bytes);
1275        cid_offsets.push(offset);
1276    }
1277    // Sentinel: end of last charstring = start of subroutine map
1278    cid_offsets.push(subr_map_offset);
1279
1280    // Parse subroutine offsets
1281    let mut subrs: Vec<Vec<u8>> = Vec::with_capacity(subr_count);
1282    if subr_count > 0 && subr_map_offset + (subr_count + 1) * sd_bytes <= binary_data.len() {
1283        let mut sub_offsets: Vec<usize> = Vec::with_capacity(subr_count + 1);
1284        for i in 0..=subr_count {
1285            let off = read_be(binary_data, subr_map_offset + i * sd_bytes, sd_bytes);
1286            sub_offsets.push(off);
1287        }
1288        for i in 0..subr_count {
1289            let start = sub_offsets[i];
1290            let end = sub_offsets[i + 1];
1291            if start < end && end <= binary_data.len() {
1292                subrs.push(binary_data[start..end].to_vec());
1293            } else {
1294                subrs.push(Vec::new());
1295            }
1296        }
1297    }
1298
1299    // Execute charstrings for each CID that has a width entry
1300    let mut paths = HashMap::new();
1301    for &cid in cid_widths.keys() {
1302        let c = cid as usize;
1303        if c >= cid_count {
1304            continue;
1305        }
1306        let cs_start = cid_offsets[c];
1307        let cs_end = cid_offsets[c + 1];
1308        if cs_start >= cs_end || cs_end > binary_data.len() {
1309            continue;
1310        }
1311        let charstring = &binary_data[cs_start..cs_end];
1312        if let Ok(result) = execute_charstring(charstring, &subrs, len_iv.into(), false) {
1313            let path = result.path.transform(&font_matrix);
1314            paths.insert(cid, path);
1315        }
1316    }
1317
1318    // Create dummy CffFont with pre-computed paths
1319    let dummy_cff = stet_fonts::cff_parser::CffFont {
1320        name: String::new(),
1321        font_matrix: [
1322            font_matrix.a,
1323            font_matrix.b,
1324            font_matrix.c,
1325            font_matrix.d,
1326            font_matrix.tx,
1327            font_matrix.ty,
1328        ],
1329        font_bbox: [0.0; 4],
1330        char_strings: Vec::new(),
1331        global_subrs: Vec::new(),
1332        local_subrs: Vec::new(),
1333        charset: Vec::new(),
1334        encoding: Vec::new(),
1335        default_width_x: 0.0,
1336        nominal_width_x: 0.0,
1337        is_cid: true,
1338        fd_array: Vec::new(),
1339        fd_select: Vec::new(),
1340        ros: None,
1341        cid_to_gid: Vec::new(),
1342    };
1343
1344    Ok(PdfFont::CidCff(CidCffPdfFont {
1345        font: dummy_cff,
1346        default_width,
1347        cid_widths,
1348        font_matrix,
1349        cmap: None,
1350        pdf_cid_to_gid: None,
1351        identity_cid_to_gid: true,
1352        ordering: Vec::new(),
1353        code_lengths,
1354        code_to_cid,
1355        wmode,
1356        dw2,
1357        w2,
1358        type1_paths: Some(paths),
1359    }))
1360}
1361
1362/// Create a CID font from Type 1 font data mislabeled as CIDFontType0.
1363///
1364/// Parses the Type 1 font, maps each CID to a glyph name via ToUnicode + AGL,
1365/// executes the charstring, and stores pre-computed paths in a CidCffPdfFont.
1366fn create_cid_from_type1(
1367    font_data: &[u8],
1368    default_width: f64,
1369    cid_widths: HashMap<u16, f64>,
1370    _to_unicode: &HashMap<u16, u32>,
1371    code_lengths: [u8; 256],
1372    code_to_cid: HashMap<u32, u32>,
1373    wmode: u8,
1374    dw2: [f64; 2],
1375    w2: HashMap<u16, [f64; 3]>,
1376) -> Result<PdfFont, PdfError> {
1377    let font =
1378        parse_type1(font_data).map_err(|e| PdfError::Other(format!("Type1 parse error: {e}")))?;
1379    let fm = font.font_matrix;
1380    let font_matrix = Matrix::new(fm[0], fm[1], fm[2], fm[3], fm[4], fm[5]);
1381
1382    // Build CID → glyph path mapping.
1383    // The CID directly indexes the Type 1 font's built-in encoding:
1384    // CID N → encoding[N] → glyph name → charstring → path.
1385    let mut paths = HashMap::new();
1386    for (&cid, _) in &cid_widths {
1387        let glyph_name = if (cid as usize) < font.encoding.len() {
1388            font.encoding[cid as usize].as_str()
1389        } else {
1390            ".notdef"
1391        };
1392        if let Some(cs) = font.charstrings.get(glyph_name) {
1393            if let Ok(result) = execute_charstring(cs, &font.subrs, font.len_iv, false) {
1394                let path = result.path.transform(&font_matrix);
1395                paths.insert(cid, path);
1396            }
1397        }
1398    }
1399    // Also map CIDs that are in the encoding but not in /W
1400    for (code, name) in font.encoding.iter().enumerate() {
1401        let cid = code as u16;
1402        if paths.contains_key(&cid) {
1403            continue;
1404        }
1405        {
1406            let name = name.as_str();
1407            if name != ".notdef" {
1408                if let Some(cs) = font.charstrings.get(name) {
1409                    if let Ok(result) = execute_charstring(cs, &font.subrs, font.len_iv, false) {
1410                        let path = result.path.transform(&font_matrix);
1411                        paths.insert(cid, path);
1412                    }
1413                }
1414            }
1415        }
1416    }
1417
1418    // Create a dummy CffFont — the type1_paths field will be used instead
1419    let dummy_cff = stet_fonts::cff_parser::CffFont {
1420        name: font.font_name.clone(),
1421        font_matrix: fm,
1422        font_bbox: [0.0; 4],
1423        char_strings: Vec::new(),
1424        global_subrs: Vec::new(),
1425        local_subrs: Vec::new(),
1426        charset: Vec::new(),
1427        encoding: Vec::new(),
1428        default_width_x: 0.0,
1429        nominal_width_x: 0.0,
1430        is_cid: false,
1431        fd_array: Vec::new(),
1432        fd_select: Vec::new(),
1433        ros: None,
1434        cid_to_gid: Vec::new(),
1435    };
1436
1437    Ok(PdfFont::CidCff(CidCffPdfFont {
1438        font: dummy_cff,
1439        default_width,
1440        cid_widths,
1441        font_matrix,
1442        cmap: None,
1443        pdf_cid_to_gid: None,
1444        identity_cid_to_gid: true,
1445        ordering: Vec::new(),
1446        code_lengths,
1447        code_to_cid,
1448        wmode,
1449        dw2,
1450        w2,
1451        type1_paths: Some(paths),
1452    }))
1453}
1454
1455/// Fix malformed `head.indexToLocFormat` in embedded TrueType fonts.
1456///
1457/// Some PDF generators write invalid values (e.g. 256 instead of 0 or 1).
1458/// Skrifa checks `== 1` for long format, so any value other than 0 or 1
1459/// causes it to use short format incorrectly. Determine the correct format
1460/// from the loca table size and patch the head table in-place.
1461fn sanitize_index_to_loc_format(font_data: &mut [u8]) {
1462    use stet_fonts::truetype::{find_table, read_i16, read_u16};
1463
1464    let head = find_table(font_data, b"head");
1465    let loca = find_table(font_data, b"loca");
1466    let maxp = find_table(font_data, b"maxp");
1467    let (head_off, _) = match head {
1468        Some(h) => h,
1469        None => return,
1470    };
1471    if head_off + 52 > font_data.len() {
1472        return;
1473    }
1474    let format = read_i16(font_data, head_off + 50);
1475    if format == 0 || format == 1 {
1476        return; // already valid
1477    }
1478    // Determine correct format from loca table size vs numGlyphs
1479    let correct = if let (Some((_, loca_len)), Some((maxp_off, _))) = (loca, maxp) {
1480        if maxp_off + 6 <= font_data.len() {
1481            let num_glyphs = read_u16(font_data, maxp_off + 4) as usize;
1482            // Long format: (numGlyphs + 1) * 4 bytes
1483            // Short format: (numGlyphs + 1) * 2 bytes
1484            if loca_len == (num_glyphs + 1) * 4 {
1485                1i16 // long
1486            } else {
1487                0i16 // short
1488            }
1489        } else {
1490            if format != 0 { 1 } else { 0 }
1491        }
1492    } else {
1493        if format != 0 { 1 } else { 0 }
1494    };
1495    font_data[head_off + 50] = (correct >> 8) as u8;
1496    font_data[head_off + 51] = correct as u8;
1497}
1498
1499/// Try to load a TrueType font from the system font cache.
1500///
1501/// Used when a CIDFontType2 font is not embedded in the PDF (missing FontFile2).
1502/// Falls back to substitution table and fuzzy name matching.
1503fn load_system_truetype_font(base_font: &str) -> Result<Vec<u8>, PdfError> {
1504    use stet_fonts::system_fonts::get_system_font_cache;
1505
1506    let cache = get_system_font_cache();
1507
1508    // Strip subset prefix (e.g. "ABCDEF+Calibri,Bold" → "Calibri,Bold")
1509    let mut clean_name = base_font;
1510    if clean_name.len() > 7 && clean_name.as_bytes().get(6) == Some(&b'+') {
1511        clean_name = &clean_name[7..];
1512    }
1513
1514    // Try exact match first
1515    if let Some(path) = cache.get_font_path(clean_name)
1516        && let Ok(data) = read_font_file(path, clean_name)
1517    {
1518        return Ok(data);
1519    }
1520
1521    // Try known substitutions
1522    for &(from, to) in CID_FONT_SUBSTITUTIONS {
1523        if from == clean_name
1524            && let Some(path) = cache.get_font_path(to)
1525            && let Ok(data) = read_font_file(path, to)
1526        {
1527            return Ok(data);
1528        }
1529    }
1530
1531    // Fuzzy family match — split on '-' or ',' to extract family name
1532    let lower = clean_name.to_ascii_lowercase();
1533    let is_bold = lower.contains("bold") || lower.contains("demi");
1534    let is_italic = lower.contains("italic") || lower.contains("oblique");
1535
1536    for (ps_name, path) in cache.iter() {
1537        let ps_lower = ps_name.to_ascii_lowercase();
1538        let family = lower.split(&['-', ','][..]).next().unwrap_or(&lower);
1539        if ps_lower.contains(family) || family.contains(ps_lower.split('-').next().unwrap_or("")) {
1540            let name_bold = ps_lower.contains("bold") || ps_lower.contains("demi");
1541            let name_italic = ps_lower.contains("italic") || ps_lower.contains("oblique");
1542            if name_bold == is_bold
1543                && name_italic == is_italic
1544                && let Ok(data) = read_font_file(path, ps_name)
1545            {
1546                return Ok(data);
1547            }
1548        }
1549    }
1550
1551    Err(PdfError::Other(format!(
1552        "font '{}' not found on system",
1553        clean_name
1554    )))
1555}
1556
1557/// Fallback for CID fonts whose names can't be resolved (e.g. GBK-encoded
1558/// native names like 黑体). Uses the CIDSystemInfo Ordering to select
1559/// the appropriate Noto CJK regional variant. For non-CJK orderings
1560/// (Identity), falls back to a Latin sans-serif font instead.
1561fn load_cjk_fallback_font(ordering: &[u8], base_font: &str) -> Result<Vec<u8>, PdfError> {
1562    use stet_fonts::system_fonts::get_system_font_cache;
1563
1564    if ordering.is_empty() {
1565        return Err(PdfError::Other("no CJK ordering for fallback".into()));
1566    }
1567
1568    let cache = get_system_font_cache();
1569    let lower = base_font.to_ascii_lowercase();
1570    let is_bold = lower.contains("bold") || lower.contains("demi") || lower.contains("black");
1571
1572    // For "Identity" ordering, check whether the font name indicates a CJK
1573    // font. If so, fall through to the CJK lookup path instead of using a
1574    // Latin fallback that can't render CJK characters.
1575    // Note: "gothic" needs special handling — it appears in CJK fonts
1576    // (MSGothic, MS-Gothic, IPAGothic) but also Western fonts (CenturyGothic,
1577    // FranklinGothic). Only match when preceded by a non-letter (word boundary).
1578    let has_cjk_gothic = {
1579        if let Some(pos) = lower.find("gothic") {
1580            pos == 0 || !lower.as_bytes()[pos - 1].is_ascii_alphabetic()
1581        } else {
1582            false
1583        }
1584    };
1585    let is_cjk_name = has_cjk_gothic
1586        || [
1587            "cn", "sc", "jp", "kr", "tc", "hk", "cjk", "ming", "song", "hei", "kai", "fang", "han",
1588        ]
1589        .iter()
1590        .any(|kw| lower.contains(kw));
1591    if ordering == b"Identity" && !is_cjk_name {
1592        let latin_targets: &[&str] = if is_bold {
1593            &["LiberationSans-Bold", "DejaVuSans-Bold"]
1594        } else {
1595            &["LiberationSans", "DejaVuSans"]
1596        };
1597        for &target in latin_targets {
1598            if let Some(path) = cache.get_font_path(target)
1599                && let Ok(data) = read_font_file(path, target)
1600            {
1601                return Ok(data);
1602            }
1603        }
1604        return Err(PdfError::Other(format!(
1605            "Latin fallback font not found for '{}'",
1606            base_font
1607        )));
1608    }
1609
1610    // Noto CJK .ttc files contain JP/SC/TC/HK/KR sub-fonts with different
1611    // GID orderings. Select the variant matching the font name or ordering
1612    // so GIDs are compatible with the original font.
1613    let lang = if lower.contains("cn") || lower.contains("sc") || ordering == b"GB1" {
1614        "sc"
1615    } else if lower.contains("tw") || lower.contains("tc") || ordering == b"CNS1" {
1616        "tc"
1617    } else if lower.contains("kr") || ordering == b"Korea1" {
1618        "kr"
1619    } else if lower.contains("hk") {
1620        "hk"
1621    } else {
1622        "jp" // default: Japan1 or unknown
1623    };
1624    let heavy = lower.contains("heavy") || lower.contains("black");
1625    // Try weight-matched variant first, then regular/bold fallback
1626    let weight_suffix = if heavy {
1627        "Black"
1628    } else if is_bold {
1629        "Bold"
1630    } else {
1631        "Regular"
1632    };
1633    let targets = [
1634        format!("NotoSansCJK{lang}-{weight_suffix}"),
1635        if is_bold || heavy {
1636            format!("NotoSansCJK{lang}-Bold")
1637        } else {
1638            format!("NotoSansCJK{lang}-Regular")
1639        },
1640        format!("NotoSansCJKjp-{weight_suffix}"),
1641    ];
1642    for target in &targets {
1643        if let Some(path) = cache.get_font_path(target)
1644            && let Ok(data) = read_font_file(path, target)
1645        {
1646            return Ok(data);
1647        }
1648    }
1649
1650    Err(PdfError::Other(format!(
1651        "CJK fallback font not found on system for '{}'",
1652        base_font
1653    )))
1654}
1655
1656/// Embedded Type 1 substitute fonts (URW families).
1657/// Compiled into the binary so the PDF reader works from any directory.
1658const EMBEDDED_FONTS: &[(&str, &[u8])] = &[
1659    // NimbusRoman (Times)
1660    (
1661        "NimbusRoman-Regular",
1662        include_bytes!("../../fonts/NimbusRoman-Regular.t1"),
1663    ),
1664    (
1665        "NimbusRoman-Bold",
1666        include_bytes!("../../fonts/NimbusRoman-Bold.t1"),
1667    ),
1668    (
1669        "NimbusRoman-Italic",
1670        include_bytes!("../../fonts/NimbusRoman-Italic.t1"),
1671    ),
1672    (
1673        "NimbusRoman-BoldItalic",
1674        include_bytes!("../../fonts/NimbusRoman-BoldItalic.t1"),
1675    ),
1676    // NimbusSans (Helvetica/Arial)
1677    (
1678        "NimbusSans-Regular",
1679        include_bytes!("../../fonts/NimbusSans-Regular.t1"),
1680    ),
1681    (
1682        "NimbusSans-Bold",
1683        include_bytes!("../../fonts/NimbusSans-Bold.t1"),
1684    ),
1685    (
1686        "NimbusSans-Italic",
1687        include_bytes!("../../fonts/NimbusSans-Italic.t1"),
1688    ),
1689    (
1690        "NimbusSans-BoldItalic",
1691        include_bytes!("../../fonts/NimbusSans-BoldItalic.t1"),
1692    ),
1693    // NimbusSansNarrow (Helvetica Narrow)
1694    (
1695        "NimbusSansNarrow-Regular",
1696        include_bytes!("../../fonts/NimbusSansNarrow-Regular.t1"),
1697    ),
1698    (
1699        "NimbusSansNarrow-Bold",
1700        include_bytes!("../../fonts/NimbusSansNarrow-Bold.t1"),
1701    ),
1702    (
1703        "NimbusSansNarrow-Oblique",
1704        include_bytes!("../../fonts/NimbusSansNarrow-Oblique.t1"),
1705    ),
1706    (
1707        "NimbusSansNarrow-BoldOblique",
1708        include_bytes!("../../fonts/NimbusSansNarrow-BoldOblique.t1"),
1709    ),
1710    // NimbusMonoPS (Courier)
1711    (
1712        "NimbusMonoPS-Regular",
1713        include_bytes!("../../fonts/NimbusMonoPS-Regular.t1"),
1714    ),
1715    (
1716        "NimbusMonoPS-Bold",
1717        include_bytes!("../../fonts/NimbusMonoPS-Bold.t1"),
1718    ),
1719    (
1720        "NimbusMonoPS-Italic",
1721        include_bytes!("../../fonts/NimbusMonoPS-Italic.t1"),
1722    ),
1723    (
1724        "NimbusMonoPS-BoldItalic",
1725        include_bytes!("../../fonts/NimbusMonoPS-BoldItalic.t1"),
1726    ),
1727    // P052 (Palatino)
1728    ("P052-Roman", include_bytes!("../../fonts/P052-Roman.t1")),
1729    ("P052-Bold", include_bytes!("../../fonts/P052-Bold.t1")),
1730    ("P052-Italic", include_bytes!("../../fonts/P052-Italic.t1")),
1731    (
1732        "P052-BoldItalic",
1733        include_bytes!("../../fonts/P052-BoldItalic.t1"),
1734    ),
1735    // C059 (New Century Schoolbook)
1736    ("C059-Roman", include_bytes!("../../fonts/C059-Roman.t1")),
1737    ("C059-Bold", include_bytes!("../../fonts/C059-Bold.t1")),
1738    ("C059-Italic", include_bytes!("../../fonts/C059-Italic.t1")),
1739    ("C059-BdIta", include_bytes!("../../fonts/C059-BdIta.t1")),
1740    // URWBookman (Bookman)
1741    (
1742        "URWBookman-Light",
1743        include_bytes!("../../fonts/URWBookman-Light.t1"),
1744    ),
1745    (
1746        "URWBookman-Demi",
1747        include_bytes!("../../fonts/URWBookman-Demi.t1"),
1748    ),
1749    (
1750        "URWBookman-LightItalic",
1751        include_bytes!("../../fonts/URWBookman-LightItalic.t1"),
1752    ),
1753    (
1754        "URWBookman-DemiItalic",
1755        include_bytes!("../../fonts/URWBookman-DemiItalic.t1"),
1756    ),
1757    // URWGothic (AvantGarde)
1758    (
1759        "URWGothic-Book",
1760        include_bytes!("../../fonts/URWGothic-Book.t1"),
1761    ),
1762    (
1763        "URWGothic-Demi",
1764        include_bytes!("../../fonts/URWGothic-Demi.t1"),
1765    ),
1766    (
1767        "URWGothic-BookOblique",
1768        include_bytes!("../../fonts/URWGothic-BookOblique.t1"),
1769    ),
1770    (
1771        "URWGothic-DemiOblique",
1772        include_bytes!("../../fonts/URWGothic-DemiOblique.t1"),
1773    ),
1774    // Symbol fonts
1775    (
1776        "StandardSymbolsPS",
1777        include_bytes!("../../fonts/StandardSymbolsPS.t1"),
1778    ),
1779    ("D050000L", include_bytes!("../../fonts/D050000L.t1")),
1780    (
1781        "Z003-MediumItalic",
1782        include_bytes!("../../fonts/Z003-MediumItalic.t1"),
1783    ),
1784];
1785
1786/// Look up an embedded Type 1 substitute font by name.
1787fn embedded_font(name: &str) -> Option<Vec<u8>> {
1788    EMBEDDED_FONTS
1789        .iter()
1790        .find(|(n, _)| *n == name)
1791        .map(|(_, data)| data.to_vec())
1792}
1793
1794/// Read a font file, handling TrueType Collection (.ttc) files by extracting
1795/// the sub-font matching `ps_name` (or the first font if no match found).
1796fn read_font_file(path: &std::path::Path, ps_name: &str) -> std::io::Result<Vec<u8>> {
1797    let data = std::fs::read(path)?;
1798    if data.len() > 12 && &data[0..4] == b"ttcf" {
1799        // TTC: extract the sub-font at the correct offset
1800        let num_fonts = u32::from_be_bytes([data[8], data[9], data[10], data[11]]) as usize;
1801        // Try to find the font matching ps_name by checking each font's name table
1802        let mut best_offset = if num_fonts > 0 {
1803            u32::from_be_bytes([data[12], data[13], data[14], data[15]]) as usize
1804        } else {
1805            0
1806        };
1807        for i in 0..num_fonts {
1808            let off_pos = 12 + i * 4;
1809            if off_pos + 4 > data.len() {
1810                break;
1811            }
1812            let font_offset = u32::from_be_bytes([
1813                data[off_pos],
1814                data[off_pos + 1],
1815                data[off_pos + 2],
1816                data[off_pos + 3],
1817            ]) as usize;
1818            // Check PostScript name in the name table of this sub-font
1819            if let Some(name) = extract_ps_name_at_offset(&data, font_offset)
1820                && name == ps_name
1821            {
1822                best_offset = font_offset;
1823                break;
1824            }
1825        }
1826        // Build a standalone TTF by rewriting the header to point to tables
1827        // at their absolute offsets within the TTC
1828        extract_ttf_from_ttc(&data, best_offset)
1829    } else {
1830        Ok(data)
1831    }
1832}
1833
1834/// Extract the PostScript name from a font at a given offset within TTC data.
1835fn extract_ps_name_at_offset(data: &[u8], offset: usize) -> Option<String> {
1836    use stet_fonts::truetype::read_u16;
1837    // Manually find the 'name' table from the sub-font's table directory
1838    if offset + 12 > data.len() {
1839        return None;
1840    }
1841    let num_tables = read_u16(data, offset + 4) as usize;
1842    let mut name_off = 0usize;
1843    let mut name_len = 0usize;
1844    for i in 0..num_tables {
1845        let entry = offset + 12 + i * 16;
1846        if entry + 16 > data.len() {
1847            break;
1848        }
1849        if &data[entry..entry + 4] == b"name" {
1850            name_off = u32::from_be_bytes([
1851                data[entry + 8],
1852                data[entry + 9],
1853                data[entry + 10],
1854                data[entry + 11],
1855            ]) as usize;
1856            name_len = u32::from_be_bytes([
1857                data[entry + 12],
1858                data[entry + 13],
1859                data[entry + 14],
1860                data[entry + 15],
1861            ]) as usize;
1862            break;
1863        }
1864    }
1865    if name_off == 0 || name_off + name_len > data.len() {
1866        return None;
1867    }
1868    let nd = &data[name_off..name_off + name_len];
1869    let count = read_u16(nd, 2) as usize;
1870    let string_offset = read_u16(nd, 4) as usize;
1871    for i in 0..count {
1872        let rec = 6 + i * 12;
1873        if rec + 12 > nd.len() {
1874            break;
1875        }
1876        let pid = read_u16(nd, rec);
1877        let name_id = read_u16(nd, rec + 6);
1878        let length = read_u16(nd, rec + 8) as usize;
1879        let str_off = read_u16(nd, rec + 10) as usize;
1880        if name_id == 6 {
1881            let start = string_offset + str_off;
1882            if start + length <= nd.len() {
1883                let raw = &nd[start..start + length];
1884                if pid == 3 {
1885                    let s: String = raw
1886                        .chunks(2)
1887                        .filter_map(|c| {
1888                            if c.len() == 2 {
1889                                Some(u16::from_be_bytes([c[0], c[1]]) as u8 as char)
1890                            } else {
1891                                None
1892                            }
1893                        })
1894                        .collect();
1895                    return Some(s);
1896                } else {
1897                    return Some(String::from_utf8_lossy(raw).to_string());
1898                }
1899            }
1900        }
1901    }
1902    None
1903}
1904
1905/// Extract a single TTF from a TTC by building a standalone font file.
1906/// The sub-font header at `font_offset` contains a table directory with
1907/// offsets that are absolute within the TTC. We copy the header + directory
1908/// and then append all referenced table data, adjusting offsets accordingly.
1909fn extract_ttf_from_ttc(ttc_data: &[u8], font_offset: usize) -> std::io::Result<Vec<u8>> {
1910    use stet_fonts::truetype::{read_u16, read_u32};
1911
1912    if font_offset + 12 > ttc_data.len() {
1913        return Err(std::io::Error::other("TTC font offset out of range"));
1914    }
1915
1916    let num_tables = read_u16(ttc_data, font_offset + 4) as usize;
1917    let header_size = 12 + num_tables * 16;
1918
1919    // Collect table info: (tag, ttc_offset, length)
1920    let mut tables = Vec::with_capacity(num_tables);
1921    for i in 0..num_tables {
1922        let entry = font_offset + 12 + i * 16;
1923        if entry + 16 > ttc_data.len() {
1924            break;
1925        }
1926        let tag = &ttc_data[entry..entry + 4];
1927        let offset = read_u32(ttc_data, entry + 8) as usize;
1928        let length = read_u32(ttc_data, entry + 12) as usize;
1929        tables.push((tag.to_vec(), offset, length));
1930    }
1931
1932    // Build standalone TTF: header + directory + table data
1933    let mut result = Vec::with_capacity(
1934        header_size + tables.iter().map(|(_, _, l)| (l + 3) & !3).sum::<usize>(),
1935    );
1936
1937    // Copy the 12-byte sfnt header
1938    result.extend_from_slice(&ttc_data[font_offset..font_offset + 12]);
1939
1940    // First pass: compute new offsets (tables follow directory)
1941    let mut data_offset = header_size as u32;
1942    let mut new_offsets = Vec::with_capacity(num_tables);
1943    for (_, _, length) in &tables {
1944        new_offsets.push(data_offset);
1945        data_offset += ((*length as u32) + 3) & !3; // 4-byte aligned
1946    }
1947
1948    // Write table directory with new offsets
1949    for (i, (tag, _, length)) in tables.iter().enumerate() {
1950        let entry = font_offset + 12 + i * 16;
1951        result.extend_from_slice(tag); // tag
1952        result.extend_from_slice(&ttc_data[entry + 4..entry + 8]); // checksum
1953        result.extend_from_slice(&new_offsets[i].to_be_bytes()); // new offset
1954        result.extend_from_slice(&(*length as u32).to_be_bytes()); // length
1955    }
1956
1957    // Copy table data
1958    for (_, ttc_offset, length) in &tables {
1959        let end = (*ttc_offset + *length).min(ttc_data.len());
1960        if *ttc_offset < ttc_data.len() {
1961            result.extend_from_slice(&ttc_data[*ttc_offset..end]);
1962            // Pad to 4-byte alignment
1963            let pad = (4 - (length % 4)) % 4;
1964            result.extend(std::iter::repeat_n(0u8, pad));
1965        }
1966    }
1967
1968    Ok(result)
1969}
1970
1971/// Resolve a Type 3 font: glyphs defined as content streams.
1972fn resolve_type3(resolver: &Resolver, font_dict: &PdfDict) -> Result<PdfFont, PdfError> {
1973    let first_char = font_dict.get_int(b"FirstChar").unwrap_or(0) as usize;
1974
1975    // Parse widths array (already in glyph space — Type 3 FontMatrix maps to text space).
1976    // /Widths may be an indirect reference — resolve before accessing.
1977    let mut widths = [0.0f64; 256];
1978    let widths_resolved = font_dict
1979        .get(b"Widths")
1980        .and_then(|obj| resolver.deref(obj).ok());
1981    if let Some(ref w_obj) = widths_resolved
1982        && let Some(w_arr) = w_obj.as_array()
1983    {
1984        for (i, obj) in w_arr.iter().enumerate() {
1985            let code = first_char + i;
1986            if code < 256 {
1987                // Width entries may be indirect references
1988                let val = if obj.as_f64().is_some() {
1989                    obj.as_f64().unwrap()
1990                } else if let Ok(resolved) = resolver.deref(obj) {
1991                    resolved.as_f64().unwrap_or(0.0)
1992                } else {
1993                    0.0
1994                };
1995                widths[code] = val;
1996            }
1997        }
1998    }
1999
2000    // FontMatrix (typically something like [0.01 0 0 0.01 0 0] for 100-unit glyph space)
2001    let font_matrix = font_dict
2002        .get_array(b"FontMatrix")
2003        .map(|a| {
2004            let v: Vec<f64> = a.iter().filter_map(|o| o.as_f64()).collect();
2005            if v.len() >= 6 {
2006                Matrix::new(v[0], v[1], v[2], v[3], v[4], v[5])
2007            } else {
2008                Matrix::new(0.001, 0.0, 0.0, 0.001, 0.0, 0.0)
2009            }
2010        })
2011        .unwrap_or_else(|| Matrix::new(0.001, 0.0, 0.0, 0.001, 0.0, 0.0));
2012
2013    let font_bbox = font_dict
2014        .get_array(b"FontBBox")
2015        .map(|a| {
2016            let v: Vec<f64> = a.iter().filter_map(|o| o.as_f64()).collect();
2017            if v.len() >= 4 {
2018                [v[0], v[1], v[2], v[3]]
2019            } else {
2020                [0.0, 0.0, 1.0, 1.0]
2021            }
2022        })
2023        .unwrap_or([0.0, 0.0, 1.0, 1.0]);
2024
2025    // Resolve encoding: maps char codes → glyph names in CharProcs
2026    let (encoding, _, _, _) = resolve_encoding(font_dict, resolver)?;
2027
2028    // Get CharProcs dict: maps glyph names → content streams
2029    // May be a direct dict or an indirect reference
2030    let char_procs_dict = if let Some(obj) = font_dict.get(b"CharProcs") {
2031        match resolver.deref(obj)? {
2032            PdfObj::Dict(d) => d,
2033            _ => return Err(PdfError::Other("Type3 CharProcs is not a dict".into())),
2034        }
2035    } else {
2036        return Err(PdfError::Other("Type3 font missing CharProcs".into()));
2037    };
2038
2039    // Resources for interpreting CharProc streams
2040    let resources = if let Some(res_ref) = font_dict.get(b"Resources") {
2041        match resolver.deref(res_ref)? {
2042            PdfObj::Dict(d) => d,
2043            _ => PdfDict::new(),
2044        }
2045    } else {
2046        PdfDict::new()
2047    };
2048
2049    // Pre-decode all CharProc streams: encoding[code] → stream bytes
2050    let mut char_procs = HashMap::new();
2051    for code in 0..256u16 {
2052        if let Some(glyph_name) = &encoding[code as usize]
2053            && let Some(proc_ref) = char_procs_dict.get(glyph_name.as_bytes())
2054            && let Ok(data) = resolver.stream_data_from_obj(proc_ref)
2055        {
2056            char_procs.insert(code as u8, data);
2057        }
2058    }
2059    Ok(PdfFont::Type3(Type3PdfFont {
2060        char_procs,
2061        resources,
2062        widths,
2063        font_matrix,
2064        font_bbox,
2065    }))
2066}
2067
2068fn resolve_type1(
2069    resolver: &Resolver,
2070    descriptor: &Option<PdfDict>,
2071    encoding: [Option<String>; 256],
2072    widths: [f64; 256],
2073    has_explicit_encoding: bool,
2074    has_pdf_widths: bool,
2075    differences: &[(usize, String)],
2076    no_base_encoding: bool,
2077) -> Result<PdfFont, PdfError> {
2078    let desc = descriptor
2079        .as_ref()
2080        .ok_or(PdfError::Other("Type1 font missing FontDescriptor".into()))?;
2081    // Check FontFile first (traditional Type 1), then FontFile3 (CFF or Type1C)
2082    if let Some(ff3_ref) = desc.get(b"FontFile3") {
2083        // FontFile3 may contain CFF (Type1C) data — handle via CFF parser
2084        let ff3_obj = resolver.deref(ff3_ref)?;
2085        let ff3_dict = ff3_obj.as_dict();
2086        let subtype = ff3_dict.and_then(|d| d.get_name(b"Subtype")).unwrap_or(b"");
2087        if subtype == b"Type1C" || subtype == b"CIDFontType0C" || subtype == b"OpenType" {
2088            let raw_data = resolver.stream_data_from_obj(ff3_ref)?;
2089            // If data starts with "OTTO" it's an OpenType container — extract CFF table
2090            let font_data = if raw_data.starts_with(b"OTTO") {
2091                use stet_fonts::truetype::find_table;
2092                let (offset, length) = find_table(&raw_data, b"CFF ")
2093                    .ok_or(PdfError::Other("OpenType font has no CFF table".into()))?;
2094                raw_data[offset..offset + length].to_vec()
2095            } else {
2096                raw_data
2097            };
2098            let fonts = parse_cff(&font_data)
2099                .map_err(|e| PdfError::Other(format!("CFF parse error: {e}")))?;
2100            let font = fonts
2101                .into_iter()
2102                .next()
2103                .ok_or(PdfError::Other("CFF contains no fonts".into()))?;
2104
2105            let fm = font.font_matrix;
2106            let font_matrix = Matrix::new(fm[0], fm[1], fm[2], fm[3], fm[4], fm[5]);
2107
2108            return Ok(PdfFont::Cff(CffPdfFont {
2109                font,
2110                encoding,
2111                widths,
2112                font_matrix,
2113            }));
2114        }
2115    }
2116
2117    let ff_ref = desc
2118        .get(b"FontFile")
2119        .or_else(|| desc.get(b"FontFile3"))
2120        .ok_or(PdfError::Other("Type1 font missing FontFile".into()))?;
2121    let font_data = resolver.stream_data_from_obj(ff_ref)?;
2122
2123    // Strip PFB (Printer Font Binary) headers if present
2124    let font_data = strip_pfb(&font_data);
2125
2126    let font =
2127        parse_type1(&font_data).map_err(|e| PdfError::Other(format!("Type1 parse error: {e}")))?;
2128
2129    // PDF spec 9.6.6.1: encoding base depends on whether the font is embedded.
2130    // When the /Encoding dict has no /BaseEncoding, the base for embedded fonts
2131    // is the font's built-in encoding (not StandardEncoding). This matters for
2132    // expert fonts where e.g. code 97 = "Asmall", not "a".
2133    let encoding = if no_base_encoding && font.encoding.len() == 256 {
2134        // Encoding dict had no BaseEncoding. For embedded fonts,
2135        // the base is the font's built-in encoding, not StandardEncoding.
2136        let mut builtin: [Option<String>; 256] = std::array::from_fn(|_| None);
2137        for (i, name) in font.encoding.iter().enumerate() {
2138            if name != ".notdef" {
2139                builtin[i] = Some(name.clone());
2140            }
2141        }
2142        for (code, name) in differences {
2143            if *code < 256 {
2144                builtin[*code] = Some(name.clone());
2145            }
2146        }
2147        builtin
2148    } else if !has_explicit_encoding {
2149        let flags = desc.get_int(b"Flags").unwrap_or(0) as u32;
2150        let is_symbolic = flags & 4 != 0;
2151        if is_symbolic && font.encoding.len() == 256 {
2152            let mut builtin: [Option<String>; 256] = std::array::from_fn(|_| None);
2153            for (i, name) in font.encoding.iter().enumerate() {
2154                if name != ".notdef" {
2155                    builtin[i] = Some(name.clone());
2156                }
2157            }
2158            builtin
2159        } else {
2160            encoding
2161        }
2162    } else {
2163        encoding
2164    };
2165
2166    // Check if the encoding's glyph names are completely incompatible with the
2167    // font's CharStrings (e.g. StandardEncoding "A","B" vs custom "G41","G42").
2168    // If so, enable fallback to the font's built-in encoding at glyph lookup.
2169    let builtin_fallback = {
2170        let flags = desc.get_int(b"Flags").unwrap_or(0) as u32;
2171        let is_sym = flags & 4 != 0;
2172        let builtin_useful =
2173            is_sym && font.encoding.len() == 256 && font.encoding.iter().any(|n| n != ".notdef");
2174        if builtin_useful {
2175            !encoding[32..127].iter().any(|slot| {
2176                slot.as_ref()
2177                    .is_some_and(|name| font.charstrings.contains_key(name.as_str()))
2178            })
2179        } else {
2180            false
2181        }
2182    };
2183
2184    let fm = font.font_matrix;
2185    let font_matrix = Matrix::new(fm[0], fm[1], fm[2], fm[3], fm[4], fm[5]);
2186
2187    // When the PDF has no /Widths array, derive widths from the Type 1 charstrings.
2188    let widths = if !has_pdf_widths {
2189        let mut derived = [0.0f64; 256];
2190        for code in 0..256usize {
2191            let glyph_name = encoding[code].as_deref().unwrap_or(".notdef");
2192            if glyph_name == ".notdef" {
2193                continue;
2194            }
2195            if let Some(charstring) = font.charstrings.get(glyph_name) {
2196                let cs_lookup =
2197                    |name: &str| -> Option<Vec<u8>> { font.charstrings.get(name).cloned() };
2198                if let Ok(result) = execute_charstring_mm(
2199                    charstring,
2200                    &font.subrs,
2201                    font.len_iv,
2202                    false,
2203                    Some(&cs_lookup),
2204                    font.weight_vector.as_deref(),
2205                ) {
2206                    derived[code] = result.width_x * fm[0];
2207                }
2208            }
2209        }
2210        derived
2211    } else {
2212        widths
2213    };
2214
2215    let weight_vector = font.weight_vector.clone();
2216    Ok(PdfFont::Type1(Type1PdfFont {
2217        font,
2218        encoding,
2219        widths,
2220        font_matrix,
2221        weight_vector,
2222        builtin_fallback,
2223        per_char_width_scale: false,
2224    }))
2225}
2226
2227/// Resolve a TrueType font from its FontDescriptor.
2228/// If the font data has table directory entries pointing past the data,
2229/// try re-decompressing with raw deflate (skipping the zlib header).
2230/// Some fonts have corrupt zlib headers (CINFO < 7) that cause truncation.
2231fn try_raw_deflate_if_truncated(resolver: &Resolver, ff_ref: &PdfObj, data: Vec<u8>) -> Vec<u8> {
2232    // Check if any table extends past the data
2233    if data.len() < 12 {
2234        return data;
2235    }
2236    let num_tables = u16::from_be_bytes([data[4], data[5]]) as usize;
2237    let mut max_end = 0usize;
2238    for i in 0..num_tables {
2239        let e = 12 + i * 16;
2240        if e + 16 > data.len() {
2241            break;
2242        }
2243        let off =
2244            u32::from_be_bytes([data[e + 8], data[e + 9], data[e + 10], data[e + 11]]) as usize;
2245        let len =
2246            u32::from_be_bytes([data[e + 12], data[e + 13], data[e + 14], data[e + 15]]) as usize;
2247        max_end = max_end.max(off.saturating_add(len));
2248    }
2249    if max_end <= data.len() {
2250        return data; // all tables fit, no truncation
2251    }
2252    // Tables extend past the data — try raw deflate on the stream
2253    let raw_bytes = match resolver.raw_stream_bytes(ff_ref) {
2254        Some(b) if b.len() > 2 => b,
2255        _ => return data,
2256    };
2257    // Only retry if the zlib header has CINFO < 7 (suspect window size)
2258    let cinfo = raw_bytes[0] >> 4;
2259    let cm = raw_bytes[0] & 0xF;
2260    if cm != 8 || cinfo >= 7 {
2261        return data;
2262    }
2263    // Decompress with raw deflate (skip 2-byte zlib header)
2264    let mut decoder = flate2::Decompress::new(false);
2265    let mut output = Vec::with_capacity(data.len() * 2);
2266    let mut buf = [0u8; 8192];
2267    let input = &raw_bytes[2..];
2268    let mut input_offset = 0;
2269    loop {
2270        let before_in = decoder.total_in() as usize;
2271        let before_out = decoder.total_out() as usize;
2272        let result = decoder.decompress(
2273            &input[input_offset..],
2274            &mut buf,
2275            flate2::FlushDecompress::None,
2276        );
2277        let consumed = decoder.total_in() as usize - before_in;
2278        let produced = decoder.total_out() as usize - before_out;
2279        input_offset += consumed;
2280        output.extend_from_slice(&buf[..produced]);
2281        match result {
2282            Ok(flate2::Status::StreamEnd) => break,
2283            Ok(_) => {
2284                if consumed == 0 && produced == 0 {
2285                    break;
2286                }
2287            }
2288            Err(_) => break,
2289        }
2290    }
2291    if output.len() <= data.len() {
2292        return data;
2293    }
2294    // Verify ALL tables fit in the raw output (reject if still truncated)
2295    let mut raw_max_end = 0usize;
2296    for i in 0..num_tables {
2297        let e = 12 + i * 16;
2298        if e + 16 > output.len() {
2299            return data;
2300        }
2301        let off = u32::from_be_bytes([output[e + 8], output[e + 9], output[e + 10], output[e + 11]])
2302            as usize;
2303        let len = u32::from_be_bytes([
2304            output[e + 12],
2305            output[e + 13],
2306            output[e + 14],
2307            output[e + 15],
2308        ]) as usize;
2309        raw_max_end = raw_max_end.max(off.saturating_add(len));
2310    }
2311    if raw_max_end > output.len() {
2312        return data; // raw output still truncated, don't use it
2313    }
2314    // Validate the head table has a plausible unitsPerEm (detects shifted data
2315    // where the head bytes are misaligned and read as zero)
2316    if stet_fonts::truetype::get_units_per_em(&output) == 0 {
2317        return data;
2318    }
2319    output
2320}
2321
2322fn resolve_truetype(
2323    resolver: &Resolver,
2324    descriptor: &Option<PdfDict>,
2325    encoding: [Option<String>; 256],
2326    widths: [f64; 256],
2327    font_dict: &PdfDict,
2328) -> Result<PdfFont, PdfError> {
2329    let desc = descriptor.as_ref().ok_or(PdfError::Other(
2330        "TrueType font missing FontDescriptor".into(),
2331    ))?;
2332    let ff_ref = desc
2333        .get(b"FontFile2")
2334        .ok_or(PdfError::Other("TrueType font missing FontFile2".into()))?;
2335    let data = resolver.stream_data_from_obj(ff_ref)?;
2336
2337    // Some fonts have corrupt zlib headers (CINFO < 7) that cause the zlib
2338    // decompressor to truncate. If key tables are out of bounds, try raw
2339    // deflate decompression which ignores the header.
2340    let data = try_raw_deflate_if_truncated(resolver, ff_ref, data);
2341
2342    // Validate that glyph outline data is actually present
2343    use stet_fonts::truetype::find_table;
2344    let has_glyf = find_table(&data, b"glyf").is_some();
2345    let has_usable_glyx = if let Some((off, len)) = find_table(&data, b"glyx") {
2346        off + len <= data.len()
2347    } else {
2348        false
2349    };
2350    if !has_glyf && !has_usable_glyx {
2351        // Some PDFs store CFF/OpenType fonts as FontFile2 (malformed but common).
2352        // Detect and route to CFF parsing instead of failing.
2353        let is_otf = data.starts_with(b"OTTO");
2354        let is_cff = is_raw_cff(&data);
2355        if is_otf || is_cff {
2356            let has_explicit_encoding = font_dict.get(b"Encoding").is_some();
2357            let has_pdf_widths = font_dict.get(b"Widths").is_some();
2358            return build_cff_font(
2359                data,
2360                encoding,
2361                widths,
2362                has_explicit_encoding,
2363                has_pdf_widths,
2364                &[],
2365                false,
2366            );
2367        }
2368        return Err(PdfError::Other(
2369            "TrueType font has no usable glyph outline data".into(),
2370        ));
2371    }
2372
2373    // Validate essential tables are within bounds. Truncated font data
2374    // (e.g. from corrupt zlib headers) may have table directory entries
2375    // pointing past the decompressed data.
2376    if let Some((off, _)) = find_table(&data, b"head") {
2377        if off + 54 > data.len() {
2378            return Err(PdfError::Other(
2379                "TrueType font head table is out of bounds (truncated data)".into(),
2380            ));
2381        }
2382    }
2383
2384    let units_per_em = get_units_per_em(&data) as f64;
2385
2386    // Reject fonts with degenerate unitsPerEm (< 16). These are dummy subsets
2387    // with placeholder rectangle "glyphs" that produce enormous shapes when
2388    // normalized. Fall through to the substitute font path instead.
2389    if units_per_em < 16.0 {
2390        return Err(PdfError::Other(
2391            "TrueType font has degenerate unitsPerEm (placeholder outlines)".into(),
2392        ));
2393    }
2394
2395    let (cmap, cmap_is_unicode) = parse_cmap_with_info(&data);
2396
2397    // Parse post table (GID → name) and invert to name → GID for fallback lookup
2398    let post_name_to_gid = stet_fonts::system_fonts::parse_post_table(&data)
2399        .map(|gid_to_name| {
2400            gid_to_name
2401                .into_iter()
2402                .map(|(gid, name)| (name, gid))
2403                .collect()
2404        })
2405        .unwrap_or_default();
2406
2407    // Symbolic TrueType fonts without explicit /Encoding use identity mapping
2408    // (char_code = GID). The cmap is often misleading for re-encoded fonts
2409    // (e.g. Tamil glyphs at Latin cmap positions).
2410    let flags = desc.get_int(b"Flags").unwrap_or(0) as u32;
2411    let is_symbolic = flags & 4 != 0;
2412    let has_encoding = font_dict.get(b"Encoding").is_some();
2413    let identity_gid = is_symbolic && !has_encoding && cmap_is_unicode;
2414    let gid_hex = TrueTypePdfFont::detect_gid_hex(&encoding);
2415
2416    Ok(PdfFont::TrueType(TrueTypePdfFont {
2417        data,
2418        encoding,
2419        widths,
2420        cmap,
2421        cmap_is_unicode,
2422        post_name_to_gid,
2423        units_per_em,
2424        to_unicode: if let Some(tu_obj) = font_dict.get(b"ToUnicode") {
2425            resolver
2426                .stream_data_from_obj(tu_obj)
2427                .map(|d| parse_to_unicode(&d))
2428                .unwrap_or_default()
2429        } else {
2430            HashMap::new()
2431        },
2432        identity_gid,
2433        gid_hex,
2434    }))
2435}
2436
2437/// Resolve a CFF (Type1C) font from its FontDescriptor.
2438fn resolve_cff(
2439    resolver: &Resolver,
2440    descriptor: &Option<PdfDict>,
2441    encoding: [Option<String>; 256],
2442    widths: [f64; 256],
2443    has_explicit_encoding: bool,
2444    has_pdf_widths: bool,
2445    differences: &[(usize, String)],
2446    no_base_encoding: bool,
2447) -> Result<PdfFont, PdfError> {
2448    let desc = descriptor
2449        .as_ref()
2450        .ok_or(PdfError::Other("CFF font missing FontDescriptor".into()))?;
2451    let ff_ref = desc
2452        .get(b"FontFile3")
2453        .ok_or(PdfError::Other("CFF font missing FontFile3".into()))?;
2454    let raw_data = resolver.stream_data_from_obj(ff_ref)?;
2455    build_cff_font(
2456        raw_data,
2457        encoding,
2458        widths,
2459        has_explicit_encoding,
2460        has_pdf_widths,
2461        differences,
2462        no_base_encoding,
2463    )
2464}
2465
2466/// Build a CFF font from raw font data (may be OpenType/CFF or raw CFF).
2467fn build_cff_font(
2468    raw_data: Vec<u8>,
2469    encoding: [Option<String>; 256],
2470    widths: [f64; 256],
2471    has_explicit_encoding: bool,
2472    has_pdf_widths: bool,
2473    differences: &[(usize, String)],
2474    no_base_encoding: bool,
2475) -> Result<PdfFont, PdfError> {
2476    // If data starts with "OTTO" it's an OpenType container — extract CFF table
2477    let font_data = if raw_data.starts_with(b"OTTO") {
2478        use stet_fonts::truetype::find_table;
2479        let (offset, length) = find_table(&raw_data, b"CFF ")
2480            .ok_or(PdfError::Other("OpenType font has no CFF table".into()))?;
2481        raw_data[offset..offset + length].to_vec()
2482    } else {
2483        raw_data
2484    };
2485
2486    let fonts =
2487        parse_cff(&font_data).map_err(|e| PdfError::Other(format!("CFF parse error: {e}")))?;
2488    let font = fonts
2489        .into_iter()
2490        .next()
2491        .ok_or(PdfError::Other("CFF contains no fonts".into()))?;
2492
2493    // PDF spec 9.6.6.1: encoding base depends on whether the font is embedded.
2494    // When the /Encoding dict has no /BaseEncoding, the base for embedded fonts
2495    // is the font's built-in encoding (not StandardEncoding).
2496    // Helper: build encoding from the CFF's built-in encoding table.
2497    // For codes where the CFF encoding maps to a valid GID, use that name.
2498    // For unmapped codes in fonts with Expert charset names (Asmall, etc.),
2499    // fill in from the Expert encoding table — handles buggy subset fonts that
2500    // claim Standard encoding but have Expert glyph names.
2501    let build_cff_encoding = |font: &stet_fonts::cff_parser::CffFont| -> [Option<String>; 256] {
2502        let mut enc: [Option<String>; 256] = std::array::from_fn(|_| None);
2503        let name_to_gid: std::collections::HashMap<&str, u16> = font
2504            .charset
2505            .iter()
2506            .enumerate()
2507            .map(|(gid, name)| (name.as_str(), gid as u16))
2508            .collect();
2509        #[allow(clippy::needless_range_loop)]
2510        for code in 0..256 {
2511            let gid = font.encoding[code] as usize;
2512            if gid > 0 && gid < font.charset.len() && font.charset[gid] != ".notdef" {
2513                enc[code] = Some(font.charset[gid].clone());
2514            }
2515        }
2516        // Fill gaps from Expert encoding for fonts with Expert glyph names.
2517        if name_to_gid.contains_key("Asmall") {
2518            for &(code, sid) in &stet_fonts::cff_parser::EXPERT_ENCODING_MAP {
2519                if enc[code as usize].is_none() {
2520                    let name = stet_fonts::cff_parser::get_sid_string(sid, &[]);
2521                    if let Some(&gid) = name_to_gid.get(name.as_str()) {
2522                        if gid > 0 {
2523                            enc[code as usize] = Some(font.charset[gid as usize].clone());
2524                        }
2525                    }
2526                }
2527            }
2528            // Map lowercase a-z to XYZsmall names for broken Expert subsets
2529            // where the CFF encoding doesn't cover all used codes.
2530            for code in b'a'..=b'z' {
2531                if enc[code as usize].is_none() {
2532                    let small_name = format!("{}small", (code - b'a' + b'A') as char);
2533                    if name_to_gid.contains_key(small_name.as_str()) {
2534                        enc[code as usize] = Some(small_name);
2535                    }
2536                }
2537            }
2538        }
2539        enc
2540    };
2541
2542    let encoding = if no_base_encoding || !differences.is_empty() {
2543        // Encoding dict had no BaseEncoding — use CFF built-in
2544        // encoding as base, then apply Differences.
2545        let mut enc = build_cff_encoding(&font);
2546        for (code, name) in differences {
2547            if *code < 256 {
2548                enc[*code] = Some(name.clone());
2549            }
2550        }
2551        enc
2552    } else if !has_explicit_encoding {
2553        // No /Encoding at all — use CFF built-in encoding directly.
2554        build_cff_encoding(&font)
2555    } else {
2556        encoding
2557    };
2558
2559    let fm = font.font_matrix;
2560    let font_matrix = Matrix::new(fm[0], fm[1], fm[2], fm[3], fm[4], fm[5]);
2561
2562    // When the PDF has no /Widths array, derive widths from the CFF charstrings.
2563    let widths = if !has_pdf_widths {
2564        use stet_fonts::type2_charstring::execute_type2_charstring;
2565        let mut derived = [0.0f64; 256];
2566        for code in 0..256usize {
2567            let glyph_name = encoding[code].as_deref().unwrap_or(".notdef");
2568            let gid = font
2569                .charset
2570                .iter()
2571                .position(|name| name == glyph_name)
2572                .unwrap_or(0);
2573            if gid > 0 && gid < font.char_strings.len() {
2574                if let Ok(result) = execute_type2_charstring(
2575                    &font.char_strings[gid],
2576                    &font.local_subrs,
2577                    &font.global_subrs,
2578                    font.default_width_x,
2579                    font.nominal_width_x,
2580                    true, // width_only
2581                ) {
2582                    derived[code] = result.width_x * fm[0];
2583                }
2584            }
2585        }
2586        derived
2587    } else {
2588        widths
2589    };
2590
2591    Ok(PdfFont::Cff(CffPdfFont {
2592        font,
2593        encoding,
2594        widths,
2595        font_matrix,
2596    }))
2597}
2598
2599/// Resolve a Type 0 composite font (CIDFontType2 descendant with TrueType outlines).
2600fn resolve_type0(resolver: &Resolver, font_dict: &PdfDict) -> Result<PdfFont, PdfError> {
2601    // Check if encoding is UCS2-based (character codes are Unicode, not CIDs).
2602    let encoding_obj = font_dict.get(b"Encoding");
2603    let encoding_name = font_dict.get_name(b"Encoding").unwrap_or(b"");
2604    let ucs2_encoding = encoding_name.windows(4).any(|w| w == b"UCS2");
2605
2606    // Parse the encoding CMap's codespace ranges to determine byte widths,
2607    // and the code-to-CID mapping for non-identity encodings.
2608    // The encoding can be:
2609    //   - a stream containing a custom CMap
2610    //   - a name like "Identity-H" (identity mapping, 2-byte codes)
2611    //   - a predefined CMap name like "GBK-EUC-H" (load from system)
2612    let (code_lengths, code_to_cid, mut wmode) = if let Some(enc_obj) = encoding_obj {
2613        if let Ok(cmap_data) = resolver.stream_data_from_obj(enc_obj) {
2614            // Embedded CMap stream
2615            let cmap = super::cmap::CMap::parse_with_loader(
2616                &cmap_data,
2617                Some(&|name| load_predefined_cmap(name)),
2618            );
2619            (cmap.code_lengths, cmap.code_to_cid, cmap.wmode)
2620        } else if !encoding_name.is_empty() && !encoding_name.starts_with(b"Identity") {
2621            // Predefined CMap name (e.g. GBK-EUC-H) — load from system
2622            if let Some(cmap_data) = load_predefined_cmap(encoding_name) {
2623                let cmap = super::cmap::CMap::parse_with_loader(
2624                    &cmap_data,
2625                    Some(&|name| load_predefined_cmap(name)),
2626                );
2627                (cmap.code_lengths, cmap.code_to_cid, cmap.wmode)
2628            } else {
2629                eprintln!(
2630                    "warning: predefined CMap '{}' not found; \
2631                     set STET_CMAP_DIR or install poppler-data for CJK support",
2632                    String::from_utf8_lossy(encoding_name)
2633                );
2634                ([2u8; 256], HashMap::new(), 0)
2635            }
2636        } else {
2637            ([2u8; 256], HashMap::new(), 0) // Identity-H/V or fallback
2638        }
2639    } else {
2640        ([2u8; 256], HashMap::new(), 0)
2641    };
2642    // Encoding name suffix overrides CMap WMode: -V = vertical, -H = horizontal
2643    if encoding_name.ends_with(b"-V") {
2644        wmode = 1;
2645    } else if encoding_name.ends_with(b"-H") {
2646        wmode = 0;
2647    }
2648
2649    // Get DescendantFonts array (must have exactly one entry).
2650    // May be a direct array or an indirect reference to one.
2651    let descendants_obj = font_dict
2652        .get(b"DescendantFonts")
2653        .ok_or(PdfError::Other("Type0 font missing DescendantFonts".into()))?;
2654    let descendants_resolved = resolver.deref(descendants_obj)?;
2655    let descendants = descendants_resolved
2656        .as_array()
2657        .ok_or(PdfError::Other("DescendantFonts is not an array".into()))?;
2658    let cid_font_ref = descendants
2659        .first()
2660        .ok_or(PdfError::Other("DescendantFonts is empty".into()))?;
2661    let cid_font_obj = resolver.deref(cid_font_ref)?;
2662    let cid_font_dict = cid_font_obj
2663        .as_dict()
2664        .ok_or(PdfError::Other("CIDFont is not a dict".into()))?;
2665
2666    let cid_subtype = cid_font_dict.get_name(b"Subtype").unwrap_or(b"");
2667
2668    // Get FontDescriptor from the CIDFont
2669    let descriptor = get_font_descriptor(cid_font_dict, resolver)?;
2670    let desc = descriptor
2671        .as_ref()
2672        .ok_or(PdfError::Other("CIDFont missing FontDescriptor".into()))?;
2673
2674    // Parse /DW (default width) — may be int or real
2675    let default_width = cid_font_dict.get_f64(b"DW").unwrap_or(1000.0) / 1000.0;
2676
2677    // Parse /DW2 (default vertical metrics: [v_y w1])
2678    // Default: [880, -1000] per PDF spec Table 117
2679    let dw2 = cid_font_dict
2680        .get_array(b"DW2")
2681        .and_then(|arr| {
2682            let v: Vec<f64> = arr.iter().filter_map(|o| o.as_f64()).collect();
2683            if v.len() >= 2 {
2684                Some([v[0], v[1]])
2685            } else {
2686                None
2687            }
2688        })
2689        .unwrap_or([880.0, -1000.0]);
2690
2691    // Parse /W array (CID-specific widths)
2692    let cid_widths = parse_cid_widths(cid_font_dict, resolver);
2693
2694    // Parse /W2 array (per-CID vertical metrics)
2695    let w2 = parse_cid_w2(cid_font_dict, resolver);
2696
2697    // When a UCS2-based CMap (e.g. UniJIS-UCS2-H) couldn't be loaded from
2698    // disk, build a basic Latin fallback mapping. All Adobe CID collections
2699    // (Japan1, GB1, CNS1, Korea1) map Unicode basic Latin to:
2700    //   CID 1 = U+0020 (space), CID 2..95 = U+0021..U+007E
2701    // This handles the common case of CJK-font PDFs containing English text
2702    // when CMap resource files aren't installed.
2703    let code_to_cid = if code_to_cid.is_empty()
2704        && code_lengths[0] == 2
2705        && encoding_name.windows(4).any(|w| w == b"UCS2")
2706    {
2707        let mut map = HashMap::new();
2708        for unicode in 0x0020u32..=0x007Eu32 {
2709            let cid = unicode - 0x001F;
2710            map.insert(unicode, cid);
2711        }
2712        map
2713    } else {
2714        code_to_cid
2715    };
2716
2717    // Parse /ToUnicode CMap from the parent Type 0 font dict
2718    let to_unicode = if let Some(tu_obj) = font_dict.get(b"ToUnicode") {
2719        match resolver.stream_data_from_obj(tu_obj) {
2720            Ok(data) => parse_to_unicode(&data),
2721            Err(_) => HashMap::new(),
2722        }
2723    } else {
2724        HashMap::new()
2725    };
2726
2727    // Extract CIDSystemInfo /Ordering for CID→Unicode fallback lookup.
2728    // /Ordering is a string (parenthesized), not a name.
2729    let ordering = {
2730        let si_dict = cid_font_dict
2731            .get_dict(b"CIDSystemInfo")
2732            .cloned()
2733            .or_else(|| {
2734                cid_font_dict
2735                    .get(b"CIDSystemInfo")
2736                    .and_then(|obj| resolver.deref(obj).ok())
2737                    .and_then(|obj| obj.as_dict().cloned())
2738            });
2739        si_dict
2740            .and_then(|d| {
2741                d.get(b"Ordering").and_then(|v| match v {
2742                    PdfObj::Str(s) => Some(s.clone()),
2743                    PdfObj::Name(n) => Some(n.clone()),
2744                    _ => None,
2745                })
2746            })
2747            .unwrap_or_default()
2748    };
2749
2750    match cid_subtype {
2751        b"CIDFontType2" => {
2752            let mut substituted;
2753            let mut data = if let Some(ff_ref) = desc
2754                .get(b"FontFile2")
2755                // Some PDFs store TrueType data under /FontFile instead
2756                // of the correct /FontFile2 — accept it as a fallback.
2757                .or_else(|| {
2758                    desc.get(b"FontFile").filter(|obj| {
2759                        resolver
2760                            .stream_data_from_obj(obj)
2761                            .ok()
2762                            .is_some_and(|d| d.len() > 4 && d[..4] == [0, 1, 0, 0])
2763                    })
2764                })
2765                // PDF/X-4 and others embed CIDFontType2 outlines via
2766                // /FontFile3 with /Subtype /OpenType. The wrapped sfnt may
2767                // be TrueType-flavored (glyf/loca) or CFF-flavored (OTTO);
2768                // downstream OTTO/raw-CFF detection routes either correctly.
2769                .or_else(|| {
2770                    desc.get(b"FontFile3").filter(|obj| {
2771                        resolver.stream_data_from_obj(obj).ok().is_some_and(|d| {
2772                            d.len() > 4
2773                                && (d[..4] == [0, 1, 0, 0]
2774                                    || &d[..4] == b"true"
2775                                    || &d[..4] == b"OTTO")
2776                        })
2777                    })
2778                }) {
2779                substituted = false;
2780                let mut font_data = resolver.stream_data_from_obj(ff_ref)?;
2781                sanitize_index_to_loc_format(&mut font_data);
2782                // Some PDFs store CFF fonts as FontFile2 instead of FontFile3.
2783                // Detect OpenType/CFF (OTTO magic) or raw CFF and route accordingly.
2784                let is_otf_cff = font_data.len() > 4 && &font_data[0..4] == b"OTTO";
2785                let is_raw = is_raw_cff(&font_data);
2786                if is_otf_cff || is_raw {
2787                    // Parse CIDToGIDMap from the PDF
2788                    let cid_to_gid_map = if let Some(map_obj) = cid_font_dict.get(b"CIDToGIDMap") {
2789                        if cid_font_dict.get_name(b"CIDToGIDMap") != Some(b"Identity") {
2790                            resolver.stream_data_from_obj(map_obj).ok().map(|d| {
2791                                d.chunks_exact(2)
2792                                    .map(|p| u16::from_be_bytes([p[0], p[1]]))
2793                                    .collect()
2794                            })
2795                        } else {
2796                            None
2797                        }
2798                    } else {
2799                        None
2800                    };
2801                    // For CID-keyed CFF fonts, the CFF handles CID→charstring
2802                    // mapping internally. The PDF's CIDToGIDMap is a sparse subset
2803                    // artifact that maps most CIDs to GID 0 — ignore it.
2804                    // For non-CID CFF fonts, the CIDToGIDMap provides the actual
2805                    // CID→GID mapping and must be used.
2806                    let is_cid_keyed = {
2807                        use stet_fonts::truetype::find_table;
2808                        let cff_range = if is_otf_cff {
2809                            find_table(&font_data, b"CFF ")
2810                        } else {
2811                            Some((0, font_data.len()))
2812                        };
2813                        cff_range
2814                            .and_then(|(off, len)| parse_cff(&font_data[off..off + len]).ok())
2815                            .and_then(|fonts| fonts.into_iter().next())
2816                            .is_some_and(|f| f.is_cid)
2817                    };
2818                    let (cid_to_gid_map, identity) = if is_cid_keyed {
2819                        (None, true) // CFF handles CID mapping
2820                    } else {
2821                        let id = cid_to_gid_map.is_none();
2822                        (cid_to_gid_map, id)
2823                    };
2824                    if is_otf_cff {
2825                        return create_cid_cff_from_otf(
2826                            &font_data,
2827                            default_width,
2828                            cid_widths,
2829                            &ordering,
2830                            cid_to_gid_map,
2831                            identity,
2832                            code_lengths,
2833                            code_to_cid.clone(),
2834                            wmode,
2835                            dw2,
2836                            w2.clone(),
2837                        );
2838                    } else {
2839                        return create_cid_cff_from_raw(
2840                            &font_data,
2841                            default_width,
2842                            cid_widths,
2843                            &ordering,
2844                            cid_to_gid_map,
2845                            identity,
2846                            code_lengths,
2847                            code_to_cid.clone(),
2848                            wmode,
2849                            dw2,
2850                            w2.clone(),
2851                        );
2852                    }
2853                }
2854                font_data
2855            } else {
2856                // Font not embedded — try system font lookup
2857                substituted = true;
2858                let base_font = cid_font_dict
2859                    .get_name(b"BaseFont")
2860                    .map(|n| {
2861                        let s = String::from_utf8_lossy(n);
2862                        if s.len() > 7 && s.as_bytes().get(6) == Some(&b'+') {
2863                            s[7..].to_string()
2864                        } else {
2865                            s.to_string()
2866                        }
2867                    })
2868                    .unwrap_or_default();
2869                let sys_data = load_system_truetype_font(&base_font)
2870                    .or_else(|_| load_cjk_fallback_font(&ordering, &base_font))?;
2871                // If the system font is OpenType/CFF, use CFF rendering path
2872                if sys_data.len() > 4 && &sys_data[0..4] == b"OTTO" {
2873                    return create_cid_cff_from_otf(
2874                        &sys_data,
2875                        default_width,
2876                        cid_widths,
2877                        &ordering,
2878                        None,
2879                        false, // substituted: use cmap, not identity
2880                        code_lengths,
2881                        code_to_cid.clone(),
2882                        wmode,
2883                        dw2,
2884                        w2.clone(),
2885                    );
2886                }
2887                sys_data
2888            };
2889
2890            // Detect corrupted font data: check whether ANY CID in the /W
2891            // table produces a valid glyph outline.  If none do, the font data
2892            // is likely damaged (e.g. from a broken/truncated zlib stream) and
2893            // we should fall back to the system font.
2894            // Only check when identity CID→GID is in effect (no explicit
2895            // CIDToGIDMap stream), since we test CIDs directly as GIDs.
2896            let has_cid_to_gid_map = cid_font_dict
2897                .get(b"CIDToGIDMap")
2898                .is_some_and(|v| v.as_name().is_none_or(|n| n != b"Identity"));
2899            if !substituted && !cid_widths.is_empty() && !has_cid_to_gid_map {
2900                let upm_f = get_units_per_em(&data) as f64;
2901                let any_glyph = cid_widths
2902                    .keys()
2903                    .any(|&cid| skrifa_glyph_path(&data, cid, upm_f).is_some());
2904                if !any_glyph {
2905                    let base_font = cid_font_dict
2906                        .get_name(b"BaseFont")
2907                        .map(|n| {
2908                            let s = String::from_utf8_lossy(n);
2909                            if s.len() > 7 && s.as_bytes().get(6) == Some(&b'+') {
2910                                s[7..].to_string()
2911                            } else {
2912                                s.to_string()
2913                            }
2914                        })
2915                        .unwrap_or_default();
2916                    if let Ok(sys_data) = load_system_truetype_font(&base_font) {
2917                        data = sys_data;
2918                        substituted = true;
2919                    }
2920                }
2921            }
2922            let units_per_em = get_units_per_em(&data) as f64;
2923            let cmap = parse_cmap(&data);
2924
2925            // Parse CIDToGIDMap: either /Identity name or a stream of big-endian u16 pairs
2926            let (identity_cid_to_gid, cid_to_gid_map) =
2927                if let Some(name) = cid_font_dict.get_name(b"CIDToGIDMap") {
2928                    (name == b"Identity", None)
2929                } else if let Some(map_obj) = cid_font_dict.get(b"CIDToGIDMap") {
2930                    match resolver.stream_data_from_obj(map_obj) {
2931                        Ok(stream_data) => {
2932                            let mut gid_map = Vec::with_capacity(stream_data.len() / 2);
2933                            for pair in stream_data.chunks_exact(2) {
2934                                gid_map.push(u16::from_be_bytes([pair[0], pair[1]]));
2935                            }
2936                            (false, Some(gid_map))
2937                        }
2938                        Err(_) => (true, None), // fallback to identity
2939                    }
2940                } else {
2941                    (true, None) // no CIDToGIDMap → default to identity
2942                };
2943
2944            // For substituted fonts, always discard CIDToGIDMap streams.
2945            // The map encodes GID ordering specific to the original font and
2946            // is never valid for a different font — even metric-compatible
2947            // pairs (e.g. TimesNewRoman ↔ LiberationSerif) share ASCII GIDs
2948            // but diverge for extended characters (ě, í, – etc.).
2949            let cid_to_gid_map = if substituted && cid_to_gid_map.is_some() {
2950                None
2951            } else {
2952                cid_to_gid_map
2953            };
2954            // Don't promote to identity when we discarded an incompatible
2955            // CIDToGIDMap — the CIDs are Unicode values, not GIDs, so the
2956            // gid_to_unicode enrichment below must NOT run.
2957
2958            // For non-embedded fonts with Identity CIDToGIDMap, the CID values
2959            // are GIDs from the original font. A substitute font has different
2960            // glyph ordering, so CID-as-GID produces garbled text. Use hardcoded
2961            // GID-to-Unicode tables (same approach as PDF.js) to map known fonts'
2962            // GIDs to Unicode, enabling correct rendering with any substitute.
2963            let to_unicode = if substituted
2964                && identity_cid_to_gid
2965                && to_unicode.is_empty()
2966                && encoding_name.starts_with(b"Identity")
2967            {
2968                let base_name = cid_font_dict.get_name(b"BaseFont").unwrap_or(b"");
2969                let name_str = String::from_utf8_lossy(base_name);
2970                // Strip subset prefix (e.g. "ABCDEF+Calibri,Bold" → "Calibri,Bold")
2971                let clean = if name_str.len() > 7 && name_str.as_bytes().get(6) == Some(&b'+') {
2972                    &name_str[7..]
2973                } else {
2974                    &name_str
2975                };
2976                // Extract family name before style suffix, stripping
2977                // PostScript suffixes (MT, PS, PSMT) that don't appear
2978                // in the GID map keys.
2979                let mut family = clean
2980                    .split(&[',', '-'][..])
2981                    .next()
2982                    .unwrap_or(clean)
2983                    .to_ascii_lowercase();
2984                for suffix in &["psmt", "ps", "mt"] {
2985                    if family.len() > suffix.len() && family.ends_with(suffix) {
2986                        family.truncate(family.len() - suffix.len());
2987                        break;
2988                    }
2989                }
2990                super::gid_maps::get_gid_to_unicode_map(&family).unwrap_or(to_unicode)
2991            } else {
2992                to_unicode
2993            };
2994
2995            Ok(PdfFont::CidTrueType(CidTrueTypePdfFont {
2996                data,
2997                default_width,
2998                cid_widths,
2999                cmap,
3000                units_per_em,
3001                identity_cid_to_gid,
3002                substituted,
3003                cid_to_gid_map,
3004                to_unicode,
3005                ordering: ordering.clone(),
3006                ucs2_encoding,
3007                code_lengths,
3008                code_to_cid: code_to_cid.clone(),
3009                wmode,
3010                dw2,
3011                w2: w2.clone(),
3012            }))
3013        }
3014        b"CIDFontType0" => {
3015            // CFF-based CID font: FontFile3 with /Subtype /CIDFontType0C
3016            // Some PDFs use /FontFile instead of /FontFile3 — accept both.
3017            if let Some(ff_ref) = desc.get(b"FontFile3").or_else(|| desc.get(b"FontFile")) {
3018                let font_data = resolver.stream_data_from_obj(ff_ref)?;
3019                // Some PDFs mislabel TrueType data as CIDFontType0C. Detect the
3020                // TrueType magic (\x00\x01\x00\x00) and route to TrueType path.
3021                let is_truetype = font_data.len() > 4 && &font_data[0..4] == b"\x00\x01\x00\x00";
3022                if is_truetype {
3023                    let mut font_data = font_data;
3024                    sanitize_index_to_loc_format(&mut font_data);
3025                    let units_per_em = get_units_per_em(&font_data) as f64;
3026                    let cmap = parse_cmap(&font_data);
3027                    let (identity_cid_to_gid, cid_to_gid_map) =
3028                        if let Some(name) = cid_font_dict.get_name(b"CIDToGIDMap") {
3029                            (name == b"Identity", None)
3030                        } else {
3031                            (true, None)
3032                        };
3033                    return Ok(PdfFont::CidTrueType(CidTrueTypePdfFont {
3034                        data: font_data,
3035                        default_width,
3036                        cid_widths,
3037                        cmap,
3038                        units_per_em,
3039                        identity_cid_to_gid,
3040                        substituted: false,
3041                        cid_to_gid_map,
3042                        to_unicode,
3043                        ordering: ordering.clone(),
3044                        ucs2_encoding,
3045                        code_lengths,
3046                        code_to_cid: code_to_cid.clone(),
3047                        wmode,
3048                        dw2,
3049                        w2: w2.clone(),
3050                    }));
3051                }
3052                // FontFile3 may be raw CFF or OpenType/CFF (OTTO wrapper)
3053                if font_data.len() > 4 && &font_data[0..4] == b"OTTO" {
3054                    // Parse CIDToGIDMap for OpenType-wrapped CFF
3055                    let pdf_cid_to_gid = if let Some(map_obj) = cid_font_dict.get(b"CIDToGIDMap") {
3056                        match resolver.stream_data_from_obj(map_obj) {
3057                            Ok(stream_data) => {
3058                                let mut gid_map = Vec::with_capacity(stream_data.len() / 2);
3059                                for pair in stream_data.chunks_exact(2) {
3060                                    gid_map.push(u16::from_be_bytes([pair[0], pair[1]]));
3061                                }
3062                                Some(gid_map)
3063                            }
3064                            Err(_) => None,
3065                        }
3066                    } else {
3067                        None
3068                    };
3069                    // For non-CID CFF fonts used as CIDFontType0, the CID IS the
3070                    // charstring index (identity mapping). For true CID-keyed CFF fonts,
3071                    // the CFF charset provides the CID→GID mapping, or the OTF cmap is used.
3072                    let cff_is_cid = is_cff_cid_keyed(&font_data);
3073                    return create_cid_cff_from_otf(
3074                        &font_data,
3075                        default_width,
3076                        cid_widths,
3077                        &ordering,
3078                        pdf_cid_to_gid,
3079                        !cff_is_cid,
3080                        code_lengths,
3081                        code_to_cid.clone(),
3082                        wmode,
3083                        dw2,
3084                        w2.clone(),
3085                    );
3086                }
3087                // PostScript CIDFont programs: "%!PS-Adobe-3.0 Resource-CIDFont"
3088                // These contain binary charstring data after StartData.
3089                if font_data.starts_with(b"%!")
3090                    && font_data.windows(16).any(|w| w == b"Resource-CIDFont")
3091                {
3092                    return create_cid_from_ps_cidfont(
3093                        &font_data,
3094                        default_width,
3095                        cid_widths,
3096                        code_lengths,
3097                        code_to_cid.clone(),
3098                        wmode,
3099                        dw2,
3100                        w2.clone(),
3101                    );
3102                }
3103                // Detect Type 1 font data (ASCII "%!" or PFB 0x80) mislabeled
3104                // as CIDFontType0.  Parse as Type 1, pre-compute glyph paths
3105                // for each CID using ToUnicode → AGL → charstrings.
3106                let is_type1 = font_data.starts_with(b"%!") || font_data.first() == Some(&0x80);
3107                if is_type1 {
3108                    return create_cid_from_type1(
3109                        &font_data,
3110                        default_width,
3111                        cid_widths,
3112                        &to_unicode,
3113                        code_lengths,
3114                        code_to_cid.clone(),
3115                        wmode,
3116                        dw2,
3117                        w2.clone(),
3118                    );
3119                }
3120                let fonts = parse_cff(&font_data)
3121                    .map_err(|e| PdfError::Other(format!("CFF parse error: {e}")))?;
3122                let font = fonts
3123                    .into_iter()
3124                    .next()
3125                    .ok_or(PdfError::Other("CFF contains no fonts".into()))?;
3126                // Detect tiny CFF subsets for CJK fonts. Some PDFs embed a
3127                // minimal "ghost" CFF (3-4 glyphs) to satisfy spec requirements
3128                // while expecting the viewer to use the system CJK font (which
3129                // has matching GID ordering). Only fall through for CJK fonts
3130                // where the system substitute has compatible glyph indices.
3131                //
3132                // Gate this on the Adobe CID registry from CIDSystemInfo
3133                // /Ordering, *not* on substring matches against the BaseFont
3134                // name. The 2-letter substrings the old heuristic tested ("sc",
3135                // "cn", "jp", "kr", "tc", "hk") false-positive on common Latin
3136                // font names like "BentonSansCond" → "sc", causing the embedded
3137                // CFF to be discarded and replaced with NotoSansCJK whose GIDs
3138                // don't match — producing garbled text.
3139                let cs_count = font.char_strings.len();
3140                let is_adobe_cjk_registry = matches!(
3141                    ordering.as_slice(),
3142                    b"GB1" | b"CNS1" | b"Japan1" | b"Japan2" | b"Korea1" | b"KR"
3143                );
3144                if cs_count > 0 && cid_widths.len() > cs_count * 4 && is_adobe_cjk_registry {
3145                    // Drop the parsed CFF and fall through to the system font path.
3146                } else {
3147                    let fm = font.font_matrix;
3148                    let font_matrix = Matrix::new(fm[0], fm[1], fm[2], fm[3], fm[4], fm[5]);
3149                    return Ok(PdfFont::CidCff(CidCffPdfFont {
3150                        font,
3151                        default_width,
3152                        cid_widths,
3153                        font_matrix,
3154                        cmap: None,
3155                        pdf_cid_to_gid: None,
3156                        identity_cid_to_gid: false,
3157                        ordering: ordering.clone(),
3158                        code_lengths,
3159                        code_to_cid: code_to_cid.clone(),
3160                        wmode,
3161                        dw2,
3162                        w2: w2.clone(),
3163                        type1_paths: None,
3164                    }));
3165                }
3166            }
3167            // Not embedded or tiny subset — substitute with a system font
3168            {
3169                let base_font = cid_font_dict
3170                    .get_name(b"BaseFont")
3171                    .map(|n| String::from_utf8_lossy(n).to_string())
3172                    .unwrap_or_default();
3173                let sys_data = if ucs2_encoding {
3174                    // UCS2-encoded CID fonts: use a substitute TrueType font so
3175                    // the text stays on the composite CID rendering path (correct
3176                    // code_lengths and CID width advancement). Without this, the
3177                    // simple fallback font treats 2-byte UCS-2 codes as individual
3178                    // bytes, producing doubled character spacing.
3179                    // Try CJK fallback before Latin fallback — UCS2-encoded CJK
3180                    // fonts (Adobe-Japan1 etc.) need a font with CJK glyphs.
3181                    load_system_truetype_font(&base_font)
3182                        .or_else(|_| load_cjk_fallback_font(&ordering, &base_font))
3183                        .or_else(|_| load_system_truetype_font("DejaVuSans"))
3184                        .or_else(|_| load_system_truetype_font("LiberationSans"))
3185                        .or_else(|_| load_system_truetype_font("NimbusSans"))?
3186                } else {
3187                    load_system_truetype_font(&base_font)
3188                        .or_else(|_| load_cjk_fallback_font(&ordering, &base_font))?
3189                };
3190                // For Identity ordering, CIDs are GIDs from the original font.
3191                // When the substitute is the same font family, identity mapping
3192                // gives correct glyphs. For non-Identity orderings, the cmap
3193                // path (CID→Unicode→GID) is used instead.
3194                let identity = ordering == b"Identity";
3195                // If the system font is OpenType/CFF (or a TTC containing
3196                // OpenType/CFF sub-fonts), use the CFF rendering path.
3197                // find_table() handles both plain OTF and TTC files.
3198                let is_otto = sys_data.len() > 4 && &sys_data[0..4] == b"OTTO";
3199                let is_ttc_cff = sys_data.len() > 16 && &sys_data[0..4] == b"ttcf" && {
3200                    let off = u32::from_be_bytes([
3201                        sys_data[12],
3202                        sys_data[13],
3203                        sys_data[14],
3204                        sys_data[15],
3205                    ]) as usize;
3206                    off + 4 <= sys_data.len() && &sys_data[off..off + 4] == b"OTTO"
3207                };
3208                if is_otto || is_ttc_cff {
3209                    return create_cid_cff_from_otf(
3210                        &sys_data,
3211                        default_width,
3212                        cid_widths,
3213                        &ordering,
3214                        None,
3215                        identity,
3216                        code_lengths,
3217                        code_to_cid.clone(),
3218                        wmode,
3219                        dw2,
3220                        w2.clone(),
3221                    );
3222                }
3223                let data = sys_data;
3224                let units_per_em = get_units_per_em(&data) as f64;
3225                let cmap = parse_cmap(&data);
3226                Ok(PdfFont::CidTrueType(CidTrueTypePdfFont {
3227                    data,
3228                    default_width,
3229                    cid_widths,
3230                    cmap,
3231                    units_per_em,
3232                    identity_cid_to_gid: false,
3233                    substituted: true,
3234                    cid_to_gid_map: None,
3235                    to_unicode,
3236                    ordering: ordering.clone(),
3237                    ucs2_encoding,
3238                    code_lengths,
3239                    code_to_cid: code_to_cid.clone(),
3240                    wmode,
3241                    dw2,
3242                    w2,
3243                }))
3244            }
3245        }
3246        _ => Err(PdfError::Other(format!(
3247            "Unsupported CIDFont subtype: {}",
3248            String::from_utf8_lossy(cid_subtype)
3249        ))),
3250    }
3251}
3252
3253/// Parse /W array from CIDFont dict into CID → width map.
3254///
3255/// Format: `[ cid_first [w1 w2 ...] cid_first cid_last w ... ]`
3256/// Extract `<hex>` tokens from a string, returning raw hex strings.
3257fn extract_hex_tokens(s: &str) -> Vec<&str> {
3258    let mut tokens = Vec::new();
3259    let mut rest = s;
3260    while let Some(start) = rest.find('<') {
3261        rest = &rest[start + 1..];
3262        if let Some(end) = rest.find('>') {
3263            let hex = rest[..end].trim();
3264            if !hex.is_empty() {
3265                tokens.push(hex);
3266            }
3267            rest = &rest[end + 1..];
3268        } else {
3269            break;
3270        }
3271    }
3272    tokens
3273}
3274
3275/// Parse a hex string as a Unicode codepoint.
3276/// For multi-byte destinations (>4 hex digits), extract just the first codepoint (first 4 digits).
3277fn hex_to_unicode(hex: &str) -> Option<u32> {
3278    if hex.len() <= 4 {
3279        u32::from_str_radix(hex, 16).ok()
3280    } else {
3281        // Multi-byte: two or more 16-bit codepoints packed together.
3282        // Check for common ligature sequences and map to Unicode ligature codepoints.
3283        match hex {
3284            "00660066" => Some(0xFB00),     // ff
3285            "00660069" => Some(0xFB01),     // fi
3286            "0066006C" => Some(0xFB02),     // fl
3287            "006600660069" => Some(0xFB03), // ffi
3288            "00660066006C" => Some(0xFB04), // ffl
3289            "017F0074" => Some(0xFB05),     // ſt (long s + t)
3290            "00730074" => Some(0xFB06),     // st
3291            _ => {
3292                // Unknown sequence — use first 16-bit codepoint
3293                u32::from_str_radix(&hex[..hex.len().min(4)], 16).ok()
3294            }
3295        }
3296    }
3297}
3298
3299/// Parse a ToUnicode CMap stream into a CID → Unicode mapping.
3300///
3301/// Handles `beginbfchar` and `beginbfrange` sections with hex-encoded values.
3302/// Multi-byte destination values (ligatures etc.) are mapped to their first codepoint.
3303fn parse_to_unicode(data: &[u8]) -> HashMap<u16, u32> {
3304    let mut map = HashMap::new();
3305    let text = String::from_utf8_lossy(data);
3306
3307    // Parse bfchar entries: <src_cid> <dst_unicode>
3308    // Process line-by-line to avoid pairing issues with multi-byte destinations
3309    let mut in_bfchar = false;
3310    let mut in_bfrange = false;
3311    let mut range_tokens: Vec<&str> = Vec::new();
3312
3313    for line in text.lines() {
3314        let trimmed = line.trim();
3315        if trimmed.ends_with("beginbfchar") {
3316            in_bfchar = true;
3317            continue;
3318        }
3319        if trimmed == "endbfchar" {
3320            in_bfchar = false;
3321            continue;
3322        }
3323        if trimmed.ends_with("beginbfrange") {
3324            in_bfrange = true;
3325            range_tokens.clear();
3326            continue;
3327        }
3328        if trimmed == "endbfrange" {
3329            in_bfrange = false;
3330            range_tokens.clear();
3331            continue;
3332        }
3333
3334        if in_bfchar {
3335            let tokens = extract_hex_tokens(trimmed);
3336            if tokens.len() >= 2
3337                && let Ok(cid) = u32::from_str_radix(tokens[0], 16)
3338                && let Some(unicode) = hex_to_unicode(tokens[1])
3339            {
3340                map.insert(cid as u16, unicode);
3341            }
3342        }
3343
3344        if in_bfrange {
3345            let line_tokens = extract_hex_tokens(trimmed);
3346            // Check for array syntax: <start> <end> [<u1> <u2> ...]
3347            if trimmed.contains('[') {
3348                // Collect start/end from previous tokens or this line
3349                let all_before_bracket: Vec<&str> = {
3350                    let before = trimmed.split('[').next().unwrap_or("");
3351                    extract_hex_tokens(before)
3352                };
3353                let in_bracket = {
3354                    let after_open = trimmed.split('[').nth(1).unwrap_or("");
3355                    let before_close = after_open.split(']').next().unwrap_or(after_open);
3356                    extract_hex_tokens(before_close)
3357                };
3358                if all_before_bracket.len() >= 2
3359                    && let (Some(start), Some(end)) = (
3360                        u32::from_str_radix(all_before_bracket[0], 16).ok(),
3361                        u32::from_str_radix(all_before_bracket[1], 16).ok(),
3362                    )
3363                {
3364                    for (j, cid) in (start..=end).enumerate() {
3365                        if j < in_bracket.len()
3366                            && let Some(u) = hex_to_unicode(in_bracket[j])
3367                        {
3368                            map.insert(cid as u16, u);
3369                        }
3370                    }
3371                }
3372            } else if line_tokens.len() >= 3 {
3373                // <start> <end> <dst_start>
3374                if let (Some(start), Some(end), Some(mut dst)) = (
3375                    u32::from_str_radix(line_tokens[0], 16).ok(),
3376                    u32::from_str_radix(line_tokens[1], 16).ok(),
3377                    hex_to_unicode(line_tokens[2]),
3378                ) {
3379                    for cid in start..=end {
3380                        map.insert(cid as u16, dst);
3381                        dst += 1;
3382                    }
3383                }
3384            }
3385        }
3386    }
3387
3388    map
3389}
3390
3391fn parse_cid_widths(cid_font_dict: &PdfDict, resolver: &Resolver) -> HashMap<u16, f64> {
3392    let mut widths = HashMap::new();
3393    // /W may be an indirect reference — resolve it before accessing as array
3394    let w_obj = match cid_font_dict.get(b"W") {
3395        Some(obj) => match resolver.deref(obj) {
3396            Ok(resolved) => resolved,
3397            Err(_) => return widths,
3398        },
3399        None => return widths,
3400    };
3401    let w_arr = match w_obj.as_array() {
3402        Some(arr) => arr,
3403        None => return widths,
3404    };
3405    let mut i = 0;
3406    while i < w_arr.len() {
3407        let first_cid = match &w_arr[i] {
3408            PdfObj::Int(n) => *n as u16,
3409            _ => break,
3410        };
3411        i += 1;
3412        if i >= w_arr.len() {
3413            break;
3414        }
3415        // Next element: array (individual widths) or int (range end)
3416        let next = resolver.deref(&w_arr[i]).unwrap_or(w_arr[i].clone());
3417        match &next {
3418            PdfObj::Array(arr) => {
3419                // [ cid_first [w1 w2 w3 ...] ] — consecutive CID widths
3420                for (j, w_obj) in arr.iter().enumerate() {
3421                    // Width entries may be indirect references
3422                    let w_val = w_obj
3423                        .as_f64()
3424                        .or_else(|| resolver.deref(w_obj).ok().and_then(|r| r.as_f64()))
3425                        .unwrap_or(0.0);
3426                    widths.insert(first_cid + j as u16, w_val / 1000.0);
3427                }
3428                i += 1;
3429            }
3430            _ => {
3431                // [ cid_first cid_last w ] — range with uniform width
3432                let last_cid = match &next {
3433                    PdfObj::Int(n) => *n as u16,
3434                    _ => first_cid,
3435                };
3436                i += 1;
3437                let w = if i < w_arr.len() {
3438                    let obj = &w_arr[i];
3439                    obj.as_f64()
3440                        .or_else(|| resolver.deref(obj).ok().and_then(|r| r.as_f64()))
3441                        .unwrap_or(0.0)
3442                        / 1000.0
3443                } else {
3444                    0.0
3445                };
3446                i += 1;
3447                for cid in first_cid..=last_cid {
3448                    widths.insert(cid, w);
3449                }
3450            }
3451        }
3452    }
3453    widths
3454}
3455
3456/// Parse /W2 array from CIDFont dict into CID → vertical metrics map.
3457///
3458/// Format mirrors /W but each entry has 3 values: w1 (vertical advance),
3459/// v_x and v_y (position vector from horizontal to vertical origin).
3460/// All values are in 1/1000 em units (NOT divided by 1000).
3461fn parse_cid_w2(cid_font_dict: &PdfDict, resolver: &Resolver) -> HashMap<u16, [f64; 3]> {
3462    let mut metrics = HashMap::new();
3463    let w2_obj = match cid_font_dict.get(b"W2") {
3464        Some(obj) => match resolver.deref(obj) {
3465            Ok(resolved) => resolved,
3466            Err(_) => return metrics,
3467        },
3468        None => return metrics,
3469    };
3470    let arr = match w2_obj.as_array() {
3471        Some(a) => a,
3472        None => return metrics,
3473    };
3474    let mut i = 0;
3475    while i < arr.len() {
3476        let first_cid = match &arr[i] {
3477            PdfObj::Int(n) => *n as u16,
3478            _ => break,
3479        };
3480        i += 1;
3481        if i >= arr.len() {
3482            break;
3483        }
3484        let next = resolver.deref(&arr[i]).unwrap_or(arr[i].clone());
3485        match &next {
3486            PdfObj::Array(sub) => {
3487                // [ cid_first [w1_1 v_x1 v_y1 w1_2 v_x2 v_y2 ...] ]
3488                let vals: Vec<f64> = sub.iter().filter_map(|o| o.as_f64()).collect();
3489                for (j, chunk) in vals.chunks(3).enumerate() {
3490                    if chunk.len() == 3 {
3491                        metrics.insert(first_cid + j as u16, [chunk[0], chunk[1], chunk[2]]);
3492                    }
3493                }
3494                i += 1;
3495            }
3496            _ => {
3497                // [ cid_first cid_last w1 v_x v_y ]
3498                let last_cid = match &next {
3499                    PdfObj::Int(n) => *n as u16,
3500                    _ => first_cid,
3501                };
3502                i += 1;
3503                if i + 2 < arr.len() {
3504                    let w1 = arr[i].as_f64().unwrap_or(-1000.0);
3505                    let vx = arr[i + 1].as_f64().unwrap_or(0.0);
3506                    let vy = arr[i + 2].as_f64().unwrap_or(880.0);
3507                    i += 3;
3508                    for cid in first_cid..=last_cid {
3509                        metrics.insert(cid, [w1, vx, vy]);
3510                    }
3511                } else {
3512                    break;
3513                }
3514            }
3515        }
3516    }
3517    metrics
3518}
3519
3520/// Strip PFB (Printer Font Binary) headers from Type 1 font data.
3521///
3522/// PFB format wraps ASCII and binary segments with 6-byte headers:
3523/// [0x80, type, len_lo, len_lo2, len_hi, len_hi2] + segment data
3524/// Type 1 = ASCII, Type 2 = binary (eexec), Type 3 = EOF.
3525fn strip_pfb(data: &[u8]) -> Vec<u8> {
3526    if data.len() < 2 || data[0] != 0x80 {
3527        return data.to_vec();
3528    }
3529    let mut result = Vec::with_capacity(data.len());
3530    let mut pos = 0;
3531    while pos + 6 <= data.len() && data[pos] == 0x80 {
3532        let segment_type = data[pos + 1];
3533        if segment_type == 3 {
3534            break; // EOF marker
3535        }
3536        let len = u32::from_le_bytes([data[pos + 2], data[pos + 3], data[pos + 4], data[pos + 5]])
3537            as usize;
3538        pos += 6;
3539        let end = (pos + len).min(data.len());
3540        result.extend_from_slice(&data[pos..end]);
3541        pos = end;
3542    }
3543    result
3544}
3545
3546// === Glyph rendering ===
3547
3548impl PdfFont {
3549    /// Get glyph outline path for a character code (single-byte fonts).
3550    /// Returns None for Type 3 fonts (they use content streams, not outlines).
3551    pub fn glyph_path(&self, char_code: u8) -> Option<PsPath> {
3552        match self {
3553            PdfFont::Type1(f) => f.glyph_path(char_code),
3554            PdfFont::TrueType(f) => f.glyph_path(char_code),
3555            PdfFont::Cff(f) => f.glyph_path(char_code),
3556            PdfFont::CidTrueType(f) => f.glyph_path_cid(char_code as u16),
3557            PdfFont::CidCff(f) => f.glyph_path_cid(char_code as u16),
3558            PdfFont::Type3(_) => None,
3559        }
3560    }
3561
3562    /// Get glyph outline path for a CID (2-byte composite fonts).
3563    pub fn glyph_path_cid(&self, cid: u16) -> Option<PsPath> {
3564        match self {
3565            PdfFont::CidTrueType(f) => f.glyph_path_cid(cid),
3566            PdfFont::CidCff(f) => f.glyph_path_cid(cid),
3567            _ => self.glyph_path(cid as u8),
3568        }
3569    }
3570
3571    /// Get glyph path for a Unicode code point, bypassing CID machinery.
3572    /// Used for malformed PDFs that mix WinAnsi literal strings in CID fonts.
3573    pub fn glyph_path_unicode(&self, unicode: u16) -> Option<PsPath> {
3574        match self {
3575            PdfFont::CidTrueType(f) => f.glyph_path_unicode(unicode),
3576            PdfFont::CidCff(f) => f.glyph_path_unicode(unicode),
3577            _ => None,
3578        }
3579    }
3580
3581    /// Get width for a Unicode code point from hmtx, bypassing CID widths.
3582    pub fn glyph_width_unicode(&self, unicode: u16) -> f64 {
3583        match self {
3584            PdfFont::CidTrueType(f) => f.glyph_width_unicode(unicode),
3585            _ => 0.0,
3586        }
3587    }
3588
3589    /// Get width for a character code (in text space units, already ÷1000).
3590    pub fn glyph_width(&self, char_code: u8) -> f64 {
3591        match self {
3592            PdfFont::Type1(f) => f.widths[char_code as usize],
3593            PdfFont::TrueType(f) => f.widths[char_code as usize],
3594            PdfFont::Cff(f) => f.widths[char_code as usize],
3595            PdfFont::CidTrueType(f) => f.glyph_width_cid(char_code as u16),
3596            PdfFont::CidCff(f) => f.glyph_width_cid(char_code as u16),
3597            PdfFont::Type3(f) => f.widths[char_code as usize],
3598        }
3599    }
3600
3601    /// Get width for a CID (2-byte composite fonts).
3602    pub fn glyph_width_cid(&self, cid: u16) -> f64 {
3603        match self {
3604            PdfFont::CidTrueType(f) => f.glyph_width_cid(cid),
3605            PdfFont::CidCff(f) => f.glyph_width_cid(cid),
3606            _ => self.glyph_width(cid as u8),
3607        }
3608    }
3609
3610    /// Font matrix (glyph space → text space).
3611    ///
3612    /// Returns identity for CidCff because the full matrix (including per-FD
3613    /// composition) is applied inside `glyph_path_cid()`.
3614    pub fn font_matrix(&self) -> Matrix {
3615        match self {
3616            PdfFont::Type1(f) => f.font_matrix,
3617            PdfFont::TrueType(_) | PdfFont::CidTrueType(_) => Matrix::identity(),
3618            PdfFont::Cff(f) => f.font_matrix,
3619            PdfFont::CidCff(_) => Matrix::identity(),
3620            PdfFont::Type3(f) => f.font_matrix,
3621        }
3622    }
3623
3624    /// Whether this is a composite (CID) font that uses multi-byte character codes.
3625    pub fn is_composite(&self) -> bool {
3626        matches!(self, PdfFont::CidTrueType(_) | PdfFont::CidCff(_))
3627    }
3628
3629    /// Writing mode: 0 = horizontal, 1 = vertical.
3630    pub fn wmode(&self) -> u8 {
3631        match self {
3632            PdfFont::CidTrueType(f) => f.wmode,
3633            PdfFont::CidCff(f) => f.wmode,
3634            _ => 0,
3635        }
3636    }
3637
3638    /// Default vertical metrics [v_y, w1] for vertical writing mode.
3639    /// v_y = vertical origin y offset (in 1/1000 em), w1 = vertical advance.
3640    pub fn dw2(&self) -> [f64; 2] {
3641        match self {
3642            PdfFont::CidTrueType(f) => f.dw2,
3643            PdfFont::CidCff(f) => f.dw2,
3644            _ => [880.0, -1000.0],
3645        }
3646    }
3647
3648    /// Get per-CID vertical metrics (w1, v_x, v_y), falling back to DW2.
3649    /// Returns values in 1/1000 em units.
3650    pub fn vertical_metrics_cid(&self, cid: u16) -> [f64; 3] {
3651        match self {
3652            PdfFont::CidTrueType(f) => {
3653                if let Some(&m) = f.w2.get(&cid) {
3654                    m
3655                } else {
3656                    // DW2 = [v_y, w1]; v_x defaults to half the horizontal width
3657                    let w0 = f.cid_widths.get(&cid).copied().unwrap_or(f.default_width) * 1000.0;
3658                    [f.dw2[1], w0 / 2.0, f.dw2[0]]
3659                }
3660            }
3661            PdfFont::CidCff(f) => {
3662                if let Some(&m) = f.w2.get(&cid) {
3663                    m
3664                } else {
3665                    let w0 = f.cid_widths.get(&cid).copied().unwrap_or(f.default_width) * 1000.0;
3666                    [f.dw2[1], w0 / 2.0, f.dw2[0]]
3667                }
3668            }
3669            _ => [-1000.0, 500.0, 880.0],
3670        }
3671    }
3672
3673    /// Whether a CID maps to a GID that exists in the font.
3674    /// Used to distinguish valid 2-byte CID codes from misinterpreted WinAnsi
3675    /// bytes in malformed PDFs that mix 1-byte literal text with CID fonts.
3676    pub fn has_cid_glyph(&self, cid: u16) -> bool {
3677        match self {
3678            PdfFont::CidTrueType(f) => f.has_glyph(cid),
3679            PdfFont::CidCff(_) => true, // CFF handles this differently
3680            _ => false,
3681        }
3682    }
3683
3684    /// Map a raw character code to a CID using the encoding CMap.
3685    /// Returns the code unchanged if no mapping exists (identity encoding).
3686    pub fn resolve_code_to_cid(&self, code: u32) -> u32 {
3687        match self {
3688            PdfFont::CidTrueType(f) => f.code_to_cid.get(&code).copied().unwrap_or(code),
3689            PdfFont::CidCff(f) => f.code_to_cid.get(&code).copied().unwrap_or(code),
3690            _ => code,
3691        }
3692    }
3693
3694    /// Get the byte width of a character code starting with the given byte.
3695    /// Only meaningful for composite fonts; returns 1 for simple fonts.
3696    pub fn code_width(&self, first_byte: u8) -> usize {
3697        match self {
3698            PdfFont::CidTrueType(f) => {
3699                let w = f.code_lengths[first_byte as usize];
3700                if w == 0 { 2 } else { w as usize }
3701            }
3702            PdfFont::CidCff(f) => {
3703                let w = f.code_lengths[first_byte as usize];
3704                if w == 0 { 2 } else { w as usize }
3705            }
3706            _ => 1,
3707        }
3708    }
3709
3710    /// Whether this is a Type 3 font (glyphs are content streams).
3711    pub fn is_type3(&self) -> bool {
3712        matches!(self, PdfFont::Type3(_))
3713    }
3714
3715    /// Get the Type 3 glyph stream data for a character code.
3716    pub fn type3_char_proc(&self, char_code: u8) -> Option<&[u8]> {
3717        match self {
3718            PdfFont::Type3(f) => f.char_procs.get(&char_code).map(|v| v.as_slice()),
3719            _ => None,
3720        }
3721    }
3722
3723    /// Get the Type 3 font resources dict.
3724    pub fn type3_resources(&self) -> Option<&PdfDict> {
3725        match self {
3726            PdfFont::Type3(f) => Some(&f.resources),
3727            _ => None,
3728        }
3729    }
3730}
3731
3732impl Type1PdfFont {
3733    fn glyph_path(&self, char_code: u8) -> Option<PsPath> {
3734        let glyph_name = self.encoding[char_code as usize].as_deref();
3735        let charstring = glyph_name
3736            .and_then(|name| self.font.charstrings.get(name))
3737            .or_else(|| {
3738                if !self.builtin_fallback {
3739                    return None;
3740                }
3741                let builtin = self.font.encoding.get(char_code as usize)?;
3742                if builtin != ".notdef" && glyph_name.map_or(true, |n| n != builtin) {
3743                    self.font.charstrings.get(builtin.as_str())
3744                } else {
3745                    None
3746                }
3747            })?;
3748        // Provide charstring lookup for seac (accented character composition)
3749        let cs_lookup =
3750            |name: &str| -> Option<Vec<u8>> { self.font.charstrings.get(name).cloned() };
3751        let result = execute_charstring_mm(
3752            charstring,
3753            &self.font.subrs,
3754            self.font.len_iv,
3755            false,
3756            Some(&cs_lookup),
3757            self.weight_vector.as_deref(),
3758        )
3759        .ok()?;
3760        // For non-metric-compatible substitutes, scale glyph horizontally so its
3761        // width matches the PDF's /Widths entry. Without this, the substitute
3762        // font's different glyph metrics cause crowded or sparse character spacing.
3763        if self.per_char_width_scale {
3764            let pdf_w = self.widths[char_code as usize];
3765            let font_w = result.width_x * self.font_matrix.a;
3766            if font_w.abs() > 0.001 && pdf_w > 0.001 && (pdf_w / font_w - 1.0).abs() > 0.01 {
3767                return Some(result.path.transform(&Matrix::scale(pdf_w / font_w, 1.0)));
3768            }
3769        }
3770        Some(result.path)
3771    }
3772}
3773
3774impl TrueTypePdfFont {
3775    /// Check if any gNNNN glyph name in the encoding contains hex letters (a-f),
3776    /// indicating the subsetting tool used hexadecimal GIDs.
3777    fn detect_gid_hex(encoding: &[Option<String>; 256]) -> bool {
3778        encoding.iter().any(|name| {
3779            if let Some(n) = name {
3780                n.starts_with('g')
3781                    && n.len() > 1
3782                    && n[1..].bytes().all(|b| b.is_ascii_hexdigit())
3783                    && n[1..]
3784                        .bytes()
3785                        .any(|b| b.is_ascii_hexdigit() && !b.is_ascii_digit())
3786            } else {
3787                false
3788            }
3789        })
3790    }
3791
3792    fn glyph_path(&self, char_code: u8) -> Option<PsPath> {
3793        let gid = self.char_code_to_gid(char_code);
3794        let gid = gid?;
3795        let path = skrifa_glyph_path(&self.data, gid, self.units_per_em).or_else(|| {
3796            // Fallback for locx/glyx PDF-subset fonts that skrifa can't parse
3797            let glyf_data = get_glyf_data(&self.data, gid)?;
3798            let data_ref = &self.data;
3799            let p = parse_glyf_to_path(&glyf_data, &|cid| get_glyf_data(data_ref, cid));
3800            if p.is_empty() { None } else { Some(p) }
3801        })?;
3802        let scale = 1.0 / self.units_per_em;
3803        let m = Matrix::scale(scale, scale);
3804        Some(path.transform(&m))
3805    }
3806
3807    fn char_code_to_gid(&self, char_code: u8) -> Option<u16> {
3808        // Symbolic re-encoded fonts: skip the encoding→AGL→cmap path, which maps
3809        // StandardEncoding names (e.g. "circumflex") to wrong Unicode→GID values.
3810        // Go directly to the cmap lookup by char code.
3811        if self.identity_gid {
3812            if let Some(&gid) = self.cmap.get(&(char_code as u32)) {
3813                return Some(gid);
3814            }
3815            if let Some(&gid) = self.cmap.get(&(0xF000 + char_code as u32)) {
3816                return Some(gid);
3817            }
3818            return Some(char_code as u16);
3819        }
3820        if let Some(glyph_name) = &self.encoding[char_code as usize] {
3821            // Only use encoding → glyph name → Unicode → cmap when the cmap
3822            // is Unicode-keyed ((3,1), (3,10), or (0,*)).  Non-Unicode cmaps
3823            // ((1,0) Mac Roman, (3,0) Symbol) in subset fonts map re-encoded
3824            // char codes directly — looking up Unicode values gives wrong GIDs.
3825            if self.cmap_is_unicode {
3826                if let Some(unicode) = stet_fonts::agl::glyph_name_to_unicode(glyph_name)
3827                    && let Some(&gid) = self.cmap.get(&(unicode as u32))
3828                {
3829                    return Some(gid);
3830                }
3831            }
3832        }
3833        // ToUnicode CMap → Unicode → cmap GID.  Tried before gNNNN because
3834        // gNNNN GIDs are font-specific — they're wrong for substitute fonts
3835        // (e.g. SimSun g18331 ≠ NotoSerif GID 18331).
3836        // Only use when cmap is Unicode-keyed — non-Unicode cmaps map
3837        // re-encoded char codes, not Unicode values.
3838        if self.cmap_is_unicode {
3839            if let Some(&unicode) = self.to_unicode.get(&(char_code as u16))
3840                && let Some(&gid) = self.cmap.get(&unicode)
3841            {
3842                return Some(gid);
3843            }
3844        }
3845        if let Some(glyph_name) = &self.encoding[char_code as usize] {
3846            // Try gNNNN pattern → direct GID (for embedded fonts where GIDs match).
3847            // Some subsetting tools use hex (g003a = GID 58), others decimal (g1863).
3848            // detect_gid_hex() checks if any name in this font has hex letters (a-f).
3849            if glyph_name.starts_with('g')
3850                && glyph_name.len() > 1
3851                && glyph_name[1..].bytes().all(|b| b.is_ascii_hexdigit())
3852            {
3853                let suffix = &glyph_name[1..];
3854                let gid = if self.gid_hex {
3855                    u16::from_str_radix(suffix, 16).ok()
3856                } else {
3857                    suffix.parse::<u16>().ok()
3858                };
3859                if let Some(gid) = gid {
3860                    return Some(gid);
3861                }
3862            }
3863        }
3864        // Direct cmap lookup by char code — preferred over post table for subset
3865        // TrueType fonts where glyph names may not match character code positions.
3866        if let Some(&gid) = self.cmap.get(&(char_code as u32)) {
3867            return Some(gid);
3868        }
3869        if let Some(glyph_name) = &self.encoding[char_code as usize] {
3870            // Post table fallback (handles ligatures like fl/fi)
3871            if let Some(&gid) = self.post_name_to_gid.get(glyph_name.as_str()) {
3872                return Some(gid);
3873            }
3874        }
3875        // Windows Symbol encoding (U+F0XX range, common in subset fonts)
3876        if let Some(&gid) = self.cmap.get(&(0xF000 + char_code as u32)) {
3877            return Some(gid);
3878        }
3879        // ToUnicode → glyph lookup.  Handles buggy subset fonts where the (3,0)
3880        // Symbol cmap is missing entries (e.g. issue8234: 0xF020 omitted).
3881        // Try AGL name → post table first, then skrifa's charmap (which checks
3882        // all cmap subtables including ones our parser may not have selected).
3883        if let Some(&unicode) = self.to_unicode.get(&(char_code as u16)) {
3884            if let Some(name) = stet_fonts::system_fonts::unicode_to_glyph_name(unicode) {
3885                if let Some(&gid) = self.post_name_to_gid.get(name) {
3886                    return Some(gid);
3887                }
3888            }
3889            if let Ok(font_ref) = skrifa::FontRef::new(&self.data) {
3890                let charmap = font_ref.charmap();
3891                if let Some(gid) = charmap.map(unicode) {
3892                    return Some(gid.to_u32() as u16);
3893                }
3894            }
3895            // Last resort: scan for unmapped composite GIDs.  Buggy subset
3896            // fonts may omit cmap entries for some glyphs.  The missing glyph
3897            // is typically a TrueType composite (e.g. ä = a + dieresis) at an
3898            // unmapped GID — sometimes even GID 0 (issue8234).
3899            if !self.cmap.is_empty() {
3900                use stet_fonts::truetype::{find_table, read_u16};
3901                let mapped: std::collections::HashSet<u16> = self.cmap.values().copied().collect();
3902                let num_glyphs = find_table(&self.data, b"maxp")
3903                    .map(|(off, _)| read_u16(&self.data, off + 4))
3904                    .unwrap_or(0);
3905                // Find an unmapped GID that is a composite glyph (numContours < 0).
3906                // Composites are the actual characters; simple glyphs at unmapped
3907                // GIDs are base components (a, dieresis, ring, etc.).
3908                for gid in 0..num_glyphs {
3909                    if mapped.contains(&gid) {
3910                        continue;
3911                    }
3912                    if let Some(glyf_data) = get_glyf_data(&self.data, gid) {
3913                        if glyf_data.len() >= 2 {
3914                            let num_contours = stet_fonts::truetype::read_i16(&glyf_data, 0);
3915                            if num_contours < 0 {
3916                                return Some(gid);
3917                            }
3918                        }
3919                    }
3920                }
3921            }
3922        }
3923        if self.cmap.is_empty() {
3924            // No cmap table: use char code as GID directly (PDF subset identity mapping)
3925            Some(char_code as u16)
3926        } else {
3927            None
3928        }
3929    }
3930}
3931
3932impl CidTrueTypePdfFont {
3933    /// For UCS2 encodings, convert Unicode code point to CID for width lookup.
3934    fn resolve_cid(&self, code: u16) -> u16 {
3935        // Only remap when code_to_cid is empty (no CMap loaded) — in that case
3936        // the code IS a raw Unicode code point that needs mapping to a CID.
3937        // When a CMap IS loaded, it has already mapped to the correct CID.
3938        if self.ucs2_encoding && !self.ordering.is_empty() && self.code_to_cid.is_empty() {
3939            super::cid_unicode::unicode_to_cid(&self.ordering, code as u32).unwrap_or(code)
3940        } else {
3941            code
3942        }
3943    }
3944
3945    fn glyph_path_cid(&self, cid: u16) -> Option<PsPath> {
3946        if std::env::var("STET_DEBUG_TEXT").is_ok() {
3947            eprintln!(
3948                "[cid_tt] cid={} sub={} ordering={} identity={} to_unicode={} cmap={} cid_to_gid_map={}",
3949                cid,
3950                self.substituted,
3951                String::from_utf8_lossy(&self.ordering),
3952                self.identity_cid_to_gid,
3953                !self.to_unicode.is_empty(),
3954                !self.cmap.is_empty(),
3955                self.cid_to_gid_map.is_some()
3956            );
3957        }
3958        let gid = if self.ucs2_encoding && !self.cmap.is_empty() && self.code_to_cid.is_empty() {
3959            // UCS2 encoding with no CMap: cid is a raw Unicode code point, map via cmap.
3960            if let Some(&g) = self.cmap.get(&(cid as u32)) {
3961                g
3962            } else {
3963                return None;
3964            }
3965        } else if self.ucs2_encoding && self.substituted && !self.ordering.is_empty() {
3966            // CMap was loaded: cid is an Adobe CID, convert back to Unicode for glyph lookup
3967            let unicode = super::cid_unicode::cid_to_unicode(&self.ordering, cid)?;
3968            *self.cmap.get(&unicode)?
3969        } else if let Some(ref map) = self.cid_to_gid_map {
3970            // Explicit CIDToGIDMap stream: look up CID → GID
3971            *map.get(cid as usize).unwrap_or(&0)
3972        } else if self.substituted && !self.to_unicode.is_empty() {
3973            // Substituted font: CID → Unicode (via ToUnicode) → GID (via cmap)
3974            if let Some(&unicode) = self.to_unicode.get(&cid) {
3975                *self.cmap.get(&unicode)?
3976            } else {
3977                // GID not covered by the to_unicode map (e.g. extended Latin
3978                // chars missing from the standard glyph map). Fall back to
3979                // CID as GID directly — may be wrong but better than blank.
3980                cid
3981            }
3982        } else if self.substituted && !self.ordering.is_empty() && self.ordering != b"Identity" {
3983            // Substituted font with Adobe CID registry (CJK): use CID→Unicode table
3984            let unicode = super::cid_unicode::cid_to_unicode(&self.ordering, cid)?;
3985            *self.cmap.get(&unicode)?
3986        } else if self.identity_cid_to_gid {
3987            // Identity CIDToGIDMap: CID = GID directly.
3988            cid
3989        } else if self.substituted && !self.cmap.is_empty() {
3990            // Substituted font with no ToUnicode, no CIDToGIDMap, and non-identity:
3991            // treat CID as Unicode and map through the substitute's cmap.
3992            if let Some(&g) = self.cmap.get(&(cid as u32)) {
3993                g
3994            } else {
3995                cid
3996            }
3997        } else if !self.cmap.is_empty() {
3998            // Non-Identity mapping: CID is Unicode, use cmap
3999            *self.cmap.get(&(cid as u32))?
4000        } else {
4001            cid
4002        };
4003        let path = skrifa_glyph_path(&self.data, gid, self.units_per_em).or_else(|| {
4004            // Fallback for fonts where skrifa can't render a glyph (e.g. locx/glyx
4005            // PDF-subset tables, or skrifa CFF rendering gaps).
4006            let glyf_data = get_glyf_data(&self.data, gid)?;
4007            let data_ref = &self.data;
4008            let p = parse_glyf_to_path(&glyf_data, &|cid| get_glyf_data(data_ref, cid));
4009            // Sanity check: real glyphs have at most a few thousand segments.
4010            // Bogus GIDs reading random glyf bytes can produce millions.
4011            if p.is_empty() || p.segments.len() > 10_000 {
4012                None
4013            } else {
4014                Some(p)
4015            }
4016        });
4017        let path = path?;
4018        let scale = 1.0 / self.units_per_em;
4019        // For substituted fonts, scale glyphs horizontally so their width matches
4020        // the PDF's /W array (original font metrics). Without this, the substitute
4021        // font's wider/narrower glyphs cause crowded or sparse text.
4022        let m = if self.substituted {
4023            // Only scale horizontally when the CID has an explicit /W entry.
4024            // When the width comes from the substitute font's hmtx (no /W entry),
4025            // the advance and glyph width already match — scaling would stretch
4026            // the glyph to DW while the advance uses the natural hmtx width.
4027            let pdf_w = self.cid_widths.get(&cid).copied();
4028            let font_w =
4029                hmtx_advance_width(&self.data, gid, self.units_per_em).unwrap_or(0.0) / 1000.0;
4030            if let Some(pw) = pdf_w {
4031                if font_w > 0.001 && pw > 0.001 {
4032                    Matrix::new(scale * pw / font_w, 0.0, 0.0, scale, 0.0, 0.0)
4033                } else {
4034                    Matrix::scale(scale, scale)
4035                }
4036            } else {
4037                Matrix::scale(scale, scale)
4038            }
4039        } else {
4040            Matrix::scale(scale, scale)
4041        };
4042        Some(path.transform(&m))
4043    }
4044
4045    /// Check if a GID exists in the font (GID < numGlyphs from maxp table).
4046    /// Unlike glyph_path_cid, this returns true for space/whitespace GIDs
4047    /// that have no visible outline.
4048    fn has_glyph(&self, cid: u16) -> bool {
4049        // A CID with an explicit width in /W is always valid — even if we
4050        // can't resolve it to a glyph in the substitute font, the CID path
4051        // must be used so text advancement uses the correct width.
4052        if self.cid_widths.contains_key(&cid) {
4053            return true;
4054        }
4055        // Resolve CID to GID using the same logic as glyph_path_cid
4056        let gid = if let Some(ref map) = self.cid_to_gid_map {
4057            *map.get(cid as usize).unwrap_or(&0)
4058        } else if self.substituted && !self.to_unicode.is_empty() {
4059            // Substituted font with GID→Unicode table: resolve via cmap
4060            if let Some(&unicode) = self.to_unicode.get(&cid) {
4061                if let Some(&g) = self.cmap.get(&unicode) {
4062                    g
4063                } else {
4064                    return false;
4065                }
4066            } else {
4067                return false;
4068            }
4069        } else if self.identity_cid_to_gid {
4070            cid
4071        } else {
4072            return true; // non-identity: assume valid
4073        };
4074        // Check against font's glyph count
4075        let num_glyphs = stet_fonts::truetype::get_num_glyphs(&self.data);
4076        (gid as u32) < num_glyphs
4077    }
4078
4079    fn glyph_width_cid(&self, cid: u16) -> f64 {
4080        let resolved = self.resolve_cid(cid);
4081        if let Some(&w) = self.cid_widths.get(&resolved) {
4082            return w;
4083        }
4084        // For substituted fonts with GID-to-Unicode tables, use the substitute
4085        // font's actual advance width instead of /DW. Many PDFs only populate
4086        // /W for a subset of CIDs, and /DW 1000 (full em) is wildly wrong for
4087        // narrow Latin characters like accented letters.
4088        if self.substituted && !self.to_unicode.is_empty() {
4089            if let Some(&unicode) = self.to_unicode.get(&cid) {
4090                if let Some(&gid) = self.cmap.get(&unicode) {
4091                    if let Some(w) = hmtx_advance_width(&self.data, gid, self.units_per_em) {
4092                        return w / 1000.0;
4093                    }
4094                }
4095            }
4096        }
4097        self.default_width
4098    }
4099
4100    /// Get glyph path for a Unicode code point via cmap, bypassing CID mapping.
4101    /// Used when malformed PDFs embed WinAnsi literal strings in a CID font.
4102    fn glyph_path_unicode(&self, unicode: u16) -> Option<PsPath> {
4103        let &gid = self.cmap.get(&(unicode as u32))?;
4104        let path = skrifa_glyph_path(&self.data, gid, self.units_per_em)?;
4105        let scale = 1.0 / self.units_per_em;
4106        let m = Matrix::scale(scale, scale);
4107        Some(path.transform(&m))
4108    }
4109
4110    /// Get width for a Unicode code point from hmtx via cmap, bypassing CID widths.
4111    /// Returns width in the same scale as glyph_width_cid (1/1000 of text space).
4112    fn glyph_width_unicode(&self, unicode: u16) -> f64 {
4113        if let Some(&gid) = self.cmap.get(&(unicode as u32)) {
4114            // hmtx_advance_width returns units in 1/1000 em; CID widths are stored
4115            // already divided by 1000, so divide here too for consistency.
4116            hmtx_advance_width(&self.data, gid, self.units_per_em)
4117                .map(|w| w / 1000.0)
4118                .unwrap_or(self.default_width)
4119        } else {
4120            self.default_width
4121        }
4122    }
4123}
4124
4125impl CidCffPdfFont {
4126    /// Render the CFF charstring at the given GID.
4127    fn glyph_path_at_gid(&self, gid: usize) -> Option<PsPath> {
4128        if gid >= self.font.char_strings.len() {
4129            return None;
4130        }
4131        let (default_width_x, nominal_width_x, local_subrs, fd_font_matrix) = if self.font.is_cid
4132            && !self.font.fd_select.is_empty()
4133            && !self.font.fd_array.is_empty()
4134        {
4135            let fd_idx = *self.font.fd_select.get(gid).unwrap_or(&0) as usize;
4136            if let Some(fd) = self.font.fd_array.get(fd_idx) {
4137                (
4138                    fd.default_width_x,
4139                    fd.nominal_width_x,
4140                    &fd.local_subrs,
4141                    fd.font_matrix,
4142                )
4143            } else {
4144                (
4145                    self.font.default_width_x,
4146                    self.font.nominal_width_x,
4147                    &self.font.local_subrs,
4148                    None,
4149                )
4150            }
4151        } else {
4152            (
4153                self.font.default_width_x,
4154                self.font.nominal_width_x,
4155                &self.font.local_subrs,
4156                None,
4157            )
4158        };
4159        let result = execute_type2_charstring(
4160            &self.font.char_strings[gid],
4161            local_subrs,
4162            &self.font.global_subrs,
4163            default_width_x,
4164            nominal_width_x,
4165            false,
4166        )
4167        .ok()?;
4168        let effective_fm = if let Some(fd_fm) = fd_font_matrix {
4169            let fd = Matrix::new(fd_fm[0], fd_fm[1], fd_fm[2], fd_fm[3], fd_fm[4], fd_fm[5]);
4170            if fd.a.abs() < 0.01 || fd.d.abs() < 0.01 {
4171                fd
4172            } else {
4173                self.font_matrix.concat(&fd)
4174            }
4175        } else {
4176            self.font_matrix
4177        };
4178        Some(result.path.transform(&effective_fm))
4179    }
4180
4181    /// Render a glyph by Unicode code point via the font's cmap table.
4182    fn glyph_path_unicode(&self, unicode: u16) -> Option<PsPath> {
4183        let cmap = self.cmap.as_ref()?;
4184        let &gid = cmap.get(&(unicode as u32))?;
4185        self.glyph_path_at_gid(gid as usize)
4186    }
4187
4188    fn glyph_path_cid(&self, cid: u16) -> Option<PsPath> {
4189        // Type 1 fonts wrapped as CIDFontType0: use pre-computed paths.
4190        if let Some(ref paths) = self.type1_paths {
4191            return paths.get(&cid).cloned();
4192        }
4193        // For embedded OTF/CFF with PDF CIDToGIDMap, use the PDF's mapping.
4194        // For OpenType/CFF substitutes with a cmap, map Unicode → GID directly.
4195        // For embedded CID-keyed CFF, use cid_to_gid mapping.
4196        let gid = if let Some(ref map) = self.pdf_cid_to_gid {
4197            // Embedded font with PDF-supplied CID→GID map
4198            *map.get(cid as usize).unwrap_or(&0) as usize
4199        } else if self.identity_cid_to_gid {
4200            // Identity CIDToGIDMap: CID = charstring index directly.
4201            // Common for CIDFontType2 fonts stored as OTTO/CFF in FontFile2.
4202            cid as usize
4203        } else if let Some(ref cmap) = self.cmap {
4204            // OTF font with Unicode cmap (substituted fonts, or non-CID fonts).
4205            // If this is a substituted font with an Adobe CID ordering
4206            // (e.g. Japan1), the CID is from the Adobe registry, not Unicode.
4207            // Convert CID → Unicode first, then look up in cmap.
4208            if !self.ordering.is_empty() && self.ordering != b"Identity" {
4209                let unicode = super::cid_unicode::cid_to_unicode(&self.ordering, cid)?;
4210                // For CJK substitution, try full-width glyph variants first.
4211                // The substitute font may have a proportional glyph for U+00B7
4212                // (MIDDLE DOT, narrow) while the original CJK font used a
4213                // full-width centered dot. U+30FB is the CJK full-width variant.
4214                let gid_opt = cjk_fullwidth_alternative(unicode)
4215                    .and_then(|alt| cmap.get(&alt))
4216                    .or_else(|| cmap.get(&unicode));
4217                *gid_opt? as usize
4218            } else {
4219                *cmap.get(&(cid as u32))? as usize
4220            }
4221        } else if !self.font.cid_to_gid.is_empty() {
4222            let g = *self.font.cid_to_gid.get(cid as usize)?;
4223            if g == 0xFFFF {
4224                return None;
4225            }
4226            g as usize
4227        } else {
4228            cid as usize
4229        };
4230        self.glyph_path_at_gid(gid)
4231    }
4232
4233    fn glyph_width_cid(&self, cid: u16) -> f64 {
4234        // CID widths from the /W array are already keyed by CID — use directly.
4235        self.cid_widths
4236            .get(&cid)
4237            .copied()
4238            .unwrap_or(self.default_width)
4239    }
4240}
4241
4242impl CffPdfFont {
4243    fn glyph_path(&self, char_code: u8) -> Option<PsPath> {
4244        let glyph_name = self.encoding[char_code as usize].as_deref()?;
4245        // The PDF /Encoding is authoritative: map char_code → glyph name,
4246        // then find that glyph in the CFF charset. This is essential for
4247        // subset fonts where the CFF internal encoding maps codes to
4248        // sequential GIDs that don't match the PDF encoding's glyph names.
4249        // Fall back to the CFF's built-in encoding only when the charset
4250        // lookup fails (e.g., fonts without a proper charset).
4251        let gid = self
4252            .font
4253            .charset
4254            .iter()
4255            .position(|name| name == glyph_name)
4256            .or_else(|| {
4257                let cff_gid = self
4258                    .font
4259                    .encoding
4260                    .get(char_code as usize)
4261                    .copied()
4262                    .unwrap_or(0) as usize;
4263                if cff_gid > 0 && cff_gid < self.font.char_strings.len() {
4264                    Some(cff_gid)
4265                } else {
4266                    None
4267                }
4268            });
4269        let gid = gid?;
4270        if gid >= self.font.char_strings.len() {
4271            return None;
4272        }
4273        let result = execute_type2_charstring(
4274            &self.font.char_strings[gid],
4275            &self.font.local_subrs,
4276            &self.font.global_subrs,
4277            self.font.default_width_x,
4278            self.font.nominal_width_x,
4279            false,
4280        )
4281        .ok()?;
4282
4283        // Handle deprecated seac (accented character composition)
4284        if let Some((adx, ady, bchar, achar)) = result.seac {
4285            return self.compose_seac(adx, ady, bchar, achar);
4286        }
4287
4288        Some(result.path)
4289    }
4290
4291    /// Compose a seac (Standard Encoding Accented Character) glyph from
4292    /// base and accent glyphs. bchar/achar are Standard Encoding codes.
4293    fn compose_seac(&self, adx: f64, ady: f64, bchar: u8, achar: u8) -> Option<PsPath> {
4294        use stet_fonts::encoding::STANDARD_ENCODING;
4295
4296        let base_name = STANDARD_ENCODING.get(bchar as usize).copied().unwrap_or("");
4297        let accent_name = STANDARD_ENCODING.get(achar as usize).copied().unwrap_or("");
4298
4299        let base_gid = self.font.charset.iter().position(|n| n == base_name)?;
4300        let accent_gid = self.font.charset.iter().position(|n| n == accent_name)?;
4301
4302        let base_result = execute_type2_charstring(
4303            &self.font.char_strings[base_gid],
4304            &self.font.local_subrs,
4305            &self.font.global_subrs,
4306            self.font.default_width_x,
4307            self.font.nominal_width_x,
4308            false,
4309        )
4310        .ok()?;
4311
4312        let accent_result = execute_type2_charstring(
4313            &self.font.char_strings[accent_gid],
4314            &self.font.local_subrs,
4315            &self.font.global_subrs,
4316            self.font.default_width_x,
4317            self.font.nominal_width_x,
4318            false,
4319        )
4320        .ok()?;
4321
4322        // Combine: base path + accent path offset by (adx, ady)
4323        let mut combined = base_result.path;
4324        let offset = Matrix::translate(adx, ady);
4325        let shifted_accent = accent_result.path.transform(&offset);
4326        combined
4327            .segments
4328            .extend_from_slice(&shifted_accent.segments);
4329        Some(combined)
4330    }
4331}
4332
4333/// Pen adapter that converts skrifa outline callbacks into a `PsPath`.
4334struct PsPathPen {
4335    path: PsPath,
4336    cur_x: f64,
4337    cur_y: f64,
4338}
4339
4340impl skrifa::outline::OutlinePen for PsPathPen {
4341    fn move_to(&mut self, x: f32, y: f32) {
4342        self.cur_x = x as f64;
4343        self.cur_y = y as f64;
4344        self.path
4345            .segments
4346            .push(PathSegment::MoveTo(self.cur_x, self.cur_y));
4347    }
4348    fn line_to(&mut self, x: f32, y: f32) {
4349        self.cur_x = x as f64;
4350        self.cur_y = y as f64;
4351        self.path
4352            .segments
4353            .push(PathSegment::LineTo(self.cur_x, self.cur_y));
4354    }
4355    fn quad_to(&mut self, cx: f32, cy: f32, x: f32, y: f32) {
4356        let cx = cx as f64;
4357        let cy = cy as f64;
4358        let ex = x as f64;
4359        let ey = y as f64;
4360        // Quadratic → cubic degree elevation
4361        let cp1x = self.cur_x + 2.0 / 3.0 * (cx - self.cur_x);
4362        let cp1y = self.cur_y + 2.0 / 3.0 * (cy - self.cur_y);
4363        let cp2x = ex + 2.0 / 3.0 * (cx - ex);
4364        let cp2y = ey + 2.0 / 3.0 * (cy - ey);
4365        self.cur_x = ex;
4366        self.cur_y = ey;
4367        self.path.segments.push(PathSegment::CurveTo {
4368            x1: cp1x,
4369            y1: cp1y,
4370            x2: cp2x,
4371            y2: cp2y,
4372            x3: ex,
4373            y3: ey,
4374        });
4375    }
4376    fn curve_to(&mut self, cx0: f32, cy0: f32, cx1: f32, cy1: f32, x: f32, y: f32) {
4377        self.cur_x = x as f64;
4378        self.cur_y = y as f64;
4379        self.path.segments.push(PathSegment::CurveTo {
4380            x1: cx0 as f64,
4381            y1: cy0 as f64,
4382            x2: cx1 as f64,
4383            y2: cy1 as f64,
4384            x3: self.cur_x,
4385            y3: self.cur_y,
4386        });
4387    }
4388    fn close(&mut self) {
4389        self.path.segments.push(PathSegment::ClosePath);
4390    }
4391}
4392
4393/// Extract a TrueType glyph outline using skrifa with hinting enabled.
4394///
4395/// Hinting is needed for correct composite glyph assembly — some fonts have
4396/// TrueType instructions that adjust component positions. Falls back to the
4397/// hand-written parser for fonts skrifa can't handle (e.g., locx/glyx subsets).
4398/// Map a WinAnsiEncoding byte to its Unicode code point.
4399/// Bytes 0x00-0x7F and 0xA0-0xFF match Unicode (ISO 8859-1).
4400/// Bytes 0x80-0x9F differ — WinAnsi maps these to specific Unicode characters.
4401pub(crate) fn winansi_byte_to_unicode(byte: u8) -> u16 {
4402    match byte {
4403        0x80 => 0x20AC, // €
4404        0x82 => 0x201A, // ‚
4405        0x83 => 0x0192, // ƒ
4406        0x84 => 0x201E, // „
4407        0x85 => 0x2026, // …
4408        0x86 => 0x2020, // †
4409        0x87 => 0x2021, // ‡
4410        0x88 => 0x02C6, // ˆ
4411        0x89 => 0x2030, // ‰
4412        0x8A => 0x0160, // Š
4413        0x8B => 0x2039, // ‹
4414        0x8C => 0x0152, // Œ
4415        0x8E => 0x017D, // Ž
4416        0x91 => 0x2018, // '
4417        0x92 => 0x2019, // '
4418        0x93 => 0x201C, // "
4419        0x94 => 0x201D, // "
4420        0x95 => 0x2022, // •
4421        0x96 => 0x2013, // –
4422        0x97 => 0x2014, // —
4423        0x98 => 0x02DC, // ˜
4424        0x99 => 0x2122, // ™
4425        0x9A => 0x0161, // š
4426        0x9B => 0x203A, // ›
4427        0x9C => 0x0153, // œ
4428        0x9E => 0x017E, // ž
4429        0x9F => 0x0178, // Ÿ
4430        _ => byte as u16,
4431    }
4432}
4433
4434/// Read the advance width for a GID from the hmtx table, returning the width
4435/// in text space (1/1000 em) for PDF CID width compatibility.
4436fn hmtx_advance_width(font_data: &[u8], gid: u16, units_per_em: f64) -> Option<f64> {
4437    use stet_fonts::truetype::{find_table, read_u16};
4438    let (hhea_off, _) = find_table(font_data, b"hhea")?;
4439    let (hmtx_off, _) = find_table(font_data, b"hmtx")?;
4440    if hhea_off + 36 > font_data.len() {
4441        return None;
4442    }
4443    let num_h_metrics = read_u16(font_data, hhea_off + 34) as usize;
4444    let gid = gid as usize;
4445    let advance = if gid < num_h_metrics {
4446        let offset = hmtx_off + gid * 4;
4447        if offset + 2 > font_data.len() {
4448            return None;
4449        }
4450        read_u16(font_data, offset)
4451    } else {
4452        // Use last metric for GIDs beyond num_h_metrics
4453        if num_h_metrics == 0 {
4454            return None;
4455        }
4456        let offset = hmtx_off + (num_h_metrics - 1) * 4;
4457        if offset + 2 > font_data.len() {
4458            return None;
4459        }
4460        read_u16(font_data, offset)
4461    };
4462    // Convert from font units to 1/1000 em (PDF text space)
4463    Some(advance as f64 / units_per_em * 1000.0)
4464}
4465
4466fn skrifa_glyph_path(font_data: &[u8], gid: u16, units_per_em: f64) -> Option<PsPath> {
4467    // Use from_index(0) to handle both plain TrueType and TTC files.
4468    let font_ref = skrifa::FontRef::from_index(font_data, 0).ok()?;
4469    let outlines = font_ref.outline_glyphs();
4470    let glyph = outlines.get(skrifa::GlyphId::new(gid as u32))?;
4471
4472    // Use TrueType bytecode interpreter with mono hinting for correct composite
4473    // glyph assembly. Some fonts have TT instructions that adjust component positions;
4474    // the auto-hinter doesn't handle these correctly.
4475    let hinting = skrifa::outline::HintingInstance::new(
4476        &outlines,
4477        skrifa::prelude::Size::new(units_per_em as f32),
4478        skrifa::instance::LocationRef::default(),
4479        skrifa::outline::HintingOptions {
4480            engine: skrifa::outline::Engine::Interpreter,
4481            target: skrifa::outline::Target::Mono,
4482        },
4483    )
4484    .ok();
4485
4486    let mut pen = PsPathPen {
4487        path: PsPath::new(),
4488        cur_x: 0.0,
4489        cur_y: 0.0,
4490    };
4491
4492    let result = if let Some(ref instance) = hinting {
4493        glyph.draw(instance, &mut pen)
4494    } else {
4495        glyph.draw(
4496            skrifa::outline::DrawSettings::unhinted(
4497                skrifa::prelude::Size::new(units_per_em as f32),
4498                skrifa::instance::LocationRef::default(),
4499            ),
4500            &mut pen,
4501        )
4502    };
4503
4504    result.ok()?;
4505    if pen.path.is_empty() {
4506        None
4507    } else {
4508        Some(pen.path)
4509    }
4510}