Skip to main content

max / quasi-type

Cut Quasi Body: the body slot, and the advance decision it needed Atkinson Hyperlegible Next is pinned and `quasi-body` is cut from it, so the standard's two slots are the two halves of one superfamily. It was stock IBM Plex Sans under IBM's own name, on the rule that a slot runs upstream until it needs the house set; taking Atkinson for both slots satisfies that from the other side. THE ADVANCE DECISION. A mark takes the reference glyph's advance - `+`'s, what the recipes measure their band against and what `params` reads the cell off - and answers the axis by pointing at that glyph's own HVAR delta set. On a monospace base that is the cell and nothing changes. On a proportional one it is the width the base sets its own symbols on, and it narrows with `+` where `+` narrows: 606 units at rest, 595 at wght 800. The two rejected options, recorded in `extend_hvar`. Refitting each mark's own drawn width per master is truthful in principle and needs variation data this pipeline would have to invent, for a number within a pixel of the reference glyph's. A fixed advance holding still across the axis is what the mono gets for free and is wrong by a little at the heavy end. What the old refusal was protecting against turns out to be an accident rather than a choice: HVAR's advance map is indexed by glyph id, and a glyph appended past its end inherits whatever the last entry says - one arbitrary glyph's width response. The entries are written now. A base whose advances vary with no HVAR advance mapping at all is still refused, since delta sets are then indexed by glyph id directly. THE SUBSET. `glyphs = "marks"` is the first slot selection that is not `*`. Box drawing and block elements are sized against the cell and have to be cell-exact to tile; a proportional face has no cell and nothing set in one tiles, so a `┌` there draws a corner that joins nothing. The coverage floor follows the role for the same reason: `BODY_SURFACE` is Alloy's list minus the four codepoints that exist to tile. ITALIC is not cut, and the suite ships one. A consumer reaches for italic on letters, and letters are the base's own and unmodified; a house mark in an italic run is the upright mark, which is what every text face does with `▲`. The italic file's sha256 is recorded in the pin against the day something wants a mark slanted.
Author: Max Johnson <me@maxj.phd> · 2026-08-17 14:25 UTC
Signed with PGP, not checked
Commit: 9288815836881bf2506e21fc3626e0b056897dfa
Parent: 75e4e6d
10 files changed, +470 insertions, -61 deletions
M README.md +32 -6
@@ -44,7 +44,9 @@
44 44 and they have to be cell-exact to tile, which is arithmetic. The table is
45 45 derived from those names by `scripts/derive-cell-table.py` and committed.
46 46 - `bases/pins.toml` — the bases, pinned by version and sha256, and the slots cut
47 - from them.
47 + from them. Two today: `quasi-mono` from Atkinson Hyperlegible Mono and
48 + `quasi-body` from Atkinson Hyperlegible Next, the two halves of one
49 + superfamily.
48 50 - `out/` — built faces. Not committed: they rebuild from the checkout, and a
49 51 committed binary is a second source of truth.
50 52
@@ -141,6 +143,28 @@
141 143 heads met in the middle and read as a bowtie. Each had a correct bbox. Draw the
142 144 glyphs on a screen before believing a green suite.
143 145
146 + ## Slots, and what a slot takes
147 +
148 + A slot is a house type role, and the output family tracks the slot rather than
149 + the base: `quasi-mono` moved from IBM Plex Mono to Atkinson without a consumer
150 + changing a family name.
151 +
152 + A slot may take the whole set (`glyphs = "*"`) or the authored marks alone
153 + (`glyphs = "marks"`). `quasi-body` takes the marks: box drawing and block
154 + elements are sized against the cell and have to be cell-exact to tile, and a
155 + proportional face has no cell, so a `┌` in it would draw a corner that joins
156 + nothing. Its coverage floor is the role's for the same reason — a body face is
157 + asked for everything a described screen sets in copy and for none of the four
158 + codepoints that exist to tile.
159 +
160 + **A mark takes the reference glyph's advance**, which is `+`'s: what the recipes
161 + measure their band against and what `quasi-type params` reads the cell off. On a
162 + monospace base that is the cell. On a proportional one it is the width the base
163 + sets its own symbols on, and it answers the axis by pointing at that glyph's own
164 + `HVAR` delta set, so a mark narrows with `+` where `+` narrows. The trap this
165 + replaces is silent: `HVAR`'s advance map is indexed by glyph id, and a glyph
166 + appended past its end inherits whatever the last entry happens to say.
167 +
144 168 ## Adding a base
145 169
146 170 Pin it in `bases/pins.toml` with its sha256, then read its licence. Most OFL
@@ -159,12 +183,14 @@
159 183 changed the bytes before, which would read here as "upstream moved" and mean
160 184 nothing of the sort.
161 185
162 - Two more refusals, both about assumptions the pipeline makes elsewhere. A base
186 + One refusal is left, about an assumption the pipeline makes elsewhere: a base
163 187 that varies on more than one axis needs a decision about the master grid, since
164 - drawing at each end of one axis says nothing about the corners of two. And a
165 - base whose advances vary across its axis needs an advance decision first: every
166 - mark here takes the cell, which is true of a monospace face and not of a
167 - proportional one.
188 + drawing at each end of one axis says nothing about the corners of two.
189 +
190 + A base whose advances vary was the second refusal until 2026-08-17, when the
191 + body slot answered it (see "Slots, and what a slot takes"). What is still
192 + refused is a base whose advances vary and whose `HVAR` carries no advance
193 + mapping, since there is then nothing for an appended glyph to point at.
168 194
169 195 ## Licence
170 196
M bases/pins.toml +57 -10
@@ -60,12 +60,46 @@
60 60 sha256 = "5ce8b1698d1ded7dff2178c1a3ad159470085a58ea239e8b2cb88f4fb4a6f646"
61 61 variable = true
62 62
63 - # Atkinson Hyperlegible Next, the body slot's base, is deliberately not pinned
64 - # yet. It is proportional and varies its advances (`HVAR`, three regions), and
65 - # this pipeline gives every mark the cell — so the build would refuse it, and
66 - # correctly. What it needs first is an advance decision: what width a mark takes
67 - # in a proportional face and how that width answers the axis. That belongs with
68 - # `aab673c1`, not here.
63 + # Atkinson Hyperlegible Next, the body slot's base. Same designers, same axis,
64 + # same licence and no Reserved Font Name; the sans half of the superfamily the
65 + # typography standard is built on.
66 + #
67 + # It is PROPORTIONAL, which is the one thing this pipeline had never cut from.
68 + # Every mark took the cell, which is true of a monospace face and meaningless
69 + # here, so `advance` is what the slot has to answer; see the `quasi-body` slot
70 + # below for what it answers and why.
71 + #
72 + # Its default instance is `Regular` at `wght` 400, unlike the mono's ExtraLight,
73 + # so the ExtraLight trap does not arise on this face.
74 + [[base]]
75 + id = "atkinson-next"
76 + family = "Atkinson Hyperlegible Next"
77 + version = "2.001"
78 + license = "OFL-1.1"
79 + license_url = "https://raw.githubusercontent.com/googlefonts/atkinson-hyperlegible-next/7925f50f649b3813257faf2f4c0b381011f434f1/OFL.txt"
80 + license_sha256 = "aca6a428580965d2297d1b718042dd427c2a9443ece3b0d02d758e161e0c4030"
81 + copyright = "Copyright 2020-2024 The Atkinson Hyperlegible Next Project Authors (https://github.com/googlefonts/atkinson-hyperlegible-next)"
82 + designer = "Applied Design Works, Letters from Sweden"
83 + vendor_url = "https://www.brailleinstitute.org/"
84 +
85 + # No releases and no tags here either, so the pin is a file at a commit:
86 + # 7925f50, the head of `main` on 2026-08-17.
87 + [[base.face]]
88 + style = "Regular"
89 + url = "https://raw.githubusercontent.com/googlefonts/atkinson-hyperlegible-next/7925f50f649b3813257faf2f4c0b381011f434f1/fonts/variable/AtkinsonHyperlegibleNext%5Bwght%5D.ttf"
90 + sha256 = "5a455d1cfa099b601ab70751bb9673e8fe1854dc4500c80e1a220d0d75e31745"
91 + variable = true
92 +
93 + # Italic is not cut, for the same reason it is not cut from the mono and with a
94 + # different amount of doubt. The suite ships a matching italic here
95 + # (`AtkinsonHyperlegibleNext-Italic[wght].ttf`, sha256
96 + # ce9cffed32742ad2d9238c561a93220385e5934cdc02b8eb4097a50efa957dc6 at the same
97 + # commit) and body text is where an italic actually gets used — but a consumer
98 + # reaches for italic on *letters*, and letters are the base's own and unmodified.
99 + # A house mark in an italic run is the upright mark, which is what every text
100 + # face does with `▲` anyway. So the italic stays stock upstream under upstream's
101 + # name until something wants a mark slanted, and that is a face away, not a
102 + # redesign.
69 103
70 104
71 105 # A slot is a house type role. The name tracks the slot and not the base, which
@@ -80,7 +114,20 @@
80 114 # Every glyph in the house set. A slot may take a subset; none does today.
81 115 glyphs = "*"
82 116
83 - # `quasi-body` is stock IBM Plex Sans and is deliberately absent. A slot runs the
84 - # upstream face under the upstream's own name until it needs the house set; the
85 - # day a described surface wants a mark in body text, it gets an entry here and
86 - # becomes `Quasi Body`.
117 + # `quasi-body`, cut for the first time 2026-08-17. It was stock IBM Plex Sans
118 + # with no entry here, on the rule that a slot runs the upstream face under the
119 + # upstream's own name until it needs the house set. Taking Atkinson for both
120 + # slots satisfies that condition from the other side: the body slot needs the
121 + # same marks the mono slot does, for the same reason, so it becomes a real cut.
122 + #
123 + # `marks` rather than `*`, and this is the first slot to take a subset. Box
124 + # drawing and block elements are sized against the cell and have to be
125 + # cell-exact to tile; a proportional face has no cell, and nothing set in one
126 + # tiles, so a `┌` here would be a corner that joins nothing. The marks are the
127 + # half that carries meaning rather than geometry, and meaning is the same in
128 + # body text as in a terminal — which is the whole argument for a house set.
129 + [[slot]]
130 + id = "quasi-body"
131 + family = "Quasi Body"
132 + base = "atkinson-next"
133 + glyphs = "marks"
M src/assert.rs +33 -5
@@ -27,6 +27,20 @@
27 27 0x2588, 0x258F, 0x2591, 0x25B8, 0x25C2, 0x2718,
28 28 ];
29 29
30 + /// What a **body** face has to cover, which is Alloy's list minus the cell grid.
31 + ///
32 + /// `│ █ ▏ ░` are a terminal's: they exist to tile, and a proportional face has
33 + /// no cell to tile into. Everything else on the list is text a described screen
34 + /// sets in body copy as readily as in a status line, so it stays.
35 + ///
36 + /// The floor is per role rather than per face because that is what it means: it
37 + /// says what a consumer in that role emits, and a slot answers to the floor of
38 + /// the role it fills.
39 + pub const BODY_SURFACE: [u32; 14] = [
40 + 0x00B7, 0x00BB, 0x2014, 0x2022, 0x2026, 0x2191, 0x2192, 0x2193, 0x2195, 0x23CE, 0x2423, 0x25B8,
41 + 0x25C2, 0x2718,
42 + ];
43 +
30 44 /// The sort carets, which Alloy does not emit but every described screen does.
31 45 pub const SORT_CARETS: [u32; 2] = [0x25B2, 0x25BC];
32 46
@@ -42,8 +56,11 @@
42 56 }
43 57
44 58 /// Check a built face against everything a consumer is entitled to assume.
45 - pub fn check(mappings: &BTreeMap<u32, GlyphId>, house_set: &[u32]) -> Coverage {
46 - let mut required: Vec<u32> = ALLOY_SURFACE
59 + ///
60 + /// `floor` is the role's surface — `ALLOY_SURFACE` for a face that will answer
61 + /// `monospace`, `BODY_SURFACE` for one that will answer `sans-serif`.
62 + pub fn check(floor: &[u32], mappings: &BTreeMap<u32, GlyphId>, house_set: &[u32]) -> Coverage {
63 + let mut required: Vec<u32> = floor
47 64 .iter()
48 65 .chain(SORT_CARETS.iter())
49 66 .chain(house_set.iter())
@@ -89,7 +106,7 @@
89 106
90 107 #[test]
91 108 fn the_floor_is_the_twenty_the_done_condition_names() {
92 - let coverage = check(&mapping(&[]), &[]);
109 + let coverage = check(&ALLOY_SURFACE, &mapping(&[]), &[]);
93 110 assert_eq!(
94 111 coverage.required.len(),
95 112 20,
@@ -101,17 +118,28 @@
101 118 fn a_face_missing_a_caret_fails() {
102 119 let mut all: Vec<u32> = ALLOY_SURFACE.to_vec();
103 120 all.push(0x25B2);
104 - let coverage = check(&mapping(&all), &[]);
121 + let coverage = check(&ALLOY_SURFACE, &mapping(&all), &[]);
105 122 assert_eq!(coverage.missing, vec![0x25BC]);
106 123 assert!(describe(&coverage).is_err());
107 124 }
108 125
126 + /// A body face is not asked for the cell grid, and is asked for everything
127 + /// else. The two floors differ by exactly the four that tile.
128 + #[test]
129 + fn the_body_floor_is_the_terminal_floor_minus_the_cell_grid() {
130 + let terminal: std::collections::BTreeSet<u32> = ALLOY_SURFACE.into_iter().collect();
131 + let body: std::collections::BTreeSet<u32> = BODY_SURFACE.into_iter().collect();
132 + assert!(body.is_subset(&terminal));
133 + let dropped: Vec<u32> = terminal.difference(&body).copied().collect();
134 + assert_eq!(dropped, vec![0x2502, 0x2588, 0x258F, 0x2591]);
135 + }
136 +
109 137 #[test]
110 138 fn a_notdef_mapping_does_not_count_as_coverage() {
111 139 let mut mappings = mapping(&ALLOY_SURFACE);
112 140 mappings.insert(0x25B2, GlyphId::from(0u16));
113 141 mappings.insert(0x25BC, GlyphId::from(0u16));
114 - let coverage = check(&mappings, &[]);
142 + let coverage = check(&ALLOY_SURFACE, &mappings, &[]);
115 143 assert_eq!(coverage.missing, vec![0x25B2, 0x25BC]);
116 144 }
117 145 }
M src/compose.rs +152 -27
@@ -80,7 +80,6 @@
80 80 let variation = base::variation(base_bytes)?;
81 81 if let Some(axis) = &variation {
82 82 check_default_instance(id, axis)?;
83 - check_advances_hold_still(&font)?;
84 83 }
85 84 // A variable base is measured at its axis default rather than out of `glyf`,
86 85 // which is the same location by definition and the same numbers for a static
@@ -99,6 +98,11 @@
99 98 })
100 99 .collect::<Result<_, Error>>()?;
101 100 let mut mappings = base::mappings(base_bytes)?;
101 + // The reference glyph, `+`: what the recipes measure their band against and
102 + // what `quasi-type params` reads the cell off. See `extend_hvar`.
103 + let reference = *mappings
104 + .get(&0x2B)
105 + .ok_or_else(|| Error::Font("base has no `+`, so no mark can be measured".into()))?;
102 106
103 107 let head = font.head().map_err(missing("head"))?;
104 108 let maxp = font.maxp().map_err(missing("maxp"))?;
@@ -181,6 +185,7 @@
181 185 &font,
182 186 base_glyph_count,
183 187 hhea.number_of_h_metrics(),
188 + params.advance,
184 189 &appended,
185 190 )?;
186 191 let mut hhea_bytes = table_bytes(&font, Tag::new(b"hhea"))?.to_vec();
@@ -299,6 +304,18 @@
299 304 builder.add_raw(gvar_tag, vary::splice(base_gvar, &variations, axis_count)?);
300 305 }
301 306
307 + // --- HVAR ---------------------------------------------------------------
308 +
309 + let hvar_tag = Tag::new(b"HVAR");
310 + let has_hvar = font
311 + .table_directory()
312 + .table_records()
313 + .iter()
314 + .any(|r| r.tag() == hvar_tag);
315 + if has_hvar {
316 + builder.add_raw(hvar_tag, extend_hvar(&font, reference, appended.len())?);
317 + }
318 +
302 319 // Everything the pipeline does not touch is carried across as bytes, minus
303 320 // the tables that modification invalidates.
304 321 for record in font.table_directory().table_records() {
@@ -307,6 +324,7 @@
307 324 || DROPPED.contains(&tag)
308 325 || tag == Tag::new(b"head")
309 326 || tag == Tag::new(b"OS/2")
327 + || tag == hvar_tag
310 328 || (variation.is_some() && tag == gvar_tag)
311 329 {
312 330 continue;
@@ -352,31 +370,134 @@
352 370 })
353 371 }
354 372
355 - /// A mark takes the cell at every weight, so the base's advances have to hold
356 - /// still for `hmtx` alone to describe them.
373 + /// THE ADVANCE DECISION, settled 2026-08-17 cutting the body slot from Atkinson
374 + /// Hyperlegible Next: **a mark takes the reference glyph's advance, and answers
375 + /// the axis by pointing at that glyph's own `HVAR` delta set.**
357 376 ///
358 - /// `HVAR` is where a variable face puts advance deltas, and a glyph appended
359 - /// past the end of its mapping picks up whatever the last entry says. On a
360 - /// monospace base there is nothing there to pick up — Atkinson Mono ships an
361 - /// `HVAR` with no regions at all — and on a proportional one the mark would
362 - /// change width with weight for reasons nobody chose. Refusing here is what
363 - /// keeps that from being discovered by a shifted terminal grid.
364 - fn check_advances_hold_still(font: &FontRef<'_>) -> Result<(), Error> {
365 - let Ok(hvar) = font.hvar() else {
366 - return Ok(());
367 - };
368 - let regions = hvar
369 - .item_variation_store()
370 - .and_then(|store| store.variation_region_list())
371 - .map_or(0, |list| list.region_count());
372 - if regions == 0 {
373 - return Ok(());
377 + /// The reference glyph is `+`, which is already what `quasi-type params` reads
378 + /// the cell off and what the recipes measure their band against. On a monospace
379 + /// base that is the cell and nothing changes. On a proportional one it is the
380 + /// width the base itself gives the symbols these marks are drawn to sit among,
381 + /// which is the honest answer to "how wide is a triangle in a face where glyphs
382 + /// have their own widths": as wide as the base sets its own symbols.
383 + ///
384 + /// The two rejected options are worth recording. Refitting each mark's own
385 + /// drawn width per master is the truthful-in-principle answer and needs
386 + /// variation data this pipeline would have to invent — new `HVAR` regions for
387 + /// a number that lands within a pixel of the reference glyph's anyway. Pinning
388 + /// a fixed advance that holds still across the axis is what the mono does for
389 + /// free, and on a proportional base it is wrong by a little at the heavy end,
390 + /// where Next narrows `+` from 606 units to 595.
391 + ///
392 + /// What this rules out is the accident: `HVAR`'s advance map is indexed by glyph
393 + /// id, and a glyph appended past its end picks up **whatever the last entry
394 + /// says**, which is one arbitrary glyph's width response. So the entries are
395 + /// written rather than left to fall off the end.
396 + fn extend_hvar(font: &FontRef<'_>, reference: GlyphId, added: usize) -> Result<Vec<u8>, Error> {
397 + let bytes = table_bytes(font, Tag::new(b"HVAR"))?.to_vec();
398 + if added == 0 || bytes.len() < 20 {
399 + return Ok(bytes);
374 400 }
375 - Err(Error::Font(format!(
376 - "the base's advances vary across its axis ({regions} `HVAR` regions), and this \
377 - pipeline gives every mark the cell. Cutting a proportional variable base needs \
378 - an advance decision first: what width a mark takes, and how that width varies."
379 - )))
401 + let regions = font
402 + .hvar()
403 + .ok()
404 + .and_then(|hvar| hvar.item_variation_store().ok())
405 + .and_then(|store| store.variation_region_list().ok())
406 + .map_or(0, |list| list.region_count());
407 + // No regions is a table that describes no variation, which is what a
408 + // monospace face ships. Nothing to point at, and nothing to get wrong.
409 + if regions == 0 {
410 + return Ok(bytes);
411 + }
412 +
413 + let read_u32 = |at: usize| u32::from_be_bytes(bytes[at..at + 4].try_into().unwrap()) as usize;
414 + let map_offset = read_u32(8);
415 + if map_offset == 0 {
416 + return Err(Error::Font(
417 + "the base's advances vary and its `HVAR` carries no advance-width mapping, so \
418 + delta sets are indexed by glyph id directly and an appended glyph indexes past \
419 + the end of the data. Nothing here can say what its advance should do."
420 + .into(),
421 + ));
422 + }
423 + let map = &bytes[map_offset..];
424 + let (entry_format, count, header) = match map[0] {
425 + 0 => (
426 + map[1],
427 + u16::from_be_bytes([map[2], map[3]]) as usize,
428 + 4usize,
429 + ),
430 + 1 => (
431 + map[1],
432 + u32::from_be_bytes([map[2], map[3], map[4], map[5]]) as usize,
433 + 6usize,
434 + ),
435 + other => {
436 + return Err(Error::Font(format!(
437 + "the base's `HVAR` advance mapping is format {other}, which this pipeline \
438 + does not know how to extend"
439 + )));
440 + }
441 + };
442 + let entry_size = ((entry_format & 0x30) >> 4) as usize + 1;
443 + let reference = reference.to_u32() as usize;
444 + // A glyph inside the map has its own entry; one past the end already means
445 + // "the last entry", which is the rule this whole function exists to stop
446 + // relying on by accident.
447 + let at = header + reference.min(count.saturating_sub(1)) * entry_size;
448 + let entry = map
449 + .get(at..at + entry_size)
450 + .ok_or_else(|| Error::Font("the base's `HVAR` advance mapping is short".into()))?
451 + .to_vec();
452 +
453 + // Rebuilt rather than patched in place: the four offsets in the header are
454 + // into the table, so growing one blob has to move the ones after it. Laying
455 + // them out in offset order and recomputing is shorter than deciding which
456 + // of the four happened to be last.
457 + let mut blobs: Vec<(usize, usize)> = (0..4)
458 + .map(|i| read_u32(4 + i * 4))
459 + .filter(|&off| off != 0)
460 + .collect::<std::collections::BTreeSet<_>>()
461 + .into_iter()
462 + .map(|start| (start, 0))
463 + .collect();
464 + for i in 0..blobs.len() {
465 + let end = blobs.get(i + 1).map_or(bytes.len(), |(next, _)| *next);
466 + blobs[i].1 = end;
467 + }
468 +
469 + let mut out = bytes[..20].to_vec();
470 + let mut moved: Vec<(usize, usize)> = Vec::new();
471 + for (start, end) in blobs {
472 + let new_start = out.len();
473 + if start == map_offset {
474 + let mut extended = bytes[start..end].to_vec();
475 + match extended[0] {
476 + 0 => extended[2..4].copy_from_slice(&((count + added) as u16).to_be_bytes()),
477 + _ => extended[2..6].copy_from_slice(&((count + added) as u32).to_be_bytes()),
478 + }
479 + // The map's entries run to the end of its blob, so the copies go
480 + // where the last entry already sits.
481 + let tail = header + count * entry_size;
482 + extended.truncate(tail);
483 + for _ in 0..added {
484 + extended.extend_from_slice(&entry);
485 + }
486 + out.extend_from_slice(&extended);
487 + } else {
488 + out.extend_from_slice(&bytes[start..end]);
489 + }
490 + moved.push((start, new_start));
491 + }
492 + for i in 0..4 {
493 + let old = read_u32(4 + i * 4);
494 + let new = moved
495 + .iter()
496 + .find(|(from, _)| *from == old)
497 + .map_or(0, |(_, to)| *to);
498 + out[4 + i * 4..8 + i * 4].copy_from_slice(&(new as u32).to_be_bytes());
499 + }
500 + Ok(out)
380 501 }
381 502
382 503 struct Bounds {
@@ -431,6 +552,7 @@
431 552 font: &FontRef<'_>,
432 553 base_glyph_count: u16,
433 554 number_of_h_metrics: u16,
555 + advance: u16,
434 556 appended: &[(GlyphId, &crate::manifest::GlyphSpec, Vec<u8>, Bounds)],
435 557 ) -> Result<Vec<u8>, Error> {
436 558 let hmtx = font.hmtx().map_err(missing("hmtx"))?;
@@ -448,9 +570,12 @@
448 570 out.extend_from_slice(&lsb.to_be_bytes());
449 571 }
450 572 for (_, _, _, bounds) in appended {
451 - // Monospace: every glyph takes the cell. The left side bearing has to
452 - // match the outline's own x_min or hinting and layout disagree.
453 - out.extend_from_slice(&last_advance.to_be_bytes());
573 + // The reference glyph's advance, which on a monospace base is the cell
574 + // and on a proportional one is what the base sets its own symbols on.
575 + // See `extend_hvar` for the decision and for how it answers the axis.
576 + // The left side bearing has to match the outline's own x_min or hinting
577 + // and layout disagree.
578 + out.extend_from_slice(&advance.to_be_bytes());
454 579 out.extend_from_slice(&bounds.x_min.to_be_bytes());
455 580 }
456 581 Ok(out)
M src/main.rs +12 -3
@@ -174,9 +174,18 @@
174 174 let selected: Vec<&quasi_type::manifest::GlyphSpec> = manifest
175 175 .glyphs
176 176 .iter()
177 - .filter(|g| slot.glyphs.includes(g.codepoint))
177 + .filter(|g| slot.glyphs.includes(g))
178 178 .collect();
179 179 let house: Vec<u32> = selected.iter().map(|g| g.codepoint).collect();
180 + // The floor is the role's, and the slot's own selection is what says which
181 + // role it fills: a slot that takes no cell furniture is not the face that
182 + // will answer `monospace`, so asking it for `│ █ ▏ ░` asks it to carry
183 + // glyphs that cannot tile in it.
184 + let floor: &[u32] = if selected.iter().any(|g| g.shape.is_cell_furniture()) {
185 + &assert::ALLOY_SURFACE
186 + } else {
187 + &assert::BODY_SURFACE
188 + };
180 189
181 190 let cache = base::cache_dir(root);
182 191 let faces = base::load(base_pin, &cache, offline)?;
@@ -203,7 +212,7 @@
203 212 base: base_pin,
204 213 };
205 214 let built = compose::build(&face.bytes, &selected, &id)?;
206 - let coverage = assert::check(&compose::coverage(&built.bytes)?, &house);
215 + let coverage = assert::check(floor, &compose::coverage(&built.bytes)?, &house);
207 216 let verdict = assert::describe(&coverage)?;
208 217 let web = woff2::encode(&built.bytes)?;
209 218
@@ -280,7 +289,7 @@
280 289 let selected: Vec<&quasi_type::manifest::GlyphSpec> = manifest
281 290 .glyphs
282 291 .iter()
283 - .filter(|g| slot.glyphs.includes(g.codepoint))
292 + .filter(|g| slot.glyphs.includes(g))
284 293 .collect();
285 294 let faces = base::load(base_pin, &base::cache_dir(root), offline)?;
286 295 let version = format!("{}.{}", manifest.set.version, base_pin.version);
M src/pins.rs +15 -4
@@ -90,7 +90,8 @@
90 90 /// The output family. Tracks the slot, never the base.
91 91 pub family: String,
92 92 pub base: String,
93 - /// `"*"` for the whole house set, or a list of codepoints.
93 + /// `"*"` for the whole house set, `"marks"` for the authored tier only, or
94 + /// a list of codepoints.
94 95 #[serde(default = "everything")]
95 96 pub glyphs: GlyphSelection,
96 97 }
@@ -102,6 +103,14 @@
102 103 #[derive(Debug)]
103 104 pub enum GlyphSelection {
104 105 All,
106 + /// The authored tier: the marks, and not the generated cell furniture.
107 + ///
108 + /// Box drawing and block elements are sized against the cell and have to be
109 + /// cell-exact to tile, which is a property a proportional face does not
110 + /// have and cannot be given: its glyphs carry their own widths, so nothing
111 + /// set in it tiles. A body face carrying a `┌` would be carrying a glyph
112 + /// that draws a corner joining nothing.
113 + Marks,
105 114 Some(Vec<u32>),
106 115 }
107 116
@@ -115,8 +124,9 @@
115 124 }
116 125 match Raw::deserialize(deserializer)? {
117 126 Raw::Star(s) if s == "*" => Ok(GlyphSelection::All),
127 + Raw::Star(s) if s == "marks" => Ok(GlyphSelection::Marks),
118 128 Raw::Star(s) => Err(serde::de::Error::custom(format!(
119 - "expected \"*\" or a list of codepoints, got {s:?}"
129 + "expected \"*\", \"marks\" or a list of codepoints, got {s:?}"
120 130 ))),
121 131 Raw::List(list) => Ok(GlyphSelection::Some(list)),
122 132 }
@@ -124,10 +134,11 @@
124 134 }
125 135
126 136 impl GlyphSelection {
127 - pub fn includes(&self, codepoint: u32) -> bool {
137 + pub fn includes(&self, glyph: &crate::manifest::GlyphSpec) -> bool {
128 138 match self {
129 139 GlyphSelection::All => true,
130 - GlyphSelection::Some(list) => list.contains(&codepoint),
140 + GlyphSelection::Marks => !glyph.shape.is_cell_furniture(),
141 + GlyphSelection::Some(list) => list.contains(&glyph.codepoint),
131 142 }
132 143 }
133 144 }
M src/proof.rs +12 -4
@@ -126,16 +126,24 @@
126 126 0x258F, 0x258E, 0x258D, 0x258C, 0x258B, 0x258A, 0x2589, 0x2588, 0x0020, 0x2596, 0x2597,
127 127 0x2598, 0x259D,
128 128 ]));
129 - rows.retain(|row| !row.is_empty());
129 + // A row that lost every glyph but its spaces is a row the face does not
130 + // carry — a body slot takes no cell furniture, so its box-drawing rows come
131 + // back blank rather than absent.
132 + rows.retain(|row| row.iter().any(|&c| c != 0x0020));
130 133 rows
131 134 }
132 135
133 136 /// Rasterise one page of a face.
134 137 ///
135 138 /// `wght` is a location on the axis for a variable face and is ignored by a
136 - /// static one. Cells are the face's own metrics rounded to whole pixels, which
137 - /// is what a terminal lays out with, so a glyph that only tiles at its exact
138 - /// design size shows its seam here the same way it would on a screen.
139 + /// static one.
140 + ///
141 + /// Glyphs are set on a grid of the face's widest advance rounded to whole
142 + /// pixels, which for a monospace face is the cell a terminal lays out with — so
143 + /// furniture that only tiles at its exact design size shows its seam here the
144 + /// same way it would on a screen. For a proportional face the grid is a
145 + /// specimen layout and nothing more: its glyphs carry their own widths and the
146 + /// sheet is not a text setting.
139 147 pub fn render(bytes: &[u8], page: &Page) -> Result<Sheet, Error> {
140 148 let font = FontRef::new(bytes).map_err(|e| Error::Font(e.to_string()))?;
141 149 let coverage = compose::coverage(bytes)?;
@@ -88,7 +88,7 @@
88 88 };
89 89 let house: Vec<u32> = specs.iter().map(|g| g.codepoint).collect();
90 90 let mappings = compose::coverage(&bytes).unwrap();
91 - let result = coverage::check(&mappings, &house);
91 + let result = coverage::check(&coverage::ALLOY_SURFACE, &mappings, &house);
92 92 assert!(
93 93 result.ok(),
94 94 "missing {:?}",
@@ -368,7 +368,11 @@
368 368 fn the_cut_face_is_fit_for_the_mono_slot() {
369 369 let Some((bytes, specs)) = cut() else { return };
370 370 let house: Vec<u32> = specs.iter().map(|g| g.codepoint).collect();
371 - let result = coverage::check(&compose::coverage(&bytes).unwrap(), &house);
371 + let result = coverage::check(
372 + &coverage::ALLOY_SURFACE,
373 + &compose::coverage(&bytes).unwrap(),
374 + &house,
375 + );
372 376 assert!(
373 377 result.ok(),
374 378 "missing {:?}",
@@ -1,0 +1,151 @@
1 + //! The body slot, and the one thing about it that is new: a proportional base.
2 + //!
3 + //! `quasi-mono` is cut from a monospace face where every glyph takes the cell,
4 + //! which is the assumption this pipeline was written on. `quasi-body` is cut
5 + //! from Atkinson Hyperlegible Next, where glyphs carry their own widths and
6 + //! those widths answer the axis (`HVAR`, three regions). What is asserted here
7 + //! is the advance decision holding: a mark takes the reference glyph's advance,
8 + //! and varies the way that glyph varies.
9 + //!
10 + //! Skipped rather than failed when the base is not cached, same as the others.
11 + //! `cargo run -- build quasi-body` once and it runs for good.
12 +
13 + use quasi_type::base::{self, BaseFace};
14 + use quasi_type::compose::{self, Identity};
15 + use quasi_type::manifest::Manifest;
16 + use quasi_type::pins::{Base, Pins};
17 + use quasi_type::{HOUSE_SET, PINS, assert as coverage};
18 +
19 + use read_fonts::types::GlyphId;
20 + use read_fonts::{FontRef, TableProvider};
21 + use skrifa::MetadataProvider;
22 + use skrifa::instance::Size;
23 +
24 + const SLOT: &str = "quasi-body";
25 +
26 + fn root() -> std::path::PathBuf {
27 + std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
28 + }
29 +
30 + fn pins() -> &'static Pins {
31 + Box::leak(Box::new(Pins::parse(PINS).unwrap()))
32 + }
33 +
34 + fn base_face() -> Option<(BaseFace, &'static Base)> {
35 + let pins = pins();
36 + let base = pins.base(&pins.slot(SLOT).unwrap().base).unwrap();
37 + match base::load(base, &base::cache_dir(&root()), true) {
38 + Ok(faces) => faces.into_iter().next().map(|face| (face, base)),
39 + Err(_) => {
40 + eprintln!("skipped: {SLOT}'s base is not cached. Run `cargo run -- build {SLOT}`");
41 + None
42 + }
43 + }
44 + }
45 +
46 + /// The cut face, and the codepoints the slot selected.
47 + fn cut() -> Option<(Vec<u8>, Vec<u32>)> {
48 + let (face, base) = base_face()?;
49 + let pins = pins();
50 + let slot = pins.slot(SLOT).unwrap();
51 + let manifest = Manifest::parse(HOUSE_SET).unwrap();
52 + let selected: Vec<_> = manifest
53 + .glyphs
54 + .iter()
55 + .filter(|g| slot.glyphs.includes(g))
56 + .collect();
57 + let codepoints: Vec<u32> = selected.iter().map(|g| g.codepoint).collect();
58 + let id = Identity {
59 + family: &slot.family,
60 + style: &base.faces[0].style,
61 + version: &format!("1.{}", base.version),
62 + set_version: manifest.set.version,
63 + base,
64 + };
65 + let built = compose::build(&face.bytes, &selected, &id).expect("the body face cuts");
66 + Some((built.bytes, codepoints))
67 + }
68 +
69 + /// The subset is the decision, so it is asserted rather than counted.
70 + ///
71 + /// Box drawing and block elements are sized against the cell and have to be
72 + /// cell-exact to tile. A proportional face has no cell and nothing set in it
73 + /// tiles, so a `┌` here would draw a corner that joins nothing.
74 + #[test]
75 + fn the_body_slot_takes_the_marks_and_not_the_cell_grid() {
76 + let pins = pins();
77 + let slot = pins.slot(SLOT).unwrap();
78 + let manifest = Manifest::parse(HOUSE_SET).unwrap();
79 + for glyph in &manifest.glyphs {
80 + assert_eq!(
81 + slot.glyphs.includes(glyph),
82 + !glyph.shape.is_cell_furniture(),
83 + "{} is on the wrong side of the body slot's selection",
84 + glyph.name
85 + );
86 + }
87 + assert!(
88 + manifest.glyphs.iter().any(|g| g.shape.is_cell_furniture()),
89 + "a selection that excludes nothing is not a selection"
90 + );
91 + }
92 +
93 + /// A body face answers `sans-serif`, so its floor is the role's rather than the
94 + /// terminal's: everything a described screen sets in copy, and none of the four
95 + /// codepoints that exist to tile.
96 + #[test]
97 + fn the_face_covers_the_body_floor() {
98 + let Some((bytes, house)) = cut() else { return };
99 + let mappings = compose::coverage(&bytes).unwrap();
100 + let result = coverage::check(&coverage::BODY_SURFACE, &mappings, &house);
101 + assert!(result.ok(), "missing {:?}", result.missing);
102 + }
103 +
104 + /// The advance decision, measured on the built face.
105 + ///
106 + /// Both halves matter and only the second is new. The mark is as wide as `+`,
107 + /// the glyph its band is measured against; and at the far end of the axis it is
108 + /// still as wide as `+`, which on this base means 595 units rather than the 606
109 + /// it takes at rest. Left to the pipeline's old behaviour it would have taken
110 + /// whatever the last glyph in the base's `HVAR` map does, which is one
111 + /// arbitrary glyph's width response.
112 + #[test]
113 + fn a_mark_takes_the_reference_glyphs_advance_and_varies_with_it() {
114 + let Some((bytes, house)) = cut() else { return };
115 + let font = FontRef::new(&bytes).unwrap();
116 + let mappings = compose::coverage(&bytes).unwrap();
117 + let plus = mappings[&0x2B];
118 +
119 + for wght in [200.0, 400.0, 800.0] {
120 + let location = font.axes().location([("wght", wght)]);
121 + let metrics = font.glyph_metrics(Size::unscaled(), &location);
122 + let reference = metrics.advance_width(plus).unwrap();
123 + for codepoint in &house {
124 + let gid: GlyphId = mappings[codepoint];
125 + if gid.to_u32() < font.maxp().unwrap().num_glyphs() as u32 - house.len() as u32 {
126 + // A codepoint the base already drew keeps the base's own glyph
127 + // and the base's own width.
128 + continue;
129 + }
130 + assert_eq!(
131 + metrics.advance_width(gid),
132 + Some(reference),
133 + "U+{codepoint:04X} is {:?} wide at wght {wght} and `+` is {reference}",
134 + metrics.advance_width(gid)
135 + );
136 + }
137 + }
138 +
139 + // The point of the second half: `+` does not hold still on this base, so a
140 + // mark that held still would drift away from it.
141 + let at = |w: f32| {
142 + let location = font.axes().location([("wght", w)]);
143 + font.glyph_metrics(Size::unscaled(), &location)
144 + .advance_width(plus)
145 + .unwrap()
146 + };
147 + assert!(
148 + (at(800.0) - at(400.0)).abs() > 1.0,
149 + "the base's advances do not vary, so this test is asserting nothing"
150 + );
151 + }