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