max / quasi-type
10 files changed,
+470 insertions,
-61 deletions
| @@ -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 |
| @@ -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" |
| @@ -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 | } |
| @@ -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) |
| @@ -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); |
| @@ -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 | } |
| @@ -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 | + | } |