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(®ular)?;
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}