Skip to main content

max / quasi-type

The house font pipeline, and Quasi Mono as its first face A pinned base plus the house glyph set in, a Quasi <Slot> face out. Seven marks today: the sort carets, starship's separators and error state, and helix's whitespace renders. Nothing in the tree drew them, and no configuration of a text face produces them. Marks are drawn parametrically off the base's own measurements, so the set refits to the next base rather than being redrawn for it. The weight term is measured: Plex Mono redraws its symbols heavier in Bold rather than only thickening them, while its block elements hold still. So solid marks grow, stroked marks thicken, and the X is calibrated against the base's own multiply sign rather than by eye. glyf is spliced rather than recompiled, so the base's hinting and layout tables survive byte for byte. DSIG is dropped, since it signs bytes that changed. The coverage assertion moves here from alloy's Containerfile, which greps fc-list after installing a face somebody else built. Every consumer inherits it now, and the image is left to assert only that it installed the right family.
Author: Max Johnson <me@maxj.phd> · 2026-08-17 00:47 UTC
Signed with PGP, not checked
Commit: e0075ca5bb6acf5641fa41646139bbbc5177fa8a
21 files changed, +4122 insertions, -0 deletions
A .gitignore +6
@@ -1,0 +1,6 @@
1 + /target
2 + /bases/cache
3 + /out
4 +
5 + # Claude Code instructions (project-local; not for the public repo)
6 + CLAUDE.md
A Cargo.lock +500
@@ -1,0 +1,576 @@
1 + # This file is automatically @generated by Cargo.
2 + # It is not intended for manual editing.
3 + version = 4
4 +
5 + [[package]]
6 + name = "adler2"
7 + version = "2.0.1"
8 + source = "registry+https://github.com/rust-lang/crates.io-index"
9 + checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
10 +
11 + [[package]]
12 + name = "alloc-no-stdlib"
13 + version = "2.0.4"
14 + source = "registry+https://github.com/rust-lang/crates.io-index"
15 + checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3"
16 +
17 + [[package]]
18 + name = "alloc-stdlib"
19 + version = "0.2.4"
20 + source = "registry+https://github.com/rust-lang/crates.io-index"
21 + checksum = "0e76a019e91224d279006ff972f1e984179a6e9feb050adba6ce8274aef23195"
22 + dependencies = [
23 + "alloc-no-stdlib",
24 + ]
25 +
26 + [[package]]
27 + name = "arbitrary"
28 + version = "1.4.2"
29 + source = "registry+https://github.com/rust-lang/crates.io-index"
30 + checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1"
31 + dependencies = [
32 + "derive_arbitrary",
33 + ]
34 +
35 + [[package]]
36 + name = "arrayvec"
37 + version = "0.7.8"
38 + source = "registry+https://github.com/rust-lang/crates.io-index"
39 + checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56"
40 +
41 + [[package]]
42 + name = "autocfg"
43 + version = "1.5.1"
44 + source = "registry+https://github.com/rust-lang/crates.io-index"
45 + checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
46 +
47 + [[package]]
48 + name = "block-buffer"
49 + version = "0.10.4"
50 + source = "registry+https://github.com/rust-lang/crates.io-index"
51 + checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
52 + dependencies = [
53 + "generic-array",
54 + ]
55 +
56 + [[package]]
57 + name = "brotli"
58 + version = "8.0.4"
59 + source = "registry+https://github.com/rust-lang/crates.io-index"
60 + checksum = "5cc91aac060a7a1e25823bdccbfb6af1875b88f17c6daac97894eed8207166b3"
61 + dependencies = [
62 + "alloc-no-stdlib",
63 + "alloc-stdlib",
64 + "brotli-decompressor",
65 + ]
66 +
67 + [[package]]
68 + name = "brotli-decompressor"
69 + version = "5.0.3"
70 + source = "registry+https://github.com/rust-lang/crates.io-index"
71 + checksum = "3a32acac15fe1967bc3986b2a6347dffc965602354ea6f450ad07e8bfd253583"
72 + dependencies = [
73 + "alloc-no-stdlib",
74 + "alloc-stdlib",
75 + ]
76 +
77 + [[package]]
78 + name = "bumpalo"
79 + version = "3.20.3"
80 + source = "registry+https://github.com/rust-lang/crates.io-index"
81 + checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
82 +
83 + [[package]]
84 + name = "bytemuck"
85 + version = "1.25.2"
86 + source = "registry+https://github.com/rust-lang/crates.io-index"
87 + checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797"
88 + dependencies = [
89 + "bytemuck_derive",
90 + ]
91 +
92 + [[package]]
93 + name = "bytemuck_derive"
94 + version = "1.12.0"
95 + source = "registry+https://github.com/rust-lang/crates.io-index"
96 + checksum = "fc0e56a716f1e132ff6bf4bdac1c944a3fcdc1cae65f70a4a2a1ac3b401d2d1f"
97 + dependencies = [
98 + "proc-macro2",
99 + "quote",
100 + "syn 3.0.3",
101 + ]
102 +
103 + [[package]]
104 + name = "cfg-if"
105 + version = "1.0.4"
106 + source = "registry+https://github.com/rust-lang/crates.io-index"
107 + checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
108 +
109 + [[package]]
110 + name = "cpufeatures"
111 + version = "0.2.17"
112 + source = "registry+https://github.com/rust-lang/crates.io-index"
113 + checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
114 + dependencies = [
115 + "libc",
116 + ]
117 +
118 + [[package]]
119 + name = "crc32fast"
120 + version = "1.5.0"
121 + source = "registry+https://github.com/rust-lang/crates.io-index"
122 + checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511"
123 + dependencies = [
124 + "cfg-if",
125 + ]
126 +
127 + [[package]]
128 + name = "crypto-common"
129 + version = "0.1.7"
130 + source = "registry+https://github.com/rust-lang/crates.io-index"
131 + checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
132 + dependencies = [
133 + "generic-array",
134 + "typenum",
135 + ]
136 +
137 + [[package]]
138 + name = "derive_arbitrary"
139 + version = "1.4.2"
140 + source = "registry+https://github.com/rust-lang/crates.io-index"
141 + checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a"
142 + dependencies = [
143 + "proc-macro2",
144 + "quote",
145 + "syn 2.0.119",
146 + ]
147 +
148 + [[package]]
149 + name = "digest"
150 + version = "0.10.7"
151 + source = "registry+https://github.com/rust-lang/crates.io-index"
152 + checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
153 + dependencies = [
154 + "block-buffer",
155 + "crypto-common",
156 + ]
157 +
158 + [[package]]
159 + name = "equivalent"
160 + version = "1.0.2"
161 + source = "registry+https://github.com/rust-lang/crates.io-index"
162 + checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
163 +
164 + [[package]]
165 + name = "euclid"
166 + version = "0.22.14"
167 + source = "registry+https://github.com/rust-lang/crates.io-index"
168 + checksum = "f1a05365e3b1c6d1650318537c7460c6923f1abdd272ad6842baa2b509957a06"
169 + dependencies = [
170 + "num-traits",
171 + ]
172 +
173 + [[package]]
174 + name = "flate2"
175 + version = "1.1.9"
176 + source = "registry+https://github.com/rust-lang/crates.io-index"
177 + checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c"
178 + dependencies = [
179 + "miniz_oxide",
180 + "zlib-rs",
181 + ]
182 +
183 + [[package]]
184 + name = "font-types"
185 + version = "0.12.3"
186 + source = "registry+https://github.com/rust-lang/crates.io-index"
187 + checksum = "75382bc7392ef10aad10935f92fc3db36d2d4dad0e5d96d8d65e04f89a07ec39"
188 + dependencies = [
189 + "bytemuck",
190 + ]
191 +
192 + [[package]]
193 + name = "generic-array"
194 + version = "0.14.7"
195 + source = "registry+https://github.com/rust-lang/crates.io-index"
196 + checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
197 + dependencies = [
198 + "typenum",
199 + "version_check",
200 + ]
201 +
202 + [[package]]
203 + name = "hashbrown"
204 + version = "0.17.1"
205 + source = "registry+https://github.com/rust-lang/crates.io-index"
206 + checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
207 +
208 + [[package]]
209 + name = "indexmap"
210 + version = "2.14.0"
211 + source = "registry+https://github.com/rust-lang/crates.io-index"
212 + checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9"
213 + dependencies = [
214 + "equivalent",
215 + "hashbrown",
216 + ]
217 +
218 + [[package]]
219 + name = "kurbo"
220 + version = "0.13.1"
221 + source = "registry+https://github.com/rust-lang/crates.io-index"
222 + checksum = "4b60dfc32f652b926df6192e55525b16d186c69d47876c3ead4da5cc9f8450e2"
223 + dependencies = [
224 + "arrayvec",
225 + "euclid",
226 + "polycool",
227 + "smallvec",
228 + ]
229 +
230 + [[package]]
231 + name = "libc"
232 + version = "0.2.189"
233 + source = "registry+https://github.com/rust-lang/crates.io-index"
234 + checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
235 +
236 + [[package]]
237 + name = "log"
238 + version = "0.4.33"
239 + source = "registry+https://github.com/rust-lang/crates.io-index"
240 + checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
241 +
242 + [[package]]
243 + name = "memchr"
244 + version = "2.8.3"
245 + source = "registry+https://github.com/rust-lang/crates.io-index"
246 + checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
247 +
248 + [[package]]
249 + name = "miniz_oxide"
250 + version = "0.8.9"
251 + source = "registry+https://github.com/rust-lang/crates.io-index"
252 + checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316"
253 + dependencies = [
254 + "adler2",
255 + "simd-adler32",
256 + ]
257 +
258 + [[package]]
259 + name = "num-traits"
260 + version = "0.2.19"
261 + source = "registry+https://github.com/rust-lang/crates.io-index"
262 + checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
263 + dependencies = [
264 + "autocfg",
265 + ]
266 +
267 + [[package]]
268 + name = "once_cell"
269 + version = "1.21.4"
270 + source = "registry+https://github.com/rust-lang/crates.io-index"
271 + checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
272 +
273 + [[package]]
274 + name = "polycool"
275 + version = "0.4.0"
276 + source = "registry+https://github.com/rust-lang/crates.io-index"
277 + checksum = "50596ddc09eb5ad5f75cacd40209568e66df71baf86e1499a0e99c4cff12a5a6"
278 + dependencies = [
279 + "arrayvec",
280 + ]
281 +
282 + [[package]]
283 + name = "proc-macro2"
284 + version = "1.0.107"
285 + source = "registry+https://github.com/rust-lang/crates.io-index"
286 + checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
287 + dependencies = [
288 + "unicode-ident",
289 + ]
290 +
291 + [[package]]
292 + name = "quasi-type"
293 + version = "0.1.0"
294 + dependencies = [
295 + "brotli",
296 + "font-types",
297 + "kurbo",
298 + "read-fonts",
299 + "serde",
300 + "sha2",
301 + "toml",
302 + "write-fonts",
303 + "zip",
304 + ]
305 +
306 + [[package]]
307 + name = "quote"
308 + version = "1.0.47"
309 + source = "registry+https://github.com/rust-lang/crates.io-index"
310 + checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
311 + dependencies = [
312 + "proc-macro2",
313 + ]
314 +
315 + [[package]]
316 + name = "read-fonts"
317 + version = "0.43.0"
318 + source = "registry+https://github.com/rust-lang/crates.io-index"
319 + checksum = "a6b5cae85882cfb0167038e7726a426f47ebd808e67af78c2d6bcbb02acbf3ce"
320 + dependencies = [
321 + "bytemuck",
322 + "font-types",
323 + "once_cell",
324 + ]
325 +
326 + [[package]]
327 + name = "serde"
328 + version = "1.0.229"
329 + source = "registry+https://github.com/rust-lang/crates.io-index"
330 + checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
331 + dependencies = [
332 + "serde_core",
333 + "serde_derive",
334 + ]
335 +
336 + [[package]]
337 + name = "serde_core"
338 + version = "1.0.229"
339 + source = "registry+https://github.com/rust-lang/crates.io-index"
340 + checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
341 + dependencies = [
342 + "serde_derive",
343 + ]
344 +
345 + [[package]]
346 + name = "serde_derive"
347 + version = "1.0.229"
348 + source = "registry+https://github.com/rust-lang/crates.io-index"
349 + checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
350 + dependencies = [
351 + "proc-macro2",
352 + "quote",
353 + "syn 3.0.3",
354 + ]
355 +
356 + [[package]]
357 + name = "serde_spanned"
358 + version = "1.1.1"
359 + source = "registry+https://github.com/rust-lang/crates.io-index"
360 + checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26"
361 + dependencies = [
362 + "serde_core",
363 + ]
364 +
365 + [[package]]
366 + name = "sha2"
367 + version = "0.10.9"
368 + source = "registry+https://github.com/rust-lang/crates.io-index"
369 + checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
370 + dependencies = [
371 + "cfg-if",
372 + "cpufeatures",
373 + "digest",
374 + ]
375 +
376 + [[package]]
377 + name = "simd-adler32"
378 + version = "0.3.10"
379 + source = "registry+https://github.com/rust-lang/crates.io-index"
380 + checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea"
381 +
382 + [[package]]
383 + name = "smallvec"
384 + version = "1.15.2"
385 + source = "registry+https://github.com/rust-lang/crates.io-index"
386 + checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90"
387 +
388 + [[package]]
389 + name = "syn"
390 + version = "2.0.119"
391 + source = "registry+https://github.com/rust-lang/crates.io-index"
392 + checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297"
393 + dependencies = [
394 + "proc-macro2",
395 + "quote",
396 + "unicode-ident",
397 + ]
398 +
399 + [[package]]
400 + name = "syn"
401 + version = "3.0.3"
402 + source = "registry+https://github.com/rust-lang/crates.io-index"
403 + checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3"
404 + dependencies = [
405 + "proc-macro2",
406 + "quote",
407 + "unicode-ident",
408 + ]
409 +
410 + [[package]]
411 + name = "toml"
412 + version = "1.1.4+spec-1.1.0"
413 + source = "registry+https://github.com/rust-lang/crates.io-index"
414 + checksum = "3aace63f4bbcdfc2c965b059de67119c89c4017a70d633be6c104910f67056f5"
415 + dependencies = [
416 + "indexmap",
417 + "serde_core",
418 + "serde_spanned",
419 + "toml_datetime",
420 + "toml_parser",
421 + "toml_writer",
422 + "winnow",
423 + ]
424 +
425 + [[package]]
426 + name = "toml_datetime"
427 + version = "1.1.1+spec-1.1.0"
428 + source = "registry+https://github.com/rust-lang/crates.io-index"
429 + checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7"
430 + dependencies = [
431 + "serde_core",
432 + ]
433 +
434 + [[package]]
435 + name = "toml_parser"
436 + version = "1.1.3+spec-1.1.0"
437 + source = "registry+https://github.com/rust-lang/crates.io-index"
438 + checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56"
439 + dependencies = [
440 + "winnow",
441 + ]
442 +
443 + [[package]]
444 + name = "toml_writer"
445 + version = "1.1.2+spec-1.1.0"
446 + source = "registry+https://github.com/rust-lang/crates.io-index"
447 + checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2"
448 +
449 + [[package]]
450 + name = "typenum"
451 + version = "1.20.1"
452 + source = "registry+https://github.com/rust-lang/crates.io-index"
453 + checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
454 +
455 + [[package]]
456 + name = "unicode-ident"
457 + version = "1.0.24"
458 + source = "registry+https://github.com/rust-lang/crates.io-index"
459 + checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
460 +
461 + [[package]]
462 + name = "version_check"
463 + version = "0.9.5"
464 + source = "registry+https://github.com/rust-lang/crates.io-index"
465 + checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
466 +
467 + [[package]]
468 + name = "winnow"
469 + version = "1.0.4"
470 + source = "registry+https://github.com/rust-lang/crates.io-index"
471 + checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81"
472 +
473 + [[package]]
474 + name = "write-fonts"
475 + version = "0.52.0"
476 + source = "registry+https://github.com/rust-lang/crates.io-index"
477 + checksum = "843cebb7bd36359861dbce43d54ac8b33dfdf7b80a95913adc1ef7c7bb881e62"
478 + dependencies = [
479 + "font-types",
480 + "indexmap",
481 + "kurbo",
482 + "log",
483 + "read-fonts",
484 + ]
485 +
486 + [[package]]
487 + name = "zip"
488 + version = "6.0.0"
489 + source = "registry+https://github.com/rust-lang/crates.io-index"
490 + checksum = "eb2a05c7c36fde6c09b08576c9f7fb4cda705990f73b58fe011abf7dfb24168b"
491 + dependencies = [
492 + "arbitrary",
493 + "crc32fast",
494 + "flate2",
495 + "indexmap",
496 + "memchr",
497 + "zopfli",
498 + ]
499 +
500 + [[package]]
Lines truncated
A Cargo.toml +59
@@ -1,0 +1,59 @@
1 + [package]
2 + name = "quasi-type"
3 + version = "0.1.0"
4 + edition = "2024"
5 + description = "The house font pipeline: a pinned base face plus the house glyph set in, a Quasi <Slot> face out."
6 + license = "MIT"
7 + repository = "https://makenot.work/git/max/quasi-type"
8 + publish = false
9 +
10 + [lib]
11 + name = "quasi_type"
12 + path = "src/lib.rs"
13 +
14 + [[bin]]
15 + name = "quasi-type"
16 + path = "src/main.rs"
17 +
18 + [dependencies]
19 + read-fonts = "0.43"
20 + write-fonts = "0.52"
21 + font-types = "0.12"
22 + kurbo = "0.13"
23 + brotli = "8"
24 + sha2 = "0.10"
25 + zip = { version = "6", default-features = false, features = ["deflate"] }
26 + serde = { version = "1", features = ["derive"] }
27 + toml = "1.1"
28 +
29 + [lints.rust]
30 + unused = "warn"
31 + unreachable_pub = "warn"
32 +
33 + [lints.clippy]
34 + pedantic = { level = "warn", priority = -1 }
35 + # Allow-list tuned from a measured breakdown across server/multithreaded/pter
36 + # (2026-07-22). These are the high-churn / low-signal pedantic lints; everything
37 + # else in `pedantic` stays a warning. Keep this block identical across repos.
38 + module_name_repetitions = "allow"
39 + # Doc lints. No docs-completeness push is underway.
40 + missing_errors_doc = "allow"
41 + missing_panics_doc = "allow"
42 + doc_markdown = "allow"
43 + # Numeric casts. Endemic and mostly intentional in size and byte math.
44 + cast_possible_truncation = "allow"
45 + cast_sign_loss = "allow"
46 + cast_precision_loss = "allow"
47 + cast_possible_wrap = "allow"
48 + cast_lossless = "allow"
49 + # Subjective structure and style nags. High churn, low signal.
50 + must_use_candidate = "allow"
51 + too_many_lines = "allow"
52 + struct_excessive_bools = "allow"
53 + similar_names = "allow"
54 + items_after_statements = "allow"
55 + single_match_else = "allow"
56 + # Frequent false-positives in TUI and router-heavy code.
57 + match_same_arms = "allow"
58 + unnecessary_wraps = "allow"
59 + type_complexity = "allow"
A LICENSE +21
@@ -1,0 +1,21 @@
1 + MIT License
2 +
3 + Copyright (c) 2026 Make Creative, LLC
4 +
5 + Permission is hereby granted, free of charge, to any person obtaining a copy
6 + of this software and associated documentation files (the "Software"), to deal
7 + in the Software without restriction, including without limitation the rights
8 + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 + copies of the Software, and to permit persons to whom the Software is
10 + furnished to do so, subject to the following conditions:
11 +
12 + The above copyright notice and this permission notice shall be included in all
13 + copies or substantial portions of the Software.
14 +
15 + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 + SOFTWARE.
A README.md +97
@@ -1,0 +1,97 @@
1 + # quasi-type
2 +
3 + The house font pipeline. A pinned base face plus the house glyph set goes in, a
4 + `Quasi <Slot>` face comes out, and the run is repeatable when the upstream moves
5 + or when a slot changes its base.
6 +
7 + ```
8 + cargo run -- build quasi-mono
9 + ```
10 +
11 + ```
12 + Quasi Mono from IBM Plex Mono 2.5.0 (7 marks)
13 + Quasi Mono Regular ttf 157.3K woff2 53.6K +7 marks, cmap covers all 20
14 + Quasi Mono Bold ttf 159.2K woff2 54.9K +7 marks, cmap covers all 20
15 + OFL.txt the base's licence, as OFL requires
16 + ```
17 +
18 + ## Why it exists
19 +
20 + The faces worth setting text in ship minimal mark inventories on principle. IBM
21 + Plex Mono has one glyph in the whole geometric-shapes block; Plex Sans and Lato
22 + have no `▲` either. That is a property of a good text face rather than a defect,
23 + so the answer is to patch rather than to shop.
24 +
25 + Split the vocabulary in two. **Letters, digits and punctuation** are the base
26 + face's job and are meant to differ per slot. **Marks** — carets, separators,
27 + status symbols, whitespace renders — carry meaning, and the meaning is the same
28 + in a terminal, a webview and an egui panel, so the drawing should be too. Before
29 + this, `▲` in a webview fell back per glyph to whatever the OS ordered first, so
30 + "unify the sort caret on the triangles" meant unifying on three platforms'
31 + opinions.
32 +
33 + ## What is in the box
34 +
35 + - `glyphs/manifest.toml` — the house glyph set. Seven marks today. This is the
36 + durable artifact; the pipeline is what consumes it.
37 + - `bases/pins.toml` — the bases, pinned by version and sha256, and the slots cut
38 + from them.
39 + - `out/` — built faces. Not committed: they rebuild from the checkout, and a
40 + committed binary is a second source of truth.
41 +
42 + ```
43 + quasi-type build <slot> cut every face of a slot, verify, write to out/
44 + quasi-type verify <slot> cut without writing; assert coverage only
45 + quasi-type params <slot> print what each base face measures
46 + quasi-type list print the house glyph set and the pinned slots
47 + ```
48 +
49 + ## How a mark is drawn
50 +
51 + Parametrically, off the base's own measurements, so the set refits to the next
52 + base rather than being redrawn for it. Every dimension is
53 +
54 + band extent x band + base stroke x weight
55 +
56 + with both terms read out of the face (`quasi-type params`): the symbol band off
57 + `+`, the horizontal stroke off `-`, the vertical stroke off `|`, the cell off
58 + `hmtx`.
59 +
60 + Consistent means same design, not byte-identical. `▲` in Quasi Mono and in a
61 + future Quasi Body will not share an outline; they read as the same mark, each
62 + tuned to sit among its own neighbours.
63 +
64 + The weight term is measured rather than chosen. Plex Mono redraws its symbols
65 + heavier in Bold instead of only thickening them in place — `•` runs 244 to 308
66 + units wide, `×` 424 to 460 — while its block elements hold still, being cell-fill
67 + primitives. So solid marks grow and stroked marks thicken, and the X is
68 + calibrated against the base's own `×` rather than by eye.
69 +
70 + ## Adding a glyph
71 +
72 + Edit `glyphs/manifest.toml` and re-run. Nothing downstream changes: the set is
73 + data, and the coverage assertion picks the new codepoint up on its own.
74 +
75 + Braille and the sextant sets stay out until a surface wants one. No `Canvas` and
76 + no `Sparkline` exists anywhere in the tree.
77 +
78 + ## Adding a base
79 +
80 + Pin it in `bases/pins.toml` with its sha256, then read its licence. Most OFL
81 + faces declare a Reserved Font Name, and clause 3 bars it as a prefix and as a
82 + suffix alike; the `quasi-*` naming clears that by construction, so the only live
83 + gate is whether anyone reached for the base's own name. The pipeline refuses a
84 + slot whose family name carries its base's reserved word.
85 +
86 + A base also has to be measurable: it needs `|`, `-` and `+`, since those are
87 + what the recipes refit against. One missing is an error rather than a guess.
88 +
89 + ## Licence
90 +
91 + The pipeline is MIT. A face it cuts is OFL 1.1, inherited from its base, and
92 + `build` writes the base's licence text beside the files because the OFL requires
93 + it to travel with a modified build. The base's copyright and the lineage go in
94 + the face's own `name` table, which is where the licence asks for them and where
95 + a Reserved Font Name does not reach.
96 +
97 + Design record: wiki `typography-standard`.
@@ -1,0 +1,56 @@
1 + # The bases this pipeline may cut a face from, and the slots it cuts.
2 + #
3 + # A base is pinned by upstream version and by the sha256 of the archive upstream
4 + # publishes. A moved upstream is then a checksum failure rather than a silent
5 + # redraw, which is the whole point of pinning: the same checkout produces the
6 + # same face, and a base that changed under us says so.
7 + #
8 + # Adding a base is a licence read first. Most OFL faces declare a Reserved Font
9 + # Name, and clause 3 bars it as a prefix and as a suffix alike. The `quasi-*`
10 + # naming clears that by construction, so the only live gate is "did anyone reach
11 + # for the base's own name". A base that is not OFL at all needs its own read.
12 +
13 + [[base]]
14 + id = "plex-mono"
15 + family = "IBM Plex Mono"
16 + version = "2.5.0"
17 + url = "https://github.com/IBM/plex/releases/download/%40ibm%2Fplex-mono%402.5.0/ibm-plex-mono.zip"
18 + sha256 = "6d23f01257663d8cc49a0d64c22ced630b79e0e2a0ac08a0da86e9a38bbc481c"
19 + license = "OFL-1.1"
20 + license_path = "ibm-plex-mono/LICENSE.txt"
21 + # OFL 1.1 clause 3. Recorded so the naming gate is data rather than lore.
22 + reserved_font_name = "Plex"
23 + copyright = "Copyright 2017 IBM Corp. All rights reserved."
24 + designer = "Mike Abbink, Paul van der Laan, Pieter van Rosmalen"
25 + vendor_url = "http://www.ibm.com/plex"
26 +
27 + [[base.face]]
28 + style = "Regular"
29 + path = "ibm-plex-mono/fonts/complete/ttf/IBMPlexMono-Regular.ttf"
30 + sha256 = "7c6fbddca4b700be918f5f6183d9bd4464fa427fe435f0b480d77fe2bb8c5a43"
31 +
32 + [[base.face]]
33 + style = "Bold"
34 + path = "ibm-plex-mono/fonts/complete/ttf/IBMPlexMono-Bold.ttf"
35 + sha256 = "74e5eedcfa4596497d34e19023cabdabd3a8c852b903007a5654a59591a72ffb"
36 +
37 + # Italic is deliberately not cut. No consumer loads a mono italic today: MNW
38 + # serves Regular and Bold, shop bundles one face, and Alloy's fontconfig names a
39 + # family rather than a style. Add the face here when something asks for it.
40 +
41 +
42 + # A slot is a house type role. The name tracks the slot and not the base, so if
43 + # `quasi-mono` ever moves off Plex Mono the output is still `Quasi Mono` and the
44 + # base is named in the metadata, where the licence requires it anyway.
45 +
46 + [[slot]]
47 + id = "quasi-mono"
48 + family = "Quasi Mono"
49 + base = "plex-mono"
50 + # Every glyph in the house set. A slot may take a subset; none does today.
51 + glyphs = "*"
52 +
53 + # `quasi-body` is stock IBM Plex Sans and is deliberately absent. A slot runs the
54 + # upstream face under the upstream's own name until it needs the house set; the
55 + # day a described surface wants a mark in body text, it gets an entry here and
56 + # becomes `Quasi Body`.
@@ -1,0 +1,149 @@
1 + # The house glyph set: the durable artifact this pipeline exists to carry.
2 + #
3 + # Every entry is a mark rather than a letter. Letters, digits and punctuation are
4 + # the base face's job and are meant to differ per slot; marks carry meaning, and
5 + # the meaning does not change between a terminal, a webview and an egui panel, so
6 + # neither should the drawing.
7 + #
8 + # Sizes are never absolute units, so a glyph is refitted to the next base rather
9 + # than redrawn for it. Every dimension is
10 + #
11 + # band extent x band + base stroke x weight
12 + #
13 + # and both terms are measured off the base itself (see `quasi-type params`):
14 + #
15 + # band the symbol band, read off `+`: the extent the base fits its own
16 + # symbols into, horizontally and vertically.
17 + # stroke `-`'s bbox height, the base's horizontal stroke weight.
18 + # stem `|`'s bbox width, the base's vertical stroke weight.
19 + # advance the cell width. Marks centre on advance/2, not on the band.
20 + #
21 + # A dimension with no weight term is written as a bare number.
22 + #
23 + # WHY THE WEIGHT TERM EXISTS, measured 2026-08-16 across Plex Mono's two weights.
24 + # The base redraws its symbols heavier in Bold rather than only thickening them
25 + # in place: `•` runs 244 to 308 units wide, `×` 424 to 460, `↑` 414 to 478, and
26 + # `✓`, `❌` and `◊` all grow too. What holds still is `█` and `░`, and those are
27 + # cell-fill primitives rather than symbols. `+` is the other pattern: identical
28 + # bbox in both weights, heavier bars inside it.
29 + #
30 + # So the set follows the base in both, and which one a mark follows is a property
31 + # of the mark rather than a setting:
32 + #
33 + # solid marks (the triangles) have no stroke to thicken, so they grow.
34 + # stroked marks (the X, the box, the return arrow) grow a little and thicken,
35 + # which is the pattern `x` and `+` follow in the base.
36 + #
37 + # Sizing off the band alone would leave a mark at Regular's size inside a Bold
38 + # face, reading light among its neighbours. That was this file's first rule and
39 + # it was wrong: it generalised from the block elements, which are the one thing
40 + # in the base that does not answer weight at all.
41 + #
42 + # Adding a glyph is an edit here plus a re-run against every face that consumes
43 + # the set. Nothing downstream needs touching.
44 +
45 + [set]
46 + version = 1
47 + name = "quasi house glyph set"
48 +
49 + # ---------------------------------------------------------------------------
50 + # Sort carets. Decision 591fca15 unified the three renderers on the triangles;
51 + # until this set shipped, a webview drawing one fell back per glyph to whatever
52 + # the OS ordered first, so the "unified" caret was three platforms' opinions.
53 + # ---------------------------------------------------------------------------
54 +
55 + [[glyph]]
56 + codepoint = 0x25B2
57 + name = "uni25B2"
58 + role = "sort caret, ascending"
59 + source = "decision 591fca15"
60 + shape = "triangle"
61 + direction = "up"
62 + # Across the base edge, off the band's width.
63 + span = { band = 0.75, weight = 0.62 }
64 + # Apex distance as a ratio of span. 0.866 is equilateral, at every weight.
65 + depth = 0.866
66 + anchor = "band-center"
67 +
68 + [[glyph]]
69 + codepoint = 0x25BC
70 + name = "uni25BC"
71 + role = "sort caret, descending"
72 + source = "decision 591fca15"
73 + shape = "triangle"
74 + direction = "down"
75 + span = { band = 0.75, weight = 0.62 }
76 + depth = 0.866
77 + anchor = "band-center"
78 +
79 + # ---------------------------------------------------------------------------
80 + # Starship segment separators and error state.
81 + # ---------------------------------------------------------------------------
82 +
83 + [[glyph]]
84 + codepoint = 0x25B8
85 + name = "uni25B8"
86 + role = "starship, segment separator"
87 + shape = "triangle"
88 + direction = "right"
89 + # Unicode calls these SMALL, and they sit inline in a prompt. Span is the
90 + # vertical extent for a horizontal mark, so it comes off the band's height.
91 + span = { band = 0.50, weight = 0.50 }
92 + depth = 0.75
93 + anchor = "band-center"
94 +
95 + [[glyph]]
96 + codepoint = 0x25C2
97 + name = "uni25C2"
98 + role = "starship, segment separator"
99 + shape = "triangle"
100 + direction = "left"
101 + span = { band = 0.50, weight = 0.50 }
102 + depth = 0.75
103 + anchor = "band-center"
104 +
105 + [[glyph]]
106 + codepoint = 0x2718
107 + name = "uni2718"
108 + role = "starship, error state"
109 + shape = "cross"
110 + width = { band = 0.70, weight = 0.50 }
111 + height = { band = 0.70, weight = 0.50 }
112 + # Calibrated against the base's own cross rather than by eye. Plex Mono's `x`
113 + # (U+00D7) fills 38.2% of its box in Regular and 53.4% in Bold; at 0.80 of the
114 + # base's bar this one fills 40.7% and 55.5%, which is what HEAVY BALLOT X should
115 + # be: above the base's cross, and not far above it. A flat 1.5x read as a blob.
116 + stroke = 0.80
117 +
118 + # ---------------------------------------------------------------------------
119 + # Helix whitespace renders. Both sit low in the cell, next to lowercase.
120 + # ---------------------------------------------------------------------------
121 +
122 + [[glyph]]
123 + codepoint = 0x2423
124 + name = "uni2423"
125 + role = "helix, space render"
126 + shape = "open-box"
127 + width = { band = 0.72, weight = 0.30 }
128 + height = { band = 0.38, weight = 0.30 }
129 + # Above the baseline, as a ratio of the band's height.
130 + bottom = 0.04
131 + # A stroked mark: its bars are the base's own, unmultiplied.
132 + stroke = 1.0
133 +
134 + [[glyph]]
135 + codepoint = 0x23CE
136 + name = "uni23CE"
137 + role = "helix, newline render"
138 + shape = "return-arrow"
139 + # Height of the riser's flat cap, as a ratio of the band's height.
140 + top = 0.92
141 + # Centre of the shaft, as a ratio of the band's height above its floor.
142 + shaft = 0.26
143 + # The head is solid, so it takes a weight term. Its coefficient is larger than
144 + # the triangles' because the head is measured against the shaft it sits on
145 + # rather than against the band: at { 0.36, 0.30 } the Bold head cleared its own
146 + # stroke by 38 units and read as a nub rather than an arrow.
147 + head_span = { band = 0.20, weight = 1.35 }
148 + head_depth = { band = 0.26, weight = 0.20 }
149 + stroke = 1.0
@@ -1,0 +1,4 @@
1 + [toolchain]
2 + channel = "1.97.1"
3 + profile = "minimal"
4 + components = ["rustfmt", "clippy"]
@@ -1,0 +1,284 @@
1 + #!/usr/bin/env python3
2 + """Do the tree's in-house `version` requirements still resolve?
3 +
4 + DO NOT EDIT IN PLACE. The master is _private/infra/bootstrap/githooks/internal-deps.py.
5 +
6 + Usage:
7 + internal-deps.py <tree-root> [repo-root]
8 +
9 + With a repo root, only pairs that repo is on either side of can fail the run;
10 + everything else is reported as a note. Without one, every pair is graded, which
11 + is the whole-tree report:
12 +
13 + python3 internal-deps.py ~/Code
14 +
15 + WHAT IT GRADES. Every dependency in the tree that carries both a `git` URL on one
16 + of our forges and a `version` requirement, against the version in the working
17 + copy of the crate that URL names. That is the pairing cargo enforces and the one
18 + that broke: a requirement of "0.11" against a sibling that has moved to 0.14 is
19 + not a warning, it is a graph that will not resolve on any machine.
20 +
21 + WHY WORKING COPIES AND NOT REMOTES. `~/Code/.cargo/config.toml` patches every one
22 + of these dependencies to the working copy in the tree, so what is on disk here is
23 + what every local build reads. A bump that has not been pushed yet breaks its
24 + consumers just as thoroughly, and finding that out at push time is the point.
25 +
26 + WHAT IT DOES NOT GRADE, on purpose:
27 +
28 + crates.io deps the makeover suite and friends resolve from the registry,
29 + where working ahead of a release is normal and a tree
30 + version above the published one is not a finding. The
31 + sweep's `coherence` check grades those against the index.
32 + path deps no version requirement to be wrong about.
33 + ranges and wildcards `>=`, `<`, `*` and comma lists are deliberate statements
34 + about a span, not a pin that drifts. Counted as unchecked.
35 + """
36 +
37 + import os
38 + import re
39 + import sys
40 + import tomllib
41 +
42 + # The forges that make a git URL ours. A dependency on somebody else's git repo
43 + # is not something this tree can forward-fix.
44 + OURS = re.compile(r"(makenot\.work|git\.sr\.ht/~maxmj)", re.I)
45 +
46 + # Directories that hold code we do not grade: retired, staged for deletion, or
47 + # not ours. Mirrors the sweep's exclusions rather than inventing a second list.
48 + SKIP_DIRS = {
49 + "target", ".git", "node_modules", "dist", "vendor",
50 + "_archive", "_scratch", "trash", "_meta", "vtebench",
51 + }
52 + MAX_DEPTH = 4
53 +
54 + DEP_SECTIONS = ("dependencies", "dev-dependencies", "build-dependencies")
55 +
56 +
57 + def manifests(root):
58 + """Every Cargo.toml in the tree, shallow-walked."""
59 + out = []
60 + stack = [(root, 0)]
61 + while stack:
62 + d, depth = stack.pop()
63 + try:
64 + entries = list(os.scandir(d))
65 + except OSError:
66 + continue
67 + for e in entries:
68 + if e.is_file() and e.name == "Cargo.toml":
69 + out.append(e.path)
70 + elif e.is_dir() and e.name not in SKIP_DIRS and depth < MAX_DEPTH:
71 + stack.append((e.path, depth + 1))
72 + return out
73 +
74 +
75 + def load(path):
76 + try:
77 + with open(path, "rb") as fh:
78 + return tomllib.load(fh)
79 + except (OSError, tomllib.TOMLDecodeError):
80 + return None
81 +
82 +
83 + def dep_tables(doc):
84 + """Every dependency table in a manifest, including per-target and workspace."""
85 + for section in DEP_SECTIONS:
86 + table = doc.get(section)
87 + if isinstance(table, dict):
88 + yield table
89 + for cfg in (doc.get("target") or {}).values():
90 + if not isinstance(cfg, dict):
91 + continue
92 + for section in DEP_SECTIONS:
93 + table = cfg.get(section)
94 + if isinstance(table, dict):
95 + yield table
96 + ws = doc.get("workspace") or {}
97 + table = ws.get("dependencies")
98 + if isinstance(table, dict):
99 + yield table
100 +
101 +
102 + def parse_version(v):
103 + """A version as a 3-tuple, prerelease dropped. Junk sorts as (0, 0, 0)."""
104 + core = str(v).split("+")[0].split("-")[0]
105 + parts = []
106 + for piece in core.split(".")[:3]:
107 + try:
108 + parts.append(int(piece))
109 + except ValueError:
110 + parts.append(0)
111 + while len(parts) < 3:
112 + parts.append(0)
113 + return tuple(parts)
114 +
115 +
116 + def satisfies(req, version):
117 + """Cargo's default (caret) requirement semantics. None means 'not graded'.
118 +
119 + The rule that matters here is the 0.x one: under 0.1.0 and above, the MINOR
120 + is the compatibility boundary, which is why a 0.11 requirement rejects 0.14
121 + outright rather than treating it as a newer patch.
122 + """
123 + req = req.strip()
124 + if not req or any(c in req for c in "<>*,~"):
125 + return None
126 + # A prerelease satisfies nothing that does not ask for a prerelease of the
127 + # same version, so a plain requirement rejects it. This is the shape the
128 + # maturity ladder produces at beta entry: a sibling at 1.0.0-beta.1 does not
129 + # resolve for a consumer requiring "1.0", and cargo says so.
130 + if "-" in str(version).split("+")[0] and "-" not in req:
131 + return False
132 + exact = req.startswith("=")
133 + req = req.lstrip("^=").strip()
134 + if not req:
135 + return None
136 + given = req.split(".")
137 + try:
138 + r = [int(p) for p in given[:3]]
139 + except ValueError:
140 + return None
141 + v = parse_version(version)
142 + if exact:
143 + return tuple(v[: len(r)]) == tuple(r)
144 + if r[0] > 0:
145 + return v[0] == r[0] and v[1:] >= tuple(r[1:] + [0] * (2 - len(r[1:])))
146 + if len(r) == 1:
147 + return v[0] == 0
148 + if r[1] > 0:
149 + return v[0] == 0 and v[1] == r[1] and v[2] >= (r[2] if len(r) > 2 else 0)
150 + # 0.0.x: every patch is its own compatibility island.
151 + if len(r) > 2:
152 + return v[:3] == (0, 0, r[2])
153 + return v[0] == 0 and v[1] == 0
154 +
155 +
156 + def main():
157 + if len(sys.argv) < 2:
158 + print(__doc__.strip(), file=sys.stderr)
159 + return 2
160 + tree = os.path.realpath(sys.argv[1])
161 + repo = os.path.realpath(sys.argv[2]) if len(sys.argv) > 2 else None
162 +
163 + paths = manifests(tree)
164 + docs = {p: load(p) for p in paths}
165 +
166 + # Workspace versions first: a member saying `version.workspace = true` gets
167 + # its number from the root, and reporting it as 0.0.0 would be a false break.
168 + ws_version = {}
169 + for p, doc in docs.items():
170 + if not doc:
171 + continue
172 + v = ((doc.get("workspace") or {}).get("package") or {}).get("version")
173 + if isinstance(v, str):
174 + ws_version[os.path.dirname(p)] = v
175 +
176 + def resolve_version(manifest_path, pkg):
177 + v = pkg.get("version")
178 + if isinstance(v, str):
179 + return v
180 + d = os.path.dirname(manifest_path)
181 + while d.startswith(tree):
182 + if d in ws_version:
183 + return ws_version[d]
184 + d = os.path.dirname(d)
185 + return None
186 +
187 + # crate name -> (version, manifest path)
188 + versions = {}
189 + for p, doc in docs.items():
190 + if not doc:
191 + continue
192 + pkg = doc.get("package")
193 + if not isinstance(pkg, dict) or not isinstance(pkg.get("name"), str):
194 + continue
195 + v = resolve_version(p, pkg)
196 + if v:
197 + versions[pkg["name"]] = (v, p)
198 +
199 + broken, unchecked, absent, graded = [], 0, set(), 0
200 + for p, doc in docs.items():
201 + if not doc:
202 + continue
203 + for table in dep_tables(doc):
204 + for key, spec in table.items():
205 + if not isinstance(spec, dict):
206 + continue
207 + git = spec.get("git")
208 + req = spec.get("version")
209 + if not isinstance(git, str) or not isinstance(req, str):
210 + continue
211 + if not OURS.search(git):
212 + continue
213 + name = spec.get("package") if isinstance(spec.get("package"), str) else key
214 + known = versions.get(name)
215 + if known is None:
216 + # A repo that is not on this machine (ripgrow lives on mbp
217 + # only). Not a finding: nothing here can be wrong about it.
218 + absent.add(name)
219 + continue
220 + verdict = satisfies(req, known[0])
221 + if verdict is None:
222 + unchecked += 1
223 + continue
224 + graded += 1
225 + if not verdict:
226 + broken.append((p, name, req, known[0], known[1]))
227 +
228 + if not broken:
229 + print(
230 + f"pre-push: internal deps coherent ({graded} requirements"
231 + + (f", {unchecked} unchecked" if unchecked else "")
232 + + (f", {len(absent)} crates not in this tree" if absent else "")
233 + + ")."
234 + )
235 + return 0
236 +
237 + def rel(path):
238 + return os.path.relpath(path, tree)
239 +
240 + ours, theirs = [], []
241 + for item in broken:
242 + consumer_manifest, name, req, have, provider_manifest = item
243 + mine = repo is not None and (
244 + consumer_manifest.startswith(repo + os.sep)
245 + or provider_manifest.startswith(repo + os.sep)
246 + )
247 + (ours if mine else theirs).append(item)
248 +
249 + for consumer_manifest, name, req, have, provider_manifest in ours + theirs:
250 + print(
251 + f" {rel(consumer_manifest)}: requires {name} \"{req}\", "
252 + f"the tree has {have} ({rel(provider_manifest)})",
253 + file=sys.stderr,
254 + )
255 +
256 + if repo is None:
257 + print(f"internal deps: {len(broken)} unresolvable requirements.", file=sys.stderr)
258 + return 1
259 +
260 + if not ours:
261 + # Somebody else's skew. Worth seeing, never worth blocking this push on:
262 + # a gate that fails for a reason the pusher cannot fix is a gate that
263 + # gets bypassed by reflex, and then it is not a gate.
264 + print(
265 + f"pre-push: {len(theirs)} unresolvable requirements elsewhere in the "
266 + "tree (listed above, not this push's).",
267 + )
268 + return 0
269 +
270 + print("", file=sys.stderr)
271 + print(
272 + "pre-push: this push leaves a dependency that cannot resolve.\n"
273 + " A version requirement states which major a consumer was written against,\n"
274 + " so bumping a library and fixing its consumers is one pass (CLAUDE.md,\n"
275 + " \"a breaking bump of an in-house crate is forward-fixed, in the same pass\").\n"
276 + " Fix: bump the requirement in the manifests above, make the consumers\n"
277 + " compile, and push them with this one.",
278 + file=sys.stderr,
279 + )
280 + return 1
281 +
282 +
283 + if __name__ == "__main__":
284 + sys.exit(main())
@@ -1,0 +1,212 @@
1 + #!/bin/bash
2 + # Canonical pre-commit gate. Byte-identical in every repo under ~/Code.
3 + #
4 + # DO NOT EDIT IN PLACE. The master is _private/infra/bootstrap/githooks/pre-commit
5 + # and install-githooks.sh --check reports any copy that has drifted from it. Edit
6 + # the master, re-run the installer, commit the repos it touched.
7 + #
8 + # Activate in a fresh clone (one-time):
9 + # git config core.hooksPath scripts/githooks
10 + # clone-tree.sh does this for every repo it clones, so only a hand clone needs it.
11 + #
12 + # Bypass for a work-in-progress commit: git commit --no-verify
13 + #
14 + # Every gate below decides for itself whether it applies, from what is in the repo
15 + # and what is staged. That is what lets one file serve a library, an app and a
16 + # server: the repo's shape selects the gates rather than a per-repo edit, which is
17 + # the drift that let makeover-immediate 0.18.0 reach its release preflight
18 + # unformatted and left eight violations sitting on quasi's main (infra a33fdaab).
19 + #
20 + # NOT here, deliberately: clippy. It is slow enough that a commit-time gate is one
21 + # people bypass, so it stays in CI and the sweep.
22 + #
23 + # Genuinely repo-local extras go in scripts/githooks/pre-commit.local, which this
24 + # runs last if it exists.
25 + set -euo pipefail
26 +
27 + ROOT="$(git rev-parse --show-toplevel)"
28 + cd "$ROOT"
29 +
30 + # git invoked from an editor, a cron job, or a non-interactive shell does not
31 + # source the profile that puts ~/.local/bin on PATH, and a hook that silently
32 + # cannot find gitleaks or cargo is worse than no hook. (Lesson from _private's
33 + # own hook, which is stricter still: it refuses to commit blind.)
34 + export PATH="$HOME/.local/bin:$HOME/.cargo/bin:/opt/homebrew/bin:/usr/local/bin:$PATH"
35 +
36 + # --- secret scan (gitleaks) -------------------------------------------------
37 + # Independent guardrail: blocks a commit whose staged changes contain a secret,
38 + # regardless of whether a human judged the value "safe". Shared ruleset lives at
39 + # ~/Code/.gitleaks.toml. Degrades gracefully if gitleaks is not installed (the
40 + # astra pre-receive hook is the backstop that always runs). Task: infra 97ffeda0.
41 + if command -v gitleaks >/dev/null 2>&1; then
42 + GL_CFG=""
43 + if [ -f "$ROOT/.gitleaks.toml" ]; then
44 + GL_CFG="$ROOT/.gitleaks.toml"
45 + elif [ -f "$HOME/Code/.gitleaks.toml" ]; then
46 + GL_CFG="$HOME/Code/.gitleaks.toml"
47 + fi
48 + gl_args=(git --staged --no-banner --redact)
49 + [ -n "$GL_CFG" ] && gl_args+=(-c "$GL_CFG")
50 + if ! gitleaks "${gl_args[@]}"; then
51 + echo "pre-commit: gitleaks found a secret in the staged changes."
52 + echo " remove it (or allowlist a false positive), then restage."
53 + echo " bypass: git commit --no-verify."
54 + exit 1
55 + fi
56 + echo "pre-commit: gitleaks clean."
57 + else
58 + echo "pre-commit: gitleaks not installed; skipping secret scan (astra gates on push)."
59 + fi
60 +
61 + # --- migration immutability -------------------------------------------------
62 + # Applies to any repo with migrations, which is why it is not MNW-local: sqlx
63 + # checksums a migration's whole file when it runs it and refuses one whose bytes
64 + # changed since, so editing an already-applied migration breaks every deploy
65 + # against that database with "previously applied but modified" -- for a comment
66 + # edit, and for a line-ending change, exactly as much as for a schema change.
67 + #
68 + # The 2026-07-27 exorcise sweep rewrote comments in 29 applied MNW migrations and
69 + # converted one from CRLF to LF. Nothing in the test suite checksums a migration,
70 + # so it stayed invisible while it blocked every server deploy for three days.
71 + #
72 + # goingson and balanced_breakfast are in scope too: both kept their
73 + # `_sqlx_migrations` ledger verbatim through the 2026-08-07 rusqlite migration, so
74 + # an upgraded install still reads those rows and an edited file still contradicts
75 + # them. Adding a new migration is always fine; this only blocks M/D/R.
76 + touched="$(git diff --cached --name-only --diff-filter=MDR -- '*migrations/*.sql')"
77 + if [ -n "$touched" ]; then
78 + echo "pre-commit: these already-committed migrations were modified, renamed, or deleted:"
79 + while IFS= read -r m; do
80 + [ -n "$m" ] && echo " $m"
81 + done <<< "$touched"
82 + echo " A migration is immutable once applied; the runner checksums the"
83 + echo " whole file, comments included. Write a new migration instead."
84 + echo " Bypass ONLY if it has never been applied anywhere, including"
85 + echo " prod, staging, and your dev database: git commit --no-verify."
86 + exit 1
87 + fi
88 +
89 + # --- frontend design-system lint --------------------------------------------
90 + # Runs any scripts/lint-frontend.sh the repo carries when the commit touches a
91 + # frontend asset. Each of those scripts resolves its own paths from its location,
92 + # so finding them is enough and no path knowledge belongs here. Both known scripts
93 + # live at repo root (goingson, balanced_breakfast) or one level down (MNW's is
94 + # server/scripts/lint-frontend.sh), hence the depth-2 search.
95 + #
96 + # This sits ABOVE the rustfmt gate deliberately: that gate exits early when no .rs
97 + # files are staged, which is exactly the case where a frontend commit needs
98 + # checking. goingson's copy also runs the JS suite, which carries the CHRONIC-XSS
99 + # escaping gate.
100 + staged_fe="$(git diff --cached --name-only --diff-filter=ACMR -- '*.js' '*.css' '*.html')"
101 + if [ -n "$staged_fe" ]; then
102 + while IFS= read -r lint; do
103 + [ -n "$lint" ] || continue
104 + if ! fe_out=$(bash "$lint" 2>&1); then
105 + echo "$fe_out"
106 + echo "pre-commit: frontend lint failed ($lint)."
107 + echo " fix the rules above, then restage."
108 + echo " bypass: git commit --no-verify."
109 + exit 1
110 + fi
111 + echo "pre-commit: frontend lint clean ($lint)."
112 + done <<< "$(find . -maxdepth 3 -path ./target -prune -o \
113 + -path '*/scripts/lint-frontend.sh' -print 2>/dev/null | sort)"
114 + fi
115 +
116 + # --- rustfmt ----------------------------------------------------------------
117 + # Blocks a commit whose staged Rust files are not formatted. Only crates with
118 + # staged .rs changes are checked, so the hook stays fast on a large repo. Each
119 + # file maps to the nearest enclosing Cargo.toml and the check runs as `cargo fmt`
120 + # there, which picks up that crate's edition and any rustfmt.toml rather than
121 + # guessing -- and is why this works unchanged in MNW, which has no root workspace.
122 + #
123 + # SKIP_PATHS is an extended regex of repo-relative paths to ignore. Empty means
124 + # check everything. Set it in pre-commit.local if a repo ever needs one.
125 + SKIP_PATHS="${SKIP_PATHS:-}"
126 +
127 + staged="$(git diff --cached --name-only --diff-filter=ACMR -- '*.rs')"
128 + if [ -n "$SKIP_PATHS" ]; then
129 + staged="$(printf '%s\n' "$staged" | grep -Ev "$SKIP_PATHS" || true)"
130 + fi
131 +
132 + if [ -n "$staged" ]; then
133 + # Map each staged file to the directory of its nearest Cargo.toml.
134 + crates=""
135 + while IFS= read -r f; do
136 + [ -n "$f" ] || continue
137 + d="$(dirname "$f")"
138 + while [ "$d" != "." ] && [ ! -f "$d/Cargo.toml" ]; do
139 + d="$(dirname "$d")"
140 + done
141 + [ -f "$d/Cargo.toml" ] || continue
142 + crates="$crates$d"$'\n'
143 + done <<< "$staged"
144 +
145 + crates="$(printf '%s' "$crates" | sort -u)"
146 +
147 + failed=0
148 + while IFS= read -r c; do
149 + [ -n "$c" ] || continue
150 + if ! (cd "$c" && cargo fmt --check >/dev/null 2>&1); then
151 + echo "pre-commit: rustfmt gate failed in $c"
152 + failed=1
153 + fi
154 + done <<< "$crates"
155 +
156 + if [ "$failed" -ne 0 ]; then
157 + echo "pre-commit: run 'cargo fmt' in the crates above, then restage."
158 + echo "pre-commit: commit aborted (use --no-verify to bypass)."
159 + exit 1
160 + fi
161 + echo "pre-commit: rustfmt gate clean."
162 + fi
163 +
164 + # --- openapi.json staleness -------------------------------------------------
165 + # Only fires in a repo that commits a generated spec, which today is MNW alone.
166 + #
167 + # server/openapi.json is a committed artifact and `openapi::tests::
168 + # committed_spec_matches_generated` asserts it matches the generated spec. The
169 + # spec embeds CARGO_PKG_VERSION, so EVERY version bump invalidates it even when no
170 + # route changed.
171 + #
172 + # Nothing local caught that. The /deploy pre-push guard is a `cargo test --no-run`
173 + # compile check, and the spec is read at runtime by path rather than include_str!,
174 + # so a stale copy compiles fine. On 2026-08-06 the v0.11.8 bump left the spec at
175 + # 0.11.7, pushed clean to all three remotes, and killed Sando run 38 about fifteen
176 + # minutes in -- two full remote build cycles for a one-line diff in info.version.
177 + #
178 + # So: regenerate to stdout and compare against the STAGED copy (not the working
179 + # tree one -- regenerating without restaging is the same bug wearing a hat).
180 + if [ -f "$ROOT/server/openapi.json" ]; then
181 + specish="$(git diff --cached --name-only --diff-filter=ACMR \
182 + -- 'server/Cargo.toml' 'server/src/*.rs' 'server/src/**/*.rs' 'server/openapi.json')"
183 + if [ -n "$specish" ]; then
184 + echo "pre-commit: checking openapi.json against the generated spec..."
185 + gen="$(mktemp)"
186 + trap 'rm -f "$gen"' EXIT
187 + if (cd "$ROOT/server" && cargo run --quiet --bin export-openapi -- --stdout) > "$gen" 2>/dev/null; then
188 + if ! git show :server/openapi.json 2>/dev/null | diff -q - "$gen" >/dev/null; then
189 + echo "pre-commit: server/openapi.json is stale (or regenerated but not staged)."
190 + echo " cd server && cargo run --bin export-openapi"
191 + echo " git add server/openapi.json"
192 + echo " Then vendor the same bytes into the OTHER repo, which this"
193 + echo " commit cannot carry and Sando will fail on:"
194 + echo " cp server/openapi.json ../synckit/synckit-client/tests/openapi.json"
195 + echo " Bypass: git commit --no-verify."
196 + exit 1
197 + fi
198 + echo "pre-commit: openapi.json current."
199 + else
200 + echo "pre-commit: could not build export-openapi; skipping spec check."
201 + echo " cargo_test in Sando is the backstop, 15 minutes into the build."
202 + fi
203 + fi
204 + fi
205 +
206 + # --- repo-local extras ------------------------------------------------------
207 + # The escape hatch for a gate that cannot be selected from the repo's shape. Keep
208 + # it small: anything a second repo wants belongs in the canonical file above,
209 + # guarded by its own detection.
210 + if [ -f "$ROOT/scripts/githooks/pre-commit.local" ]; then
211 + bash "$ROOT/scripts/githooks/pre-commit.local" || exit 1
212 + fi
@@ -1,0 +1,84 @@
1 + #!/bin/bash
2 + # Canonical pre-push gate. Two gates, and they answer different questions:
3 + #
4 + # internal deps does every in-house `version` requirement in the tree still
5 + # resolve against the crate it names? Runs in EVERY repo.
6 + # test targets do this repo's test targets build? Runs where there is a
7 + # root Cargo.toml to run one command in.
8 + #
9 + # DO NOT EDIT IN PLACE. The master is _private/infra/bootstrap/githooks/pre-push.
10 + #
11 + # Bypass for a work-in-progress push: git push --no-verify
12 + #
13 + # WHY THE FIRST GATE EXISTS. Every cross-repo dependency carries a `version`
14 + # alongside its `git` URL, so cargo refuses a sibling it was not written against
15 + # instead of compiling something surprising. That requirement is the protection
16 + # and it is also the maintenance: bumping a library's minor breaks every consumer
17 + # whose requirement excludes it, and CLAUDE.md's rule is that the bump and the
18 + # forward fix are one pass. Nothing enforced the rule, so quasi went 0.11 -> 0.14
19 + # over two evenings and MNW's server could not resolve at all for a day. The
20 + # nightly sweep found it and a red cell in a grid is not the same as being told.
21 + #
22 + # This gate is that rule, mechanised, at the moment it is broken: the push that
23 + # would leave a consumer unable to build is the push that is refused. It reads
24 + # the WORKING COPIES in the tree, not the remotes, because `~/Code/.cargo/config.toml`
25 + # redirects every one of these dependencies to the working copy -- so a local bump
26 + # breaks a consumer's build here whether or not it has been pushed anywhere.
27 + #
28 + # `cargo check` and `cargo clippy` both compile only the lib and bin targets, so a
29 + # break confined to `tests/` or a `#[cfg(test)]` module is clean under both and
30 + # lands unnoticed (goingson's sqlx 0.9 upgrade shipped exactly that way).
31 + # `--no-run` builds every test target without running them, which is the cheap
32 + # half of the suite and enough to catch a compile break. Tests still run
33 + # separately.
34 + #
35 + # `--workspace` is load-bearing wherever default-members is narrower than the
36 + # workspace: goingson's is src-tauri alone, so a bare `cargo test --no-run` would
37 + # skip core, db-sqlite, go-mcp and got.
38 + set -euo pipefail
39 +
40 + ROOT="$(git rev-parse --show-toplevel)"
41 + cd "$ROOT"
42 +
43 + # See the canonical pre-commit: a hook run from an editor or a cron job does not
44 + # get the profile's PATH, and a hook that cannot find cargo is worse than none.
45 + export PATH="$HOME/.cargo/bin:$HOME/.local/bin:/opt/homebrew/bin:/usr/local/bin:$PATH"
46 +
47 + # Refs arrive on stdin as "<local ref> <local sha> <remote ref> <remote sha>".
48 + # A branch deletion has an all-zero local sha and no tree to push. Read once,
49 + # ahead of both gates: stdin is not seekable and a second reader gets nothing.
50 + pushing=0
51 + while read -r _local_ref local_sha _remote_ref _remote_sha; do
52 + case "$local_sha" in
53 + *[!0]*) pushing=1 ;;
54 + esac
55 + done
56 + [ "$pushing" -eq 1 ] || exit 0
57 +
58 + # ── gate 1: internal dependency coherence ──────────────────────────────────
59 + #
60 + # Deliberately no cargo: this reads manifests and answers in well under a second,
61 + # where `cargo metadata` on the server is tens of seconds and fails outright on
62 + # exactly the state being detected.
63 + CODE_ROOT="${CODE_ROOT:-$HOME/Code}"
64 + if [ -d "$CODE_ROOT" ] && command -v python3 >/dev/null 2>&1; then
65 + if ! python3 "$ROOT/scripts/githooks/internal-deps.py" "$CODE_ROOT" "$ROOT"; then
66 + echo "pre-push: push aborted (use --no-verify to bypass)."
67 + exit 1
68 + fi
69 + fi
70 +
71 + # ── gate 2: test targets build ─────────────────────────────────────────────
72 + #
73 + # MNW and synckit have no root Cargo.toml by design (standalone crates, no root
74 + # workspace), so there is no one command to run and they get gate 1 only.
75 + [ -f "$ROOT/Cargo.toml" ] || exit 0
76 +
77 + echo "pre-push: building test targets (cargo test --no-run --workspace)..."
78 + if ! cargo test --no-run --workspace; then
79 + echo "pre-push: test targets failed to build."
80 + echo "pre-push: push aborted (use --no-verify to bypass)."
81 + exit 1
82 + fi
83 +
84 + echo "pre-push: test targets build clean."
A src/assert.rs +117
@@ -1,0 +1,117 @@
1 + //! The coverage assertion, run once per built face.
2 + //!
3 + //! This used to live in Alloy's `Containerfile`, which greps
4 + //! `fc-list ':charset=25B2'` after installing the font. That check is in the
5 + //! wrong place: it asks the image whether a face somebody else built covers a
6 + //! mark, so a gap surfaces as a failed image build rather than as a failed font
7 + //! build, and only for the one consumer that thought to grep. Asserting here
8 + //! means every consumer inherits the guarantee, and the image is left to assert
9 + //! only that it installed the right family.
10 +
11 + use std::collections::BTreeMap;
12 +
13 + use read_fonts::types::GlyphId;
14 +
15 + use crate::Error;
16 + use crate::manifest::format_codepoint;
17 +
18 + /// Every codepoint Alloy's own surfaces put on a screen, measured 2026-08-06
19 + /// against the built image and recorded in `alloy/docs/FONTS.md`.
20 + ///
21 + /// A face for the `monospace` alias has to cover all of it or a shipped config
22 + /// renders a missing glyph. Kept here as the pipeline's floor rather than in
23 + /// the manifest: the manifest says what we draw, this says what a consumer
24 + /// needs, and the seven exist only because the two differ.
25 + pub const ALLOY_SURFACE: [u32; 18] = [
26 + 0x00B7, 0x00BB, 0x2014, 0x2022, 0x2026, 0x2191, 0x2192, 0x2193, 0x2195, 0x23CE, 0x2423, 0x2502,
27 + 0x2588, 0x258F, 0x2591, 0x25B8, 0x25C2, 0x2718,
28 + ];
29 +
30 + /// The sort carets, which Alloy does not emit but every described screen does.
31 + pub const SORT_CARETS: [u32; 2] = [0x25B2, 0x25BC];
32 +
33 + pub struct Coverage {
34 + pub required: Vec<u32>,
35 + pub missing: Vec<u32>,
36 + }
37 +
38 + impl Coverage {
39 + pub fn ok(&self) -> bool {
40 + self.missing.is_empty()
41 + }
42 + }
43 +
44 + /// Check a built face against everything a consumer is entitled to assume.
45 + pub fn check(mappings: &BTreeMap<u32, GlyphId>, house_set: &[u32]) -> Coverage {
46 + let mut required: Vec<u32> = ALLOY_SURFACE
47 + .iter()
48 + .chain(SORT_CARETS.iter())
49 + .chain(house_set.iter())
50 + .copied()
51 + .collect();
52 + required.sort_unstable();
53 + required.dedup();
54 +
55 + let missing = required
56 + .iter()
57 + .copied()
58 + .filter(|cp| mappings.get(cp).is_none_or(|gid| gid.to_u32() == 0))
59 + .collect();
60 +
61 + Coverage { required, missing }
62 + }
63 +
64 + pub fn describe(coverage: &Coverage) -> Result<String, Error> {
65 + if coverage.ok() {
66 + return Ok(format!("cmap covers all {}", coverage.required.len()));
67 + }
68 + Err(Error::Coverage(
69 + coverage
70 + .missing
71 + .iter()
72 + .map(|cp| format_codepoint(*cp))
73 + .collect::<Vec<_>>()
74 + .join(" "),
75 + ))
76 + }
77 +
78 + #[cfg(test)]
79 + mod tests {
80 + use super::*;
81 +
82 + fn mapping(codepoints: &[u32]) -> BTreeMap<u32, GlyphId> {
83 + codepoints
84 + .iter()
85 + .enumerate()
86 + .map(|(i, cp)| (*cp, GlyphId::from(i as u16 + 1)))
87 + .collect()
88 + }
89 +
90 + #[test]
91 + fn the_floor_is_the_twenty_the_done_condition_names() {
92 + let coverage = check(&mapping(&[]), &[]);
93 + assert_eq!(
94 + coverage.required.len(),
95 + 20,
96 + "Alloy's eighteen plus the two carets"
97 + );
98 + }
99 +
100 + #[test]
101 + fn a_face_missing_a_caret_fails() {
102 + let mut all: Vec<u32> = ALLOY_SURFACE.to_vec();
103 + all.push(0x25B2);
104 + let coverage = check(&mapping(&all), &[]);
105 + assert_eq!(coverage.missing, vec![0x25BC]);
106 + assert!(describe(&coverage).is_err());
107 + }
108 +
109 + #[test]
110 + fn a_notdef_mapping_does_not_count_as_coverage() {
111 + let mut mappings = mapping(&ALLOY_SURFACE);
112 + mappings.insert(0x25B2, GlyphId::from(0u16));
113 + mappings.insert(0x25BC, GlyphId::from(0u16));
114 + let coverage = check(&mappings, &[]);
115 + assert_eq!(coverage.missing, vec![0x25B2, 0x25BC]);
116 + }
117 + }
A src/base.rs +314
@@ -1,0 +1,314 @@
1 + //! Fetching a pinned base, and measuring the parameters a refit needs.
2 + //!
3 + //! Nothing here draws. It answers one question about a base face: what are its
4 + //! stroke weights, its cell, and the band its own symbols are fitted into.
5 +
6 + use std::collections::BTreeMap;
7 + use std::io::Read;
8 + use std::path::{Path, PathBuf};
9 + use std::process::Command;
10 +
11 + use read_fonts::tables::cmap::{Cmap, CmapSubtable};
12 + use read_fonts::types::GlyphId;
13 + use read_fonts::{FontRef, TableProvider};
14 + use sha2::{Digest, Sha256};
15 +
16 + use crate::Error;
17 + use crate::pins::Base;
18 +
19 + /// A base face, verified against its pin and read into memory.
20 + pub struct BaseFace {
21 + pub style: String,
22 + pub bytes: Vec<u8>,
23 + }
24 +
25 + /// Everything a drawing recipe is allowed to depend on, measured off the base.
26 + ///
27 + /// Every field is read from the face rather than configured. That is what makes
28 + /// the set a pipeline instead of seven one-offs: point the recipes at a
29 + /// different base and the marks refit to its weight and cell.
30 + #[derive(Debug, Clone, Copy)]
31 + pub struct BaseParams {
32 + pub upem: u16,
33 + /// The cell width. Marks centre on `advance / 2`.
34 + pub advance: u16,
35 + pub cap_height: i16,
36 + pub x_height: i16,
37 + /// `|`'s bbox width: the base's vertical stroke weight.
38 + pub stem: i16,
39 + /// `-`'s bbox height: the base's horizontal stroke weight.
40 + pub stroke: i16,
41 + /// `+`'s bbox: the band the base fits its own symbols into.
42 + pub band_x0: i16,
43 + pub band_x1: i16,
44 + pub band_y0: i16,
45 + pub band_y1: i16,
46 + }
47 +
48 + impl BaseParams {
49 + pub fn band_width(&self) -> f64 {
50 + f64::from(self.band_x1 - self.band_x0)
51 + }
52 +
53 + pub fn band_height(&self) -> f64 {
54 + f64::from(self.band_y1 - self.band_y0)
55 + }
56 +
57 + pub fn band_center_y(&self) -> f64 {
58 + f64::from(self.band_y0 + self.band_y1) / 2.0
59 + }
60 +
61 + /// Horizontal centre of the cell, which is not the band's centre in a face
62 + /// whose symbols are asymmetric.
63 + pub fn center_x(&self) -> f64 {
64 + f64::from(self.advance) / 2.0
65 + }
66 +
67 + pub fn x_height_center_y(&self) -> f64 {
68 + f64::from(self.x_height) / 2.0
69 + }
70 + }
71 +
72 + /// The reference glyphs a base must have before it can be refitted against.
73 + /// A base missing one cannot be measured, and guessing is worse than refusing.
74 + const REFERENCES: [(char, &str); 3] = [
75 + ('|', "the vertical stroke weight"),
76 + ('-', "the horizontal stroke weight"),
77 + ('+', "the symbol band"),
78 + ];
79 +
80 + pub fn measure(bytes: &[u8]) -> Result<BaseParams, Error> {
81 + let font = FontRef::new(bytes).map_err(|e| Error::Font(format!("base is unreadable: {e}")))?;
82 + let head = font.head().map_err(table_err("head"))?;
83 + let os2 = font.os2().map_err(table_err("OS/2"))?;
84 + let hmtx = font.hmtx().map_err(table_err("hmtx"))?;
85 + let cmap = font.cmap().map_err(table_err("cmap"))?;
86 +
87 + for (ch, what) in REFERENCES {
88 + if lookup(&cmap, ch).is_none() {
89 + return Err(Error::UnmeasurableBase {
90 + missing: ch,
91 + what: what.to_owned(),
92 + });
93 + }
94 + }
95 +
96 + let bar = bbox(&font, lookup(&cmap, '|').unwrap())?;
97 + let hyphen = bbox(&font, lookup(&cmap, '-').unwrap())?;
98 + let plus = bbox(&font, lookup(&cmap, '+').unwrap())?;
99 +
100 + // A monospace face gives the same advance for every glyph; taking it off a
101 + // reference glyph rather than off hhea keeps that assumption checkable.
102 + let advance = hmtx
103 + .advance(lookup(&cmap, '+').unwrap())
104 + .ok_or_else(|| Error::Font("base has no advance for `+`".into()))?;
105 +
106 + let cap_height = os2
107 + .s_cap_height()
108 + .unwrap_or(bbox_or_zero(&font, &cmap, 'H').3);
109 + let x_height = os2.sx_height().unwrap_or(bbox_or_zero(&font, &cmap, 'x').3);
110 +
111 + Ok(BaseParams {
112 + upem: head.units_per_em(),
113 + advance,
114 + cap_height,
115 + x_height,
116 + stem: bar.2 - bar.0,
117 + stroke: hyphen.3 - hyphen.1,
118 + band_x0: plus.0,
119 + band_x1: plus.2,
120 + band_y0: plus.1,
121 + band_y1: plus.3,
122 + })
123 + }
124 +
125 + fn table_err(tag: &'static str) -> impl Fn(read_fonts::ReadError) -> Error {
126 + move |e| Error::Font(format!("base has no readable `{tag}` table: {e}"))
127 + }
128 +
129 + /// The Unicode subtable a base maps through, preferring full-repertoire.
130 + pub fn best_subtable<'a>(cmap: &Cmap<'a>) -> Option<CmapSubtable<'a>> {
131 + let mut best: Option<(u8, CmapSubtable<'a>)> = None;
132 + for record in cmap.encoding_records() {
133 + use read_fonts::tables::cmap::PlatformId::{Unicode, Windows};
134 + let rank = match (record.platform_id(), record.encoding_id()) {
135 + (Windows, 10) => 4,
136 + (Unicode, 4 | 6) => 3,
137 + (Windows, 1) => 2,
138 + (Unicode, 0..=3) => 1,
139 + _ => continue,
140 + };
141 + let Ok(subtable) = record.subtable(cmap.offset_data()) else {
142 + continue;
143 + };
144 + if best.as_ref().is_none_or(|(r, _)| rank > *r) {
145 + best = Some((rank, subtable));
146 + }
147 + }
148 + best.map(|(_, s)| s)
149 + }
150 +
151 + fn lookup(cmap: &Cmap<'_>, ch: char) -> Option<GlyphId> {
152 + cmap.map_codepoint(ch)
153 + }
154 +
155 + /// Every codepoint the base maps, so the rebuilt cmap keeps all of it.
156 + pub fn mappings(bytes: &[u8]) -> Result<BTreeMap<u32, GlyphId>, Error> {
157 + let font = FontRef::new(bytes).map_err(|e| Error::Font(format!("base is unreadable: {e}")))?;
158 + let cmap = font.cmap().map_err(table_err("cmap"))?;
159 + let subtable = best_subtable(&cmap)
160 + .ok_or_else(|| Error::Font("base has no Unicode cmap subtable".into()))?;
161 + let mut out = BTreeMap::new();
162 + for (codepoint, gid) in subtable.iter() {
163 + if gid.to_u32() != 0 && char::from_u32(codepoint).is_some() {
164 + out.insert(codepoint, gid);
165 + }
166 + }
167 + Ok(out)
168 + }
169 +
170 + /// `(x_min, y_min, x_max, y_max)` for a glyph, composites included.
171 + fn bbox(font: &FontRef<'_>, gid: GlyphId) -> Result<(i16, i16, i16, i16), Error> {
172 + let loca = font.loca(None).map_err(table_err("loca"))?;
173 + let glyf = font.glyf().map_err(table_err("glyf"))?;
174 + let glyph = loca
175 + .get_glyf(gid, &glyf)
176 + .map_err(|e| Error::Font(format!("unreadable glyph {gid}: {e}")))?
177 + .ok_or_else(|| Error::Font(format!("glyph {gid} is empty")))?;
178 + Ok(match glyph {
179 + read_fonts::tables::glyf::Glyph::Simple(g) => (g.x_min(), g.y_min(), g.x_max(), g.y_max()),
180 + read_fonts::tables::glyf::Glyph::Composite(g) => {
181 + (g.x_min(), g.y_min(), g.x_max(), g.y_max())
182 + }
183 + })
184 + }
185 +
186 + fn bbox_or_zero(font: &FontRef<'_>, cmap: &Cmap<'_>, ch: char) -> (i16, i16, i16, i16) {
187 + lookup(cmap, ch)
188 + .and_then(|gid| bbox(font, gid).ok())
189 + .unwrap_or((0, 0, 0, 0))
190 + }
191 +
192 + /// Fetch the pinned archive if it is not cached, verify it, and read out the
193 + /// faces the pin names.
194 + ///
195 + /// The archive is verified before anything is read out of it, and each face is
196 + /// verified again on the way out. Two checks rather than one because they fail
197 + /// differently: the first says upstream moved, the second says the pin names a
198 + /// path that no longer holds what it did.
199 + 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)?;
209 + }
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 + let mut faces = Vec::new();
224 + 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(),
228 + expected: face.sha256.clone(),
229 + found,
230 + })?;
231 + faces.push(BaseFace {
232 + style: face.style.clone(),
233 + bytes: data,
234 + });
235 + }
236 + Ok(faces)
237 + }
238 +
239 + /// The upstream licence text, which travels with every build the OFL requires
240 + /// it to.
241 + pub fn license_text(base: &Base, cache: &Path) -> Result<Vec<u8>, Error> {
242 + let archive = cache.join(format!("{}-{}.zip", base.id, base.version));
243 + let bytes = std::fs::read(&archive).map_err(|e| Error::Io(archive.clone(), e))?;
244 + let cursor = std::io::Cursor::new(&bytes);
245 + let mut zip = zip::ZipArchive::new(cursor)
246 + .map_err(|e| Error::Archive(format!("{} is not a readable zip: {e}", archive.display())))?;
247 + read_entry(&mut zip, &base.license_path)
248 + }
249 +
250 + fn read_entry<R: std::io::Read + std::io::Seek>(
251 + zip: &mut zip::ZipArchive<R>,
252 + path: &str,
253 + ) -> Result<Vec<u8>, Error> {
254 + let mut entry = zip
255 + .by_name(path)
256 + .map_err(|_| Error::Archive(format!("the pin names `{path}`, which the archive lacks")))?;
257 + let mut data = Vec::new();
258 + entry
259 + .read_to_end(&mut data)
260 + .map_err(|e| Error::Archive(format!("`{path}` is unreadable: {e}")))?;
261 + Ok(data)
262 + }
263 +
264 + fn verify(bytes: &[u8], expected: &str) -> Result<(), String> {
265 + let found = hex(&Sha256::digest(bytes));
266 + if found == expected {
267 + Ok(())
268 + } else {
269 + Err(found)
270 + }
271 + }
272 +
273 + pub fn hex(bytes: &[u8]) -> String {
274 + use std::fmt::Write;
275 + bytes.iter().fold(String::new(), |mut out, b| {
276 + let _ = write!(out, "{b:02x}");
277 + out
278 + })
279 + }
280 +
281 + /// Fetching shells out to curl rather than linking an HTTP client.
282 + ///
283 + /// This is a build-time tool that downloads one pinned URL. A TLS stack would
284 + /// be the largest thing in the dependency tree and would drag in the crypto
285 + /// provider question for no gain: the integrity guarantee here is the sha256
286 + /// below, not the transport.
287 + fn fetch(url: &str, dest: &Path) -> Result<(), Error> {
288 + if let Some(parent) = dest.parent() {
289 + std::fs::create_dir_all(parent).map_err(|e| Error::Io(parent.to_path_buf(), e))?;
290 + }
291 + let partial = dest.with_extension("part");
292 + let status = Command::new("curl")
293 + .args([
294 + "--fail",
295 + "--location",
296 + "--silent",
297 + "--show-error",
298 + "--output",
299 + ])
300 + .arg(&partial)
301 + .arg(url)
302 + .status()
303 + .map_err(|e| Error::Fetch(format!("could not run curl: {e}")))?;
304 + if !status.success() {
305 + let _ = std::fs::remove_file(&partial);
306 + return Err(Error::Fetch(format!("curl failed on {url} ({status})")));
307 + }
308 + std::fs::rename(&partial, dest).map_err(|e| Error::Io(dest.to_path_buf(), e))?;
309 + Ok(())
310 + }
311 +
312 + pub fn cache_dir(root: &Path) -> PathBuf {
313 + root.join("bases").join("cache")
314 + }
@@ -1,0 +1,410 @@
1 + //! Assembling the output face: base tables in, a `Quasi <Slot>` face out.
2 + //!
3 + //! The base's own glyphs are copied as bytes and never recompiled. Plex Mono is
4 + //! hinted (`cvt `, `fpgm`, `prep`), and round-tripping 1,207 glyphs through a
5 + //! builder to add seven risks changing a face nobody asked us to change. So
6 + //! `glyf` is spliced: base bytes, then ours, with `loca` rebuilt over the pair.
7 +
8 + use std::collections::BTreeMap;
9 +
10 + use read_fonts::types::{GlyphId, Tag};
11 + use read_fonts::{FontRef, TableProvider, TopLevelTable};
12 + use write_fonts::FontBuilder;
13 + use write_fonts::tables::cmap::Cmap;
14 + use write_fonts::tables::glyf::{Glyph, SimpleGlyph};
15 + use write_fonts::tables::name::{Name, NameRecord};
16 + use write_fonts::tables::post::Post;
17 + use write_fonts::types::{Fixed, NameId, Version16Dot16};
18 +
19 + use crate::Error;
20 + use crate::base::{self, BaseParams};
21 + use crate::draw;
22 + use crate::manifest::GlyphSpec;
23 + use crate::pins::Base;
24 +
25 + /// What the pipeline stamps into a face's `name` table.
26 + pub struct Identity<'a> {
27 + pub family: &'a str,
28 + pub style: &'a str,
29 + pub version: &'a str,
30 + pub base: &'a Base,
31 + }
32 +
33 + /// A face, and what went into it.
34 + pub struct Built {
35 + pub bytes: Vec<u8>,
36 + pub added: Vec<(u32, String)>,
37 + pub params: BaseParams,
38 + }
39 +
40 + /// Tables the pipeline rebuilds. Everything else is copied verbatim.
41 + const REBUILT: [Tag; 8] = [
42 + Tag::new(b"glyf"),
43 + Tag::new(b"loca"),
44 + Tag::new(b"cmap"),
45 + Tag::new(b"hmtx"),
46 + Tag::new(b"hhea"),
47 + Tag::new(b"maxp"),
48 + Tag::new(b"name"),
49 + Tag::new(b"post"),
50 + ];
51 +
52 + /// Tables that do not survive modification.
53 + ///
54 + /// `DSIG` signs the bytes we just changed, so keeping it would ship a signature
55 + /// that fails to verify. Dropping it is what every font tool does here.
56 + const DROPPED: [Tag; 1] = [Tag::new(b"DSIG")];
57 +
58 + pub fn build(base_bytes: &[u8], glyphs: &[&GlyphSpec], id: &Identity<'_>) -> Result<Built, Error> {
59 + let font =
60 + FontRef::new(base_bytes).map_err(|e| Error::Font(format!("base is unreadable: {e}")))?;
61 + let params = base::measure(base_bytes)?;
62 + let mut mappings = base::mappings(base_bytes)?;
63 +
64 + let head = font.head().map_err(missing("head"))?;
65 + let maxp = font.maxp().map_err(missing("maxp"))?;
66 + let hhea = font.hhea().map_err(missing("hhea"))?;
67 + let base_glyph_count = maxp.num_glyphs();
68 +
69 + // --- the new glyphs, compiled ------------------------------------------
70 +
71 + let mut appended: Vec<(GlyphId, &GlyphSpec, Vec<u8>, Bounds)> = Vec::new();
72 + for (index, spec) in glyphs.iter().enumerate() {
73 + if let Some(existing) = mappings.get(&spec.codepoint) {
74 + // Not a merge tool. A base that already draws a mark keeps its own,
75 + // and the set says so rather than quietly winning.
76 + return Err(Error::AlreadyDrawn {
77 + codepoint: spec.codepoint,
78 + base: id.base.family.clone(),
79 + gid: existing.to_u32(),
80 + });
81 + }
82 + let path = draw::draw(&spec.shape, &params).to_bezpath();
83 + let simple = SimpleGlyph::from_bezpath(&path)
84 + .map_err(|e| Error::Draw(format!("{}: {e:?}", spec.name)))?;
85 + let bounds = Bounds::of(&simple);
86 + let bytes = write_fonts::dump_table(&Glyph::Simple(simple))
87 + .map_err(|e| Error::Draw(format!("{}: {e}", spec.name)))?;
88 + let gid = GlyphId::from(base_glyph_count + index as u16);
89 + mappings.insert(spec.codepoint, gid);
90 + appended.push((gid, spec, bytes, bounds));
91 + }
92 +
93 + let glyph_count = base_glyph_count + appended.len() as u16;
94 +
95 + // --- glyf and loca ------------------------------------------------------
96 +
97 + let base_glyf = table_bytes(&font, Tag::new(b"glyf"))?;
98 + let base_loca = read_loca(&font, base_glyph_count)?;
99 + // `loca`'s last entry is the end of the glyph data, which can sit short of
100 + // the padded table length. Appending from the table's end instead would
101 + // leave a gap the offsets do not describe.
102 + let mut glyf = base_glyf[..*base_loca.last().unwrap() as usize].to_vec();
103 + let mut loca = base_loca;
104 + for (_, _, bytes, _) in &appended {
105 + glyf.extend_from_slice(bytes);
106 + // Long-format offsets need no alignment, but keeping glyphs on a
107 + // four-byte boundary matches what every other tool writes.
108 + while glyf.len() % 4 != 0 {
109 + glyf.push(0);
110 + }
111 + loca.push(glyf.len() as u32);
112 + }
113 + let loca_bytes: Vec<u8> = loca.iter().flat_map(|o| o.to_be_bytes()).collect();
114 +
115 + // --- hmtx and hhea ------------------------------------------------------
116 +
117 + let hmtx_bytes = rebuild_hmtx(
118 + &font,
119 + base_glyph_count,
120 + hhea.number_of_h_metrics(),
121 + &appended,
122 + )?;
123 + let mut hhea_bytes = table_bytes(&font, Tag::new(b"hhea"))?.to_vec();
124 + write_u16(&mut hhea_bytes, 34, glyph_count);
125 +
126 + // --- maxp ---------------------------------------------------------------
127 +
128 + let mut maxp_bytes = table_bytes(&font, Tag::new(b"maxp"))?.to_vec();
129 + write_u16(&mut maxp_bytes, 4, glyph_count);
130 + if maxp_bytes.len() >= 8 {
131 + let points = appended
132 + .iter()
133 + .map(|(_, _, _, b)| b.points)
134 + .max()
135 + .unwrap_or(0);
136 + let contours = appended
137 + .iter()
138 + .map(|(_, _, _, b)| b.contours)
139 + .max()
140 + .unwrap_or(0);
141 + bump_u16(&mut maxp_bytes, 6, points);
142 + bump_u16(&mut maxp_bytes, 8, contours);
143 + }
144 +
145 + // --- head ---------------------------------------------------------------
146 +
147 + let mut head_bytes = table_bytes(&font, Tag::new(b"head"))?.to_vec();
148 + // Long offsets unconditionally: a spliced `glyf` can cross the 128KB the
149 + // short format reaches, and converting up front means one code path.
150 + write_i16(&mut head_bytes, 50, 1);
151 + let mut bbox = (head.x_min(), head.y_min(), head.x_max(), head.y_max());
152 + for (_, _, _, b) in &appended {
153 + bbox.0 = bbox.0.min(b.x_min);
154 + bbox.1 = bbox.1.min(b.y_min);
155 + bbox.2 = bbox.2.max(b.x_max);
156 + bbox.3 = bbox.3.max(b.y_max);
157 + }
158 + write_i16(&mut head_bytes, 36, bbox.0);
159 + write_i16(&mut head_bytes, 38, bbox.1);
160 + write_i16(&mut head_bytes, 40, bbox.2);
161 + write_i16(&mut head_bytes, 42, bbox.3);
162 + // `modified` is left exactly as the base wrote it. A build stamped with the
163 + // wall clock is a build that differs from itself, and the done condition
164 + // here is a byte-identical face from a clean checkout.
165 +
166 + // --- OS/2 ---------------------------------------------------------------
167 +
168 + let mut os2_bytes = table_bytes(&font, Tag::new(b"OS/2"))?.to_vec();
169 + let first = mappings.keys().copied().min().unwrap_or(0);
170 + let last = mappings.keys().copied().max().unwrap_or(0);
171 + write_u16(&mut os2_bytes, 64, u16::try_from(first).unwrap_or(0xFFFF));
172 + write_u16(&mut os2_bytes, 66, u16::try_from(last).unwrap_or(0xFFFF));
173 +
174 + // --- cmap, name, post ---------------------------------------------------
175 +
176 + let cmap = Cmap::from_mappings(
177 + mappings
178 + .iter()
179 + .filter_map(|(cp, gid)| char::from_u32(*cp).map(|c| (c, *gid))),
180 + )
181 + .map_err(|e| Error::Font(format!("cmap: {e}")))?;
182 +
183 + let name = name_table(id);
184 + // `post` 3.0: the base ships 2.0 with a name per glyph, and a 2.0 table has
185 + // to carry exactly `numGlyphs` entries. Extending it would mean inventing
186 + // names for the seven and rewriting the base's, and nothing on any target
187 + // reads glyph names.
188 + let post = Post {
189 + version: Version16Dot16::VERSION_3_0,
190 + italic_angle: Fixed::from_f64(0.0),
191 + underline_position: font
192 + .post()
193 + .map(|p| p.underline_position())
194 + .unwrap_or_default(),
195 + underline_thickness: font
196 + .post()
197 + .map(|p| p.underline_thickness())
198 + .unwrap_or_default(),
199 + is_fixed_pitch: font.post().map_or(0, |p| p.is_fixed_pitch()),
200 + ..Default::default()
201 + };
202 +
203 + // --- assemble -----------------------------------------------------------
204 +
205 + let mut builder = FontBuilder::new();
206 + builder.add_raw(Tag::new(b"glyf"), glyf);
207 + builder.add_raw(Tag::new(b"loca"), loca_bytes);
208 + builder.add_raw(Tag::new(b"hmtx"), hmtx_bytes);
209 + builder.add_raw(Tag::new(b"hhea"), hhea_bytes);
210 + builder.add_raw(Tag::new(b"maxp"), maxp_bytes);
211 + builder.add_raw(Tag::new(b"head"), head_bytes);
212 + builder.add_raw(Tag::new(b"OS/2"), os2_bytes);
213 + builder
214 + .add_table(&cmap)
215 + .map_err(|e| Error::Font(format!("cmap: {e}")))?;
216 + builder
217 + .add_table(&name)
218 + .map_err(|e| Error::Font(format!("name: {e}")))?;
219 + builder
220 + .add_table(&post)
221 + .map_err(|e| Error::Font(format!("post: {e}")))?;
222 +
223 + // Everything the pipeline does not touch is carried across as bytes, minus
224 + // the tables that modification invalidates.
225 + for record in font.table_directory().table_records() {
226 + let tag = record.tag();
227 + if REBUILT.contains(&tag)
228 + || DROPPED.contains(&tag)
229 + || tag == Tag::new(b"head")
230 + || tag == Tag::new(b"OS/2")
231 + {
232 + continue;
233 + }
234 + let data = table_bytes(&font, tag)?;
235 + builder.add_raw(tag, data.to_vec());
236 + }
237 +
238 + Ok(Built {
239 + bytes: builder.build(),
240 + added: appended
241 + .iter()
242 + .map(|(_, spec, _, _)| (spec.codepoint, spec.name.clone()))
243 + .collect(),
244 + params,
245 + })
246 + }
247 +
248 + struct Bounds {
249 + x_min: i16,
250 + y_min: i16,
251 + x_max: i16,
252 + y_max: i16,
253 + points: u16,
254 + contours: u16,
255 + }
256 +
257 + impl Bounds {
258 + fn of(glyph: &SimpleGlyph) -> Self {
259 + let bbox = glyph.bbox;
260 + Self {
261 + x_min: bbox.x_min,
262 + y_min: bbox.y_min,
263 + x_max: bbox.x_max,
264 + y_max: bbox.y_max,
265 + points: glyph.contours.iter().map(|c| c.len() as u16).sum(),
266 + contours: glyph.contours.len() as u16,
267 + }
268 + }
269 + }
270 +
271 + fn missing(tag: &'static str) -> impl Fn(read_fonts::ReadError) -> Error {
272 + move |e| Error::Font(format!("base has no readable `{tag}`: {e}"))
273 + }
274 +
275 + fn table_bytes<'a>(font: &FontRef<'a>, tag: Tag) -> Result<&'a [u8], Error> {
276 + font.table_data(tag)
277 + .map(|d| d.as_bytes())
278 + .ok_or_else(|| Error::Font(format!("base has no `{tag}` table")))
279 + }
280 +
281 + fn read_loca(font: &FontRef<'_>, glyph_count: u16) -> Result<Vec<u32>, Error> {
282 + let loca = font.loca(None).map_err(missing("loca"))?;
283 + (0..=glyph_count as usize)
284 + .map(|i| {
285 + loca.get_raw(i)
286 + .ok_or_else(|| Error::Font(format!("base `loca` is short at {i}")))
287 + })
288 + .collect()
289 + }
290 +
291 + /// Rebuild `hmtx` with an entry per appended glyph.
292 + ///
293 + /// A base may store fewer `longHorMetrics` than glyphs, with the tail carrying
294 + /// left side bearings only. Expanding to one metric per glyph costs two bytes
295 + /// each and removes the special case; monospace faces are already full-length.
296 + fn rebuild_hmtx(
297 + font: &FontRef<'_>,
298 + base_glyph_count: u16,
299 + number_of_h_metrics: u16,
300 + appended: &[(GlyphId, &crate::manifest::GlyphSpec, Vec<u8>, Bounds)],
301 + ) -> Result<Vec<u8>, Error> {
302 + let hmtx = font.hmtx().map_err(missing("hmtx"))?;
303 + let raw = table_bytes(font, Tag::new(b"hmtx"))?;
304 + let mut out = Vec::with_capacity(raw.len() + appended.len() * 4);
305 +
306 + let last_advance = hmtx
307 + .advance(GlyphId::from(number_of_h_metrics.saturating_sub(1)))
308 + .ok_or_else(|| Error::Font("base `hmtx` carries no metrics".into()))?;
309 +
310 + for gid in 0..base_glyph_count {
311 + let advance = hmtx.advance(GlyphId::from(gid)).unwrap_or(last_advance);
312 + let lsb = side_bearing(raw, number_of_h_metrics, gid);
313 + out.extend_from_slice(&advance.to_be_bytes());
314 + out.extend_from_slice(&lsb.to_be_bytes());
315 + }
316 + for (_, _, _, bounds) in appended {
317 + // Monospace: every glyph takes the cell. The left side bearing has to
318 + // match the outline's own x_min or hinting and layout disagree.
319 + out.extend_from_slice(&last_advance.to_be_bytes());
320 + out.extend_from_slice(&bounds.x_min.to_be_bytes());
321 + }
322 + Ok(out)
323 + }
324 +
325 + fn side_bearing(raw: &[u8], number_of_h_metrics: u16, gid: u16) -> i16 {
326 + let offset = if gid < number_of_h_metrics {
327 + gid as usize * 4 + 2
328 + } else {
329 + number_of_h_metrics as usize * 4 + (gid - number_of_h_metrics) as usize * 2
330 + };
331 + raw.get(offset..offset + 2)
332 + .map_or(0, |b| i16::from_be_bytes([b[0], b[1]]))
333 + }
334 +
335 + fn name_table(id: &Identity<'_>) -> Name {
336 + let full = format!("{} {}", id.family, id.style);
337 + let postscript = format!(
338 + "{}-{}",
339 + id.family.replace(' ', ""),
340 + id.style.replace(' ', "")
341 + );
342 + // OFL 1.1 requires the base's copyright and licence to travel with a
343 + // modified build, and asks that the lineage be stated. It goes here rather
344 + // than in the family name, which the Reserved Font Name forbids.
345 + let description = format!(
346 + "Derived from {} {}, by drawing the quasi house glyph set into it. \
347 + The letterforms are unmodified.",
348 + id.base.family, id.base.version
349 + );
350 + let records = vec![
351 + record(NameId::COPYRIGHT_NOTICE, &id.base.copyright),
352 + record(NameId::FAMILY_NAME, id.family),
353 + record(NameId::SUBFAMILY_NAME, id.style),
354 + record(NameId::UNIQUE_ID, &format!("{full}; {}", id.version)),
355 + record(NameId::FULL_NAME, &full),
356 + record(NameId::VERSION_STRING, &format!("Version {}", id.version)),
357 + record(NameId::POSTSCRIPT_NAME, &postscript),
358 + record(NameId::DESCRIPTION, &description),
359 + record(NameId::DESIGNER, &id.base.designer),
360 + record(
361 + NameId::LICENSE_DESCRIPTION,
362 + "This Font Software is licensed under the SIL Open Font License, Version 1.1. \
363 + This license is available with a FAQ at https://openfontlicense.org",
364 + ),
365 + record(NameId::LICENSE_URL, "https://openfontlicense.org"),
366 + ];
367 + let mut all = Vec::with_capacity(records.len() * 2);
368 + for (name_id, value) in records {
369 + // Windows/Unicode BMP, English (US), which is the pair every consumer
370 + // reads, plus the Macintosh Roman record older tools still look for.
371 + all.push(NameRecord::new(3, 1, 0x0409, name_id, value.clone().into()));
372 + all.push(NameRecord::new(1, 0, 0, name_id, value.into()));
373 + }
374 + all.sort_by_key(|r| (r.platform_id, r.encoding_id, r.language_id, r.name_id));
375 + Name::new(all)
376 + }
377 +
378 + fn record(name_id: NameId, value: &str) -> (NameId, String) {
379 + (name_id, value.to_owned())
380 + }
381 +
382 + fn write_u16(bytes: &mut [u8], offset: usize, value: u16) {
383 + if let Some(slot) = bytes.get_mut(offset..offset + 2) {
384 + slot.copy_from_slice(&value.to_be_bytes());
385 + }
386 + }
387 +
388 + fn write_i16(bytes: &mut [u8], offset: usize, value: i16) {
389 + if let Some(slot) = bytes.get_mut(offset..offset + 2) {
390 + slot.copy_from_slice(&value.to_be_bytes());
391 + }
392 + }
393 +
394 + fn bump_u16(bytes: &mut [u8], offset: usize, value: u16) {
395 + let current = bytes
396 + .get(offset..offset + 2)
397 + .map_or(0, |b| u16::from_be_bytes([b[0], b[1]]));
398 + write_u16(bytes, offset, current.max(value));
399 + }
400 +
401 + /// The codepoints a built face maps, for the coverage assertion.
402 + pub fn coverage(bytes: &[u8]) -> Result<BTreeMap<u32, GlyphId>, Error> {
403 + base::mappings(bytes)
404 + }
405 +
406 + /// `head`'s tag, re-exported so the CLI can name it without importing
407 + /// read-fonts.
408 + pub fn head_tag() -> Tag {
409 + read_fonts::tables::head::Head::TAG
410 + }
A src/draw.rs +500
@@ -1,0 +1,503 @@
1 + //! The parametric primitives.
2 + //!
3 + //! Every shape is built from the base's own measurements, so the same recipe
4 + //! produces a mark tuned to whichever face it is cut into. That is what "same
5 + //! design, not byte-identical" means in practice: `▲` in Quasi Mono and in a
6 + //! future Quasi Body will not share an outline, and both read as the mark.
7 + //!
8 + //! All seven marks are straight-edged, so every contour is a polygon of
9 + //! on-curve points. Nothing here emits a curve, and the day a recipe needs one
10 + //! it gets a quadratic rather than a cubic, because that is what `glyf` stores.
11 +
12 + use kurbo::BezPath;
13 +
14 + use crate::base::BaseParams;
15 + use crate::manifest::{Anchor, Dim, Direction, Shape};
16 +
17 + /// A drawn mark, before it becomes a glyph.
18 + pub struct Drawing {
19 + pub contours: Vec<Vec<(f64, f64)>>,
20 + }
21 +
22 + impl Drawing {
23 + /// A `BezPath` with every contour closed and wound the way `glyf` fills.
24 + ///
25 + /// TrueType fills non-zero with y up, so an outer contour runs clockwise,
26 + /// which is a negative shoelace area. Winding is enforced here rather than
27 + /// asked of each recipe: a mark that comes out inside-out is a bug nobody
28 + /// sees until a renderer drops it.
29 + pub fn to_bezpath(&self) -> BezPath {
30 + let mut path = BezPath::new();
31 + for contour in &self.contours {
32 + let mut points = contour.clone();
33 + if signed_area(&points) > 0.0 {
34 + points.reverse();
35 + }
36 + let Some(&(x, y)) = points.first() else {
37 + continue;
38 + };
39 + path.move_to((x, y));
40 + for &(x, y) in &points[1..] {
41 + path.line_to((x, y));
42 + }
43 + path.close_path();
44 + }
45 + path
46 + }
47 + }
48 +
49 + /// The base's horizontal stroke weight, which is the set's one weight signal.
50 + fn stroke_of(params: &BaseParams) -> f64 {
51 + f64::from(params.stroke)
52 + }
53 +
54 + fn signed_area(points: &[(f64, f64)]) -> f64 {
55 + let n = points.len();
56 + let mut area = 0.0;
57 + for i in 0..n {
58 + let (x0, y0) = points[i];
59 + let (x1, y1) = points[(i + 1) % n];
60 + area += x0 * y1 - x1 * y0;
61 + }
62 + area / 2.0
63 + }
64 +
65 + pub fn draw(shape: &Shape, params: &BaseParams) -> Drawing {
66 + match shape {
67 + Shape::Triangle {
68 + direction,
69 + span,
70 + depth,
71 + anchor,
72 + } => triangle(params, *direction, *span, *depth, *anchor),
73 + Shape::Cross {
74 + width,
75 + height,
76 + stroke,
77 + } => cross(params, *width, *height, *stroke),
78 + Shape::OpenBox {
79 + width,
80 + height,
81 + bottom,
82 + stroke,
83 + } => open_box(params, *width, *height, *bottom, *stroke),
84 + Shape::ReturnArrow {
85 + top,
86 + shaft,
87 + head_span,
88 + head_depth,
89 + stroke,
90 + } => return_arrow(params, *top, *shaft, *head_span, *head_depth, *stroke),
91 + }
92 + }
93 +
94 + fn anchor_y(params: &BaseParams, anchor: Anchor) -> f64 {
95 + match anchor {
96 + Anchor::BandCenter => params.band_center_y(),
97 + Anchor::XHeight => params.x_height_center_y(),
98 + }
99 + }
100 +
101 + /// A solid triangle, sized off the band and centred on the cell.
102 + ///
103 + /// `span` is measured across the base edge and `depth` from that edge to the
104 + /// apex, both regardless of which way the mark points, so a left-pointing and
105 + /// an up-pointing triangle of the same numbers are the same triangle rotated.
106 + fn triangle(
107 + params: &BaseParams,
108 + direction: Direction,
109 + span: Dim,
110 + depth: f64,
111 + anchor: Anchor,
112 + ) -> Drawing {
113 + let cx = params.center_x();
114 + let cy = anchor_y(params, anchor);
115 + let (span_px, depth_px) = match direction {
116 + // A horizontal mark's span runs up the cell, so it comes off the band's
117 + // height; a vertical mark's runs across, off the band's width.
118 + Direction::Up | Direction::Down => {
119 + let s = span.resolve(params.band_width(), stroke_of(params));
120 + (s, s * depth)
121 + }
122 + Direction::Left | Direction::Right => {
123 + let s = span.resolve(params.band_height(), stroke_of(params));
124 + (s, s * depth)
125 + }
126 + };
127 + let half_span = span_px / 2.0;
128 + let half_depth = depth_px / 2.0;
129 +
130 + let points = match direction {
131 + Direction::Up => vec![
132 + (cx - half_span, cy - half_depth),
133 + (cx + half_span, cy - half_depth),
134 + (cx, cy + half_depth),
135 + ],
136 + Direction::Down => vec![
137 + (cx - half_span, cy + half_depth),
138 + (cx + half_span, cy + half_depth),
139 + (cx, cy - half_depth),
140 + ],
141 + Direction::Right => vec![
142 + (cx - half_depth, cy - half_span),
143 + (cx - half_depth, cy + half_span),
144 + (cx + half_depth, cy),
145 + ],
146 + Direction::Left => vec![
147 + (cx + half_depth, cy - half_span),
148 + (cx + half_depth, cy + half_span),
149 + (cx - half_depth, cy),
150 + ],
151 + };
152 + Drawing {
153 + contours: vec![points],
154 + }
155 + }
156 +
157 + /// Two crossed strokes as one contour: the twelve-point X.
158 + ///
159 + /// Drawn as a single outline rather than two overlapping bars so the fill is
160 + /// correct under any fill rule and the join at the centre is a real join.
161 + fn cross(params: &BaseParams, width: Dim, height: Dim, stroke: f64) -> Drawing {
162 + let cx = params.center_x();
163 + let cy = params.band_center_y();
164 + let base_stroke = stroke_of(params);
165 + let half_w = width.resolve(params.band_width(), base_stroke) / 2.0;
166 + let half_h = height.resolve(params.band_height(), base_stroke) / 2.0;
167 + let weight = base_stroke * stroke;
168 +
169 + // The arms only sit at 45 degrees when the extents are square, so both
170 + // offsets are derived from the diagonal rather than assumed equal. `gap_y`
171 + // is where the arms' inner edges meet above and below the centre; `gap_x`
172 + // is the same meeting left and right. The arm ends are cut across the
173 + // corner, which puts the same two offsets at each end.
174 + let diagonal = half_w.hypot(half_h);
175 + let gap_y = weight / 2.0 * diagonal / half_w;
176 + let gap_x = weight / 2.0 * diagonal / half_h;
177 +
178 + let points = vec![
179 + (cx - half_w, cy + half_h - gap_y),
180 + (cx - half_w + gap_x, cy + half_h),
181 + (cx, cy + gap_y),
182 + (cx + half_w - gap_x, cy + half_h),
183 + (cx + half_w, cy + half_h - gap_y),
184 + (cx + gap_x, cy),
185 + (cx + half_w, cy - half_h + gap_y),
186 + (cx + half_w - gap_x, cy - half_h),
187 + (cx, cy - gap_y),
188 + (cx - half_w + gap_x, cy - half_h),
189 + (cx - half_w, cy - half_h + gap_y),
190 + (cx - gap_x, cy),
191 + ];
192 + Drawing {
193 + contours: vec![points],
194 + }
195 + }
196 +
197 + /// `U+2423`, a box open at the top: two risers and a floor, one contour.
198 + fn open_box(params: &BaseParams, width: Dim, height: Dim, bottom: f64, stroke: f64) -> Drawing {
199 + let cx = params.center_x();
200 + let base_stroke = stroke_of(params);
201 + let half_w = width.resolve(params.band_width(), base_stroke) / 2.0;
202 + let h = height.resolve(params.band_height(), base_stroke);
203 + let y0 = params.band_height() * bottom;
204 + let y1 = y0 + h;
205 + // The risers take the vertical stroke weight and the floor the horizontal
206 + // one, which is what the base does with every other box it draws.
207 + let riser = f64::from(params.stem) * stroke;
208 + let floor = f64::from(params.stroke) * stroke;
209 +
210 + let points = vec![
211 + (cx - half_w, y1),
212 + (cx - half_w + riser, y1),
213 + (cx - half_w + riser, y0 + floor),
214 + (cx + half_w - riser, y0 + floor),
215 + (cx + half_w - riser, y1),
216 + (cx + half_w, y1),
217 + (cx + half_w, y0),
218 + (cx - half_w, y0),
219 + ];
220 + Drawing {
221 + contours: vec![points],
222 + }
223 + }
224 +
225 + /// `U+23CE`: a left-pointing arrow along the bottom with a riser at its right
226 + /// end, drawn as one contour so the elbow is a join rather than an overlap.
227 + fn return_arrow(
228 + params: &BaseParams,
229 + top: f64,
230 + shaft: f64,
231 + head_span: Dim,
232 + head_depth: Dim,
233 + stroke: f64,
234 + ) -> Drawing {
235 + let x0 = f64::from(params.band_x0);
236 + let x1 = f64::from(params.band_x1);
237 + let y_floor = f64::from(params.band_y0);
238 + let band_h = params.band_height();
239 +
240 + let weight = f64::from(params.stroke) * stroke;
241 + let riser_weight = f64::from(params.stem) * stroke;
242 + let half = weight / 2.0;
243 + let shaft_y = y_floor + band_h * shaft;
244 + let cap_y = y_floor + band_h * top;
245 + // The head is solid geometry, so it comes off the band and holds still
246 + // across weights. Sizing it in multiples of the stroke instead would grow
247 + // it by two thirds into Bold and push its back past the riser.
248 + let head_half = head_span.resolve(band_h, f64::from(params.stroke)) / 2.0;
249 + let head_x = x0 + head_depth.resolve(x1 - x0, f64::from(params.stroke));
250 +
251 + let points = vec![
252 + // The tip, then up the head's back and into the shaft.
253 + (x0, shaft_y),
254 + (head_x, shaft_y + head_half),
255 + (head_x, shaft_y + half),
256 + // Along the shaft's top edge to the riser, then up it.
257 + (x1 - riser_weight, shaft_y + half),
258 + (x1 - riser_weight, cap_y),
259 + (x1, cap_y),
260 + // Down the riser's right edge and back along the shaft's underside.
261 + (x1, shaft_y - half),
262 + (head_x, shaft_y - half),
263 + (head_x, shaft_y - head_half),
264 + ];
265 + Drawing {
266 + contours: vec![points],
267 + }
268 + }
269 +
270 + #[cfg(test)]
271 + mod tests {
272 + use super::*;
273 + use crate::manifest::{Manifest, WeightResponse};
274 +
275 + /// IBM Plex Mono Regular, measured 2026-08-16 from `@ibm/plex-mono@2.5.0`.
276 + fn plex_regular() -> BaseParams {
277 + BaseParams {
278 + upem: 1000,
279 + advance: 600,
280 + cap_height: 698,
281 + x_height: 516,
282 + stem: 70,
283 + stroke: 84,
284 + band_x0: 62,
285 + band_x1: 538,
286 + band_y0: 62,
287 + band_y1: 548,
288 + }
289 + }
290 +
291 + /// The same face in Bold: the band and the cell are identical, the strokes
292 + /// are not. That is the measurement the set's weight rule rests on.
293 + fn plex_bold() -> BaseParams {
294 + BaseParams {
295 + stem: 126,
296 + stroke: 142,
297 + ..plex_regular()
298 + }
299 + }
300 +
301 + fn bounds(drawing: &Drawing) -> (f64, f64, f64, f64) {
302 + let points = drawing.contours.concat();
303 + let xs: Vec<f64> = points.iter().map(|p| p.0).collect();
304 + let ys: Vec<f64> = points.iter().map(|p| p.1).collect();
305 + (
306 + xs.iter().copied().fold(f64::MAX, f64::min),
307 + ys.iter().copied().fold(f64::MAX, f64::min),
308 + xs.iter().copied().fold(f64::MIN, f64::max),
309 + ys.iter().copied().fold(f64::MIN, f64::max),
310 + )
311 + }
312 +
313 + fn shape(name: &str) -> Shape {
314 + let manifest = Manifest::parse(crate::HOUSE_SET).unwrap();
315 + let mut glyphs = manifest.glyphs;
316 + let index = glyphs
317 + .iter()
318 + .position(|g| g.name == name)
319 + .unwrap_or_else(|| panic!("no glyph {name}"));
320 + glyphs.swap_remove(index).shape
321 + }
322 +
323 + #[test]
324 + fn every_mark_fits_inside_the_cell() {
325 + let manifest = Manifest::parse(crate::HOUSE_SET).unwrap();
326 + for params in [plex_regular(), plex_bold()] {
327 + for glyph in &manifest.glyphs {
328 + let drawing = draw(&glyph.shape, &params);
329 + let (x0, _, x1, _) = bounds(&drawing);
330 + assert!(
331 + x0 >= 0.0 && x1 <= f64::from(params.advance),
332 + "{} runs outside the cell: x[{x0}, {x1}] in {}",
333 + glyph.name,
334 + params.advance
335 + );
336 + }
337 + }
338 + }
339 +
340 + #[test]
341 + fn every_mark_sits_above_the_descender() {
342 + let manifest = Manifest::parse(crate::HOUSE_SET).unwrap();
343 + let params = plex_regular();
344 + for glyph in &manifest.glyphs {
345 + let (_, y0, _, y1) = bounds(&draw(&glyph.shape, &params));
346 + assert!(
347 + y0 > -350.0,
348 + "{} dips below the base's descender",
349 + glyph.name
350 + );
351 + assert!(
352 + y1 <= f64::from(params.cap_height) + 60.0,
353 + "{} rides above cap height",
354 + glyph.name
355 + );
356 + }
357 + }
358 +
359 + #[test]
360 + fn marks_are_centred_on_the_cell_not_the_band() {
361 + let params = plex_regular();
362 + for name in ["uni25B2", "uni25BC", "uni25B8", "uni25C2", "uni2718"] {
363 + let (x0, _, x1, _) = bounds(&draw(&shape(name), &params));
364 + let centre = f64::midpoint(x0, x1);
365 + assert!(
366 + (centre - params.center_x()).abs() < 0.51,
367 + "{name} centres at {centre}, not {}",
368 + params.center_x()
369 + );
370 + }
371 + }
372 +
373 + #[test]
374 + fn every_mark_answers_a_heavier_base() {
375 + let manifest = Manifest::parse(crate::HOUSE_SET).unwrap();
376 + for glyph in &manifest.glyphs {
377 + let light = draw(&glyph.shape, &plex_regular()).to_bezpath();
378 + let bold = draw(&glyph.shape, &plex_bold()).to_bezpath();
379 + assert_ne!(
380 + light.to_svg(),
381 + bold.to_svg(),
382 + "{} is drawn identically at both weights, so it will read light \
383 + inside the Bold face",
384 + glyph.name
385 + );
386 + }
387 + }
388 +
389 + #[test]
390 + fn a_solid_mark_grows_and_a_stroked_one_holds_its_extent() {
391 + let manifest = Manifest::parse(crate::HOUSE_SET).unwrap();
392 + for glyph in &manifest.glyphs {
393 + let light = bounds(&draw(&glyph.shape, &plex_regular()));
394 + let bold = bounds(&draw(&glyph.shape, &plex_bold()));
395 + let grew = (bold.2 - bold.0) > (light.2 - light.0) + 0.5;
396 + match glyph.shape.weight_response() {
397 + WeightResponse::Grows => assert!(
398 + grew,
399 + "{} has no stroke to thicken, so it has to grow",
400 + glyph.name
401 + ),
402 + WeightResponse::Thickens => {
403 + // The box and the arrow do carry a small extent term, the
404 + // way the base grows `+`'s bars inside a fixed bbox; what
405 + // they must not do is grow like a solid mark.
406 + let growth = (bold.2 - bold.0) / (light.2 - light.0);
407 + assert!(
408 + growth < 1.10,
409 + "{} grew {growth:.2}x, which is a solid mark's response",
410 + glyph.name
411 + );
412 + }
413 + }
414 + }
415 + }
416 +
417 + /// A head that does not clear its own shaft is not an arrowhead.
418 + ///
419 + /// The bound is the shaft's stroke: the head has to stand at least half a
420 + /// stroke proud on each side at every weight. Bold is where this bites,
421 + /// since the shaft thickens by 69% and a band-sized head does not.
422 + #[test]
423 + fn the_return_arrows_head_clears_its_shaft() {
424 + let Shape::ReturnArrow {
425 + head_span, stroke, ..
426 + } = shape("uni23CE")
427 + else {
428 + panic!("uni23CE is not a return arrow");
429 + };
430 + for params in [plex_regular(), plex_bold()] {
431 + let band = params.band_height();
432 + let weight = f64::from(params.stroke) * stroke;
433 + let head = head_span.resolve(band, f64::from(params.stroke));
434 + let proud = (head - weight) / 2.0;
435 + assert!(
436 + proud >= weight * 0.5,
437 + "the head stands {proud:.0} proud of a {weight:.0} shaft"
438 + );
439 + }
440 + }
441 +
442 + #[test]
443 + fn the_carets_are_reflections_of_each_other() {
444 + let params = plex_regular();
445 + let up = bounds(&draw(&shape("uni25B2"), &params));
446 + let down = bounds(&draw(&shape("uni25BC"), &params));
447 + assert!((up.0 - down.0).abs() < 0.01 && (up.2 - down.2).abs() < 0.01);
448 + let cy = params.band_center_y();
449 + assert!(
450 + ((up.1 - cy) + (down.3 - cy)).abs() < 0.01,
451 + "▲ and ▼ are not mirrored about the band"
452 + );
453 + }
454 +
455 + #[test]
456 + fn every_contour_comes_out_wound_for_glyf() {
457 + let manifest = Manifest::parse(crate::HOUSE_SET).unwrap();
458 + let params = plex_regular();
459 + for glyph in &manifest.glyphs {
460 + let drawing = draw(&glyph.shape, &params);
461 + for contour in &drawing.contours {
462 + let mut points = contour.clone();
463 + if signed_area(&points) > 0.0 {
464 + points.reverse();
465 + }
466 + assert!(
467 + signed_area(&points) < 0.0,
468 + "{} has a degenerate contour",
469 + glyph.name
470 + );
471 + }
472 + }
473 + }
474 +
475 + /// The X is calibrated against the base's own cross rather than by eye.
476 + ///
477 + /// Plex Mono's `x` (U+00D7) fills 38.2% of its bounding box in Regular and
478 + /// 53.4% in Bold, measured 2026-08-16 by flattening its outline. U+2718 is
479 + /// the HEAVY ballot X, so it has to sit above that and not far above it.
480 + /// The first attempt used a flat 1.5x the base stroke and put 213 units of
481 + /// ink across a 380-unit mark in Bold, which is the failure this bounds.
482 + #[test]
483 + fn the_cross_is_heavier_than_the_bases_own_and_no_heavier() {
484 + for (params, base_fill) in [(plex_regular(), 0.382), (plex_bold(), 0.534)] {
485 + let drawing = draw(&shape("uni2718"), &params);
486 + let ink = signed_area(&drawing.contours[0]).abs();
487 + let (x0, y0, x1, y1) = bounds(&drawing);
488 + let fill = ink / ((x1 - x0) * (y1 - y0));
489 + assert!(
490 + fill > base_fill,
491 + "the X fills {:.1}%, lighter than the base's own cross at {:.1}%",
492 + fill * 100.0,
493 + base_fill * 100.0
494 + );
495 + assert!(
496 + fill < base_fill + 0.04,
497 + "the X fills {:.1}%, well past the base's {:.1}%",
498 + fill * 100.0,
499 + base_fill * 100.0
500 + );
Lines truncated
A src/lib.rs +152
A src/main.rs +224
A src/pins.rs +191
A src/woff2.rs +175