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