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 | |
| 13 | use crate::vfs; |
| 14 | |
| 15 | use oxedyne_fe2o3_core::prelude::*; |
| 16 | use oxedyne_fe2o3_font::{ |
| 17 | face::{ |
| 18 | Face, |
| 19 | FaceInfo, |
| 20 | }, |
| 21 | font::Font, |
| 22 | set::FontSet, |
| 23 | }; |
| 24 | |
| 25 | use std::collections::HashMap; |
| 26 | use std::path::Path; |
| 27 | use 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)] |
| 36 | struct 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)] |
| 45 | struct Family { |
| 46 | regular: Option<Variant>, |
| 47 | bold: Option<Variant>, |
| 48 | italic: Option<Variant>, |
| 49 | bold_italic: Option<Variant>, |
| 50 | } |
| 51 | |
| 52 | impl 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)] |
| 95 | struct 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)] |
| 105 | struct FontLibrary { |
| 106 | faces: Vec<Declared>, |
| 107 | } |
| 108 | |
| 109 | impl 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`. |
| 173 | pub 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. |
| 180 | pub 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. |
| 191 | pub 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. |
| 197 | fn 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)] |
| 209 | pub 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 | |
| 215 | impl 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. |
| 368 | fn 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. |
| 381 | fn 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. |
| 412 | fn 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 | |
| 426 | const SERIF: &[u8] = include_bytes!("../fonts/LibertinusSerif-Regular.otf"); |
| 427 | const BOLD: &[u8] = include_bytes!("../fonts/LibertinusSerif-Bold.otf"); |
| 428 | const ITALIC: &[u8] = include_bytes!("../fonts/LibertinusSerif-Italic.otf"); |
| 429 | const BOLD_ITALIC: &[u8] = include_bytes!("../fonts/LibertinusSerif-BoldItalic.otf"); |
| 430 | const 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`). |
| 434 | pub(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. |
| 437 | const 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. |
| 442 | pub 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. |
| 455 | pub 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. |
| 460 | fn 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. |
| 471 | pub 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. |
| 492 | pub 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)] |
| 503 | mod 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 | } |