max / quasi-type
- Co-Authored-By
- Claude Opus 5 (1M context) <noreply@anthropic.com>
12 files changed,
+1624 insertions,
-65 deletions
| @@ -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" |
| @@ -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" |
| @@ -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 |
| @@ -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>( |
| @@ -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, ¶ms).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, ¶ms)?; | |
| 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 | } |
| @@ -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, |
| @@ -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), ¶ms); | |
| 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), ¶ms); | |
| 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()); |
| @@ -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. |
| @@ -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 | + | } |