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    if parsed.abort {
402        family.clone_from(&name);
403        base_font = None;
404    } else {
405        weight = parsed.weight;
406        n_style = parsed.style;
407    }
408
409    // Step 7 — with no database at all, go straight to the built-ins.
410    if db.faces().is_empty() {
411        return terminal(
412            base_font,
413            old_weight,
414            italic_angle,
415            pitch_family,
416            subst,
417            diags,
418        );
419    }
420
421    // Step 8 — charset and family rewriting.
422    let charset = request_charset(req.code_page, base_font, req.flags);
423    let is_cjk = charset.is_cjk();
424    let mut is_italic = n_style & style_bits::ITALIC != 0;
425    let family_str = String::from_utf8_lossy(&family).into_owned();
426    let mut family_str = match font_family(n_style, &family_str) {
427        Some(f) => f.to_owned(),
428        None => family_str,
429    };
430
431    // Step 9 — installed-name matching, with a second, looser attempt.
432    let name_str = String::from_utf8_lossy(&name).into_owned();
433    let mut matched = db.match_installed(&tt_normalize(&family_str));
434    if matched.is_none()
435        && family_str != name_str
436        && !has_comma
437        && (!has_hyphen || !is_style_available)
438    {
439        matched = db.match_installed(&tt_normalize(&name_str));
440    }
441
442    // Step 10 — the two branches.
443    let mut pitch_family = pitch_family;
444    if matched.is_none() && base_font.is_none() {
445        if is_cjk {
446            subst.subst_cjk = true;
447            if n_style != 0 {
448                subst.weight_cjk = Some(weight);
449            }
450            if n_style & style_bits::ITALIC != 0 {
451                subst.italic_cjk = true;
452            }
453        } else {
454            if style::is_third_party_font(&family_str) {
455                pitch_family = PitchFamily(pitch_family.0 & !PitchFamily::ROMAN);
456            } else {
457                // The italic decision is *overridden* by the angle here.
458                is_italic = italic_angle != 0;
459                if !opts.skip_font_enumeration {
460                    weight = old_weight;
461                }
462            }
463            if is_narrow_font_name(&name_str) {
464                NARROW_FAMILY.clone_into(&mut family_str);
465            }
466        }
467        // The PDF's own italic flag can still force it on.
468        if req.flags.is_italic() {
469            is_italic = true;
470        }
471    } else {
472        italic_angle = 0;
473        // `[oracle-bug]` `cfx_fontmapper.cpp:644` asks `nStyle ==
474        // kFontStyleNormal`, which conflates "has no style" with "has no
475        // *bold*": an italic standard face such as `Helvetica-Oblique` skips
476        // the reset and keeps the requested 700, where Annex D makes it a
477        // regular-weight face. The test the reset needs is "not force-bold";
478        // pdf.js reaches 400 structurally, weight being a property of the
479        // resolved face (`font_substitutions.js:32-35` `ITALIC = { style:
480        // "italic", weight: "normal" }`, bound at `:129-133`).
481        if n_style & style_bits::FORCE_BOLD == 0 {
482            weight = 400;
483        }
484        if let Some(m) = &matched {
485            family_str.clone_from(m);
486        }
487        if let Some(bf) = base_font {
488            let adjusted = adjust_base_font_for_style(bf, n_style);
489            base_font = Some(adjusted);
490            canonical_font_name(adjusted).clone_into(&mut family_str);
491        }
492    }
493    let _ = &mut is_style_available;
494
495    // Step 11 — rung 1: ask the database.
496    if let Some(h) = db.find_font(weight, is_italic, charset, pitch_family, &family_str, true)
497        && let Some(s) = external(db, h, weight, is_italic, italic_angle, charset, &mut subst)
498    {
499        return Substitution {
500            glyphs: s,
501            subst,
502            #[cfg(test)]
503            standard: base_font,
504        };
505    }
506
507    // Step 12 — rung 2: the exact installed name.
508    if is_cjk {
509        is_italic = italic_angle != 0;
510        weight = old_weight;
511    }
512    if let Some(m) = &matched {
513        return match db.font_by_name(m) {
514            None => terminal(
515                base_font,
516                old_weight,
517                italic_angle,
518                pitch_family,
519                subst,
520                diags,
521            ),
522            Some(h) => {
523                match external(db, h, weight, is_italic, italic_angle, charset, &mut subst) {
524                    Some(s) => Substitution {
525                        glyphs: s,
526                        subst,
527                        #[cfg(test)]
528                        standard: base_font,
529                    },
530                    None => terminal(
531                        base_font,
532                        old_weight,
533                        italic_angle,
534                        pitch_family,
535                        subst,
536                        diags,
537                    ),
538                }
539            }
540        };
541    }
542
543    // Step 13 — rung 3: retry a symbolic request as a plain one, **once**.
544    if charset == Charset::Symbol {
545        if name == b"Symbol" {
546            "Chrome Symbol".clone_into(&mut subst.family);
547            subst.charset = Charset::Symbol;
548            return terminal(
549                Some(StandardFont::Symbol),
550                old_weight,
551                italic_angle,
552                pitch_family,
553                subst,
554                diags,
555            );
556        }
557        if !retried {
558            // Dropping the symbolic bit makes `request_charset` return ANSI,
559            // so this rung cannot be re-entered — but the flag makes that a
560            // fact rather than an argument.
561            let retry = FontRequest {
562                name: family.clone(),
563                flags: req.flags.without(FontFlags::SYMBOLIC),
564                weight,
565                italic_angle,
566                code_page: CodePage::DefAnsi,
567                ..req.clone()
568            };
569            return resolve_inner(&retry, db, opts, diags, true);
570        }
571    }
572
573    // Step 14 — rung 4: an ANSI request that found nothing takes the built-ins.
574    if charset == Charset::Ansi {
575        return terminal(
576            base_font,
577            old_weight,
578            italic_angle,
579            pitch_family,
580            subst,
581            diags,
582        );
583    }
584
585    // Step 15 — rung 5: any installed face claiming this charset, in
586    // insertion order.
587    let by_charset = db
588        .faces()
589        .iter()
590        .position(|f| f.charsets.contains(&charset))
591        .map(FaceHandle::from_index);
592    match by_charset {
593        None => terminal(
594            base_font,
595            old_weight,
596            italic_angle,
597            pitch_family,
598            subst,
599            diags,
600        ),
601        Some(h) => {
602            if let Some(s) = external(db, h, weight, is_italic, italic_angle, charset, &mut subst) {
603                Substitution {
604                    glyphs: s,
605                    subst,
606                    #[cfg(test)]
607                    standard: base_font,
608                }
609            } else {
610                // The one place PDFium returns nothing at all.
611                diags.record(Severity::Suspicious, DiagKind::FontSubstitutionFailed, None);
612                Substitution {
613                    glyphs: GlyphSource::None,
614                    subst,
615                    #[cfg(test)]
616                    standard: base_font,
617                }
618            }
619        }
620    }
621}
622
623/// The style bits a base-14 index implies (`GetStyleFromBaseFont`).
624///
625/// Reads `index % 4` against the family layout Regular / Bold / BoldOblique /
626/// Oblique — bold at positions 1 and 2, italic at 2 and 3.
627#[must_use]
628pub fn style_from_standard_font(f: StandardFont) -> u32 {
629    let pos = f.index() % 4;
630    let mut style = style_bits::NORMAL;
631    if pos == 1 || pos == 2 {
632        style |= style_bits::FORCE_BOLD;
633    }
634    if pos / 2 != 0 {
635        style |= style_bits::ITALIC;
636    }
637    style
638}
639
640/// Apply a style to a base-14 index by arithmetic (`AdjustBaseFontForStyle`).
641///
642/// Only the three family heads can be styled; anything else is already a
643/// styled member and is returned unchanged.
644#[must_use]
645pub fn adjust_base_font_for_style(base: StandardFont, style: u32) -> StandardFont {
646    if style == style_bits::NORMAL || !base.is_stylable() {
647        return base;
648    }
649    let bold = style & style_bits::FORCE_BOLD != 0;
650    let italic = style & style_bits::ITALIC != 0;
651    let offset = match (bold, italic) {
652        (true, true) => 2,
653        (true, false) => 1,
654        (false, true) => 3,
655        (false, false) => 0,
656    };
657    StandardFont::from_index(base.index() + offset).unwrap_or(base)
658}
659
660/// The charset a request is for (`GetCharset`).
661#[must_use]
662fn request_charset(cp: CodePage, base: Option<StandardFont>, flags: FontFlags) -> Charset {
663    if cp != CodePage::DefAnsi {
664        return Charset::from_code_page(cp);
665    }
666    // Symbolic *and* not a standard font: only then is it a symbol request.
667    if flags.is_symbolic() && base.is_none() {
668        return Charset::Symbol;
669    }
670    Charset::Ansi
671}
672
673/// The style suffix a hyphen split produced, recomputed rather than threaded
674/// because the split is local to step 4.
675fn suffix_after_hyphen(name: &[u8], has_hyphen: bool) -> Vec<u8> {
676    if !has_hyphen {
677        return Vec::new();
678    }
679    name.iter()
680        .rposition(|c| *c == b'-')
681        .and_then(|p| name.get(p + 1..))
682        .unwrap_or_default()
683        .to_vec()
684}
685
686/// Build a face from a database handle (`external_subst`).
687fn external(
688    db: &impl FontDb,
689    h: FaceHandle,
690    weight: i32,
691    is_italic: bool,
692    italic_angle: i32,
693    charset: Charset,
694    subst: &mut SubstFont,
695) -> Option<GlyphSource> {
696    let (bytes, index) = db.face_bytes(h)?;
697    let face = Face::new(bytes, index)?;
698    let info = db.faces().get(h.index())?;
699    // A database that cannot name its own face falls back to the face's, which
700    // is the `SetSubstFontNameWhenGetFaceNameFails` behavior.
701    let name = if info.name.is_empty() {
702        face.display_name().unwrap_or_default()
703    } else {
704        info.name.clone()
705    };
706    subst.configure_external(
707        name,
708        charset,
709        weight,
710        is_italic,
711        italic_angle,
712        info.styles & style_bits::FORCE_BOLD != 0,
713        info.styles & style_bits::ITALIC != 0,
714    );
715    Some(GlyphSource::Fontations(face))
716}
717
718/// The terminal rung (`internal_subst`), itself two-level.
719///
720/// A resolved base-14 index takes the exact Foxit blob and **leaves the
721/// substitution record untouched** — weight, angle and pitch are all ignored,
722/// because the blob is already the right face. Anything else takes one of the
723/// two Multiple-Master generics, which *do* record the weight, because their
724/// design space is how the weight gets applied at all.
725fn terminal(
726    base_font: Option<StandardFont>,
727    weight: i32,
728    italic_angle: i32,
729    pitch_family: PitchFamily,
730    mut subst: SubstFont,
731    diags: &mut Diagnostics,
732) -> Substitution {
733    if let Some(f) = base_font {
734        let glyphs = builtin_standard(f);
735        if !glyphs.is_some() {
736            diags.record(Severity::Suspicious, DiagKind::FontSubstitutionFailed, None);
737        }
738        return Substitution {
739            glyphs,
740            subst,
741            #[cfg(test)]
742            standard: Some(f),
743        };
744    }
745
746    subst.is_builtin_generic = true;
747    subst.italic_angle = italic_angle;
748    if weight != 0 {
749        subst.weight = Some(weight);
750    }
751    let serif = pitch_family.has(PitchFamily::ROMAN);
752    let (glyphs, family) = builtin_generic(serif);
753    if serif {
754        subst.use_chrome_serif();
755    } else {
756        family.clone_into(&mut subst.family);
757    }
758    if !glyphs.is_some() {
759        diags.record(Severity::Suspicious, DiagKind::FontSubstitutionFailed, None);
760    }
761    Substitution {
762        glyphs,
763        subst,
764        #[cfg(test)]
765        standard: None,
766    }
767}
768
769/// One of the fourteen standard faces, parsed once per process.
770///
771/// The same memoization [`builtin_generic`] gets and for the same reason: the
772/// blob is an `include_bytes!` constant, so the parse is a pure function of the
773/// `StandardFont` index and there is no key to get wrong. A dense array rather
774/// than a map because the index is already `0..14` and dense — `StandardFont`'s
775/// discriminants are load-bearing arithmetic (see its own docs), not an
776/// arbitrary tag.
777///
778/// `Face` holds its bytes behind an `Arc`, so the clone is a refcount bump and
779/// the 66-113 KB of CFF is stored once rather than once per `Helv` in a form's
780/// resource dictionary.
781fn builtin_standard(f: StandardFont) -> GlyphSource {
782    /// One cell per base-14 index.
783    static FACES: OnceLock<[GlyphSource; 14]> = OnceLock::new();
784
785    let faces = FACES.get_or_init(|| {
786        std::array::from_fn(|i| {
787            let Some(f) = StandardFont::from_index(i) else {
788                return GlyphSource::None;
789            };
790            let bytes: Arc<[u8]> = Arc::from(standard_font_data(f));
791            Face::new(bytes, 0).map_or(GlyphSource::None, GlyphSource::Fontations)
792        })
793    });
794    faces.get(f.index()).cloned().unwrap_or_default()
795}
796
797/// One of the two built-in Multiple-Master generic faces, and its family name.
798///
799/// These are the reason `pdfrum-type1` exists: they are PFB Type 1 Multiple
800/// Master, and instantiating them at an arbitrary weight and width is what
801/// draws every font neither the document nor the system supplied.
802///
803/// # Parsed once per process, then shared
804///
805/// The two PFB blobs are `include_bytes!` constants, so parsing one is a pure
806/// function of a `bool` — the same 66 KB (sans) or 113 KB of container split,
807/// `eexec` decryption, charstring extraction and glyph-name indexing, producing
808/// the same face, every time. Unmemoized it ran on **every call**, and the call
809/// is the last rung of the substitution ladder: it fires for every non-embedded
810/// font whose name is not one of the base fourteen and which no system database
811/// supplied. `mixed_formfield.pdf` has sixteen such fonts in its AcroForm
812/// `/DR /Font` — fifteen of them byte-identical SimSun descriptors under
813/// different resource names — and the form-field appearance pass reloads all of
814/// them on every render, so a single render of a single page paid **fifteen
815/// full Multiple-Master parses**. That was 55 ms of the document's 87 ms.
816///
817/// A `OnceLock` per variant fixes it at the only layer where the memoization is
818/// unconditionally sound: the input is a compile-time constant, so there is no
819/// key to get wrong, no lifetime to scope, and no document whose cache this
820/// could leak across. `GlyphSource::Type1` holds an `Arc`, so the clone handed
821/// to each caller is a refcount bump. `Face` is likewise `Arc<[u8]>`-backed.
822///
823/// This is deliberately *not* the general font cache `FontCache`'s doc comment
824/// promises and its single `AtomicU64` field does not deliver. That remains
825/// outstanding, and it is the fix for a document that loads the same *embedded*
826/// font sixteen times. What is fixed here is the built-in fallback path, which
827/// is the one the corpus actually exercises.
828#[must_use]
829pub fn builtin_generic(serif: bool) -> (GlyphSource, &'static str) {
830    /// The parsed sans face, or `None` if the blob failed to parse.
831    static SANS: OnceLock<GlyphSource> = OnceLock::new();
832    /// The parsed serif face.
833    static SERIF: OnceLock<GlyphSource> = OnceLock::new();
834
835    let (cell, bytes, family) = if serif {
836        (
837            &SERIF,
838            &include_bytes!("../../fontdata/FoxitSerifMM.pfb")[..],
839            "Chrome Serif",
840        )
841    } else {
842        (
843            &SANS,
844            &include_bytes!("../../fontdata/FoxitSansMM.pfb")[..],
845            "Chrome Sans",
846        )
847    };
848    // Diagnostics are discarded here exactly as they were before: the limit is
849    // zero, the input is a constant this crate ships, and a caller has no way
850    // to act on damage in a blob they did not supply. `is_some()` is how the
851    // one failure that matters reaches `terminal`.
852    let source = cell.get_or_init(|| {
853        let font = pdfrum_type1::Type1Font::parse(
854            bytes,
855            &pdfrum_common::Limits::default(),
856            &mut Diagnostics::with_limit(0),
857        );
858        match font {
859            Ok(f) => GlyphSource::Type1(Arc::new(f)),
860            Err(_) => GlyphSource::None,
861        }
862    });
863    (source.clone(), family)
864}
865
866#[cfg(test)]
867#[path = "subst_tests.rs"]
868mod tests;