Oregami
Repositories/oxedyne/fe2o3

oxedyne/fe2o3/fe2o3_austenite/src/fonts.rs

22.3 KiB, 107 runs

created by r1870400018:36318, which is this file's identity for as long as the history lasts, whatever it is later renamed to

download · who wrote it · its history

1//! The document typefaces: Libertinus Serif for prose and New Computer Modern Math for equations, embedded
2//! so a build renders identically anywhere, and any family a document names from the fonts it supplies.
3//!
4//! Libertinus is the maintained descendant of Linux Libertine -- the libre successor to Times, and the
5//! face of the Wikipedia wordmark. New Computer Modern Math is the Computer Modern a reader knows from
6//! mathematics. Both are Typst's own defaults, so a document naming neither sets as the oracle sets it,
7//! and both are carried under permissive licences beside the font files (`LibertinusSerif-OFL.txt`,
8//! `NewCMMath-GUST-LICENSE.txt`).
9//!
10//! 2026-09-23: the maths face moved from Latin Modern Math to New Computer Modern Math, Typst's default,
11//! when Daimond dropped the typst.ts vendor copy that had been its only source.
12
13use crate::vfs;
14
15use oxedyne_fe2o3_core::prelude::*;
16use oxedyne_fe2o3_font::{
17 face::{
18 Face,
19 FaceInfo,
20 },
21 font::Font,
22 set::FontSet,
23};
24
25use std::collections::HashMap;
26use std::path::Path;
27use std::sync::Arc;
28
29// ┌───────────────────────────────────────────────────────────────────────────┐
30// │ NAMED FACE RESOLVER │
31// └───────────────────────────────────────────────────────────────────────────┘
32
33/// One face of a named family: the parsed font, for a heading drawn in it alone, and the file's bytes, so
34/// a reading set can chain the face ahead of its fall-backs (a chain owns its faces, so it parses its own).
35#[derive(Clone)]
36struct Variant {
37 font: Arc<Font>,
38 bytes: Arc<Vec<u8>>,
39}
40
41/// A loaded family: the weight/slant variants found for one named face, each optional since a family may
42/// ship only a Regular. Resolution falls back toward Regular when a requested weight or slant has no file,
43/// rather than failing -- Typst's nearest-variant choice, with no synthesised bold or slant.
44#[derive(Clone, Default)]
45struct Family {
46 regular: Option<Variant>,
47 bold: Option<Variant>,
48 italic: Option<Variant>,
49 bold_italic: Option<Variant>,
50}
51
52impl Family {
53 /// The best available variant for a requested weight and slant: the exact match where present, then a
54 /// near relative, ending at whatever the family does hold. `None` only for a family that loaded nothing.
55 fn pick(&self, bold: bool, italic: bool) -> Option<&Variant> {
56 let order: [&Option<Variant>; 4] = match (bold, italic) {
57 (true, true) => [&self.bold_italic, &self.bold, &self.italic, &self.regular],
58 (true, false) => [&self.bold, &self.bold_italic, &self.regular, &self.italic],
59 (false, true) => [&self.italic, &self.bold_italic, &self.regular, &self.bold],
60 (false, false) => [&self.regular, &self.italic, &self.bold, &self.bold_italic],
61 };
62 order.into_iter().flatten().next()
63 }
64
65 /// Does the family hold the exact variant requested, with no fall-back?
66 fn has_exact(&self, bold: bool, italic: bool) -> bool {
67 self.slot(bold, italic).is_some()
68 }
69
70 fn slot(&self, bold: bool, italic: bool) -> &Option<Variant> {
71 match (bold, italic) {
72 (true, true) => &self.bold_italic,
73 (true, false) => &self.bold,
74 (false, true) => &self.italic,
75 (false, false) => &self.regular,
76 }
77 }
78
79 fn slot_mut(&mut self, bold: bool, italic: bool) -> &mut Option<Variant> {
80 match (bold, italic) {
81 (true, true) => &mut self.bold_italic,
82 (true, false) => &mut self.bold,
83 (false, true) => &mut self.italic,
84 (false, false) => &mut self.regular,
85 }
86 }
87
88 fn is_empty(&self) -> bool {
89 self.regular.is_none() && self.bold.is_none() && self.italic.is_none() && self.bold_italic.is_none()
90 }
91}
92
93/// One font file found under the document's font directory, known by what it declares about itself.
94#[derive(Clone)]
95struct Declared {
96 info: FaceInfo,
97 bytes: Arc<Vec<u8>>,
98}
99
100/// Every font file a document was given -- the files under its font directory, which is where a wasm
101/// project's injected `fonts` are routed -- indexed by the family each declares in its own name table. This
102/// is what a `font: "Name"` is matched against, as Typst matches it: by the designer's family name,
103/// ignoring case, whatever the file is called.
104#[derive(Clone, Default)]
105struct FontLibrary {
106 faces: Vec<Declared>,
107}
108
109impl FontLibrary {
110 /// Reads every `.ttf`/`.otf` file beneath `dir`, at any depth, after the faces the crate embeds -- so a
111 /// document may name an embedded family without supplying it, as Typst's own embedded fonts need no
112 /// file. A file that will not parse, or declares no family, is left out: it cannot answer to any name,
113 /// so it can neither match nor mislead.
114 fn scan(dir: &Path) -> Self {
115 let mut faces: Vec<Declared> = Vec::new();
116 for bytes in EMBEDDED {
117 if let Ok(info) = FaceInfo::read(bytes) {
118 faces.push(Declared { info, bytes: Arc::new(bytes.to_vec()) });
119 }
120 }
121 for path in vfs::list_files(dir) {
122 let ext = path.extension().and_then(|e| e.to_str()).map(|e| e.to_ascii_lowercase());
123 if !matches!(ext.as_deref(), Some("ttf") | Some("otf")) {
124 continue;
125 }
126 let bytes = match vfs::read(&path) {
127 Ok(b) => b,
128 Err(_) => continue,
129 };
130 if let Ok(info) = FaceInfo::read(&bytes) {
131 faces.push(Declared { info, bytes: Arc::new(bytes) });
132 }
133 }
134 Self { faces }
135 }
136
137 /// The distinct families the directory declares, sorted, for a diagnostic that names what was on offer.
138 fn families(&self) -> Vec<String> {
139 let mut out: Vec<String> = self.faces.iter().map(|d| d.info.family.clone()).collect();
140 out.sort_by_key(|f| f.to_lowercase());
141 out.dedup_by(|a, b| same_family(a, b));
142 out
143 }
144
145 /// The family `name` names (ignoring case), each weight/slant slot holding the declared face nearest its
146 /// canonical weight -- 400 for the upright and italic slots, 700 for the bold ones -- with a face of 600
147 /// or heavier counting as bold. Empty when no file declares the family.
148 fn family(&self, name: &str) -> Outcome<Family> {
149 let mut fam = Family::default();
150 let mut best: [Option<u16>; 4] = [None; 4]; // distance to the slot's canonical weight
151 for d in &self.faces {
152 if !same_family(&d.info.family, name) {
153 continue;
154 }
155 let bold = d.info.weight >= 600;
156 let italic = d.info.italic;
157 let target = if bold { 700i32 } else { 400i32 };
158 let dist = (d.info.weight as i32 - target).unsigned_abs() as u16;
159 let i = (bold as usize) * 2 + italic as usize;
160 if best[i].map_or(true, |b| dist < b) {
161 best[i] = Some(dist);
162 let font = Arc::new(res!(Font::new(d.bytes.as_ref().clone())));
163 *fam.slot_mut(bold, italic) = Some(Variant { font, bytes: d.bytes.clone() });
164 }
165 }
166 Ok(fam)
167 }
168}
169
170/// Do two family names name the same family? Typst matches a family ignoring case; this also ignores
171/// white space, since one family is written both ways in the wild -- the New Computer Modern files declare
172/// `NewComputerModern Math` where Typst's own list and every document write `New Computer Modern Math`.
173pub fn same_family(a: &str, b: &str) -> bool {
174 let fold = |s: &str| -> String { s.chars().filter(|c| !c.is_whitespace()).flat_map(|c| c.to_lowercase()).collect() };
175 fold(a) == fold(b)
176}
177
178/// The families the crate embeds, which a document may name without supplying a file: the Libertinus
179/// reading set and the maths face. Normalised to the spelling Typst lists them under.
180pub fn embedded_families() -> Vec<String> {
181 vec![
182 "Libertinus Serif".to_string(),
183 "Libertinus Mono".to_string(),
184 "New Computer Modern Math".to_string(),
185 ]
186}
187
188/// The family a font file declares in its own name table: the name a document's `font:` is matched
189/// against, whatever the file is called. A caller supplying fonts can compare this, through
190/// [`same_family`], with the families a document names before it compiles.
191pub fn declared_family(bytes: &[u8]) -> Outcome<String> {
192 Ok(res!(FaceInfo::read(bytes)).family)
193}
194
195/// Is `name` the family of the embedded reading set? A document naming it asks for what it already has,
196/// so it is resolved to that set rather than looked up, which keeps such a document byte-identical.
197fn is_embedded_family(name: &str) -> bool {
198 same_family(name, "Libertinus Serif")
199}
200
201/// A document's named faces: its heading display faces and its body families, each loaded once from the
202/// document's own font directory. A heading face is named by family (`"Graystroke"`, `"Radley"`) and found
203/// by its `<name>-Regular/Bold/Italic/BoldItalic.{ttf,otf}` files, else by the family its files declare; a
204/// heading name with no file is simply absent, so a theme that names the body family (or a face the tree
205/// does not ship) renders in the body role exactly as before. A body family list (`#set text(font: ...)`)
206/// is required rather than hoped for: [`FaceResolver::require`] fails on a family no file declares, and
207/// builds the reading set every such list is set in.
208#[derive(Clone, Default)]
209pub struct FaceResolver {
210 families: HashMap<String, Family>,
211 bodies: HashMap<Vec<String>, Arc<FontSet>>, // keyed by the list as written; see `body_set`
212 embedded: Option<Arc<FontSet>>, // the embedded set, built only when a scope names it back
213}
214
215impl FaceResolver {
216 /// Loads each named face from `dir`: its `<name>-<Variant>.{ttf,otf}` files where they exist, else every
217 /// file beneath `dir` declaring that family. A name matching neither is left out rather than failing the
218 /// load, so a missing display face degrades to the body role rather than stopping the render. The
219 /// directory is only scanned when a name has no file of its own, so a document naming no face -- or only
220 /// faces its tree ships by filename -- reads exactly the files it read before.
221 pub fn load(dir: &Path, names: &[String]) -> Self {
222 let mut families: HashMap<String, Family> = HashMap::new();
223 let mut library: Option<FontLibrary> = None;
224 for name in names {
225 if name.is_empty() || families.contains_key(name) {
226 continue;
227 }
228 let mut fam = Family::default();
229 load_variant(dir, name, "Regular", &mut fam.regular);
230 load_variant(dir, name, "Bold", &mut fam.bold);
231 load_variant(dir, name, "Italic", &mut fam.italic);
232 load_variant(dir, name, "BoldItalic", &mut fam.bold_italic);
233 // A heading named after the reading set's own family, with no file of its own beside the book, is
234 // set in the reading set's role faces -- the same family -- exactly as before the library existed.
235 if fam.is_empty() && !is_embedded_family(name) {
236 let lib = library.get_or_insert_with(|| FontLibrary::scan(dir));
237 if let Ok(found) = lib.family(name) {
238 fam = found;
239 }
240 }
241 if !fam.is_empty() {
242 families.insert(name.clone(), fam);
243 }
244 }
245 Self { families, bodies: HashMap::new(), embedded: None }
246 }
247
248 /// Typst's missing-family precheck, made a hard error: every family the document names by
249 /// `text(font: ...)` -- `bodies`, each a fallback list -- and every heading face it names itself --
250 /// `headings` -- must be declared by a file under `dir` (or be the embedded Libertinus Serif), else the
251 /// compile fails naming the family and the families that were on offer, rather than setting the text in
252 /// a face the author did not choose. Each body list's reading set is built here, once, for
253 /// [`FaceResolver::body_set`] to hand out.
254 pub fn require(&mut self, dir: &Path, bodies: &[Vec<String>], headings: &[String]) -> Outcome<()> {
255 let mut library: Option<FontLibrary> = None;
256 for list in bodies {
257 // A list naming only the embedded family asks for the embedded set: the one the document already
258 // has at its root, and one a scope returning to it needs built.
259 if list.iter().all(|n| is_embedded_family(n)) {
260 if !list.is_empty() && self.embedded.is_none() {
261 self.embedded = Some(Arc::new(res!(libertinus())));
262 }
263 continue;
264 }
265 if self.bodies.contains_key(list) {
266 continue;
267 }
268 let mut chosen: Vec<Option<Family>> = Vec::with_capacity(list.len());
269 for name in list {
270 if is_embedded_family(name) {
271 chosen.push(None); // the embedded face, at its place in the fall-back order
272 continue;
273 }
274 let fam = match self.families.get(name) {
275 Some(f) => f.clone(),
276 None => {
277 let lib = library.get_or_insert_with(|| FontLibrary::scan(dir));
278 res!(lib.family(name))
279 },
280 };
281 if fam.is_empty() {
282 let lib = library.get_or_insert_with(|| FontLibrary::scan(dir));
283 return Err(missing_family(name, "text(font:)", dir, lib));
284 }
285 self.families.entry(name.clone()).or_insert_with(|| fam.clone());
286 chosen.push(Some(fam));
287 }
288 let set = res!(reading_set(&chosen));
289 self.bodies.insert(list.clone(), Arc::new(set));
290 }
291 // A body set in another family no longer carries the embedded family in its roles, so a heading
292 // named after the embedded family is then loaded as a face of its own.
293 let body_moved = bodies.iter().any(|l| !l.iter().all(|n| is_embedded_family(n)));
294 for name in headings {
295 if name.is_empty() || self.resolves(name) {
296 continue;
297 }
298 if is_embedded_family(name) {
299 if body_moved {
300 let lib = library.get_or_insert_with(|| FontLibrary::scan(dir));
301 let fam = res!(lib.family(name));
302 if !fam.is_empty() {
303 self.families.insert(name.clone(), fam);
304 }
305 }
306 continue;
307 }
308 let lib = library.get_or_insert_with(|| FontLibrary::scan(dir));
309 return Err(missing_family(name, "heading", dir, lib));
310 }
311 Ok(())
312 }
313
314 /// The reading set a body family list is set in, or `None` for an empty list or one naming only the
315 /// embedded family -- the signal to keep the document's own set. A list [`FaceResolver::require`] never saw also
316 /// yields `None`; every caller requires first, so that is a construction error, not a fall-back.
317 pub fn body_set(&self, families: &[String]) -> Option<Arc<FontSet>> {
318 self.bodies.get(families).cloned()
319 }
320
321 /// The reading set a scope's `text(font: ...)` puts in force: the list's own set, or the embedded set
322 /// for a list naming only the embedded family (or clearing the family). `None` only for a list
323 /// [`FaceResolver::require`] never saw -- a construction error the caller reports.
324 pub fn scope_set(&self, families: &[String]) -> Outcome<Option<Arc<FontSet>>> {
325 if families.iter().all(|n| is_embedded_family(n)) {
326 return Ok(Some(match &self.embedded {
327 Some(e) => e.clone(),
328 None => Arc::new(res!(libertinus())),
329 }));
330 }
331 Ok(self.body_set(families))
332 }
333
334 /// The upright regular face for `name` (or its nearest available variant), or `None` when the book
335 /// ships no file for the name -- the signal for the renderer to fall back to a role face. Kept for
336 /// callers that want the base face; a weighted heading uses [`FaceResolver::resolve_weighted`].
337 pub fn resolve(&self, name: &str) -> Option<&Arc<Font>> {
338 self.families.get(name).and_then(|f| f.pick(false, false)).map(|v| &v.font)
339 }
340
341 /// The face for `name` at a requested weight and slant, falling back toward Regular when the exact
342 /// variant has no file. `None` when the name has no file at all.
343 pub fn resolve_weighted(&self, name: &str, bold: bool, italic: bool) -> Option<&Arc<Font>> {
344 self.families.get(name).and_then(|f| f.pick(bold, italic)).map(|v| &v.font)
345 }
346
347 /// Does `name` load at least one file? A name that does but lacks a requested weight/slant still
348 /// resolves (to Regular); this only distinguishes a named-but-absent face from a loaded one.
349 pub fn resolves(&self, name: &str) -> bool {
350 self.families.contains_key(name)
351 }
352
353 /// Does `name` hold the exact weight/slant variant, with no fall-back to Regular? Used to record a note
354 /// when a heading asks for a variant the book does not ship.
355 pub fn has_variant(&self, name: &str, bold: bool, italic: bool) -> bool {
356 self.families.get(name).map_or(false, |f| f.has_exact(bold, italic))
357 }
358
359 /// Does this hold no loaded face? A book naming only its body family resolves nothing.
360 pub fn is_empty(&self) -> bool {
361 self.families.is_empty()
362 }
363}
364
365/// The hard error for a family no font declares: the family, where it was named, the directory searched
366/// and the families that directory does declare, so the fix -- supply the file, or correct the name -- is
367/// plain from the message alone.
368fn missing_family(name: &str, site: &str, dir: &Path, lib: &FontLibrary) -> Error<ErrTag> {
369 let offered = lib.families();
370 let offered = if offered.is_empty() { "none".to_string() } else { offered.join(", ") };
371 err!("The font family {:?} named by {} is not declared by any font file under {:?}; the families \
372 there are: {}. Supply the font (a wasm project passes it in `fonts`) or correct the name.",
373 name, site, dir, offered; Missing, Input)
374}
375
376/// The reading set for a body family list: each role a chain of the listed families' nearest variant for
377/// that role, in list order (`None` standing for the embedded Libertinus face), ending in the embedded
378/// face for the role when the list did not name it, so a character none of the listed families draws
379/// still reaches ink (Typst's font fall-back). The mono role keeps Libertinus Mono, since Typst sets `raw`
380/// in its own face whatever the body family.
381fn reading_set(families: &[Option<Family>]) -> Outcome<FontSet> {
382 let chain = |bold: bool, italic: bool, embedded: &[u8]| -> Outcome<Font> {
383 let mut faces: Vec<Face> = Vec::with_capacity(families.len() + 1);
384 let mut has_embedded = false;
385 for fam in families {
386 match fam {
387 Some(f) => if let Some(v) = f.pick(bold, italic) {
388 faces.push(res!(Face::new(v.bytes.as_ref().clone())));
389 },
390 None => if !has_embedded {
391 faces.push(res!(Face::new(embedded.to_vec())));
392 has_embedded = true;
393 },
394 }
395 }
396 if !has_embedded {
397 faces.push(res!(Face::new(embedded.to_vec())));
398 }
399 Font::chain(faces)
400 };
401 Ok(FontSet::new(
402 res!(chain(false, false, SERIF)),
403 res!(chain(true, false, BOLD)),
404 res!(chain(false, true, ITALIC)),
405 res!(chain(true, true, BOLD_ITALIC)),
406 res!(Font::new(MONO.to_vec())),
407 ))
408}
409
410/// Loads one weight/slant variant of a named face -- `<name>-<suffix>.ttf` or `.otf` under `dir` -- into
411/// `slot`, leaving it `None` when neither file exists or the one present will not parse.
412fn load_variant(dir: &Path, name: &str, suffix: &str, slot: &mut Option<Variant>) {
413 for ext in ["ttf", "otf"] {
414 let path = dir.join(fmt!("{}-{}.{}", name, suffix, ext));
415 if vfs::is_file(&path) {
416 if let Ok(bytes) = vfs::read(&path) {
417 if let Ok(font) = Font::new(bytes.clone()) {
418 *slot = Some(Variant { font: Arc::new(font), bytes: Arc::new(bytes) });
419 }
420 }
421 return;
422 }
423 }
424}
425
426const SERIF: &[u8] = include_bytes!("../fonts/LibertinusSerif-Regular.otf");
427const BOLD: &[u8] = include_bytes!("../fonts/LibertinusSerif-Bold.otf");
428const ITALIC: &[u8] = include_bytes!("../fonts/LibertinusSerif-Italic.otf");
429const BOLD_ITALIC: &[u8] = include_bytes!("../fonts/LibertinusSerif-BoldItalic.otf");
430const MONO: &[u8] = include_bytes!("../fonts/LibertinusMono-Regular.otf");
431
432// The maths face: New Computer Modern Math, Typst's own default for `math.equation`, so an equation sets
433// in the face the oracle sets it in. Its OpenType MATH table drives the maths layout (see `crate::math`).
434pub(crate) const MATH: &[u8] = include_bytes!("../fonts/NewCMMath-Regular.otf");
435
436// Every embedded face, for the family library a document's `font:` is matched against.
437const EMBEDDED: [&[u8]; 6] = [SERIF, BOLD, ITALIC, BOLD_ITALIC, MONO, MATH];
438
439/// The Libertinus Serif reading set: one face per role. Libertinus covers the Latin, punctuation and
440/// figures a set document needs, so each role is a single face; a symbol a document reaches for that the
441/// family lacks would fall to the not-defined glyph, which the demos do not hit.
442pub fn libertinus() -> Outcome<FontSet> {
443 Ok(FontSet::new(
444 res!(Font::new(SERIF.to_vec())),
445 res!(Font::new(BOLD.to_vec())),
446 res!(Font::new(ITALIC.to_vec())),
447 res!(Font::new(BOLD_ITALIC.to_vec())),
448 res!(Font::new(MONO.to_vec())),
449 ))
450}
451
452/// One face loaded from a file as a shareable handle, for a role outside the five-face reading set --
453/// a heading face a book supplies by path (Radley, say). It is shaped through the `Solo` path the way
454/// the maths font is. The error names the path, so the caller can choose to fall back rather than fail.
455pub fn font_from_file(path: &Path) -> Outcome<std::sync::Arc<Font>> {
456 Ok(std::sync::Arc::new(res!(face_from_file(path))))
457}
458
459/// Reads one face from a file, naming the path when the read fails so a missing font is obvious.
460fn face_from_file(path: &Path) -> Outcome<Font> {
461 let bytes = match vfs::read(path) {
462 Ok(b) => b,
463 Err(e) => return Err(err!(e, "Could not read the font file {:?}.", path; File, Read)),
464 };
465 Font::new(bytes)
466}
467
468/// Builds a reading set from five explicit face files, one per role. A book supplies its own faces by
469/// path -- Libertinus lives in the book's assets tree, not fontconfig, so the set is loaded at run
470/// time from the paths the book uses rather than the faces embedded in the crate.
471pub fn from_files(
472 body: &Path,
473 bold: &Path,
474 italic: &Path,
475 bold_italic: &Path,
476 mono: &Path,
477)
478 -> Outcome<FontSet>
479{
480 Ok(FontSet::new(
481 res!(face_from_file(body)),
482 res!(face_from_file(bold)),
483 res!(face_from_file(italic)),
484 res!(face_from_file(bold_italic)),
485 res!(face_from_file(mono)),
486 ))
487}
488
489/// The Libertinus Serif reading set loaded by path from a book's Libertinus directory (the folder
490/// holding `LibertinusSerif-*.otf` and `LibertinusMono-Regular.otf`). This is the book body face:
491/// Typst's own default, so prose set here matches the oracle's.
492pub fn libertinus_from_dir(dir: &Path) -> Outcome<FontSet> {
493 from_files(
494 &dir.join("LibertinusSerif-Regular.otf"),
495 &dir.join("LibertinusSerif-Bold.otf"),
496 &dir.join("LibertinusSerif-Italic.otf"),
497 &dir.join("LibertinusSerif-BoldItalic.otf"),
498 &dir.join("LibertinusMono-Regular.otf"),
499 )
500}
501
502#[cfg(test)]
503mod tests {
504 use super::*;
505
506 /// The resolver loads a named face from a directory holding its `<name>-Regular.otf`, and returns
507 /// `None` for a name with no file, so the renderer falls back to a role face rather than failing.
508 #[test]
509 fn face_resolver_loads_a_named_face() {
510 let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("fonts");
511 let r = FaceResolver::load(&dir, &["LibertinusSerif".to_string(), "NoSuchFace".to_string()]);
512 assert!(r.resolve("LibertinusSerif").is_some(), "an existing face file must resolve to a font");
513 assert!(r.resolve("NoSuchFace").is_none(), "a name with no file must not resolve");
514 assert!(!r.is_empty());
515 }
516}