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    (
1729        "P052-Roman",
1730        include_bytes!("../../fonts/P052-Roman.t1"),
1731    ),
1732    (
1733        "P052-Bold",
1734        include_bytes!("../../fonts/P052-Bold.t1"),
1735    ),
1736    (
1737        "P052-Italic",
1738        include_bytes!("../../fonts/P052-Italic.t1"),
1739    ),
1740    (
1741        "P052-BoldItalic",
1742        include_bytes!("../../fonts/P052-BoldItalic.t1"),
1743    ),
1744    // C059 (New Century Schoolbook)
1745    (
1746        "C059-Roman",
1747        include_bytes!("../../fonts/C059-Roman.t1"),
1748    ),
1749    (
1750        "C059-Bold",
1751        include_bytes!("../../fonts/C059-Bold.t1"),
1752    ),
1753    (
1754        "C059-Italic",
1755        include_bytes!("../../fonts/C059-Italic.t1"),
1756    ),
1757    (
1758        "C059-BdIta",
1759        include_bytes!("../../fonts/C059-BdIta.t1"),
1760    ),
1761    // URWBookman (Bookman)
1762    (
1763        "URWBookman-Light",
1764        include_bytes!("../../fonts/URWBookman-Light.t1"),
1765    ),
1766    (
1767        "URWBookman-Demi",
1768        include_bytes!("../../fonts/URWBookman-Demi.t1"),
1769    ),
1770    (
1771        "URWBookman-LightItalic",
1772        include_bytes!("../../fonts/URWBookman-LightItalic.t1"),
1773    ),
1774    (
1775        "URWBookman-DemiItalic",
1776        include_bytes!("../../fonts/URWBookman-DemiItalic.t1"),
1777    ),
1778    // URWGothic (AvantGarde)
1779    (
1780        "URWGothic-Book",
1781        include_bytes!("../../fonts/URWGothic-Book.t1"),
1782    ),
1783    (
1784        "URWGothic-Demi",
1785        include_bytes!("../../fonts/URWGothic-Demi.t1"),
1786    ),
1787    (
1788        "URWGothic-BookOblique",
1789        include_bytes!("../../fonts/URWGothic-BookOblique.t1"),
1790    ),
1791    (
1792        "URWGothic-DemiOblique",
1793        include_bytes!("../../fonts/URWGothic-DemiOblique.t1"),
1794    ),
1795    // Symbol fonts
1796    (
1797        "StandardSymbolsPS",
1798        include_bytes!("../../fonts/StandardSymbolsPS.t1"),
1799    ),
1800    (
1801        "D050000L",
1802        include_bytes!("../../fonts/D050000L.t1"),
1803    ),
1804    (
1805        "Z003-MediumItalic",
1806        include_bytes!("../../fonts/Z003-MediumItalic.t1"),
1807    ),
1808];
1809
1810/// Look up an embedded Type 1 substitute font by name.
1811fn embedded_font(name: &str) -> Option<Vec<u8>> {
1812    EMBEDDED_FONTS
1813        .iter()
1814        .find(|(n, _)| *n == name)
1815        .map(|(_, data)| data.to_vec())
1816}
1817
1818/// Read a font file, handling TrueType Collection (.ttc) files by extracting
1819/// the sub-font matching `ps_name` (or the first font if no match found).
1820fn read_font_file(path: &std::path::Path, ps_name: &str) -> std::io::Result<Vec<u8>> {
1821    let data = std::fs::read(path)?;
1822    if data.len() > 12 && &data[0..4] == b"ttcf" {
1823        // TTC: extract the sub-font at the correct offset
1824        let num_fonts = u32::from_be_bytes([data[8], data[9], data[10], data[11]]) as usize;
1825        // Try to find the font matching ps_name by checking each font's name table
1826        let mut best_offset = if num_fonts > 0 {
1827            u32::from_be_bytes([data[12], data[13], data[14], data[15]]) as usize
1828        } else {
1829            0
1830        };
1831        for i in 0..num_fonts {
1832            let off_pos = 12 + i * 4;
1833            if off_pos + 4 > data.len() {
1834                break;
1835            }
1836            let font_offset = u32::from_be_bytes([
1837                data[off_pos],
1838                data[off_pos + 1],
1839                data[off_pos + 2],
1840                data[off_pos + 3],
1841            ]) as usize;
1842            // Check PostScript name in the name table of this sub-font
1843            if let Some(name) = extract_ps_name_at_offset(&data, font_offset)
1844                && name == ps_name
1845            {
1846                best_offset = font_offset;
1847                break;
1848            }
1849        }
1850        // Build a standalone TTF by rewriting the header to point to tables
1851        // at their absolute offsets within the TTC
1852        extract_ttf_from_ttc(&data, best_offset)
1853    } else {
1854        Ok(data)
1855    }
1856}
1857
1858/// Extract the PostScript name from a font at a given offset within TTC data.
1859fn extract_ps_name_at_offset(data: &[u8], offset: usize) -> Option<String> {
1860    use stet_fonts::truetype::read_u16;
1861    // Manually find the 'name' table from the sub-font's table directory
1862    if offset + 12 > data.len() {
1863        return None;
1864    }
1865    let num_tables = read_u16(data, offset + 4) as usize;
1866    let mut name_off = 0usize;
1867    let mut name_len = 0usize;
1868    for i in 0..num_tables {
1869        let entry = offset + 12 + i * 16;
1870        if entry + 16 > data.len() {
1871            break;
1872        }
1873        if &data[entry..entry + 4] == b"name" {
1874            name_off = u32::from_be_bytes([
1875                data[entry + 8],
1876                data[entry + 9],
1877                data[entry + 10],
1878                data[entry + 11],
1879            ]) as usize;
1880            name_len = u32::from_be_bytes([
1881                data[entry + 12],
1882                data[entry + 13],
1883                data[entry + 14],
1884                data[entry + 15],
1885            ]) as usize;
1886            break;
1887        }
1888    }
1889    if name_off == 0 || name_off + name_len > data.len() {
1890        return None;
1891    }
1892    let nd = &data[name_off..name_off + name_len];
1893    let count = read_u16(nd, 2) as usize;
1894    let string_offset = read_u16(nd, 4) as usize;
1895    for i in 0..count {
1896        let rec = 6 + i * 12;
1897        if rec + 12 > nd.len() {
1898            break;
1899        }
1900        let pid = read_u16(nd, rec);
1901        let name_id = read_u16(nd, rec + 6);
1902        let length = read_u16(nd, rec + 8) as usize;
1903        let str_off = read_u16(nd, rec + 10) as usize;
1904        if name_id == 6 {
1905            let start = string_offset + str_off;
1906            if start + length <= nd.len() {
1907                let raw = &nd[start..start + length];
1908                if pid == 3 {
1909                    let s: String = raw
1910                        .chunks(2)
1911                        .filter_map(|c| {
1912                            if c.len() == 2 {
1913                                Some(u16::from_be_bytes([c[0], c[1]]) as u8 as char)
1914                            } else {
1915                                None
1916                            }
1917                        })
1918                        .collect();
1919                    return Some(s);
1920                } else {
1921                    return Some(String::from_utf8_lossy(raw).to_string());
1922                }
1923            }
1924        }
1925    }
1926    None
1927}
1928
1929/// Extract a single TTF from a TTC by building a standalone font file.
1930/// The sub-font header at `font_offset` contains a table directory with
1931/// offsets that are absolute within the TTC. We copy the header + directory
1932/// and then append all referenced table data, adjusting offsets accordingly.
1933fn extract_ttf_from_ttc(ttc_data: &[u8], font_offset: usize) -> std::io::Result<Vec<u8>> {
1934    use stet_fonts::truetype::{read_u16, read_u32};
1935
1936    if font_offset + 12 > ttc_data.len() {
1937        return Err(std::io::Error::other("TTC font offset out of range"));
1938    }
1939
1940    let num_tables = read_u16(ttc_data, font_offset + 4) as usize;
1941    let header_size = 12 + num_tables * 16;
1942
1943    // Collect table info: (tag, ttc_offset, length)
1944    let mut tables = Vec::with_capacity(num_tables);
1945    for i in 0..num_tables {
1946        let entry = font_offset + 12 + i * 16;
1947        if entry + 16 > ttc_data.len() {
1948            break;
1949        }
1950        let tag = &ttc_data[entry..entry + 4];
1951        let offset = read_u32(ttc_data, entry + 8) as usize;
1952        let length = read_u32(ttc_data, entry + 12) as usize;
1953        tables.push((tag.to_vec(), offset, length));
1954    }
1955
1956    // Build standalone TTF: header + directory + table data
1957    let mut result = Vec::with_capacity(
1958        header_size + tables.iter().map(|(_, _, l)| (l + 3) & !3).sum::<usize>(),
1959    );
1960
1961    // Copy the 12-byte sfnt header
1962    result.extend_from_slice(&ttc_data[font_offset..font_offset + 12]);
1963
1964    // First pass: compute new offsets (tables follow directory)
1965    let mut data_offset = header_size as u32;
1966    let mut new_offsets = Vec::with_capacity(num_tables);
1967    for (_, _, length) in &tables {
1968        new_offsets.push(data_offset);
1969        data_offset += ((*length as u32) + 3) & !3; // 4-byte aligned
1970    }
1971
1972    // Write table directory with new offsets
1973    for (i, (tag, _, length)) in tables.iter().enumerate() {
1974        let entry = font_offset + 12 + i * 16;
1975        result.extend_from_slice(tag); // tag
1976        result.extend_from_slice(&ttc_data[entry + 4..entry + 8]); // checksum
1977        result.extend_from_slice(&new_offsets[i].to_be_bytes()); // new offset
1978        result.extend_from_slice(&(*length as u32).to_be_bytes()); // length
1979    }
1980
1981    // Copy table data
1982    for (_, ttc_offset, length) in &tables {
1983        let end = (*ttc_offset + *length).min(ttc_data.len());
1984        if *ttc_offset < ttc_data.len() {
1985            result.extend_from_slice(&ttc_data[*ttc_offset..end]);
1986            // Pad to 4-byte alignment
1987            let pad = (4 - (length % 4)) % 4;
1988            result.extend(std::iter::repeat_n(0u8, pad));
1989        }
1990    }
1991
1992    Ok(result)
1993}
1994
1995/// Resolve a Type 3 font: glyphs defined as content streams.
1996fn resolve_type3(resolver: &Resolver, font_dict: &PdfDict) -> Result<PdfFont, PdfError> {
1997    let first_char = font_dict.get_int(b"FirstChar").unwrap_or(0) as usize;
1998
1999    // Parse widths array (already in glyph space — Type 3 FontMatrix maps to text space).
2000    // /Widths may be an indirect reference — resolve before accessing.
2001    let mut widths = [0.0f64; 256];
2002    let widths_resolved = font_dict
2003        .get(b"Widths")
2004        .and_then(|obj| resolver.deref(obj).ok());
2005    if let Some(ref w_obj) = widths_resolved
2006        && let Some(w_arr) = w_obj.as_array()
2007    {
2008        for (i, obj) in w_arr.iter().enumerate() {
2009            let code = first_char + i;
2010            if code < 256 {
2011                // Width entries may be indirect references
2012                let val = if obj.as_f64().is_some() {
2013                    obj.as_f64().unwrap()
2014                } else if let Ok(resolved) = resolver.deref(obj) {
2015                    resolved.as_f64().unwrap_or(0.0)
2016                } else {
2017                    0.0
2018                };
2019                widths[code] = val;
2020            }
2021        }
2022    }
2023
2024    // FontMatrix (typically something like [0.01 0 0 0.01 0 0] for 100-unit glyph space)
2025    let font_matrix = font_dict
2026        .get_array(b"FontMatrix")
2027        .map(|a| {
2028            let v: Vec<f64> = a.iter().filter_map(|o| o.as_f64()).collect();
2029            if v.len() >= 6 {
2030                Matrix::new(v[0], v[1], v[2], v[3], v[4], v[5])
2031            } else {
2032                Matrix::new(0.001, 0.0, 0.0, 0.001, 0.0, 0.0)
2033            }
2034        })
2035        .unwrap_or_else(|| Matrix::new(0.001, 0.0, 0.0, 0.001, 0.0, 0.0));
2036
2037    let font_bbox = font_dict
2038        .get_array(b"FontBBox")
2039        .map(|a| {
2040            let v: Vec<f64> = a.iter().filter_map(|o| o.as_f64()).collect();
2041            if v.len() >= 4 {
2042                [v[0], v[1], v[2], v[3]]
2043            } else {
2044                [0.0, 0.0, 1.0, 1.0]
2045            }
2046        })
2047        .unwrap_or([0.0, 0.0, 1.0, 1.0]);
2048
2049    // Resolve encoding: maps char codes → glyph names in CharProcs
2050    let (encoding, _, _, _) = resolve_encoding(font_dict, resolver)?;
2051
2052    // Get CharProcs dict: maps glyph names → content streams
2053    // May be a direct dict or an indirect reference
2054    let char_procs_dict = if let Some(obj) = font_dict.get(b"CharProcs") {
2055        match resolver.deref(obj)? {
2056            PdfObj::Dict(d) => d,
2057            _ => return Err(PdfError::Other("Type3 CharProcs is not a dict".into())),
2058        }
2059    } else {
2060        return Err(PdfError::Other("Type3 font missing CharProcs".into()));
2061    };
2062
2063    // Resources for interpreting CharProc streams
2064    let resources = if let Some(res_ref) = font_dict.get(b"Resources") {
2065        match resolver.deref(res_ref)? {
2066            PdfObj::Dict(d) => d,
2067            _ => PdfDict::new(),
2068        }
2069    } else {
2070        PdfDict::new()
2071    };
2072
2073    // Pre-decode all CharProc streams: encoding[code] → stream bytes
2074    let mut char_procs = HashMap::new();
2075    for code in 0..256u16 {
2076        if let Some(glyph_name) = &encoding[code as usize]
2077            && let Some(proc_ref) = char_procs_dict.get(glyph_name.as_bytes())
2078            && let Ok(data) = resolver.stream_data_from_obj(proc_ref)
2079        {
2080            char_procs.insert(code as u8, data);
2081        }
2082    }
2083    Ok(PdfFont::Type3(Type3PdfFont {
2084        char_procs,
2085        resources,
2086        widths,
2087        font_matrix,
2088        font_bbox,
2089    }))
2090}
2091
2092fn resolve_type1(
2093    resolver: &Resolver,
2094    descriptor: &Option<PdfDict>,
2095    encoding: [Option<String>; 256],
2096    widths: [f64; 256],
2097    has_explicit_encoding: bool,
2098    has_pdf_widths: bool,
2099    differences: &[(usize, String)],
2100    no_base_encoding: bool,
2101) -> Result<PdfFont, PdfError> {
2102    let desc = descriptor
2103        .as_ref()
2104        .ok_or(PdfError::Other("Type1 font missing FontDescriptor".into()))?;
2105    // Check FontFile first (traditional Type 1), then FontFile3 (CFF or Type1C)
2106    if let Some(ff3_ref) = desc.get(b"FontFile3") {
2107        // FontFile3 may contain CFF (Type1C) data — handle via CFF parser
2108        let ff3_obj = resolver.deref(ff3_ref)?;
2109        let ff3_dict = ff3_obj.as_dict();
2110        let subtype = ff3_dict.and_then(|d| d.get_name(b"Subtype")).unwrap_or(b"");
2111        if subtype == b"Type1C" || subtype == b"CIDFontType0C" || subtype == b"OpenType" {
2112            let raw_data = resolver.stream_data_from_obj(ff3_ref)?;
2113            // If data starts with "OTTO" it's an OpenType container — extract CFF table
2114            let font_data = if raw_data.starts_with(b"OTTO") {
2115                use stet_fonts::truetype::find_table;
2116                let (offset, length) = find_table(&raw_data, b"CFF ")
2117                    .ok_or(PdfError::Other("OpenType font has no CFF table".into()))?;
2118                raw_data[offset..offset + length].to_vec()
2119            } else {
2120                raw_data
2121            };
2122            let fonts = parse_cff(&font_data)
2123                .map_err(|e| PdfError::Other(format!("CFF parse error: {e}")))?;
2124            let font = fonts
2125                .into_iter()
2126                .next()
2127                .ok_or(PdfError::Other("CFF contains no fonts".into()))?;
2128
2129            let fm = font.font_matrix;
2130            let font_matrix = Matrix::new(fm[0], fm[1], fm[2], fm[3], fm[4], fm[5]);
2131
2132            return Ok(PdfFont::Cff(CffPdfFont {
2133                font,
2134                encoding,
2135                widths,
2136                font_matrix,
2137            }));
2138        }
2139    }
2140
2141    let ff_ref = desc
2142        .get(b"FontFile")
2143        .or_else(|| desc.get(b"FontFile3"))
2144        .ok_or(PdfError::Other("Type1 font missing FontFile".into()))?;
2145    let font_data = resolver.stream_data_from_obj(ff_ref)?;
2146
2147    // Strip PFB (Printer Font Binary) headers if present
2148    let font_data = strip_pfb(&font_data);
2149
2150    let font =
2151        parse_type1(&font_data).map_err(|e| PdfError::Other(format!("Type1 parse error: {e}")))?;
2152
2153    // PDF spec 9.6.6.1: encoding base depends on whether the font is embedded.
2154    // When the /Encoding dict has no /BaseEncoding, the base for embedded fonts
2155    // is the font's built-in encoding (not StandardEncoding). This matters for
2156    // expert fonts where e.g. code 97 = "Asmall", not "a".
2157    let encoding = if no_base_encoding && font.encoding.len() == 256 {
2158        // Encoding dict had no BaseEncoding. For embedded fonts,
2159        // the base is the font's built-in encoding, not StandardEncoding.
2160        let mut builtin: [Option<String>; 256] = std::array::from_fn(|_| None);
2161        for (i, name) in font.encoding.iter().enumerate() {
2162            if name != ".notdef" {
2163                builtin[i] = Some(name.clone());
2164            }
2165        }
2166        for (code, name) in differences {
2167            if *code < 256 {
2168                builtin[*code] = Some(name.clone());
2169            }
2170        }
2171        builtin
2172    } else if !has_explicit_encoding {
2173        let flags = desc.get_int(b"Flags").unwrap_or(0) as u32;
2174        let is_symbolic = flags & 4 != 0;
2175        if is_symbolic && font.encoding.len() == 256 {
2176            let mut builtin: [Option<String>; 256] = std::array::from_fn(|_| None);
2177            for (i, name) in font.encoding.iter().enumerate() {
2178                if name != ".notdef" {
2179                    builtin[i] = Some(name.clone());
2180                }
2181            }
2182            builtin
2183        } else {
2184            encoding
2185        }
2186    } else {
2187        encoding
2188    };
2189
2190    // Check if the encoding's glyph names are completely incompatible with the
2191    // font's CharStrings (e.g. StandardEncoding "A","B" vs custom "G41","G42").
2192    // If so, enable fallback to the font's built-in encoding at glyph lookup.
2193    let builtin_fallback = {
2194        let flags = desc.get_int(b"Flags").unwrap_or(0) as u32;
2195        let is_sym = flags & 4 != 0;
2196        let builtin_useful =
2197            is_sym && font.encoding.len() == 256 && font.encoding.iter().any(|n| n != ".notdef");
2198        if builtin_useful {
2199            !encoding[32..127].iter().any(|slot| {
2200                slot.as_ref()
2201                    .is_some_and(|name| font.charstrings.contains_key(name.as_str()))
2202            })
2203        } else {
2204            false
2205        }
2206    };
2207
2208    let fm = font.font_matrix;
2209    let font_matrix = Matrix::new(fm[0], fm[1], fm[2], fm[3], fm[4], fm[5]);
2210
2211    // When the PDF has no /Widths array, derive widths from the Type 1 charstrings.
2212    let widths = if !has_pdf_widths {
2213        let mut derived = [0.0f64; 256];
2214        for code in 0..256usize {
2215            let glyph_name = encoding[code].as_deref().unwrap_or(".notdef");
2216            if glyph_name == ".notdef" {
2217                continue;
2218            }
2219            if let Some(charstring) = font.charstrings.get(glyph_name) {
2220                let cs_lookup =
2221                    |name: &str| -> Option<Vec<u8>> { font.charstrings.get(name).cloned() };
2222                if let Ok(result) = execute_charstring_mm(
2223                    charstring,
2224                    &font.subrs,
2225                    font.len_iv,
2226                    false,
2227                    Some(&cs_lookup),
2228                    font.weight_vector.as_deref(),
2229                ) {
2230                    derived[code] = result.width_x * fm[0];
2231                }
2232            }
2233        }
2234        derived
2235    } else {
2236        widths
2237    };
2238
2239    let weight_vector = font.weight_vector.clone();
2240    Ok(PdfFont::Type1(Type1PdfFont {
2241        font,
2242        encoding,
2243        widths,
2244        font_matrix,
2245        weight_vector,
2246        builtin_fallback,
2247        per_char_width_scale: false,
2248    }))
2249}
2250
2251/// Resolve a TrueType font from its FontDescriptor.
2252/// If the font data has table directory entries pointing past the data,
2253/// try re-decompressing with raw deflate (skipping the zlib header).
2254/// Some fonts have corrupt zlib headers (CINFO < 7) that cause truncation.
2255fn try_raw_deflate_if_truncated(resolver: &Resolver, ff_ref: &PdfObj, data: Vec<u8>) -> Vec<u8> {
2256    // Check if any table extends past the data
2257    if data.len() < 12 {
2258        return data;
2259    }
2260    let num_tables = u16::from_be_bytes([data[4], data[5]]) as usize;
2261    let mut max_end = 0usize;
2262    for i in 0..num_tables {
2263        let e = 12 + i * 16;
2264        if e + 16 > data.len() {
2265            break;
2266        }
2267        let off =
2268            u32::from_be_bytes([data[e + 8], data[e + 9], data[e + 10], data[e + 11]]) as usize;
2269        let len =
2270            u32::from_be_bytes([data[e + 12], data[e + 13], data[e + 14], data[e + 15]]) as usize;
2271        max_end = max_end.max(off.saturating_add(len));
2272    }
2273    if max_end <= data.len() {
2274        return data; // all tables fit, no truncation
2275    }
2276    // Tables extend past the data — try raw deflate on the stream
2277    let raw_bytes = match resolver.raw_stream_bytes(ff_ref) {
2278        Some(b) if b.len() > 2 => b,
2279        _ => return data,
2280    };
2281    // Only retry if the zlib header has CINFO < 7 (suspect window size)
2282    let cinfo = raw_bytes[0] >> 4;
2283    let cm = raw_bytes[0] & 0xF;
2284    if cm != 8 || cinfo >= 7 {
2285        return data;
2286    }
2287    // Decompress with raw deflate (skip 2-byte zlib header)
2288    let mut decoder = flate2::Decompress::new(false);
2289    let mut output = Vec::with_capacity(data.len() * 2);
2290    let mut buf = [0u8; 8192];
2291    let input = &raw_bytes[2..];
2292    let mut input_offset = 0;
2293    loop {
2294        let before_in = decoder.total_in() as usize;
2295        let before_out = decoder.total_out() as usize;
2296        let result = decoder.decompress(
2297            &input[input_offset..],
2298            &mut buf,
2299            flate2::FlushDecompress::None,
2300        );
2301        let consumed = decoder.total_in() as usize - before_in;
2302        let produced = decoder.total_out() as usize - before_out;
2303        input_offset += consumed;
2304        output.extend_from_slice(&buf[..produced]);
2305        match result {
2306            Ok(flate2::Status::StreamEnd) => break,
2307            Ok(_) => {
2308                if consumed == 0 && produced == 0 {
2309                    break;
2310                }
2311            }
2312            Err(_) => break,
2313        }
2314    }
2315    if output.len() <= data.len() {
2316        return data;
2317    }
2318    // Verify ALL tables fit in the raw output (reject if still truncated)
2319    let mut raw_max_end = 0usize;
2320    for i in 0..num_tables {
2321        let e = 12 + i * 16;
2322        if e + 16 > output.len() {
2323            return data;
2324        }
2325        let off = u32::from_be_bytes([output[e + 8], output[e + 9], output[e + 10], output[e + 11]])
2326            as usize;
2327        let len = u32::from_be_bytes([
2328            output[e + 12],
2329            output[e + 13],
2330            output[e + 14],
2331            output[e + 15],
2332        ]) as usize;
2333        raw_max_end = raw_max_end.max(off.saturating_add(len));
2334    }
2335    if raw_max_end > output.len() {
2336        return data; // raw output still truncated, don't use it
2337    }
2338    // Validate the head table has a plausible unitsPerEm (detects shifted data
2339    // where the head bytes are misaligned and read as zero)
2340    if stet_fonts::truetype::get_units_per_em(&output) == 0 {
2341        return data;
2342    }
2343    output
2344}
2345
2346fn resolve_truetype(
2347    resolver: &Resolver,
2348    descriptor: &Option<PdfDict>,
2349    encoding: [Option<String>; 256],
2350    widths: [f64; 256],
2351    font_dict: &PdfDict,
2352) -> Result<PdfFont, PdfError> {
2353    let desc = descriptor.as_ref().ok_or(PdfError::Other(
2354        "TrueType font missing FontDescriptor".into(),
2355    ))?;
2356    let ff_ref = desc
2357        .get(b"FontFile2")
2358        .ok_or(PdfError::Other("TrueType font missing FontFile2".into()))?;
2359    let data = resolver.stream_data_from_obj(ff_ref)?;
2360
2361    // Some fonts have corrupt zlib headers (CINFO < 7) that cause the zlib
2362    // decompressor to truncate. If key tables are out of bounds, try raw
2363    // deflate decompression which ignores the header.
2364    let data = try_raw_deflate_if_truncated(resolver, ff_ref, data);
2365
2366    // Validate that glyph outline data is actually present
2367    use stet_fonts::truetype::find_table;
2368    let has_glyf = find_table(&data, b"glyf").is_some();
2369    let has_usable_glyx = if let Some((off, len)) = find_table(&data, b"glyx") {
2370        off + len <= data.len()
2371    } else {
2372        false
2373    };
2374    if !has_glyf && !has_usable_glyx {
2375        // Some PDFs store CFF/OpenType fonts as FontFile2 (malformed but common).
2376        // Detect and route to CFF parsing instead of failing.
2377        let is_otf = data.starts_with(b"OTTO");
2378        let is_cff = is_raw_cff(&data);
2379        if is_otf || is_cff {
2380            let has_explicit_encoding = font_dict.get(b"Encoding").is_some();
2381            let has_pdf_widths = font_dict.get(b"Widths").is_some();
2382            return build_cff_font(
2383                data,
2384                encoding,
2385                widths,
2386                has_explicit_encoding,
2387                has_pdf_widths,
2388                &[],
2389                false,
2390            );
2391        }
2392        return Err(PdfError::Other(
2393            "TrueType font has no usable glyph outline data".into(),
2394        ));
2395    }
2396
2397    // Validate essential tables are within bounds. Truncated font data
2398    // (e.g. from corrupt zlib headers) may have table directory entries
2399    // pointing past the decompressed data.
2400    if let Some((off, _)) = find_table(&data, b"head") {
2401        if off + 54 > data.len() {
2402            return Err(PdfError::Other(
2403                "TrueType font head table is out of bounds (truncated data)".into(),
2404            ));
2405        }
2406    }
2407
2408    let units_per_em = get_units_per_em(&data) as f64;
2409
2410    // Reject fonts with degenerate unitsPerEm (< 16). These are dummy subsets
2411    // with placeholder rectangle "glyphs" that produce enormous shapes when
2412    // normalized. Fall through to the substitute font path instead.
2413    if units_per_em < 16.0 {
2414        return Err(PdfError::Other(
2415            "TrueType font has degenerate unitsPerEm (placeholder outlines)".into(),
2416        ));
2417    }
2418
2419    let (cmap, cmap_is_unicode) = parse_cmap_with_info(&data);
2420
2421    // Parse post table (GID → name) and invert to name → GID for fallback lookup
2422    let post_name_to_gid = stet_fonts::system_fonts::parse_post_table(&data)
2423        .map(|gid_to_name| {
2424            gid_to_name
2425                .into_iter()
2426                .map(|(gid, name)| (name, gid))
2427                .collect()
2428        })
2429        .unwrap_or_default();
2430
2431    // Symbolic TrueType fonts without explicit /Encoding use identity mapping
2432    // (char_code = GID). The cmap is often misleading for re-encoded fonts
2433    // (e.g. Tamil glyphs at Latin cmap positions).
2434    let flags = desc.get_int(b"Flags").unwrap_or(0) as u32;
2435    let is_symbolic = flags & 4 != 0;
2436    let has_encoding = font_dict.get(b"Encoding").is_some();
2437    let identity_gid = is_symbolic && !has_encoding && cmap_is_unicode;
2438    let gid_hex = TrueTypePdfFont::detect_gid_hex(&encoding);
2439
2440    Ok(PdfFont::TrueType(TrueTypePdfFont {
2441        data,
2442        encoding,
2443        widths,
2444        cmap,
2445        cmap_is_unicode,
2446        post_name_to_gid,
2447        units_per_em,
2448        to_unicode: if let Some(tu_obj) = font_dict.get(b"ToUnicode") {
2449            resolver
2450                .stream_data_from_obj(tu_obj)
2451                .map(|d| parse_to_unicode(&d))
2452                .unwrap_or_default()
2453        } else {
2454            HashMap::new()
2455        },
2456        identity_gid,
2457        gid_hex,
2458    }))
2459}
2460
2461/// Resolve a CFF (Type1C) font from its FontDescriptor.
2462fn resolve_cff(
2463    resolver: &Resolver,
2464    descriptor: &Option<PdfDict>,
2465    encoding: [Option<String>; 256],
2466    widths: [f64; 256],
2467    has_explicit_encoding: bool,
2468    has_pdf_widths: bool,
2469    differences: &[(usize, String)],
2470    no_base_encoding: bool,
2471) -> Result<PdfFont, PdfError> {
2472    let desc = descriptor
2473        .as_ref()
2474        .ok_or(PdfError::Other("CFF font missing FontDescriptor".into()))?;
2475    let ff_ref = desc
2476        .get(b"FontFile3")
2477        .ok_or(PdfError::Other("CFF font missing FontFile3".into()))?;
2478    let raw_data = resolver.stream_data_from_obj(ff_ref)?;
2479    build_cff_font(
2480        raw_data,
2481        encoding,
2482        widths,
2483        has_explicit_encoding,
2484        has_pdf_widths,
2485        differences,
2486        no_base_encoding,
2487    )
2488}
2489
2490/// Build a CFF font from raw font data (may be OpenType/CFF or raw CFF).
2491fn build_cff_font(
2492    raw_data: Vec<u8>,
2493    encoding: [Option<String>; 256],
2494    widths: [f64; 256],
2495    has_explicit_encoding: bool,
2496    has_pdf_widths: bool,
2497    differences: &[(usize, String)],
2498    no_base_encoding: bool,
2499) -> Result<PdfFont, PdfError> {
2500    // If data starts with "OTTO" it's an OpenType container — extract CFF table
2501    let font_data = if raw_data.starts_with(b"OTTO") {
2502        use stet_fonts::truetype::find_table;
2503        let (offset, length) = find_table(&raw_data, b"CFF ")
2504            .ok_or(PdfError::Other("OpenType font has no CFF table".into()))?;
2505        raw_data[offset..offset + length].to_vec()
2506    } else {
2507        raw_data
2508    };
2509
2510    let fonts =
2511        parse_cff(&font_data).map_err(|e| PdfError::Other(format!("CFF parse error: {e}")))?;
2512    let font = fonts
2513        .into_iter()
2514        .next()
2515        .ok_or(PdfError::Other("CFF contains no fonts".into()))?;
2516
2517    // PDF spec 9.6.6.1: encoding base depends on whether the font is embedded.
2518    // When the /Encoding dict has no /BaseEncoding, the base for embedded fonts
2519    // is the font's built-in encoding (not StandardEncoding).
2520    // Helper: build encoding from the CFF's built-in encoding table.
2521    // For codes where the CFF encoding maps to a valid GID, use that name.
2522    // For unmapped codes in fonts with Expert charset names (Asmall, etc.),
2523    // fill in from the Expert encoding table — handles buggy subset fonts that
2524    // claim Standard encoding but have Expert glyph names.
2525    let build_cff_encoding = |font: &stet_fonts::cff_parser::CffFont| -> [Option<String>; 256] {
2526        let mut enc: [Option<String>; 256] = std::array::from_fn(|_| None);
2527        let name_to_gid: std::collections::HashMap<&str, u16> = font
2528            .charset
2529            .iter()
2530            .enumerate()
2531            .map(|(gid, name)| (name.as_str(), gid as u16))
2532            .collect();
2533        #[allow(clippy::needless_range_loop)]
2534        for code in 0..256 {
2535            let gid = font.encoding[code] as usize;
2536            if gid > 0 && gid < font.charset.len() && font.charset[gid] != ".notdef" {
2537                enc[code] = Some(font.charset[gid].clone());
2538            }
2539        }
2540        // Fill gaps from Expert encoding for fonts with Expert glyph names.
2541        if name_to_gid.contains_key("Asmall") {
2542            for &(code, sid) in &stet_fonts::cff_parser::EXPERT_ENCODING_MAP {
2543                if enc[code as usize].is_none() {
2544                    let name = stet_fonts::cff_parser::get_sid_string(sid, &[]);
2545                    if let Some(&gid) = name_to_gid.get(name.as_str()) {
2546                        if gid > 0 {
2547                            enc[code as usize] = Some(font.charset[gid as usize].clone());
2548                        }
2549                    }
2550                }
2551            }
2552            // Map lowercase a-z to XYZsmall names for broken Expert subsets
2553            // where the CFF encoding doesn't cover all used codes.
2554            for code in b'a'..=b'z' {
2555                if enc[code as usize].is_none() {
2556                    let small_name = format!("{}small", (code - b'a' + b'A') as char);
2557                    if name_to_gid.contains_key(small_name.as_str()) {
2558                        enc[code as usize] = Some(small_name);
2559                    }
2560                }
2561            }
2562        }
2563        enc
2564    };
2565
2566    let encoding = if no_base_encoding || !differences.is_empty() {
2567        // Encoding dict had no BaseEncoding — use CFF built-in
2568        // encoding as base, then apply Differences.
2569        let mut enc = build_cff_encoding(&font);
2570        for (code, name) in differences {
2571            if *code < 256 {
2572                enc[*code] = Some(name.clone());
2573            }
2574        }
2575        enc
2576    } else if !has_explicit_encoding {
2577        // No /Encoding at all — use CFF built-in encoding directly.
2578        build_cff_encoding(&font)
2579    } else {
2580        encoding
2581    };
2582
2583    let fm = font.font_matrix;
2584    let font_matrix = Matrix::new(fm[0], fm[1], fm[2], fm[3], fm[4], fm[5]);
2585
2586    // When the PDF has no /Widths array, derive widths from the CFF charstrings.
2587    let widths = if !has_pdf_widths {
2588        use stet_fonts::type2_charstring::execute_type2_charstring;
2589        let mut derived = [0.0f64; 256];
2590        for code in 0..256usize {
2591            let glyph_name = encoding[code].as_deref().unwrap_or(".notdef");
2592            let gid = font
2593                .charset
2594                .iter()
2595                .position(|name| name == glyph_name)
2596                .unwrap_or(0);
2597            if gid > 0 && gid < font.char_strings.len() {
2598                if let Ok(result) = execute_type2_charstring(
2599                    &font.char_strings[gid],
2600                    &font.local_subrs,
2601                    &font.global_subrs,
2602                    font.default_width_x,
2603                    font.nominal_width_x,
2604                    true, // width_only
2605                ) {
2606                    derived[code] = result.width_x * fm[0];
2607                }
2608            }
2609        }
2610        derived
2611    } else {
2612        widths
2613    };
2614
2615    Ok(PdfFont::Cff(CffPdfFont {
2616        font,
2617        encoding,
2618        widths,
2619        font_matrix,
2620    }))
2621}
2622
2623/// Resolve a Type 0 composite font (CIDFontType2 descendant with TrueType outlines).
2624fn resolve_type0(resolver: &Resolver, font_dict: &PdfDict) -> Result<PdfFont, PdfError> {
2625    // Check if encoding is UCS2-based (character codes are Unicode, not CIDs).
2626    let encoding_obj = font_dict.get(b"Encoding");
2627    let encoding_name = font_dict.get_name(b"Encoding").unwrap_or(b"");
2628    let ucs2_encoding = encoding_name.windows(4).any(|w| w == b"UCS2");
2629
2630    // Parse the encoding CMap's codespace ranges to determine byte widths,
2631    // and the code-to-CID mapping for non-identity encodings.
2632    // The encoding can be:
2633    //   - a stream containing a custom CMap
2634    //   - a name like "Identity-H" (identity mapping, 2-byte codes)
2635    //   - a predefined CMap name like "GBK-EUC-H" (load from system)
2636    let (code_lengths, code_to_cid, mut wmode) = if let Some(enc_obj) = encoding_obj {
2637        if let Ok(cmap_data) = resolver.stream_data_from_obj(enc_obj) {
2638            // Embedded CMap stream
2639            let cmap = super::cmap::CMap::parse_with_loader(
2640                &cmap_data,
2641                Some(&|name| load_predefined_cmap(name)),
2642            );
2643            (cmap.code_lengths, cmap.code_to_cid, cmap.wmode)
2644        } else if !encoding_name.is_empty() && !encoding_name.starts_with(b"Identity") {
2645            // Predefined CMap name (e.g. GBK-EUC-H) — load from system
2646            if let Some(cmap_data) = load_predefined_cmap(encoding_name) {
2647                let cmap = super::cmap::CMap::parse_with_loader(
2648                    &cmap_data,
2649                    Some(&|name| load_predefined_cmap(name)),
2650                );
2651                (cmap.code_lengths, cmap.code_to_cid, cmap.wmode)
2652            } else {
2653                eprintln!(
2654                    "warning: predefined CMap '{}' not found; \
2655                     set STET_CMAP_DIR or install poppler-data for CJK support",
2656                    String::from_utf8_lossy(encoding_name)
2657                );
2658                ([2u8; 256], HashMap::new(), 0)
2659            }
2660        } else {
2661            ([2u8; 256], HashMap::new(), 0) // Identity-H/V or fallback
2662        }
2663    } else {
2664        ([2u8; 256], HashMap::new(), 0)
2665    };
2666    // Encoding name suffix overrides CMap WMode: -V = vertical, -H = horizontal
2667    if encoding_name.ends_with(b"-V") {
2668        wmode = 1;
2669    } else if encoding_name.ends_with(b"-H") {
2670        wmode = 0;
2671    }
2672
2673    // Get DescendantFonts array (must have exactly one entry).
2674    // May be a direct array or an indirect reference to one.
2675    let descendants_obj = font_dict
2676        .get(b"DescendantFonts")
2677        .ok_or(PdfError::Other("Type0 font missing DescendantFonts".into()))?;
2678    let descendants_resolved = resolver.deref(descendants_obj)?;
2679    let descendants = descendants_resolved
2680        .as_array()
2681        .ok_or(PdfError::Other("DescendantFonts is not an array".into()))?;
2682    let cid_font_ref = descendants
2683        .first()
2684        .ok_or(PdfError::Other("DescendantFonts is empty".into()))?;
2685    let cid_font_obj = resolver.deref(cid_font_ref)?;
2686    let cid_font_dict = cid_font_obj
2687        .as_dict()
2688        .ok_or(PdfError::Other("CIDFont is not a dict".into()))?;
2689
2690    let cid_subtype = cid_font_dict.get_name(b"Subtype").unwrap_or(b"");
2691
2692    // Get FontDescriptor from the CIDFont
2693    let descriptor = get_font_descriptor(cid_font_dict, resolver)?;
2694    let desc = descriptor
2695        .as_ref()
2696        .ok_or(PdfError::Other("CIDFont missing FontDescriptor".into()))?;
2697
2698    // Parse /DW (default width) — may be int or real
2699    let default_width = cid_font_dict.get_f64(b"DW").unwrap_or(1000.0) / 1000.0;
2700
2701    // Parse /DW2 (default vertical metrics: [v_y w1])
2702    // Default: [880, -1000] per PDF spec Table 117
2703    let dw2 = cid_font_dict
2704        .get_array(b"DW2")
2705        .and_then(|arr| {
2706            let v: Vec<f64> = arr.iter().filter_map(|o| o.as_f64()).collect();
2707            if v.len() >= 2 {
2708                Some([v[0], v[1]])
2709            } else {
2710                None
2711            }
2712        })
2713        .unwrap_or([880.0, -1000.0]);
2714
2715    // Parse /W array (CID-specific widths)
2716    let cid_widths = parse_cid_widths(cid_font_dict, resolver);
2717
2718    // Parse /W2 array (per-CID vertical metrics)
2719    let w2 = parse_cid_w2(cid_font_dict, resolver);
2720
2721    // When a UCS2-based CMap (e.g. UniJIS-UCS2-H) couldn't be loaded from
2722    // disk, build a basic Latin fallback mapping. All Adobe CID collections
2723    // (Japan1, GB1, CNS1, Korea1) map Unicode basic Latin to:
2724    //   CID 1 = U+0020 (space), CID 2..95 = U+0021..U+007E
2725    // This handles the common case of CJK-font PDFs containing English text
2726    // when CMap resource files aren't installed.
2727    let code_to_cid = if code_to_cid.is_empty()
2728        && code_lengths[0] == 2
2729        && encoding_name.windows(4).any(|w| w == b"UCS2")
2730    {
2731        let mut map = HashMap::new();
2732        for unicode in 0x0020u32..=0x007Eu32 {
2733            let cid = unicode - 0x001F;
2734            map.insert(unicode, cid);
2735        }
2736        map
2737    } else {
2738        code_to_cid
2739    };
2740
2741    // Parse /ToUnicode CMap from the parent Type 0 font dict
2742    let to_unicode = if let Some(tu_obj) = font_dict.get(b"ToUnicode") {
2743        match resolver.stream_data_from_obj(tu_obj) {
2744            Ok(data) => parse_to_unicode(&data),
2745            Err(_) => HashMap::new(),
2746        }
2747    } else {
2748        HashMap::new()
2749    };
2750
2751    // Extract CIDSystemInfo /Ordering for CID→Unicode fallback lookup.
2752    // /Ordering is a string (parenthesized), not a name.
2753    let ordering = {
2754        let si_dict = cid_font_dict
2755            .get_dict(b"CIDSystemInfo")
2756            .cloned()
2757            .or_else(|| {
2758                cid_font_dict
2759                    .get(b"CIDSystemInfo")
2760                    .and_then(|obj| resolver.deref(obj).ok())
2761                    .and_then(|obj| obj.as_dict().cloned())
2762            });
2763        si_dict
2764            .and_then(|d| {
2765                d.get(b"Ordering").and_then(|v| match v {
2766                    PdfObj::Str(s) => Some(s.clone()),
2767                    PdfObj::Name(n) => Some(n.clone()),
2768                    _ => None,
2769                })
2770            })
2771            .unwrap_or_default()
2772    };
2773
2774    match cid_subtype {
2775        b"CIDFontType2" => {
2776            let mut substituted;
2777            let mut data = if let Some(ff_ref) = desc
2778                .get(b"FontFile2")
2779                // Some PDFs store TrueType data under /FontFile instead
2780                // of the correct /FontFile2 — accept it as a fallback.
2781                .or_else(|| {
2782                    desc.get(b"FontFile").filter(|obj| {
2783                        resolver
2784                            .stream_data_from_obj(obj)
2785                            .ok()
2786                            .is_some_and(|d| d.len() > 4 && d[..4] == [0, 1, 0, 0])
2787                    })
2788                })
2789                // PDF/X-4 and others embed CIDFontType2 outlines via
2790                // /FontFile3 with /Subtype /OpenType. The wrapped sfnt may
2791                // be TrueType-flavored (glyf/loca) or CFF-flavored (OTTO);
2792                // downstream OTTO/raw-CFF detection routes either correctly.
2793                .or_else(|| {
2794                    desc.get(b"FontFile3").filter(|obj| {
2795                        resolver.stream_data_from_obj(obj).ok().is_some_and(|d| {
2796                            d.len() > 4
2797                                && (d[..4] == [0, 1, 0, 0]
2798                                    || &d[..4] == b"true"
2799                                    || &d[..4] == b"OTTO")
2800                        })
2801                    })
2802                }) {
2803                substituted = false;
2804                let mut font_data = resolver.stream_data_from_obj(ff_ref)?;
2805                sanitize_index_to_loc_format(&mut font_data);
2806                // Some PDFs store CFF fonts as FontFile2 instead of FontFile3.
2807                // Detect OpenType/CFF (OTTO magic) or raw CFF and route accordingly.
2808                let is_otf_cff = font_data.len() > 4 && &font_data[0..4] == b"OTTO";
2809                let is_raw = is_raw_cff(&font_data);
2810                if is_otf_cff || is_raw {
2811                    // Parse CIDToGIDMap from the PDF
2812                    let cid_to_gid_map = if let Some(map_obj) = cid_font_dict.get(b"CIDToGIDMap") {
2813                        if cid_font_dict.get_name(b"CIDToGIDMap") != Some(b"Identity") {
2814                            resolver.stream_data_from_obj(map_obj).ok().map(|d| {
2815                                d.chunks_exact(2)
2816                                    .map(|p| u16::from_be_bytes([p[0], p[1]]))
2817                                    .collect()
2818                            })
2819                        } else {
2820                            None
2821                        }
2822                    } else {
2823                        None
2824                    };
2825                    // For CID-keyed CFF fonts, the CFF handles CID→charstring
2826                    // mapping internally. The PDF's CIDToGIDMap is a sparse subset
2827                    // artifact that maps most CIDs to GID 0 — ignore it.
2828                    // For non-CID CFF fonts, the CIDToGIDMap provides the actual
2829                    // CID→GID mapping and must be used.
2830                    let is_cid_keyed = {
2831                        use stet_fonts::truetype::find_table;
2832                        let cff_range = if is_otf_cff {
2833                            find_table(&font_data, b"CFF ")
2834                        } else {
2835                            Some((0, font_data.len()))
2836                        };
2837                        cff_range
2838                            .and_then(|(off, len)| parse_cff(&font_data[off..off + len]).ok())
2839                            .and_then(|fonts| fonts.into_iter().next())
2840                            .is_some_and(|f| f.is_cid)
2841                    };
2842                    let (cid_to_gid_map, identity) = if is_cid_keyed {
2843                        (None, true) // CFF handles CID mapping
2844                    } else {
2845                        let id = cid_to_gid_map.is_none();
2846                        (cid_to_gid_map, id)
2847                    };
2848                    if is_otf_cff {
2849                        return create_cid_cff_from_otf(
2850                            &font_data,
2851                            default_width,
2852                            cid_widths,
2853                            &ordering,
2854                            cid_to_gid_map,
2855                            identity,
2856                            code_lengths,
2857                            code_to_cid.clone(),
2858                            wmode,
2859                            dw2,
2860                            w2.clone(),
2861                        );
2862                    } else {
2863                        return create_cid_cff_from_raw(
2864                            &font_data,
2865                            default_width,
2866                            cid_widths,
2867                            &ordering,
2868                            cid_to_gid_map,
2869                            identity,
2870                            code_lengths,
2871                            code_to_cid.clone(),
2872                            wmode,
2873                            dw2,
2874                            w2.clone(),
2875                        );
2876                    }
2877                }
2878                font_data
2879            } else {
2880                // Font not embedded — try system font lookup
2881                substituted = true;
2882                let base_font = cid_font_dict
2883                    .get_name(b"BaseFont")
2884                    .map(|n| {
2885                        let s = String::from_utf8_lossy(n);
2886                        if s.len() > 7 && s.as_bytes().get(6) == Some(&b'+') {
2887                            s[7..].to_string()
2888                        } else {
2889                            s.to_string()
2890                        }
2891                    })
2892                    .unwrap_or_default();
2893                let sys_data = load_system_truetype_font(&base_font)
2894                    .or_else(|_| load_cjk_fallback_font(&ordering, &base_font))?;
2895                // If the system font is OpenType/CFF, use CFF rendering path
2896                if sys_data.len() > 4 && &sys_data[0..4] == b"OTTO" {
2897                    return create_cid_cff_from_otf(
2898                        &sys_data,
2899                        default_width,
2900                        cid_widths,
2901                        &ordering,
2902                        None,
2903                        false, // substituted: use cmap, not identity
2904                        code_lengths,
2905                        code_to_cid.clone(),
2906                        wmode,
2907                        dw2,
2908                        w2.clone(),
2909                    );
2910                }
2911                sys_data
2912            };
2913
2914            // Detect corrupted font data: check whether ANY CID in the /W
2915            // table produces a valid glyph outline.  If none do, the font data
2916            // is likely damaged (e.g. from a broken/truncated zlib stream) and
2917            // we should fall back to the system font.
2918            // Only check when identity CID→GID is in effect (no explicit
2919            // CIDToGIDMap stream), since we test CIDs directly as GIDs.
2920            let has_cid_to_gid_map = cid_font_dict
2921                .get(b"CIDToGIDMap")
2922                .is_some_and(|v| v.as_name().is_none_or(|n| n != b"Identity"));
2923            if !substituted && !cid_widths.is_empty() && !has_cid_to_gid_map {
2924                let upm_f = get_units_per_em(&data) as f64;
2925                let any_glyph = cid_widths
2926                    .keys()
2927                    .any(|&cid| skrifa_glyph_path(&data, cid, upm_f).is_some());
2928                if !any_glyph {
2929                    let base_font = cid_font_dict
2930                        .get_name(b"BaseFont")
2931                        .map(|n| {
2932                            let s = String::from_utf8_lossy(n);
2933                            if s.len() > 7 && s.as_bytes().get(6) == Some(&b'+') {
2934                                s[7..].to_string()
2935                            } else {
2936                                s.to_string()
2937                            }
2938                        })
2939                        .unwrap_or_default();
2940                    if let Ok(sys_data) = load_system_truetype_font(&base_font) {
2941                        data = sys_data;
2942                        substituted = true;
2943                    }
2944                }
2945            }
2946            let units_per_em = get_units_per_em(&data) as f64;
2947            let cmap = parse_cmap(&data);
2948
2949            // Parse CIDToGIDMap: either /Identity name or a stream of big-endian u16 pairs
2950            let (identity_cid_to_gid, cid_to_gid_map) =
2951                if let Some(name) = cid_font_dict.get_name(b"CIDToGIDMap") {
2952                    (name == b"Identity", None)
2953                } else if let Some(map_obj) = cid_font_dict.get(b"CIDToGIDMap") {
2954                    match resolver.stream_data_from_obj(map_obj) {
2955                        Ok(stream_data) => {
2956                            let mut gid_map = Vec::with_capacity(stream_data.len() / 2);
2957                            for pair in stream_data.chunks_exact(2) {
2958                                gid_map.push(u16::from_be_bytes([pair[0], pair[1]]));
2959                            }
2960                            (false, Some(gid_map))
2961                        }
2962                        Err(_) => (true, None), // fallback to identity
2963                    }
2964                } else {
2965                    (true, None) // no CIDToGIDMap → default to identity
2966                };
2967
2968            // For substituted fonts, always discard CIDToGIDMap streams.
2969            // The map encodes GID ordering specific to the original font and
2970            // is never valid for a different font — even metric-compatible
2971            // pairs (e.g. TimesNewRoman ↔ LiberationSerif) share ASCII GIDs
2972            // but diverge for extended characters (ě, í, – etc.).
2973            let cid_to_gid_map = if substituted && cid_to_gid_map.is_some() {
2974                None
2975            } else {
2976                cid_to_gid_map
2977            };
2978            // Don't promote to identity when we discarded an incompatible
2979            // CIDToGIDMap — the CIDs are Unicode values, not GIDs, so the
2980            // gid_to_unicode enrichment below must NOT run.
2981
2982            // For non-embedded fonts with Identity CIDToGIDMap, the CID values
2983            // are GIDs from the original font. A substitute font has different
2984            // glyph ordering, so CID-as-GID produces garbled text. Use hardcoded
2985            // GID-to-Unicode tables (same approach as PDF.js) to map known fonts'
2986            // GIDs to Unicode, enabling correct rendering with any substitute.
2987            let to_unicode = if substituted
2988                && identity_cid_to_gid
2989                && to_unicode.is_empty()
2990                && encoding_name.starts_with(b"Identity")
2991            {
2992                let base_name = cid_font_dict.get_name(b"BaseFont").unwrap_or(b"");
2993                let name_str = String::from_utf8_lossy(base_name);
2994                // Strip subset prefix (e.g. "ABCDEF+Calibri,Bold" → "Calibri,Bold")
2995                let clean = if name_str.len() > 7 && name_str.as_bytes().get(6) == Some(&b'+') {
2996                    &name_str[7..]
2997                } else {
2998                    &name_str
2999                };
3000                // Extract family name before style suffix, stripping
3001                // PostScript suffixes (MT, PS, PSMT) that don't appear
3002                // in the GID map keys.
3003                let mut family = clean
3004                    .split(&[',', '-'][..])
3005                    .next()
3006                    .unwrap_or(clean)
3007                    .to_ascii_lowercase();
3008                for suffix in &["psmt", "ps", "mt"] {
3009                    if family.len() > suffix.len() && family.ends_with(suffix) {
3010                        family.truncate(family.len() - suffix.len());
3011                        break;
3012                    }
3013                }
3014                super::gid_maps::get_gid_to_unicode_map(&family).unwrap_or(to_unicode)
3015            } else {
3016                to_unicode
3017            };
3018
3019            Ok(PdfFont::CidTrueType(CidTrueTypePdfFont {
3020                data,
3021                default_width,
3022                cid_widths,
3023                cmap,
3024                units_per_em,
3025                identity_cid_to_gid,
3026                substituted,
3027                cid_to_gid_map,
3028                to_unicode,
3029                ordering: ordering.clone(),
3030                ucs2_encoding,
3031                code_lengths,
3032                code_to_cid: code_to_cid.clone(),
3033                wmode,
3034                dw2,
3035                w2: w2.clone(),
3036            }))
3037        }
3038        b"CIDFontType0" => {
3039            // CFF-based CID font: FontFile3 with /Subtype /CIDFontType0C
3040            // Some PDFs use /FontFile instead of /FontFile3 — accept both.
3041            if let Some(ff_ref) = desc.get(b"FontFile3").or_else(|| desc.get(b"FontFile")) {
3042                let font_data = resolver.stream_data_from_obj(ff_ref)?;
3043                // Some PDFs mislabel TrueType data as CIDFontType0C. Detect the
3044                // TrueType magic (\x00\x01\x00\x00) and route to TrueType path.
3045                let is_truetype = font_data.len() > 4 && &font_data[0..4] == b"\x00\x01\x00\x00";
3046                if is_truetype {
3047                    let mut font_data = font_data;
3048                    sanitize_index_to_loc_format(&mut font_data);
3049                    let units_per_em = get_units_per_em(&font_data) as f64;
3050                    let cmap = parse_cmap(&font_data);
3051                    let (identity_cid_to_gid, cid_to_gid_map) =
3052                        if let Some(name) = cid_font_dict.get_name(b"CIDToGIDMap") {
3053                            (name == b"Identity", None)
3054                        } else {
3055                            (true, None)
3056                        };
3057                    return Ok(PdfFont::CidTrueType(CidTrueTypePdfFont {
3058                        data: font_data,
3059                        default_width,
3060                        cid_widths,
3061                        cmap,
3062                        units_per_em,
3063                        identity_cid_to_gid,
3064                        substituted: false,
3065                        cid_to_gid_map,
3066                        to_unicode,
3067                        ordering: ordering.clone(),
3068                        ucs2_encoding,
3069                        code_lengths,
3070                        code_to_cid: code_to_cid.clone(),
3071                        wmode,
3072                        dw2,
3073                        w2: w2.clone(),
3074                    }));
3075                }
3076                // FontFile3 may be raw CFF or OpenType/CFF (OTTO wrapper)
3077                if font_data.len() > 4 && &font_data[0..4] == b"OTTO" {
3078                    // Parse CIDToGIDMap for OpenType-wrapped CFF
3079                    let pdf_cid_to_gid = if let Some(map_obj) = cid_font_dict.get(b"CIDToGIDMap") {
3080                        match resolver.stream_data_from_obj(map_obj) {
3081                            Ok(stream_data) => {
3082                                let mut gid_map = Vec::with_capacity(stream_data.len() / 2);
3083                                for pair in stream_data.chunks_exact(2) {
3084                                    gid_map.push(u16::from_be_bytes([pair[0], pair[1]]));
3085                                }
3086                                Some(gid_map)
3087                            }
3088                            Err(_) => None,
3089                        }
3090                    } else {
3091                        None
3092                    };
3093                    // For non-CID CFF fonts used as CIDFontType0, the CID IS the
3094                    // charstring index (identity mapping). For true CID-keyed CFF fonts,
3095                    // the CFF charset provides the CID→GID mapping, or the OTF cmap is used.
3096                    let cff_is_cid = is_cff_cid_keyed(&font_data);
3097                    return create_cid_cff_from_otf(
3098                        &font_data,
3099                        default_width,
3100                        cid_widths,
3101                        &ordering,
3102                        pdf_cid_to_gid,
3103                        !cff_is_cid,
3104                        code_lengths,
3105                        code_to_cid.clone(),
3106                        wmode,
3107                        dw2,
3108                        w2.clone(),
3109                    );
3110                }
3111                // PostScript CIDFont programs: "%!PS-Adobe-3.0 Resource-CIDFont"
3112                // These contain binary charstring data after StartData.
3113                if font_data.starts_with(b"%!")
3114                    && font_data.windows(16).any(|w| w == b"Resource-CIDFont")
3115                {
3116                    return create_cid_from_ps_cidfont(
3117                        &font_data,
3118                        default_width,
3119                        cid_widths,
3120                        code_lengths,
3121                        code_to_cid.clone(),
3122                        wmode,
3123                        dw2,
3124                        w2.clone(),
3125                    );
3126                }
3127                // Detect Type 1 font data (ASCII "%!" or PFB 0x80) mislabeled
3128                // as CIDFontType0.  Parse as Type 1, pre-compute glyph paths
3129                // for each CID using ToUnicode → AGL → charstrings.
3130                let is_type1 = font_data.starts_with(b"%!") || font_data.first() == Some(&0x80);
3131                if is_type1 {
3132                    return create_cid_from_type1(
3133                        &font_data,
3134                        default_width,
3135                        cid_widths,
3136                        &to_unicode,
3137                        code_lengths,
3138                        code_to_cid.clone(),
3139                        wmode,
3140                        dw2,
3141                        w2.clone(),
3142                    );
3143                }
3144                let fonts = parse_cff(&font_data)
3145                    .map_err(|e| PdfError::Other(format!("CFF parse error: {e}")))?;
3146                let font = fonts
3147                    .into_iter()
3148                    .next()
3149                    .ok_or(PdfError::Other("CFF contains no fonts".into()))?;
3150                // Detect tiny CFF subsets for CJK fonts. Some PDFs embed a
3151                // minimal "ghost" CFF (3-4 glyphs) to satisfy spec requirements
3152                // while expecting the viewer to use the system CJK font (which
3153                // has matching GID ordering). Only fall through for CJK fonts
3154                // where the system substitute has compatible glyph indices.
3155                //
3156                // Gate this on the Adobe CID registry from CIDSystemInfo
3157                // /Ordering, *not* on substring matches against the BaseFont
3158                // name. The 2-letter substrings the old heuristic tested ("sc",
3159                // "cn", "jp", "kr", "tc", "hk") false-positive on common Latin
3160                // font names like "BentonSansCond" → "sc", causing the embedded
3161                // CFF to be discarded and replaced with NotoSansCJK whose GIDs
3162                // don't match — producing garbled text.
3163                let cs_count = font.char_strings.len();
3164                let is_adobe_cjk_registry = matches!(
3165                    ordering.as_slice(),
3166                    b"GB1" | b"CNS1" | b"Japan1" | b"Japan2" | b"Korea1" | b"KR"
3167                );
3168                if cs_count > 0 && cid_widths.len() > cs_count * 4 && is_adobe_cjk_registry {
3169                    // Drop the parsed CFF and fall through to the system font path.
3170                } else {
3171                    let fm = font.font_matrix;
3172                    let font_matrix = Matrix::new(fm[0], fm[1], fm[2], fm[3], fm[4], fm[5]);
3173                    return Ok(PdfFont::CidCff(CidCffPdfFont {
3174                        font,
3175                        default_width,
3176                        cid_widths,
3177                        font_matrix,
3178                        cmap: None,
3179                        pdf_cid_to_gid: None,
3180                        identity_cid_to_gid: false,
3181                        ordering: ordering.clone(),
3182                        code_lengths,
3183                        code_to_cid: code_to_cid.clone(),
3184                        wmode,
3185                        dw2,
3186                        w2: w2.clone(),
3187                        type1_paths: None,
3188                    }));
3189                }
3190            }
3191            // Not embedded or tiny subset — substitute with a system font
3192            {
3193                let base_font = cid_font_dict
3194                    .get_name(b"BaseFont")
3195                    .map(|n| String::from_utf8_lossy(n).to_string())
3196                    .unwrap_or_default();
3197                let sys_data = if ucs2_encoding {
3198                    // UCS2-encoded CID fonts: use a substitute TrueType font so
3199                    // the text stays on the composite CID rendering path (correct
3200                    // code_lengths and CID width advancement). Without this, the
3201                    // simple fallback font treats 2-byte UCS-2 codes as individual
3202                    // bytes, producing doubled character spacing.
3203                    // Try CJK fallback before Latin fallback — UCS2-encoded CJK
3204                    // fonts (Adobe-Japan1 etc.) need a font with CJK glyphs.
3205                    load_system_truetype_font(&base_font)
3206                        .or_else(|_| load_cjk_fallback_font(&ordering, &base_font))
3207                        .or_else(|_| load_system_truetype_font("DejaVuSans"))
3208                        .or_else(|_| load_system_truetype_font("LiberationSans"))
3209                        .or_else(|_| load_system_truetype_font("NimbusSans"))?
3210                } else {
3211                    load_system_truetype_font(&base_font)
3212                        .or_else(|_| load_cjk_fallback_font(&ordering, &base_font))?
3213                };
3214                // For Identity ordering, CIDs are GIDs from the original font.
3215                // When the substitute is the same font family, identity mapping
3216                // gives correct glyphs. For non-Identity orderings, the cmap
3217                // path (CID→Unicode→GID) is used instead.
3218                let identity = ordering == b"Identity";
3219                // If the system font is OpenType/CFF (or a TTC containing
3220                // OpenType/CFF sub-fonts), use the CFF rendering path.
3221                // find_table() handles both plain OTF and TTC files.
3222                let is_otto = sys_data.len() > 4 && &sys_data[0..4] == b"OTTO";
3223                let is_ttc_cff = sys_data.len() > 16 && &sys_data[0..4] == b"ttcf" && {
3224                    let off = u32::from_be_bytes([
3225                        sys_data[12],
3226                        sys_data[13],
3227                        sys_data[14],
3228                        sys_data[15],
3229                    ]) as usize;
3230                    off + 4 <= sys_data.len() && &sys_data[off..off + 4] == b"OTTO"
3231                };
3232                if is_otto || is_ttc_cff {
3233                    return create_cid_cff_from_otf(
3234                        &sys_data,
3235                        default_width,
3236                        cid_widths,
3237                        &ordering,
3238                        None,
3239                        identity,
3240                        code_lengths,
3241                        code_to_cid.clone(),
3242                        wmode,
3243                        dw2,
3244                        w2.clone(),
3245                    );
3246                }
3247                let data = sys_data;
3248                let units_per_em = get_units_per_em(&data) as f64;
3249                let cmap = parse_cmap(&data);
3250                Ok(PdfFont::CidTrueType(CidTrueTypePdfFont {
3251                    data,
3252                    default_width,
3253                    cid_widths,
3254                    cmap,
3255                    units_per_em,
3256                    identity_cid_to_gid: false,
3257                    substituted: true,
3258                    cid_to_gid_map: None,
3259                    to_unicode,
3260                    ordering: ordering.clone(),
3261                    ucs2_encoding,
3262                    code_lengths,
3263                    code_to_cid: code_to_cid.clone(),
3264                    wmode,
3265                    dw2,
3266                    w2,
3267                }))
3268            }
3269        }
3270        _ => Err(PdfError::Other(format!(
3271            "Unsupported CIDFont subtype: {}",
3272            String::from_utf8_lossy(cid_subtype)
3273        ))),
3274    }
3275}
3276
3277/// Parse /W array from CIDFont dict into CID → width map.
3278///
3279/// Format: `[ cid_first [w1 w2 ...] cid_first cid_last w ... ]`
3280/// Extract `<hex>` tokens from a string, returning raw hex strings.
3281fn extract_hex_tokens(s: &str) -> Vec<&str> {
3282    let mut tokens = Vec::new();
3283    let mut rest = s;
3284    while let Some(start) = rest.find('<') {
3285        rest = &rest[start + 1..];
3286        if let Some(end) = rest.find('>') {
3287            let hex = rest[..end].trim();
3288            if !hex.is_empty() {
3289                tokens.push(hex);
3290            }
3291            rest = &rest[end + 1..];
3292        } else {
3293            break;
3294        }
3295    }
3296    tokens
3297}
3298
3299/// Parse a hex string as a Unicode codepoint.
3300/// For multi-byte destinations (>4 hex digits), extract just the first codepoint (first 4 digits).
3301fn hex_to_unicode(hex: &str) -> Option<u32> {
3302    if hex.len() <= 4 {
3303        u32::from_str_radix(hex, 16).ok()
3304    } else {
3305        // Multi-byte: two or more 16-bit codepoints packed together.
3306        // Check for common ligature sequences and map to Unicode ligature codepoints.
3307        match hex {
3308            "00660066" => Some(0xFB00),     // ff
3309            "00660069" => Some(0xFB01),     // fi
3310            "0066006C" => Some(0xFB02),     // fl
3311            "006600660069" => Some(0xFB03), // ffi
3312            "00660066006C" => Some(0xFB04), // ffl
3313            "017F0074" => Some(0xFB05),     // ſt (long s + t)
3314            "00730074" => Some(0xFB06),     // st
3315            _ => {
3316                // Unknown sequence — use first 16-bit codepoint
3317                u32::from_str_radix(&hex[..hex.len().min(4)], 16).ok()
3318            }
3319        }
3320    }
3321}
3322
3323/// Parse a ToUnicode CMap stream into a CID → Unicode mapping.
3324///
3325/// Handles `beginbfchar` and `beginbfrange` sections with hex-encoded values.
3326/// Multi-byte destination values (ligatures etc.) are mapped to their first codepoint.
3327fn parse_to_unicode(data: &[u8]) -> HashMap<u16, u32> {
3328    let mut map = HashMap::new();
3329    let text = String::from_utf8_lossy(data);
3330
3331    // Parse bfchar entries: <src_cid> <dst_unicode>
3332    // Process line-by-line to avoid pairing issues with multi-byte destinations
3333    let mut in_bfchar = false;
3334    let mut in_bfrange = false;
3335    let mut range_tokens: Vec<&str> = Vec::new();
3336
3337    for line in text.lines() {
3338        let trimmed = line.trim();
3339        if trimmed.ends_with("beginbfchar") {
3340            in_bfchar = true;
3341            continue;
3342        }
3343        if trimmed == "endbfchar" {
3344            in_bfchar = false;
3345            continue;
3346        }
3347        if trimmed.ends_with("beginbfrange") {
3348            in_bfrange = true;
3349            range_tokens.clear();
3350            continue;
3351        }
3352        if trimmed == "endbfrange" {
3353            in_bfrange = false;
3354            range_tokens.clear();
3355            continue;
3356        }
3357
3358        if in_bfchar {
3359            let tokens = extract_hex_tokens(trimmed);
3360            if tokens.len() >= 2
3361                && let Ok(cid) = u32::from_str_radix(tokens[0], 16)
3362                && let Some(unicode) = hex_to_unicode(tokens[1])
3363            {
3364                map.insert(cid as u16, unicode);
3365            }
3366        }
3367
3368        if in_bfrange {
3369            let line_tokens = extract_hex_tokens(trimmed);
3370            // Check for array syntax: <start> <end> [<u1> <u2> ...]
3371            if trimmed.contains('[') {
3372                // Collect start/end from previous tokens or this line
3373                let all_before_bracket: Vec<&str> = {
3374                    let before = trimmed.split('[').next().unwrap_or("");
3375                    extract_hex_tokens(before)
3376                };
3377                let in_bracket = {
3378                    let after_open = trimmed.split('[').nth(1).unwrap_or("");
3379                    let before_close = after_open.split(']').next().unwrap_or(after_open);
3380                    extract_hex_tokens(before_close)
3381                };
3382                if all_before_bracket.len() >= 2
3383                    && let (Some(start), Some(end)) = (
3384                        u32::from_str_radix(all_before_bracket[0], 16).ok(),
3385                        u32::from_str_radix(all_before_bracket[1], 16).ok(),
3386                    )
3387                {
3388                    for (j, cid) in (start..=end).enumerate() {
3389                        if j < in_bracket.len()
3390                            && let Some(u) = hex_to_unicode(in_bracket[j])
3391                        {
3392                            map.insert(cid as u16, u);
3393                        }
3394                    }
3395                }
3396            } else if line_tokens.len() >= 3 {
3397                // <start> <end> <dst_start>
3398                if let (Some(start), Some(end), Some(mut dst)) = (
3399                    u32::from_str_radix(line_tokens[0], 16).ok(),
3400                    u32::from_str_radix(line_tokens[1], 16).ok(),
3401                    hex_to_unicode(line_tokens[2]),
3402                ) {
3403                    for cid in start..=end {
3404                        map.insert(cid as u16, dst);
3405                        dst += 1;
3406                    }
3407                }
3408            }
3409        }
3410    }
3411
3412    map
3413}
3414
3415fn parse_cid_widths(cid_font_dict: &PdfDict, resolver: &Resolver) -> HashMap<u16, f64> {
3416    let mut widths = HashMap::new();
3417    // /W may be an indirect reference — resolve it before accessing as array
3418    let w_obj = match cid_font_dict.get(b"W") {
3419        Some(obj) => match resolver.deref(obj) {
3420            Ok(resolved) => resolved,
3421            Err(_) => return widths,
3422        },
3423        None => return widths,
3424    };
3425    let w_arr = match w_obj.as_array() {
3426        Some(arr) => arr,
3427        None => return widths,
3428    };
3429    let mut i = 0;
3430    while i < w_arr.len() {
3431        let first_cid = match &w_arr[i] {
3432            PdfObj::Int(n) => *n as u16,
3433            _ => break,
3434        };
3435        i += 1;
3436        if i >= w_arr.len() {
3437            break;
3438        }
3439        // Next element: array (individual widths) or int (range end)
3440        let next = resolver.deref(&w_arr[i]).unwrap_or(w_arr[i].clone());
3441        match &next {
3442            PdfObj::Array(arr) => {
3443                // [ cid_first [w1 w2 w3 ...] ] — consecutive CID widths
3444                for (j, w_obj) in arr.iter().enumerate() {
3445                    // Width entries may be indirect references
3446                    let w_val = w_obj
3447                        .as_f64()
3448                        .or_else(|| resolver.deref(w_obj).ok().and_then(|r| r.as_f64()))
3449                        .unwrap_or(0.0);
3450                    widths.insert(first_cid + j as u16, w_val / 1000.0);
3451                }
3452                i += 1;
3453            }
3454            _ => {
3455                // [ cid_first cid_last w ] — range with uniform width
3456                let last_cid = match &next {
3457                    PdfObj::Int(n) => *n as u16,
3458                    _ => first_cid,
3459                };
3460                i += 1;
3461                let w = if i < w_arr.len() {
3462                    let obj = &w_arr[i];
3463                    obj.as_f64()
3464                        .or_else(|| resolver.deref(obj).ok().and_then(|r| r.as_f64()))
3465                        .unwrap_or(0.0)
3466                        / 1000.0
3467                } else {
3468                    0.0
3469                };
3470                i += 1;
3471                for cid in first_cid..=last_cid {
3472                    widths.insert(cid, w);
3473                }
3474            }
3475        }
3476    }
3477    widths
3478}
3479
3480/// Parse /W2 array from CIDFont dict into CID → vertical metrics map.
3481///
3482/// Format mirrors /W but each entry has 3 values: w1 (vertical advance),
3483/// v_x and v_y (position vector from horizontal to vertical origin).
3484/// All values are in 1/1000 em units (NOT divided by 1000).
3485fn parse_cid_w2(cid_font_dict: &PdfDict, resolver: &Resolver) -> HashMap<u16, [f64; 3]> {
3486    let mut metrics = HashMap::new();
3487    let w2_obj = match cid_font_dict.get(b"W2") {
3488        Some(obj) => match resolver.deref(obj) {
3489            Ok(resolved) => resolved,
3490            Err(_) => return metrics,
3491        },
3492        None => return metrics,
3493    };
3494    let arr = match w2_obj.as_array() {
3495        Some(a) => a,
3496        None => return metrics,
3497    };
3498    let mut i = 0;
3499    while i < arr.len() {
3500        let first_cid = match &arr[i] {
3501            PdfObj::Int(n) => *n as u16,
3502            _ => break,
3503        };
3504        i += 1;
3505        if i >= arr.len() {
3506            break;
3507        }
3508        let next = resolver.deref(&arr[i]).unwrap_or(arr[i].clone());
3509        match &next {
3510            PdfObj::Array(sub) => {
3511                // [ cid_first [w1_1 v_x1 v_y1 w1_2 v_x2 v_y2 ...] ]
3512                let vals: Vec<f64> = sub.iter().filter_map(|o| o.as_f64()).collect();
3513                for (j, chunk) in vals.chunks(3).enumerate() {
3514                    if chunk.len() == 3 {
3515                        metrics.insert(first_cid + j as u16, [chunk[0], chunk[1], chunk[2]]);
3516                    }
3517                }
3518                i += 1;
3519            }
3520            _ => {
3521                // [ cid_first cid_last w1 v_x v_y ]
3522                let last_cid = match &next {
3523                    PdfObj::Int(n) => *n as u16,
3524                    _ => first_cid,
3525                };
3526                i += 1;
3527                if i + 2 < arr.len() {
3528                    let w1 = arr[i].as_f64().unwrap_or(-1000.0);
3529                    let vx = arr[i + 1].as_f64().unwrap_or(0.0);
3530                    let vy = arr[i + 2].as_f64().unwrap_or(880.0);
3531                    i += 3;
3532                    for cid in first_cid..=last_cid {
3533                        metrics.insert(cid, [w1, vx, vy]);
3534                    }
3535                } else {
3536                    break;
3537                }
3538            }
3539        }
3540    }
3541    metrics
3542}
3543
3544/// Strip PFB (Printer Font Binary) headers from Type 1 font data.
3545///
3546/// PFB format wraps ASCII and binary segments with 6-byte headers:
3547/// [0x80, type, len_lo, len_lo2, len_hi, len_hi2] + segment data
3548/// Type 1 = ASCII, Type 2 = binary (eexec), Type 3 = EOF.
3549fn strip_pfb(data: &[u8]) -> Vec<u8> {
3550    if data.len() < 2 || data[0] != 0x80 {
3551        return data.to_vec();
3552    }
3553    let mut result = Vec::with_capacity(data.len());
3554    let mut pos = 0;
3555    while pos + 6 <= data.len() && data[pos] == 0x80 {
3556        let segment_type = data[pos + 1];
3557        if segment_type == 3 {
3558            break; // EOF marker
3559        }
3560        let len = u32::from_le_bytes([data[pos + 2], data[pos + 3], data[pos + 4], data[pos + 5]])
3561            as usize;
3562        pos += 6;
3563        let end = (pos + len).min(data.len());
3564        result.extend_from_slice(&data[pos..end]);
3565        pos = end;
3566    }
3567    result
3568}
3569
3570// === Glyph rendering ===
3571
3572impl PdfFont {
3573    /// Get glyph outline path for a character code (single-byte fonts).
3574    /// Returns None for Type 3 fonts (they use content streams, not outlines).
3575    pub fn glyph_path(&self, char_code: u8) -> Option<PsPath> {
3576        match self {
3577            PdfFont::Type1(f) => f.glyph_path(char_code),
3578            PdfFont::TrueType(f) => f.glyph_path(char_code),
3579            PdfFont::Cff(f) => f.glyph_path(char_code),
3580            PdfFont::CidTrueType(f) => f.glyph_path_cid(char_code as u16),
3581            PdfFont::CidCff(f) => f.glyph_path_cid(char_code as u16),
3582            PdfFont::Type3(_) => None,
3583        }
3584    }
3585
3586    /// Get glyph outline path for a CID (2-byte composite fonts).
3587    pub fn glyph_path_cid(&self, cid: u16) -> Option<PsPath> {
3588        match self {
3589            PdfFont::CidTrueType(f) => f.glyph_path_cid(cid),
3590            PdfFont::CidCff(f) => f.glyph_path_cid(cid),
3591            _ => self.glyph_path(cid as u8),
3592        }
3593    }
3594
3595    /// Get glyph path for a Unicode code point, bypassing CID machinery.
3596    /// Used for malformed PDFs that mix WinAnsi literal strings in CID fonts.
3597    pub fn glyph_path_unicode(&self, unicode: u16) -> Option<PsPath> {
3598        match self {
3599            PdfFont::CidTrueType(f) => f.glyph_path_unicode(unicode),
3600            PdfFont::CidCff(f) => f.glyph_path_unicode(unicode),
3601            _ => None,
3602        }
3603    }
3604
3605    /// Get width for a Unicode code point from hmtx, bypassing CID widths.
3606    pub fn glyph_width_unicode(&self, unicode: u16) -> f64 {
3607        match self {
3608            PdfFont::CidTrueType(f) => f.glyph_width_unicode(unicode),
3609            _ => 0.0,
3610        }
3611    }
3612
3613    /// Get width for a character code (in text space units, already ÷1000).
3614    pub fn glyph_width(&self, char_code: u8) -> f64 {
3615        match self {
3616            PdfFont::Type1(f) => f.widths[char_code as usize],
3617            PdfFont::TrueType(f) => f.widths[char_code as usize],
3618            PdfFont::Cff(f) => f.widths[char_code as usize],
3619            PdfFont::CidTrueType(f) => f.glyph_width_cid(char_code as u16),
3620            PdfFont::CidCff(f) => f.glyph_width_cid(char_code as u16),
3621            PdfFont::Type3(f) => f.widths[char_code as usize],
3622        }
3623    }
3624
3625    /// Get width for a CID (2-byte composite fonts).
3626    pub fn glyph_width_cid(&self, cid: u16) -> f64 {
3627        match self {
3628            PdfFont::CidTrueType(f) => f.glyph_width_cid(cid),
3629            PdfFont::CidCff(f) => f.glyph_width_cid(cid),
3630            _ => self.glyph_width(cid as u8),
3631        }
3632    }
3633
3634    /// Font matrix (glyph space → text space).
3635    ///
3636    /// Returns identity for CidCff because the full matrix (including per-FD
3637    /// composition) is applied inside `glyph_path_cid()`.
3638    pub fn font_matrix(&self) -> Matrix {
3639        match self {
3640            PdfFont::Type1(f) => f.font_matrix,
3641            PdfFont::TrueType(_) | PdfFont::CidTrueType(_) => Matrix::identity(),
3642            PdfFont::Cff(f) => f.font_matrix,
3643            PdfFont::CidCff(_) => Matrix::identity(),
3644            PdfFont::Type3(f) => f.font_matrix,
3645        }
3646    }
3647
3648    /// Whether this is a composite (CID) font that uses multi-byte character codes.
3649    pub fn is_composite(&self) -> bool {
3650        matches!(self, PdfFont::CidTrueType(_) | PdfFont::CidCff(_))
3651    }
3652
3653    /// Writing mode: 0 = horizontal, 1 = vertical.
3654    pub fn wmode(&self) -> u8 {
3655        match self {
3656            PdfFont::CidTrueType(f) => f.wmode,
3657            PdfFont::CidCff(f) => f.wmode,
3658            _ => 0,
3659        }
3660    }
3661
3662    /// Default vertical metrics [v_y, w1] for vertical writing mode.
3663    /// v_y = vertical origin y offset (in 1/1000 em), w1 = vertical advance.
3664    pub fn dw2(&self) -> [f64; 2] {
3665        match self {
3666            PdfFont::CidTrueType(f) => f.dw2,
3667            PdfFont::CidCff(f) => f.dw2,
3668            _ => [880.0, -1000.0],
3669        }
3670    }
3671
3672    /// Get per-CID vertical metrics (w1, v_x, v_y), falling back to DW2.
3673    /// Returns values in 1/1000 em units.
3674    pub fn vertical_metrics_cid(&self, cid: u16) -> [f64; 3] {
3675        match self {
3676            PdfFont::CidTrueType(f) => {
3677                if let Some(&m) = f.w2.get(&cid) {
3678                    m
3679                } else {
3680                    // DW2 = [v_y, w1]; v_x defaults to half the horizontal width
3681                    let w0 = f.cid_widths.get(&cid).copied().unwrap_or(f.default_width) * 1000.0;
3682                    [f.dw2[1], w0 / 2.0, f.dw2[0]]
3683                }
3684            }
3685            PdfFont::CidCff(f) => {
3686                if let Some(&m) = f.w2.get(&cid) {
3687                    m
3688                } else {
3689                    let w0 = f.cid_widths.get(&cid).copied().unwrap_or(f.default_width) * 1000.0;
3690                    [f.dw2[1], w0 / 2.0, f.dw2[0]]
3691                }
3692            }
3693            _ => [-1000.0, 500.0, 880.0],
3694        }
3695    }
3696
3697    /// Whether a CID maps to a GID that exists in the font.
3698    /// Used to distinguish valid 2-byte CID codes from misinterpreted WinAnsi
3699    /// bytes in malformed PDFs that mix 1-byte literal text with CID fonts.
3700    pub fn has_cid_glyph(&self, cid: u16) -> bool {
3701        match self {
3702            PdfFont::CidTrueType(f) => f.has_glyph(cid),
3703            PdfFont::CidCff(_) => true, // CFF handles this differently
3704            _ => false,
3705        }
3706    }
3707
3708    /// Map a raw character code to a CID using the encoding CMap.
3709    /// Returns the code unchanged if no mapping exists (identity encoding).
3710    pub fn resolve_code_to_cid(&self, code: u32) -> u32 {
3711        match self {
3712            PdfFont::CidTrueType(f) => f.code_to_cid.get(&code).copied().unwrap_or(code),
3713            PdfFont::CidCff(f) => f.code_to_cid.get(&code).copied().unwrap_or(code),
3714            _ => code,
3715        }
3716    }
3717
3718    /// Get the byte width of a character code starting with the given byte.
3719    /// Only meaningful for composite fonts; returns 1 for simple fonts.
3720    pub fn code_width(&self, first_byte: u8) -> usize {
3721        match self {
3722            PdfFont::CidTrueType(f) => {
3723                let w = f.code_lengths[first_byte as usize];
3724                if w == 0 { 2 } else { w as usize }
3725            }
3726            PdfFont::CidCff(f) => {
3727                let w = f.code_lengths[first_byte as usize];
3728                if w == 0 { 2 } else { w as usize }
3729            }
3730            _ => 1,
3731        }
3732    }
3733
3734    /// Whether this is a Type 3 font (glyphs are content streams).
3735    pub fn is_type3(&self) -> bool {
3736        matches!(self, PdfFont::Type3(_))
3737    }
3738
3739    /// Get the Type 3 glyph stream data for a character code.
3740    pub fn type3_char_proc(&self, char_code: u8) -> Option<&[u8]> {
3741        match self {
3742            PdfFont::Type3(f) => f.char_procs.get(&char_code).map(|v| v.as_slice()),
3743            _ => None,
3744        }
3745    }
3746
3747    /// Get the Type 3 font resources dict.
3748    pub fn type3_resources(&self) -> Option<&PdfDict> {
3749        match self {
3750            PdfFont::Type3(f) => Some(&f.resources),
3751            _ => None,
3752        }
3753    }
3754}
3755
3756impl Type1PdfFont {
3757    fn glyph_path(&self, char_code: u8) -> Option<PsPath> {
3758        let glyph_name = self.encoding[char_code as usize].as_deref();
3759        let charstring = glyph_name
3760            .and_then(|name| self.font.charstrings.get(name))
3761            .or_else(|| {
3762                if !self.builtin_fallback {
3763                    return None;
3764                }
3765                let builtin = self.font.encoding.get(char_code as usize)?;
3766                if builtin != ".notdef" && glyph_name.map_or(true, |n| n != builtin) {
3767                    self.font.charstrings.get(builtin.as_str())
3768                } else {
3769                    None
3770                }
3771            })?;
3772        // Provide charstring lookup for seac (accented character composition)
3773        let cs_lookup =
3774            |name: &str| -> Option<Vec<u8>> { self.font.charstrings.get(name).cloned() };
3775        let result = execute_charstring_mm(
3776            charstring,
3777            &self.font.subrs,
3778            self.font.len_iv,
3779            false,
3780            Some(&cs_lookup),
3781            self.weight_vector.as_deref(),
3782        )
3783        .ok()?;
3784        // For non-metric-compatible substitutes, scale glyph horizontally so its
3785        // width matches the PDF's /Widths entry. Without this, the substitute
3786        // font's different glyph metrics cause crowded or sparse character spacing.
3787        if self.per_char_width_scale {
3788            let pdf_w = self.widths[char_code as usize];
3789            let font_w = result.width_x * self.font_matrix.a;
3790            if font_w.abs() > 0.001 && pdf_w > 0.001 && (pdf_w / font_w - 1.0).abs() > 0.01 {
3791                return Some(result.path.transform(&Matrix::scale(pdf_w / font_w, 1.0)));
3792            }
3793        }
3794        Some(result.path)
3795    }
3796}
3797
3798impl TrueTypePdfFont {
3799    /// Check if any gNNNN glyph name in the encoding contains hex letters (a-f),
3800    /// indicating the subsetting tool used hexadecimal GIDs.
3801    fn detect_gid_hex(encoding: &[Option<String>; 256]) -> bool {
3802        encoding.iter().any(|name| {
3803            if let Some(n) = name {
3804                n.starts_with('g')
3805                    && n.len() > 1
3806                    && n[1..].bytes().all(|b| b.is_ascii_hexdigit())
3807                    && n[1..]
3808                        .bytes()
3809                        .any(|b| b.is_ascii_hexdigit() && !b.is_ascii_digit())
3810            } else {
3811                false
3812            }
3813        })
3814    }
3815
3816    fn glyph_path(&self, char_code: u8) -> Option<PsPath> {
3817        let gid = self.char_code_to_gid(char_code);
3818        let gid = gid?;
3819        let path = skrifa_glyph_path(&self.data, gid, self.units_per_em).or_else(|| {
3820            // Fallback for locx/glyx PDF-subset fonts that skrifa can't parse
3821            let glyf_data = get_glyf_data(&self.data, gid)?;
3822            let data_ref = &self.data;
3823            let p = parse_glyf_to_path(&glyf_data, &|cid| get_glyf_data(data_ref, cid));
3824            if p.is_empty() { None } else { Some(p) }
3825        })?;
3826        let scale = 1.0 / self.units_per_em;
3827        let m = Matrix::scale(scale, scale);
3828        Some(path.transform(&m))
3829    }
3830
3831    fn char_code_to_gid(&self, char_code: u8) -> Option<u16> {
3832        // Symbolic re-encoded fonts: skip the encoding→AGL→cmap path, which maps
3833        // StandardEncoding names (e.g. "circumflex") to wrong Unicode→GID values.
3834        // Go directly to the cmap lookup by char code.
3835        if self.identity_gid {
3836            if let Some(&gid) = self.cmap.get(&(char_code as u32)) {
3837                return Some(gid);
3838            }
3839            if let Some(&gid) = self.cmap.get(&(0xF000 + char_code as u32)) {
3840                return Some(gid);
3841            }
3842            return Some(char_code as u16);
3843        }
3844        if let Some(glyph_name) = &self.encoding[char_code as usize] {
3845            // Only use encoding → glyph name → Unicode → cmap when the cmap
3846            // is Unicode-keyed ((3,1), (3,10), or (0,*)).  Non-Unicode cmaps
3847            // ((1,0) Mac Roman, (3,0) Symbol) in subset fonts map re-encoded
3848            // char codes directly — looking up Unicode values gives wrong GIDs.
3849            if self.cmap_is_unicode {
3850                if let Some(unicode) = stet_fonts::agl::glyph_name_to_unicode(glyph_name)
3851                    && let Some(&gid) = self.cmap.get(&(unicode as u32))
3852                {
3853                    return Some(gid);
3854                }
3855            }
3856        }
3857        // ToUnicode CMap → Unicode → cmap GID.  Tried before gNNNN because
3858        // gNNNN GIDs are font-specific — they're wrong for substitute fonts
3859        // (e.g. SimSun g18331 ≠ NotoSerif GID 18331).
3860        // Only use when cmap is Unicode-keyed — non-Unicode cmaps map
3861        // re-encoded char codes, not Unicode values.
3862        if self.cmap_is_unicode {
3863            if let Some(&unicode) = self.to_unicode.get(&(char_code as u16))
3864                && let Some(&gid) = self.cmap.get(&unicode)
3865            {
3866                return Some(gid);
3867            }
3868        }
3869        if let Some(glyph_name) = &self.encoding[char_code as usize] {
3870            // Try gNNNN pattern → direct GID (for embedded fonts where GIDs match).
3871            // Some subsetting tools use hex (g003a = GID 58), others decimal (g1863).
3872            // detect_gid_hex() checks if any name in this font has hex letters (a-f).
3873            if glyph_name.starts_with('g')
3874                && glyph_name.len() > 1
3875                && glyph_name[1..].bytes().all(|b| b.is_ascii_hexdigit())
3876            {
3877                let suffix = &glyph_name[1..];
3878                let gid = if self.gid_hex {
3879                    u16::from_str_radix(suffix, 16).ok()
3880                } else {
3881                    suffix.parse::<u16>().ok()
3882                };
3883                if let Some(gid) = gid {
3884                    return Some(gid);
3885                }
3886            }
3887        }
3888        // Direct cmap lookup by char code — preferred over post table for subset
3889        // TrueType fonts where glyph names may not match character code positions.
3890        if let Some(&gid) = self.cmap.get(&(char_code as u32)) {
3891            return Some(gid);
3892        }
3893        if let Some(glyph_name) = &self.encoding[char_code as usize] {
3894            // Post table fallback (handles ligatures like fl/fi)
3895            if let Some(&gid) = self.post_name_to_gid.get(glyph_name.as_str()) {
3896                return Some(gid);
3897            }
3898        }
3899        // Windows Symbol encoding (U+F0XX range, common in subset fonts)
3900        if let Some(&gid) = self.cmap.get(&(0xF000 + char_code as u32)) {
3901            return Some(gid);
3902        }
3903        // ToUnicode → glyph lookup.  Handles buggy subset fonts where the (3,0)
3904        // Symbol cmap is missing entries (e.g. issue8234: 0xF020 omitted).
3905        // Try AGL name → post table first, then skrifa's charmap (which checks
3906        // all cmap subtables including ones our parser may not have selected).
3907        if let Some(&unicode) = self.to_unicode.get(&(char_code as u16)) {
3908            if let Some(name) = stet_fonts::system_fonts::unicode_to_glyph_name(unicode) {
3909                if let Some(&gid) = self.post_name_to_gid.get(name) {
3910                    return Some(gid);
3911                }
3912            }
3913            if let Ok(font_ref) = skrifa::FontRef::new(&self.data) {
3914                let charmap = font_ref.charmap();
3915                if let Some(gid) = charmap.map(unicode) {
3916                    return Some(gid.to_u32() as u16);
3917                }
3918            }
3919            // Last resort: scan for unmapped composite GIDs.  Buggy subset
3920            // fonts may omit cmap entries for some glyphs.  The missing glyph
3921            // is typically a TrueType composite (e.g. ä = a + dieresis) at an
3922            // unmapped GID — sometimes even GID 0 (issue8234).
3923            if !self.cmap.is_empty() {
3924                use stet_fonts::truetype::{find_table, read_u16};
3925                let mapped: std::collections::HashSet<u16> = self.cmap.values().copied().collect();
3926                let num_glyphs = find_table(&self.data, b"maxp")
3927                    .map(|(off, _)| read_u16(&self.data, off + 4))
3928                    .unwrap_or(0);
3929                // Find an unmapped GID that is a composite glyph (numContours < 0).
3930                // Composites are the actual characters; simple glyphs at unmapped
3931                // GIDs are base components (a, dieresis, ring, etc.).
3932                for gid in 0..num_glyphs {
3933                    if mapped.contains(&gid) {
3934                        continue;
3935                    }
3936                    if let Some(glyf_data) = get_glyf_data(&self.data, gid) {
3937                        if glyf_data.len() >= 2 {
3938                            let num_contours = stet_fonts::truetype::read_i16(&glyf_data, 0);
3939                            if num_contours < 0 {
3940                                return Some(gid);
3941                            }
3942                        }
3943                    }
3944                }
3945            }
3946        }
3947        if self.cmap.is_empty() {
3948            // No cmap table: use char code as GID directly (PDF subset identity mapping)
3949            Some(char_code as u16)
3950        } else {
3951            None
3952        }
3953    }
3954}
3955
3956impl CidTrueTypePdfFont {
3957    /// For UCS2 encodings, convert Unicode code point to CID for width lookup.
3958    fn resolve_cid(&self, code: u16) -> u16 {
3959        // Only remap when code_to_cid is empty (no CMap loaded) — in that case
3960        // the code IS a raw Unicode code point that needs mapping to a CID.
3961        // When a CMap IS loaded, it has already mapped to the correct CID.
3962        if self.ucs2_encoding && !self.ordering.is_empty() && self.code_to_cid.is_empty() {
3963            super::cid_unicode::unicode_to_cid(&self.ordering, code as u32).unwrap_or(code)
3964        } else {
3965            code
3966        }
3967    }
3968
3969    fn glyph_path_cid(&self, cid: u16) -> Option<PsPath> {
3970        if std::env::var("STET_DEBUG_TEXT").is_ok() {
3971            eprintln!(
3972                "[cid_tt] cid={} sub={} ordering={} identity={} to_unicode={} cmap={} cid_to_gid_map={}",
3973                cid,
3974                self.substituted,
3975                String::from_utf8_lossy(&self.ordering),
3976                self.identity_cid_to_gid,
3977                !self.to_unicode.is_empty(),
3978                !self.cmap.is_empty(),
3979                self.cid_to_gid_map.is_some()
3980            );
3981        }
3982        let gid = if self.ucs2_encoding && !self.cmap.is_empty() && self.code_to_cid.is_empty() {
3983            // UCS2 encoding with no CMap: cid is a raw Unicode code point, map via cmap.
3984            if let Some(&g) = self.cmap.get(&(cid as u32)) {
3985                g
3986            } else {
3987                return None;
3988            }
3989        } else if self.ucs2_encoding && self.substituted && !self.ordering.is_empty() {
3990            // CMap was loaded: cid is an Adobe CID, convert back to Unicode for glyph lookup
3991            let unicode = super::cid_unicode::cid_to_unicode(&self.ordering, cid)?;
3992            *self.cmap.get(&unicode)?
3993        } else if let Some(ref map) = self.cid_to_gid_map {
3994            // Explicit CIDToGIDMap stream: look up CID → GID
3995            *map.get(cid as usize).unwrap_or(&0)
3996        } else if self.substituted && !self.to_unicode.is_empty() {
3997            // Substituted font: CID → Unicode (via ToUnicode) → GID (via cmap)
3998            if let Some(&unicode) = self.to_unicode.get(&cid) {
3999                *self.cmap.get(&unicode)?
4000            } else {
4001                // GID not covered by the to_unicode map (e.g. extended Latin
4002                // chars missing from the standard glyph map). Fall back to
4003                // CID as GID directly — may be wrong but better than blank.
4004                cid
4005            }
4006        } else if self.substituted && !self.ordering.is_empty() && self.ordering != b"Identity" {
4007            // Substituted font with Adobe CID registry (CJK): use CID→Unicode table
4008            let unicode = super::cid_unicode::cid_to_unicode(&self.ordering, cid)?;
4009            *self.cmap.get(&unicode)?
4010        } else if self.identity_cid_to_gid {
4011            // Identity CIDToGIDMap: CID = GID directly.
4012            cid
4013        } else if self.substituted && !self.cmap.is_empty() {
4014            // Substituted font with no ToUnicode, no CIDToGIDMap, and non-identity:
4015            // treat CID as Unicode and map through the substitute's cmap.
4016            if let Some(&g) = self.cmap.get(&(cid as u32)) {
4017                g
4018            } else {
4019                cid
4020            }
4021        } else if !self.cmap.is_empty() {
4022            // Non-Identity mapping: CID is Unicode, use cmap
4023            *self.cmap.get(&(cid as u32))?
4024        } else {
4025            cid
4026        };
4027        let path = skrifa_glyph_path(&self.data, gid, self.units_per_em).or_else(|| {
4028            // Fallback for fonts where skrifa can't render a glyph (e.g. locx/glyx
4029            // PDF-subset tables, or skrifa CFF rendering gaps).
4030            let glyf_data = get_glyf_data(&self.data, gid)?;
4031            let data_ref = &self.data;
4032            let p = parse_glyf_to_path(&glyf_data, &|cid| get_glyf_data(data_ref, cid));
4033            // Sanity check: real glyphs have at most a few thousand segments.
4034            // Bogus GIDs reading random glyf bytes can produce millions.
4035            if p.is_empty() || p.segments.len() > 10_000 {
4036                None
4037            } else {
4038                Some(p)
4039            }
4040        });
4041        let path = path?;
4042        let scale = 1.0 / self.units_per_em;
4043        // For substituted fonts, scale glyphs horizontally so their width matches
4044        // the PDF's /W array (original font metrics). Without this, the substitute
4045        // font's wider/narrower glyphs cause crowded or sparse text.
4046        let m = if self.substituted {
4047            // Only scale horizontally when the CID has an explicit /W entry.
4048            // When the width comes from the substitute font's hmtx (no /W entry),
4049            // the advance and glyph width already match — scaling would stretch
4050            // the glyph to DW while the advance uses the natural hmtx width.
4051            let pdf_w = self.cid_widths.get(&cid).copied();
4052            let font_w =
4053                hmtx_advance_width(&self.data, gid, self.units_per_em).unwrap_or(0.0) / 1000.0;
4054            if let Some(pw) = pdf_w {
4055                if font_w > 0.001 && pw > 0.001 {
4056                    Matrix::new(scale * pw / font_w, 0.0, 0.0, scale, 0.0, 0.0)
4057                } else {
4058                    Matrix::scale(scale, scale)
4059                }
4060            } else {
4061                Matrix::scale(scale, scale)
4062            }
4063        } else {
4064            Matrix::scale(scale, scale)
4065        };
4066        Some(path.transform(&m))
4067    }
4068
4069    /// Check if a GID exists in the font (GID < numGlyphs from maxp table).
4070    /// Unlike glyph_path_cid, this returns true for space/whitespace GIDs
4071    /// that have no visible outline.
4072    fn has_glyph(&self, cid: u16) -> bool {
4073        // A CID with an explicit width in /W is always valid — even if we
4074        // can't resolve it to a glyph in the substitute font, the CID path
4075        // must be used so text advancement uses the correct width.
4076        if self.cid_widths.contains_key(&cid) {
4077            return true;
4078        }
4079        // Resolve CID to GID using the same logic as glyph_path_cid
4080        let gid = if let Some(ref map) = self.cid_to_gid_map {
4081            *map.get(cid as usize).unwrap_or(&0)
4082        } else if self.substituted && !self.to_unicode.is_empty() {
4083            // Substituted font with GID→Unicode table: resolve via cmap
4084            if let Some(&unicode) = self.to_unicode.get(&cid) {
4085                if let Some(&g) = self.cmap.get(&unicode) {
4086                    g
4087                } else {
4088                    return false;
4089                }
4090            } else {
4091                return false;
4092            }
4093        } else if self.identity_cid_to_gid {
4094            cid
4095        } else {
4096            return true; // non-identity: assume valid
4097        };
4098        // Check against font's glyph count
4099        let num_glyphs = stet_fonts::truetype::get_num_glyphs(&self.data);
4100        (gid as u32) < num_glyphs
4101    }
4102
4103    fn glyph_width_cid(&self, cid: u16) -> f64 {
4104        let resolved = self.resolve_cid(cid);
4105        if let Some(&w) = self.cid_widths.get(&resolved) {
4106            return w;
4107        }
4108        // For substituted fonts with GID-to-Unicode tables, use the substitute
4109        // font's actual advance width instead of /DW. Many PDFs only populate
4110        // /W for a subset of CIDs, and /DW 1000 (full em) is wildly wrong for
4111        // narrow Latin characters like accented letters.
4112        if self.substituted && !self.to_unicode.is_empty() {
4113            if let Some(&unicode) = self.to_unicode.get(&cid) {
4114                if let Some(&gid) = self.cmap.get(&unicode) {
4115                    if let Some(w) = hmtx_advance_width(&self.data, gid, self.units_per_em) {
4116                        return w / 1000.0;
4117                    }
4118                }
4119            }
4120        }
4121        self.default_width
4122    }
4123
4124    /// Get glyph path for a Unicode code point via cmap, bypassing CID mapping.
4125    /// Used when malformed PDFs embed WinAnsi literal strings in a CID font.
4126    fn glyph_path_unicode(&self, unicode: u16) -> Option<PsPath> {
4127        let &gid = self.cmap.get(&(unicode as u32))?;
4128        let path = skrifa_glyph_path(&self.data, gid, self.units_per_em)?;
4129        let scale = 1.0 / self.units_per_em;
4130        let m = Matrix::scale(scale, scale);
4131        Some(path.transform(&m))
4132    }
4133
4134    /// Get width for a Unicode code point from hmtx via cmap, bypassing CID widths.
4135    /// Returns width in the same scale as glyph_width_cid (1/1000 of text space).
4136    fn glyph_width_unicode(&self, unicode: u16) -> f64 {
4137        if let Some(&gid) = self.cmap.get(&(unicode as u32)) {
4138            // hmtx_advance_width returns units in 1/1000 em; CID widths are stored
4139            // already divided by 1000, so divide here too for consistency.
4140            hmtx_advance_width(&self.data, gid, self.units_per_em)
4141                .map(|w| w / 1000.0)
4142                .unwrap_or(self.default_width)
4143        } else {
4144            self.default_width
4145        }
4146    }
4147}
4148
4149impl CidCffPdfFont {
4150    /// Render the CFF charstring at the given GID.
4151    fn glyph_path_at_gid(&self, gid: usize) -> Option<PsPath> {
4152        if gid >= self.font.char_strings.len() {
4153            return None;
4154        }
4155        let (default_width_x, nominal_width_x, local_subrs, fd_font_matrix) = if self.font.is_cid
4156            && !self.font.fd_select.is_empty()
4157            && !self.font.fd_array.is_empty()
4158        {
4159            let fd_idx = *self.font.fd_select.get(gid).unwrap_or(&0) as usize;
4160            if let Some(fd) = self.font.fd_array.get(fd_idx) {
4161                (
4162                    fd.default_width_x,
4163                    fd.nominal_width_x,
4164                    &fd.local_subrs,
4165                    fd.font_matrix,
4166                )
4167            } else {
4168                (
4169                    self.font.default_width_x,
4170                    self.font.nominal_width_x,
4171                    &self.font.local_subrs,
4172                    None,
4173                )
4174            }
4175        } else {
4176            (
4177                self.font.default_width_x,
4178                self.font.nominal_width_x,
4179                &self.font.local_subrs,
4180                None,
4181            )
4182        };
4183        let result = execute_type2_charstring(
4184            &self.font.char_strings[gid],
4185            local_subrs,
4186            &self.font.global_subrs,
4187            default_width_x,
4188            nominal_width_x,
4189            false,
4190        )
4191        .ok()?;
4192        let effective_fm = if let Some(fd_fm) = fd_font_matrix {
4193            let fd = Matrix::new(fd_fm[0], fd_fm[1], fd_fm[2], fd_fm[3], fd_fm[4], fd_fm[5]);
4194            if fd.a.abs() < 0.01 || fd.d.abs() < 0.01 {
4195                fd
4196            } else {
4197                self.font_matrix.concat(&fd)
4198            }
4199        } else {
4200            self.font_matrix
4201        };
4202        Some(result.path.transform(&effective_fm))
4203    }
4204
4205    /// Render a glyph by Unicode code point via the font's cmap table.
4206    fn glyph_path_unicode(&self, unicode: u16) -> Option<PsPath> {
4207        let cmap = self.cmap.as_ref()?;
4208        let &gid = cmap.get(&(unicode as u32))?;
4209        self.glyph_path_at_gid(gid as usize)
4210    }
4211
4212    fn glyph_path_cid(&self, cid: u16) -> Option<PsPath> {
4213        // Type 1 fonts wrapped as CIDFontType0: use pre-computed paths.
4214        if let Some(ref paths) = self.type1_paths {
4215            return paths.get(&cid).cloned();
4216        }
4217        // For embedded OTF/CFF with PDF CIDToGIDMap, use the PDF's mapping.
4218        // For OpenType/CFF substitutes with a cmap, map Unicode → GID directly.
4219        // For embedded CID-keyed CFF, use cid_to_gid mapping.
4220        let gid = if let Some(ref map) = self.pdf_cid_to_gid {
4221            // Embedded font with PDF-supplied CID→GID map
4222            *map.get(cid as usize).unwrap_or(&0) as usize
4223        } else if self.identity_cid_to_gid {
4224            // Identity CIDToGIDMap: CID = charstring index directly.
4225            // Common for CIDFontType2 fonts stored as OTTO/CFF in FontFile2.
4226            cid as usize
4227        } else if let Some(ref cmap) = self.cmap {
4228            // OTF font with Unicode cmap (substituted fonts, or non-CID fonts).
4229            // If this is a substituted font with an Adobe CID ordering
4230            // (e.g. Japan1), the CID is from the Adobe registry, not Unicode.
4231            // Convert CID → Unicode first, then look up in cmap.
4232            if !self.ordering.is_empty() && self.ordering != b"Identity" {
4233                let unicode = super::cid_unicode::cid_to_unicode(&self.ordering, cid)?;
4234                // For CJK substitution, try full-width glyph variants first.
4235                // The substitute font may have a proportional glyph for U+00B7
4236                // (MIDDLE DOT, narrow) while the original CJK font used a
4237                // full-width centered dot. U+30FB is the CJK full-width variant.
4238                let gid_opt = cjk_fullwidth_alternative(unicode)
4239                    .and_then(|alt| cmap.get(&alt))
4240                    .or_else(|| cmap.get(&unicode));
4241                *gid_opt? as usize
4242            } else {
4243                *cmap.get(&(cid as u32))? as usize
4244            }
4245        } else if !self.font.cid_to_gid.is_empty() {
4246            let g = *self.font.cid_to_gid.get(cid as usize)?;
4247            if g == 0xFFFF {
4248                return None;
4249            }
4250            g as usize
4251        } else {
4252            cid as usize
4253        };
4254        self.glyph_path_at_gid(gid)
4255    }
4256
4257    fn glyph_width_cid(&self, cid: u16) -> f64 {
4258        // CID widths from the /W array are already keyed by CID — use directly.
4259        self.cid_widths
4260            .get(&cid)
4261            .copied()
4262            .unwrap_or(self.default_width)
4263    }
4264}
4265
4266impl CffPdfFont {
4267    fn glyph_path(&self, char_code: u8) -> Option<PsPath> {
4268        let glyph_name = self.encoding[char_code as usize].as_deref()?;
4269        // The PDF /Encoding is authoritative: map char_code → glyph name,
4270        // then find that glyph in the CFF charset. This is essential for
4271        // subset fonts where the CFF internal encoding maps codes to
4272        // sequential GIDs that don't match the PDF encoding's glyph names.
4273        // Fall back to the CFF's built-in encoding only when the charset
4274        // lookup fails (e.g., fonts without a proper charset).
4275        let gid = self
4276            .font
4277            .charset
4278            .iter()
4279            .position(|name| name == glyph_name)
4280            .or_else(|| {
4281                let cff_gid = self
4282                    .font
4283                    .encoding
4284                    .get(char_code as usize)
4285                    .copied()
4286                    .unwrap_or(0) as usize;
4287                if cff_gid > 0 && cff_gid < self.font.char_strings.len() {
4288                    Some(cff_gid)
4289                } else {
4290                    None
4291                }
4292            });
4293        let gid = gid?;
4294        if gid >= self.font.char_strings.len() {
4295            return None;
4296        }
4297        let result = execute_type2_charstring(
4298            &self.font.char_strings[gid],
4299            &self.font.local_subrs,
4300            &self.font.global_subrs,
4301            self.font.default_width_x,
4302            self.font.nominal_width_x,
4303            false,
4304        )
4305        .ok()?;
4306
4307        // Handle deprecated seac (accented character composition)
4308        if let Some((adx, ady, bchar, achar)) = result.seac {
4309            return self.compose_seac(adx, ady, bchar, achar);
4310        }
4311
4312        Some(result.path)
4313    }
4314
4315    /// Compose a seac (Standard Encoding Accented Character) glyph from
4316    /// base and accent glyphs. bchar/achar are Standard Encoding codes.
4317    fn compose_seac(&self, adx: f64, ady: f64, bchar: u8, achar: u8) -> Option<PsPath> {
4318        use stet_fonts::encoding::STANDARD_ENCODING;
4319
4320        let base_name = STANDARD_ENCODING.get(bchar as usize).copied().unwrap_or("");
4321        let accent_name = STANDARD_ENCODING.get(achar as usize).copied().unwrap_or("");
4322
4323        let base_gid = self.font.charset.iter().position(|n| n == base_name)?;
4324        let accent_gid = self.font.charset.iter().position(|n| n == accent_name)?;
4325
4326        let base_result = execute_type2_charstring(
4327            &self.font.char_strings[base_gid],
4328            &self.font.local_subrs,
4329            &self.font.global_subrs,
4330            self.font.default_width_x,
4331            self.font.nominal_width_x,
4332            false,
4333        )
4334        .ok()?;
4335
4336        let accent_result = execute_type2_charstring(
4337            &self.font.char_strings[accent_gid],
4338            &self.font.local_subrs,
4339            &self.font.global_subrs,
4340            self.font.default_width_x,
4341            self.font.nominal_width_x,
4342            false,
4343        )
4344        .ok()?;
4345
4346        // Combine: base path + accent path offset by (adx, ady)
4347        let mut combined = base_result.path;
4348        let offset = Matrix::translate(adx, ady);
4349        let shifted_accent = accent_result.path.transform(&offset);
4350        combined
4351            .segments
4352            .extend_from_slice(&shifted_accent.segments);
4353        Some(combined)
4354    }
4355}
4356
4357/// Pen adapter that converts skrifa outline callbacks into a `PsPath`.
4358struct PsPathPen {
4359    path: PsPath,
4360    cur_x: f64,
4361    cur_y: f64,
4362}
4363
4364impl skrifa::outline::OutlinePen for PsPathPen {
4365    fn move_to(&mut self, x: f32, y: f32) {
4366        self.cur_x = x as f64;
4367        self.cur_y = y as f64;
4368        self.path
4369            .segments
4370            .push(PathSegment::MoveTo(self.cur_x, self.cur_y));
4371    }
4372    fn line_to(&mut self, x: f32, y: f32) {
4373        self.cur_x = x as f64;
4374        self.cur_y = y as f64;
4375        self.path
4376            .segments
4377            .push(PathSegment::LineTo(self.cur_x, self.cur_y));
4378    }
4379    fn quad_to(&mut self, cx: f32, cy: f32, x: f32, y: f32) {
4380        let cx = cx as f64;
4381        let cy = cy as f64;
4382        let ex = x as f64;
4383        let ey = y as f64;
4384        // Quadratic → cubic degree elevation
4385        let cp1x = self.cur_x + 2.0 / 3.0 * (cx - self.cur_x);
4386        let cp1y = self.cur_y + 2.0 / 3.0 * (cy - self.cur_y);
4387        let cp2x = ex + 2.0 / 3.0 * (cx - ex);
4388        let cp2y = ey + 2.0 / 3.0 * (cy - ey);
4389        self.cur_x = ex;
4390        self.cur_y = ey;
4391        self.path.segments.push(PathSegment::CurveTo {
4392            x1: cp1x,
4393            y1: cp1y,
4394            x2: cp2x,
4395            y2: cp2y,
4396            x3: ex,
4397            y3: ey,
4398        });
4399    }
4400    fn curve_to(&mut self, cx0: f32, cy0: f32, cx1: f32, cy1: f32, x: f32, y: f32) {
4401        self.cur_x = x as f64;
4402        self.cur_y = y as f64;
4403        self.path.segments.push(PathSegment::CurveTo {
4404            x1: cx0 as f64,
4405            y1: cy0 as f64,
4406            x2: cx1 as f64,
4407            y2: cy1 as f64,
4408            x3: self.cur_x,
4409            y3: self.cur_y,
4410        });
4411    }
4412    fn close(&mut self) {
4413        self.path.segments.push(PathSegment::ClosePath);
4414    }
4415}
4416
4417/// Extract a TrueType glyph outline using skrifa with hinting enabled.
4418///
4419/// Hinting is needed for correct composite glyph assembly — some fonts have
4420/// TrueType instructions that adjust component positions. Falls back to the
4421/// hand-written parser for fonts skrifa can't handle (e.g., locx/glyx subsets).
4422/// Map a WinAnsiEncoding byte to its Unicode code point.
4423/// Bytes 0x00-0x7F and 0xA0-0xFF match Unicode (ISO 8859-1).
4424/// Bytes 0x80-0x9F differ — WinAnsi maps these to specific Unicode characters.
4425pub(crate) fn winansi_byte_to_unicode(byte: u8) -> u16 {
4426    match byte {
4427        0x80 => 0x20AC, // €
4428        0x82 => 0x201A, // ‚
4429        0x83 => 0x0192, // ƒ
4430        0x84 => 0x201E, // „
4431        0x85 => 0x2026, // …
4432        0x86 => 0x2020, // †
4433        0x87 => 0x2021, // ‡
4434        0x88 => 0x02C6, // ˆ
4435        0x89 => 0x2030, // ‰
4436        0x8A => 0x0160, // Š
4437        0x8B => 0x2039, // ‹
4438        0x8C => 0x0152, // Œ
4439        0x8E => 0x017D, // Ž
4440        0x91 => 0x2018, // '
4441        0x92 => 0x2019, // '
4442        0x93 => 0x201C, // "
4443        0x94 => 0x201D, // "
4444        0x95 => 0x2022, // •
4445        0x96 => 0x2013, // –
4446        0x97 => 0x2014, // —
4447        0x98 => 0x02DC, // ˜
4448        0x99 => 0x2122, // ™
4449        0x9A => 0x0161, // š
4450        0x9B => 0x203A, // ›
4451        0x9C => 0x0153, // œ
4452        0x9E => 0x017E, // ž
4453        0x9F => 0x0178, // Ÿ
4454        _ => byte as u16,
4455    }
4456}
4457
4458/// Read the advance width for a GID from the hmtx table, returning the width
4459/// in text space (1/1000 em) for PDF CID width compatibility.
4460fn hmtx_advance_width(font_data: &[u8], gid: u16, units_per_em: f64) -> Option<f64> {
4461    use stet_fonts::truetype::{find_table, read_u16};
4462    let (hhea_off, _) = find_table(font_data, b"hhea")?;
4463    let (hmtx_off, _) = find_table(font_data, b"hmtx")?;
4464    if hhea_off + 36 > font_data.len() {
4465        return None;
4466    }
4467    let num_h_metrics = read_u16(font_data, hhea_off + 34) as usize;
4468    let gid = gid as usize;
4469    let advance = if gid < num_h_metrics {
4470        let offset = hmtx_off + gid * 4;
4471        if offset + 2 > font_data.len() {
4472            return None;
4473        }
4474        read_u16(font_data, offset)
4475    } else {
4476        // Use last metric for GIDs beyond num_h_metrics
4477        if num_h_metrics == 0 {
4478            return None;
4479        }
4480        let offset = hmtx_off + (num_h_metrics - 1) * 4;
4481        if offset + 2 > font_data.len() {
4482            return None;
4483        }
4484        read_u16(font_data, offset)
4485    };
4486    // Convert from font units to 1/1000 em (PDF text space)
4487    Some(advance as f64 / units_per_em * 1000.0)
4488}
4489
4490fn skrifa_glyph_path(font_data: &[u8], gid: u16, units_per_em: f64) -> Option<PsPath> {
4491    // Use from_index(0) to handle both plain TrueType and TTC files.
4492    let font_ref = skrifa::FontRef::from_index(font_data, 0).ok()?;
4493    let outlines = font_ref.outline_glyphs();
4494    let glyph = outlines.get(skrifa::GlyphId::new(gid as u32))?;
4495
4496    // Use TrueType bytecode interpreter with mono hinting for correct composite
4497    // glyph assembly. Some fonts have TT instructions that adjust component positions;
4498    // the auto-hinter doesn't handle these correctly.
4499    let hinting = skrifa::outline::HintingInstance::new(
4500        &outlines,
4501        skrifa::prelude::Size::new(units_per_em as f32),
4502        skrifa::instance::LocationRef::default(),
4503        skrifa::outline::HintingOptions {
4504            engine: skrifa::outline::Engine::Interpreter,
4505            target: skrifa::outline::Target::Mono,
4506        },
4507    )
4508    .ok();
4509
4510    let mut pen = PsPathPen {
4511        path: PsPath::new(),
4512        cur_x: 0.0,
4513        cur_y: 0.0,
4514    };
4515
4516    let result = if let Some(ref instance) = hinting {
4517        glyph.draw(instance, &mut pen)
4518    } else {
4519        glyph.draw(
4520            skrifa::outline::DrawSettings::unhinted(
4521                skrifa::prelude::Size::new(units_per_em as f32),
4522                skrifa::instance::LocationRef::default(),
4523            ),
4524            &mut pen,
4525        )
4526    };
4527
4528    result.ok()?;
4529    if pen.path.is_empty() {
4530        None
4531    } else {
4532        Some(pen.path)
4533    }
4534}