Skip to main content

rustyfi_pdf/
ttf.rs

1//! A [`FontMetrics`] provider backed by real TrueType/OpenType font files.
2//! Loads up to three faces — regular, bold, oblique —
3//! mapped onto the existing `FontKey(0/1/2)` convention from `base14`, and
4//! measures through `ttf-parser`'s `cmap`/`hmtx`/`hhea`/`OS/2` tables instead
5//! of hardcoded AFM widths.
6
7use std::collections::BTreeMap;
8use std::fs;
9use std::path::{Path, PathBuf};
10
11use rustyfi_backend::{
12    FontKey, FontMetrics, Length, MathConstants, MathCorner, MathVariantGlyph, Script,
13    VertVariantPolicy,
14};
15use ttf_parser::gsub::{SingleSubstitution, SubstitutionSubtable};
16use ttf_parser::Face;
17
18#[derive(Debug, thiserror::Error)]
19pub enum FontError {
20    #[error("failed to read font file {path}: {source}")]
21    Io {
22        path: PathBuf,
23        #[source]
24        source: std::io::Error,
25    },
26    #[error("failed to parse font {path}: {source}")]
27    Parse {
28        path: PathBuf,
29        #[source]
30        source: ttf_parser::FaceParsingError,
31    },
32}
33
34/// Owns the raw bytes of every distinct font file that was loaded, plus a
35/// `FontKey(0/1/2) -> file` lookup that lets bold/oblique fall back to the
36/// regular face without duplicating its bytes in memory (and, in the PDF
37/// writer, without embedding the same font file twice).
38///
39/// Face ownership: rather than caching a `ttf_parser::Face<'a>` alongside the
40/// `Vec<u8>` it borrows from (which needs either `unsafe` self-referential
41/// storage or a crate like `owned-ttf-parser`), each accessor reparses a
42/// `Face` on demand from the stored bytes. `Face::parse` only walks the sfnt
43/// table directory and a few small required tables (`head`, `hhea`, `maxp`,
44/// `OS/2`, ...); it does not touch glyph outlines, so its cost does not scale
45/// with document size and is cheap at the milestone's scale (a handful of
46/// pages, one parse per glyph lookup). This keeps `TtfFontStore` a plain,
47/// safe struct.
48pub struct TtfFontStore {
49    files: Vec<Vec<u8>>,
50    /// `FontKey(0)=regular, 1=bold, 2=oblique, 3.. = registry abbrevs` ->
51    /// index into `files`. Missing bold/oblique share the regular slot
52    /// (index 0). `FontRegistry::build_store` allocates one slot per
53    /// configured abbrev beyond the three seeded defaults; `slots[0..3]`
54    /// always stay regular/bold/oblique, so a bare `TtfFontStore::load`
55    /// store has exactly 3.
56    slots: Vec<usize>,
57    /// Registry abbrev ("ipaexm", "Junicode-b", ...) -> the `FontKey`
58    /// allocated for it by `FontRegistry::build_store`. Empty for a bare
59    /// `TtfFontStore::load` (no registry involved) — `resolve_font_abbrev`
60    /// then returns `None` and callers fall back to the 3-face name
61    /// heuristic (`resolve_font_abbrev` free fn, rustyfi-lang).
62    abbrevs: BTreeMap<String, FontKey>,
63    /// The configured default `(font, ratio, rising)` per `Script`
64    /// (`context::Script` as `usize`), from `default-font.satysfi-hash`'s
65    /// optional `scripts` block. `None` per-slot (the default) means
66    /// "no script scheme configured" — callers overlay `(ctx.font, 1.0,
67    /// 0.0)` themselves, keeping today's single-font behavior.
68    script_defaults: [Option<(FontKey, f64, f64)>; 4],
69    /// The `FontKey` allocated for `default-font.satysfi-hash`'s optional
70    /// `"math"` abbrev. `None` for a bare `TtfFontStore::load` or
71    /// a registry with no `"math"` entry — `get-initial-context` then leaves
72    /// `Context::math_font` at its `Context::initial` seed.
73    math_default: Option<FontKey>,
74}
75
76impl TtfFontStore {
77    /// Load up to three faces. `bold`/`oblique` fall back to `regular` when
78    /// not given.
79    pub fn load(
80        regular: &Path,
81        bold: Option<&Path>,
82        oblique: Option<&Path>,
83    ) -> Result<Self, FontError> {
84        let regular = Self::read_and_validate(regular)?;
85        let bold = bold.map(Self::read_and_validate).transpose()?;
86        let oblique = oblique.map(Self::read_and_validate).transpose()?;
87        // Already validated above, with the real paths in any error; the
88        // re-parse `from_bytes` does is cheap (the sfnt table directory only)
89        // and cannot fail here.
90        Self::from_bytes(regular, bold, oblique, "<font file>")
91    }
92
93    /// [`Self::load`] for bytes that never came from a path.
94    ///
95    /// The WebAssembly build is why this exists: a browser has no filesystem,
96    /// so a font supplied by the user arrives as bytes from a file picker.
97    /// `label` names the source in a [`FontError::Parse`] — a file name, a URL,
98    /// whatever the caller can show the user — since there is no path to
99    /// report.
100    ///
101    /// `bold`/`oblique` fall back to `regular` when absent, exactly as
102    /// [`Self::load`] does, and the bytes are shared rather than duplicated.
103    pub fn from_bytes(
104        regular: Vec<u8>,
105        bold: Option<Vec<u8>>,
106        oblique: Option<Vec<u8>>,
107        label: &str,
108    ) -> Result<Self, FontError> {
109        let validate = |bytes: &[u8]| {
110            // Fail at construction rather than at the first metrics call, the
111            // same contract `read_and_validate` holds to.
112            Face::parse(bytes, 0)
113                .map(|_| ())
114                .map_err(|source| FontError::Parse {
115                    path: PathBuf::from(label),
116                    source,
117                })
118        };
119
120        validate(&regular)?;
121        let mut files = vec![regular];
122        let mut slots = vec![0usize, 0, 0];
123        for (slot, bytes) in [(1, bold), (2, oblique)] {
124            if let Some(bytes) = bytes {
125                validate(&bytes)?;
126                files.push(bytes);
127                slots[slot] = files.len() - 1;
128            }
129        }
130
131        Ok(TtfFontStore {
132            files,
133            slots,
134            abbrevs: BTreeMap::new(),
135            script_defaults: [None; 4],
136            math_default: None,
137        })
138    }
139
140    /// Builder used only by [`crate::fonts::FontRegistry::build_store`]:
141    /// construct a store with the three default slots already
142    /// loaded (via [`Self::load`]) plus every other configured abbrev's
143    /// file appended as its own slot (deduped by canonical path against
144    /// files already loaded), and the abbrev -> `FontKey` map that
145    /// `resolve_font_abbrev` consults.
146    pub(crate) fn from_parts(
147        files: Vec<Vec<u8>>,
148        slots: Vec<usize>,
149        abbrevs: BTreeMap<String, FontKey>,
150        script_defaults: [Option<(FontKey, f64, f64)>; 4],
151        math_default: Option<FontKey>,
152    ) -> Self {
153        TtfFontStore {
154            files,
155            slots,
156            abbrevs,
157            script_defaults,
158            math_default,
159        }
160    }
161
162    pub(crate) fn read_and_validate(path: &Path) -> Result<Vec<u8>, FontError> {
163        let bytes = fs::read(path).map_err(|source| FontError::Io {
164            path: path.to_path_buf(),
165            source,
166        })?;
167        // Fail fast at load time rather than the first metrics/embedding call.
168        Face::parse(&bytes, 0).map_err(|source| FontError::Parse {
169            path: path.to_path_buf(),
170            source,
171        })?;
172        Ok(bytes)
173    }
174
175    /// Clamp an arbitrary `FontKey` onto the known slots, mirroring
176    /// `base14::Base14Metrics`'s treatment of out-of-range keys.
177    fn key_slot(&self, font: FontKey) -> usize {
178        (font.0 as usize).min(self.slots.len() - 1)
179    }
180
181    /// The physical-file index backing `font` (after bold/oblique fallback).
182    /// Used by the CID embedder to dedup: two `FontKey`s that resolve to the
183    /// same file are embedded (and their Type0 font object shared) once.
184    ///
185    /// `pub` rather than `pub(crate)` because `rustyfi-html` needs it too, to
186    /// key a run's CSS `font-family` stack by physical file the same way — a
187    /// one-way dependency, since `rustyfi-pdf` does not depend back on it.
188    pub fn file_index(&self, font: FontKey) -> usize {
189        self.slots[self.key_slot(font)]
190    }
191
192    pub fn num_files(&self) -> usize {
193        self.files.len()
194    }
195
196    /// A human name for physical file `file_index`, for diagnostics only —
197    /// the configured abbrev where there is one (what the author actually
198    /// wrote in `fonts.satysfi-hash`, so it is the name they can act on),
199    /// else the default slot's role, else the resource name.
200    ///
201    /// Reverse scan for the same reason [`FontMetrics::font_abbrev`] is one:
202    /// the map holds one row per configured font, and this runs once per file
203    /// per document.
204    pub(crate) fn file_label(&self, file_index: usize) -> String {
205        if let Some((abbrev, _)) = self
206            .abbrevs
207            .iter()
208            .find(|(_, k)| self.file_index(**k) == file_index)
209        {
210            return abbrev.clone();
211        }
212        match self.slots.iter().position(|&f| f == file_index) {
213            Some(0) => "regular".to_string(),
214            Some(1) => "bold".to_string(),
215            Some(2) => "oblique".to_string(),
216            _ => format!("file {file_index}"),
217        }
218    }
219
220    /// Number of allocated `FontKey` slots (3 for a bare `load`; 3 + one
221    /// per extra configured abbrev for a registry-built store).
222    ///
223    /// Only a test consumer remains (`fonts.rs`'s in-src unit tests) since
224    /// the font registry landed, so this is `cfg(test)`-gated rather than a live
225    /// `pub(crate)` accessor with no non-test caller.
226    #[cfg(test)]
227    pub(crate) fn num_slots(&self) -> usize {
228        self.slots.len()
229    }
230
231    /// Raw bytes of a physical file, for `FontFile2` embedding.
232    pub fn file_bytes(&self, file_index: usize) -> &[u8] {
233        &self.files[file_index]
234    }
235
236    /// The typographic family name a physical file declares in its `name`
237    /// table (English where the font offers it, since that is what a CSS
238    /// `font-family` has to match), or `None` for a file with no usable
239    /// family record.
240    ///
241    /// `pub` for `rustyfi-html`'s reflow backend, which NAMES fonts rather
242    /// than embedding them: a reflowed document is explicitly not
243    /// metric-faithful, so paying several megabytes of base64 to pin the
244    /// exact face would buy nothing it wants and cost the reader everything
245    /// (`fonts::reflow_font_stack`).
246    pub fn file_family_name(&self, file_index: usize) -> Option<String> {
247        let face = Face::parse(self.files.get(file_index)?, 0).ok()?;
248        face.names()
249            .into_iter()
250            .filter(|n| {
251                // 16 = typographic/preferred family, 1 = legacy family. The
252                // typographic name is the one that groups an optical or
253                // weight family correctly, so prefer it when present.
254                (n.name_id == 16 || n.name_id == 1) && n.is_unicode()
255            })
256            .min_by_key(|n| if n.name_id == 16 { 0 } else { 1 })
257            .and_then(|n| n.to_string())
258            .filter(|s| !s.trim().is_empty())
259    }
260
261    /// Resolve a registry abbrev ("ipaexm", "Junicode-b", ...) to its
262    /// allocated `FontKey`, or `None` if the store has no such abbrev
263    /// (either it wasn't configured, or the store came from a bare `load`).
264    pub fn abbrev_key(&self, abbrev: &str) -> Option<FontKey> {
265        self.abbrevs.get(abbrev).copied()
266    }
267
268    /// See the `script_defaults` field doc.
269    pub fn script_default(&self, script: usize) -> Option<(FontKey, f64, f64)> {
270        self.script_defaults.get(script).copied().flatten()
271    }
272
273    /// See the `math_default` field doc.
274    pub(crate) fn math_font_default(&self) -> Option<FontKey> {
275        self.math_default
276    }
277
278    /// Parse the face for a given font key. See the struct doc for why this
279    /// reparses on every call instead of caching a `Face`.
280    pub fn face(&self, font: FontKey) -> Option<Face<'_>> {
281        self.face_by_file(self.file_index(font))
282    }
283
284    pub(crate) fn face_by_file(&self, file_index: usize) -> Option<Face<'_>> {
285        Face::parse(self.files.get(file_index)?, 0).ok()
286    }
287}
288
289impl FontMetrics for TtfFontStore {
290    fn advance(&self, font: FontKey, c: char, size: Length) -> Option<Length> {
291        let face = self.face(font)?;
292        let gid = face.glyph_index(c)?;
293        let advance = face.glyph_hor_advance(gid)? as f64;
294        let units_per_em = face.units_per_em() as f64;
295        Some(size * (advance / units_per_em))
296    }
297
298    fn ascender(&self, font: FontKey, size: Length) -> Length {
299        let Some(face) = self.face(font) else {
300            return Length::ZERO;
301        };
302        // `Face::ascender` already prefers the OS/2 typographic ascender over
303        // hhea's when the face's `fsSelection` USE_TYPO_METRICS bit is set
304        // (falling back to hhea, then to OS/2's Win ascender otherwise) —
305        // the same resolution order FreeType uses. We rely on that rather
306        // than re-deriving it, since it is exactly "prefer typographic
307        // OS/2 values when present".
308        let units_per_em = face.units_per_em() as f64;
309        size * (face.ascender() as f64 / units_per_em)
310    }
311
312    fn descender(&self, font: FontKey, size: Length) -> Length {
313        let Some(face) = self.face(font) else {
314            return Length::ZERO;
315        };
316        let units_per_em = face.units_per_em() as f64;
317        // ttf-parser's descender (hhea/typographic OS/2, same resolution
318        // order as `ascender`) is negative — depth below the baseline —
319        // while `FontMetrics::descender` wants a positive depth.
320        size * (-(face.descender() as f64) / units_per_em)
321    }
322
323    fn glyph_vextent(&self, font: FontKey, c: char, size: Length) -> Option<(Length, Length)> {
324        let face = self.face(font)?;
325        let gid = face.glyph_index(c)?;
326        // Actual glyph ink box — SATySFi's `get_glyph_metrics` (fontFormat.ml):
327        // `hgt = ymax`, `dpt = ymin`. A blank glyph (space) has no bbox and
328        // contributes nothing to the run's extent.
329        let bbox = face.glyph_bounding_box(gid)?;
330        let units_per_em = face.units_per_em() as f64;
331        let height = size * (bbox.y_max as f64 / units_per_em);
332        let depth = size * (-(bbox.y_min as f64) / units_per_em);
333        Some((height, depth))
334    }
335
336    // ---- OpenType MATH table ---------
337    //
338    // Read through ttf-parser 0.25.1's `tables::math`:
339    // `Face::tables().math -> Option<math::Table>` with `.constants` /
340    // `.glyph_info` / `.variants`. Every `Constants` accessor except the two
341    // percent-scale-downs returns a `MathValue { value: i16, device }`
342    // struct, not a plain integer — hence the `mv.value` field access in
343    // `r(...)` below. `GlyphInfo.italic_corrections`/`.kern_infos` are
344    // fields, not methods, each with a `.get(GlyphId)` accessor;
345    // `KernInfo`'s four corners are `Option<Kern>` fields.
346    //
347    // `math_vertical_variant`, below, consumes `Variants` itself:
348    // `Variants { min_connector_overlap: u16, vertical_constructions,
349    // horizontal_constructions }`; `GlyphConstruction { assembly:
350    // Option<GlyphAssembly>, variants: LazyArray16<GlyphVariant> }`;
351    // `GlyphVariant { variant_glyph: GlyphId, advance_measurement: u16 }`.
352
353    fn math_constants(&self, font: FontKey) -> Option<MathConstants> {
354        let face = self.face(font)?;
355        let c = face.tables().math?.constants?;
356        let upem = face.units_per_em() as f64;
357        let r = |mv: ttf_parser::math::MathValue| mv.value as f64 / upem;
358        Some(MathConstants {
359            axis_height: r(c.axis_height()),
360            superscript_bottom_min: r(c.superscript_bottom_min()),
361            superscript_shift_up: r(c.superscript_shift_up()),
362            superscript_shift_up_cramped: r(c.superscript_shift_up_cramped()),
363            superscript_baseline_drop_max: r(c.superscript_baseline_drop_max()),
364            subscript_top_max: r(c.subscript_top_max()),
365            subscript_shift_down: r(c.subscript_shift_down()),
366            subscript_baseline_drop_min: r(c.subscript_baseline_drop_min()),
367            script_scale_down: c.script_percent_scale_down() as f64 / 100.0,
368            script_script_scale_down: c.script_script_percent_scale_down() as f64 / 100.0,
369            space_after_script: r(c.space_after_script()),
370            sub_superscript_gap_min: r(c.sub_superscript_gap_min()),
371            fraction_rule_thickness: r(c.fraction_rule_thickness()),
372            fraction_numer_shift_up: r(c.fraction_numerator_display_style_shift_up()),
373            fraction_numer_gap_min: r(c.fraction_num_display_style_gap_min()),
374            fraction_denom_shift_down: r(c.fraction_denominator_display_style_shift_down()),
375            fraction_denom_gap_min: r(c.fraction_denom_display_style_gap_min()),
376            radical_extra_ascender: r(c.radical_extra_ascender()),
377            radical_rule_thickness: r(c.radical_rule_thickness()),
378            radical_vertical_gap: r(c.radical_display_style_vertical_gap()),
379            upper_limit_gap_min: r(c.upper_limit_gap_min()),
380            upper_limit_baseline_rise_min: r(c.upper_limit_baseline_rise_min()),
381            lower_limit_gap_min: r(c.lower_limit_gap_min()),
382            lower_limit_baseline_drop_min: r(c.lower_limit_baseline_drop_min()),
383        })
384    }
385
386    fn italic_correction(&self, font: FontKey, c: char, size: Length) -> Option<Length> {
387        let face = self.face(font)?;
388        let gid = face.glyph_index(c)?;
389        let mv = face.tables().math?.glyph_info?.italic_corrections?.get(gid)?;
390        Some(size * (mv.value as f64 / face.units_per_em() as f64))
391    }
392
393    fn math_kern(
394        &self,
395        font: FontKey,
396        c: char,
397        size: Length,
398        corner: MathCorner,
399        corr: Length,
400    ) -> Option<Length> {
401        let face = self.face(font)?;
402        let gid = face.glyph_index(c)?;
403        let ki = face.tables().math?.glyph_info?.kern_infos?.get(gid)?;
404        let kern = match corner {
405            MathCorner::TopRight => ki.top_right,
406            MathCorner::TopLeft => ki.top_left,
407            MathCorner::BottomRight => ki.bottom_right,
408            MathCorner::BottomLeft => ki.bottom_left,
409        }?;
410        let upem = face.units_per_em() as f64;
411        let corr_du = (corr.0 / size.0) * upem;
412        let n = kern.count();
413        let mut idx = n; // default = last kern (kfinal)
414        for i in 0..n {
415            if corr_du < kern.height(i)?.value as f64 {
416                idx = i;
417                break;
418            }
419        }
420        Some(size * (kern.kern(idx)?.value as f64 / upem))
421    }
422
423    /// `ssty` (Math Script Style): the GSUB feature a math font uses to swap in
424    /// purpose-drawn exponent/index forms — upstream's
425    /// `FontFormat.get_math_script_variant` (`fontFormat.ml:2216-2241`).
426    ///
427    /// Two divergences from upstream's fold, neither reachable in the math
428    /// fonts this port ships or tests against:
429    ///
430    ///   * upstream reaches `ssty` through a SCRIPT and its default langsys
431    ///     (`fontFormat.ml:2185-2194`); this scans the feature LIST by tag, so
432    ///     a font whose `ssty` differs per script would diverge;
433    ///   * an `Alternate` substitution takes the FIRST alternate — upstream's
434    ///     `gidorgto :: _` verbatim, where OpenType would index it by script
435    ///     LEVEL. Matching upstream is the point.
436    ///
437    /// Upstream substitutes `ssty` BEFORE looking for a `MathVariants` vertical
438    /// variant (`fontInfo.ml:379-401`); this port applies it in
439    /// `push_char_glyph` only, so a big operator inside a script keeps its
440    /// unsubstituted vertical variant. The two coverages are disjoint in the
441    /// fonts here, so the orders agree.
442    fn math_script_variant(
443        &self,
444        font: FontKey,
445        c: char,
446        size: Length,
447    ) -> Option<MathVariantGlyph> {
448        let face = self.face(font)?;
449        let gid = face.glyph_index(c)?;
450        let gsub = face.tables().gsub?;
451        let ssty = ttf_parser::Tag::from_bytes(b"ssty");
452        let mut sub: Option<ttf_parser::GlyphId> = None;
453        'outer: for fi in 0..gsub.features.len() {
454            let feature = gsub.features.get(fi)?;
455            if feature.tag != ssty {
456                continue;
457            }
458            for li in 0..feature.lookup_indices.len() {
459                let lookup = gsub.lookups.get(feature.lookup_indices.get(li)?)?;
460                for st in lookup.subtables.into_iter::<SubstitutionSubtable>() {
461                    match st {
462                        SubstitutionSubtable::Single(s) => {
463                            let idx = s.coverage().get(gid);
464                            match (s, idx) {
465                                (SingleSubstitution::Format1 { delta, .. }, Some(_)) => {
466                                    sub = Some(ttf_parser::GlyphId(
467                                        (gid.0 as i32 + delta as i32) as u16,
468                                    ));
469                                }
470                                (SingleSubstitution::Format2 { substitutes, .. }, Some(i)) => {
471                                    sub = substitutes.get(i);
472                                }
473                                _ => continue,
474                            }
475                        }
476                        SubstitutionSubtable::Alternate(a) => {
477                            let Some(i) = a.coverage.get(gid) else {
478                                continue;
479                            };
480                            sub = a.alternate_sets.get(i).and_then(|s| s.alternates.get(0));
481                        }
482                        _ => continue,
483                    }
484                    if sub.is_some() {
485                        break 'outer;
486                    }
487                }
488            }
489        }
490        let vgid = sub?;
491        if vgid == gid {
492            return None;
493        }
494        let upem = face.units_per_em() as f64;
495        let advance = face.glyph_hor_advance(vgid)? as f64;
496        // Same y-truncation as `math_glyph_vextent` / `math_vertical_variant`:
497        // upstream's `truncate_negative`/`truncate_positive`
498        // (`fontFormat.ml:2257-2264`), so a glyph wholly on one side of the
499        // baseline reports zero on the other.
500        let bbox = face.glyph_bounding_box(vgid)?;
501        Some(MathVariantGlyph {
502            gid: vgid.0,
503            advance: size * (advance / upem),
504            height: size * (bbox.y_max.max(0) as f64 / upem),
505            depth: size * ((-(bbox.y_min.min(0) as i32)) as f64 / upem),
506        })
507    }
508
509    /// Pick a vertically-grown MATH variant (`MathVariants`) of `c` per
510    /// `policy` and report its real per-glyph ink metrics at `size`.
511    /// Assembly-only constructions (`variants.len() == 0`, big enough
512    /// stretchy delimiters in some fonts) return `None` here — they are
513    /// `math_vertical_assembly`'s job.
514    fn math_vertical_variant(
515        &self,
516        font: FontKey,
517        c: char,
518        size: Length,
519        policy: VertVariantPolicy,
520    ) -> Option<MathVariantGlyph> {
521        let face = self.face(font)?;
522        let gid = face.glyph_index(c)?;
523        let construction = face
524            .tables()
525            .math?
526            .variants?
527            .vertical_constructions
528            .get(gid)?;
529        let n = construction.variants.len();
530        if n == 0 {
531            return None;
532        }
533        let upem = face.units_per_em() as f64;
534        let rec = match policy {
535            VertVariantPolicy::BigOp => {
536                construction.variants.get(if n >= 2 { 1 } else { 0 })?
537            }
538            VertVariantPolicy::AtLeast(min) => {
539                let min_du = (min.0 / size.0) * upem;
540                let mut chosen = construction.variants.get(n - 1)?; // largest fallback
541                for i in 0..n {
542                    let v = construction.variants.get(i)?;
543                    if v.advance_measurement as f64 >= min_du {
544                        chosen = v;
545                        break;
546                    }
547                }
548                chosen
549            }
550        };
551        let vgid = rec.variant_glyph;
552        let advance = face.glyph_hor_advance(vgid)? as f64;
553        let bbox = face.glyph_bounding_box(vgid)?;
554        Some(MathVariantGlyph {
555            gid: vgid.0,
556            advance: size * (advance / upem),
557            height: size * (bbox.y_max.max(0) as f64 / upem),
558            depth: size * ((-(bbox.y_min.min(0) as i32)) as f64 / upem),
559        })
560    }
561
562    /// Stretch `c` (via OpenType MATH `GlyphAssembly`) beyond the largest discrete
563    /// `MathVariants` record by stacking the assembly's `GlyphPart`s
564    /// vertically, repeating `extender` parts to reach `target`. Faithful to
565    /// the OpenType "assembling glyphs" recipe (and `math.ml`'s
566    /// `MathVariants`/`GlyphConstruction` reader): parts are listed
567    /// bottom-to-top; every non-extender part is placed exactly once, and all
568    /// extender parts are repeated the same number of times `r` (the smallest
569    /// `r` whose stacked extent, at the minimum `min_connector_overlap`
570    /// overlap, covers `target`). Each connection overlaps by exactly
571    /// `min_connector_overlap` design units (the smallest legal overlap, which
572    /// yields the LONGEST assembly for a given part count — so the result
573    /// always covers `target`). Returns `(gid, dy, advance)` per placed part
574    /// with `dy` the y-up box-local baseline offset (bottom part at `dy = 0`,
575    /// each next part raised by the previous part's advance minus the
576    /// overlap) and `advance` the part's `full_advance` scaled to `size`.
577    fn math_vertical_assembly(
578        &self,
579        font: FontKey,
580        c: char,
581        size: Length,
582        target: Length,
583    ) -> Option<Vec<(u16, Length, Length)>> {
584        let face = self.face(font)?;
585        let gid = face.glyph_index(c)?;
586        let variants = face.tables().math?.variants?;
587        let construction = variants.vertical_constructions.get(gid)?;
588        let assembly = construction.assembly?;
589        let parts: Vec<ttf_parser::math::GlyphPart> = assembly.parts.into_iter().collect();
590        if parts.is_empty() {
591            return None;
592        }
593        let upem = face.units_per_em() as f64;
594        let overlap_du = variants.min_connector_overlap as f64;
595        // The extent of an ordered part list, in design units, at the minimum
596        // (`min_connector_overlap`) overlap on every connection — i.e. the
597        // longest the list can stack. `sum(full_advance) - overlap *
598        // (count - 1)`.
599        let extent_du = |seq: &[&ttf_parser::math::GlyphPart]| -> f64 {
600            if seq.is_empty() {
601                return 0.0;
602            }
603            let sum: f64 = seq.iter().map(|p| p.full_advance as f64).sum();
604            sum - overlap_du * (seq.len() as f64 - 1.0)
605        };
606        let target_du = (target.0 / size.0) * upem;
607        // Grow the extender repeat count `r` until the stack covers `target`
608        // (or a hard cap keeps a pathological/degenerate assembly from
609        // looping forever — 256 repeats is far past any real delimiter).
610        let build = |r: usize| -> Vec<&ttf_parser::math::GlyphPart> {
611            let mut seq: Vec<&ttf_parser::math::GlyphPart> = Vec::new();
612            for p in &parts {
613                let times = if p.part_flags.extender() { r } else { 1 };
614                for _ in 0..times {
615                    seq.push(p);
616                }
617            }
618            seq
619        };
620        let has_extender = parts.iter().any(|p| p.part_flags.extender());
621        let mut r = if has_extender { 1 } else { 0 };
622        let mut seq = build(r);
623        while has_extender && extent_du(&seq) < target_du && r < 256 {
624            r += 1;
625            seq = build(r);
626        }
627        if seq.is_empty() {
628            return None;
629        }
630        let overlap_scaled = size * (overlap_du / upem);
631        let mut out = Vec::with_capacity(seq.len());
632        let mut cursor = Length::ZERO;
633        for p in &seq {
634            let advance = size * (p.full_advance as f64 / upem);
635            out.push((p.glyph_id.0, cursor, advance));
636            cursor += advance - overlap_scaled;
637        }
638        Some(out)
639    }
640
641    // ---- Registry-abbrev resolution --------------------------------------------------------------------
642
643    fn resolve_font_abbrev(&self, abbrev: &str) -> Option<FontKey> {
644        self.abbrev_key(abbrev)
645    }
646
647    /// Reverse scan of `abbrevs`. Linear, but that map holds one row per
648    /// configured font (tens at most) and `get-font` is called a handful of
649    /// times per document, so a second index would cost more than it saves.
650    fn font_abbrev(&self, key: FontKey) -> Option<String> {
651        self.abbrevs
652            .iter()
653            .find(|(_, k)| **k == key)
654            .map(|(abbrev, _)| abbrev.clone())
655    }
656
657    fn default_script_font(&self, script: Script) -> Option<(FontKey, f64, f64)> {
658        self.script_default(script as usize)
659    }
660
661    fn default_math_font(&self) -> Option<FontKey> {
662        self.math_font_default()
663    }
664}
665
666#[cfg(test)]
667mod tests {
668    use super::*;
669
670    /// `expect_err` is unavailable here — `TtfFontStore` is deliberately not
671    /// `Debug` (it owns whole font files), so the error is taken by `match`.
672    fn expect_rejected(result: Result<TtfFontStore, FontError>) -> FontError {
673        match result {
674            Ok(_) => panic!("these bytes are not a font, but were accepted"),
675            Err(e) => e,
676        }
677    }
678
679    /// Validation happens at construction, not at the first metrics call, and
680    /// the caller's `label` is what identifies the source — there is no path
681    /// to report when the bytes came from a browser file picker.
682    #[test]
683    fn from_bytes_rejects_something_that_is_not_a_font() {
684        let err = expect_rejected(TtfFontStore::from_bytes(
685            b"not a font at all".to_vec(),
686            None,
687            None,
688            "upload.ttf",
689        ));
690        assert!(err.to_string().contains("upload.ttf"), "{err}");
691        assert!(matches!(err, FontError::Parse { .. }), "{err}");
692    }
693
694    /// A bad BOLD face must not slip through behind a good regular one: every
695    /// slot handed in is validated, not just the first.
696    #[test]
697    fn from_bytes_validates_every_face_it_is_given() {
698        // Only meaningful with a real regular face; without one the first slot
699        // already rejects and the test would prove nothing.
700        let Some(regular) = system_font() else {
701            return;
702        };
703        let err = expect_rejected(TtfFontStore::from_bytes(
704            regular,
705            Some(b"not a font".to_vec()),
706            None,
707            "bold.ttf",
708        ));
709        assert!(matches!(err, FontError::Parse { .. }), "{err}");
710    }
711
712    /// A real font from the system, when one is installed. Returns `None`
713    /// rather than failing: which faces exist varies by machine, and a font
714    /// test must not be the reason an unrelated change looks broken.
715    fn system_font() -> Option<Vec<u8>> {
716        [
717            "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
718            "/usr/share/fonts/dejavu/DejaVuSans.ttf",
719            "/usr/share/fonts/TTF/DejaVuSans.ttf",
720            "/usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf",
721        ]
722        .iter()
723        .find_map(|path| std::fs::read(path).ok())
724    }
725}