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;