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    if let Ok(fd_ref) = dict.get_ref("FontDescriptor") {
80        if let Ok(fd) = file.resolve(fd_ref) {
81            if let Ok(fd) = fd.as_dict() {
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        }
93    }
94    hints
95}
96
97/// Try to substitute an installed system font for a non-embedded simple font.
98/// The PDF /Widths stay authoritative for advances when present; otherwise the
99/// standard-14 metrics (if the name matches one) seed the widths.
100fn try_system_substitute_simple(
101    file: &PdfFile,
102    dict: &zpdf_core::PdfDict,
103    base_font: &str,
104    font_type: PdfFontType,
105    mut cid_widths: CidWidths,
106) -> Option<LoadedFont> {
107    let hints = substitute_hints(file, dict);
108    let m = zpdf_font::system::find_system_font(base_font, hints, None)?;
109    if cid_widths.is_empty() {
110        if let Some(metrics) = zpdf_font::standard_fonts::lookup(base_font) {
111            for (code, &w) in metrics.widths.iter().enumerate() {
112                if w > 0 {
113                    cid_widths.set(code as u16, w as f64);
114                }
115            }
116        }
117    }
118    LoadedFont::new_substitute(
119        font_type,
120        base_font.to_string(),
121        m.data,
122        m.face_index,
123        cid_widths,
124    )
125}
126
127/// Attach the simple-font /Encoding, the symbolic flag, and /ToUnicode (for
128/// text extraction) to a freshly-loaded font.
129fn attach_text_mappings(
130    file: &PdfFile,
131    dict: &zpdf_core::PdfDict,
132    subtype: &str,
133    font: &mut LoadedFont,
134) {
135    // /ToUnicode lives at the top-level font dict for both simple and Type0 fonts.
136    if let Ok(tu_ref) = dict.get_ref("ToUnicode") {
137        if let Ok(data) = file.resolve_stream_data(tu_ref) {
138            let map = zpdf_font::cmap::ToUnicodeMap::parse(&data);
139            if !map.is_empty() {
140                font.to_unicode = Some(map);
141            }
142        }
143    }
144
145    // /Encoding and the symbolic flag apply only to simple (non-composite) fonts.
146    if subtype == "Type0" {
147        return;
148    }
149
150    font.symbolic = font_descriptor_symbolic(file, dict);
151
152    let encoding = if dict.get("Encoding").is_none() {
153        // No explicit /Encoding: the Symbol/ZapfDingbats standard fonts carry their
154        // own built-in encoding; other symbolic fonts use the font program's cmap.
155        builtin_symbol_encoding(&font.base_font)
156            .or_else(|| parse_encoding(file, dict, subtype, font.symbolic))
157    } else {
158        parse_encoding(file, dict, subtype, font.symbolic)
159    };
160    if let Some(enc) = encoding {
161        font.encoding = Some(enc);
162    }
163
164    // With encoding and widths in place, recover Quartz-subset glyphs that are
165    // reachable through no declared encoding (charset entries named ".notdef").
166    font.map_unencoded_orphans();
167}
168
169/// The built-in encoding for the Symbol / ZapfDingbats standard fonts, matched by
170/// BaseFont (ignoring any subset prefix). Used when no explicit /Encoding is given,
171/// so symbolic Symbol/Dingbats text is still extractable via the glyph list.
172fn builtin_symbol_encoding(base_font: &str) -> Option<zpdf_font::encoding::Encoding> {
173    use zpdf_font::encoding::{base_encoding_by_name, Encoding};
174    let name = base_font.rsplit('+').next().unwrap_or(base_font);
175    let canonical = if name.contains("ZapfDingbats") || name.contains("Dingbats") {
176        "ZapfDingbats"
177    } else if name.contains("Symbol") {
178        "Symbol"
179    } else {
180        return None;
181    };
182    base_encoding_by_name(canonical).map(Encoding::from_base)
183}
184
185/// Read the FontDescriptor /Flags and decide whether the font is symbolic
186/// (bit 3 set, bit 6 clear).
187fn font_descriptor_symbolic(file: &PdfFile, dict: &zpdf_core::PdfDict) -> bool {
188    let fd_ref = match dict.get_ref("FontDescriptor") {
189        Ok(r) => r,
190        Err(_) => return false,
191    };
192    let flags = file
193        .resolve(fd_ref)
194        .ok()
195        .and_then(|o| o.as_dict().ok().and_then(|d| d.get_i64("Flags").ok()));
196    matches!(flags, Some(f) if (f & 4) != 0 && (f & 32) == 0)
197}
198
199/// Resolve the FontDescriptor dict carrying the embedded program's metadata,
200/// handling the Type0 indirection (the descriptor lives on the descendant
201/// CIDFont, not the top-level Type0 dict).
202fn font_descriptor_dict(file: &PdfFile, dict: &zpdf_core::PdfDict) -> Option<zpdf_core::PdfDict> {
203    let host = if dict.get_name("Subtype").unwrap_or("") == "Type0" {
204        let descendants = resolve_array(file, dict, "DescendantFonts")?;
205        let desc_ref = descendants.first()?.as_ref().ok()?;
206        file.resolve(desc_ref).ok()?.as_dict().ok()?.clone()
207    } else {
208        dict.clone()
209    };
210    let fd_ref = host.get_ref("FontDescriptor").ok()?;
211    file.resolve(fd_ref).ok()?.as_dict().ok().cloned()
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    let desc_ref = descendants
370        .first()
371        .ok_or_else(|| zpdf_core::Error::MissingKey("DescendantFonts[0]".into()))?
372        .as_ref()?;
373
374    let desc_obj = file.resolve(desc_ref)?;
375    let desc_dict = desc_obj.as_dict()?;
376
377    let mut cid_widths = parse_cid_widths(file, desc_dict);
378    parse_cid_w2(file, desc_dict, &mut cid_widths);
379    let cmap = parse_type0_encoding(file, dict);
380    let dw2 = parse_dw2(file, desc_dict);
381
382    let font_data = extract_font_file(file, desc_dict);
383
384    let mut font = match font_data {
385        Some(data) => {
386            let mut font = LoadedFont::new_with_data(
387                PdfFontType::Type0CidType2,
388                base_font.clone(),
389                data,
390                cid_widths.clone(),
391            );
392            // /CIDToGIDMap stream: explicit CID → GID table, authoritative for
393            // CIDFontType2 (TrueType-based) descendants. A raw-CFF CIDFontType0
394            // descendant keeps its charset-derived map built in new_with_data —
395            // there /CIDToGIDMap is not even a legal key.
396            if let Some(map) = parse_cid_to_gid_stream(file, desc_dict) {
397                let subtype = desc_dict.get_name("Subtype").unwrap_or("");
398                if subtype == "CIDFontType2" || font.cid_to_gid.is_none() {
399                    font.cid_to_gid = Some(map);
400                }
401            }
402            // Some embedded CID-keyed CFF subsets are defective and cannot be
403            // outlined (unparseable per-FD Private DICTs strand the local subrs),
404            // so most glyphs render blank. When the font is identifiably CJK and
405            // the embedded program fails to outline most sampled glyphs, fall
406            // back to a system CJK face (glyphs then route CID→Unicode→GID via
407            // /ToUnicode, attached later in load_single_font).
408            let cjk = is_cjk_ordering(desc_ordering(file, desc_dict).as_deref())
409                || zpdf_font::system::cjk_ordering_for(&base_font).is_some();
410            if cjk && font.embedded_outline_failure_rate() > 0.5 {
411                if let Some(sub) = substitute_type0_font(file, desc_dict, &base_font, cid_widths) {
412                    font = sub;
413                }
414            }
415            font
416        }
417        None => {
418            // Non-embedded composite font (typically CJK): substitute a system
419            // face. CIDs are remapped through /ToUnicode once it is attached
420            // (see build_substitute_cid_to_gid in load_single_font).
421            substitute_type0_font(file, desc_dict, &base_font, cid_widths)
422                .unwrap_or_else(|| LoadedFont::new_placeholder(base_font))
423        }
424    };
425    font.cid_cmap = Some(cmap);
426    font.dw2 = dw2;
427    // A Unicode-coded CMap is only usable when the font program can resolve
428    // Unicode; otherwise fall back to Identity (codes pass through as CIDs).
429    font.validate_cid_cmap();
430    Ok(font)
431}
432
433/// The descendant CIDFont's `/CIDSystemInfo /Ordering` (e.g. "GB1", "Identity").
434fn desc_ordering(file: &PdfFile, desc_dict: &zpdf_core::PdfDict) -> Option<String> {
435    resolve_dict(file, desc_dict, "CIDSystemInfo").and_then(|csi| match csi.get("Ordering") {
436        Some(PdfObject::String(s)) => Some(s.to_string_lossy()),
437        Some(PdfObject::Name(n)) => Some(n.as_str().to_string()),
438        _ => None,
439    })
440}
441
442/// A registered CJK character-collection ordering (not Adobe-Identity).
443fn is_cjk_ordering(ordering: Option<&str>) -> bool {
444    matches!(ordering, Some("GB1" | "CNS1" | "Japan1" | "Korea1" | "KR"))
445}
446
447/// Build a system-font substitute for a composite (Type0) font, carrying over
448/// the PDF's authoritative /W advances. Returns `None` when no installed face
449/// matches (caller keeps the embedded font or a placeholder).
450fn substitute_type0_font(
451    file: &PdfFile,
452    desc_dict: &zpdf_core::PdfDict,
453    base_font: &str,
454    cid_widths: CidWidths,
455) -> Option<LoadedFont> {
456    let ordering = desc_ordering(file, desc_dict);
457    let hints = substitute_hints(file, desc_dict);
458    zpdf_font::system::find_system_font(base_font, hints, ordering.as_deref()).and_then(|m| {
459        LoadedFont::new_substitute(
460            PdfFontType::Type0CidType2,
461            base_font.to_string(),
462            m.data,
463            m.face_index,
464            cid_widths,
465        )
466    })
467}
468
469/// Decode a /CIDToGIDMap stream into a CID → GID table: two bytes per CID,
470/// big-endian, indexed by CID. Returns `None` for /Identity, absence, or any
471/// non-stream form, which keeps the identity (or charset-derived) behavior.
472/// CIDs mapped to GID 0 (.notdef) are omitted — `glyph_outline` treats a
473/// missing entry as "no glyph", which matches the spec semantics.
474fn parse_cid_to_gid_stream(
475    file: &PdfFile,
476    desc_dict: &zpdf_core::PdfDict,
477) -> Option<std::collections::HashMap<u16, u16>> {
478    let stream_ref = match desc_dict.get("CIDToGIDMap") {
479        Some(PdfObject::Ref(r)) => *r,
480        // /Identity (the common name form), absent, or malformed.
481        _ => return None,
482    };
483    let data = match file.resolve_stream_data(stream_ref) {
484        Ok(d) => d,
485        Err(e) => {
486            // e.g. an indirect /Identity name, or an undecodable stream.
487            tracing::debug!("CIDToGIDMap {stream_ref}: not a decodable stream - {e}");
488            return None;
489        }
490    };
491    let mut map = std::collections::HashMap::new();
492    for (cid, gid_bytes) in data.chunks_exact(2).enumerate().take(u16::MAX as usize + 1) {
493        let gid = u16::from_be_bytes([gid_bytes[0], gid_bytes[1]]);
494        if gid != 0 {
495            map.insert(cid as u16, gid);
496        }
497    }
498    if map.is_empty() {
499        None
500    } else {
501        Some(map)
502    }
503}
504
505fn load_truetype_font(
506    file: &PdfFile,
507    dict: &zpdf_core::PdfDict,
508    base_font: String,
509) -> Result<LoadedFont> {
510    let cid_widths = parse_simple_widths(file, dict);
511    let font_data = extract_font_file_from_descriptor(file, dict);
512
513    match font_data {
514        Some(data) => Ok(LoadedFont::new_with_data(
515            PdfFontType::TrueType,
516            base_font,
517            data,
518            cid_widths,
519        )),
520        None => Ok(try_system_substitute_simple(
521            file,
522            dict,
523            &base_font,
524            PdfFontType::TrueType,
525            cid_widths,
526        )
527        .or_else(|| LoadedFont::new_standard(base_font.clone()))
528        .unwrap_or_else(|| LoadedFont::new_placeholder(base_font))),
529    }
530}
531
532fn type3_differences(diffs: &[PdfObject]) -> Vec<String> {
533    let mut encoding = Vec::new();
534    let mut current = Some(0usize);
535    for obj in diffs {
536        match obj {
537            PdfObject::Integer(n) => {
538                current = usize::try_from(*n).ok().filter(|&code| code <= 255);
539            }
540            PdfObject::Name(name) => {
541                let Some(code) = current else { continue };
542                encoding.resize(code + 1, String::new());
543                encoding[code] = name.0.clone();
544                current = code.checked_add(1).filter(|&next| next <= 255);
545            }
546            _ => {}
547        }
548    }
549    encoding
550}
551
552fn load_type3_font(
553    file: &PdfFile,
554    dict: &zpdf_core::PdfDict,
555    base_font: String,
556) -> Result<LoadedFont> {
557    use std::sync::Arc;
558
559    // All four Type3 keys are commonly emitted as indirect objects; a direct-only
560    // read would silently drop every glyph, so resolve one level of indirection.
561
562    // FontMatrix: typically [0.001 0 0 -0.001 0 0] for 1000-unit glyph space
563    let font_matrix = {
564        let mut m = [0.001, 0.0, 0.0, -0.001, 0.0, 0.0];
565        if let Some(arr) = resolve_array(file, dict, "FontMatrix") {
566            for (i, obj) in arr.iter().enumerate().take(6) {
567                if let Ok(v) = obj.as_f64() {
568                    m[i] = v;
569                }
570            }
571        }
572        m
573    };
574
575    // Encoding/Differences → glyph name list
576    let mut encoding = Vec::new();
577    if let Some(enc_dict) = resolve_dict(file, dict, "Encoding") {
578        if let Some(diffs) = resolve_array(file, &enc_dict, "Differences") {
579            encoding = type3_differences(&diffs);
580        }
581    }
582
583    // CharProcs: name → stream ref
584    let mut char_procs = std::collections::HashMap::new();
585    let encoded_names: std::collections::HashSet<&str> = encoding
586        .iter()
587        .map(String::as_str)
588        .filter(|name| !name.is_empty())
589        .collect();
590    if let Some(cp_dict) = resolve_dict(file, dict, "CharProcs") {
591        for (name, obj) in &cp_dict.0 {
592            if char_procs.len() >= encoded_names.len() {
593                break;
594            }
595            if !encoded_names.contains(name.as_str()) {
596                continue;
597            }
598            if let PdfObject::Ref(r) = obj {
599                if let Ok(data) = file.resolve_stream_data(*r) {
600                    char_procs.insert(name.0.clone(), Arc::from(data));
601                }
602            }
603        }
604    }
605
606    // Widths
607    let first_char = dict
608        .get_i64("FirstChar")
609        .ok()
610        .and_then(|n| u8::try_from(n).ok())
611        .map(u16::from)
612        .unwrap_or(0);
613    let widths: Vec<f64> = resolve_array(file, dict, "Widths")
614        .unwrap_or_default()
615        .iter()
616        .take(256)
617        .map(|o| o.as_f64().unwrap_or(0.0))
618        .collect();
619
620    let font = LoadedFont {
621        font_type: zpdf_font::PdfFontType::Type3 {
622            font_matrix,
623            char_procs,
624            encoding,
625            widths,
626            first_char,
627        },
628        base_font,
629        font_data: None,
630        face_index: 0,
631        is_substitute: false,
632        cid_widths: CidWidths::new(1000.0),
633        units_per_em: 1000.0,
634        ascent: 880.0,
635        descent: -120.0,
636        cid_to_gid: None,
637        builtin_encoding_gids: None,
638        orphan_gids: Vec::new(),
639        encoding: None,
640        to_unicode: None,
641        symbolic: false,
642        type1: None,
643        cid_cmap: None,
644        dw2: (880.0, -1000.0),
645        variations: Vec::new(),
646    };
647
648    Ok(font)
649}
650
651fn load_type1_font(
652    file: &PdfFile,
653    dict: &zpdf_core::PdfDict,
654    base_font: String,
655) -> Result<LoadedFont> {
656    let cid_widths = parse_simple_widths(file, dict);
657    let font_data = extract_font_file_from_descriptor(file, dict);
658
659    match font_data {
660        Some(data) => Ok(LoadedFont::new_with_data(
661            PdfFontType::Type1,
662            base_font,
663            data,
664            cid_widths,
665        )),
666        None => Ok(try_system_substitute_simple(
667            file,
668            dict,
669            &base_font,
670            PdfFontType::Type1,
671            cid_widths,
672        )
673        .or_else(|| LoadedFont::new_standard(base_font.clone()))
674        .unwrap_or_else(|| LoadedFont::new_placeholder(base_font))),
675    }
676}
677
678/// Extract embedded font binary from FontDescriptor → FontFile2 (TrueType).
679fn extract_font_file(file: &PdfFile, cid_dict: &zpdf_core::PdfDict) -> Option<Vec<u8>> {
680    let fd_ref = cid_dict.get_ref("FontDescriptor").ok()?;
681    let fd_obj = file.resolve(fd_ref).ok()?;
682    let fd_dict = fd_obj.as_dict().ok()?;
683
684    // Try FontFile2 (TrueType), then FontFile3 (OpenType/CFF), then FontFile (Type1)
685    for key in &["FontFile2", "FontFile3", "FontFile"] {
686        if let Ok(ff_ref) = fd_dict.get_ref(key) {
687            if let Ok(data) = file.resolve_stream_data(ff_ref) {
688                if !data.is_empty() {
689                    return Some(data);
690                }
691            }
692        }
693    }
694    None
695}
696
697fn extract_font_file_from_descriptor(
698    file: &PdfFile,
699    font_dict: &zpdf_core::PdfDict,
700) -> Option<Vec<u8>> {
701    let fd_ref = font_dict.get_ref("FontDescriptor").ok()?;
702    let fd_obj = file.resolve(fd_ref).ok()?;
703    let fd_dict = fd_obj.as_dict().ok()?;
704
705    for key in &["FontFile2", "FontFile3", "FontFile"] {
706        if let Ok(ff_ref) = fd_dict.get_ref(key) {
707            if let Ok(data) = file.resolve_stream_data(ff_ref) {
708                if !data.is_empty() {
709                    return Some(data);
710                }
711            }
712        }
713    }
714    None
715}
716
717/// Fetch an array value, resolving one level of indirect reference. pdftex (and
718/// many other producers) commonly emit `/Widths` and `/W` as indirect objects,
719/// which a plain `get_array` would miss (leaving every glyph at the default width).
720fn resolve_array(file: &PdfFile, dict: &zpdf_core::PdfDict, key: &str) -> Option<Vec<PdfObject>> {
721    match dict.get(key) {
722        Some(PdfObject::Array(a)) => Some(a.clone()),
723        Some(PdfObject::Ref(id)) => file
724            .resolve(*id)
725            .ok()
726            .and_then(|o| o.as_array().ok().map(|a| a.to_vec())),
727        _ => None,
728    }
729}
730
731/// Fetch a dictionary value, resolving one level of indirect reference, in the
732/// same spirit as [`resolve_array`] (Type3 producers commonly emit /CharProcs
733/// and /Encoding as indirect objects).
734fn resolve_dict(
735    file: &PdfFile,
736    dict: &zpdf_core::PdfDict,
737    key: &str,
738) -> Option<zpdf_core::PdfDict> {
739    match dict.get(key) {
740        Some(PdfObject::Dict(d)) => Some(d.clone()),
741        Some(PdfObject::Ref(id)) => file
742            .resolve(*id)
743            .ok()
744            .and_then(|o| o.as_dict().ok().cloned()),
745        _ => None,
746    }
747}
748
749/// Parse CID /W array: format is [cid [w1 w2 ...]] or [cid_first cid_last w]
750fn parse_cid_widths(file: &PdfFile, dict: &zpdf_core::PdfDict) -> CidWidths {
751    let dw = dict.get_f64("DW").unwrap_or(1000.0);
752    let mut widths = CidWidths::new(dw);
753
754    let w_array = match resolve_array(file, dict, "W") {
755        Some(arr) => arr,
756        None => return widths,
757    };
758
759    let mut i = 0;
760    while i < w_array.len() {
761        let cid_start = match w_array[i].as_i64().ok().and_then(|v| u16::try_from(v).ok()) {
762            Some(v) => v,
763            None => break,
764        };
765        i += 1;
766        if i >= w_array.len() {
767            break;
768        }
769
770        match &w_array[i] {
771            PdfObject::Array(arr) => {
772                // [cid_start [w1 w2 w3 ...]]
773                for (j, obj) in arr.iter().enumerate() {
774                    let Some(cid) = u16::try_from(j)
775                        .ok()
776                        .and_then(|delta| cid_start.checked_add(delta))
777                    else {
778                        break;
779                    };
780                    if let Ok(w) = obj.as_f64() {
781                        widths.set(cid, w);
782                    }
783                }
784                i += 1;
785            }
786            PdfObject::Integer(_) | PdfObject::Real(_) => {
787                // [cid_start cid_end width]
788                let cid_end = w_array[i]
789                    .as_i64()
790                    .ok()
791                    .and_then(|v| u16::try_from(v).ok())
792                    .unwrap_or(cid_start);
793                i += 1;
794                if i < w_array.len() {
795                    let w = w_array[i].as_f64().unwrap_or(dw);
796                    for cid in cid_start..=cid_end {
797                        widths.set(cid, w);
798                    }
799                    i += 1;
800                }
801            }
802            _ => {
803                i += 1;
804            }
805        }
806    }
807
808    widths
809}
810
811/// Parse the CID /W2 array (PDF 9.7.4.3) into per-CID vertical metrics.
812/// Two element forms, mirroring /W but with THREE numbers per glyph:
813///   `c [ w1y_1 vx_1 vy_1  w1y_2 vx_2 vy_2 ... ]`   (list form)
814///   `cFirst cLast w1y vx vy`                         (range form)
815/// where `w1y` is the vertical displacement and `(vx, vy)` the position vector.
816fn parse_cid_w2(file: &PdfFile, dict: &zpdf_core::PdfDict, widths: &mut CidWidths) {
817    if let Some(arr) = resolve_array(file, dict, "W2") {
818        apply_w2_array(&arr, widths);
819    }
820}
821
822fn apply_w2_array(w2_array: &[PdfObject], widths: &mut CidWidths) {
823    let mut i = 0;
824    while i < w2_array.len() {
825        let cid_start = match w2_array[i]
826            .as_i64()
827            .ok()
828            .and_then(|v| u16::try_from(v).ok())
829        {
830            Some(v) => v,
831            None => break,
832        };
833        i += 1;
834        if i >= w2_array.len() {
835            break;
836        }
837
838        match &w2_array[i] {
839            PdfObject::Array(arr) => {
840                // List form: triples (w1y, vx, vy) starting at cid_start.
841                let mut k = 0;
842                while k + 2 < arr.len() {
843                    let (Ok(w1y), Ok(vx), Ok(vy)) =
844                        (arr[k].as_f64(), arr[k + 1].as_f64(), arr[k + 2].as_f64())
845                    else {
846                        break;
847                    };
848                    let Some(cid) = u16::try_from(k / 3)
849                        .ok()
850                        .and_then(|delta| cid_start.checked_add(delta))
851                    else {
852                        break;
853                    };
854                    widths.set_v(cid, w1y, vx, vy);
855                    k += 3;
856                }
857                i += 1;
858            }
859            PdfObject::Integer(_) | PdfObject::Real(_) => {
860                // Range form: cFirst cLast w1y vx vy.
861                let cid_end = w2_array[i]
862                    .as_i64()
863                    .ok()
864                    .and_then(|v| u16::try_from(v).ok())
865                    .unwrap_or(cid_start);
866                if i + 3 < w2_array.len() {
867                    let (Ok(w1y), Ok(vx), Ok(vy)) = (
868                        w2_array[i + 1].as_f64(),
869                        w2_array[i + 2].as_f64(),
870                        w2_array[i + 3].as_f64(),
871                    ) else {
872                        break;
873                    };
874                    for cid in cid_start..=cid_end {
875                        widths.set_v(cid, w1y, vx, vy);
876                    }
877                    i += 4;
878                } else {
879                    break;
880                }
881            }
882            _ => {
883                i += 1;
884            }
885        }
886    }
887}
888
889fn parse_simple_widths(file: &PdfFile, dict: &zpdf_core::PdfDict) -> CidWidths {
890    let first_char = dict
891        .get_i64("FirstChar")
892        .ok()
893        .and_then(|v| u8::try_from(v).ok())
894        .map(u16::from)
895        .unwrap_or(0);
896    let mut widths = CidWidths::new(1000.0);
897
898    if let Some(arr) = resolve_array(file, dict, "Widths") {
899        for (j, obj) in arr.iter().take(256).enumerate() {
900            let Some(code) = u16::try_from(j)
901                .ok()
902                .and_then(|delta| first_char.checked_add(delta))
903                .filter(|&code| code <= 255)
904            else {
905                break;
906            };
907            if let Ok(w) = obj.as_f64() {
908                widths.set(code, w);
909            }
910        }
911    }
912
913    widths
914}
915
916#[cfg(test)]
917mod tests {
918    use super::*;
919
920    fn int(v: i64) -> PdfObject {
921        PdfObject::Integer(v)
922    }
923    fn real(v: f64) -> PdfObject {
924        PdfObject::Real(v)
925    }
926
927    #[test]
928    fn w2_list_form_assigns_consecutive_cids() {
929        // 120 [w1y vx vy  w1y vx vy] → CIDs 120 and 121.
930        let arr = vec![
931            int(120),
932            PdfObject::Array(vec![
933                real(-1000.0),
934                real(500.0),
935                real(880.0),
936                int(-900),
937                int(450),
938                int(820),
939            ]),
940        ];
941        let mut w = CidWidths::new(1000.0);
942        apply_w2_array(&arr, &mut w);
943        assert_eq!(w.get_v(120), Some((-1000.0, 500.0, 880.0)));
944        assert_eq!(w.get_v(121), Some((-900.0, 450.0, 820.0)));
945        assert_eq!(w.get_v(122), None);
946    }
947
948    #[test]
949    fn w2_range_form_assigns_inclusive_range() {
950        // cFirst cLast w1y vx vy
951        let arr = vec![int(10), int(12), int(-1000), int(500), int(880)];
952        let mut w = CidWidths::new(1000.0);
953        apply_w2_array(&arr, &mut w);
954        for cid in 10..=12 {
955            assert_eq!(w.get_v(cid), Some((-1000.0, 500.0, 880.0)));
956        }
957        assert_eq!(w.get_v(9), None);
958        assert_eq!(w.get_v(13), None);
959    }
960
961    #[test]
962    fn w2_truncated_entry_is_ignored_not_panic() {
963        // Range header without the trailing metric numbers must not panic.
964        let arr = vec![int(10), int(12), int(-1000)];
965        let mut w = CidWidths::new(1000.0);
966        apply_w2_array(&arr, &mut w);
967        assert_eq!(w.get_v(10), None);
968    }
969
970    #[test]
971    fn type3_differences_reject_out_of_range_codes_without_growth() {
972        let name = |s: &str| PdfObject::Name(zpdf_core::PdfName::new(s));
973        let diffs = vec![
974            int(-1),
975            name("ignored-negative"),
976            int(i64::MAX),
977            name("ignored-huge"),
978            int(255),
979            name("last"),
980            name("past-end"),
981        ];
982        let encoding = type3_differences(&diffs);
983        assert_eq!(encoding.len(), 256);
984        assert_eq!(encoding[255], "last");
985    }
986
987    #[test]
988    fn simple_differences_do_not_overflow_after_huge_code() {
989        let mut dict = zpdf_core::PdfDict::new();
990        dict.insert(
991            zpdf_core::PdfName::new("Differences"),
992            PdfObject::Array(vec![
993                int(i64::MAX),
994                PdfObject::Name(zpdf_core::PdfName::new("ignored")),
995            ]),
996        );
997        let mut encoding =
998            zpdf_font::encoding::Encoding::from_base(&zpdf_font::encoding::STANDARD_ENCODING);
999        apply_differences(&dict, &mut encoding);
1000    }
1001}