Skip to main content

rustyfi_pdf/
fonts.rs

1//! Font configuration discovery and resolution: reads a small,
2//! plain-JSON font configuration and turns it into a [`TtfFontStore`] via
3//! [`TtfFontStore::load`].
4//!
5//! # Compatibility with SATySFi's own `fonts.satysfi-hash`
6//!
7//! SATySFi keys font selection through two files under
8//! `<runtime>/dist/hash/`: `fonts.satysfi-hash` maps a font *abbrev* to a
9//! font file, and `default-font.satysfi-hash` maps a *script* to `{
10//! font-name = abbrev; ratio; rising }`. The filenames and directory layout
11//! are upstream's — a font package installs into this tree.
12//!
13//! `default-font` is plain JSON. `fonts` is Yojson: each entry is wrapped in
14//! a variant that no JSON parser accepts, which `yojson_to_json` strips
15//! before the schema below parses with `serde_json`. Both path spellings are
16//! accepted: `src` and upstream's `src-dist`, which resolve against
17//! different bases — see `RawFontEntry`'s field docs.
18//!
19//! # `fonts.satysfi-hash`
20//!
21//! A JSON object mapping an arbitrary *abbrev* name to a font source:
22//!
23//! ```json
24//! { "lmroman":  { "src": "dist/fonts/lmroman10-regular.otf" },
25//!   "ipaexm":   { "src": "dist/fonts/ipaexm.ttf" },
26//!   "somettc":  { "src": "dist/fonts/foo.ttc", "index": 0 } }
27//! ```
28//!
29//! `src` is resolved relative to the *font root* (the directory under which
30//! `dist/hash/fonts.satysfi-hash` was found) when relative, or used as-is
31//! when absolute. `index`, present only for a TrueType Collection (`.ttc`)
32//! member, mirrors v0.0.6's `FontAccess.Collection` — but only `index: 0`
33//! can actually be *loaded* today: [`TtfFontStore::load`] always parses face
34//! 0, so a non-zero index is accepted by the schema (forward compatibility)
35//! but rejected with [`FontConfigError::UnsupportedCollectionIndex`] at
36//! [`FontRegistry::build_store`] time — never silently loading the wrong
37//! face.
38//!
39//! # `default-font.satysfi-hash` (port-specific; not upstream's schema)
40//!
41//! Upstream's `default-font.satysfi-hash` maps *script* to a font selection
42//! (with `ratio`/`rising` scaling knobs). This port defines a smaller,
43//! unrelated schema at the same filename, seeding the three faces the
44//! base-14 provider already has (`FontKey(0/1/2)` = regular/bold/oblique,
45//! `base14.rs`), with the per-script scheme as an optional `scripts` block
46//! (see `RawScripts`):
47//!
48//! ```json
49//! { "regular": "lmroman", "bold": "lmroman-bold", "oblique": "lmroman-oblique" }
50//! ```
51//!
52//! Only `regular` is required; `bold`/`oblique` default to `regular`'s own
53//! abbrev when omitted, so [`FontRegistry::build_store`] calls
54//! [`TtfFontStore::load`] with `None` for the missing slot(s) rather than
55//! loading the regular face's bytes a second time under a different slot.
56//!
57//! An optional `"math"` key names the abbrev `get-initial-context`
58//! seeds `Context::math_font` with, e.g. `{ "regular": "Junicode", "math":
59//! "lmmath" }` — `download-fonts.sh` wires this to the bundled Latin Modern
60//! Math (upstream SATySFi's own default math font), falling back to
61//! `dejavu-math` only if LM Math is unavailable. Absent ⇒ `math_font` stays
62//! at `Context::initial`'s own seed (`FontKey(0)`, the regular text face).
63//!
64//! # Discovery and error handling
65//!
66//! See [`FontRegistry::discover`] for the full precedence chain. A missing
67//! configuration is `Ok(None)` (base-14 path); once *something* is found,
68//! further problems (malformed JSON, an undefined default-face abbrev, a
69//! font file that fails to load) are real errors (`Err`), deliberately never
70//! a silent fall-back to base-14.
71
72use std::collections::BTreeMap;
73use std::path::{Path, PathBuf};
74
75use serde::Deserialize;
76
77use rustyfi_backend::FontKey;
78
79use crate::ttf::{FontError, TtfFontStore};
80
81/// One font source, as resolved from `fonts.satysfi-hash` (`src` already
82/// joined against the font root) or synthesized from a `--font`/
83/// `--font-bold`/`--font-oblique` CLI flag.
84///
85/// Mirrors v0.0.6's `FontAccess` (`loadFont.ml:43-47`); see the module docs
86/// for why `Collection`'s index is currently limited to `0`.
87#[derive(Debug, Clone, PartialEq, Eq)]
88pub enum FontSource {
89    /// A plain font file (TrueType, OpenType, ...).
90    Single(PathBuf),
91    /// One face of a TrueType Collection (`.ttc`), by index.
92    Collection(PathBuf, u32),
93}
94
95/// A resolved `abbrev -> font file` mapping plus the three seeded default
96/// faces (`FontKey(0/1/2)` = regular/bold/oblique), ready to become a
97/// [`TtfFontStore`] via [`FontRegistry::build_store`].
98///
99/// The full abbrev map is kept (not just the three resolved paths) so
100/// `set-font` can resolve an *arbitrary* abbrev to a `FontKey` beyond the
101/// three seeded slots.
102#[derive(Debug, Clone)]
103pub struct FontRegistry {
104    faces: BTreeMap<String, FontSource>,
105    /// `[regular, bold, oblique]` abbrevs. `bold`/`oblique` equal `regular`
106    /// verbatim when the config left them unset, which `build_store` uses
107    /// to decide when to pass `None` (rather than resolving and loading the
108    /// same file a second time) to `TtfFontStore::load`.
109    default_faces: [String; 3],
110    /// Per-script default `(abbrev, ratio, rising)`, indexed by
111    /// `Script`'s discriminant — from `default-font.satysfi-hash`'s
112    /// optional `scripts` block. `None` per-slot when that script wasn't
113    /// named (or the whole block is absent, or the registry came from
114    /// `--font`/CLI flags, which have no `scripts` concept at all).
115    script_fonts: [Option<(String, f64, f64)>; 4],
116    /// The abbrev named by `default-font.satysfi-hash`'s optional `"math"`
117    /// key — the font `get-initial-context` seeds
118    /// `Context::math_font` with. `None` when absent (or the registry came
119    /// from `--font`/CLI flags, which have no `"math"` concept).
120    math_font: Option<String>,
121}
122
123/// Config-less one-off face selection (`--font`/`--font-bold`/
124/// `--font-oblique`): the highest-precedence source in
125/// [`FontRegistry::discover`], bypassing `fonts.satysfi-hash` entirely.
126#[derive(Debug, Clone, Default)]
127pub struct FontFlags {
128    pub regular: Option<PathBuf>,
129    pub bold: Option<PathBuf>,
130    pub oblique: Option<PathBuf>,
131}
132
133impl FontFlags {
134    fn is_empty(&self) -> bool {
135        self.regular.is_none() && self.bold.is_none() && self.oblique.is_none()
136    }
137}
138
139/// Errors from discovering or resolving a font configuration. Distinct from
140/// [`FontError`] (which is about a font *file* failing to load/parse): this
141/// type is about the *configuration* pointing at a bad state in the first
142/// place (malformed JSON, a dangling abbrev reference, an unsupported TTC
143/// index, ...). [`FontError`]s that occur while actually loading a resolved
144/// path are wrapped via `Font`.
145#[derive(Debug, thiserror::Error)]
146pub enum FontConfigError {
147    #[error("failed to read font config {path}: {source}")]
148    Io {
149        path: PathBuf,
150        #[source]
151        source: std::io::Error,
152    },
153    #[error("failed to parse font config {path} as JSON: {source}")]
154    Json {
155        path: PathBuf,
156        #[source]
157        source: serde_json::Error,
158    },
159    #[error(
160        "{path}: default-face {face:?} names abbrev {abbrev:?}, which is not \
161         defined in fonts.satysfi-hash"
162    )]
163    UnknownAbbrev {
164        path: PathBuf,
165        face: &'static str,
166        abbrev: String,
167    },
168    #[error(
169        "font abbrev {abbrev:?} names a font collection at TTC index {index}; \
170         only index 0 can be loaded in this port (TtfFontStore does not yet \
171         support selecting a non-zero face of a collection)"
172    )]
173    UnsupportedCollectionIndex { abbrev: String, index: u32 },
174    #[error("--font-bold/--font-oblique require --font (no regular face given)")]
175    RegularRequired,
176    #[error(transparent)]
177    Font(#[from] FontError),
178}
179
180/// Rewrite Yojson variant syntax into the JSON `serde_json` accepts.
181///
182/// SATySFi's own `fonts.satysfi-hash` wraps each entry in a variant —
183/// `<Single: {"src": "…"}>` as upstream writes it, `<"Collection":{"src-dist":
184/// "…","index":1}>` as a package installer does — which no JSON parser takes.
185/// The tag carries no information the fields do not (`index` is what
186/// distinguishes a collection member), so the wrapper is simply removed,
187/// leaving the object behind.
188///
189/// Anything inside a string literal is left alone: a font path may legitimately
190/// contain `<` or `>`.
191fn yojson_to_json(text: &str) -> String {
192    let mut out = String::with_capacity(text.len());
193    let mut chars = text.char_indices().peekable();
194    let mut in_string = false;
195    let mut escaped = false;
196    let mut depth: usize = 0;
197
198    while let Some((i, c)) = chars.next() {
199        if in_string {
200            out.push(c);
201            if escaped {
202                escaped = false;
203            } else if c == '\\' {
204                escaped = true;
205            } else if c == '"' {
206                in_string = false;
207            }
208            continue;
209        }
210        match c {
211            '"' => {
212                in_string = true;
213                out.push(c);
214            }
215            '<' => {
216                // `<Tag:` or `<"Tag":` — skip up to and including the colon,
217                // and remember to drop the matching `>`.
218                let rest = &text[i + 1..];
219                match rest.find(':') {
220                    Some(colon)
221                        if rest[..colon]
222                            .chars()
223                            .all(|t| t.is_alphanumeric() || t == '"' || t == '_' || t == '-' || t.is_whitespace()) =>
224                    {
225                        for _ in 0..=colon {
226                            chars.next();
227                        }
228                        depth += 1;
229                    }
230                    // Not a variant tag; keep the character as it stands.
231                    _ => out.push(c),
232                }
233            }
234            '>' if depth > 0 => depth -= 1,
235            _ => out.push(c),
236        }
237    }
238    out
239}
240
241/// Raw shape of one entry in `fonts.satysfi-hash` (see module docs). `index`
242/// present ⇒ a `.ttc` member.
243#[derive(Debug, Deserialize)]
244struct RawFontEntry {
245    /// A path relative to the FONT ROOT (or absolute) — this port's spelling,
246    /// and the one upstream files written by hand tend to use.
247    #[serde(default)]
248    src: Option<PathBuf>,
249    /// Upstream's other spelling: relative to `dist/fonts/`, which is where a
250    /// font package installs its faces. `(font "X.otf" …)` in a `Satyristes`
251    /// lands at `dist/fonts/<package>/X.otf`, and the hash file names it
252    /// `<package>/X.otf` — so this base is `dist/fonts`, not `dist`.
253    #[serde(default, rename = "src-dist")]
254    src_dist: Option<PathBuf>,
255    #[serde(default)]
256    index: Option<u32>,
257}
258
259impl RawFontEntry {
260    /// The font file, resolved against `root`. `Path::join` discards `root`
261    /// when the value is absolute, so both cases fall out of one call.
262    fn resolve(&self, root: &std::path::Path) -> Option<PathBuf> {
263        if let Some(src) = &self.src {
264            return Some(root.join(src));
265        }
266        self.src_dist
267            .as_ref()
268            .map(|rel| root.join("dist").join("fonts").join(rel))
269    }
270}
271
272/// Raw shape of `default-font.satysfi-hash` (port-specific; see module
273/// docs). Only `regular` is required. `scripts` is the optional
274/// per-script default scheme mirroring upstream `setDefaultFont.ml`'s
275/// shape — absent entirely ⇒ every script defaults to `(FontKey(0), 1.0,
276/// 0.0)`, i.e. today's single-font behavior
277/// (`TtfFontStore::script_default` returns `None`).
278#[derive(Debug, Deserialize)]
279struct RawDefaultFace {
280    regular: String,
281    #[serde(default)]
282    bold: Option<String>,
283    #[serde(default)]
284    oblique: Option<String>,
285    #[serde(default)]
286    scripts: Option<RawScripts>,
287    /// The abbrev `get-initial-context` seeds
288    /// `Context::math_font` with. Optional — absent means no math default is
289    /// configured, and `Context::math_font` stays at `Context::initial`'s
290    /// `FontKey(0)` seed.
291    #[serde(default)]
292    math: Option<String>,
293}
294
295/// One entry of the `scripts` block: `{ "font-name": abbrev, "ratio": f64,
296/// "rising": f64 }`.
297#[derive(Debug, Deserialize)]
298struct RawScriptFont {
299    #[serde(rename = "font-name")]
300    font_name: String,
301    ratio: f64,
302    rising: f64,
303}
304
305/// The four script slots `default-font.satysfi-hash`'s `scripts` block may
306/// name, each optional (an absent script keeps the `(FontKey(0), 1.0, 0.0)`
307/// default). Field names mirror upstream's own script identifiers
308/// (`han-ideographic`/`kana`/`latin`/`other-script`).
309#[derive(Debug, Deserialize, Default)]
310struct RawScripts {
311    #[serde(rename = "han-ideographic")]
312    han_ideographic: Option<RawScriptFont>,
313    kana: Option<RawScriptFont>,
314    latin: Option<RawScriptFont>,
315    #[serde(rename = "other-script")]
316    other_script: Option<RawScriptFont>,
317}
318
319/// Load `path`'s bytes into `files`, or reuse an already-loaded file's index
320/// when `path` canonicalizes to one already in `file_by_path` (dedup:
321/// two abbrevs naming the same physical font file share one embedded copy).
322fn load_or_dedup(
323    files: &mut Vec<Vec<u8>>,
324    file_by_path: &mut BTreeMap<PathBuf, usize>,
325    path: &Path,
326) -> Result<usize, FontConfigError> {
327    let canon = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
328    if let Some(&idx) = file_by_path.get(&canon) {
329        return Ok(idx);
330    }
331    let bytes = TtfFontStore::read_and_validate(path)?;
332    let idx = files.len();
333    files.push(bytes);
334    file_by_path.insert(canon, idx);
335    Ok(idx)
336}
337
338/// Synthetic abbrev names for the config-less `--font`/`--font-bold`/
339/// `--font-oblique` flags (never emitted by `fonts.satysfi-hash` parsing,
340/// so they cannot collide with a real config's abbrevs).
341const CLI_REGULAR: &str = "<--font>";
342const CLI_BOLD: &str = "<--font-bold>";
343const CLI_OBLIQUE: &str = "<--font-oblique>";
344
345impl FontRegistry {
346    /// Resolve font configuration, highest precedence first:
347    /// `flags` (`--font`(+bold/oblique)) > `font_dir` > `lib_root`. See the
348    /// module docs for the `Ok(None)`-vs-`Err` rule.
349    pub fn discover(
350        lib_root: Option<&Path>,
351        font_dir: Option<&Path>,
352        flags: &FontFlags,
353    ) -> Result<Option<FontRegistry>, FontConfigError> {
354        if !flags.is_empty() {
355            return Self::from_flags(flags).map(Some);
356        }
357
358        let Some(root) = font_dir.or(lib_root) else {
359            return Ok(None);
360        };
361
362        let hash_dir = root.join("dist").join("hash");
363        let fonts_path = hash_dir.join("fonts.satysfi-hash");
364        let fonts_bytes = match std::fs::read(&fonts_path) {
365            Ok(bytes) => bytes,
366            Err(source) if source.kind() == std::io::ErrorKind::NotFound => return Ok(None),
367            Err(source) => {
368                return Err(FontConfigError::Io {
369                    path: fonts_path,
370                    source,
371                })
372            }
373        };
374        // Upstream writes Yojson, so the bytes may not be JSON yet.
375        let fonts_text = String::from_utf8_lossy(&fonts_bytes);
376        let raw: BTreeMap<String, RawFontEntry> =
377            serde_json::from_str(&yojson_to_json(&fonts_text)).map_err(|source| {
378                FontConfigError::Json {
379                    path: fonts_path.clone(),
380                    source,
381                }
382            })?;
383        let faces: BTreeMap<String, FontSource> = raw
384            .into_iter()
385            .filter_map(|(abbrev, entry)| {
386                // An entry naming no file at all is skipped rather than
387                // resolved to the root directory itself.
388                let resolved = entry.resolve(root)?;
389                let source = match entry.index {
390                    Some(index) => FontSource::Collection(resolved, index),
391                    None => FontSource::Single(resolved),
392                };
393                Some((abbrev, source))
394            })
395            .collect();
396
397        let default_path = hash_dir.join("default-font.satysfi-hash");
398        let default_bytes = std::fs::read(&default_path).map_err(|source| FontConfigError::Io {
399            path: default_path.clone(),
400            source,
401        })?;
402        let raw_default: RawDefaultFace =
403            serde_json::from_slice(&default_bytes).map_err(|source| FontConfigError::Json {
404                path: default_path.clone(),
405                source,
406            })?;
407
408        let regular = raw_default.regular;
409        let bold = raw_default.bold.unwrap_or_else(|| regular.clone());
410        let oblique = raw_default.oblique.unwrap_or_else(|| regular.clone());
411
412        for (face, abbrev) in [
413            ("regular", &regular),
414            ("bold", &bold),
415            ("oblique", &oblique),
416        ] {
417            if !faces.contains_key(abbrev) {
418                return Err(FontConfigError::UnknownAbbrev {
419                    path: default_path,
420                    face,
421                    abbrev: abbrev.clone(),
422                });
423            }
424        }
425
426        // Validate + resolve the optional `scripts` block. Same
427        // doctrine as the three default faces above — a script naming an
428        // abbrev absent from `faces` is a broken config (`Err`), never a
429        // silent fall-back.
430        let mut script_fonts: [Option<(String, f64, f64)>; 4] = [None, None, None, None];
431        if let Some(scripts) = raw_default.scripts {
432            for (idx, name, entry) in [
433                (0usize, "han-ideographic", scripts.han_ideographic),
434                (1, "kana", scripts.kana),
435                (2, "latin", scripts.latin),
436                (3, "other-script", scripts.other_script),
437            ] {
438                let Some(entry) = entry else { continue };
439                if !faces.contains_key(&entry.font_name) {
440                    return Err(FontConfigError::UnknownAbbrev {
441                        path: default_path,
442                        face: name,
443                        abbrev: entry.font_name,
444                    });
445                }
446                script_fonts[idx] = Some((entry.font_name, entry.ratio, entry.rising));
447            }
448        }
449
450        // Same doctrine for the optional `"math"` abbrev — a config
451        // that NAMES a math default but gets the abbrev wrong is a broken
452        // config (`Err`), never a silent "no math default configured".
453        if let Some(abbrev) = &raw_default.math {
454            if !faces.contains_key(abbrev) {
455                return Err(FontConfigError::UnknownAbbrev {
456                    path: default_path,
457                    face: "math",
458                    abbrev: abbrev.clone(),
459                });
460            }
461        }
462
463        Ok(Some(FontRegistry {
464            faces,
465            default_faces: [regular, bold, oblique],
466            script_fonts,
467            math_font: raw_default.math,
468        }))
469    }
470
471    /// Synthesize a registry directly from `--font`/`--font-bold`/
472    /// `--font-oblique`, with no `fonts.satysfi-hash` involved at all.
473    fn from_flags(flags: &FontFlags) -> Result<FontRegistry, FontConfigError> {
474        let Some(regular) = &flags.regular else {
475            return Err(FontConfigError::RegularRequired);
476        };
477        let mut faces = BTreeMap::new();
478        faces.insert(CLI_REGULAR.to_string(), FontSource::Single(regular.clone()));
479        let bold_abbrev = match &flags.bold {
480            Some(path) => {
481                faces.insert(CLI_BOLD.to_string(), FontSource::Single(path.clone()));
482                CLI_BOLD.to_string()
483            }
484            None => CLI_REGULAR.to_string(),
485        };
486        let oblique_abbrev = match &flags.oblique {
487            Some(path) => {
488                faces.insert(CLI_OBLIQUE.to_string(), FontSource::Single(path.clone()));
489                CLI_OBLIQUE.to_string()
490            }
491            None => CLI_REGULAR.to_string(),
492        };
493        Ok(FontRegistry {
494            faces,
495            default_faces: [CLI_REGULAR.to_string(), bold_abbrev, oblique_abbrev],
496            script_fonts: [None, None, None, None],
497            math_font: None,
498        })
499    }
500
501    /// Resolve every configured abbrev and build an N-slot [`TtfFontStore`]:
502    /// the three seeded default faces occupy `FontKey(0/1/2)`, and
503    /// every OTHER abbrev in `Self::faces` gets its own slot beyond that,
504    /// deduped against every file already loaded (by canonical path) so two
505    /// abbrevs naming the same physical font file share one embedded copy.
506    ///
507    /// **Eager, not lazy.** Upstream (`fontInfo.ml:24-132`) loads a face the
508    /// first time a script/abbrev actually needs it. `TtfFontStore` cannot
509    /// do that without unsafe self-referential storage or a crate like
510    /// `owned-ttf-parser` (see `ttf.rs`'s struct doc on why `Face` is
511    /// reparsed on demand instead of cached) — over a realistic registry
512    /// (~11 files, ~20 MB for the stdja family) eager loading is simpler and
513    /// cheap enough; revisit only if a huge registry appears.
514    pub fn build_store(&self) -> Result<TtfFontStore, FontConfigError> {
515        // Resolve every path FIRST (can fail with `UnsupportedCollectionIndex`
516        // — a pure config check, no I/O) before any file is actually read, so
517        // a bad config abbrev is reported without touching disk at all
518        // (`build_store_rejects_nonzero_collection_index_without_touching_
519        // disk`).
520        let regular_path = self.resolve(&self.default_faces[0])?;
521        let bold_path = if self.default_faces[1] != self.default_faces[0] {
522            Some(self.resolve(&self.default_faces[1])?)
523        } else {
524            None
525        };
526        let oblique_path = if self.default_faces[2] != self.default_faces[0] {
527            Some(self.resolve(&self.default_faces[2])?)
528        } else {
529            None
530        };
531        let mut other_paths: Vec<(String, PathBuf)> = Vec::new();
532        for abbrev in self.faces.keys() {
533            if self.default_faces.contains(abbrev) {
534                continue; // handled below, mapped to FontKey(0/1/2).
535            }
536            other_paths.push((abbrev.clone(), self.resolve(abbrev)?));
537        }
538
539        let mut files: Vec<Vec<u8>> = Vec::new();
540        let mut file_by_path: BTreeMap<PathBuf, usize> = BTreeMap::new();
541
542        // Step 1: the three default slots, in `FontKey(0/1/2)` order —
543        // dedup only regular-vs-{bold,oblique} by ABBREV-NAME equality
544        // (`TtfFontStore::load`'s own bold/oblique-falls-back-to-regular
545        // convention).
546        let regular_idx = load_or_dedup(&mut files, &mut file_by_path, &regular_path)?;
547        let mut slots = vec![regular_idx, regular_idx, regular_idx];
548        if let Some(path) = &bold_path {
549            slots[1] = load_or_dedup(&mut files, &mut file_by_path, path)?;
550        }
551        if let Some(path) = &oblique_path {
552            slots[2] = load_or_dedup(&mut files, &mut file_by_path, path)?;
553        }
554
555        // Step 2: every other configured abbrev gets its own slot — the
556        // exact allocation order is not part of the contract, only that
557        // each distinct abbrev gets a distinct `FontKey` unless it shares a
558        // file with one already loaded.
559        let mut abbrevs: BTreeMap<String, FontKey> = BTreeMap::new();
560        for (abbrev, path) in &other_paths {
561            let idx = load_or_dedup(&mut files, &mut file_by_path, path)?;
562            let key = FontKey(slots.len() as u16);
563            slots.push(idx);
564            abbrevs.insert(abbrev.clone(), key);
565        }
566
567        // Step 3: the three default-face abbrevs resolve to FontKey(0/1/2)
568        // regardless of what slot (if any) step 2 gave a same-named-but-
569        // different-abbrev file — `resolve_font_abbrev("Junicode")` must
570        // agree with `set-font-key 0` when "Junicode" IS the regular face.
571        // `or_insert` (not a plain overwrite): when bold/oblique default to
572        // the SAME abbrev string as regular (the common case — no bold/
573        // oblique configured), all three loop iterations see that one
574        // string, and the FIRST (smallest, i.e. regular's own FontKey(0))
575        // must win, not the last.
576        for (i, abbrev) in self.default_faces.iter().enumerate() {
577            abbrevs.entry(abbrev.clone()).or_insert(FontKey(i as u16));
578        }
579
580        // `scripts` block: resolve each configured abbrev to the FontKey
581        // just allocated for it (always present — `discover` validated
582        // every `scripts` abbrev against `faces` up front).
583        let mut script_defaults: [Option<(FontKey, f64, f64)>; 4] = [None, None, None, None];
584        for (i, entry) in self.script_fonts.iter().enumerate() {
585            if let Some((abbrev, ratio, rising)) = entry {
586                let key = *abbrevs.get(abbrev).unwrap_or_else(|| {
587                    panic!("FontRegistry invariant violated: scripts abbrev {abbrev:?} unresolved")
588                });
589                script_defaults[i] = Some((key, *ratio, *rising));
590            }
591        }
592
593        // Resolve the configured `"math"` abbrev (if any) — same
594        // lookup as the `scripts` block above.
595        let math_default = self.math_font.as_ref().map(|abbrev| {
596            *abbrevs.get(abbrev).unwrap_or_else(|| {
597                panic!("FontRegistry invariant violated: math abbrev {abbrev:?} unresolved")
598            })
599        });
600
601        Ok(TtfFontStore::from_parts(
602            files,
603            slots,
604            abbrevs,
605            script_defaults,
606            math_default,
607        ))
608    }
609
610    /// Resolve `abbrev` to a loadable file path.
611    ///
612    /// Panics if `abbrev` is not a key of `self.faces` — an invariant both
613    /// `discover` (which validates every `default_faces` abbrev against the
614    /// parsed map before returning) and `from_flags` (which always inserts
615    /// an abbrev and its face together) maintain by construction, so this
616    /// is only ever reached with a valid key.
617    fn resolve(&self, abbrev: &str) -> Result<PathBuf, FontConfigError> {
618        match self
619            .faces
620            .get(abbrev)
621            .unwrap_or_else(|| panic!("FontRegistry invariant violated: {abbrev:?} unresolved"))
622        {
623            FontSource::Single(path) => Ok(path.clone()),
624            FontSource::Collection(path, 0) => Ok(path.clone()),
625            FontSource::Collection(_, index) => Err(FontConfigError::UnsupportedCollectionIndex {
626                abbrev: abbrev.to_string(),
627                index: *index,
628            }),
629        }
630    }
631
632    /// Only a test consumer remains (this file's own `#[cfg(test)] mod
633    /// tests`); `cfg(test)`-gated rather than a live `pub(crate)` accessor
634    /// with no non-test caller.
635    #[cfg(test)]
636    pub(crate) fn faces(&self) -> &BTreeMap<String, FontSource> {
637        &self.faces
638    }
639}
640
641#[cfg(test)]
642mod tests {
643    use super::*;
644    use rustyfi_backend::FontMetrics as _;
645    use std::process::Command;
646    use std::sync::atomic::{AtomicU64, Ordering};
647
648    fn tmpdir(tag: &str) -> PathBuf {
649        static COUNTER: AtomicU64 = AtomicU64::new(0);
650        let n = COUNTER.fetch_add(1, Ordering::Relaxed);
651        let dir = std::env::temp_dir().join(format!(
652            "rustyfi-pdf-fonts-test-{tag}-{}-{}-{n}",
653            std::process::id(),
654            std::time::SystemTime::now()
655                .duration_since(std::time::UNIX_EPOCH)
656                .unwrap()
657                .as_nanos(),
658        ));
659        std::fs::create_dir_all(&dir).unwrap();
660        dir
661    }
662
663    fn write_hash_dir(root: &Path, fonts_json: &str, default_json: Option<&str>) {
664        let hash_dir = root.join("dist/hash");
665        std::fs::create_dir_all(&hash_dir).unwrap();
666        std::fs::write(hash_dir.join("fonts.satysfi-hash"), fonts_json).unwrap();
667        if let Some(default_json) = default_json {
668            std::fs::write(hash_dir.join("default-font.satysfi-hash"), default_json).unwrap();
669        }
670    }
671
672    /// Locate a real TrueType file for tests that must call `build_store`
673    /// successfully. Gracefully skipped (not failed) when absent.
674    fn find_regular_font() -> Option<PathBuf> {
675        if let Ok(output) = Command::new("fc-match")
676            .args(["--format=%{file}", "DejaVuSans"])
677            .output()
678        {
679            if output.status.success() {
680                let path = String::from_utf8_lossy(&output.stdout).trim().to_string();
681                if !path.is_empty() && Path::new(&path).is_file() {
682                    return Some(PathBuf::from(path));
683                }
684            }
685        }
686        for candidate in [
687            "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
688            "/usr/share/fonts/dejavu/DejaVuSans.ttf",
689            "/run/current-system/sw/share/fonts/truetype/DejaVuSans.ttf",
690            "/run/current-system/sw/share/X11/fonts/DejaVuSans.ttf",
691        ] {
692            if Path::new(candidate).is_file() {
693                return Some(PathBuf::from(candidate));
694            }
695        }
696        None
697    }
698
699    macro_rules! need_font {
700        () => {
701            match find_regular_font() {
702                Some(path) => path,
703                None => {
704                    eprintln!("skipping: no DejaVuSans-like TrueType font found on this system");
705                    return;
706                }
707            }
708        };
709    }
710
711    #[test]
712    fn discover_returns_none_with_nothing_configured() {
713        let flags = FontFlags::default();
714        assert!(FontRegistry::discover(None, None, &flags)
715            .unwrap()
716            .is_none());
717    }
718
719    #[test]
720    fn discover_returns_none_when_root_has_no_hash_dir() {
721        let dir = tmpdir("no-hash-dir");
722        let flags = FontFlags::default();
723        assert!(
724            FontRegistry::discover(Some(&dir), None, &flags)
725                .unwrap()
726                .is_none(),
727            "an existing root with no dist/hash/fonts.satysfi-hash is 'nothing configured'"
728        );
729        std::fs::remove_dir_all(&dir).ok();
730    }
731
732    #[test]
733    fn discover_parses_single_and_collection_sources() {
734        let dir = tmpdir("parse-sources");
735        write_hash_dir(
736            &dir,
737            r#"{ "lmroman": { "src": "dist/fonts/lmroman.otf" },
738                 "somettc": { "src": "dist/fonts/foo.ttc", "index": 2 } }"#,
739            Some(r#"{ "regular": "lmroman" }"#),
740        );
741        let registry = FontRegistry::discover(Some(&dir), None, &FontFlags::default())
742            .unwrap()
743            .expect("config present");
744        assert_eq!(
745            registry.faces().get("lmroman"),
746            Some(&FontSource::Single(dir.join("dist/fonts/lmroman.otf")))
747        );
748        assert_eq!(
749            registry.faces().get("somettc"),
750            Some(&FontSource::Collection(dir.join("dist/fonts/foo.ttc"), 2))
751        );
752        assert_eq!(registry.default_faces, ["lmroman", "lmroman", "lmroman"]);
753        std::fs::remove_dir_all(&dir).ok();
754    }
755
756    #[test]
757    fn discover_resolves_absolute_src_verbatim() {
758        let dir = tmpdir("abs-src");
759        // Need not exist: discover never reads font file bytes.
760        let abs = dir.join("elsewhere/regular.ttf");
761        write_hash_dir(
762            &dir,
763            &format!(r#"{{ "abbr": {{ "src": {:?} }} }}"#, abs.to_str().unwrap()),
764            Some(r#"{ "regular": "abbr" }"#),
765        );
766        let registry = FontRegistry::discover(Some(&dir), None, &FontFlags::default())
767            .unwrap()
768            .unwrap();
769        assert_eq!(registry.faces().get("abbr"), Some(&FontSource::Single(abs)));
770        std::fs::remove_dir_all(&dir).ok();
771    }
772
773    #[test]
774    fn discover_font_dir_takes_precedence_over_lib_root() {
775        let lib_root = tmpdir("precedence-lib-root");
776        let font_root = tmpdir("precedence-font-root");
777        write_hash_dir(
778            &lib_root,
779            r#"{ "fromlib": { "src": "a.ttf" } }"#,
780            Some(r#"{ "regular": "fromlib" }"#),
781        );
782        write_hash_dir(
783            &font_root,
784            r#"{ "fromfontdir": { "src": "b.ttf" } }"#,
785            Some(r#"{ "regular": "fromfontdir" }"#),
786        );
787        let registry =
788            FontRegistry::discover(Some(&lib_root), Some(&font_root), &FontFlags::default())
789                .unwrap()
790                .unwrap();
791        assert!(registry.faces().contains_key("fromfontdir"));
792        assert!(!registry.faces().contains_key("fromlib"));
793        std::fs::remove_dir_all(&lib_root).ok();
794        std::fs::remove_dir_all(&font_root).ok();
795    }
796
797    #[test]
798    fn default_face_bold_and_oblique_can_diverge_from_regular() {
799        let dir = tmpdir("distinct-faces");
800        write_hash_dir(
801            &dir,
802            r#"{ "reg": { "src": "reg.ttf" },
803                 "b":   { "src": "b.ttf" },
804                 "obl": { "src": "obl.ttf" } }"#,
805            Some(r#"{ "regular": "reg", "bold": "b", "oblique": "obl" }"#),
806        );
807        let registry = FontRegistry::discover(Some(&dir), None, &FontFlags::default())
808            .unwrap()
809            .unwrap();
810        assert_eq!(registry.default_faces, ["reg", "b", "obl"]);
811        std::fs::remove_dir_all(&dir).ok();
812    }
813
814    #[test]
815    fn malformed_json_is_an_error_not_none() {
816        let dir = tmpdir("malformed");
817        write_hash_dir(&dir, "{ not json", None);
818        let err = FontRegistry::discover(Some(&dir), None, &FontFlags::default()).unwrap_err();
819        assert!(matches!(err, FontConfigError::Json { .. }), "{err}");
820        std::fs::remove_dir_all(&dir).ok();
821    }
822
823    #[test]
824    fn missing_default_font_file_is_an_error_once_fonts_hash_exists() {
825        let dir = tmpdir("missing-default");
826        write_hash_dir(&dir, r#"{ "reg": { "src": "reg.ttf" } }"#, None);
827        let err = FontRegistry::discover(Some(&dir), None, &FontFlags::default()).unwrap_err();
828        assert!(matches!(err, FontConfigError::Io { .. }), "{err}");
829        std::fs::remove_dir_all(&dir).ok();
830    }
831
832    #[test]
833    fn unknown_default_abbrev_is_an_error() {
834        let dir = tmpdir("unknown-abbrev");
835        write_hash_dir(
836            &dir,
837            r#"{ "reg": { "src": "reg.ttf" } }"#,
838            Some(r#"{ "regular": "does-not-exist" }"#),
839        );
840        let err = FontRegistry::discover(Some(&dir), None, &FontFlags::default()).unwrap_err();
841        assert!(
842            matches!(err, FontConfigError::UnknownAbbrev { .. }),
843            "{err}"
844        );
845        std::fs::remove_dir_all(&dir).ok();
846    }
847
848    #[test]
849    fn from_flags_without_regular_is_an_error() {
850        let flags = FontFlags {
851            regular: None,
852            bold: Some(PathBuf::from("bold.ttf")),
853            oblique: None,
854        };
855        let err = FontRegistry::discover(None, None, &flags).unwrap_err();
856        assert!(matches!(err, FontConfigError::RegularRequired), "{err}");
857    }
858
859    #[test]
860    fn flags_take_precedence_over_any_directory() {
861        let dir = tmpdir("flags-precedence");
862        write_hash_dir(
863            &dir,
864            r#"{ "fromconfig": { "src": "a.ttf" } }"#,
865            Some(r#"{ "regular": "fromconfig" }"#),
866        );
867        let flags = FontFlags {
868            regular: Some(PathBuf::from("cli-regular.ttf")),
869            bold: None,
870            oblique: None,
871        };
872        let registry = FontRegistry::discover(Some(&dir), None, &flags)
873            .unwrap()
874            .unwrap();
875        assert!(!registry.faces().contains_key("fromconfig"));
876        std::fs::remove_dir_all(&dir).ok();
877    }
878
879    #[test]
880    fn build_store_rejects_nonzero_collection_index_without_touching_disk() {
881        // `resolve` short-circuits on the index check before `TtfFontStore`
882        // ever tries to read a file, so bogus (non-existent) paths are fine
883        // here — this test intentionally does not need a real font.
884        let dir = tmpdir("bad-index");
885        write_hash_dir(
886            &dir,
887            r#"{ "reg": { "src": "reg.ttf" },
888                 "obl": { "src": "obl.ttc", "index": 1 } }"#,
889            Some(r#"{ "regular": "reg", "oblique": "obl" }"#),
890        );
891        let registry = FontRegistry::discover(Some(&dir), None, &FontFlags::default())
892            .unwrap()
893            .unwrap();
894        // `TtfFontStore` (the `Ok` side) is not `Debug`, so match directly
895        // rather than `unwrap_err()` (which requires `T: Debug`).
896        let Err(err) = registry.build_store() else {
897            panic!("expected an UnsupportedCollectionIndex error");
898        };
899        assert!(
900            matches!(
901                err,
902                FontConfigError::UnsupportedCollectionIndex { index: 1, .. }
903            ),
904            "{err}"
905        );
906        std::fs::remove_dir_all(&dir).ok();
907    }
908
909    #[test]
910    fn build_store_loads_a_real_font_end_to_end() {
911        let font_path = need_font!();
912        let dir = tmpdir("real-font");
913        write_hash_dir(
914            &dir,
915            &format!(
916                r#"{{ "reg": {{ "src": {:?} }} }}"#,
917                font_path.to_str().unwrap()
918            ),
919            Some(r#"{ "regular": "reg" }"#),
920        );
921        let registry = FontRegistry::discover(Some(&dir), None, &FontFlags::default())
922            .unwrap()
923            .unwrap();
924        let store = registry.build_store().expect("build_store should succeed");
925        assert_eq!(store.num_files(), 1);
926        let size = rustyfi_backend::Length::pt(12.0);
927        assert!(store
928            .advance(rustyfi_backend::FontKey(0), 'A', size)
929            .is_some());
930        std::fs::remove_dir_all(&dir).ok();
931    }
932
933    #[test]
934    fn build_store_from_cli_flags_end_to_end() {
935        let font_path = need_font!();
936        let flags = FontFlags {
937            regular: Some(font_path),
938            bold: None,
939            oblique: None,
940        };
941        let registry = FontRegistry::discover(None, None, &flags).unwrap().unwrap();
942        let store = registry.build_store().expect("build_store should succeed");
943        assert_eq!(store.num_files(), 1);
944        // bold/oblique fall back to the regular slot (same file, no
945        // duplicate load) exactly like a bare `TtfFontStore::load(p, None, None)`.
946        let size = rustyfi_backend::Length::pt(12.0);
947        assert_eq!(
948            store.advance(rustyfi_backend::FontKey(0), 'A', size),
949            store.advance(rustyfi_backend::FontKey(1), 'A', size)
950        );
951    }
952
953    // N-slot store, abbrev roundtrip, file dedup, `scripts` block.
954
955    /// A second real TrueType file, distinct from `find_regular_font`'s —
956    /// needed to prove a genuinely different abbrev gets its own
957    /// physical-file slot, not just dedup.
958    fn find_second_font() -> Option<PathBuf> {
959        if let Ok(output) = Command::new("fc-match")
960            .args(["--format=%{file}", "DejaVu Sans Mono"])
961            .output()
962        {
963            if output.status.success() {
964                let path = String::from_utf8_lossy(&output.stdout).trim().to_string();
965                if !path.is_empty() && Path::new(&path).is_file() {
966                    return Some(PathBuf::from(path));
967                }
968            }
969        }
970        for candidate in [
971            "/usr/share/fonts/truetype/dejavu/DejaVuSansMono.ttf",
972            "/run/current-system/sw/share/fonts/truetype/DejaVuSansMono.ttf",
973        ] {
974            if Path::new(candidate).is_file() {
975                return Some(PathBuf::from(candidate));
976            }
977        }
978        None
979    }
980
981    macro_rules! need_second_font {
982        () => {
983            match find_second_font() {
984                Some(path) => path,
985                None => {
986                    eprintln!("skipping: no DejaVuSansMono-like TrueType font found");
987                    return;
988                }
989            }
990        };
991    }
992
993    #[test]
994    fn build_store_allocates_extra_slots_and_dedups_shared_files() {
995        let regular = need_font!();
996        let mono = need_second_font!();
997        let dir = tmpdir("extra-abbrevs");
998        write_hash_dir(
999            &dir,
1000            &format!(
1001                r#"{{ "reg": {{ "src": {:?} }},
1002                     "mono": {{ "src": {:?} }},
1003                     "regalias": {{ "src": {:?} }} }}"#,
1004                regular.to_str().unwrap(),
1005                mono.to_str().unwrap(),
1006                regular.to_str().unwrap(),
1007            ),
1008            Some(r#"{ "regular": "reg" }"#),
1009        );
1010        let registry = FontRegistry::discover(Some(&dir), None, &FontFlags::default())
1011            .unwrap()
1012            .unwrap();
1013        let store = registry.build_store().expect("build_store should succeed");
1014
1015        // Two distinct physical files ("reg"/"regalias" share one; "mono" is
1016        // its own), five allocated FontKey slots (0/1/2 default + mono +
1017        // regalias).
1018        assert_eq!(store.num_files(), 2, "reg and regalias must dedup to one file");
1019        assert_eq!(store.num_slots(), 5);
1020
1021        // abbrev_key roundtrip: the three default-face abbrevs resolve to
1022        // FontKey(0/1/2) regardless of iteration order (step 3 override);
1023        // the two "extra" abbrevs get later slots.
1024        assert_eq!(store.abbrev_key("reg"), Some(rustyfi_backend::FontKey(0)));
1025        let mono_key = store.abbrev_key("mono").expect("mono abbrev resolves");
1026        let regalias_key = store.abbrev_key("regalias").expect("regalias abbrev resolves");
1027        assert_ne!(mono_key, rustyfi_backend::FontKey(0));
1028        assert_ne!(regalias_key, rustyfi_backend::FontKey(0));
1029        assert_ne!(mono_key, regalias_key);
1030        assert_eq!(store.abbrev_key("no-such-abbrev"), None);
1031
1032        // File-index dedup: "regalias" backs the SAME physical file as the
1033        // regular slot; "mono" backs a DIFFERENT one.
1034        assert_eq!(store.file_index(regalias_key), store.file_index(rustyfi_backend::FontKey(0)));
1035        assert_ne!(store.file_index(mono_key), store.file_index(rustyfi_backend::FontKey(0)));
1036
1037        std::fs::remove_dir_all(&dir).ok();
1038    }
1039
1040    #[test]
1041    fn scripts_block_parses_and_resolves_default_script_font() {
1042        let regular = need_font!();
1043        let cjk_stand_in = need_second_font!();
1044        let dir = tmpdir("scripts-block");
1045        write_hash_dir(
1046            &dir,
1047            &format!(
1048                r#"{{ "reg": {{ "src": {:?} }}, "cjk": {{ "src": {:?} }} }}"#,
1049                regular.to_str().unwrap(),
1050                cjk_stand_in.to_str().unwrap(),
1051            ),
1052            Some(
1053                r#"{ "regular": "reg",
1054                     "scripts": {
1055                       "han-ideographic": { "font-name": "cjk", "ratio": 0.88, "rising": 0.0 },
1056                       "latin":           { "font-name": "reg", "ratio": 1.0,  "rising": 0.0 }
1057                     } }"#,
1058            ),
1059        );
1060        let registry = FontRegistry::discover(Some(&dir), None, &FontFlags::default())
1061            .unwrap()
1062            .unwrap();
1063        let store = registry.build_store().expect("build_store should succeed");
1064
1065        let cjk_key = store.abbrev_key("cjk").expect("cjk abbrev resolves");
1066        // Script indices: HanIdeographic=0, Kana=1, Latin=2, OtherScript=3
1067        // (`context::Script`'s discriminants).
1068        assert_eq!(store.script_default(0), Some((cjk_key, 0.88, 0.0)));
1069        assert_eq!(
1070            store.script_default(2),
1071            Some((rustyfi_backend::FontKey(0), 1.0, 0.0))
1072        );
1073        // Unconfigured scripts stay `None` (caller falls back to
1074        // `(ctx.font, 1.0, 0.0)`).
1075        assert_eq!(store.script_default(1), None); // Kana
1076        assert_eq!(store.script_default(3), None); // OtherScript
1077
1078        std::fs::remove_dir_all(&dir).ok();
1079    }
1080
1081    #[test]
1082    fn scripts_block_is_absent_by_default() {
1083        let font_path = need_font!();
1084        let dir = tmpdir("no-scripts-block");
1085        write_hash_dir(
1086            &dir,
1087            &format!(r#"{{ "reg": {{ "src": {:?} }} }}"#, font_path.to_str().unwrap()),
1088            Some(r#"{ "regular": "reg" }"#),
1089        );
1090        let registry = FontRegistry::discover(Some(&dir), None, &FontFlags::default())
1091            .unwrap()
1092            .unwrap();
1093        let store = registry.build_store().expect("build_store should succeed");
1094        for script in 0..4 {
1095            assert_eq!(store.script_default(script), None);
1096        }
1097        std::fs::remove_dir_all(&dir).ok();
1098    }
1099
1100    #[test]
1101    fn scripts_block_unknown_abbrev_is_an_error() {
1102        let font_path = need_font!();
1103        let dir = tmpdir("scripts-unknown-abbrev");
1104        write_hash_dir(
1105            &dir,
1106            &format!(r#"{{ "reg": {{ "src": {:?} }} }}"#, font_path.to_str().unwrap()),
1107            Some(
1108                r#"{ "regular": "reg",
1109                     "scripts": { "kana": { "font-name": "nope", "ratio": 1.0, "rising": 0.0 } } }"#,
1110            ),
1111        );
1112        let err = FontRegistry::discover(Some(&dir), None, &FontFlags::default()).unwrap_err();
1113        assert!(matches!(err, FontConfigError::UnknownAbbrev { .. }), "{err}");
1114        std::fs::remove_dir_all(&dir).ok();
1115    }
1116}
1117
1118#[cfg(test)]
1119mod satysfi_compat_tests {
1120    use super::*;
1121
1122    /// Both spellings seen in the wild: upstream's own unquoted tag with a
1123    /// root-relative `src`, and a package installer's quoted tag with a
1124    /// `dist/`-relative `src-dist`.
1125    const UPSTREAM: &str = r#"{
1126  "fonts-noto-emoji:NotoEmoji-Regular" : <Single: {"src": "dist/fonts/fonts-noto-emoji/NotoEmoji-Regular.ttf"}>,
1127  "fonts-theano:TheanoDidot":<"Single":{"src-dist":"fonts-theano/TheanoDidot-Regular.otf"}>,
1128  "somettc":<"Collection":{"src-dist":"x/foo.ttc","index":0}>
1129}"#;
1130
1131    #[test]
1132    fn upstream_variants_become_plain_json() {
1133        let json = yojson_to_json(UPSTREAM);
1134        assert!(!json.contains('<') && !json.contains('>'), "{json}");
1135        let raw: BTreeMap<String, RawFontEntry> =
1136            serde_json::from_str(&json).expect("should parse once the variants are gone");
1137        assert_eq!(raw.len(), 3);
1138        assert_eq!(
1139            raw["fonts-theano:TheanoDidot"].src_dist.as_deref(),
1140            Some(std::path::Path::new("fonts-theano/TheanoDidot-Regular.otf"))
1141        );
1142        assert_eq!(raw["somettc"].index, Some(0));
1143    }
1144
1145    #[test]
1146    fn src_and_src_dist_resolve_from_different_bases() {
1147        let root = std::path::Path::new("/root");
1148        let by_src = RawFontEntry {
1149            src: Some("dist/fonts/a.ttf".into()),
1150            src_dist: None,
1151            index: None,
1152        };
1153        let by_dist = RawFontEntry {
1154            src: None,
1155            src_dist: Some("fonts-theano/b.otf".into()),
1156            index: None,
1157        };
1158        assert_eq!(by_src.resolve(root).unwrap(), root.join("dist/fonts/a.ttf"));
1159        assert_eq!(
1160            by_dist.resolve(root).unwrap(),
1161            root.join("dist/fonts/fonts-theano/b.otf"),
1162            "`src-dist` is relative to dist/fonts/ — where a package installs"
1163        );
1164    }
1165
1166    #[test]
1167    fn an_angle_bracket_inside_a_string_survives() {
1168        // A path may contain `<`; only variant wrappers are removed.
1169        let json = yojson_to_json(r#"{"a":<Single: {"src":"we<ird>.ttf"}>}"#);
1170        assert_eq!(json, r#"{"a": {"src":"we<ird>.ttf"}}"#);
1171    }
1172}