Skip to main content

max / quasi-type

Keep the axis when the base is variable, and pin Atkinson Mono A static base is cut once per weight, so a mark is drawn once per weight and that is the end of it. A variable base is one file covering wght 200 to 800, and a glyph spliced into it with no variation data holds still across the whole range: correct at the default instance and progressively wrong everywhere else. A bold table border would draw at the light stroke weight. So the cut keeps the axis rather than instancing it away. Instancing is cheaper and throws away the thing the base was chosen for, and the set's `weight` term already describes a relationship the axis makes continuous instead of sampled at two points. The recipes are parametric in the base's own measurements, so a master is the same recipe measured at another location: no second drawing, no second source of truth. What that costs is `gvar`, which src/vary.rs writes. The base's own entries are copied as bytes, for the same reason `glyf` is spliced rather than recompiled. The trap this pins against: Atkinson Mono's default instance is wght 200 and its name table reads ExtraLight. Keeping the axis keeps that default, so the face is pinned as ExtraLight and the build refuses any other style name -- a face labelled with a weight it does not draw at rest is one every naive @font-face and every fc-match will believe. A consumer asks for the weight it wants instead. The base is pinned and no slot cuts from it yet. Moving quasi-mono onto it waits on box drawing and block elements, which Atkinson ships none of. Measured here rather than taken on faith: 359 codepoints, 0/128 box drawing, 0/32 blocks, 1/96 geometric shapes.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-17 02:19 UTC
Signed with PGP, not checked
Commit: 0829849ee3dea30d94a7f84afddebaea56b7136d
Parent: 03829c5
12 files changed, +1624 insertions, -65 deletions
M Cargo.lock +23 -12
@@ -298,6 +298,7 @@
298 298 "read-fonts",
299 299 "serde",
300 300 "sha2",
301 + "skrifa",
301 302 "toml",
302 303 "write-fonts",
303 304 "zip",
@@ -379,6 +380,16 @@
379 380 source = "registry+https://github.com/rust-lang/crates.io-index"
380 381 checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea"
381 382
383 + [[package]]
384 + name = "skrifa"
385 + version = "0.46.0"
386 + source = "registry+https://github.com/rust-lang/crates.io-index"
387 + checksum = "de0bbcc507e438bfe765692ae5228059959781db7430fe36e1cfd569e7c88c89"
388 + dependencies = [
389 + "bytemuck",
390 + "read-fonts",
391 + ]
392 +
382 393 [[package]]
383 394 name = "smallvec"
384 395 version = "1.15.2"
@@ -515,18 +526,6 @@
515 526 "simd-adler32",
516 527 ]
517 528
518 - [[patch.unused]]
519 - name = "synckit-client"
520 - version = "0.8.0"
521 -
522 - [[patch.unused]]
523 - name = "synckit-config"
524 - version = "0.2.0"
525 -
526 - [[patch.unused]]
527 - name = "docengine"
528 - version = "0.7.0"
529 -
530 529 [[patch.unused]]
531 530 name = "quasi-axum"
532 531 version = "0.18.0"
@@ -559,6 +558,18 @@
559 558 name = "quasi-webview"
560 559 version = "0.18.0"
561 560
561 + [[patch.unused]]
562 + name = "docengine"
563 + version = "0.7.0"
564 +
565 + [[patch.unused]]
566 + name = "synckit-client"
567 + version = "0.8.0"
568 +
569 + [[patch.unused]]
570 + name = "synckit-config"
571 + version = "0.2.0"
572 +
562 573 [[patch.unused]]
563 574 name = "kberg"
564 575 version = "0.1.0"
M Cargo.toml +4
@@ -18,6 +18,10 @@
18 18 [dependencies]
19 19 read-fonts = "0.43"
20 20 write-fonts = "0.52"
21 + # Reading a variable base at a location: applying `gvar` and `avar` is skrifa's
22 + # job, not read-fonts'. Pinned to the release that takes read-fonts 0.43, so the
23 + # tree resolves one copy of it rather than two.
24 + skrifa = "0.46"
21 25 font-types = "0.12"
22 26 kurbo = "0.13"
23 27 brotli = "8"
M README.md +48
@@ -43,6 +43,7 @@
43 43 quasi-type build <slot> cut every face of a slot, verify, write to out/
44 44 quasi-type verify <slot> cut without writing; assert coverage only
45 45 quasi-type params <slot> print what each base face measures
46 + quasi-type params --base <id> measure a base no slot names yet
46 47 quasi-type list print the house glyph set and the pinned slots
47 48 ```
48 49
@@ -75,6 +76,39 @@
75 76 Braille and the sextant sets stay out until a surface wants one. No `Canvas` and
76 77 no `Sparkline` exists anywhere in the tree.
77 78
79 + ## Variable bases
80 +
81 + A variable base is one file covering a range — Atkinson Hyperlegible Mono is
82 + `wght` 200 to 800 — and a cut **keeps that axis** rather than instancing a
83 + weight out of it. Instancing is the cheaper path and throws away the thing the
84 + base was chosen for.
85 +
86 + So a mark is drawn once per master: at the axis default, and at each end of the
87 + axis. The recipes are already parametric in the base's own measurements, so a
88 + master is the same recipe read at another location rather than a second drawing.
89 + The differences between them ship as `gvar` deltas, and the mark then answers
90 + the axis the way the base's own glyphs do. Without them a spliced glyph holds
91 + still across the whole range: right at the default instance, and a light table
92 + border inside a bold one everywhere else.
93 +
94 + Two things this asks of a recipe, both free if it is parametric:
95 +
96 + - **Its point count cannot depend on the measurements.** Deltas are per point,
97 + so the masters have to be the same polygon at different sizes. The pipeline
98 + refuses a mark that changes topology instead of shipping one that interpolates
99 + into a different shape halfway along the axis.
100 + - **A mark that ignores weight gets no variation data**, rather than an empty
101 + tuple saying so at length.
102 +
103 + **Watch the default instance.** Keeping the axis means keeping the base's
104 + default, and a base does not have to default to Regular: Atkinson Mono defaults
105 + to `wght` 200 and its own name table reads `ExtraLight`. A face cut from it is
106 + `ExtraLight` at rest, whatever a consumer hoped. The pin declares the style and
107 + the build asserts it against the base, so the trap fails the build rather than
108 + reaching a screen — and a consumer names the weight it wants
109 + (`font-weight: 200 800` in the `@font-face`, and the browser resolves `normal`
110 + to 400) instead of loading the file and taking what it opens at.
111 +
78 112 ## Adding a base
79 113
80 114 Pin it in `bases/pins.toml` with its sha256, then read its licence. Most OFL
@@ -86,6 +120,20 @@
86 120 A base also has to be measurable: it needs `|`, `-` and `+`, since those are
87 121 what the recipes refit against. One missing is an error rather than a guess.
88 122
123 + A base arrives one of two ways and the pin says which. A project that publishes
124 + releases gets an archive `url` plus a `path` per face; one that publishes none
125 + gets a `url` per face instead, pinned at a commit. The shape that is deliberately
126 + missing is a generated source tarball: GitHub builds those on demand and has
127 + changed the bytes before, which would read here as "upstream moved" and mean
128 + nothing of the sort.
129 +
130 + Two more refusals, both about assumptions the pipeline makes elsewhere. A base
131 + that varies on more than one axis needs a decision about the master grid, since
132 + drawing at each end of one axis says nothing about the corners of two. And a
133 + base whose advances vary across its axis needs an advance decision first: every
134 + mark here takes the cell, which is true of a monospace face and not of a
135 + proportional one.
136 +
89 137 ## Licence
90 138
91 139 The pipeline is MIT. A face it cuts is OFL 1.1, inherited from its base, and
@@ -9,6 +9,13 @@
9 9 # Name, and clause 3 bars it as a prefix and as a suffix alike. The `quasi-*`
10 10 # naming clears that by construction, so the only live gate is "did anyone reach
11 11 # for the base's own name". A base that is not OFL at all needs its own read.
12 + #
13 + # A base arrives one of two ways and the pin says which. A release archive gets
14 + # `url` + `sha256` here and a `path` per face; a project that publishes no
15 + # releases gets no archive and a `url` per face instead. The second shape exists
16 + # because the alternative is a generated source tarball, and GitHub builds those
17 + # on demand: the bytes have changed before under repositories nobody touched,
18 + # which would read here as "upstream moved" and mean nothing of the sort.
12 19
13 20 [[base]]
14 21 id = "plex-mono"
@@ -39,6 +46,54 @@
39 46 # family rather than a style. Add the face here when something asks for it.
40 47
41 48
49 + # Atkinson Hyperlegible Mono. Pinned, and not yet cut from: no slot names it.
50 + # Moving `quasi-mono` onto it is GO makeover `aab673c1`, which waits on the box
51 + # drawing and block elements (`872fd945`) because Atkinson ships neither and
52 + # every TUI in the tree draws with them. The pin is here because the variable
53 + # capability is only real if something exercises it, and this is the base the
54 + # decision names.
55 + #
56 + # Variable, `wght` 200-800, one file. Everything about it that is new is in
57 + # `src/vary.rs`; the short version is that a cut keeps the axis, so the marks
58 + # carry `gvar` deltas drawn at each end of it.
59 + #
60 + # WATCH THE DEFAULT INSTANCE. This face defaults to `wght` 200 and its own name
61 + # table reads `Atkinson Hyperlegible Mono ExtraLight`. Keeping the axis means
62 + # keeping that default, so the face below is pinned as `ExtraLight` and the build
63 + # refuses any other style name. A consumer therefore has to ask for the weight it
64 + # wants — `font-weight: 200 800` in the `@font-face` and the browser resolves
65 + # `normal` to 400 — rather than loading the file and getting whatever it opens at.
66 + #
67 + # The licence read: OFL 1.1, and **no Reserved Font Name is declared**. The only
68 + # RFN text in the file is the boilerplate definition, so clause 3 does not bite
69 + # here at all. `Quasi Mono` clears it either way, on its own grounds.
70 + [[base]]
71 + id = "atkinson-mono"
72 + family = "Atkinson Hyperlegible Mono"
73 + version = "2.001"
74 + license = "OFL-1.1"
75 + license_url = "https://raw.githubusercontent.com/googlefonts/atkinson-hyperlegible-next-mono/154d50362016cc3e873eb21d242cd0772384c8f9/OFL.txt"
76 + license_sha256 = "1ebb31cf7393164f20d10c1d48406cddb5314feff8465531cf1e4ba37e9dd740"
77 + copyright = "Copyright 2020-2024 The Atkinson Hyperlegible Mono Project Authors (https://github.com/googlefonts/atkinson-hyperlegible-next-mono)"
78 + designer = "Applied Design Works, Letters from Sweden"
79 + vendor_url = "https://www.brailleinstitute.org/"
80 +
81 + # The project publishes no releases and no tags, so the pin is a file at a
82 + # commit: 154d503, the head of `main` on 2026-08-16.
83 + [[base.face]]
84 + style = "ExtraLight"
85 + url = "https://raw.githubusercontent.com/googlefonts/atkinson-hyperlegible-next-mono/154d50362016cc3e873eb21d242cd0772384c8f9/fonts/variable/AtkinsonHyperlegibleMono%5Bwght%5D.ttf"
86 + sha256 = "5ce8b1698d1ded7dff2178c1a3ad159470085a58ea239e8b2cb88f4fb4a6f646"
87 + variable = true
88 +
89 + # Atkinson Hyperlegible Next, the body slot's base, is deliberately not pinned
90 + # yet. It is proportional and varies its advances (`HVAR`, three regions), and
91 + # this pipeline gives every mark the cell — so the build would refuse it, and
92 + # correctly. What it needs first is an advance decision: what width a mark takes
93 + # in a proportional face and how that width answers the axis. That belongs with
94 + # `aab673c1`, not here.
95 +
96 +
42 97 # A slot is a house type role. The name tracks the slot and not the base, so if
43 98 # `quasi-mono` ever moves off Plex Mono the output is still `Quasi Mono` and the
44 99 # base is named in the metadata, where the licence requires it anyway.
@@ -39,6 +39,21 @@
39 39 # it was wrong: it generalised from the block elements, which are the one thing
40 40 # in the base that does not answer weight at all.
41 41 #
42 + # ON A VARIABLE BASE, the weight term stops being sampled at two points and
43 + # becomes the axis itself. A cut from a variable base keeps that axis, so every
44 + # mark is drawn once per master — the axis default and each end — and the
45 + # differences ship as `gvar` deltas. Nothing here changes: the recipe is already
46 + # parametric in the base's measurements, and a master is the same recipe read at
47 + # another location.
48 + #
49 + # What it puts on every recipe, including the generators the box-drawing set will
50 + # need, is one rule: **a mark's point count and contour order cannot depend on
51 + # the measurements**. Deltas are per point, so two masters of one glyph have to
52 + # be the same polygon at different sizes. A recipe that grew a contour past some
53 + # threshold would interpolate into a different shape halfway along the axis. The
54 + # pipeline refuses that case rather than shipping it, and a generator that emits
55 + # a fixed topology never meets it.
56 + #
42 57 # Adding a glyph is an edit here plus a re-run against every face that consumes
43 58 # the set. Nothing downstream needs touching.
44 59
M src/base.rs +321 -27
@@ -122,6 +122,257 @@
122 122 })
123 123 }
124 124
125 + /// A base's variation axis, and the style its default instance carries.
126 + ///
127 + /// One axis only, deliberately. A face with two would need a grid of masters
128 + /// and a rule for what happens at the corners, and no base in front of us has
129 + /// one; refusing says so instead of interpolating a guess.
130 + #[derive(Debug, Clone)]
131 + pub struct Variation {
132 + pub tag: String,
133 + pub min: f32,
134 + pub default: f32,
135 + pub max: f32,
136 + /// The subfamily name of the instance sitting at the axis default.
137 + ///
138 + /// Read off the face rather than assumed. Atkinson Mono's default is `wght`
139 + /// 200 and this reads `ExtraLight`, which is the trap the whole variable
140 + /// path is written around.
141 + pub default_style: String,
142 + }
143 +
144 + /// One location a mark is drawn at.
145 + ///
146 + /// `peak` is a normalized coordinate and is only ever -1, 0 or 1. `avar` is
147 + /// required to map those three to themselves, so a master at an axis end needs
148 + /// no `avar` arithmetic and the deltas mean the same thing to every rasteriser.
149 + #[derive(Debug, Clone, Copy)]
150 + pub struct Master {
151 + /// User coordinate, e.g. `wght` 800.
152 + pub user: f32,
153 + pub peak: f32,
154 + }
155 +
156 + impl Variation {
157 + /// The default location, which is what the glyph itself is drawn at.
158 + pub fn default_master(&self) -> Master {
159 + Master {
160 + user: self.default,
161 + peak: 0.0,
162 + }
163 + }
164 +
165 + /// The locations that get `gvar` deltas: each end of the axis that is not
166 + /// already the default. Atkinson Mono defaults to its own minimum, so it
167 + /// has one; a face defaulting to the middle of its range has two.
168 + pub fn delta_masters(&self) -> Vec<Master> {
169 + let mut out = Vec::new();
170 + if self.min < self.default {
171 + out.push(Master {
172 + user: self.min,
173 + peak: -1.0,
174 + });
175 + }
176 + if self.max > self.default {
177 + out.push(Master {
178 + user: self.max,
179 + peak: 1.0,
180 + });
181 + }
182 + out
183 + }
184 + }
185 +
186 + /// The base's axis, or `None` when the base is static.
187 + pub fn variation(bytes: &[u8]) -> Result<Option<Variation>, Error> {
188 + use skrifa::MetadataProvider;
189 +
190 + let font =
191 + skrifa::FontRef::new(bytes).map_err(|e| Error::Font(format!("base is unreadable: {e}")))?;
192 + let axes = font.axes();
193 + match axes.len() {
194 + 0 => return Ok(None),
195 + 1 => {}
196 + n => {
197 + return Err(Error::Font(format!(
198 + "the base varies on {n} axes. The pipeline draws a master per axis end, \
199 + which describes one axis and says nothing about the corners of two. \
200 + Decide the master grid before pinning a base like this."
201 + )));
202 + }
203 + }
204 + let axis = axes.get(0).expect("one axis");
205 + let default = axis.default_value();
206 + let default_style = font
207 + .named_instances()
208 + .iter()
209 + .find(|instance| {
210 + instance
211 + .user_coords()
212 + .next()
213 + .is_some_and(|c| (c - default).abs() < f32::EPSILON)
214 + })
215 + .and_then(|instance| {
216 + font.localized_strings(instance.subfamily_name_id())
217 + .english_or_first()
218 + .map(|s| s.chars().collect::<String>())
219 + })
220 + .ok_or_else(|| {
221 + Error::Font(
222 + "the base names no instance at its own axis default, so there is no \
223 + truthful style name for the face a cut produces"
224 + .into(),
225 + )
226 + })?;
227 + Ok(Some(Variation {
228 + tag: axis.tag().to_string(),
229 + min: axis.min_value(),
230 + default,
231 + max: axis.max_value(),
232 + default_style,
233 + }))
234 + }
235 +
236 + /// Measure a variable base at one location on its axis.
237 + ///
238 + /// The static path reads bounding boxes straight out of `glyf`, which is only
239 + /// the default instance. Here the outline is drawn at the location first, so
240 + /// `stroke`, `stem` and the band are the base's real measurements at that
241 + /// weight rather than at its default one.
242 + pub fn measure_at(bytes: &[u8], variation: &Variation, at: Master) -> Result<BaseParams, Error> {
243 + use skrifa::MetadataProvider;
244 + use skrifa::instance::Size;
245 +
246 + let font =
247 + skrifa::FontRef::new(bytes).map_err(|e| Error::Font(format!("base is unreadable: {e}")))?;
248 + let tag = skrifa::Tag::new_checked(variation.tag.as_bytes())
249 + .map_err(|_| Error::Font(format!("`{}` is not an axis tag", variation.tag)))?;
250 + let location = font.axes().location([(tag, at.user)]);
251 + let charmap = font.charmap();
252 + let outlines = font.outline_glyphs();
253 +
254 + let measured = |ch: char, what: &str| -> Result<Extent, Error> {
255 + let gid = charmap.map(ch).ok_or_else(|| Error::UnmeasurableBase {
256 + missing: ch,
257 + what: what.to_owned(),
258 + })?;
259 + let glyph = outlines.get(gid).ok_or_else(|| Error::UnmeasurableBase {
260 + missing: ch,
261 + what: what.to_owned(),
262 + })?;
263 + let mut pen = Extent::default();
264 + glyph
265 + .draw(
266 + skrifa::outline::DrawSettings::unhinted(Size::unscaled(), &location),
267 + &mut pen,
268 + )
269 + .map_err(|e| Error::Font(format!("could not draw `{ch}` at {}: {e}", at.user)))?;
270 + if pen.empty() {
271 + return Err(Error::UnmeasurableBase {
272 + missing: ch,
273 + what: what.to_owned(),
274 + });
275 + }
276 + Ok(pen)
277 + };
278 +
279 + let bar = measured('|', "the vertical stroke weight")?;
280 + let hyphen = measured('-', "the horizontal stroke weight")?;
281 + let plus = measured('+', "the symbol band")?;
282 +
283 + let metrics = font.metrics(Size::unscaled(), &location);
284 + let advance = font
285 + .glyph_metrics(Size::unscaled(), &location)
286 + .advance_width(charmap.map('+').expect("`+` was measured above"))
287 + .ok_or_else(|| Error::Font("base has no advance for `+`".into()))?;
288 +
289 + Ok(BaseParams {
290 + upem: metrics.units_per_em,
291 + advance: advance.round() as u16,
292 + cap_height: round_i16(metrics.cap_height.unwrap_or(0.0)),
293 + x_height: round_i16(metrics.x_height.unwrap_or(0.0)),
294 + stem: bar.width(),
295 + stroke: hyphen.height(),
296 + band_x0: round_i16(plus.x0),
297 + band_x1: round_i16(plus.x1),
298 + band_y0: round_i16(plus.y0),
299 + band_y1: round_i16(plus.y1),
300 + })
301 + }
302 +
303 + /// A drawn outline's extent, accumulated straight off the pen.
304 + #[derive(Debug, Clone, Copy)]
305 + struct Extent {
306 + x0: f32,
307 + y0: f32,
308 + x1: f32,
309 + y1: f32,
310 + }
311 +
312 + impl Default for Extent {
313 + fn default() -> Self {
314 + Self {
315 + x0: f32::MAX,
316 + y0: f32::MAX,
317 + x1: f32::MIN,
318 + y1: f32::MIN,
319 + }
320 + }
321 + }
322 +
323 + impl Extent {
324 + fn empty(self) -> bool {
325 + self.x0 > self.x1 || self.y0 > self.y1
326 + }
327 +
328 + fn width(self) -> i16 {
329 + round_i16(self.x1 - self.x0)
330 + }
331 +
332 + fn height(self) -> i16 {
333 + round_i16(self.y1 - self.y0)
334 + }
335 +
336 + fn add(&mut self, x: f32, y: f32) {
337 + self.x0 = self.x0.min(x);
338 + self.y0 = self.y0.min(y);
339 + self.x1 = self.x1.max(x);
340 + self.y1 = self.y1.max(y);
341 + }
342 + }
343 +
344 + /// Control points count toward the extent the same way the stored `glyf`
345 + /// bounding box counts them, so the two measurement paths agree on a base that
346 + /// happens to be readable both ways.
347 + impl skrifa::outline::OutlinePen for Extent {
348 + fn move_to(&mut self, x: f32, y: f32) {
349 + self.add(x, y);
350 + }
351 +
352 + fn line_to(&mut self, x: f32, y: f32) {
353 + self.add(x, y);
354 + }
355 +
356 + fn quad_to(&mut self, cx0: f32, cy0: f32, x: f32, y: f32) {
357 + self.add(cx0, cy0);
358 + self.add(x, y);
359 + }
360 +
361 + fn curve_to(&mut self, cx0: f32, cy0: f32, cx1: f32, cy1: f32, x: f32, y: f32) {
362 + self.add(cx0, cy0);
363 + self.add(cx1, cy1);
364 + self.add(x, y);
365 + }
366 +
367 + fn close(&mut self) {}
368 + }
369 +
370 + fn round_i16(value: f32) -> i16 {
371 + value
372 + .round()
373 + .clamp(f32::from(i16::MIN), f32::from(i16::MAX)) as i16
374 + }
375 +
125 376 fn table_err(tag: &'static str) -> impl Fn(read_fonts::ReadError) -> Error {
126 377 move |e| Error::Font(format!("base has no readable `{tag}` table: {e}"))
127 378 }
@@ -197,34 +448,17 @@
197 448 /// differently: the first says upstream moved, the second says the pin names a
198 449 /// path that no longer holds what it did.
199 450 pub fn load(base: &Base, cache: &Path, offline: bool) -> Result<Vec<BaseFace>, Error> {
200 - let archive = cache.join(format!("{}-{}.zip", base.id, base.version));
201 - if !archive.exists() {
202 - if offline {
203 - return Err(Error::Offline {
204 - wanted: archive.clone(),
205 - url: base.url.clone(),
206 - });
207 - }
208 - fetch(&base.url, &archive)?;
451 + if base.is_archive() {
452 + return load_from_archive(base, cache, offline);
209 453 }
210 -
211 - let bytes = std::fs::read(&archive).map_err(|e| Error::Io(archive.clone(), e))?;
212 - verify(&bytes, &base.sha256).map_err(|found| Error::ArchiveChecksum {
213 - path: archive.clone(),
214 - url: base.url.clone(),
215 - expected: base.sha256.clone(),
216 - found,
217 - })?;
218 -
219 - let cursor = std::io::Cursor::new(&bytes);
220 - let mut zip = zip::ZipArchive::new(cursor)
221 - .map_err(|e| Error::Archive(format!("{} is not a readable zip: {e}", archive.display())))?;
222 -
223 454 let mut faces = Vec::new();
224 455 for face in &base.faces {
225 - let data = read_entry(&mut zip, &face.path)?;
226 - verify(&data, &face.sha256).map_err(|found| Error::FaceChecksum {
227 - path: face.path.clone(),
456 + let url = face.url.as_deref().unwrap_or_default();
457 + let path = cache.join(face.cache_name(&base.id, &base.version));
458 + let data = cached(url, &path, offline)?;
459 + verify(&data, &face.sha256).map_err(|found| Error::ArchiveChecksum {
460 + path,
461 + url: url.to_owned(),
228 462 expected: face.sha256.clone(),
229 463 found,
230 464 })?;
@@ -236,15 +470,75 @@
236 470 Ok(faces)
237 471 }
238 472
473 + fn load_from_archive(base: &Base, cache: &Path, offline: bool) -> Result<Vec<BaseFace>, Error> {
474 + let url = base.url.as_deref().unwrap_or_default();
475 + let archive = cache.join(format!("{}-{}.zip", base.id, base.version));
476 + let bytes = cached(url, &archive, offline)?;
477 + verify(&bytes, base.sha256.as_deref().unwrap_or_default()).map_err(|found| {
478 + Error::ArchiveChecksum {
479 + path: archive.clone(),
480 + url: url.to_owned(),
481 + expected: base.sha256.clone().unwrap_or_default(),
482 + found,
483 + }
484 + })?;
485 +
486 + let cursor = std::io::Cursor::new(&bytes);
487 + let mut zip = zip::ZipArchive::new(cursor)
488 + .map_err(|e| Error::Archive(format!("{} is not a readable zip: {e}", archive.display())))?;
489 +
490 + let mut faces = Vec::new();
491 + for face in &base.faces {
492 + let path = face.path.as_deref().unwrap_or_default();
493 + let data = read_entry(&mut zip, path)?;
494 + verify(&data, &face.sha256).map_err(|found| Error::FaceChecksum {
495 + path: path.to_owned(),
496 + expected: face.sha256.clone(),
497 + found,
498 + })?;
499 + faces.push(BaseFace {
500 + style: face.style.clone(),
501 + bytes: data,
502 + });
503 + }
504 + Ok(faces)
505 + }
506 +
507 + /// Read a pinned file from the cache, fetching it first if it is not there.
508 + fn cached(url: &str, path: &Path, offline: bool) -> Result<Vec<u8>, Error> {
509 + if !path.exists() {
510 + if offline {
511 + return Err(Error::Offline {
512 + wanted: path.to_path_buf(),
513 + url: url.to_owned(),
514 + });
515 + }
516 + fetch(url, path)?;
517 + }
518 + std::fs::read(path).map_err(|e| Error::Io(path.to_path_buf(), e))
519 + }
520 +
239 521 /// The upstream licence text, which travels with every build the OFL requires
240 522 /// it to.
241 - pub fn license_text(base: &Base, cache: &Path) -> Result<Vec<u8>, Error> {
523 + pub fn license_text(base: &Base, cache: &Path, offline: bool) -> Result<Vec<u8>, Error> {
524 + if let Some(url) = &base.license_url {
525 + let path = cache.join(format!("{}-{}-LICENSE.txt", base.id, base.version));
526 + let data = cached(url, &path, offline)?;
527 + if let Some(expected) = &base.license_sha256 {
528 + verify(&data, expected).map_err(|found| Error::FaceChecksum {
529 + path: url.clone(),
530 + expected: expected.clone(),
531 + found,
532 + })?;
533 + }
534 + return Ok(data);
535 + }
242 536 let archive = cache.join(format!("{}-{}.zip", base.id, base.version));
243 537 let bytes = std::fs::read(&archive).map_err(|e| Error::Io(archive.clone(), e))?;
244 538 let cursor = std::io::Cursor::new(&bytes);
245 539 let mut zip = zip::ZipArchive::new(cursor)
246 540 .map_err(|e| Error::Archive(format!("{} is not a readable zip: {e}", archive.display())))?;
247 - read_entry(&mut zip, &base.license_path)
541 + read_entry(&mut zip, base.license_path.as_deref().unwrap_or_default())
248 542 }
249 543
250 544 fn read_entry<R: std::io::Read + std::io::Seek>(
M src/compose.rs +182 -10
@@ -17,10 +17,11 @@
17 17 use write_fonts::types::{Fixed, NameId, Version16Dot16};
18 18
19 19 use crate::Error;
20 - use crate::base::{self, BaseParams};
20 + use crate::base::{self, BaseParams, Variation};
21 21 use crate::draw;
22 22 use crate::manifest::GlyphSpec;
23 23 use crate::pins::Base;
24 + use crate::vary;
24 25
25 26 /// What the pipeline stamps into a face's `name` table.
26 27 pub struct Identity<'a> {
@@ -46,6 +47,9 @@
46 47 pub bytes: Vec<u8>,
47 48 pub added: Vec<(u32, String)>,
48 49 pub params: BaseParams,
50 + /// The axis the face carries, when its base had one. A cut keeps the axis
51 + /// rather than instancing it away, so this is the base's `fvar` unchanged.
52 + pub variation: Option<Variation>,
49 53 }
50 54
51 55 /// Tables the pipeline rebuilds. Everything else is copied verbatim.
@@ -69,7 +73,27 @@
69 73 pub fn build(base_bytes: &[u8], glyphs: &[&GlyphSpec], id: &Identity<'_>) -> Result<Built, Error> {
70 74 let font =
71 75 FontRef::new(base_bytes).map_err(|e| Error::Font(format!("base is unreadable: {e}")))?;
72 - let params = base::measure(base_bytes)?;
76 + let variation = base::variation(base_bytes)?;
77 + if let Some(axis) = &variation {
78 + check_default_instance(id, axis)?;
79 + check_advances_hold_still(&font)?;
80 + }
81 + // A variable base is measured at its axis default rather than out of `glyf`,
82 + // which is the same location by definition and the same numbers for a static
83 + // face. The other masters are measured the same way, one location each.
84 + let params = match &variation {
85 + Some(axis) => base::measure_at(base_bytes, axis, axis.default_master())?,
86 + None => base::measure(base_bytes)?,
87 + };
88 + let masters: Vec<(f32, BaseParams)> = variation
89 + .iter()
90 + .flat_map(|axis| {
91 + axis.delta_masters()
92 + .into_iter()
93 + .map(|at| Ok((at.peak, base::measure_at(base_bytes, axis, at)?)))
94 + .collect::<Vec<_>>()
95 + })
96 + .collect::<Result<_, Error>>()?;
73 97 let mut mappings = base::mappings(base_bytes)?;
74 98
75 99 let head = font.head().map_err(missing("head"))?;
@@ -80,6 +104,7 @@
80 104 // --- the new glyphs, compiled ------------------------------------------
81 105
82 106 let mut appended: Vec<(GlyphId, &GlyphSpec, Vec<u8>, Bounds)> = Vec::new();
107 + let mut variations: Vec<Vec<u8>> = Vec::new();
83 108 for (index, spec) in glyphs.iter().enumerate() {
84 109 if let Some(existing) = mappings.get(&spec.codepoint) {
85 110 // Not a merge tool. A base that already draws a mark keeps its own,
@@ -90,10 +115,26 @@
90 115 gid: existing.to_u32(),
91 116 });
92 117 }
93 - let path = draw::draw(&spec.shape, &params).to_bezpath();
94 - let simple = SimpleGlyph::from_bezpath(&path)
95 - .map_err(|e| Error::Draw(format!("{}: {e:?}", spec.name)))?;
118 + let simple = compile_drawing(spec, &params)?;
96 119 let bounds = Bounds::of(&simple);
120 + if variation.is_some() {
121 + // The same recipe at another location, which is what makes a master
122 + // a measurement rather than a second drawing.
123 + let varied = vary::Varied {
124 + default: simple.clone(),
125 + masters: masters
126 + .iter()
127 + .map(|(peak, at)| Ok((*peak, compile_drawing(spec, at)?)))
128 + .collect::<Result<_, Error>>()?,
129 + };
130 + variations.push(match varied.deltas(&spec.name)? {
131 + Some(deltas) => vary::compile(deltas)?,
132 + // A zero-length entry, which is how `gvar` says "this glyph does
133 + // not vary". Every appended glyph needs one so the offsets stay
134 + // in step with the glyph ids.
135 + None => Vec::new(),
136 + });
137 + }
97 138 let bytes = write_fonts::dump_table(&Glyph::Simple(simple))
98 139 .map_err(|e| Error::Draw(format!("{}: {e}", spec.name)))?;
99 140 let gid = GlyphId::from(base_glyph_count + index as u16);
@@ -191,7 +232,7 @@
191 232 )
192 233 .map_err(|e| Error::Font(format!("cmap: {e}")))?;
193 234
194 - let name = name_table(id);
235 + let name = name_table(id, variation.as_ref(), &font);
195 236 // `post` 3.0: the base ships 2.0 with a name per glyph, and a 2.0 table has
196 237 // to carry exactly `numGlyphs` entries. Extending it would mean inventing
197 238 // names for the seven and rewriting the base's, and nothing on any target
@@ -231,6 +272,22 @@
231 272 .add_table(&post)
232 273 .map_err(|e| Error::Font(format!("post: {e}")))?;
233 274
275 + // --- gvar ---------------------------------------------------------------
276 +
277 + let gvar_tag = Tag::new(b"gvar");
278 + if variation.is_some() {
279 + let base_gvar = table_bytes(&font, gvar_tag).map_err(|_| {
280 + Error::Font(
281 + "the base has an `fvar` axis and no `gvar`, so its own glyphs do not \
282 + vary. Nothing here can tell what a mark should do on an axis the base \
283 + does not use."
284 + .into(),
285 + )
286 + })?;
287 + let axis_count = u16::try_from(variation.iter().len()).unwrap_or(1);
288 + builder.add_raw(gvar_tag, vary::splice(base_gvar, &variations, axis_count)?);
289 + }
290 +
234 291 // Everything the pipeline does not touch is carried across as bytes, minus
235 292 // the tables that modification invalidates.
236 293 for record in font.table_directory().table_records() {
@@ -239,6 +296,7 @@
239 296 || DROPPED.contains(&tag)
240 297 || tag == Tag::new(b"head")
241 298 || tag == Tag::new(b"OS/2")
299 + || (variation.is_some() && tag == gvar_tag)
242 300 {
243 301 continue;
244 302 }
@@ -253,9 +311,62 @@
253 311 .map(|(_, spec, _, _)| (spec.codepoint, spec.name.clone()))
254 312 .collect(),
255 313 params,
314 + variation,
256 315 })
257 316 }
258 317
318 + /// One mark, drawn against one set of measurements.
319 + fn compile_drawing(spec: &GlyphSpec, params: &BaseParams) -> Result<SimpleGlyph, Error> {
320 + let path = draw::draw(&spec.shape, params).to_bezpath();
321 + SimpleGlyph::from_bezpath(&path).map_err(|e| Error::Draw(format!("{}: {e:?}", spec.name)))
322 + }
323 +
324 + /// The declared style has to be the style of the base's default instance.
325 + ///
326 + /// This is the ExtraLight trap, made mechanical. Atkinson Mono's `fvar` default
327 + /// is `wght` 200 and its own `name` table reads `Atkinson Hyperlegible Mono
328 + /// ExtraLight`, so a face cut from it and labelled `Regular` would be a file
329 + /// that says one weight and draws another — and every naive `@font-face` and
330 + /// every `fc-match` would take it at its word.
331 + fn check_default_instance(id: &Identity<'_>, axis: &Variation) -> Result<(), Error> {
332 + if id.style == axis.default_style {
333 + return Ok(());
334 + }
335 + Err(Error::DefaultInstance {
336 + declared: id.style.to_owned(),
337 + actual: axis.default_style.clone(),
338 + tag: axis.tag.clone(),
339 + at: axis.default,
340 + })
341 + }
342 +
343 + /// A mark takes the cell at every weight, so the base's advances have to hold
344 + /// still for `hmtx` alone to describe them.
345 + ///
346 + /// `HVAR` is where a variable face puts advance deltas, and a glyph appended
347 + /// past the end of its mapping picks up whatever the last entry says. On a
348 + /// monospace base there is nothing there to pick up — Atkinson Mono ships an
349 + /// `HVAR` with no regions at all — and on a proportional one the mark would
350 + /// change width with weight for reasons nobody chose. Refusing here is what
351 + /// keeps that from being discovered by a shifted terminal grid.
352 + fn check_advances_hold_still(font: &FontRef<'_>) -> Result<(), Error> {
353 + let Ok(hvar) = font.hvar() else {
354 + return Ok(());
355 + };
356 + let regions = hvar
357 + .item_variation_store()
358 + .and_then(|store| store.variation_region_list())
359 + .map_or(0, |list| list.region_count());
360 + if regions == 0 {
361 + return Ok(());
362 + }
363 + Err(Error::Font(format!(
364 + "the base's advances vary across its axis ({regions} `HVAR` regions), and this \
365 + pipeline gives every mark the cell. Cutting a proportional variable base needs \
366 + an advance decision first: what width a mark takes, and how that width varies."
367 + )))
368 + }
369 +
259 370 struct Bounds {
260 371 x_min: i16,
261 372 y_min: i16,
@@ -343,7 +454,7 @@
343 454 .map_or(0, |b| i16::from_be_bytes([b[0], b[1]]))
344 455 }
345 456
346 - fn name_table(id: &Identity<'_>) -> Name {
457 + fn name_table(id: &Identity<'_>, variation: Option<&Variation>, base: &FontRef<'_>) -> Name {
347 458 let full = format!("{} {}", id.family, id.style);
348 459 let postscript = format!(
349 460 "{}-{}",
@@ -363,10 +474,23 @@
363 474 The letterforms are unmodified.",
364 475 id.base.family, id.base.version, id.set_version
365 476 );
366 - let records = vec![
477 + // A variable face is named the way its own default instance forces. The
478 + // family a naive consumer reads (name 1) can only carry the four RIBBI
479 + // styles, so a default instance of ExtraLight goes into the family name and
480 + // leaves `Regular` in the subfamily, with the honest pair in the typographic
481 + // records. That is exactly what upstream does with the same face, and it is
482 + // what keeps `Quasi Mono` one family in a menu instead of seven.
483 + let ribbi = matches!(id.style, "Regular" | "Italic" | "Bold" | "Bold Italic");
484 + let (family, subfamily) = if variation.is_some() && !ribbi {
485 + (full.clone(), "Regular".to_owned())
486 + } else {
487 + (id.family.to_owned(), id.style.to_owned())
488 + };
489 +
490 + let mut records = vec![
367 491 record(NameId::COPYRIGHT_NOTICE, &id.base.copyright),
368 - record(NameId::FAMILY_NAME, id.family),
369 - record(NameId::SUBFAMILY_NAME, id.style),
492 + record(NameId::FAMILY_NAME, &family),
493 + record(NameId::SUBFAMILY_NAME, &subfamily),
370 494 // Names both halves of the composition rather than the packed version,
371 495 // so the record that exists to identify one exact build identifies it
372 496 // without a decoder ring.
@@ -389,6 +513,19 @@
389 513 ),
390 514 record(NameId::LICENSE_URL, "https://openfontlicense.org"),
391 515 ];
516 + if variation.is_some() {
517 + // The typographic pair is where the face states what it really is, and
518 + // name 25 is the stem every instance's PostScript name is built from.
519 + // Without it, an application generating one for `wght` 700 would build
520 + // it out of the base's prefix and hand back the upstream's name.
521 + records.push(record(NameId::TYPOGRAPHIC_FAMILY_NAME, id.family));
522 + records.push(record(NameId::TYPOGRAPHIC_SUBFAMILY_NAME, id.style));
523 + records.push(record(
524 + NameId::VARIATIONS_POSTSCRIPT_NAME_PREFIX,
525 + &id.family.replace(' ', ""),
526 + ));
527 + }
528 +
392 529 let mut all = Vec::with_capacity(records.len() * 2);
393 530 for (name_id, value) in records {
394 531 // Windows/Unicode BMP, English (US), which is the pair every consumer
@@ -396,10 +533,45 @@
396 533 all.push(NameRecord::new(3, 1, 0x0409, name_id, value.clone().into()));
397 534 all.push(NameRecord::new(1, 0, 0, name_id, value.into()));
398 535 }
536 + all.extend(inherited_names(base));
399 537 all.sort_by_key(|r| (r.platform_id, r.encoding_id, r.language_id, r.name_id));
538 + all.dedup_by_key(|r| (r.platform_id, r.encoding_id, r.language_id, r.name_id));
400 539 Name::new(all)
401 540 }
402 541
542 + /// The name records above 255, carried across from the base unchanged.
543 + ///
544 + /// These are not descriptive text: they are the strings other tables point at
545 + /// by number. `fvar` names its axis and every named instance that way, `STAT`
546 + /// names its axis values, and `GSUB` labels its stylistic sets. Rebuilding the
547 + /// table from the ten standard records alone left all of those dangling — the
548 + /// static cut has been shipping Plex Mono's stylistic sets with no names since
549 + /// the first build, and a variable cut would lose the names of its own seven
550 + /// weights.
551 + ///
552 + /// They are not rewritten, only kept. "Weight", "ExtraLight" and "alternate
553 + /// lowercase l" describe the base's design rather than our packaging of it.
554 + fn inherited_names(base: &FontRef<'_>) -> Vec<NameRecord> {
555 + let Ok(name) = base.name() else {
556 + return Vec::new();
557 + };
558 + let data = name.string_data();
559 + name.name_record()
560 + .iter()
561 + .filter(|record| record.name_id().to_u16() >= 256)
562 + .filter_map(|record| {
563 + let value: String = record.string(data).ok()?.chars().collect();
564 + Some(NameRecord::new(
565 + record.platform_id(),
566 + record.encoding_id(),
567 + record.language_id(),
568 + record.name_id(),
569 + value.into(),
570 + ))
571 + })
572 + .collect()
573 + }
574 +
403 575 fn record(name_id: NameId, value: &str) -> (NameId, String) {
404 576 (name_id, value.to_owned())
405 577 }
M src/lib.rs +20
@@ -21,6 +21,7 @@
21 21 pub mod draw;
22 22 pub mod manifest;
23 23 pub mod pins;
24 + pub mod vary;
24 25 pub mod woff2;
25 26
26 27 /// The house glyph set, shipped with the pipeline that consumes it.
@@ -46,6 +47,12 @@
46 47 missing: char,
47 48 what: String,
48 49 },
50 + DefaultInstance {
51 + declared: String,
52 + actual: String,
53 + tag: String,
54 + at: f32,
55 + },
49 56 AlreadyDrawn {
50 57 codepoint: u32,
51 58 base: String,
@@ -98,6 +105,19 @@
98 105 A base that cannot be measured cannot be refitted against, and guessing \
99 106 would give the marks a weight that does not match their neighbours."
100 107 ),
108 + Error::DefaultInstance {
109 + declared,
110 + actual,
111 + tag,
112 + at,
113 + } => write!(
114 + f,
115 + "the pin calls this face `{declared}` and the base's default instance is \
116 + `{actual}` ({tag} {at}). A variable cut keeps the base's axis, so it also \
117 + keeps the base's default, and a face labelled with a weight it does not \
118 + draw at rest is one every naive `@font-face` and every `fc-match` will \
119 + believe. Name the face `{actual}` in the pin, or instance the base first."
120 + ),
101 121 Error::AlreadyDrawn {
102 122 codepoint,
103 123 base,
M src/main.rs +62 -10
@@ -20,6 +20,7 @@
20 20 Options
21 21 --out <dir> where faces are written (default: out/)
22 22 --offline fail rather than fetch a base that is not cached
23 + --base <id> measure a pinned base directly, for a base no slot names yet
23 24 ";
24 25
25 26 fn main() -> ExitCode {
@@ -37,10 +38,18 @@
37 38 let mut positional: Vec<&str> = Vec::new();
38 39 let mut out = PathBuf::from("out");
39 40 let mut offline = false;
41 + let mut base_id: Option<&str> = None;
40 42 let mut rest = args.iter();
41 43 while let Some(arg) = rest.next() {
42 44 match arg.as_str() {
43 45 "--offline" => offline = true,
46 + "--base" => {
47 + base_id = Some(
48 + rest.next()
49 + .map(String::as_str)
50 + .ok_or_else(|| Error::Pins("--base needs a base id".into()))?,
51 + );
52 + }
44 53 "--out" => {
45 54 out = rest
46 55 .next()
@@ -86,12 +95,34 @@
86 95 Ok(())
87 96 }
88 97 Some((&"params", tail)) => {
89 - let slot = pins.slot(tail.first().copied().unwrap_or("quasi-mono"))?;
90 - let base_pin = pins.base(&slot.base)?;
98 + // A base no slot names yet is still measurable, which is how a base
99 + // gets read before anything is cut from it.
100 + let base_pin = match base_id {
101 + Some(id) => pins.base(id)?,
102 + None => pins.base(
103 + &pins
104 + .slot(tail.first().copied().unwrap_or("quasi-mono"))?
105 + .base,
106 + )?,
107 + };
91 108 let faces = base::load(base_pin, &base::cache_dir(&root), offline)?;
92 109 for face in &faces {
93 - let params = base::measure(&face.bytes)?;
94 - print_params(&format!("{} {}", base_pin.family, face.style), &params);
110 + let name = format!("{} {}", base_pin.family, face.style);
111 + match base::variation(&face.bytes)? {
112 + None => print_params(&name, &base::measure(&face.bytes)?),
113 + Some(axis) => {
114 + println!(
115 + "{name} variable: {} {}-{}, default {} ({})",
116 + axis.tag, axis.min, axis.max, axis.default, axis.default_style
117 + );
118 + let mut at = vec![axis.default_master()];
119 + at.extend(axis.delta_masters());
120 + for master in at {
121 + let params = base::measure_at(&face.bytes, &axis, master)?;
122 + print_params(&format!(" {} {}", axis.tag, master.user), &params);
123 + }
124 + }
125 + }
95 126 }
96 127 Ok(())
97 128 }
@@ -162,11 +193,17 @@
162 193 let verdict = assert::describe(&coverage)?;
163 194 let web = woff2::encode(&built.bytes)?;
164 195
165 - let stem = format!(
166 - "{}-{}",
167 - slot.family.replace(' ', ""),
168 - face.style.replace(' ', "")
169 - );
196 + // A variable face is named for its axis rather than for a style, the way
197 + // upstream names the file it came from. One face covers the range, and
198 + // the name says so before anyone opens it.
199 + let stem = match &built.variation {
200 + Some(axis) => format!("{}[{}]", slot.family.replace(' ', ""), axis.tag),
201 + None => format!(
202 + "{}-{}",
203 + slot.family.replace(' ', ""),
204 + face.style.replace(' ', "")
205 + ),
206 + };
170 207 println!(
171 208 " {:<22} ttf {:>7} woff2 {:>7} +{} marks, {verdict}",
172 209 format!("{} {}", slot.family, face.style),
@@ -174,6 +211,21 @@
174 211 human(web.len()),
175 212 built.added.len(),
176 213 );
214 + if let Some(axis) = &built.variation {
215 + println!(
216 + " {:<22} {} {}-{}, and the marks vary with it. At rest this face is {} {} \
217 + ({}), so a consumer names the weight it wants: `font-weight: {} {}`.",
218 + "",
219 + axis.tag,
220 + axis.min,
221 + axis.max,
222 + axis.tag,
223 + axis.default,
224 + axis.default_style,
225 + axis.min,
226 + axis.max,
227 + );
228 + }
177 229
178 230 if write {
179 231 write_file(&out.join(format!("{stem}.ttf")), &built.bytes)?;
@@ -185,7 +237,7 @@
185 237 // OFL 1.1 requires the licence to travel with a modified build, and the
186 238 // gap this closes is live: MNW serves three families with no licence
187 239 // beside them today.
188 - let license = base::license_text(base_pin, &cache)?;
240 + let license = base::license_text(base_pin, &cache, offline)?;
189 241 write_file(&out.join("OFL.txt"), &license)?;
190 242 println!(" {:<22} the base's licence, as OFL requires", "OFL.txt");
191 243 println!("\nwritten to {}", out.display());
M src/pins.rs +111 -6
@@ -18,11 +18,22 @@
18 18 /// The upstream family name. Never reused in an output name.
19 19 pub family: String,
20 20 pub version: String,
21 - pub url: String,
22 - /// sha256 of the archive upstream publishes. A moved upstream fails here.
23 - pub sha256: String,
21 + /// The archive upstream publishes, when it publishes one. Absent for a base
22 + /// pinned file by file; see `Face::url`.
23 + #[serde(default)]
24 + pub url: Option<String>,
25 + /// sha256 of the archive. A moved upstream fails here.
26 + #[serde(default)]
27 + pub sha256: Option<String>,
24 28 pub license: String,
25 - pub license_path: String,
29 + /// Path to the licence inside the archive.
30 + #[serde(default)]
31 + pub license_path: Option<String>,
32 + /// Where the licence is fetched from, for a base with no archive.
33 + #[serde(default)]
34 + pub license_url: Option<String>,
35 + #[serde(default)]
36 + pub license_sha256: Option<String>,
26 37 /// OFL 1.1 clause 3, recorded so the naming gate is data rather than lore.
27 38 #[serde(default)]
28 39 pub reserved_font_name: Option<String>,
@@ -36,10 +47,41 @@
36 47
37 48 #[derive(Debug, Deserialize)]
38 49 pub struct Face {
50 + /// The style this face carries. For a variable face it is the style of the
51 + /// **default instance**, which is not always `Regular`: Atkinson Mono's
52 + /// default is `wght` 200 and its own name table reads `ExtraLight`. The
53 + /// build asserts this against the base rather than trusting it, so the
54 + /// ExtraLight default cannot arrive by accident.
39 55 pub style: String,
40 - /// Path inside the archive.
41 - pub path: String,
56 + /// Path inside the archive, for an archive-pinned base.
57 + #[serde(default)]
58 + pub path: Option<String>,
59 + /// Where the face is fetched from, for a base with no archive.
60 + #[serde(default)]
61 + pub url: Option<String>,
42 62 pub sha256: String,
63 + /// Declares that this face carries an `fvar` axis. Asserted against the
64 + /// face, so a base that stops being variable fails rather than quietly
65 + /// cutting a static instance.
66 + #[serde(default)]
67 + pub variable: bool,
68 + }
69 +
70 + impl Face {
71 + /// What the cached copy is called. A path inside an archive keeps its file
72 + /// name; a file-pinned face is named for its pin, since two bases may
73 + /// publish files with the same name.
74 + pub fn cache_name(&self, base_id: &str, base_version: &str) -> String {
75 + let leaf = self
76 + .path
77 + .as_deref()
78 + .or(self.url.as_deref())
79 + .unwrap_or(&self.style)
80 + .rsplit('/')
81 + .next()
82 + .unwrap_or(&self.style);
83 + format!("{base_id}-{base_version}-{leaf}")
84 + }
43 85 }
44 86
45 87 #[derive(Debug, Deserialize)]
@@ -93,6 +135,9 @@
93 135 impl Pins {
94 136 pub fn parse(source: &str) -> Result<Self, Error> {
95 137 let pins: Pins = toml::from_str(source).map_err(|e| Error::Pins(e.to_string()))?;
138 + for base in &pins.bases {
139 + base.check_pinning()?;
140 + }
96 141 for slot in &pins.slots {
97 142 if !pins.bases.iter().any(|b| b.id == slot.base) {
98 143 return Err(Error::Pins(format!(
@@ -123,6 +168,66 @@
123 168 }
124 169
125 170 impl Base {
171 + /// Whether the base is fetched as one archive or as pinned files.
172 + ///
173 + /// Both shapes exist upstream and neither is a preference. IBM publishes a
174 + /// release zip; the Atkinson repositories publish no releases at all, so the
175 + /// only stable thing to pin is a file at a commit. A source tarball would be
176 + /// the third option and is the worst of them: GitHub generates it on demand
177 + /// and has changed the bytes it generates before, which would read here as
178 + /// "upstream moved" on a repository nobody touched.
179 + pub fn is_archive(&self) -> bool {
180 + self.url.is_some()
181 + }
182 +
183 + /// A base is pinned one way or the other, never half of each.
184 + fn check_pinning(&self) -> Result<(), Error> {
185 + let id = &self.id;
186 + if self.url.is_some() != self.sha256.is_some() {
187 + return Err(Error::Pins(format!(
188 + "base `{id}` gives an archive url without its sha256, or the reverse"
189 + )));
190 + }
191 + for face in &self.faces {
192 + match (self.is_archive(), face.path.is_some(), face.url.is_some()) {
193 + (true, true, false) | (false, false, true) => {}
194 + (true, _, _) => {
195 + return Err(Error::Pins(format!(
196 + "base `{id}` is pinned as an archive, so face `{}` needs a `path` \
197 + inside it and no `url` of its own",
198 + face.style
199 + )));
200 + }
201 + (false, _, _) => {
202 + return Err(Error::Pins(format!(
203 + "base `{id}` has no archive, so face `{}` needs its own `url`",
204 + face.style
205 + )));
206 + }
207 + }
208 + }
209 + match (
210 + self.is_archive(),
211 + self.license_path.is_some(),
212 + self.license_url.is_some(),
213 + ) {
214 + (true, true, false) | (false, false, true) => {}
215 + (true, _, _) => {
216 + return Err(Error::Pins(format!(
217 + "base `{id}` is pinned as an archive, so its licence needs a `license_path`"
218 + )));
219 + }
220 + (false, _, _) => {
221 + return Err(Error::Pins(format!(
222 + "base `{id}` has no archive, so its licence needs a `license_url`. \
223 + The OFL requires the text to travel with a modified build, so a base \
224 + with nowhere to read it from cannot be cut."
225 + )));
226 + }
227 + }
228 + Ok(())
229 + }
230 +
126 231 /// The one licence gate that is mechanical: an output name must not carry
127 232 /// the base's Reserved Font Name. OFL 1.1 clause 3 bars it as a prefix and
128 233 /// as a suffix alike, so this is a substring test and not a word test.
A src/vary.rs +400
@@ -1,0 +1,400 @@
1 + //! Making a drawn mark answer the base's axis.
2 + //!
3 + //! A static base is cut once per weight, so a mark is drawn once per weight and
4 + //! that is the end of it. A variable base is one file covering `wght` 200 to
5 + //! 800, and a glyph spliced into it with no variation data holds still across
6 + //! the whole range: correct at the default instance, and progressively wrong
7 + //! everywhere else. A bold table border would draw at the light stroke weight.
8 + //!
9 + //! So the pipeline **keeps the axis** rather than instancing it away. That was
10 + //! the open question and it is settled here: instancing is cheaper and throws
11 + //! away the thing the base was chosen for, and the set's `weight` term already
12 + //! describes a relationship the axis makes continuous instead of sampled at two
13 + //! points. The recipes are parametric in the base's own measurements, so a
14 + //! master is just the same recipe measured at another location — no second
15 + //! drawing, no second source of truth.
16 + //!
17 + //! What that costs is `gvar`, which this module writes. The base's own entries
18 + //! are copied as bytes for the same reason `glyf` is spliced rather than
19 + //! recompiled: round-tripping a font's variation data to append to it risks
20 + //! changing a face nobody asked us to change.
21 +
22 + use read_fonts::types::GlyphId;
23 + use write_fonts::tables::glyf::SimpleGlyph;
24 + use write_fonts::tables::gvar::{GlyphDelta, GlyphDeltas, GlyphVariations, Gvar, Tent};
25 + use write_fonts::types::F2Dot14;
26 +
27 + use crate::Error;
28 +
29 + /// The four points every glyph carries past its outline: the two side bearings
30 + /// and the two vertical ones. `gvar` counts them, and a tuple that varies the
31 + /// outline has to account for them even when they do not move.
32 + const PHANTOM_POINTS: usize = 4;
33 +
34 + /// One mark, drawn at every master.
35 + pub struct Varied {
36 + /// The drawing at the axis default, which is what goes into `glyf`.
37 + pub default: SimpleGlyph,
38 + /// `(peak, drawing)` for each master that gets deltas.
39 + pub masters: Vec<(f32, SimpleGlyph)>,
40 + }
41 +
42 + impl Varied {
43 + /// The per-point offsets from the default drawing, one tuple per master.
44 + ///
45 + /// Returns `None` when nothing moves anywhere, which is a real answer: a
46 + /// mark whose recipe ignores the base's weight has no variation data and
47 + /// should not be given an empty tuple that says so at length.
48 + pub fn deltas(&self, name: &str) -> Result<Option<Vec<GlyphDeltas>>, Error> {
49 + let default = points(&self.default);
50 + let mut out = Vec::new();
51 + for (peak, drawing) in &self.masters {
52 + let master = points(drawing);
53 + if master.len() != default.len() {
54 + // The recipes are parametric, so this cannot happen from a
55 + // weight change alone; it would take a recipe that adds a
56 + // contour past some threshold. Catching it here beats shipping
57 + // a font whose glyph interpolates into a different shape.
58 + return Err(Error::Draw(format!(
59 + "{name} has {} points at the axis default and {} at {peak}, so the \
60 + two cannot interpolate. A mark's point count has to be the same at \
61 + every master.",
62 + default.len(),
63 + master.len()
64 + )));
65 + }
66 + let mut deltas: Vec<GlyphDelta> = default
67 + .iter()
68 + .zip(&master)
69 + .map(|(from, to)| GlyphDelta::required(to.0 - from.0, to.1 - from.1))
70 + .collect();
71 + // The phantom points do not move: a mark takes the cell at every
72 + // weight, which is what `hmtx` already says.
73 + deltas.extend(std::iter::repeat_n(
74 + GlyphDelta::optional(0, 0),
75 + PHANTOM_POINTS,
76 + ));
77 + if deltas.iter().all(|d| d.x == 0 && d.y == 0) {
78 + continue;
79 + }
80 + out.push(GlyphDeltas::new(
81 + vec![Tent::new(F2Dot14::from_f32(*peak), None)],
82 + deltas,
83 + ));
84 + }
85 + Ok((!out.is_empty()).then_some(out))
86 + }
87 + }
88 +
89 + fn points(glyph: &SimpleGlyph) -> Vec<(i16, i16)> {
90 + glyph
91 + .contours
92 + .iter()
93 + .flat_map(|contour| contour.iter().map(|point| (point.x, point.y)))
94 + .collect()
95 + }
96 +
97 + /// Compile one glyph's variation data into the bytes `gvar` stores for it.
98 + ///
99 + /// Done a glyph at a time on purpose. `Gvar::new` pulls any peak tuple used by
100 + /// more than one glyph into the table's shared-tuple array and leaves an index
101 + /// behind, and an index into *our* shared array means nothing inside the base's
102 + /// table. One glyph per call cannot share with anything, so what comes back is
103 + /// self-contained and safe to splice.
104 + pub fn compile(variations: Vec<GlyphDeltas>) -> Result<Vec<u8>, Error> {
105 + let gvar = Gvar::new(vec![GlyphVariations::new(GlyphId::new(0), variations)], 1)
106 + .map_err(|e| Error::Font(format!("gvar: {e}")))?;
107 + let bytes = write_fonts::dump_table(&gvar).map_err(|e| Error::Font(format!("gvar: {e}")))?;
108 + let table = Table::read(&bytes)?;
109 + let (start, end) = table.entry(0)?;
110 + Ok(table.bytes[start..end].to_vec())
111 + }
112 +
113 + /// Splice per-glyph variation data onto the end of the base's `gvar`.
114 + ///
115 + /// The base's glyphs keep the bytes they arrived with, exactly as they keep
116 + /// their `glyf` entries. Offsets are rewritten in the long format
117 + /// unconditionally: it is one code path, and the short format stores offsets
118 + /// halved, so a table that outgrows it mid-splice would need rewriting anyway.
119 + pub fn splice(base: &[u8], appended: &[Vec<u8>], axis_count: u16) -> Result<Vec<u8>, Error> {
120 + let table = Table::read(base)?;
121 + if table.axis_count != axis_count {
122 + return Err(Error::Font(format!(
123 + "the base's `gvar` varies on {} axes and its `fvar` names {axis_count}",
124 + table.axis_count
125 + )));
126 + }
127 +
128 + let mut data = table.bytes[table.data_start..table.data_end()].to_vec();
129 + let mut offsets: Vec<u32> = (0..=table.glyph_count)
130 + .map(|i| table.offset(i))
131 + .collect::<Result<_, _>>()?;
132 + for entry in appended {
133 + data.extend_from_slice(entry);
134 + // Every entry starts on an even boundary, which the short offset format
135 + // requires and the long one is written to match.
136 + if data.len() % 2 != 0 {
137 + data.push(0);
138 + }
139 + offsets.push(data.len() as u32);
140 + }
141 +
142 + let glyph_count = table.glyph_count + appended.len() as u32;
143 + let shared = &table.bytes[table.shared_start..table.shared_start + table.shared_len];
144 + let header = 20;
145 + let offsets_len = (glyph_count as usize + 1) * 4;
146 + let shared_offset = header + offsets_len;
147 + let data_offset = shared_offset + shared.len();
148 +
149 + let mut out = Vec::with_capacity(data_offset + data.len());
150 + out.extend_from_slice(&0x0001_0000u32.to_be_bytes());
151 + out.extend_from_slice(&axis_count.to_be_bytes());
152 + out.extend_from_slice(&table.shared_count.to_be_bytes());
153 + out.extend_from_slice(&(shared_offset as u32).to_be_bytes());
154 + out.extend_from_slice(&u16::try_from(glyph_count).map_err(too_many)?.to_be_bytes());
155 + // Flag 1: the offsets below are `u32` rather than halved `u16`.
156 + out.extend_from_slice(&1u16.to_be_bytes());
157 + out.extend_from_slice(&(data_offset as u32).to_be_bytes());
158 + for offset in &offsets {
159 + out.extend_from_slice(&offset.to_be_bytes());
160 + }
161 + out.extend_from_slice(shared);
162 + out.extend_from_slice(&data);
163 + Ok(out)
164 + }
165 +
166 + fn too_many(_: std::num::TryFromIntError) -> Error {
167 + Error::Font("a face cannot carry more than 65,535 glyphs".into())
168 + }
169 +
170 + /// Just enough of `gvar` to copy the parts the pipeline does not rewrite.
171 + ///
172 + /// read-fonts parses this table into deltas, which is the wrong shape here: the
173 + /// point is to move the base's bytes across without decoding them.
174 + struct Table<'a> {
175 + bytes: &'a [u8],
176 + axis_count: u16,
177 + shared_count: u16,
178 + shared_start: usize,
179 + shared_len: usize,
180 + glyph_count: u32,
181 + long_offsets: bool,
182 + offsets_start: usize,
183 + data_start: usize,
184 + }
185 +
186 + impl<'a> Table<'a> {
187 + fn read(bytes: &'a [u8]) -> Result<Self, Error> {
188 + let short = || Error::Font("the base's `gvar` is truncated".into());
189 + let u16_at = |at: usize| -> Result<u16, Error> {
190 + bytes
191 + .get(at..at + 2)
192 + .map(|b| u16::from_be_bytes([b[0], b[1]]))
193 + .ok_or_else(short)
194 + };
195 + let u32_at = |at: usize| -> Result<u32, Error> {
196 + bytes
197 + .get(at..at + 4)
198 + .map(|b| u32::from_be_bytes([b[0], b[1], b[2], b[3]]))
199 + .ok_or_else(short)
200 + };
201 +
202 + let axis_count = u16_at(4)?;
203 + let shared_count = u16_at(6)?;
204 + let shared_start = u32_at(8)? as usize;
205 + let glyph_count = u32::from(u16_at(12)?);
206 + let long_offsets = u16_at(14)? & 1 == 1;
207 + let data_start = u32_at(16)? as usize;
208 + Ok(Table {
209 + bytes,
210 + axis_count,
211 + shared_count,
212 + shared_start,
213 + shared_len: shared_count as usize * axis_count as usize * 2,
214 + glyph_count,
215 + long_offsets,
216 + offsets_start: 20,
217 + data_start,
218 + })
219 + }
220 +
221 + /// Offsets are stored halved in the short format, which is what makes a
222 + /// misread here silently produce a face whose glyphs are someone else's.
223 + fn offset(&self, index: u32) -> Result<u32, Error> {
224 + let short = || Error::Font("the base's `gvar` offset array is truncated".into());
225 + if self.long_offsets {
226 + let at = self.offsets_start + index as usize * 4;
227 + self.bytes
228 + .get(at..at + 4)
229 + .map(|b| u32::from_be_bytes([b[0], b[1], b[2], b[3]]))
230 + .ok_or_else(short)
231 + } else {
232 + let at = self.offsets_start + index as usize * 2;
233 + self.bytes
234 + .get(at..at + 2)
235 + .map(|b| u32::from(u16::from_be_bytes([b[0], b[1]])) * 2)
236 + .ok_or_else(short)
237 + }
238 + }
239 +
240 + fn data_end(&self) -> usize {
241 + self.data_start + self.offset(self.glyph_count).unwrap_or(0) as usize
242 + }
243 +
244 + /// The byte range one glyph's variation data occupies, relative to the
245 + /// start of the data array.
246 + fn entry(&self, index: u32) -> Result<(usize, usize), Error> {
247 + Ok((
248 + self.data_start + self.offset(index)? as usize,
249 + self.data_start + self.offset(index + 1)? as usize,
250 + ))
251 + }
252 +
253 + #[cfg(test)]
254 + fn glyph_data(&self, index: u32) -> Result<&'a [u8], Error> {
255 + let (start, end) = self.entry(index)?;
256 + self.bytes
257 + .get(start..end)
258 + .ok_or_else(|| Error::Font("the base's `gvar` is truncated".into()))
259 + }
260 + }
261 +
262 + #[cfg(test)]
263 + mod tests {
264 + use super::*;
265 + use read_fonts::tables::glyf::CurvePoint;
266 + use write_fonts::tables::glyf::Contour;
267 +
268 + fn glyph(points: &[(i16, i16)]) -> SimpleGlyph {
269 + let contour: Contour = points
270 + .iter()
271 + .map(|&(x, y)| CurvePoint::on_curve(x, y))
272 + .collect::<Vec<_>>()
273 + .into();
274 + SimpleGlyph {
275 + contours: vec![contour],
276 + ..Default::default()
277 + }
278 + }
279 +
280 + fn varied(default: &[(i16, i16)], bold: &[(i16, i16)]) -> Varied {
281 + Varied {
282 + default: glyph(default),
283 + masters: vec![(1.0, glyph(bold))],
284 + }
285 + }
286 +
287 + #[test]
288 + fn a_mark_that_moves_gets_a_tuple_per_master() {
289 + let varied = varied(
290 + &[(0, 0), (100, 0), (50, 90)],
291 + &[(0, 0), (120, 0), (60, 108)],
292 + );
293 + let deltas = varied.deltas("uni25B2").unwrap().expect("it moves");
294 + assert_eq!(deltas.len(), 1);
295 + }
296 +
297 + /// A recipe with no weight term draws the same mark everywhere, and an
298 + /// empty tuple would only cost bytes to say so.
299 + #[test]
300 + fn a_mark_that_holds_still_gets_no_variation_data() {
301 + let still = [(0, 0), (100, 0), (50, 90)];
302 + assert!(varied(&still, &still).deltas("uni2588").unwrap().is_none());
303 + }
304 +
305 + #[test]
306 + fn a_mark_that_changes_point_count_is_refused() {
307 + let varied = varied(&[(0, 0), (100, 0), (50, 90)], &[(0, 0), (120, 0)]);
308 + let err = varied.deltas("uni25B2").unwrap_err().to_string();
309 + assert!(err.contains("interpolate"), "{err}");
310 + }
311 +
312 + /// The deltas are offsets from the default drawing, and the phantom points
313 + /// ride along without moving.
314 + #[test]
315 + fn the_deltas_are_offsets_from_the_default_drawing() {
316 + let varied = varied(&[(10, 10)], &[(30, 4)]);
317 + let deltas = varied.deltas("m").unwrap().unwrap();
318 + let first = &deltas[0];
319 + assert_eq!(first.deltas.len(), 1 + PHANTOM_POINTS);
320 + assert_eq!((first.deltas[0].x, first.deltas[0].y), (20, -6));
321 + assert!(first.deltas[1..].iter().all(|d| d.x == 0 && d.y == 0));
322 + }
323 +
324 + /// Compiled data has to stand on its own: a peak tuple embedded in the
325 + /// entry rather than an index into a shared array the base does not have.
326 + #[test]
327 + fn compiled_data_embeds_its_peak_rather_than_sharing_it() {
328 + let varied = Varied {
329 + default: glyph(&[(0, 0), (100, 0), (50, 90)]),
330 + masters: vec![
331 + (1.0, glyph(&[(0, 0), (120, 0), (60, 108)])),
332 + (-1.0, glyph(&[(0, 0), (90, 0), (45, 81)])),
333 + ],
334 + };
335 + let entry = compile(varied.deltas("uni25B2").unwrap().unwrap()).unwrap();
336 + let count = u16::from_be_bytes([entry[0], entry[1]]);
337 + // Bit 15 of tupleVariationCount is the shared-point-numbers flag; the
338 + // low twelve bits are the count.
339 + assert_eq!(count & 0x0FFF, 2, "one tuple per master");
340 + // The first tuple header sits past the count and the data offset; its
341 + // `tupleIndex` is the second field of that header.
342 + let flags = u16::from_be_bytes([entry[6], entry[7]]);
343 + assert_eq!(flags & 0x8000, 0x8000, "EMBEDDED_PEAK_TUPLE");
344 + }
345 +
346 + /// The point of splicing: the base's own entries survive byte for byte.
347 + #[test]
348 + fn splicing_leaves_every_base_entry_where_it_was() {
349 + let base = Gvar::new(
350 + (0..3)
351 + .map(|gid| {
352 + GlyphVariations::new(
353 + GlyphId::new(gid),
354 + vec![GlyphDeltas::new(
355 + vec![Tent::new(F2Dot14::from_f32(1.0), None)],
356 + vec![
357 + GlyphDelta::required(gid as i16 + 1, 0),
358 + GlyphDelta::required(0, gid as i16 + 2),
359 + ],
360 + )],
361 + )
362 + })
363 + .collect(),
364 + 1,
365 + )
366 + .unwrap();
367 + let base = write_fonts::dump_table(&base).unwrap();
368 + let mark = compile(
369 + varied(
370 + &[(0, 0), (100, 0), (50, 90)],
371 + &[(0, 0), (120, 0), (60, 108)],
372 + )
373 + .deltas("m")
374 + .unwrap()
375 + .unwrap(),
376 + )
377 + .unwrap();
378 +
379 + let out = splice(&base, std::slice::from_ref(&mark), 1).unwrap();
380 + let before = Table::read(&base).unwrap();
381 + let after = Table::read(&out).unwrap();
382 + assert_eq!(after.glyph_count, before.glyph_count + 1);
383 + assert!(after.long_offsets);
384 + for gid in 0..before.glyph_count {
385 + assert_eq!(
386 + after.glyph_data(gid).unwrap(),
387 + before.glyph_data(gid).unwrap(),
388 + "glyph {gid}'s variation data changed"
389 + );
390 + }
391 + assert_eq!(after.glyph_data(before.glyph_count).unwrap(), mark);
392 + }
393 +
394 + #[test]
395 + fn a_gvar_whose_axis_count_disagrees_with_fvar_is_refused() {
396 + let base = Gvar::new(vec![GlyphVariations::new(GlyphId::new(0), vec![])], 1).unwrap();
397 + let base = write_fonts::dump_table(&base).unwrap();
398 + assert!(splice(&base, &[], 2).is_err());
399 + }
400 + }
@@ -1,0 +1,383 @@
1 + //! Cutting a face from a variable base, which is the capability itself.
2 + //!
3 + //! The base is Atkinson Hyperlegible Mono: `wght` 200-800 in one file, pinned
4 + //! and not yet adopted. Moving `quasi-mono` onto it is GO `aab673c1` and waits
5 + //! on the box-drawing set; what is asserted here is that the pipeline can cut
6 + //! from it at all, and that the marks answer the axis once it has.
7 + //!
8 + //! Skipped rather than failed when the base is not cached, same as the static
9 + //! cut. `cargo run -- params --base atkinson-mono` once and it runs for good.
10 +
11 + use quasi_type::base::{self, BaseFace};
12 + use quasi_type::compose::{self, Identity};
13 + use quasi_type::manifest::{GlyphSpec, Manifest};
14 + use quasi_type::pins::{Base, Pins};
15 + use quasi_type::{HOUSE_SET, PINS, assert as coverage, woff2};
16 +
17 + use read_fonts::types::{GlyphId, Tag};
18 + use read_fonts::{FontRef, TableProvider};
19 + use skrifa::MetadataProvider;
20 + use skrifa::instance::Size;
21 +
22 + const BASE: &str = "atkinson-mono";
23 +
24 + /// The family the slot decision names. Nothing is written here, and no slot in
25 + /// `pins.toml` points at this base yet: adopting it is `aab673c1`.
26 + const FAMILY: &str = "Quasi Mono";
27 +
28 + fn root() -> std::path::PathBuf {
29 + std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
30 + }
31 +
32 + fn pins() -> Pins {
33 + Pins::parse(PINS).unwrap()
34 + }
35 +
36 + fn base_face() -> Option<(BaseFace, &'static Base)> {
37 + let pins: &'static Pins = Box::leak(Box::new(pins()));
38 + let base = pins.base(BASE).unwrap();
39 + match base::load(base, &base::cache_dir(&root()), true) {
40 + Ok(faces) => faces.into_iter().next().map(|face| (face, base)),
41 + Err(_) => {
42 + eprintln!("skipped: {BASE} is not cached. Run `cargo run -- params --base {BASE}`");
43 + None
44 + }
45 + }
46 + }
47 +
48 + fn cut_as(style: &str) -> Option<Result<(Vec<u8>, Vec<GlyphSpec>), String>> {
49 + let (face, base) = base_face()?;
50 + let specs = Manifest::parse(HOUSE_SET).unwrap().glyphs;
51 + let refs: Vec<&GlyphSpec> = specs.iter().collect();
52 + let id = Identity {
53 + family: FAMILY,
54 + style,
55 + version: "1.2.001",
56 + set_version: 1,
57 + base,
58 + };
59 + Some(
60 + compose::build(&face.bytes, &refs, &id)
61 + .map(|built| (built.bytes, specs))
62 + .map_err(|e| e.to_string()),
63 + )
64 + }
65 +
66 + fn cut() -> Option<(Vec<u8>, Vec<GlyphSpec>)> {
67 + Some(cut_as("ExtraLight")?.expect("the face cuts"))
68 + }
69 +
70 + /// The whole point: the cut face still varies, and so do the marks in it.
71 + #[test]
72 + fn the_axis_survives_the_cut_and_the_marks_ride_it() {
73 + let Some((bytes, specs)) = cut() else { return };
74 + let font = FontRef::new(&bytes).unwrap();
75 +
76 + for tag in [b"fvar", b"gvar", b"avar", b"STAT", b"HVAR"] {
77 + assert!(
78 + font.table_data(Tag::new(tag)).is_some(),
79 + "{} was dropped, so the face no longer varies the way its base did",
80 + String::from_utf8_lossy(tag)
81 + );
82 + }
83 +
84 + let axes = font.axes();
85 + assert_eq!(axes.len(), 1);
86 + let axis = axes.get(0).unwrap();
87 + assert_eq!(axis.tag(), Tag::new(b"wght"));
88 + assert_eq!((axis.min_value(), axis.max_value()), (200.0, 800.0));
89 + assert_eq!(
90 + font.named_instances().len(),
91 + 7,
92 + "the base's seven named weights"
93 + );
94 +
95 + // Every mark has variation data, and it is data the base did not have.
96 + let mappings = compose::coverage(&bytes).unwrap();
97 + let gvar = font.gvar().unwrap();
98 + for spec in &specs {
99 + let gid = mappings[&spec.codepoint];
100 + let data = gvar
101 + .glyph_variation_data(gid)
102 + .unwrap_or_else(|e| panic!("{} has no readable variation data: {e:?}", spec.name));
103 + assert!(
104 + data.is_some_and(|d| d.tuples().count() > 0),
105 + "{} carries no variation, so it would draw at wght 200 across the whole axis",
106 + spec.name
107 + );
108 + }
109 + }
110 +
111 + /// The done condition, in the form it was written in: a mark drawn at the bold
112 + /// end of the axis is drawn at the bold stroke weight.
113 + ///
114 + /// Read back through the axis rather than off the deltas, so what is measured is
115 + /// what a rasteriser would actually put on a screen.
116 + #[test]
117 + fn a_mark_at_the_heavy_end_is_heavier_than_at_the_light_end() {
118 + let Some((bytes, specs)) = cut() else { return };
119 + for spec in &specs {
120 + let light = ink(&bytes, spec.codepoint, 200.0);
121 + let heavy = ink(&bytes, spec.codepoint, 800.0);
122 + assert!(
123 + heavy > light * 1.10,
124 + "{} puts {light:.0} units of ink down at wght 200 and {heavy:.0} at 800, \
125 + which is not a face answering its own axis",
126 + spec.name
127 + );
128 + }
129 + }
130 +
131 + /// The two kinds of mark answer the axis differently, and the discriminator is
132 + /// how much of its own box a mark inks rather than how wide it gets.
133 + ///
134 + /// Extent alone does not separate them here and should not be expected to. The
135 + /// static cut compares Regular to Bold, 400 to 700; this axis runs 200 to 800
136 + /// and Atkinson's stroke goes 55 units to 139, so a stroked mark's band term
137 + /// carries it 1.15x wider on its own. Fill separates them cleanly, because it is
138 + /// what the two responses actually mean: a solid mark scales, so it inks the
139 + /// same share of a larger box, and a stroked mark thickens inside a box that
140 + /// barely moves.
141 + #[test]
142 + fn solid_marks_scale_along_the_axis_and_stroked_marks_thicken() {
143 + use quasi_type::manifest::WeightResponse;
144 +
145 + let Some((bytes, specs)) = cut() else { return };
146 + for spec in &specs {
147 + let light = fill(&bytes, spec.codepoint, 200.0);
148 + let heavy = fill(&bytes, spec.codepoint, 800.0);
149 + match spec.shape.weight_response() {
150 + WeightResponse::Grows => {
151 + assert!(
152 + (heavy - light).abs() < 0.01,
153 + "{} inks {light:.3} of its box at wght 200 and {heavy:.3} at 800. \
154 + A solid mark scales, so its fill is the shape's own constant.",
155 + spec.name
156 + );
157 + let growth =
158 + width(&bytes, spec.codepoint, 800.0) / width(&bytes, spec.codepoint, 200.0);
159 + assert!(
160 + growth > 1.10,
161 + "{} grew {growth:.2}x across the whole axis, which is not a mark \
162 + following a base whose stroke goes 55 units to 139",
163 + spec.name
164 + );
165 + }
166 + WeightResponse::Thickens => assert!(
167 + heavy > light * 1.4,
168 + "{} inks {light:.3} of its box at wght 200 and {heavy:.3} at 800, so its \
169 + stroke is not following the base's",
170 + spec.name
171 + ),
172 + }
173 + }
174 + }
175 +
176 + /// The ExtraLight trap, as a gate rather than as a warning in a comment.
177 + ///
178 + /// Keeping the axis means keeping the base's default instance, and this base's
179 + /// default is `wght` 200. A cut that called itself Regular would be a file every
180 + /// naive `@font-face` and every `fc-match` believes.
181 + #[test]
182 + fn a_face_that_misnames_the_default_instance_is_refused() {
183 + let Some(result) = cut_as("Regular") else {
184 + return;
185 + };
186 + let err = result.expect_err("Regular is not this base's default instance");
187 + assert!(err.contains("ExtraLight"), "{err}");
188 + assert!(err.contains("wght 200"), "{err}");
189 + }
190 +
191 + /// The names other tables point at by number survive, which is what makes the
192 + /// seven named instances still have names.
193 + #[test]
194 + fn the_names_the_axis_points_at_come_across() {
195 + let Some((bytes, _)) = cut() else { return };
196 + let font = FontRef::new(&bytes).unwrap();
197 + let instances: Vec<String> = font
198 + .named_instances()
199 + .iter()
200 + .map(|instance| {
201 + font.localized_strings(instance.subfamily_name_id())
202 + .english_or_first()
203 + .map(|s| s.chars().collect())
204 + .unwrap_or_default()
205 + })
206 + .collect();
207 + assert!(
208 + instances.iter().any(|n| n == "Bold") && instances.iter().any(|n| n == "ExtraLight"),
209 + "the instances lost their names: {instances:?}"
210 + );
211 +
212 + // The house name reaches the records a menu reads, and the typographic pair
213 + // states what the file really is.
214 + let names = |id: u16| -> String {
215 + font.localized_strings(skrifa::string::StringId::new(id))
216 + .english_or_first()
217 + .map(|s| s.chars().collect())
218 + .unwrap_or_default()
219 + };
220 + assert_eq!(names(1), "Quasi Mono ExtraLight");
221 + assert_eq!(names(2), "Regular");
222 + assert_eq!(names(16), "Quasi Mono");
223 + assert_eq!(names(17), "ExtraLight");
224 + assert_eq!(names(25), "QuasiMono");
225 + assert!(names(10).contains("Atkinson Hyperlegible Mono 2.001"));
226 + }
227 +
228 + /// What the box-drawing task is for, stated as a measurement rather than as a
229 + /// note. Atkinson ships no box drawing and no block elements, so a face cut from
230 + /// it today fails the floor every consumer is entitled to assume — which is why
231 + /// `aab673c1` waits on `872fd945` rather than pointing the slot at this base.
232 + #[test]
233 + fn the_cut_face_is_not_yet_fit_for_the_mono_slot() {
234 + let Some((bytes, specs)) = cut() else { return };
235 + let house: Vec<u32> = specs.iter().map(|g| g.codepoint).collect();
236 + let result = coverage::check(&compose::coverage(&bytes).unwrap(), &house);
237 + assert!(
238 + !result.ok(),
239 + "the base grew box drawing; this test and its blocker are stale"
240 + );
241 + for cell_furniture in [0x2502, 0x2588, 0x258F, 0x2591] {
242 + assert!(
243 + result.missing.contains(&cell_furniture),
244 + "U+{cell_furniture:04X} is covered, so the gap has moved"
245 + );
246 + }
247 + // The seven marks are drawn, so the gap is the base's cell furniture and
248 + // nothing the pipeline was asked to draw.
249 + for mark in &house {
250 + assert!(!result.missing.contains(mark));
251 + }
252 + }
253 +
254 + #[test]
255 + fn the_base_keeps_every_glyph_and_every_codepoint_it_had() {
256 + let Some((face, _)) = base_face() else { return };
257 + let before = base::mappings(&face.bytes).unwrap();
258 + let base_glyphs = FontRef::new(&face.bytes)
259 + .unwrap()
260 + .maxp()
261 + .unwrap()
262 + .num_glyphs();
263 + let (bytes, specs) = cut().unwrap();
264 + let after = compose::coverage(&bytes).unwrap();
265 + for (codepoint, gid) in &before {
266 + assert_eq!(after.get(codepoint), Some(gid), "U+{codepoint:04X} moved");
267 + }
268 + assert_eq!(
269 + FontRef::new(&bytes).unwrap().maxp().unwrap().num_glyphs(),
270 + base_glyphs + specs.len() as u16
271 + );
272 + }
273 +
274 + #[test]
275 + fn the_same_checkout_cuts_the_same_bytes() {
276 + let Some((first, _)) = cut() else { return };
277 + let (second, _) = cut().unwrap();
278 + assert_eq!(first, second, "the build is not reproducible");
279 + }
280 +
281 + #[test]
282 + fn the_woff2_still_encodes() {
283 + let Some((bytes, _)) = cut() else { return };
284 + let web = woff2::encode(&bytes).unwrap();
285 + assert_eq!(&web[0..4], b"wOF2");
286 + assert!(web.len() < bytes.len());
287 + }
288 +
289 + /// A glyph's outline at one point on the axis, as a `(bbox width, ink area)`.
290 + fn outline(bytes: &[u8], codepoint: u32, wght: f32) -> (f32, f32, f32) {
291 + let font = FontRef::new(bytes).unwrap();
292 + let gid: GlyphId = compose::coverage(bytes).unwrap()[&codepoint];
293 + let location = font.axes().location([("wght", wght)]);
294 + let mut pen = Trace::default();
295 + font.outline_glyphs()
296 + .get(gid)
297 + .unwrap()
298 + .draw(
299 + skrifa::outline::DrawSettings::unhinted(Size::unscaled(), &location),
300 + &mut pen,
301 + )
302 + .unwrap();
303 + (pen.x1 - pen.x0, pen.y1 - pen.y0, pen.area.abs() / 2.0)
304 + }
305 +
306 + fn width(bytes: &[u8], codepoint: u32, wght: f32) -> f32 {
307 + outline(bytes, codepoint, wght).0
308 + }
309 +
310 + fn ink(bytes: &[u8], codepoint: u32, wght: f32) -> f32 {
311 + outline(bytes, codepoint, wght).2
312 + }
313 +
314 + /// How much of its own bounding box a mark inks in.
315 + fn fill(bytes: &[u8], codepoint: u32, wght: f32) -> f32 {
316 + let (width, height, ink) = outline(bytes, codepoint, wght);
317 + ink / (width * height)
318 + }
319 +
320 + /// Accumulates a drawn outline's extent and its shoelace area. Every mark in the
321 + /// set is straight-edged, so summing the segments is exact rather than an
322 + /// approximation of a curve.
323 + #[derive(Default)]
324 + struct Trace {
325 + x0: f32,
326 + x1: f32,
327 + y0: f32,
328 + y1: f32,
329 + area: f32,
330 + start: (f32, f32),
331 + at: (f32, f32),
332 + started: bool,
333 + }
334 +
335 + impl Trace {
336 + fn point(&mut self, x: f32, y: f32) {
337 + if !self.started {
338 + self.x0 = x;
339 + self.x1 = x;
340 + self.y0 = y;
341 + self.y1 = y;
342 + self.started = true;
343 + }
344 + self.x0 = self.x0.min(x);
345 + self.x1 = self.x1.max(x);
346 + self.y0 = self.y0.min(y);
347 + self.y1 = self.y1.max(y);
348 + }
349 +
350 + fn edge(&mut self, x: f32, y: f32) {
351 + self.area += self.at.0 * y - x * self.at.1;
352 + self.at = (x, y);
353 + self.point(x, y);
354 + }
355 + }
356 +
357 + impl skrifa::outline::OutlinePen for Trace {
358 + fn move_to(&mut self, x: f32, y: f32) {
359 + self.point(x, y);
360 + self.start = (x, y);
361 + self.at = (x, y);
362 + }
363 +
364 + fn line_to(&mut self, x: f32, y: f32) {
365 + self.edge(x, y);
366 + }
367 +
368 + fn quad_to(&mut self, cx0: f32, cy0: f32, x: f32, y: f32) {
369 + self.edge(cx0, cy0);
370 + self.edge(x, y);
371 + }
372 +
373 + fn curve_to(&mut self, cx0: f32, cy0: f32, cx1: f32, cy1: f32, x: f32, y: f32) {
374 + self.edge(cx0, cy0);
375 + self.edge(cx1, cy1);
376 + self.edge(x, y);
377 + }
378 +
379 + fn close(&mut self) {
380 + let (x, y) = self.start;
381 + self.edge(x, y);
382 + }
383 + }