Skip to main content

pdfrum_font/subst/
mod.rs

1//! Substitution: choosing a face when the document did not supply one.
2//!
3//! One decision, in four pieces: a request record, a decision record, a pure
4//! function between them, and a database seam. The ladder inside that
5//! function stays whole and stays in order, because which rung fires first
6//! *is* the result.
7
8// The shape is a decomposition of `CFX_FontMapper::FindSubstFace`, a
9// thousand-line class whose only real job is that one decision.
10mod charset;
11mod db;
12// Reading only the tables a directory scan needs, rather than each font file
13// whole. Filesystem work, so it exists only where the scan does.
14#[cfg(all(feature = "system-fonts", not(target_arch = "wasm32")))]
15mod probe;
16mod standard;
17mod style;
18mod substfont;
19mod tables;
20
21pub use charset::{Charset, charset_from_unicode};
22pub(crate) use charset::{CodePage, PitchFamily};
23#[cfg(test)]
24pub(crate) use db::FaceInfo;
25pub(crate) use db::{CroscoreDb, FaceHandle, FontDb, SystemFontDb, TestFontDb};
26#[cfg(test)]
27pub(crate) use standard::ALL_STANDARD_FONTS;
28pub use standard::StandardFont;
29pub use standard::canonical_font_name;
30pub(crate) use standard::{standard_font_data, standard_font_index};
31pub(crate) use style::{
32    NARROW_FAMILY, font_family, is_narrow_font_name, parse_styles, strip_subset_prefix, style_bits,
33    style_type, subst_name, tt_normalize,
34};
35pub use substfont::SubstFont;
36pub(crate) use substfont::{GlyphSpacingGate, applies_glyph_spacing};
37
38use crate::FontFlags;
39use crate::glyphs::{Face, GlyphSource};
40use pdfrum_common::{DiagKind, Diagnostics, Severity};
41use std::collections::HashMap;
42use std::path::PathBuf;
43use std::sync::{Arc, Mutex, OnceLock};
44
45/// What a font wants from substitution.
46#[derive(Debug, Clone, PartialEq, Eq)]
47pub struct FontRequest {
48    /// The `/BaseFont` name, before any normalization.
49    pub name: Vec<u8>,
50    /// Whether the font dictionary said `/TrueType`, which changes how the
51    /// name is normalized and which charmaps are preferred.
52    pub is_truetype: bool,
53    /// The descriptor's `/Flags`.
54    pub flags: FontFlags,
55    /// The requested weight.
56    pub weight: i32,
57    /// The requested italic angle.
58    pub italic_angle: i32,
59    /// The code page a CID collection implies, or `DefAnsi`.
60    pub code_page: CodePage,
61    /// Whether the font writes vertically.
62    pub vertical: bool,
63}
64
65impl Default for FontRequest {
66    fn default() -> Self {
67        Self {
68            name: Vec::new(),
69            is_truetype: false,
70            flags: FontFlags::DEFAULT,
71            weight: 400,
72            italic_angle: 0,
73            code_page: CodePage::DefAnsi,
74            vertical: false,
75        }
76    }
77}
78
79/// How substitution finds faces.
80#[derive(Debug, Clone, Default)]
81pub struct SubstitutionOptions {
82    /// Whether the *font-database* weight rule applies rather than the
83    /// enumeration one.
84    ///
85    /// PDFium guards Branch A's weight reset on an internal flag it sets in
86    /// its "version 2" mode. `fontdb` **is** that mode — there is no
87    /// enumeration callback, we query directly — so `true` is the
88    /// architecturally honest value and bold and light variants survive into
89    /// the query.
90    ///
91    /// The default is **`false`**, because that is the behaviour the
92    /// conformance corpus was rendered under — an enumerating font info,
93    /// where the weight reset applies. Set it to `true` for the modern
94    /// behaviour.
95    pub skip_font_enumeration: bool,
96    /// Directories to scan instead of the system's, for a hermetic run.
97    pub font_dirs: Vec<PathBuf>,
98    /// Whether the *family a database lookup asks for* is rewritten to its
99    /// Croscore equivalent (`--croscore-font-names`).
100    ///
101    /// The oracle's `test_fonts` directory holds no Arial, Times or Courier:
102    /// it holds the metric-compatible Arimo, Tinos and Cousine, so a hermetic
103    /// run needs the rewrite or a `/BaseFont /Helvetica` looks for a face that
104    /// is not there and falls through to the built-ins with different metrics.
105    ///
106    /// The rewrite sits at the database boundary — the family a lookup asks
107    /// for is renamed, not the `/BaseFont` the ladder starts from — so the
108    /// whole name/style/base-14 analysis runs on the document's own spelling
109    /// first.
110    pub croscore_font_names: bool,
111    /// Whether an empty [`font_dirs`](Self::font_dirs) means *the system's own
112    /// font directories* rather than *no directories at all*.
113    ///
114    /// A Linux reference run always enumerates the system's fonts —
115    /// `/usr/share/fonts`, `/usr/share/X11/fonts/Type1`,
116    /// `/usr/share/X11/fonts/TTF` and `/usr/local/share/fonts` — so a
117    /// `--font-dir` *replaces* that search path rather than *enabling* it.
118    ///
119    /// The default is **`false`**, because a library whose output depends on
120    /// which fonts happen to be installed is not testable and every unit test
121    /// in the tree is written against the built-in faces. A host that wants
122    /// the oracle's behaviour — `pdfrum-tool` invoked without `--font-dir`
123    /// does — sets it to `true`.
124    ///
125    /// It has no effect when `font_dirs` is non-empty: those directories are
126    /// then the whole search path either way, which is why every conformance
127    /// invocation (which always passes `--font-dir`) is unaffected by it.
128    // Where the four directories come from: `pdfium_test` leaves
129    // `config.m_pUserFontPaths` null unless `--font-dir` was given
130    // (`testing/pdfium_test/pdfium_test.cc:2107-2112`), and a null path list
131    // makes `CFX_LinuxFontInfo` add exactly those four
132    // (`core/fxge/linux/fx_linux_impl.cpp:173-176`).
133    pub system_fonts: bool,
134}
135
136/// Rewrite a family name to its Croscore equivalent.
137///
138/// Three families map, by substring and in this order; everything else is
139/// returned unchanged, which is deliberate — some fixtures want the built-in
140/// fallback and reaching it depends on *not* matching here.
141///
142/// The style suffixes are appended from the *same* string, so a family the
143/// ladder resolved to `Helvetica-Bold` reaches the database as `Arimo Bold`
144/// and a bold face is what comes back.
145#[must_use]
146pub fn croscore_name(face: &str) -> String {
147    let has = |needle: &str| face.contains(needle);
148    let base = if has("Arial") || has("Calibri") || has("Helvetica") {
149        "Arimo"
150    } else if face.is_empty() || has("Times") {
151        "Tinos"
152    } else if has("Courier") {
153        "Cousine"
154    } else {
155        return face.to_owned();
156    };
157
158    let mut out = base.to_owned();
159    // Both suffixes can apply, and in this order.
160    if has("Bold") {
161        out.push_str(" Bold");
162    }
163    if has("Italic") || has("Oblique") {
164        out.push_str(" Italic");
165    }
166    out
167}
168
169/// What substitution decided.
170pub struct Substitution {
171    /// The face to draw with. Always `Some` unless even the built-in fallback
172    /// failed to parse.
173    pub glyphs: GlyphSource,
174    /// The synthetic adjustments that follow from the choice.
175    pub subst: SubstFont,
176    /// The standard font this resolved to, when it resolved to one.
177    #[cfg(test)]
178    pub standard: Option<StandardFont>,
179}
180
181/// Resolve a font request to a face (`FindSubstFace`).
182///
183/// The ladder has five rungs and always ends in something drawable: the last
184/// rung is one of the two built-in Multiple-Master faces, which are compiled
185/// in and cannot be missing. `glyphs` is `GlyphSource::None` only if even that
186/// failed to parse, which would mean a corrupted build.
187#[must_use]
188pub fn resolve(
189    req: &FontRequest,
190    db: &impl FontDb,
191    opts: &SubstitutionOptions,
192    diags: &mut Diagnostics,
193) -> Substitution {
194    if opts.croscore_font_names {
195        return resolve_inner(req, &CroscoreDb::new(db), opts, diags, false);
196    }
197    resolve_inner(req, db, opts, diags, false)
198}
199
200/// Run the ladder against whichever database `opts` selects.
201///
202/// Three databases are reachable and the choice is entirely
203/// [`SubstitutionOptions`]'s:
204///
205/// - **named directories** ([`font_dirs`](SubstitutionOptions::font_dirs) is
206///   non-empty) — those directories and nothing else, which is what
207///   `--font-dir` asks for and what every conformance run uses;
208/// - **the system's directories** (`font_dirs` empty and
209///   [`system_fonts`](SubstitutionOptions::system_fonts) set) — the oracle's
210///   own default on Linux, where `--font-dir` merely *replaces* a search path
211///   that is otherwise `/usr/share/fonts` and friends;
212/// - **the built-in faces alone** (both unset) — the hermetic default, so a
213///   unit test's answer does not depend on what is installed on the machine.
214///
215/// The host scan is cached for the process; a `--font-dir` scan is not. A
216/// document whose fonts are all embedded never reaches here at all.
217#[must_use]
218pub fn resolve_with_options(
219    req: &FontRequest,
220    opts: &SubstitutionOptions,
221    diags: &mut Diagnostics,
222) -> Substitution {
223    if opts.font_dirs.is_empty() && !opts.system_fonts {
224        return resolve(req, &TestFontDb::new(), opts, diags);
225    }
226    let db = scanned(ScanKey::of(&opts.font_dirs));
227    resolve(req, db.as_ref(), opts, diags)
228}
229
230/// Which directories a scan covers — the whole identity of its result.
231///
232/// A scan is a pure function of this key: `fontdb` enumerates exactly these
233/// directories (or, for [`ScanKey::System`], the host's own four), and
234/// nothing else about the process changes what it finds. That is what makes
235/// [`scanned`] safe to memoize on it.
236#[derive(Debug, Clone, PartialEq, Eq, Hash)]
237enum ScanKey {
238    /// The host's own font directories — an empty `font_dirs`.
239    System,
240    /// Exactly these directories, in the order given, and nothing else.
241    Dirs(Vec<PathBuf>),
242}
243
244impl ScanKey {
245    fn of(dirs: &[PathBuf]) -> Self {
246        if dirs.is_empty() {
247            Self::System
248        } else {
249            Self::Dirs(dirs.to_vec())
250        }
251    }
252
253    fn dirs(&self) -> &[PathBuf] {
254        match self {
255            Self::System => &[],
256            Self::Dirs(dirs) => dirs,
257        }
258    }
259}
260
261/// The database for `key`, scanned at most once per process.
262///
263/// Font directories do not change under a running process, and a scan reads
264/// every face they hold to describe it — 0.2 s on a host with 1 183 faces,
265/// and on the oracle's hermetic `test_fonts` 33 MB of reads costing ~12 ms of
266/// kernel time.
267///
268/// The measured corpus does not exercise the second scan — all 22 of the 44
269/// benchmark files that substitute at all substitute exactly once — so this
270/// bounds a per-font cost to per-process. Memoizing on [`ScanKey`] cannot
271/// change an answer, because the scan is a pure function of the key.
272fn scanned(key: ScanKey) -> Arc<SystemFontDb> {
273    static SCANS: OnceLock<Mutex<HashMap<ScanKey, Arc<SystemFontDb>>>> = OnceLock::new();
274    let scans = SCANS.get_or_init(|| Mutex::new(HashMap::new()));
275    if let Ok(cache) = scans.lock()
276        && let Some(db) = cache.get(&key)
277    {
278        return Arc::clone(db);
279    }
280    // Scanned outside the lock, so one slow scan does not block a thread
281    // asking for a different directory set. Two threads racing the same key
282    // both scan and then agree on whichever result landed first, which is
283    // sound because the scan is a pure function of the key.
284    let db = Arc::new(SystemFontDb::scan(key.dirs()));
285    match scans.lock() {
286        Ok(mut cache) => Arc::clone(cache.entry(key).or_insert(db)),
287        Err(_) => db,
288    }
289}
290
291// The ladder is one ordered sequence: every step reads state the steps above
292// it left behind, and which rung fires first *is* the result. Splitting it into
293// helpers would hide that order behind call sites, so it stays whole.
294#[allow(clippy::too_many_lines)]
295fn resolve_inner(
296    req: &FontRequest,
297    db: &impl FontDb,
298    opts: &SubstitutionOptions,
299    diags: &mut Diagnostics,
300    retried: bool,
301) -> Substitution {
302    let mut subst = SubstFont::default();
303
304    // Step 0 — normalize. Without `USE_EXTERN_ATTR` the caller's weight and
305    // slant are *discarded entirely*, which is why that flag's five-term
306    // conjunction in the former working note matters so much.
307    let mut weight = if req.weight == 0 { 400 } else { req.weight };
308    let mut italic_angle = req.italic_angle;
309    if !req.flags.uses_extern_attr() {
310        weight = 400;
311        italic_angle = 0;
312    }
313
314    // Step 1 — the name.
315    let name = subst_name(&req.name, req.is_truetype);
316
317    // Step 2 — the two symbolic short-circuits. Note `ZapfDingbats` has no
318    // TrueType condition while `Symbol` does.
319    if name == b"Symbol" && !req.is_truetype {
320        "Chrome Symbol".clone_into(&mut subst.family);
321        subst.charset = Charset::Symbol;
322        return terminal(
323            Some(StandardFont::Symbol),
324            weight,
325            italic_angle,
326            PitchFamily::default(),
327            subst,
328            diags,
329        );
330    }
331    if name == b"ZapfDingbats" {
332        "Chrome Dingbats".clone_into(&mut subst.family);
333        subst.charset = Charset::Symbol;
334        return terminal(
335            Some(StandardFont::Dingbats),
336            weight,
337            italic_angle,
338            PitchFamily::default(),
339            subst,
340            diags,
341        );
342    }
343
344    // Step 3 — split at the first comma.
345    let (mut family, style_str, has_comma) = style::split_style(&name);
346    let std_font = if has_comma {
347        standard_font_index(&family)
348    } else {
349        standard_font_index(&name)
350    };
351
352    // Step 4 — derive the style. A base-14 font skips name parsing entirely
353    // and reads both style and pitch off its index.
354    let mut has_hyphen = false;
355    let (mut n_style, pitch_family, mut base_font) =
356        if let Some(sf) = std_font.filter(|f| f.index() < 12) {
357            (
358                style_from_standard_font(sf),
359                PitchFamily::from_standard_font(sf),
360                Some(sf),
361            )
362        } else {
363            let mut style_out = style_bits::NORMAL;
364            let mut style_str = style_str.clone();
365            if !has_comma {
366                // The *last* hyphen, not the first.
367                if let Some(p) = family.iter().rposition(|c| *c == b'-') {
368                    style_str = family.get(p + 1..).unwrap_or_default().to_vec();
369                    family.truncate(p);
370                    has_hyphen = true;
371                }
372            }
373            if !has_hyphen
374                && let Some(sr) = std::str::from_utf8(&family)
375                    .ok()
376                    .and_then(|f| style_type(f, true))
377            {
378                family.truncate(family.len().saturating_sub(sr.name.len()));
379                style_out |= sr.style;
380            }
381            let _ = style_str;
382            (style_out, PitchFamily::from_flags(req.flags), None)
383        };
384
385    // Step 5 — bold inference. `old_weight` is the *pre-inference* value, and
386    // which of the two every downstream call passes is behavior: the internal
387    // rungs take the old one, the external rungs the new one.
388    let old_weight = weight;
389    if n_style & style_bits::FORCE_BOLD != 0 {
390        weight = 700;
391    }
392
393    // Step 6 — the style suffix, which may abort the whole parse.
394    let style_source = if has_comma {
395        style_str
396    } else {
397        suffix_after_hyphen(&name, has_hyphen)
398    };
399    let parsed = parse_styles(&style_source, weight, n_style);
400    let mut is_style_available = parsed.is_style_available;
401    // The tokens parsed *before* an abort keep what they applied.
402    // `cfx_fontmapper.cpp:124-171` writes through `int* weight` and `uint32_t*
403    // style` token by token, and both `return true` paths (`:139`, `:162`)
404    // return after those writes; the caller at `:594-597` resets only the
405    // family and the base font. So `FooSans,Bold,Italic` — which aborts on the
406    // non-first italic — still reaches the mapper at weight 700 and force-bold.
407    weight = parsed.weight;
408    n_style = parsed.style;
409    if parsed.abort {
410        family.clone_from(&name);
411        base_font = None;
412    }
413
414    // Step 7 — with no database at all, go straight to the built-ins.
415    if db.faces().is_empty() {
416        return terminal(
417            base_font,
418            old_weight,
419            italic_angle,
420            pitch_family,
421            subst,
422            diags,
423        );
424    }
425
426    // Step 8 — charset and family rewriting.
427    let charset = request_charset(req.code_page, base_font, req.flags);
428    let is_cjk = charset.is_cjk();
429    let mut is_italic = n_style & style_bits::ITALIC != 0;
430    let family_str = String::from_utf8_lossy(&family).into_owned();
431    let mut family_str = match font_family(n_style, &family_str) {
432        Some(f) => f.to_owned(),
433        None => family_str,
434    };
435
436    // Step 9 — installed-name matching, with a second, looser attempt.
437    let name_str = String::from_utf8_lossy(&name).into_owned();
438    let mut matched = db.match_installed(&tt_normalize(&family_str));
439    if matched.is_none()
440        && family_str != name_str
441        && !has_comma
442        && (!has_hyphen || !is_style_available)
443    {
444        matched = db.match_installed(&tt_normalize(&name_str));
445    }
446
447    // Step 10 — the two branches.
448    let mut pitch_family = pitch_family;
449    if matched.is_none() && base_font.is_none() {
450        if is_cjk {
451            subst.subst_cjk = true;
452            if n_style != 0 {
453                subst.weight_cjk = Some(weight);
454            }
455            if n_style & style_bits::ITALIC != 0 {
456                subst.italic_cjk = true;
457            }
458        } else {
459            if style::is_third_party_font(&family_str) {
460                pitch_family = PitchFamily(pitch_family.0 & !PitchFamily::ROMAN);
461            } else {
462                // The italic decision is *overridden* by the angle here.
463                is_italic = italic_angle != 0;
464                if !opts.skip_font_enumeration {
465                    weight = old_weight;
466                }
467            }
468            if is_narrow_font_name(&name_str) {
469                NARROW_FAMILY.clone_into(&mut family_str);
470            }
471        }
472        // The PDF's own italic flag can still force it on.
473        if req.flags.is_italic() {
474            is_italic = true;
475        }
476    } else {
477        italic_angle = 0;
478        // `[oracle-bug]` `cfx_fontmapper.cpp:644` asks `nStyle ==
479        // kFontStyleNormal`, which conflates "has no style" with "has no
480        // *bold*": an italic standard face such as `Helvetica-Oblique` skips
481        // the reset and keeps the requested 700, where Annex D makes it a
482        // regular-weight face. The test the reset needs is "not force-bold";
483        // pdf.js reaches 400 structurally, weight being a property of the
484        // resolved face (`font_substitutions.js:32-35` `ITALIC = { style:
485        // "italic", weight: "normal" }`, bound at `:129-133`).
486        if n_style & style_bits::FORCE_BOLD == 0 {
487            weight = 400;
488        }
489        if let Some(m) = &matched {
490            family_str.clone_from(m);
491        }
492        if let Some(bf) = base_font {
493            let adjusted = adjust_base_font_for_style(bf, n_style);
494            base_font = Some(adjusted);
495            canonical_font_name(adjusted).clone_into(&mut family_str);
496        }
497    }
498    let _ = &mut is_style_available;
499
500    // Step 11 — rung 1: ask the database. This is `MapFont`, not `FindFont`:
501    // the platform's system-font info gets first refusal, and on Linux it
502    // answers the four CJK charsets from its own preference lists before any
503    // scoring runs (`fx_linux_impl.cpp:95-152`).
504    if let Some(h) = db.map_font(weight, is_italic, charset, pitch_family, &family_str)
505        && let Some(s) = external(db, h, weight, is_italic, italic_angle, charset, &mut subst)
506    {
507        return Substitution {
508            glyphs: s,
509            subst,
510            #[cfg(test)]
511            standard: base_font,
512        };
513    }
514
515    // Step 12 — rung 2: the exact installed name.
516    if is_cjk {
517        is_italic = italic_angle != 0;
518        weight = old_weight;
519    }
520    if let Some(m) = &matched {
521        return match db.font_by_name(m) {
522            None => terminal(
523                base_font,
524                old_weight,
525                italic_angle,
526                pitch_family,
527                subst,
528                diags,
529            ),
530            Some(h) => {
531                match external(db, h, weight, is_italic, italic_angle, charset, &mut subst) {
532                    Some(s) => Substitution {
533                        glyphs: s,
534                        subst,
535                        #[cfg(test)]
536                        standard: base_font,
537                    },
538                    None => terminal(
539                        base_font,
540                        old_weight,
541                        italic_angle,
542                        pitch_family,
543                        subst,
544                        diags,
545                    ),
546                }
547            }
548        };
549    }
550
551    // Step 13 — rung 3: retry a symbolic request as a plain one, **once**.
552    if charset == Charset::Symbol {
553        if name == b"Symbol" {
554            "Chrome Symbol".clone_into(&mut subst.family);
555            subst.charset = Charset::Symbol;
556            return terminal(
557                Some(StandardFont::Symbol),
558                old_weight,
559                italic_angle,
560                pitch_family,
561                subst,
562                diags,
563            );
564        }
565        if !retried {
566            // Dropping the symbolic bit makes `request_charset` return ANSI,
567            // so this rung cannot be re-entered — but the flag makes that a
568            // fact rather than an argument.
569            let retry = FontRequest {
570                name: family.clone(),
571                flags: req.flags.without(FontFlags::SYMBOLIC),
572                weight,
573                italic_angle,
574                code_page: CodePage::DefAnsi,
575                ..req.clone()
576            };
577            return resolve_inner(&retry, db, opts, diags, true);
578        }
579    }
580
581    // Step 14 — rung 4: an ANSI request that found nothing takes the built-ins.
582    if charset == Charset::Ansi {
583        return terminal(
584            base_font,
585            old_weight,
586            italic_angle,
587            pitch_family,
588            subst,
589            diags,
590        );
591    }
592
593    // Step 15 — rung 5: any installed face claiming this charset, in
594    // insertion order.
595    let by_charset = db
596        .faces()
597        .iter()
598        .position(|f| f.charsets.contains(&charset))
599        .map(FaceHandle::from_index);
600    match by_charset {
601        None => terminal(
602            base_font,
603            old_weight,
604            italic_angle,
605            pitch_family,
606            subst,
607            diags,
608        ),
609        Some(h) => {
610            if let Some(s) = external(db, h, weight, is_italic, italic_angle, charset, &mut subst) {
611                Substitution {
612                    glyphs: s,
613                    subst,
614                    #[cfg(test)]
615                    standard: base_font,
616                }
617            } else {
618                // The one place PDFium returns nothing at all.
619                diags.record(Severity::Suspicious, DiagKind::FontSubstitutionFailed, None);
620                Substitution {
621                    glyphs: GlyphSource::None,
622                    subst,
623                    #[cfg(test)]
624                    standard: base_font,
625                }
626            }
627        }
628    }
629}
630
631/// The style bits a base-14 index implies (`GetStyleFromBaseFont`).
632///
633/// Reads `index % 4` against the family layout Regular / Bold / BoldOblique /
634/// Oblique — bold at positions 1 and 2, italic at 2 and 3.
635#[must_use]
636pub fn style_from_standard_font(f: StandardFont) -> u32 {
637    let pos = f.index() % 4;
638    let mut style = style_bits::NORMAL;
639    if pos == 1 || pos == 2 {
640        style |= style_bits::FORCE_BOLD;
641    }
642    if pos / 2 != 0 {
643        style |= style_bits::ITALIC;
644    }
645    style
646}
647
648/// Apply a style to a base-14 index by arithmetic (`AdjustBaseFontForStyle`).
649///
650/// Only the three family heads can be styled; anything else is already a
651/// styled member and is returned unchanged.
652#[must_use]
653pub fn adjust_base_font_for_style(base: StandardFont, style: u32) -> StandardFont {
654    if style == style_bits::NORMAL || !base.is_stylable() {
655        return base;
656    }
657    let bold = style & style_bits::FORCE_BOLD != 0;
658    let italic = style & style_bits::ITALIC != 0;
659    let offset = match (bold, italic) {
660        (true, true) => 2,
661        (true, false) => 1,
662        (false, true) => 3,
663        (false, false) => 0,
664    };
665    StandardFont::from_index(base.index() + offset).unwrap_or(base)
666}
667
668/// The charset a request is for (`GetCharset`).
669#[must_use]
670fn request_charset(cp: CodePage, base: Option<StandardFont>, flags: FontFlags) -> Charset {
671    if cp != CodePage::DefAnsi {
672        return Charset::from_code_page(cp);
673    }
674    // Symbolic *and* not a standard font: only then is it a symbol request.
675    if flags.is_symbolic() && base.is_none() {
676        return Charset::Symbol;
677    }
678    Charset::Ansi
679}
680
681/// The style suffix a hyphen split produced, recomputed rather than threaded
682/// because the split is local to step 4.
683fn suffix_after_hyphen(name: &[u8], has_hyphen: bool) -> Vec<u8> {
684    if !has_hyphen {
685        return Vec::new();
686    }
687    name.iter()
688        .rposition(|c| *c == b'-')
689        .and_then(|p| name.get(p + 1..))
690        .unwrap_or_default()
691        .to_vec()
692}
693
694/// Build a face from a database handle (`external_subst`).
695fn external(
696    db: &impl FontDb,
697    h: FaceHandle,
698    weight: i32,
699    is_italic: bool,
700    italic_angle: i32,
701    charset: Charset,
702    subst: &mut SubstFont,
703) -> Option<GlyphSource> {
704    let (bytes, index) = db.face_bytes(h)?;
705    let face = Face::new(bytes, index)?;
706    let info = db.faces().get(h.index())?;
707    // A database that cannot name its own face falls back to the face's, which
708    // is the `SetSubstFontNameWhenGetFaceNameFails` behavior.
709    let name = if info.name.is_empty() {
710        face.display_name().unwrap_or_default()
711    } else {
712        info.name.clone()
713    };
714    subst.configure_external(
715        name,
716        charset,
717        weight,
718        is_italic,
719        italic_angle,
720        info.styles & style_bits::FORCE_BOLD != 0,
721        info.styles & style_bits::ITALIC != 0,
722    );
723    Some(GlyphSource::Fontations(face))
724}
725
726/// The terminal rung (`internal_subst`), itself two-level.
727///
728/// A resolved base-14 index takes the exact Foxit blob and **leaves the
729/// substitution record untouched** — weight, angle and pitch are all ignored,
730/// because the blob is already the right face. Anything else takes one of the
731/// two Multiple-Master generics, which *do* record the weight, because their
732/// design space is how the weight gets applied at all.
733fn terminal(
734    base_font: Option<StandardFont>,
735    weight: i32,
736    italic_angle: i32,
737    pitch_family: PitchFamily,
738    mut subst: SubstFont,
739    diags: &mut Diagnostics,
740) -> Substitution {
741    if let Some(f) = base_font {
742        let glyphs = builtin_standard(f);
743        if !glyphs.is_some() {
744            diags.record(Severity::Suspicious, DiagKind::FontSubstitutionFailed, None);
745        }
746        return Substitution {
747            glyphs,
748            subst,
749            #[cfg(test)]
750            standard: Some(f),
751        };
752    }
753
754    subst.is_builtin_generic = true;
755    subst.italic_angle = italic_angle;
756    if weight != 0 {
757        subst.weight = Some(weight);
758    }
759    let serif = pitch_family.has(PitchFamily::ROMAN);
760    let (glyphs, family) = builtin_generic(serif);
761    if serif {
762        subst.use_chrome_serif();
763    } else {
764        family.clone_into(&mut subst.family);
765    }
766    if !glyphs.is_some() {
767        diags.record(Severity::Suspicious, DiagKind::FontSubstitutionFailed, None);
768    }
769    Substitution {
770        glyphs,
771        subst,
772        #[cfg(test)]
773        standard: None,
774    }
775}
776
777/// One of the fourteen standard faces, parsed once per process.
778///
779/// The same memoization [`builtin_generic`] gets and for the same reason: the
780/// blob is an `include_bytes!` constant, so the parse is a pure function of the
781/// `StandardFont` index and there is no key to get wrong. A dense array rather
782/// than a map because the index is already `0..14` and dense — `StandardFont`'s
783/// discriminants are load-bearing arithmetic (see its own docs), not an
784/// arbitrary tag.
785///
786/// `Face` holds its bytes behind an `Arc`, so the clone is a refcount bump and
787/// the 66-113 KB of CFF is stored once rather than once per `Helv` in a form's
788/// resource dictionary.
789fn builtin_standard(f: StandardFont) -> GlyphSource {
790    /// One cell per base-14 index.
791    static FACES: OnceLock<[GlyphSource; 14]> = OnceLock::new();
792
793    let faces = FACES.get_or_init(|| {
794        std::array::from_fn(|i| {
795            let Some(f) = StandardFont::from_index(i) else {
796                return GlyphSource::None;
797            };
798            let bytes: Arc<[u8]> = Arc::from(standard_font_data(f));
799            Face::new(bytes, 0).map_or(GlyphSource::None, GlyphSource::Fontations)
800        })
801    });
802    faces.get(f.index()).cloned().unwrap_or_default()
803}
804
805/// One of the two built-in Multiple-Master generic faces, and its family name.
806///
807/// These are the reason `pdfrum-type1` exists: they are PFB Type 1 Multiple
808/// Master, and instantiating them at an arbitrary weight and width is what
809/// draws every font neither the document nor the system supplied.
810///
811/// # Parsed once per process, then shared
812///
813/// The two PFB blobs are `include_bytes!` constants, so parsing one is a pure
814/// function of a `bool` — the same 66 KB (sans) or 113 KB of container split,
815/// `eexec` decryption, charstring extraction and glyph-name indexing, producing
816/// the same face, every time. Unmemoized it ran on **every call**, and the call
817/// is the last rung of the substitution ladder: it fires for every non-embedded
818/// font whose name is not one of the base fourteen and which no system database
819/// supplied. `mixed_formfield.pdf` has sixteen such fonts in its AcroForm
820/// `/DR /Font` — fifteen of them byte-identical SimSun descriptors under
821/// different resource names — and the form-field appearance pass reloads all of
822/// them on every render, so a single render of a single page paid **fifteen
823/// full Multiple-Master parses**. That was 55 ms of the document's 87 ms.
824///
825/// A `OnceLock` per variant fixes it at the only layer where the memoization is
826/// unconditionally sound: the input is a compile-time constant, so there is no
827/// key to get wrong, no lifetime to scope, and no document whose cache this
828/// could leak across. `GlyphSource::Type1` holds an `Arc`, so the clone handed
829/// to each caller is a refcount bump. `Face` is likewise `Arc<[u8]>`-backed.
830///
831/// This is deliberately *not* the general font cache `FontCache`'s doc comment
832/// promises and its single `AtomicU64` field does not deliver. That remains
833/// outstanding, and it is the fix for a document that loads the same *embedded*
834/// font sixteen times. What is fixed here is the built-in fallback path, which
835/// is the one the corpus actually exercises.
836#[must_use]
837pub fn builtin_generic(serif: bool) -> (GlyphSource, &'static str) {
838    /// The parsed sans face, or `None` if the blob failed to parse.
839    static SANS: OnceLock<GlyphSource> = OnceLock::new();
840    /// The parsed serif face.
841    static SERIF: OnceLock<GlyphSource> = OnceLock::new();
842
843    let (cell, bytes, family) = if serif {
844        (
845            &SERIF,
846            &include_bytes!("../../fontdata/FoxitSerifMM.pfb")[..],
847            "Chrome Serif",
848        )
849    } else {
850        (
851            &SANS,
852            &include_bytes!("../../fontdata/FoxitSansMM.pfb")[..],
853            "Chrome Sans",
854        )
855    };
856    // Diagnostics are discarded here exactly as they were before: the limit is
857    // zero, the input is a constant this crate ships, and a caller has no way
858    // to act on damage in a blob they did not supply. `is_some()` is how the
859    // one failure that matters reaches `terminal`.
860    let source = cell.get_or_init(|| {
861        let font = pdfrum_type1::Type1Font::parse(
862            bytes,
863            &pdfrum_common::Limits::default(),
864            &mut Diagnostics::with_limit(0),
865        );
866        match font {
867            Ok(f) => GlyphSource::Type1(Arc::new(f)),
868            Err(_) => GlyphSource::None,
869        }
870    });
871    (source.clone(), family)
872}
873
874#[cfg(test)]
875#[path = "subst_tests.rs"]
876mod tests;