Skip to main content

max / quasi-type

15.6 KB · 422 lines History Blame Raw
1 //! The house font pipeline.
2 //!
3 //! A pinned base face plus the house glyph set in, a `Quasi <Slot>` face out,
4 //! re-runnable when the upstream moves or when a slot's base changes.
5 //!
6 //! It exists because the faces worth setting text in ship minimal mark
7 //! inventories on principle: IBM Plex Mono has one glyph in the whole
8 //! geometric-shapes block, and Plex Sans and Lato have no `▲` either. So the
9 //! marks a UI depends on get drawn once, here, and patched into every face the
10 //! house adopts. Letters stay the base's job and are meant to differ per slot;
11 //! marks carry meaning, and the meaning is the same in a terminal, a webview
12 //! and an egui panel.
13 //!
14 //! <!-- wiki: typography-standard -->
15
16 use std::path::PathBuf;
17
18 pub mod assert;
19 pub mod base;
20 pub mod cells;
21 pub mod compose;
22 pub mod draw;
23 pub mod manifest;
24 pub mod pins;
25 pub mod proof;
26 pub mod vary;
27 pub mod woff2;
28
29 /// The house glyph set, shipped with the pipeline that consumes it.
30 pub const HOUSE_SET: &str = include_str!("../glyphs/manifest.toml");
31
32 /// The pinned bases and the slots cut from them.
33 pub const PINS: &str = include_str!("../bases/pins.toml");
34
35 #[derive(Debug)]
36 pub enum Error {
37 Manifest(String),
38 Pins(String),
39 UnknownSlot {
40 asked: String,
41 known: Vec<String>,
42 },
43 ReservedFontName {
44 family: String,
45 reserved: String,
46 base: String,
47 },
48 UnmeasurableBase {
49 missing: char,
50 what: String,
51 },
52 DefaultInstance {
53 declared: String,
54 actual: String,
55 tag: String,
56 at: f32,
57 },
58 AlreadyDrawn {
59 codepoint: u32,
60 base: String,
61 gid: u32,
62 },
63 ArchiveChecksum {
64 path: PathBuf,
65 url: String,
66 expected: String,
67 found: String,
68 },
69 FaceChecksum {
70 path: String,
71 expected: String,
72 found: String,
73 },
74 Offline {
75 wanted: PathBuf,
76 url: String,
77 },
78 Coverage(String),
79 Archive(String),
80 Fetch(String),
81 Font(String),
82 Draw(String),
83 Woff2(String),
84 Io(PathBuf, std::io::Error),
85 }
86
87 impl std::fmt::Display for Error {
88 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
89 match self {
90 Error::Manifest(m) => write!(f, "the house glyph set is malformed: {m}"),
91 Error::Pins(m) => write!(f, "the pins are malformed: {m}"),
92 Error::UnknownSlot { asked, known } => {
93 write!(f, "no slot `{asked}`. Pinned slots: {}", known.join(", "))
94 }
95 Error::ReservedFontName {
96 family,
97 reserved,
98 base,
99 } => write!(
100 f,
101 "`{family}` carries {base}'s Reserved Font Name \"{reserved}\", which OFL 1.1 \
102 clause 3 bars as a prefix and as a suffix alike. Name the slot instead."
103 ),
104 Error::UnmeasurableBase { missing, what } => write!(
105 f,
106 "the base does not draw `{missing}`, so {what} cannot be measured. \
107 A base that cannot be measured cannot be refitted against, and guessing \
108 would give the marks a weight that does not match their neighbours."
109 ),
110 Error::DefaultInstance {
111 declared,
112 actual,
113 tag,
114 at,
115 } => write!(
116 f,
117 "the pin calls this face `{declared}` and the base's default instance is \
118 `{actual}` ({tag} {at}). A variable cut keeps the base's axis, so it also \
119 keeps the base's default, and a face labelled with a weight it does not \
120 draw at rest is one every naive `@font-face` and every `fc-match` will \
121 believe. Name the face `{actual}` in the pin, or instance the base first."
122 ),
123 Error::AlreadyDrawn {
124 codepoint,
125 base,
126 gid,
127 } => write!(
128 f,
129 "{} is already drawn by {base} (glyph {gid}). The pipeline patches gaps and \
130 does not overwrite a base's own marks; drop it from the set for this slot.",
131 manifest::format_codepoint(*codepoint)
132 ),
133 Error::ArchiveChecksum {
134 path,
135 url,
136 expected,
137 found,
138 } => write!(
139 f,
140 "{} does not match its pin.\n expected {expected}\n found {found}\n\
141 Upstream moved, or the cached copy is damaged. Re-read {url} before \
142 repinning: a base that changed under us redraws every mark that refits \
143 against it.",
144 path.display()
145 ),
146 Error::FaceChecksum {
147 path,
148 expected,
149 found,
150 } => write!(
151 f,
152 "`{path}` inside the archive does not match its pin.\n expected {expected}\n\
153 \x20 found {found}"
154 ),
155 Error::Offline { wanted, url } => write!(
156 f,
157 "{} is not cached and --offline was given. Fetch it with:\n curl -L -o {} {url}",
158 wanted.display(),
159 wanted.display()
160 ),
161 Error::Coverage(missing) => {
162 write!(f, "the built face does not cover: {missing}")
163 }
164 Error::Archive(m) => write!(f, "{m}"),
165 Error::Fetch(m) => write!(f, "{m}"),
166 Error::Font(m) => write!(f, "{m}"),
167 Error::Draw(m) => write!(f, "could not draw: {m}"),
168 Error::Woff2(m) => write!(f, "woff2: {m}"),
169 Error::Io(path, e) => write!(f, "{}: {e}", path.display()),
170 }
171 }
172 }
173
174 impl std::error::Error for Error {}
175
176 /// One cut face, ready to write or to embed.
177 pub struct Face {
178 /// The style the pin declares, which is the base's default instance.
179 pub style: String,
180 /// The file name without an extension: `QuasiMono[wght]` for a variable
181 /// face, `QuasiMono-Regular` for a static one. A face is named for its axis
182 /// when it has one, the way upstream names the file it came from.
183 pub stem: String,
184 pub ttf: Vec<u8>,
185 pub woff2: Vec<u8>,
186 /// What the cut added to the base, as `(codepoint, glyph name)`.
187 pub added: Vec<(u32, String)>,
188 pub variation: Option<base::Variation>,
189 /// The coverage verdict, already asserted. Carried so a caller can print it
190 /// rather than re-derive it.
191 pub verdict: String,
192 }
193
194 /// A slot, cut.
195 pub struct Cut {
196 pub family: String,
197 pub base_family: String,
198 pub base_version: String,
199 /// How many glyphs of the house set this slot selected.
200 pub selected: usize,
201 pub faces: Vec<Face>,
202 /// The base's licence text, which OFL requires to travel with the build.
203 pub license: Vec<u8>,
204 }
205
206 /// Cut a slot from its pinned base: the whole pipeline, in one call.
207 ///
208 /// This is what a consumer runs. `shop` cuts its bundled face from a `build.rs`
209 /// and Alloy's image cuts both faces into `/usr/share/fonts`, and neither should
210 /// have to reassemble the steps `main.rs` walks — a second caller doing its own
211 /// version string or skipping the coverage assertion is how two builds of the
212 /// same slot stop being the same face.
213 ///
214 /// `cache` is where pinned bases are downloaded to and verified in. It is a
215 /// parameter rather than a constant because a consumer's is not this repo's:
216 /// a `build.rs` wants `OUT_DIR`, and a checkout wants `bases/cache`.
217 pub fn cut(slot_id: &str, cache: &std::path::Path, offline: bool) -> Result<Cut, Error> {
218 let pins = pins::Pins::parse(PINS)?;
219 let manifest = manifest::Manifest::parse(HOUSE_SET)?;
220 let slot = pins.slot(slot_id)?;
221 let base_pin = pins.base(&slot.base)?;
222
223 // The licence gate, before anything is built: an output name may not carry
224 // the base's Reserved Font Name.
225 base_pin.check_output_name(&slot.family)?;
226
227 let selected: Vec<&manifest::GlyphSpec> = manifest
228 .glyphs
229 .iter()
230 .filter(|g| slot.glyphs.includes(g))
231 .collect();
232 let house: Vec<u32> = selected.iter().map(|g| g.codepoint).collect();
233 // The floor is the role's, and the slot's own selection is what says which
234 // role it fills: a slot that takes no cell furniture is not the face that
235 // will answer `monospace`, so asking it for `│ █ ▏ ░` asks it to carry
236 // glyphs that cannot tile in it.
237 let floor: &[u32] = if selected.iter().any(|g| g.shape.is_cell_furniture()) {
238 &assert::ALLOY_SURFACE
239 } else {
240 &assert::BODY_SURFACE
241 };
242
243 let version = format!("{}.{}", manifest.set.version, base_pin.version);
244 let mut faces = Vec::new();
245 for face in base::load(base_pin, cache, offline)? {
246 let id = compose::Identity {
247 family: &slot.family,
248 style: &face.style,
249 version: &version,
250 set_version: manifest.set.version,
251 base: base_pin,
252 };
253 let built = compose::build(&face.bytes, &selected, &id)?;
254 let verdict = assert::describe(&assert::check(
255 floor,
256 &compose::coverage(&built.bytes)?,
257 &house,
258 ))?;
259 let stem = match &built.variation {
260 Some(axis) => format!("{}[{}]", slot.family.replace(' ', ""), axis.tag),
261 None => format!(
262 "{}-{}",
263 slot.family.replace(' ', ""),
264 face.style.replace(' ', "")
265 ),
266 };
267 faces.push(Face {
268 style: face.style,
269 stem,
270 woff2: woff2::encode(&built.bytes)?,
271 ttf: built.bytes,
272 added: built.added,
273 variation: built.variation,
274 verdict,
275 });
276 }
277
278 Ok(Cut {
279 family: slot.family.clone(),
280 base_family: base_pin.family.clone(),
281 base_version: base_pin.version.clone(),
282 selected: selected.len(),
283 license: base::license_text(base_pin, cache, offline)?,
284 faces,
285 })
286 }
287
288 /// Cut slots straight into a directory a web server serves, as woff2.
289 ///
290 /// `slots` pairs a slot id with the filename it lands under, and the filename
291 /// is the caller's because it is half of an agreement this crate cannot see:
292 /// `makeover` emits the `@font-face` that fetches these, and its
293 /// `WEBFONT_MONO_FILE` / `WEBFONT_SANS_FILE` are the other half. makeover
294 /// cannot call this — it is on crates.io and this crate is `publish = false` —
295 /// so the two meet in a consumer's build script, and this is the part of that
296 /// meeting worth having once instead of three times.
297 ///
298 /// woff2 only. Every browser that can run a Make Creative frontend has
299 /// supported it since 2018, so a ttf beside it is two to three times the bytes
300 /// for a case that does not arrive.
301 ///
302 /// The licence text travels as `OFL-<Family>.txt` beside each face, which is
303 /// what OFL requires of a build that ships the face.
304 ///
305 /// Nothing is rewritten if it is already byte-identical. A build script that
306 /// rewrites its outputs every run bumps their mtimes, and everything watching
307 /// those files then believes the fonts moved.
308 ///
309 /// # Errors
310 ///
311 /// If a slot cannot be cut, or a file cannot be written.
312 pub fn cut_web(
313 dir: &std::path::Path,
314 cache: &std::path::Path,
315 offline: bool,
316 slots: &[(&str, &str)],
317 ) -> Result<(), Error> {
318 std::fs::create_dir_all(dir).map_err(|e| Error::Io(dir.to_path_buf(), e))?;
319 for (slot_id, filename) in slots {
320 let cut = cut(slot_id, cache, offline)?;
321 let face = cut
322 .faces
323 .first()
324 .ok_or_else(|| Error::Pins(format!("slot `{slot_id}` cut no faces")))?;
325 write_if_changed(&dir.join(filename), &face.woff2)?;
326 write_if_changed(
327 &dir.join(format!("OFL-{}.txt", cut.family.replace(' ', ""))),
328 &cut.license,
329 )?;
330 }
331 Ok(())
332 }
333
334 /// One slot, cut and written for a native consumer.
335 ///
336 /// Carries what a `build.rs` has to export beside the file itself. A native
337 /// renderer registers a face by family and style and picks a weight to draw at,
338 /// and none of the three can be assumed from the filename: the style is the
339 /// base's default instance, and Atkinson Mono's is `ExtraLight` rather than
340 /// `Regular`.
341 pub struct NativeFace {
342 /// The slot id that was cut, as it appears in the pins.
343 pub slot: String,
344 /// The output family, e.g. `Quasi Mono`.
345 pub family: String,
346 /// The style of the face that was written.
347 pub style: String,
348 /// The weight to draw at unless the consumer says otherwise: the variable
349 /// axis default where there is an axis, and 400 where there is not.
350 pub default_weight: f32,
351 /// The ttf that was written.
352 pub path: std::path::PathBuf,
353 /// The licence text that was written beside it.
354 pub license_path: std::path::PathBuf,
355 }
356
357 /// Cut slots into a directory a native build points at, as ttf.
358 ///
359 /// The native counterpart of [`cut_web`], and the same agreement in a different
360 /// shape: `cut_web` writes woff2 for a browser to fetch, this writes ttf for a
361 /// font loader to register. `slots` pairs a slot id with the filename it lands
362 /// under, the filename being the caller's for the same reason as there — it is
363 /// half of an agreement this crate cannot see, and the other half lives in
364 /// whatever the consumer does with `OUT_DIR`.
365 ///
366 /// ttf rather than woff2: nothing native decodes woff2 without being told how,
367 /// and `cut` already returns both, so this is packaging rather than a second
368 /// pipeline.
369 ///
370 /// This exists because the house tier has to reach native renderers without
371 /// going through `makeover`. `makeover::FontSlot::house_face` names the *web*
372 /// copies, and a native consumer cuts its own; before this it copied about
373 /// thirty lines out of `shop/crates/shop-font/build.rs` to do it, which is a
374 /// second caller doing its own file naming and its own weight defaulting.
375 ///
376 /// The licence text travels as `OFL-<Family>.txt` beside each face, which is
377 /// what OFL requires of a build that ships the face.
378 ///
379 /// Nothing is rewritten if it is already byte-identical, for the reason
380 /// [`cut_web`] gives: a build script that rewrites its outputs every run bumps
381 /// their mtimes, and cargo then rebuilds everything watching them.
382 ///
383 /// # Errors
384 ///
385 /// If a slot cannot be cut, or a file cannot be written.
386 pub fn cut_native(
387 dir: &std::path::Path,
388 cache: &std::path::Path,
389 offline: bool,
390 slots: &[(&str, &str)],
391 ) -> Result<Vec<NativeFace>, Error> {
392 std::fs::create_dir_all(dir).map_err(|e| Error::Io(dir.to_path_buf(), e))?;
393 let mut written = Vec::with_capacity(slots.len());
394 for (slot_id, filename) in slots {
395 let cut = cut(slot_id, cache, offline)?;
396 let face = cut
397 .faces
398 .first()
399 .ok_or_else(|| Error::Pins(format!("slot `{slot_id}` cut no faces")))?;
400 let path = dir.join(filename);
401 let license_path = dir.join(format!("OFL-{}.txt", cut.family.replace(' ', "")));
402 write_if_changed(&path, &face.ttf)?;
403 write_if_changed(&license_path, &cut.license)?;
404 written.push(NativeFace {
405 slot: (*slot_id).to_string(),
406 family: cut.family.clone(),
407 style: face.style.clone(),
408 default_weight: face.variation.as_ref().map_or(400.0, |axis| axis.default),
409 path,
410 license_path,
411 });
412 }
413 Ok(written)
414 }
415
416 fn write_if_changed(path: &std::path::Path, bytes: &[u8]) -> Result<(), Error> {
417 if std::fs::read(path).is_ok_and(|existing| existing == bytes) {
418 return Ok(());
419 }
420 std::fs::write(path, bytes).map_err(|e| Error::Io(path.to_path_buf(), e))
421 }
422