max / quasi-type
21 files changed,
+4122 insertions,
-0 deletions
| @@ -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 |
| @@ -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
| @@ -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" |
| @@ -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. |
| @@ -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." |
| @@ -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 | + | } |
| @@ -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, ¶ms).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 | + | } |
| @@ -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, ¶ms); | |
| 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, ¶ms)); | |
| 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), ¶ms)); | |
| 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"), ¶ms)); | |
| 446 | + | let down = bounds(&draw(&shape("uni25BC"), ¶ms)); | |
| 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, ¶ms); | |
| 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"), ¶ms); | |
| 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