Oregami
Repositories/oxedyne/fe2o3

oxedyne/fe2o3/fe2o3_austenite/tests/font_family.rs

10.0 KiB, 1 run

created by r1870400018:59972, 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//! Do the families a document names reach the page, and does a family nobody supplied stop the compile?
2//!
3//! Every case drives the real [`compile::assemble`]/[`compile::author_and_run`] path with the document's
4//! fonts injected into the source map, as the wasm surface injects a project's `fonts`. The fixture family
5//! is Noto Sans, borrowed from `fe2o3_font`'s embedded set, and it is injected under file names that say
6//! nothing about it (`/assets/fonts/pack/f1.ttf` ...), so a pass proves the family was matched by the name
7//! its own name table declares -- Typst's rule -- and not by a filename convention.
8//!
9//! Five assertions map to one fix each, and reverting that fix reds it (checked 2026-09-23):
10//!
11//! * [`a_body_family_sets_the_body_bold_and_italic`]: the root reading-set swap in `compile::assemble`.
12//! * [`a_missing_family_is_a_hard_error`]: the precheck in `FaceResolver::require`.
13//! * [`a_heading_rule_sets_the_heading_face`]: the heading `set text(font:)` redirect in `rules.rs`.
14//! * [`a_scoped_family_sets_only_its_scope`]: the scope swap in `doc::Authoring::walk`.
15//! * [`smallcaps_asks_the_font_for_small_capitals`]: the `smcp` feature threaded through line breaking.
16//!
17//! The sixth, [`a_document_naming_no_family_is_byte_unchanged`], is the guard on all of them: fonts supplied
18//! but never named change not one byte of the page.
19
20use oxedyne_fe2o3_austenite::compile;
21use oxedyne_fe2o3_austenite::emit::svg;
22use oxedyne_fe2o3_austenite::fonts;
23use oxedyne_fe2o3_austenite::page::PlacedKind;
24use oxedyne_fe2o3_austenite::vfs;
25
26use oxedyne_fe2o3_core::prelude::*;
27
28use std::collections::HashMap;
29use std::path::PathBuf;
30use std::sync::{
31 Arc,
32 Mutex,
33};
34
35// The source map is a process-wide global, so the cases take turns.
36static VFS: Mutex<()> = Mutex::new(());
37
38/// One placed text run: its source string, the family/weight/slant of the face that set it, and its glyphs.
39#[derive(Debug)]
40struct Run {
41 src: String,
42 family: String,
43 weight: u16,
44 italic: bool,
45 glyphs: Vec<u32>,
46}
47
48/// The Noto Sans fixture faces, under deliberately uninformative file names.
49fn noto() -> Vec<(String, Vec<u8>)> {
50 let dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("..").join("fe2o3_font").join("fonts");
51 let mut out = Vec::new();
52 for (i, f) in ["NotoSans-Regular.ttf", "NotoSans-Bold.ttf", "NotoSans-Italic.ttf", "NotoSans-BoldItalic.ttf"]
53 .iter().enumerate()
54 {
55 let bytes = std::fs::read(dir.join(f)).expect("the fe2o3_font Noto Sans fixture must be present");
56 out.push((fmt!("/assets/fonts/pack/f{}.ttf", i + 1), bytes));
57 }
58 out
59}
60
61/// Compiles `src` as `/doc/main.typ` with `fonts` installed, returning every placed text run and the SVG of
62/// each page, or the assembly/compile error.
63fn compile(src: &str, fonts: &[(String, Vec<u8>)]) -> Outcome<(Vec<Run>, Vec<String>)> {
64 let _turn = match VFS.lock() {
65 Ok(g) => g,
66 Err(p) => p.into_inner(), // a failed case poisons the lock; the map is reinstalled regardless
67 };
68 let main = PathBuf::from("/doc/main.typ");
69 let mut files: HashMap<PathBuf, Vec<u8>> = HashMap::new();
70 files.insert(main.clone(), src.as_bytes().to_vec());
71 for (path, bytes) in fonts {
72 files.insert(PathBuf::from(path), bytes.clone());
73 }
74 res!(vfs::install(files));
75 let set = Arc::new(res!(fonts::libertinus()));
76 let result = compile::assemble(&main, || Ok(set.clone()))
77 .and_then(|(a, _, _)| compile::author_and_run(a));
78 let _ = vfs::clear();
79 let rendered = res!(result);
80
81 let mut runs = Vec::new();
82 let mut svgs = Vec::new();
83 for page in &rendered.out.pages {
84 svgs.push(res!(svg::render_page(page)));
85 for placed in &page.frame.placed {
86 if let PlacedKind::Text(shaped) = &placed.kind {
87 let info = res!(shaped.face_info());
88 runs.push(Run {
89 src: shaped.source().to_string(),
90 family: info.family,
91 weight: info.weight,
92 italic: info.italic,
93 glyphs: shaped.run().glyphs.iter().map(|g| g.id).collect(),
94 });
95 }
96 }
97 }
98 Ok((runs, svgs))
99}
100
101/// The first run whose source contains `word`, failing the test when the word never reached the page.
102fn run_of<'a>(runs: &'a [Run], word: &str) -> Outcome<&'a Run> {
103 match runs.iter().find(|r| r.src.contains(word)) {
104 Some(r) => Ok(r),
105 None => Err(err!("No placed run carries {:?}; runs were {:?}.", word, runs; Test, Missing)),
106 }
107}
108
109/// A root `#set text(font: "Noto Sans")` sets the body in Noto Sans, and its strong and emphasised runs in
110/// the family's own bold and italic, each matched by the weight and slant the file declares.
111#[test]
112fn a_body_family_sets_the_body_bold_and_italic() -> Outcome<()> {
113 let src = "#set text(font: \"Noto Sans\")\n\nPlain words, *heavy* words and _leaning_ words.\n";
114 let (runs, _) = res!(compile(src, &noto()));
115 let plain = res!(run_of(&runs, "Plain"));
116 assert_eq!((plain.family.as_str(), plain.weight, plain.italic), ("Noto Sans", 400, false), "{:?}", plain);
117 let heavy = res!(run_of(&runs, "heavy"));
118 assert_eq!((heavy.family.as_str(), heavy.weight, heavy.italic), ("Noto Sans", 700, false), "{:?}", heavy);
119 let lean = res!(run_of(&runs, "leaning"));
120 assert_eq!((lean.family.as_str(), lean.italic), ("Noto Sans", true), "{:?}", lean);
121
122 // A fall-back list led by the supplied family sets in it too.
123 let listed = "#set text(font: (\"Noto Sans\", \"Libertinus Serif\"))\n\nListed words.\n";
124 let (runs, _) = res!(compile(listed, &noto()));
125 assert_eq!(res!(run_of(&runs, "Listed")).family, "Noto Sans");
126 Ok(())
127}
128
129/// A family no supplied font declares stops the compile with an error naming it and the families that
130/// were on offer -- never a silent fall-back to Libertinus. A fall-back list is checked whole.
131#[test]
132fn a_missing_family_is_a_hard_error() -> Outcome<()> {
133 for src in [
134 "#set text(font: \"Nonesuch Serif\")\n\nWords.\n",
135 "#set text(font: (\"Nonesuch Serif\", \"Noto Sans\"))\n\nWords.\n",
136 "#show heading: set text(font: \"Nonesuch Serif\")\n\n= A heading\n\nWords.\n",
137 ] {
138 match compile(src, &noto()) {
139 Ok((runs, _)) => return Err(err!(
140 "{:?} named a family no font supplies, yet compiled; runs were {:?}.", src, runs; Test, Mismatch)),
141 Err(e) => {
142 let msg = fmt!("{}", e);
143 assert!(msg.contains("Nonesuch Serif"), "the error must name the family: {}", msg);
144 assert!(msg.contains("Noto Sans"), "the error must name the families on offer: {}", msg);
145 },
146 }
147 }
148 Ok(())
149}
150
151/// A document that names no family renders byte-identically whether or not fonts were supplied: an unnamed
152/// font is never read, let alone used.
153#[test]
154fn a_document_naming_no_family_is_byte_unchanged() -> Outcome<()> {
155 let src = "= A heading\n\nPlain words, *heavy* words and _leaning_ words.\n";
156 let (runs_with, svg_with) = res!(compile(src, &noto()));
157 let (_, svg_without) = res!(compile(src, &[]));
158 assert!(!svg_with.is_empty());
159 assert_eq!(svg_with, svg_without, "supplying an unnamed family must not change one byte");
160 assert!(runs_with.iter().all(|r| r.family == "Libertinus Serif"), "{:?}", runs_with);
161 Ok(())
162}
163
164/// `#show heading: set text(font: ...)` sets every heading in that family while the body keeps its own.
165#[test]
166fn a_heading_rule_sets_the_heading_face() -> Outcome<()> {
167 let src = "#show heading: set text(font: \"Noto Sans\")\n\n= Display\n\nBody words.\n\n== Second\n\nMore words.\n";
168 let (runs, _) = res!(compile(src, &noto()));
169 assert_eq!(res!(run_of(&runs, "Display")).family, "Noto Sans");
170 assert_eq!(res!(run_of(&runs, "Second")).family, "Noto Sans");
171 assert_eq!(res!(run_of(&runs, "Body")).family, "Libertinus Serif");
172 Ok(())
173}
174
175/// A family set by a scoped rule (`#show par: set text(font: ...)`) sets the paragraphs it scopes and
176/// nothing else: the heading outside the scope stays in the document's own family.
177#[test]
178fn a_scoped_family_sets_only_its_scope() -> Outcome<()> {
179 let src = "#show par: set text(font: \"Noto Sans\")\n\n= Outside\n\nDog run.\n";
180 let (runs, _) = res!(compile(src, &noto()));
181 assert_eq!(res!(run_of(&runs, "Dog")).family, "Noto Sans");
182 assert_eq!(res!(run_of(&runs, "Outside")).family, "Libertinus Serif");
183 Ok(())
184}
185
186/// `#smallcaps[...]` shapes with the font's `smcp` feature: the same word draws different glyphs from its
187/// plain setting, in the same face.
188#[test]
189fn smallcaps_asks_the_font_for_small_capitals() -> Outcome<()> {
190 let src = "A #smallcaps[capped] treaty.\n\nA plain capped treaty.\n";
191 let (runs, _) = res!(compile(src, &[]));
192 let capped: Vec<&Run> = runs.iter().filter(|r| r.src == "capped").collect();
193 assert_eq!(capped.len(), 2, "one small-capped and one plain setting of the word: {:?}", capped);
194 assert_eq!(capped[0].family, capped[1].family, "both in the body face");
195 assert_ne!(capped[0].glyphs, capped[1].glyphs,
196 "smcp must swap the lower-case letters for small capitals: {:?}", capped);
197 Ok(())
198}
199
200/// An equation sets in New Computer Modern Math -- Typst's own default maths face, embedded -- when the
201/// document names no maths font, while the prose around it keeps the body family.
202#[test]
203fn maths_sets_in_new_computer_modern_math_by_default() -> Outcome<()> {
204 let (runs, _) = res!(compile("Let $x + y$ be given.\n", &[]));
205 let maths: Vec<&Run> = runs.iter().filter(|r| r.family.to_lowercase().contains("math")).collect();
206 assert!(!maths.is_empty(), "the equation must reach the page: {:?}", runs);
207 for m in &maths {
208 assert!(fonts::same_family(&m.family, "New Computer Modern Math"), "{:?}", m);
209 }
210 assert_eq!(res!(run_of(&runs, "given")).family, "Libertinus Serif");
211 Ok(())
212}
213
214/// An embedded family needs no file: a heading rule naming Libertinus Mono resolves with nothing supplied,
215/// and the family accessors agree with what the files declare.
216#[test]
217fn embedded_families_need_no_file() -> Outcome<()> {
218 let (runs, _) = res!(compile("#show heading: set text(font: \"Libertinus Mono\")\n\n= Typed\n\nBody.\n", &[]));
219 assert_eq!(res!(run_of(&runs, "Typed")).family, "Libertinus Mono");
220 let listed = fonts::embedded_families();
221 assert!(listed.iter().any(|f| f == "New Computer Modern Math"), "{:?}", listed);
222 let noto = noto();
223 assert_eq!(res!(fonts::declared_family(&noto[0].1)), "Noto Sans");
224 assert!(fonts::same_family("NewComputerModern Math", "new computer modern math"));
225 assert!(!fonts::same_family("Noto Sans", "Noto Serif"));
226 Ok(())
227}