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;