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 | |
| 20 | use oxedyne_fe2o3_austenite::compile; |
| 21 | use oxedyne_fe2o3_austenite::emit::svg; |
| 22 | use oxedyne_fe2o3_austenite::fonts; |
| 23 | use oxedyne_fe2o3_austenite::page::PlacedKind; |
| 24 | use oxedyne_fe2o3_austenite::vfs; |
| 25 | |
| 26 | use oxedyne_fe2o3_core::prelude::*; |
| 27 | |
| 28 | use std::collections::HashMap; |
| 29 | use std::path::PathBuf; |
| 30 | use std::sync::{ |
| 31 | Arc, |
| 32 | Mutex, |
| 33 | }; |
| 34 | |
| 35 | // The source map is a process-wide global, so the cases take turns. |
| 36 | static 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)] |
| 40 | struct 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. |
| 49 | fn 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. |
| 63 | fn 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. |
| 102 | fn 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] |
| 112 | fn 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, ¬o())); |
| 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, ¬o())); |
| 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] |
| 132 | fn 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, ¬o()) { |
| 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] |
| 155 | fn 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, ¬o()) { |
| 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", ¬o()) { |
| 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] |
| 175 | fn 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, ¬o())); |
| 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] |
| 187 | fn 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, ¬o())); |
| 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] |
| 199 | fn 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, ¬o())); |
| 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] |
| 210 | fn 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] |
| 224 | fn 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] |
| 238 | fn 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(¬o[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 | } |