Skip to main content

zpdf_document/
font_loader.rs

1use zpdf_core::{ObjectId, PdfObject, Result};
2use zpdf_font::{CidWidths, FontCache, LoadedFont, PdfFontType};
3use zpdf_parser::PdfFile;
4
5use crate::page::PdfPage;
6
7/// Load all fonts referenced by a page into a FontCache.
8pub fn load_page_fonts(file: &PdfFile, page: &PdfPage) -> FontCache {
9    let mut cache = FontCache::new();
10    let max_bytes = file.limits().max_font_cache_bytes;
11
12    for (name, &font_ref) in &page.resources.fonts {
13        match load_single_font(file, font_ref) {
14            Ok(font) => {
15                if cache
16                    .try_insert_with_limit(name.clone(), font, max_bytes)
17                    .is_none()
18                {
19                    tracing::warn!(
20                        "font cache byte limit ({max_bytes}) or ID capacity reached; using placeholder for {name}"
21                    );
22                    let _ = cache.try_insert_with_limit(
23                        name.clone(),
24                        LoadedFont::new_placeholder(name.clone()),
25                        max_bytes,
26                    );
27                }
28            }
29            Err(e) => {
30                tracing::debug!("font {name} ({font_ref}): fallback - {e}");
31                let _ = cache.try_insert_with_limit(
32                    name.clone(),
33                    LoadedFont::new_placeholder(name.clone()),
34                    max_bytes,
35                );
36            }
37        }
38    }
39
40    cache
41}
42
43pub fn load_single_font(file: &PdfFile, font_ref: ObjectId) -> Result<LoadedFont> {
44    let obj = file.resolve(font_ref)?;
45    let dict = obj.as_dict()?;
46    load_single_font_dict(file, dict)
47}
48
49/// Load a font from its (already-resolved) font dictionary. Used both by
50/// [`load_single_font`] and for inline font dicts in form resources (e.g. a
51/// synthesized field appearance referencing a standard Helvetica).
52pub fn load_single_font_dict(file: &PdfFile, dict: &zpdf_core::PdfDict) -> Result<LoadedFont> {
53    let subtype = dict.get_name("Subtype").unwrap_or("");
54    let base_font = dict.get_name("BaseFont").unwrap_or("Unknown").to_string();
55
56    let mut font = match subtype {
57        "Type0" => load_type0_font(file, dict, base_font)?,
58        "TrueType" => load_truetype_font(file, dict, base_font)?,
59        "Type3" => load_type3_font(file, dict, base_font)?,
60        "Type1" | "MMType1" => load_type1_font(file, dict, base_font)?,
61        _ => LoadedFont::new_placeholder(base_font),
62    };
63
64    attach_text_mappings(file, dict, subtype, &mut font);
65    // A substituted composite font needs /ToUnicode (attached just above) to
66    // route CIDs through the system face's Unicode cmap.
67    font.build_substitute_cid_to_gid();
68    // FontDescriptor weight/width/slant → variation axes (variable fonts only).
69    apply_descriptor_variations(file, dict, &mut font);
70    Ok(font)
71}
72
73/// FontDescriptor-derived hints for system-font substitution.
74fn substitute_hints(
75    file: &PdfFile,
76    dict: &zpdf_core::PdfDict,
77) -> zpdf_font::system::SubstituteHints {
78    let mut hints = zpdf_font::system::SubstituteHints::default();
79    // /FontDescriptor must be indirect per spec, but AutoCAD and other producers
80    // inline it; resolve_dict accepts both forms.
81    if let Some(fd) = resolve_dict(file, dict, "FontDescriptor") {
82        if let Ok(flags) = fd.get_i64("Flags") {
83            hints.fixed_pitch = flags & 1 != 0;
84            hints.serif = flags & 2 != 0;
85            hints.italic = flags & 64 != 0;
86            hints.bold = flags & (1 << 18) != 0; // ForceBold
87        }
88        if let Ok(w) = fd.get_f64("StemV") {
89            hints.bold |= w >= 160.0;
90        }
91    }
92    hints
93}
94
95/// Try to substitute an installed system font for a non-embedded simple font.
96/// The PDF /Widths stay authoritative for advances when present; otherwise the
97/// standard-14 metrics (if the name matches one) seed the widths.
98fn try_system_substitute_simple(
99    file: &PdfFile,
100    dict: &zpdf_core::PdfDict,
101    base_font: &str,
102    font_type: PdfFontType,
103    mut cid_widths: CidWidths,
104) -> Option<LoadedFont> {
105    let hints = substitute_hints(file, dict);
106    let m = zpdf_font::system::find_system_font(base_font, hints, None)?;
107    if cid_widths.is_empty() {
108        if let Some(metrics) = zpdf_font::standard_fonts::lookup(base_font) {
109            for (code, &w) in metrics.widths.iter().enumerate() {
110                if w > 0 {
111                    cid_widths.set(code as u16, w as f64);
112                }
113            }
114        }
115    }
116    LoadedFont::new_substitute(
117        font_type,
118        base_font.to_string(),
119        m.data,
120        m.face_index,
121        cid_widths,
122    )
123}
124
125/// Attach the simple-font /Encoding, the symbolic flag, and /ToUnicode (for
126/// text extraction) to a freshly-loaded font.
127fn attach_text_mappings(
128    file: &PdfFile,
129    dict: &zpdf_core::PdfDict,
130    subtype: &str,
131    font: &mut LoadedFont,
132) {
133    // /ToUnicode lives at the top-level font dict for both simple and Type0 fonts.
134    if let Ok(tu_ref) = dict.get_ref("ToUnicode") {
135        if let Ok(data) = file.resolve_stream_data(tu_ref) {
136            let map = zpdf_font::cmap::ToUnicodeMap::parse(&data);
137            if !map.is_empty() {
138                font.to_unicode = Some(map);
139            }
140        }
141    }
142
143    // /Encoding and the symbolic flag apply only to simple (non-composite) fonts.
144    if subtype == "Type0" {
145        return;
146    }
147
148    font.symbolic = font_descriptor_symbolic(file, dict);
149
150    let encoding = if dict.get("Encoding").is_none() {
151        // No explicit /Encoding: the Symbol/ZapfDingbats standard fonts carry their
152        // own built-in encoding; other symbolic fonts use the font program's cmap.
153        builtin_symbol_encoding(&font.base_font)
154            .or_else(|| parse_encoding(file, dict, subtype, font.symbolic))
155    } else {
156        parse_encoding(file, dict, subtype, font.symbolic)
157    };
158    if let Some(enc) = encoding {
159        font.encoding = Some(enc);
160    }
161
162    // With encoding and widths in place, recover Quartz-subset glyphs that are
163    // reachable through no declared encoding (charset entries named ".notdef").
164    font.map_unencoded_orphans();
165}
166
167/// The built-in encoding for the Symbol / ZapfDingbats standard fonts, matched by
168/// BaseFont (ignoring any subset prefix). Used when no explicit /Encoding is given,
169/// so symbolic Symbol/Dingbats text is still extractable via the glyph list.
170fn builtin_symbol_encoding(base_font: &str) -> Option<zpdf_font::encoding::Encoding> {
171    use zpdf_font::encoding::{base_encoding_by_name, Encoding};
172    let name = base_font.rsplit('+').next().unwrap_or(base_font);
173    let canonical = if name.contains("ZapfDingbats") || name.contains("Dingbats") {
174        "ZapfDingbats"
175    } else if name.contains("Symbol") {
176        "Symbol"
177    } else {
178        return None;
179    };
180    base_encoding_by_name(canonical).map(Encoding::from_base)
181}
182
183/// Read the FontDescriptor /Flags and decide whether the font is symbolic
184/// (bit 3 set, bit 6 clear).
185fn font_descriptor_symbolic(file: &PdfFile, dict: &zpdf_core::PdfDict) -> bool {
186    let flags = resolve_dict(file, dict, "FontDescriptor").and_then(|d| d.get_i64("Flags").ok());
187    matches!(flags, Some(f) if (f & 4) != 0 && (f & 32) == 0)
188}
189
190/// Resolve the FontDescriptor dict carrying the embedded program's metadata,
191/// handling the Type0 indirection (the descriptor lives on the descendant
192/// CIDFont, not the top-level Type0 dict).
193fn font_descriptor_dict(file: &PdfFile, dict: &zpdf_core::PdfDict) -> Option<zpdf_core::PdfDict> {
194    let host = if dict.get_name("Subtype").unwrap_or("") == "Type0" {
195        let descendants = resolve_array(file, dict, "DescendantFonts")?;
196        descendant_cid_dict(file, &descendants)?
197    } else {
198        dict.clone()
199    };
200    resolve_dict(file, &host, "FontDescriptor")
201}
202
203/// The first /DescendantFonts entry as a dict. The spec requires an indirect
204/// reference, but AutoCAD and other producers inline the CIDFont dict directly
205/// in the array; accept both forms.
206fn descendant_cid_dict(file: &PdfFile, descendants: &[PdfObject]) -> Option<zpdf_core::PdfDict> {
207    match descendants.first()? {
208        PdfObject::Dict(d) => Some(d.clone()),
209        PdfObject::Ref(r) => file.resolve(*r).ok()?.as_dict().ok().cloned(),
210        _ => None,
211    }
212}
213
214/// Map a `/FontStretch` name to its OpenType `wdth`-axis percentage (Table 122).
215fn font_stretch_pct(name: &str) -> Option<f64> {
216    Some(match name {
217        "UltraCondensed" => 50.0,
218        "ExtraCondensed" => 62.5,
219        "Condensed" => 75.0,
220        "SemiCondensed" => 87.5,
221        "Normal" => 100.0,
222        "SemiExpanded" => 112.5,
223        "Expanded" => 125.0,
224        "ExtraExpanded" => 150.0,
225        "UltraExpanded" => 200.0,
226        _ => return None,
227    })
228}
229
230/// Drive a variable font's OpenType axes from the FontDescriptor's selectors
231/// (`/FontWeight`→`wght`, `/FontStretch`→`wdth`, `/ItalicAngle`→`slnt`, Italic
232/// flag→`ital`). A no-op for static fonts (the axes simply do not exist), so it
233/// is applied to every font; the common selector-less font is left untouched.
234fn apply_descriptor_variations(file: &PdfFile, dict: &zpdf_core::PdfDict, font: &mut LoadedFont) {
235    let Some(fd) = font_descriptor_dict(file, dict) else {
236        return;
237    };
238    let weight = fd.get_f64("FontWeight").ok();
239    let width_pct = fd.get_name("FontStretch").ok().and_then(font_stretch_pct);
240    let italic_angle = fd.get_f64("ItalicAngle").ok();
241    let italic = fd.get_i64("Flags").map(|f| f & 64 != 0).unwrap_or(false);
242    if weight.is_some() || width_pct.is_some() || italic_angle.is_some() || italic {
243        font.set_variations(weight, width_pct, italic_angle, italic);
244    }
245}
246
247/// Build the effective simple-font encoding from /Encoding (a name, a dict with
248/// /BaseEncoding + /Differences, or absent).
249fn parse_encoding(
250    file: &PdfFile,
251    dict: &zpdf_core::PdfDict,
252    subtype: &str,
253    symbolic: bool,
254) -> Option<zpdf_font::encoding::Encoding> {
255    use zpdf_font::encoding::{base_encoding_by_name, Encoding};
256
257    let enc_obj = match dict.get("Encoding").cloned() {
258        Some(PdfObject::Ref(r)) => file.resolve(r).ok(),
259        other => other,
260    };
261
262    match enc_obj {
263        Some(PdfObject::Name(n)) => base_encoding_by_name(n.as_str()).map(Encoding::from_base),
264        Some(PdfObject::Dict(enc_dict)) => {
265            let base = enc_dict
266                .get_name("BaseEncoding")
267                .ok()
268                .and_then(base_encoding_by_name)
269                .unwrap_or_else(|| default_simple_base(subtype));
270            let mut encoding = Encoding::from_base(base);
271            apply_differences(&enc_dict, &mut encoding);
272            Some(encoding)
273        }
274        // No /Encoding: symbolic fonts use their built-in cmap; others get a default.
275        _ if symbolic => None,
276        _ => Some(Encoding::from_base(default_simple_base(subtype))),
277    }
278}
279
280fn default_simple_base(subtype: &str) -> &'static zpdf_font::encoding::EncodingTable {
281    match subtype {
282        "TrueType" => &zpdf_font::encoding::WIN_ANSI_ENCODING,
283        _ => &zpdf_font::encoding::STANDARD_ENCODING,
284    }
285}
286
287fn apply_differences(enc_dict: &zpdf_core::PdfDict, encoding: &mut zpdf_font::encoding::Encoding) {
288    if let Ok(diffs) = enc_dict.get_array("Differences") {
289        let mut code = Some(0u16);
290        for obj in diffs {
291            match obj {
292                PdfObject::Integer(n) => code = u8::try_from(*n).ok().map(u16::from),
293                PdfObject::Name(name) => {
294                    if let Some(current) = code {
295                        encoding.apply_difference(current as u8, name.as_str());
296                        code = current.checked_add(1).filter(|&next| next <= 255);
297                    }
298                }
299                _ => {}
300            }
301        }
302    }
303}
304
305/// Resolve a Type0 font's /Encoding into a code → CID CMap: a predefined
306/// name, or an embedded CMap stream. Unknown legacy CMaps fall back to
307/// Identity-H with a warning.
308fn parse_type0_encoding(file: &PdfFile, dict: &zpdf_core::PdfDict) -> zpdf_font::cmap::CidCMap {
309    use zpdf_font::cmap::CidCMap;
310    // Unknown (legacy byte-encoded) CMaps degrade to Identity, but the
311    // writing mode is still known from the -V suffix and kept.
312    fn identity_fallback(name: &str) -> CidCMap {
313        let wmode = name.ends_with("-V") as u8;
314        tracing::warn!(
315            "unsupported predefined CMap {name}; using Identity-{}",
316            if wmode == 1 { "V" } else { "H" }
317        );
318        CidCMap::identity(wmode)
319    }
320    match dict.get("Encoding") {
321        Some(PdfObject::Name(n)) => {
322            CidCMap::predefined(n.as_str()).unwrap_or_else(|| identity_fallback(n.as_str()))
323        }
324        Some(PdfObject::Ref(r)) => match file.resolve(*r) {
325            Ok(PdfObject::Name(n)) => {
326                CidCMap::predefined(n.as_str()).unwrap_or_else(|| identity_fallback(n.as_str()))
327            }
328            Ok(PdfObject::Stream(s)) => {
329                let data = file.resolve_stream_data(*r).or_else(|_| {
330                    zpdf_parser::filters::decode_stream_with_limits(&s.data, &s.dict, file.limits())
331                });
332                let mut cmap = match data {
333                    Ok(d) => CidCMap::parse(&d),
334                    Err(e) => {
335                        tracing::warn!("undecodable embedded CMap: {e}; using Identity-H");
336                        CidCMap::identity(0)
337                    }
338                };
339                // /WMode may also live on the stream dict.
340                if let Ok(1) = s.dict.get_i64("WMode") {
341                    cmap.wmode = 1;
342                }
343                cmap
344            }
345            _ => CidCMap::identity(0),
346        },
347        _ => CidCMap::identity(0),
348    }
349}
350
351/// /DW2 vertical metrics from a CID font dict: [vy w1y], default [880 −1000].
352fn parse_dw2(file: &PdfFile, desc_dict: &zpdf_core::PdfDict) -> (f64, f64) {
353    resolve_array(file, desc_dict, "DW2")
354        .and_then(|arr| {
355            let v: Vec<f64> = arr.iter().filter_map(|o| o.as_f64().ok()).collect();
356            (v.len() >= 2).then(|| (v[0], v[1]))
357        })
358        .unwrap_or((880.0, -1000.0))
359}
360
361fn load_type0_font(
362    file: &PdfFile,
363    dict: &zpdf_core::PdfDict,
364    base_font: String,
365) -> Result<LoadedFont> {
366    // /DescendantFonts is commonly an indirect reference to the array.
367    let descendants = resolve_array(file, dict, "DescendantFonts")
368        .ok_or_else(|| zpdf_core::Error::MissingKey("DescendantFonts".into()))?;
369    // /DescendantFonts is commonly an indirect reference to the array; the
370    // CIDFont entry itself may also be inlined (AutoCAD) instead of a ref.
371    let desc_dict = descendant_cid_dict(file, &descendants)
372        .ok_or_else(|| zpdf_core::Error::MissingKey("DescendantFonts[0]".into()))?;
373    let desc_dict = &desc_dict;
374
375    let mut cid_widths = parse_cid_widths(file, desc_dict);
376    parse_cid_w2(file, desc_dict, &mut cid_widths);
377    let cmap = parse_type0_encoding(file, dict);
378    let dw2 = parse_dw2(file, desc_dict);
379
380    let font_data = extract_font_file(file, desc_dict);
381
382    let mut font = match font_data {
383        Some(data) => {
384            let mut font = LoadedFont::new_with_data(
385                PdfFontType::Type0CidType2,
386                base_font.clone(),
387                data,
388                cid_widths.clone(),
389            );
390            // /CIDToGIDMap stream: explicit CID → GID table, authoritative for
391            // CIDFontType2 (TrueType-based) descendants. A raw-CFF CIDFontType0
392            // descendant keeps its charset-derived map built in new_with_data —
393            // there /CIDToGIDMap is not even a legal key.
394            if let Some(map) = parse_cid_to_gid_stream(file, desc_dict) {
395                let subtype = desc_dict.get_name("Subtype").unwrap_or("");
396                if subtype == "CIDFontType2" || font.cid_to_gid.is_none() {
397                    font.cid_to_gid = Some(map);
398                }
399            }
400            // Some embedded CID-keyed CFF subsets are defective and cannot be
401            // outlined (unparseable per-FD Private DICTs strand the local subrs),
402            // so most glyphs render blank. When the font is identifiably CJK and
403            // the embedded program fails to outline most sampled glyphs, fall
404            // back to a system CJK face (glyphs then route CID→Unicode→GID via
405            // /ToUnicode, attached later in load_single_font).
406            let cjk = is_cjk_ordering(desc_ordering(file, desc_dict).as_deref())
407                || zpdf_font::system::cjk_ordering_for(&base_font).is_some();
408            if cjk && font.embedded_outline_failure_rate() > 0.5 {
409                if let Some(sub) = substitute_type0_font(file, desc_dict, &base_font, cid_widths) {
410                    font = sub;
411                }
412            }
413            font
414        }
415        None => {
416            // Non-embedded composite font (typically CJK): substitute a system
417            // face. CIDs are remapped through /ToUnicode once it is attached
418            // (see build_substitute_cid_to_gid in load_single_font).
419            substitute_type0_font(file, desc_dict, &base_font, cid_widths)
420                .unwrap_or_else(|| LoadedFont::new_placeholder(base_font))
421        }
422    };
423    font.cid_cmap = Some(cmap);
424    font.dw2 = dw2;
425    // A Unicode-coded CMap is only usable when the font program can resolve
426    // Unicode; otherwise fall back to Identity (codes pass through as CIDs).
427    font.validate_cid_cmap();
428    Ok(font)
429}
430
431/// The descendant CIDFont's `/CIDSystemInfo /Ordering` (e.g. "GB1", "Identity").
432fn desc_ordering(file: &PdfFile, desc_dict: &zpdf_core::PdfDict) -> Option<String> {
433    resolve_dict(file, desc_dict, "CIDSystemInfo").and_then(|csi| match csi.get("Ordering") {
434        Some(PdfObject::String(s)) => Some(s.to_string_lossy()),
435        Some(PdfObject::Name(n)) => Some(n.as_str().to_string()),
436        _ => None,
437    })
438}
439
440/// A registered CJK character-collection ordering (not Adobe-Identity).
441fn is_cjk_ordering(ordering: Option<&str>) -> bool {
442    matches!(ordering, Some("GB1" | "CNS1" | "Japan1" | "Korea1" | "KR"))
443}
444
445/// Build a system-font substitute for a composite (Type0) font, carrying over
446/// the PDF's authoritative /W advances. Returns `None` when no installed face
447/// matches (caller keeps the embedded font or a placeholder).
448fn substitute_type0_font(
449    file: &PdfFile,
450    desc_dict: &zpdf_core::PdfDict,
451    base_font: &str,
452    cid_widths: CidWidths,
453) -> Option<LoadedFont> {
454    let ordering = desc_ordering(file, desc_dict);
455    let hints = substitute_hints(file, desc_dict);
456    zpdf_font::system::find_system_font(base_font, hints, ordering.as_deref()).and_then(|m| {
457        LoadedFont::new_substitute(
458            PdfFontType::Type0CidType2,
459            base_font.to_string(),
460            m.data,
461            m.face_index,
462            cid_widths,
463        )
464    })
465}
466
467/// Decode a /CIDToGIDMap stream into a CID → GID table: two bytes per CID,
468/// big-endian, indexed by CID. Returns `None` for /Identity, absence, or any
469/// non-stream form, which keeps the identity (or charset-derived) behavior.
470/// CIDs mapped to GID 0 (.notdef) are omitted — `glyph_outline` treats a
471/// missing entry as "no glyph", which matches the spec semantics.
472fn parse_cid_to_gid_stream(
473    file: &PdfFile,
474    desc_dict: &zpdf_core::PdfDict,
475) -> Option<std::collections::HashMap<u16, u16>> {
476    let stream_ref = match desc_dict.get("CIDToGIDMap") {
477        Some(PdfObject::Ref(r)) => *r,
478        // /Identity (the common name form), absent, or malformed.
479        _ => return None,
480    };
481    let data = match file.resolve_stream_data(stream_ref) {
482        Ok(d) => d,
483        Err(e) => {
484            // e.g. an indirect /Identity name, or an undecodable stream.
485            tracing::debug!("CIDToGIDMap {stream_ref}: not a decodable stream - {e}");
486            return None;
487        }
488    };
489    let mut map = std::collections::HashMap::new();
490    for (cid, gid_bytes) in data.chunks_exact(2).enumerate().take(u16::MAX as usize + 1) {
491        let gid = u16::from_be_bytes([gid_bytes[0], gid_bytes[1]]);
492        if gid != 0 {
493            map.insert(cid as u16, gid);
494        }
495    }
496    if map.is_empty() {
497        None
498    } else {
499        Some(map)
500    }
501}
502
503fn load_truetype_font(
504    file: &PdfFile,
505    dict: &zpdf_core::PdfDict,
506    base_font: String,
507) -> Result<LoadedFont> {
508    let cid_widths = parse_simple_widths(file, dict);
509    let font_data = extract_font_file_from_descriptor(file, dict);
510
511    match font_data {
512        Some(data) => Ok(LoadedFont::new_with_data(
513            PdfFontType::TrueType,
514            base_font,
515            data,
516            cid_widths,
517        )),
518        None => Ok(try_system_substitute_simple(
519            file,
520            dict,
521            &base_font,
522            PdfFontType::TrueType,
523            cid_widths,
524        )
525        .or_else(|| LoadedFont::new_standard(base_font.clone()))
526        .unwrap_or_else(|| LoadedFont::new_placeholder(base_font))),
527    }
528}
529
530fn type3_differences(diffs: &[PdfObject]) -> Vec<String> {
531    let mut encoding = Vec::new();
532    let mut current = Some(0usize);
533    for obj in diffs {
534        match obj {
535            PdfObject::Integer(n) => {
536                current = usize::try_from(*n).ok().filter(|&code| code <= 255);
537            }
538            PdfObject::Name(name) => {
539                let Some(code) = current else { continue };
540                encoding.resize(code + 1, String::new());
541                encoding[code] = name.0.clone();
542                current = code.checked_add(1).filter(|&next| next <= 255);
543            }
544            _ => {}
545        }
546    }
547    encoding
548}
549
550fn load_type3_font(
551    file: &PdfFile,
552    dict: &zpdf_core::PdfDict,
553    base_font: String,
554) -> Result<LoadedFont> {
555    use std::sync::Arc;
556
557    // All four Type3 keys are commonly emitted as indirect objects; a direct-only
558    // read would silently drop every glyph, so resolve one level of indirection.
559
560    // FontMatrix: typically [0.001 0 0 -0.001 0 0] for 1000-unit glyph space
561    let font_matrix = {
562        let mut m = [0.001, 0.0, 0.0, -0.001, 0.0, 0.0];
563        if let Some(arr) = resolve_array(file, dict, "FontMatrix") {
564            for (i, obj) in arr.iter().enumerate().take(6) {
565                if let Ok(v) = obj.as_f64() {
566                    m[i] = v;
567                }
568            }
569        }
570        m
571    };
572
573    // Encoding/Differences → glyph name list
574    let mut encoding = Vec::new();
575    if let Some(enc_dict) = resolve_dict(file, dict, "Encoding") {
576        if let Some(diffs) = resolve_array(file, &enc_dict, "Differences") {
577            encoding = type3_differences(&diffs);
578        }
579    }
580
581    // CharProcs: name → stream ref
582    let mut char_procs = std::collections::HashMap::new();
583    let encoded_names: std::collections::HashSet<&str> = encoding
584        .iter()
585        .map(String::as_str)
586        .filter(|name| !name.is_empty())
587        .collect();
588    if let Some(cp_dict) = resolve_dict(file, dict, "CharProcs") {
589        for (name, obj) in &cp_dict.0 {
590            if char_procs.len() >= encoded_names.len() {
591                break;
592            }
593            if !encoded_names.contains(name.as_str()) {
594                continue;
595            }
596            if let PdfObject::Ref(r) = obj {
597                if let Ok(data) = file.resolve_stream_data(*r) {
598                    char_procs.insert(name.0.clone(), Arc::from(data));
599                }
600            }
601        }
602    }
603
604    // Widths
605    let first_char = dict
606        .get_i64("FirstChar")
607        .ok()
608        .and_then(|n| u8::try_from(n).ok())
609        .map(u16::from)
610        .unwrap_or(0);
611    let widths: Vec<f64> = resolve_array(file, dict, "Widths")
612        .unwrap_or_default()
613        .iter()
614        .take(256)
615        .map(|o| o.as_f64().unwrap_or(0.0))
616        .collect();
617
618    let font = LoadedFont {
619        font_type: zpdf_font::PdfFontType::Type3 {
620            font_matrix,
621            char_procs,
622            encoding,
623            widths,
624            first_char,
625        },
626        base_font,
627        font_data: None,
628        face_index: 0,
629        is_substitute: false,
630        cid_widths: CidWidths::new(1000.0),
631        units_per_em: 1000.0,
632        ascent: 880.0,
633        descent: -120.0,
634        cid_to_gid: None,
635        builtin_encoding_gids: None,
636        orphan_gids: Vec::new(),
637        encoding: None,
638        to_unicode: None,
639        symbolic: false,
640        type1: None,
641        cid_cmap: None,
642        dw2: (880.0, -1000.0),
643        variations: Vec::new(),
644    };
645
646    Ok(font)
647}
648
649fn load_type1_font(
650    file: &PdfFile,
651    dict: &zpdf_core::PdfDict,
652    base_font: String,
653) -> Result<LoadedFont> {
654    let cid_widths = parse_simple_widths(file, dict);
655    let font_data = extract_font_file_from_descriptor(file, dict);
656
657    match font_data {
658        Some(data) => Ok(LoadedFont::new_with_data(
659            PdfFontType::Type1,
660            base_font,
661            data,
662            cid_widths,
663        )),
664        None => Ok(try_system_substitute_simple(
665            file,
666            dict,
667            &base_font,
668            PdfFontType::Type1,
669            cid_widths,
670        )
671        .or_else(|| LoadedFont::new_standard(base_font.clone()))
672        .unwrap_or_else(|| LoadedFont::new_placeholder(base_font))),
673    }
674}
675
676/// Extract embedded font binary from FontDescriptor → FontFile2 (TrueType).
677fn extract_font_file(file: &PdfFile, cid_dict: &zpdf_core::PdfDict) -> Option<Vec<u8>> {
678    let fd_dict = resolve_dict(file, cid_dict, "FontDescriptor")?;
679
680    // Try FontFile2 (TrueType), then FontFile3 (OpenType/CFF), then FontFile (Type1)
681    for key in &["FontFile2", "FontFile3", "FontFile"] {
682        if let Ok(ff_ref) = fd_dict.get_ref(key) {
683            if let Ok(data) = file.resolve_stream_data(ff_ref) {
684                if !data.is_empty() {
685                    return Some(data);
686                }
687            }
688        }
689    }
690    None
691}
692
693fn extract_font_file_from_descriptor(
694    file: &PdfFile,
695    font_dict: &zpdf_core::PdfDict,
696) -> Option<Vec<u8>> {
697    let fd_dict = resolve_dict(file, font_dict, "FontDescriptor")?;
698
699    for key in &["FontFile2", "FontFile3", "FontFile"] {
700        if let Ok(ff_ref) = fd_dict.get_ref(key) {
701            if let Ok(data) = file.resolve_stream_data(ff_ref) {
702                if !data.is_empty() {
703                    return Some(data);
704                }
705            }
706        }
707    }
708    None
709}
710
711/// Fetch an array value, resolving one level of indirect reference. pdftex (and
712/// many other producers) commonly emit `/Widths` and `/W` as indirect objects,
713/// which a plain `get_array` would miss (leaving every glyph at the default width).
714fn resolve_array(file: &PdfFile, dict: &zpdf_core::PdfDict, key: &str) -> Option<Vec<PdfObject>> {
715    match dict.get(key) {
716        Some(PdfObject::Array(a)) => Some(a.clone()),
717        Some(PdfObject::Ref(id)) => file
718            .resolve(*id)
719            .ok()
720            .and_then(|o| o.as_array().ok().map(|a| a.to_vec())),
721        _ => None,
722    }
723}
724
725/// Fetch a dictionary value, resolving one level of indirect reference, in the
726/// same spirit as [`resolve_array`] (Type3 producers commonly emit /CharProcs
727/// and /Encoding as indirect objects).
728fn resolve_dict(
729    file: &PdfFile,
730    dict: &zpdf_core::PdfDict,
731    key: &str,
732) -> Option<zpdf_core::PdfDict> {
733    match dict.get(key) {
734        Some(PdfObject::Dict(d)) => Some(d.clone()),
735        Some(PdfObject::Ref(id)) => file
736            .resolve(*id)
737            .ok()
738            .and_then(|o| o.as_dict().ok().cloned()),
739        _ => None,
740    }
741}
742
743/// Parse CID /W array: format is [cid [w1 w2 ...]] or [cid_first cid_last w]
744fn parse_cid_widths(file: &PdfFile, dict: &zpdf_core::PdfDict) -> CidWidths {
745    let dw = dict.get_f64("DW").unwrap_or(1000.0);
746    let mut widths = CidWidths::new(dw);
747
748    let w_array = match resolve_array(file, dict, "W") {
749        Some(arr) => arr,
750        None => return widths,
751    };
752
753    let mut i = 0;
754    while i < w_array.len() {
755        let cid_start = match w_array[i].as_i64().ok().and_then(|v| u16::try_from(v).ok()) {
756            Some(v) => v,
757            None => break,
758        };
759        i += 1;
760        if i >= w_array.len() {
761            break;
762        }
763
764        // In the `[cid [w1 w2 ...]]` form the width sub-array is frequently an
765        // *indirect* reference (AutoCAD/nanoCAD export /W this way). Resolve one
766        // level so the `PdfObject::Array` arm below sees it; otherwise the entry
767        // fell through to `_ => i += 1`, the sub-array was skipped, and every CID
768        // in the range silently defaulted to /DW — breaking advances (and thus
769        // inter-glyph spacing), so the text drifts.
770        let resolved = if let PdfObject::Ref(id) = &w_array[i] {
771            file.resolve(*id).ok()
772        } else {
773            None
774        };
775        let entry = resolved.as_ref().unwrap_or(&w_array[i]);
776
777        match entry {
778            PdfObject::Array(arr) => {
779                // [cid_start [w1 w2 w3 ...]]
780                for (j, obj) in arr.iter().enumerate() {
781                    let Some(cid) = u16::try_from(j)
782                        .ok()
783                        .and_then(|delta| cid_start.checked_add(delta))
784                    else {
785                        break;
786                    };
787                    if let Ok(w) = obj.as_f64() {
788                        widths.set(cid, w);
789                    }
790                }
791                i += 1;
792            }
793            PdfObject::Integer(_) | PdfObject::Real(_) => {
794                // [cid_start cid_end width]
795                let cid_end = entry
796                    .as_i64()
797                    .ok()
798                    .and_then(|v| u16::try_from(v).ok())
799                    .unwrap_or(cid_start);
800                i += 1;
801                if i < w_array.len() {
802                    let w = w_array[i].as_f64().unwrap_or(dw);
803                    for cid in cid_start..=cid_end {
804                        widths.set(cid, w);
805                    }
806                    i += 1;
807                }
808            }
809            _ => {
810                i += 1;
811            }
812        }
813    }
814
815    widths
816}
817
818/// Parse the CID /W2 array (PDF 9.7.4.3) into per-CID vertical metrics.
819/// Two element forms, mirroring /W but with THREE numbers per glyph:
820///   `c [ w1y_1 vx_1 vy_1  w1y_2 vx_2 vy_2 ... ]`   (list form)
821///   `cFirst cLast w1y vx vy`                         (range form)
822/// where `w1y` is the vertical displacement and `(vx, vy)` the position vector.
823fn parse_cid_w2(file: &PdfFile, dict: &zpdf_core::PdfDict, widths: &mut CidWidths) {
824    if let Some(arr) = resolve_array(file, dict, "W2") {
825        apply_w2_array(&arr, widths);
826    }
827}
828
829fn apply_w2_array(w2_array: &[PdfObject], widths: &mut CidWidths) {
830    let mut i = 0;
831    while i < w2_array.len() {
832        let cid_start = match w2_array[i]
833            .as_i64()
834            .ok()
835            .and_then(|v| u16::try_from(v).ok())
836        {
837            Some(v) => v,
838            None => break,
839        };
840        i += 1;
841        if i >= w2_array.len() {
842            break;
843        }
844
845        match &w2_array[i] {
846            PdfObject::Array(arr) => {
847                // List form: triples (w1y, vx, vy) starting at cid_start.
848                let mut k = 0;
849                while k + 2 < arr.len() {
850                    let (Ok(w1y), Ok(vx), Ok(vy)) =
851                        (arr[k].as_f64(), arr[k + 1].as_f64(), arr[k + 2].as_f64())
852                    else {
853                        break;
854                    };
855                    let Some(cid) = u16::try_from(k / 3)
856                        .ok()
857                        .and_then(|delta| cid_start.checked_add(delta))
858                    else {
859                        break;
860                    };
861                    widths.set_v(cid, w1y, vx, vy);
862                    k += 3;
863                }
864                i += 1;
865            }
866            PdfObject::Integer(_) | PdfObject::Real(_) => {
867                // Range form: cFirst cLast w1y vx vy.
868                let cid_end = w2_array[i]
869                    .as_i64()
870                    .ok()
871                    .and_then(|v| u16::try_from(v).ok())
872                    .unwrap_or(cid_start);
873                if i + 3 < w2_array.len() {
874                    let (Ok(w1y), Ok(vx), Ok(vy)) = (
875                        w2_array[i + 1].as_f64(),
876                        w2_array[i + 2].as_f64(),
877                        w2_array[i + 3].as_f64(),
878                    ) else {
879                        break;
880                    };
881                    for cid in cid_start..=cid_end {
882                        widths.set_v(cid, w1y, vx, vy);
883                    }
884                    i += 4;
885                } else {
886                    break;
887                }
888            }
889            _ => {
890                i += 1;
891            }
892        }
893    }
894}
895
896fn parse_simple_widths(file: &PdfFile, dict: &zpdf_core::PdfDict) -> CidWidths {
897    let first_char = dict
898        .get_i64("FirstChar")
899        .ok()
900        .and_then(|v| u8::try_from(v).ok())
901        .map(u16::from)
902        .unwrap_or(0);
903    let mut widths = CidWidths::new(1000.0);
904
905    if let Some(arr) = resolve_array(file, dict, "Widths") {
906        for (j, obj) in arr.iter().take(256).enumerate() {
907            let Some(code) = u16::try_from(j)
908                .ok()
909                .and_then(|delta| first_char.checked_add(delta))
910                .filter(|&code| code <= 255)
911            else {
912                break;
913            };
914            if let Ok(w) = obj.as_f64() {
915                widths.set(code, w);
916            }
917        }
918    }
919
920    widths
921}
922
923#[cfg(test)]
924mod tests {
925    use super::*;
926
927    fn int(v: i64) -> PdfObject {
928        PdfObject::Integer(v)
929    }
930    fn real(v: f64) -> PdfObject {
931        PdfObject::Real(v)
932    }
933
934    #[test]
935    fn w2_list_form_assigns_consecutive_cids() {
936        // 120 [w1y vx vy  w1y vx vy] → CIDs 120 and 121.
937        let arr = vec![
938            int(120),
939            PdfObject::Array(vec![
940                real(-1000.0),
941                real(500.0),
942                real(880.0),
943                int(-900),
944                int(450),
945                int(820),
946            ]),
947        ];
948        let mut w = CidWidths::new(1000.0);
949        apply_w2_array(&arr, &mut w);
950        assert_eq!(w.get_v(120), Some((-1000.0, 500.0, 880.0)));
951        assert_eq!(w.get_v(121), Some((-900.0, 450.0, 820.0)));
952        assert_eq!(w.get_v(122), None);
953    }
954
955    #[test]
956    fn w2_range_form_assigns_inclusive_range() {
957        // cFirst cLast w1y vx vy
958        let arr = vec![int(10), int(12), int(-1000), int(500), int(880)];
959        let mut w = CidWidths::new(1000.0);
960        apply_w2_array(&arr, &mut w);
961        for cid in 10..=12 {
962            assert_eq!(w.get_v(cid), Some((-1000.0, 500.0, 880.0)));
963        }
964        assert_eq!(w.get_v(9), None);
965        assert_eq!(w.get_v(13), None);
966    }
967
968    #[test]
969    fn w2_truncated_entry_is_ignored_not_panic() {
970        // Range header without the trailing metric numbers must not panic.
971        let arr = vec![int(10), int(12), int(-1000)];
972        let mut w = CidWidths::new(1000.0);
973        apply_w2_array(&arr, &mut w);
974        assert_eq!(w.get_v(10), None);
975    }
976
977    #[test]
978    fn type3_differences_reject_out_of_range_codes_without_growth() {
979        let name = |s: &str| PdfObject::Name(zpdf_core::PdfName::new(s));
980        let diffs = vec![
981            int(-1),
982            name("ignored-negative"),
983            int(i64::MAX),
984            name("ignored-huge"),
985            int(255),
986            name("last"),
987            name("past-end"),
988        ];
989        let encoding = type3_differences(&diffs);
990        assert_eq!(encoding.len(), 256);
991        assert_eq!(encoding[255], "last");
992    }
993
994    #[test]
995    fn simple_differences_do_not_overflow_after_huge_code() {
996        let mut dict = zpdf_core::PdfDict::new();
997        dict.insert(
998            zpdf_core::PdfName::new("Differences"),
999            PdfObject::Array(vec![
1000                int(i64::MAX),
1001                PdfObject::Name(zpdf_core::PdfName::new("ignored")),
1002            ]),
1003        );
1004        let mut encoding =
1005            zpdf_font::encoding::Encoding::from_base(&zpdf_font::encoding::STANDARD_ENCODING);
1006        apply_differences(&dict, &mut encoding);
1007    }
1008}