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 its `@font-face` set by physical file the same way — a one-way
187    /// 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    /// Number of allocated `FontKey` slots (3 for a bare `load`; 3 + one
197    /// per extra configured abbrev for a registry-built store).
198    ///
199    /// Only a test consumer remains (`fonts.rs`'s in-src unit tests) since
200    /// the font registry landed, so this is `cfg(test)`-gated rather than a live
201    /// `pub(crate)` accessor with no non-test caller.
202    #[cfg(test)]
203    pub(crate) fn num_slots(&self) -> usize {
204        self.slots.len()
205    }
206
207    /// Raw bytes of a physical file, for `FontFile2` embedding.
208    pub fn file_bytes(&self, file_index: usize) -> &[u8] {
209        &self.files[file_index]
210    }
211
212    /// The typographic family name a physical file declares in its `name`
213    /// table (English where the font offers it, since that is what a CSS
214    /// `font-family` has to match), or `None` for a file with no usable
215    /// family record.
216    ///
217    /// `pub` for `rustyfi-html`'s reflow backend, which NAMES fonts rather
218    /// than embedding them: a reflowed document is explicitly not
219    /// metric-faithful, so paying several megabytes of base64 to pin the
220    /// exact face would buy nothing it wants and cost the reader everything
221    /// (`fonts::reflow_font_stack`). The faithful backend still embeds.
222    pub fn file_family_name(&self, file_index: usize) -> Option<String> {
223        let face = Face::parse(self.files.get(file_index)?, 0).ok()?;
224        face.names()
225            .into_iter()
226            .filter(|n| {
227                // 16 = typographic/preferred family, 1 = legacy family. The
228                // typographic name is the one that groups an optical or
229                // weight family correctly, so prefer it when present.
230                (n.name_id == 16 || n.name_id == 1) && n.is_unicode()
231            })
232            .min_by_key(|n| if n.name_id == 16 { 0 } else { 1 })
233            .and_then(|n| n.to_string())
234            .filter(|s| !s.trim().is_empty())
235    }
236
237    /// Resolve a registry abbrev ("ipaexm", "Junicode-b", ...) to its
238    /// allocated `FontKey`, or `None` if the store has no such abbrev
239    /// (either it wasn't configured, or the store came from a bare `load`).
240    pub fn abbrev_key(&self, abbrev: &str) -> Option<FontKey> {
241        self.abbrevs.get(abbrev).copied()
242    }
243
244    /// See the `script_defaults` field doc.
245    pub fn script_default(&self, script: usize) -> Option<(FontKey, f64, f64)> {
246        self.script_defaults.get(script).copied().flatten()
247    }
248
249    /// See the `math_default` field doc.
250    pub(crate) fn math_font_default(&self) -> Option<FontKey> {
251        self.math_default
252    }
253
254    /// Parse the face for a given font key. See the struct doc for why this
255    /// reparses on every call instead of caching a `Face`.
256    pub fn face(&self, font: FontKey) -> Option<Face<'_>> {
257        self.face_by_file(self.file_index(font))
258    }
259
260    pub(crate) fn face_by_file(&self, file_index: usize) -> Option<Face<'_>> {
261        Face::parse(self.files.get(file_index)?, 0).ok()
262    }
263}
264
265impl FontMetrics for TtfFontStore {
266    fn advance(&self, font: FontKey, c: char, size: Length) -> Option<Length> {
267        let face = self.face(font)?;
268        let gid = face.glyph_index(c)?;
269        let advance = face.glyph_hor_advance(gid)? as f64;
270        let units_per_em = face.units_per_em() as f64;
271        Some(size * (advance / units_per_em))
272    }
273
274    fn ascender(&self, font: FontKey, size: Length) -> Length {
275        let Some(face) = self.face(font) else {
276            return Length::ZERO;
277        };
278        // `Face::ascender` already prefers the OS/2 typographic ascender over
279        // hhea's when the face's `fsSelection` USE_TYPO_METRICS bit is set
280        // (falling back to hhea, then to OS/2's Win ascender otherwise) —
281        // the same resolution order FreeType uses. We rely on that rather
282        // than re-deriving it, since it is exactly "prefer typographic
283        // OS/2 values when present".
284        let units_per_em = face.units_per_em() as f64;
285        size * (face.ascender() as f64 / units_per_em)
286    }
287
288    fn descender(&self, font: FontKey, size: Length) -> Length {
289        let Some(face) = self.face(font) else {
290            return Length::ZERO;
291        };
292        let units_per_em = face.units_per_em() as f64;
293        // ttf-parser's descender (hhea/typographic OS/2, same resolution
294        // order as `ascender`) is negative — depth below the baseline —
295        // while `FontMetrics::descender` wants a positive depth.
296        size * (-(face.descender() as f64) / units_per_em)
297    }
298
299    fn glyph_vextent(&self, font: FontKey, c: char, size: Length) -> Option<(Length, Length)> {
300        let face = self.face(font)?;
301        let gid = face.glyph_index(c)?;
302        // Actual glyph ink box — SATySFi's `get_glyph_metrics` (fontFormat.ml):
303        // `hgt = ymax`, `dpt = ymin`. A blank glyph (space) has no bbox and
304        // contributes nothing to the run's extent.
305        let bbox = face.glyph_bounding_box(gid)?;
306        let units_per_em = face.units_per_em() as f64;
307        let height = size * (bbox.y_max as f64 / units_per_em);
308        let depth = size * (-(bbox.y_min as f64) / units_per_em);
309        Some((height, depth))
310    }
311
312    // ---- OpenType MATH table ---------
313    //
314    // Read through ttf-parser 0.25.1's `tables::math`:
315    // `Face::tables().math -> Option<math::Table>` with `.constants` /
316    // `.glyph_info` / `.variants`. Every `Constants` accessor except the two
317    // percent-scale-downs returns a `MathValue { value: i16, device }`
318    // struct, not a plain integer — hence the `mv.value` field access in
319    // `r(...)` below. `GlyphInfo.italic_corrections`/`.kern_infos` are
320    // fields, not methods, each with a `.get(GlyphId)` accessor;
321    // `KernInfo`'s four corners are `Option<Kern>` fields.
322    //
323    // `math_vertical_variant`, below, consumes `Variants` itself:
324    // `Variants { min_connector_overlap: u16, vertical_constructions,
325    // horizontal_constructions }`; `GlyphConstruction { assembly:
326    // Option<GlyphAssembly>, variants: LazyArray16<GlyphVariant> }`;
327    // `GlyphVariant { variant_glyph: GlyphId, advance_measurement: u16 }`.
328
329    fn math_constants(&self, font: FontKey) -> Option<MathConstants> {
330        let face = self.face(font)?;
331        let c = face.tables().math?.constants?;
332        let upem = face.units_per_em() as f64;
333        let r = |mv: ttf_parser::math::MathValue| mv.value as f64 / upem;
334        Some(MathConstants {
335            axis_height: r(c.axis_height()),
336            superscript_bottom_min: r(c.superscript_bottom_min()),
337            superscript_shift_up: r(c.superscript_shift_up()),
338            superscript_shift_up_cramped: r(c.superscript_shift_up_cramped()),
339            superscript_baseline_drop_max: r(c.superscript_baseline_drop_max()),
340            subscript_top_max: r(c.subscript_top_max()),
341            subscript_shift_down: r(c.subscript_shift_down()),
342            subscript_baseline_drop_min: r(c.subscript_baseline_drop_min()),
343            script_scale_down: c.script_percent_scale_down() as f64 / 100.0,
344            script_script_scale_down: c.script_script_percent_scale_down() as f64 / 100.0,
345            space_after_script: r(c.space_after_script()),
346            sub_superscript_gap_min: r(c.sub_superscript_gap_min()),
347            fraction_rule_thickness: r(c.fraction_rule_thickness()),
348            fraction_numer_shift_up: r(c.fraction_numerator_display_style_shift_up()),
349            fraction_numer_gap_min: r(c.fraction_num_display_style_gap_min()),
350            fraction_denom_shift_down: r(c.fraction_denominator_display_style_shift_down()),
351            fraction_denom_gap_min: r(c.fraction_denom_display_style_gap_min()),
352            radical_extra_ascender: r(c.radical_extra_ascender()),
353            radical_rule_thickness: r(c.radical_rule_thickness()),
354            radical_vertical_gap: r(c.radical_display_style_vertical_gap()),
355            upper_limit_gap_min: r(c.upper_limit_gap_min()),
356            upper_limit_baseline_rise_min: r(c.upper_limit_baseline_rise_min()),
357            lower_limit_gap_min: r(c.lower_limit_gap_min()),
358            lower_limit_baseline_drop_min: r(c.lower_limit_baseline_drop_min()),
359        })
360    }
361
362    fn italic_correction(&self, font: FontKey, c: char, size: Length) -> Option<Length> {
363        let face = self.face(font)?;
364        let gid = face.glyph_index(c)?;
365        let mv = face.tables().math?.glyph_info?.italic_corrections?.get(gid)?;
366        Some(size * (mv.value as f64 / face.units_per_em() as f64))
367    }
368
369    fn math_kern(
370        &self,
371        font: FontKey,
372        c: char,
373        size: Length,
374        corner: MathCorner,
375        corr: Length,
376    ) -> Option<Length> {
377        let face = self.face(font)?;
378        let gid = face.glyph_index(c)?;
379        let ki = face.tables().math?.glyph_info?.kern_infos?.get(gid)?;
380        let kern = match corner {
381            MathCorner::TopRight => ki.top_right,
382            MathCorner::TopLeft => ki.top_left,
383            MathCorner::BottomRight => ki.bottom_right,
384            MathCorner::BottomLeft => ki.bottom_left,
385        }?;
386        let upem = face.units_per_em() as f64;
387        let corr_du = (corr.0 / size.0) * upem;
388        let n = kern.count();
389        let mut idx = n; // default = last kern (kfinal)
390        for i in 0..n {
391            if corr_du < kern.height(i)?.value as f64 {
392                idx = i;
393                break;
394            }
395        }
396        Some(size * (kern.kern(idx)?.value as f64 / upem))
397    }
398
399    /// `ssty` (Math Script Style): the GSUB feature a math font uses to swap in
400    /// purpose-drawn exponent/index forms — upstream's
401    /// `FontFormat.get_math_script_variant` (`fontFormat.ml:2216-2241`).
402    ///
403    /// Two divergences from upstream's fold, neither reachable in the math
404    /// fonts this port ships or tests against:
405    ///
406    ///   * upstream reaches `ssty` through a SCRIPT and its default langsys
407    ///     (`fontFormat.ml:2185-2194`); this scans the feature LIST by tag, so
408    ///     a font whose `ssty` differs per script would diverge;
409    ///   * an `Alternate` substitution takes the FIRST alternate — upstream's
410    ///     `gidorgto :: _` verbatim, where OpenType would index it by script
411    ///     LEVEL. Matching upstream is the point.
412    ///
413    /// Upstream substitutes `ssty` BEFORE looking for a `MathVariants` vertical
414    /// variant (`fontInfo.ml:379-401`); this port applies it in
415    /// `push_char_glyph` only, so a big operator inside a script keeps its
416    /// unsubstituted vertical variant. The two coverages are disjoint in the
417    /// fonts here, so the orders agree.
418    fn math_script_variant(
419        &self,
420        font: FontKey,
421        c: char,
422        size: Length,
423    ) -> Option<MathVariantGlyph> {
424        let face = self.face(font)?;
425        let gid = face.glyph_index(c)?;
426        let gsub = face.tables().gsub?;
427        let ssty = ttf_parser::Tag::from_bytes(b"ssty");
428        let mut sub: Option<ttf_parser::GlyphId> = None;
429        'outer: for fi in 0..gsub.features.len() {
430            let feature = gsub.features.get(fi)?;
431            if feature.tag != ssty {
432                continue;
433            }
434            for li in 0..feature.lookup_indices.len() {
435                let lookup = gsub.lookups.get(feature.lookup_indices.get(li)?)?;
436                for st in lookup.subtables.into_iter::<SubstitutionSubtable>() {
437                    match st {
438                        SubstitutionSubtable::Single(s) => {
439                            let idx = s.coverage().get(gid);
440                            match (s, idx) {
441                                (SingleSubstitution::Format1 { delta, .. }, Some(_)) => {
442                                    sub = Some(ttf_parser::GlyphId(
443                                        (gid.0 as i32 + delta as i32) as u16,
444                                    ));
445                                }
446                                (SingleSubstitution::Format2 { substitutes, .. }, Some(i)) => {
447                                    sub = substitutes.get(i);
448                                }
449                                _ => continue,
450                            }
451                        }
452                        SubstitutionSubtable::Alternate(a) => {
453                            let Some(i) = a.coverage.get(gid) else {
454                                continue;
455                            };
456                            sub = a.alternate_sets.get(i).and_then(|s| s.alternates.get(0));
457                        }
458                        _ => continue,
459                    }
460                    if sub.is_some() {
461                        break 'outer;
462                    }
463                }
464            }
465        }
466        let vgid = sub?;
467        if vgid == gid {
468            return None;
469        }
470        let upem = face.units_per_em() as f64;
471        let advance = face.glyph_hor_advance(vgid)? as f64;
472        // Same y-truncation as `math_glyph_vextent` / `math_vertical_variant`:
473        // upstream's `truncate_negative`/`truncate_positive`
474        // (`fontFormat.ml:2257-2264`), so a glyph wholly on one side of the
475        // baseline reports zero on the other.
476        let bbox = face.glyph_bounding_box(vgid)?;
477        Some(MathVariantGlyph {
478            gid: vgid.0,
479            advance: size * (advance / upem),
480            height: size * (bbox.y_max.max(0) as f64 / upem),
481            depth: size * ((-(bbox.y_min.min(0) as i32)) as f64 / upem),
482        })
483    }
484
485    /// Pick a vertically-grown MATH variant (`MathVariants`) of `c` per
486    /// `policy` and report its real per-glyph ink metrics at `size`.
487    /// Assembly-only constructions (`variants.len() == 0`, big enough
488    /// stretchy delimiters in some fonts) return `None` here — they are
489    /// `math_vertical_assembly`'s job.
490    fn math_vertical_variant(
491        &self,
492        font: FontKey,
493        c: char,
494        size: Length,
495        policy: VertVariantPolicy,
496    ) -> Option<MathVariantGlyph> {
497        let face = self.face(font)?;
498        let gid = face.glyph_index(c)?;
499        let construction = face
500            .tables()
501            .math?
502            .variants?
503            .vertical_constructions
504            .get(gid)?;
505        let n = construction.variants.len();
506        if n == 0 {
507            return None;
508        }
509        let upem = face.units_per_em() as f64;
510        let rec = match policy {
511            VertVariantPolicy::BigOp => {
512                construction.variants.get(if n >= 2 { 1 } else { 0 })?
513            }
514            VertVariantPolicy::AtLeast(min) => {
515                let min_du = (min.0 / size.0) * upem;
516                let mut chosen = construction.variants.get(n - 1)?; // largest fallback
517                for i in 0..n {
518                    let v = construction.variants.get(i)?;
519                    if v.advance_measurement as f64 >= min_du {
520                        chosen = v;
521                        break;
522                    }
523                }
524                chosen
525            }
526        };
527        let vgid = rec.variant_glyph;
528        let advance = face.glyph_hor_advance(vgid)? as f64;
529        let bbox = face.glyph_bounding_box(vgid)?;
530        Some(MathVariantGlyph {
531            gid: vgid.0,
532            advance: size * (advance / upem),
533            height: size * (bbox.y_max.max(0) as f64 / upem),
534            depth: size * ((-(bbox.y_min.min(0) as i32)) as f64 / upem),
535        })
536    }
537
538    /// Stretch `c` (via OpenType MATH `GlyphAssembly`) beyond the largest discrete
539    /// `MathVariants` record by stacking the assembly's `GlyphPart`s
540    /// vertically, repeating `extender` parts to reach `target`. Faithful to
541    /// the OpenType "assembling glyphs" recipe (and `math.ml`'s
542    /// `MathVariants`/`GlyphConstruction` reader): parts are listed
543    /// bottom-to-top; every non-extender part is placed exactly once, and all
544    /// extender parts are repeated the same number of times `r` (the smallest
545    /// `r` whose stacked extent, at the minimum `min_connector_overlap`
546    /// overlap, covers `target`). Each connection overlaps by exactly
547    /// `min_connector_overlap` design units (the smallest legal overlap, which
548    /// yields the LONGEST assembly for a given part count — so the result
549    /// always covers `target`). Returns `(gid, dy, advance)` per placed part
550    /// with `dy` the y-up box-local baseline offset (bottom part at `dy = 0`,
551    /// each next part raised by the previous part's advance minus the
552    /// overlap) and `advance` the part's `full_advance` scaled to `size`.
553    fn math_vertical_assembly(
554        &self,
555        font: FontKey,
556        c: char,
557        size: Length,
558        target: Length,
559    ) -> Option<Vec<(u16, Length, Length)>> {
560        let face = self.face(font)?;
561        let gid = face.glyph_index(c)?;
562        let variants = face.tables().math?.variants?;
563        let construction = variants.vertical_constructions.get(gid)?;
564        let assembly = construction.assembly?;
565        let parts: Vec<ttf_parser::math::GlyphPart> = assembly.parts.into_iter().collect();
566        if parts.is_empty() {
567            return None;
568        }
569        let upem = face.units_per_em() as f64;
570        let overlap_du = variants.min_connector_overlap as f64;
571        // The extent of an ordered part list, in design units, at the minimum
572        // (`min_connector_overlap`) overlap on every connection — i.e. the
573        // longest the list can stack. `sum(full_advance) - overlap *
574        // (count - 1)`.
575        let extent_du = |seq: &[&ttf_parser::math::GlyphPart]| -> f64 {
576            if seq.is_empty() {
577                return 0.0;
578            }
579            let sum: f64 = seq.iter().map(|p| p.full_advance as f64).sum();
580            sum - overlap_du * (seq.len() as f64 - 1.0)
581        };
582        let target_du = (target.0 / size.0) * upem;
583        // Grow the extender repeat count `r` until the stack covers `target`
584        // (or a hard cap keeps a pathological/degenerate assembly from
585        // looping forever — 256 repeats is far past any real delimiter).
586        let build = |r: usize| -> Vec<&ttf_parser::math::GlyphPart> {
587            let mut seq: Vec<&ttf_parser::math::GlyphPart> = Vec::new();
588            for p in &parts {
589                let times = if p.part_flags.extender() { r } else { 1 };
590                for _ in 0..times {
591                    seq.push(p);
592                }
593            }
594            seq
595        };
596        let has_extender = parts.iter().any(|p| p.part_flags.extender());
597        let mut r = if has_extender { 1 } else { 0 };
598        let mut seq = build(r);
599        while has_extender && extent_du(&seq) < target_du && r < 256 {
600            r += 1;
601            seq = build(r);
602        }
603        if seq.is_empty() {
604            return None;
605        }
606        let overlap_scaled = size * (overlap_du / upem);
607        let mut out = Vec::with_capacity(seq.len());
608        let mut cursor = Length::ZERO;
609        for p in &seq {
610            let advance = size * (p.full_advance as f64 / upem);
611            out.push((p.glyph_id.0, cursor, advance));
612            cursor += advance - overlap_scaled;
613        }
614        Some(out)
615    }
616
617    // ---- Registry-abbrev resolution --------------------------------------------------------------------
618
619    fn resolve_font_abbrev(&self, abbrev: &str) -> Option<FontKey> {
620        self.abbrev_key(abbrev)
621    }
622
623    /// Reverse scan of `abbrevs`. Linear, but that map holds one row per
624    /// configured font (tens at most) and `get-font` is called a handful of
625    /// times per document, so a second index would cost more than it saves.
626    fn font_abbrev(&self, key: FontKey) -> Option<String> {
627        self.abbrevs
628            .iter()
629            .find(|(_, k)| **k == key)
630            .map(|(abbrev, _)| abbrev.clone())
631    }
632
633    fn default_script_font(&self, script: Script) -> Option<(FontKey, f64, f64)> {
634        self.script_default(script as usize)
635    }
636
637    fn default_math_font(&self) -> Option<FontKey> {
638        self.math_font_default()
639    }
640}
641
642#[cfg(test)]
643mod tests {
644    use super::*;
645
646    /// `expect_err` is unavailable here — `TtfFontStore` is deliberately not
647    /// `Debug` (it owns whole font files), so the error is taken by `match`.
648    fn expect_rejected(result: Result<TtfFontStore, FontError>) -> FontError {
649        match result {
650            Ok(_) => panic!("these bytes are not a font, but were accepted"),
651            Err(e) => e,
652        }
653    }
654
655    /// Validation happens at construction, not at the first metrics call, and
656    /// the caller's `label` is what identifies the source — there is no path
657    /// to report when the bytes came from a browser file picker.
658    #[test]
659    fn from_bytes_rejects_something_that_is_not_a_font() {
660        let err = expect_rejected(TtfFontStore::from_bytes(
661            b"not a font at all".to_vec(),
662            None,
663            None,
664            "upload.ttf",
665        ));
666        assert!(err.to_string().contains("upload.ttf"), "{err}");
667        assert!(matches!(err, FontError::Parse { .. }), "{err}");
668    }
669
670    /// A bad BOLD face must not slip through behind a good regular one: every
671    /// slot handed in is validated, not just the first.
672    #[test]
673    fn from_bytes_validates_every_face_it_is_given() {
674        // Only meaningful with a real regular face; without one the first slot
675        // already rejects and the test would prove nothing.
676        let Some(regular) = system_font() else {
677            return;
678        };
679        let err = expect_rejected(TtfFontStore::from_bytes(
680            regular,
681            Some(b"not a font".to_vec()),
682            None,
683            "bold.ttf",
684        ));
685        assert!(matches!(err, FontError::Parse { .. }), "{err}");
686    }
687
688    /// A real font from the system, when one is installed. Returns `None`
689    /// rather than failing: which faces exist varies by machine, and a font
690    /// test must not be the reason an unrelated change looks broken.
691    fn system_font() -> Option<Vec<u8>> {
692        [
693            "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
694            "/usr/share/fonts/dejavu/DejaVuSans.ttf",
695            "/usr/share/fonts/TTF/DejaVuSans.ttf",
696            "/usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf",
697        ]
698        .iter()
699        .find_map(|path| std::fs::read(path).ok())
700    }
701}