Oregami
Repositories/oxedyne/fe2o3

oxedyne/fe2o3/fe2o3_austenite/tests/font_family.rs

11.0 KiB, 5 runs

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(compile::author_and_run);
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 family written in a comment inside `#set text(...)` is no family, as Typst reads it: the rule's other
152/// arguments apply and nothing asks for the family, so the compile stands. One named after a block comment
153/// is really named, and is refused as any missing family is.
154#[test]
155fn a_font_named_in_a_comment_is_no_font() -> Outcome<()> {
156 let src = "#set text(size: 14pt // , font: \"NoSuchFace\"\n)\nBody words.\n";
157 let (runs, svgs) = match compile(src, &noto()) {
158 Ok(r) => r,
159 Err(e) => return Err(err!("a family in a comment was asked for: {}", e; Test, Mismatch)),
160 };
161 assert_eq!(svgs.len(), 1, "Typst sets one page");
162 assert_eq!(res!(run_of(&runs, "Body")).family, "Libertinus Serif");
163
164 match compile("#set text(/* size: 9pt, */ font: \"NoSuchFace\")\nBody words.\n", &noto()) {
165 Ok((runs, _)) => return Err(err!(
166 "a family named after a comment was not asked for; runs were {:?}.", runs; Test, Mismatch)),
167 Err(e) => assert!(fmt!("{}", e).contains("NoSuchFace"), "the error names the family: {}", e),
168 }
169 Ok(())
170}
171
172/// A document that names no family renders byte-identically whether or not fonts were supplied: an unnamed
173/// font is never read, let alone used.
174#[test]
175fn a_document_naming_no_family_is_byte_unchanged() -> Outcome<()> {
176 let src = "= A heading\n\nPlain words, *heavy* words and _leaning_ words.\n";
177 let (runs_with, svg_with) = res!(compile(src, &noto()));
178 let (_, svg_without) = res!(compile(src, &[]));
179 assert!(!svg_with.is_empty());
180 assert_eq!(svg_with, svg_without, "supplying an unnamed family must not change one byte");
181 assert!(runs_with.iter().all(|r| r.family == "Libertinus Serif"), "{:?}", runs_with);
182 Ok(())
183}
184
185/// `#show heading: set text(font: ...)` sets every heading in that family while the body keeps its own.
186#[test]
187fn a_heading_rule_sets_the_heading_face() -> Outcome<()> {
188 let src = "#show heading: set text(font: \"Noto Sans\")\n\n= Display\n\nBody words.\n\n== Second\n\nMore words.\n";
189 let (runs, _) = res!(compile(src, &noto()));
190 assert_eq!(res!(run_of(&runs, "Display")).family, "Noto Sans");
191 assert_eq!(res!(run_of(&runs, "Second")).family, "Noto Sans");
192 assert_eq!(res!(run_of(&runs, "Body")).family, "Libertinus Serif");
193 Ok(())
194}
195
196/// A family set by a scoped rule (`#show par: set text(font: ...)`) sets the paragraphs it scopes and
197/// nothing else: the heading outside the scope stays in the document's own family.
198#[test]
199fn a_scoped_family_sets_only_its_scope() -> Outcome<()> {
200 let src = "#show par: set text(font: \"Noto Sans\")\n\n= Outside\n\nDog run.\n";
201 let (runs, _) = res!(compile(src, &noto()));
202 assert_eq!(res!(run_of(&runs, "Dog")).family, "Noto Sans");
203 assert_eq!(res!(run_of(&runs, "Outside")).family, "Libertinus Serif");
204 Ok(())
205}
206
207/// `#smallcaps[...]` shapes with the font's `smcp` feature: the same word draws different glyphs from its
208/// plain setting, in the same face.
209#[test]
210fn smallcaps_asks_the_font_for_small_capitals() -> Outcome<()> {
211 let src = "A #smallcaps[capped] treaty.\n\nA plain capped treaty.\n";
212 let (runs, _) = res!(compile(src, &[]));
213 let capped: Vec<&Run> = runs.iter().filter(|r| r.src == "capped").collect();
214 assert_eq!(capped.len(), 2, "one small-capped and one plain setting of the word: {:?}", capped);
215 assert_eq!(capped[0].family, capped[1].family, "both in the body face");
216 assert_ne!(capped[0].glyphs, capped[1].glyphs,
217 "smcp must swap the lower-case letters for small capitals: {:?}", capped);
218 Ok(())
219}
220
221/// An equation sets in New Computer Modern Math -- Typst's own default maths face, embedded -- when the
222/// document names no maths font, while the prose around it keeps the body family.
223#[test]
224fn maths_sets_in_new_computer_modern_math_by_default() -> Outcome<()> {
225 let (runs, _) = res!(compile("Let $x + y$ be given.\n", &[]));
226 let maths: Vec<&Run> = runs.iter().filter(|r| r.family.to_lowercase().contains("math")).collect();
227 assert!(!maths.is_empty(), "the equation must reach the page: {:?}", runs);
228 for m in &maths {
229 assert!(fonts::same_family(&m.family, "New Computer Modern Math"), "{:?}", m);
230 }
231 assert_eq!(res!(run_of(&runs, "given")).family, "Libertinus Serif");
232 Ok(())
233}
234
235/// An embedded family needs no file: a heading rule naming Libertinus Mono resolves with nothing supplied,
236/// and the family accessors agree with what the files declare.
237#[test]
238fn embedded_families_need_no_file() -> Outcome<()> {
239 let (runs, _) = res!(compile("#show heading: set text(font: \"Libertinus Mono\")\n\n= Typed\n\nBody.\n", &[]));
240 assert_eq!(res!(run_of(&runs, "Typed")).family, "Libertinus Mono");
241 let listed = fonts::embedded_families();
242 assert!(listed.iter().any(|f| f == "New Computer Modern Math"), "{:?}", listed);
243 let noto = noto();
244 assert_eq!(res!(fonts::declared_family(&noto[0].1)), "Noto Sans");
245 assert!(fonts::same_family("NewComputerModern Math", "new computer modern math"));
246 assert!(!fonts::same_family("Noto Sans", "Noto Serif"));
247 Ok(())
248}