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 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}