Skip to main content

max / quasi-type

Move quasi-mono onto Atkinson, and recalibrate the marks that needed it The base decision was taken 2026-08-16 (wiki `typography-standard`); this is the slot moving onto it. `plex-mono` is deleted rather than kept beside it: an unnamed base is a second answer to what Quasi Mono is cut from. Two coefficients did not survive the base change, and both were found by rasterising rather than by a test. The ballot X was calibrated at 0.80 of the base's bar against Plex, whose `x` fills 38.2% of its box in Regular and 53.4% in Bold. Atkinson's bar is thinner against its own cross, so 0.80 came out under the base at every weight - a HEAVY BALLOT X lighter than the multiplication sign beside it. At 0.95 it fills 33.7% and 59.1% against the base's 30.0% and 56.9%. The space render closed up. Its risers are the base's stroke unmultiplied, and Atkinson's stem nearly triples across `wght` 200-800 where Plex's grew 1.8x between two static cuts, so at the heavy end two risers took 278 units of a 413-unit box and it inked 90.6% of its own bounding box. The box now grows with the stroke: an open box has to stay open, and the counter is fitted at the heavy end where it is scarce (50% of the width, against 74% at the light end). `quasi-type proof <slot>` is what made both visible. It rasterises the cut face to a grayscale PNG per master, marks on one row and cell furniture set adjacently so a seam shows, with no image dependency: a stored-block zlib stream is a correct one. Every check in here measures a bbox or an ink area, and three real defects have already walked past all of them. Also: the tests read the style off the pin rather than naming `Regular`, which would have skipped nine of them silently the day the base changed; the surviving tables are read off the base rather than listed, since Atkinson ships `prep` and no `cvt `; and the solid-versus-stroked discriminator is fill rather than extent, because over a whole 200-800 axis every mark grows.
Author: Max Johnson <me@maxj.phd> · 2026-08-17 14:17 UTC
Signed with PGP, not checked
Commit: 75e4e6d94ff500d55907524b3d829f2127ebc8ce
Parent: 3d080a6
11 files changed, +1018 insertions, -224 deletions
M Cargo.lock +24 -24
@@ -527,8 +527,28 @@
527 527 ]
528 528
529 529 [[patch.unused]]
530 - name = "docengine"
531 - version = "0.7.0"
530 + name = "kberg"
531 + version = "0.1.0"
532 +
533 + [[patch.unused]]
534 + name = "ops-status"
535 + version = "0.1.0"
536 +
537 + [[patch.unused]]
538 + name = "painhours"
539 + version = "0.1.0"
540 +
541 + [[patch.unused]]
542 + name = "tagtree"
543 + version = "0.4.0"
544 +
545 + [[patch.unused]]
546 + name = "synckit-client"
547 + version = "0.8.0"
548 +
549 + [[patch.unused]]
550 + name = "synckit-config"
551 + version = "0.2.0"
532 552
533 553 [[patch.unused]]
534 554 name = "quasi-axum"
@@ -563,25 +583,5 @@
563 583 version = "0.18.0"
564 584
565 585 [[patch.unused]]
566 - name = "synckit-client"
567 - version = "0.8.0"
568 -
569 - [[patch.unused]]
570 - name = "synckit-config"
571 - version = "0.2.0"
572 -
573 - [[patch.unused]]
574 - name = "kberg"
575 - version = "0.1.0"
576 -
577 - [[patch.unused]]
578 - name = "ops-status"
579 - version = "0.1.0"
580 -
581 - [[patch.unused]]
582 - name = "painhours"
583 - version = "0.1.0"
584 -
585 - [[patch.unused]]
586 - name = "tagtree"
587 - version = "0.4.0"
586 + name = "docengine"
587 + version = "0.7.0"
M README.md +39 -12
@@ -9,18 +9,22 @@
9 9 ```
10 10
11 11 ```
12 - Quasi Mono from IBM Plex Mono 2.5.0 (171 marks)
13 - Quasi Mono Regular ttf 157.9K woff2 53.5K +7 marks, cmap covers all 176
14 - Quasi Mono Bold ttf 159.8K woff2 55.0K +7 marks, cmap covers all 176
12 + Quasi Mono from Atkinson Hyperlegible Mono 2.001 (171 marks)
13 + Quasi Mono ExtraLight ttf 69.7K woff2 30.7K +171 marks, cmap covers all 176
14 + wght 200-800, and the marks vary with it. At rest this face
15 + is wght 200 (ExtraLight), so a consumer names the weight it
16 + wants: `font-weight: 200 800`.
15 17 OFL.txt the base's licence, as OFL requires
16 18 ```
17 19
18 20 ## Why it exists
19 21
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.
22 + The faces worth setting text in ship minimal mark inventories on principle.
23 + Atkinson Hyperlegible Mono draws 359 codepoints and not one of them is box
24 + drawing, a block element or an arrow; IBM Plex Mono, which this pipeline cut
25 + from until 2026-08-17, has a single glyph in the whole geometric-shapes block.
26 + That is a property of a good text face rather than a defect, so the answer is to
27 + patch rather than to shop.
24 28
25 29 Split the vocabulary in two. **Letters, digits and punctuation** are the base
26 30 face's job and are meant to differ per slot. **Marks** — carets, separators,
@@ -50,6 +54,7 @@
50 54 quasi-type params <slot> print what each base face measures
51 55 quasi-type params --base <id> measure a base no slot names yet
52 56 quasi-type list print the house glyph set and the pinned slots
57 + quasi-type proof <slot> rasterise the cut face to out/, so it can be seen
53 58 ```
54 59
55 60 ## How a mark is drawn
@@ -67,11 +72,19 @@
67 72 future Quasi Body will not share an outline; they read as the same mark, each
68 73 tuned to sit among its own neighbours.
69 74
70 - The weight term is measured rather than chosen. Plex Mono redraws its symbols
71 - heavier in Bold instead of only thickening them in place — `•` runs 244 to 308
72 - units wide, `×` 424 to 460 — while its block elements hold still, being cell-fill
73 - primitives. So solid marks grow and stroked marks thicken, and the X is
74 - calibrated against the base's own `×` rather than by eye.
75 + The weight term is measured rather than chosen. A base redraws its symbols
76 + heavier rather than only thickening them in place, while its block elements hold
77 + still, being cell-fill primitives. So solid marks grow and stroked marks thicken,
78 + and the X is calibrated against the base's own `×` rather than by eye.
79 +
80 + **A coefficient is a relationship to the base, so it is refitted when the base
81 + moves.** Two did when `quasi-mono` went from Plex Mono to Atkinson: the ballot
82 + X's, because Atkinson's bar is thinner against its own `×` and the old number
83 + came out lighter than the multiplication sign beside it; and the space render's,
84 + because Atkinson's stem nearly triples across `wght` 200-800 where Plex's grew
85 + 1.8x between two static cuts, so the box closed up into a blob. Both are checked
86 + against the base at each end of the axis rather than against a number somebody
87 + wrote down.
75 88
76 89 ## Adding a glyph
77 90
@@ -114,6 +127,20 @@
114 127 (`font-weight: 200 800` in the `@font-face`, and the browser resolves `normal`
115 128 to 400) instead of loading the file and taking what it opens at.
116 129
130 + ## Looking at it
131 +
132 + quasi-type proof quasi-mono --px 32
133 +
134 + Writes a grayscale PNG per master to `out/`: the authored marks on one row, then
135 + box drawing, arcs, dashes and block elements set adjacently on the cell grid,
136 + over a faint cell rule and baseline.
137 +
138 + This exists because every other check here measures a bounding box or an ink
139 + area, and three real defects walked past all of them — arcs curling the wrong
140 + way, a dashed glyph drawn as two arms with a seam, and an up-down arrow whose
141 + heads met in the middle and read as a bowtie. Each had a correct bbox. Draw the
142 + glyphs on a screen before believing a green suite.
143 +
117 144 ## Adding a base
118 145
119 146 Pin it in `bases/pins.toml` with its sha256, then read its licence. Most OFL
M bases/pins.toml +13 -38
@@ -17,41 +17,15 @@
17 17 # on demand: the bytes have changed before under repositories nobody touched,
18 18 # which would read here as "upstream moved" and mean nothing of the sort.
19 19
20 - [[base]]
21 - id = "plex-mono"
22 - family = "IBM Plex Mono"
23 - version = "2.5.0"
24 - url = "https://github.com/IBM/plex/releases/download/%40ibm%2Fplex-mono%402.5.0/ibm-plex-mono.zip"
25 - sha256 = "6d23f01257663d8cc49a0d64c22ced630b79e0e2a0ac08a0da86e9a38bbc481c"
26 - license = "OFL-1.1"
27 - license_path = "ibm-plex-mono/LICENSE.txt"
28 - # OFL 1.1 clause 3. Recorded so the naming gate is data rather than lore.
29 - reserved_font_name = "Plex"
30 - copyright = "Copyright 2017 IBM Corp. All rights reserved."
31 - designer = "Mike Abbink, Paul van der Laan, Pieter van Rosmalen"
32 - vendor_url = "http://www.ibm.com/plex"
33 -
34 - [[base.face]]
35 - style = "Regular"
36 - path = "ibm-plex-mono/fonts/complete/ttf/IBMPlexMono-Regular.ttf"
37 - sha256 = "7c6fbddca4b700be918f5f6183d9bd4464fa427fe435f0b480d77fe2bb8c5a43"
38 -
39 - [[base.face]]
40 - style = "Bold"
41 - path = "ibm-plex-mono/fonts/complete/ttf/IBMPlexMono-Bold.ttf"
42 - sha256 = "74e5eedcfa4596497d34e19023cabdabd3a8c852b903007a5654a59591a72ffb"
43 -
44 - # Italic is deliberately not cut. No consumer loads a mono italic today: MNW
45 - # serves Regular and Bold, shop bundles one face, and Alloy's fontconfig names a
46 - # family rather than a style. Add the face here when something asks for it.
47 -
48 -
49 - # Atkinson Hyperlegible Mono. Pinned, and not yet cut from: no slot names it.
50 - # Moving `quasi-mono` onto it is GO makeover `aab673c1`, which waits on the box
51 - # drawing and block elements (`872fd945`) because Atkinson ships neither and
52 - # every TUI in the tree draws with them. The pin is here because the variable
53 - # capability is only real if something exercises it, and this is the base the
54 - # decision names.
20 + # Atkinson Hyperlegible Mono, the mono slot's base since 2026-08-17.
21 + #
22 + # It replaced IBM Plex Mono, which was this file's first base and is gone rather
23 + # than kept beside it: an unnamed base is a second answer to "what is Quasi Mono
24 + # cut from", and the pin file is the place that question is settled. The Plex
25 + # entry is in the history if a comparison is ever wanted (`quasi-type@3d080a6`).
26 + # What the swap cost is recorded in wiki `typography-standard`: Plex shipped box
27 + # drawing, block elements and the four arrows complete, Atkinson ships none of
28 + # them, so the house set carries all 167 rather than the original seven.
55 29 #
56 30 # Variable, `wght` 200-800, one file. Everything about it that is new is in
57 31 # `src/vary.rs`; the short version is that a cut keeps the axis, so the marks
@@ -94,14 +68,15 @@
94 68 # `aab673c1`, not here.
95 69
96 70
97 - # A slot is a house type role. The name tracks the slot and not the base, so if
98 - # `quasi-mono` ever moves off Plex Mono the output is still `Quasi Mono` and the
71 + # A slot is a house type role. The name tracks the slot and not the base, which
72 + # is what let the base move from Plex Mono to Atkinson on 2026-08-17 without a
73 + # consumer changing a family name: the output is `Quasi Mono` either way and the
99 74 # base is named in the metadata, where the licence requires it anyway.
100 75
101 76 [[slot]]
102 77 id = "quasi-mono"
103 78 family = "Quasi Mono"
104 - base = "plex-mono"
79 + base = "atkinson-mono"
105 80 # Every glyph in the house set. A slot may take a subset; none does today.
106 81 glyphs = "*"
107 82
@@ -124,11 +124,19 @@
124 124 shape = "cross"
125 125 width = { band = 0.70, weight = 0.50 }
126 126 height = { band = 0.70, weight = 0.50 }
127 - # Calibrated against the base's own cross rather than by eye. Plex Mono's `x`
128 - # (U+00D7) fills 38.2% of its box in Regular and 53.4% in Bold; at 0.80 of the
129 - # base's bar this one fills 40.7% and 55.5%, which is what HEAVY BALLOT X should
130 - # be: above the base's cross, and not far above it. A flat 1.5x read as a blob.
131 - stroke = 0.80
127 + # Calibrated against the base's own cross rather than by eye: HEAVY BALLOT X is
128 + # heavier than `×` and not far heavier, so the number is read off the base each
129 + # time rather than carried over. A flat 1.5x read as a blob.
130 + #
131 + # RECALIBRATED 2026-08-17, adopting Atkinson, and this is the coefficient that
132 + # does not survive a base change. It was 0.80 against Plex Mono, whose `×`
133 + # (U+00D7) fills 38.2% of its own box in Regular and 53.4% in Bold, and ours
134 + # filled 40.7% and 55.5% — the margin the rule asks for. Atkinson's bar is
135 + # thinner against its own cross, so 0.80 came out *under* the base at every
136 + # weight (29.0% against 30.0%, 53.6% against 56.9%), which is a ballot X lighter
137 + # than the multiplication sign beside it. At 0.95 of the base's bar it fills
138 + # 33.7% and 59.1% against the base's 30.0% and 56.9%.
139 + stroke = 0.95
132 140
133 141 # ---------------------------------------------------------------------------
134 142 # Helix whitespace renders. Both sit low in the cell, next to lowercase.
@@ -139,8 +147,21 @@
139 147 name = "uni2423"
140 148 role = "helix, space render"
141 149 shape = "open-box"
142 - width = { band = 0.72, weight = 0.30 }
143 - height = { band = 0.38, weight = 0.30 }
150 + # RECALIBRATED 2026-08-17, adopting Atkinson. The weight terms were 0.30 and the
151 + # box closed up: Atkinson's stem runs 54 units to 158 across `wght` 200-800
152 + # against Plex's 1.8x from Regular to Bold, so at the heavy end two risers of
153 + # the base's own stroke took 278 of a 413-unit box and left a 135-unit counter.
154 + # Measured 0.906 fill of its own bounding box, which rasterises as a blob rather
155 + # than as a box, and the proof sheet is where it was seen (`quasi-type proof`).
156 + #
157 + # The rule the numbers now hold, and the one to keep when the next base arrives:
158 + # AN OPEN BOX HAS TO STAY OPEN. Its counter is what makes it read as a box, so
159 + # the box grows with the base's stroke rather than the stroke eating the box.
160 + # The aperture cannot be held exactly constant — the band term does not scale
161 + # with the stroke — so it is fitted at the heavy end, where it is scarce: the
162 + # counter is 50% of the box's width at `wght` 800 and 74% at 200.
163 + width = { band = 0.72, weight = 1.35 }
164 + height = { band = 0.38, weight = 0.60 }
144 165 # Above the baseline, as a ratio of the band's height.
145 166 bottom = 0.04
146 167 # A stroked mark: its bars are the base's own, unmultiplied.
M src/draw.rs +126 -53
@@ -363,31 +363,41 @@
363 363 use super::*;
364 364 use crate::manifest::{Manifest, WeightResponse};
365 365
366 - /// IBM Plex Mono Regular, measured 2026-08-16 from `@ibm/plex-mono@2.5.0`.
367 - fn plex_regular() -> BaseParams {
366 + /// Atkinson Hyperlegible Mono at `wght` 200, its own default instance,
367 + /// measured 2026-08-17 from the pinned file (`quasi-type params`).
368 + ///
369 + /// These were Plex Mono Regular and Bold until the base moved. The two ends
370 + /// of one axis are a wider span than two static cuts were — 200 to 800
371 + /// against 400 to 700 — which is why the coefficients they check had to be
372 + /// refitted rather than carried over.
373 + fn light() -> BaseParams {
368 374 BaseParams {
369 375 upem: 1000,
370 - advance: 600,
371 - cap_height: 698,
372 - x_height: 516,
373 - stem: 70,
374 - stroke: 84,
375 - band_x0: 62,
376 - band_x1: 538,
377 - band_y0: 62,
378 - band_y1: 548,
379 - ascent: 1025,
380 - descent: -275,
376 + advance: 632,
377 + cap_height: 668,
378 + x_height: 496,
379 + stem: 54,
380 + stroke: 55,
381 + band_x0: 68,
382 + band_x1: 564,
383 + band_y0: 0,
384 + band_y1: 496,
385 + ascent: 984,
386 + descent: -316,
381 387 }
382 388 }
383 389
384 - /// The same face in Bold: the band and the cell are identical, the strokes
385 - /// are not. That is the measurement the set's weight rule rests on.
386 - fn plex_bold() -> BaseParams {
390 + /// The far end of the axis, `wght` 800. The cell holds and the strokes
391 + /// nearly triple, which is the measurement the set's weight rule rests on:
392 + /// the base's stem runs 54 units to 158 where Plex's ran 70 to 126.
393 + fn heavy() -> BaseParams {
387 394 BaseParams {
388 - stem: 126,
389 - stroke: 142,
390 - ..plex_regular()
395 + stem: 158,
396 + stroke: 139,
397 + // The band widens with the weight; its height does not.
398 + band_x0: 58,
399 + band_x1: 574,
400 + ..light()
391 401 }
392 402 }
393 403
@@ -403,6 +413,17 @@
403 413 )
404 414 }
405 415
416 + /// How much of its own bounding box a mark inks in.
417 + ///
418 + /// Contours are summed rather than unioned, so a shaft running into its own
419 + /// head counts twice. That is the same reading the built face is measured
420 + /// with, and both are comparing a mark against itself at another weight.
421 + fn fill_of(drawing: &Drawing) -> f64 {
422 + let ink: f64 = drawing.contours.iter().map(|c| signed_area(c).abs()).sum();
423 + let (x0, y0, x1, y1) = bounds(drawing);
424 + ink / ((x1 - x0) * (y1 - y0))
425 + }
426 +
406 427 fn shape(name: &str) -> Shape {
407 428 let manifest = Manifest::parse(crate::HOUSE_SET).unwrap();
408 429 let mut glyphs = manifest.glyphs;
@@ -421,7 +442,7 @@
421 442 /// is the measurement behind it rather than the eye that caught it.
422 443 #[test]
423 444 fn an_arrow_with_two_heads_still_has_a_shaft() {
424 - let params = plex_regular();
445 + let params = light();
425 446 let drawing = draw(&shape("uni2195"), &params);
426 447 let heads: Vec<&Vec<(f64, f64)>> =
427 448 drawing.contours.iter().filter(|c| c.len() == 3).collect();
@@ -453,7 +474,7 @@
453 474 #[test]
454 475 fn every_mark_fits_inside_the_cell() {
455 476 let manifest = Manifest::parse(crate::HOUSE_SET).unwrap();
456 - for params in [plex_regular(), plex_bold()] {
477 + for params in [light(), heavy()] {
457 478 // Band-relative marks only. Cell furniture is sized against the cell
458 479 // instead and obeys different rules, which `crate::cells` asserts.
459 480 for glyph in manifest
@@ -476,7 +497,7 @@
476 497 #[test]
477 498 fn every_mark_sits_above_the_descender() {
478 499 let manifest = Manifest::parse(crate::HOUSE_SET).unwrap();
479 - let params = plex_regular();
500 + let params = light();
480 501 // Band-relative marks only. Cell furniture is sized against the cell
481 502 // instead and obeys different rules, which `crate::cells` asserts.
482 503 for glyph in manifest
@@ -500,7 +521,7 @@
500 521
501 522 #[test]
502 523 fn marks_are_centred_on_the_cell_not_the_band() {
503 - let params = plex_regular();
524 + let params = light();
504 525 for name in ["uni25B2", "uni25BC", "uni25B8", "uni25C2", "uni2718"] {
505 526 let (x0, _, x1, _) = bounds(&draw(&shape(name), &params));
506 527 let centre = f64::midpoint(x0, x1);
@@ -522,8 +543,8 @@
522 543 .iter()
523 544 .filter(|glyph| !glyph.shape.is_cell_furniture())
524 545 {
525 - let light = draw(&glyph.shape, &plex_regular()).to_bezpath();
526 - let bold = draw(&glyph.shape, &plex_bold()).to_bezpath();
546 + let light = draw(&glyph.shape, &light()).to_bezpath();
547 + let bold = draw(&glyph.shape, &heavy()).to_bezpath();
527 548 assert_ne!(
528 549 light.to_svg(),
529 550 bold.to_svg(),
@@ -534,8 +555,17 @@
534 555 }
535 556 }
536 557
558 + /// The discriminator is fill, not extent.
559 + ///
560 + /// It was extent until the base moved, and that read the two responses apart
561 + /// only because Plex's two static cuts were 400 and 700 apart. Over a whole
562 + /// 200-800 axis a stroked mark's own band term carries it 1.15x wider on its
563 + /// own, so every mark grows and the test said nothing. What the two
564 + /// responses actually mean is how much of its own box a mark inks: a solid
565 + /// mark scales, so it inks the same share of a larger box, and a stroked one
566 + /// thickens inside a box that barely moves.
537 567 #[test]
538 - fn a_solid_mark_grows_and_a_stroked_one_holds_its_extent() {
568 + fn a_solid_mark_scales_and_a_stroked_one_thickens() {
539 569 let manifest = Manifest::parse(crate::HOUSE_SET).unwrap();
540 570 // Band-relative marks only. Cell furniture is sized against the cell
541 571 // instead and obeys different rules, which `crate::cells` asserts.
@@ -544,33 +574,73 @@
544 574 .iter()
545 575 .filter(|glyph| !glyph.shape.is_cell_furniture())
546 576 {
547 - let light = bounds(&draw(&glyph.shape, &plex_regular()));
548 - let bold = bounds(&draw(&glyph.shape, &plex_bold()));
549 - let grew = (bold.2 - bold.0) > (light.2 - light.0) + 0.5;
577 + let thin = fill_of(&draw(&glyph.shape, &light()));
578 + let thick = fill_of(&draw(&glyph.shape, &heavy()));
550 579 match glyph.shape.weight_response() {
551 - WeightResponse::Grows => assert!(
552 - grew,
553 - "{} has no stroke to thicken, so it has to grow",
554 - glyph.name
555 - ),
556 - // Only the generated cell fills hold, and the manifest's own
557 - // marks are never one.
558 - WeightResponse::Holds => unreachable!("{} is an authored mark", glyph.name),
559 - WeightResponse::Thickens => {
560 - // The box and the arrow do carry a small extent term, the
561 - // way the base grows `+`'s bars inside a fixed bbox; what
562 - // they must not do is grow like a solid mark.
563 - let growth = (bold.2 - bold.0) / (light.2 - light.0);
580 + WeightResponse::Grows => {
581 + let light_bounds = bounds(&draw(&glyph.shape, &light()));
582 + let heavy_bounds = bounds(&draw(&glyph.shape, &heavy()));
564 583 assert!(
565 - growth < 1.10,
566 - "{} grew {growth:.2}x, which is a solid mark's response",
584 + (thick - thin).abs() < 0.01,
585 + "{} inks {thin:.3} of its box at the light end and {thick:.3} at the \
586 + heavy one. A solid mark scales, so its fill is the shape's own constant.",
587 + glyph.name
588 + );
589 + assert!(
590 + (heavy_bounds.2 - heavy_bounds.0) > (light_bounds.2 - light_bounds.0) + 0.5,
591 + "{} has no stroke to thicken, so it has to grow",
567 592 glyph.name
568 593 );
569 594 }
595 + // Only the generated cell fills hold, and the manifest's own
596 + // marks are never one.
597 + WeightResponse::Holds => unreachable!("{} is an authored mark", glyph.name),
598 + WeightResponse::Thickens => assert!(
599 + thick > thin * 1.3,
600 + "{} inks {thin:.3} of its box at the light end and {thick:.3} at the heavy \
601 + one, so its stroke is not following the base's",
602 + glyph.name
603 + ),
570 604 }
571 605 }
572 606 }
573 607
608 + /// An open box has to stay open, which is the rule its recalibration holds.
609 + ///
610 + /// The counter is what makes the mark read as a box rather than as a blob,
611 + /// and it is the base's stroke that eats it: Atkinson's nearly triples
612 + /// across the axis where Plex's grew 1.8x between two static cuts. So this
613 + /// is checked at the heavy end, where the aperture is scarce, and it is
614 + /// checked as a share of the box rather than in units, so it refits.
615 + #[test]
616 + fn the_space_render_keeps_its_counter_open() {
617 + let Shape::OpenBox {
618 + width,
619 + height,
620 + stroke,
621 + ..
622 + } = shape("uni2423")
623 + else {
624 + panic!("uni2423 is not an open box");
625 + };
626 + for params in [light(), heavy()] {
627 + let bar = f64::from(params.stroke) * stroke;
628 + let box_width = width.resolve(params.band_width(), f64::from(params.stroke));
629 + let box_height = height.resolve(params.band_height(), f64::from(params.stroke));
630 + // Two risers across, and one bar up: the box is open at the top.
631 + let across = (box_width - 2.0 * bar) / box_width;
632 + let up = (box_height - bar) / box_height;
633 + assert!(
634 + across > 0.45 && up > 0.45,
635 + "the space render's counter is {:.0}% of its width and {:.0}% of its height at \
636 + stroke {}, which closes up into a blob rather than reading as a box",
637 + across * 100.0,
638 + up * 100.0,
639 + params.stroke
640 + );
641 + }
642 + }
643 +
574 644 /// A head that does not clear its own shaft is not an arrowhead.
575 645 ///
576 646 /// The bound is the shaft's stroke: the head has to stand at least half a
@@ -584,7 +654,7 @@
584 654 else {
585 655 panic!("uni23CE is not a return arrow");
586 656 };
587 - for params in [plex_regular(), plex_bold()] {
657 + for params in [light(), heavy()] {
588 658 let band = params.band_height();
589 659 let weight = f64::from(params.stroke) * stroke;
590 660 let head = head_span.resolve(band, f64::from(params.stroke));
@@ -598,7 +668,7 @@
598 668
599 669 #[test]
600 670 fn the_carets_are_reflections_of_each_other() {
601 - let params = plex_regular();
671 + let params = light();
602 672 let up = bounds(&draw(&shape("uni25B2"), &params));
603 673 let down = bounds(&draw(&shape("uni25BC"), &params));
604 674 assert!((up.0 - down.0).abs() < 0.01 && (up.2 - down.2).abs() < 0.01);
@@ -612,7 +682,7 @@
612 682 #[test]
613 683 fn every_contour_comes_out_wound_for_glyf() {
614 684 let manifest = Manifest::parse(crate::HOUSE_SET).unwrap();
615 - let params = plex_regular();
685 + let params = light();
616 686 for glyph in &manifest.glyphs {
617 687 let drawing = draw(&glyph.shape, &params);
618 688 for contour in &drawing.contours {
@@ -631,14 +701,17 @@
631 701
632 702 /// The X is calibrated against the base's own cross rather than by eye.
633 703 ///
634 - /// Plex Mono's `x` (U+00D7) fills 38.2% of its bounding box in Regular and
635 - /// 53.4% in Bold, measured 2026-08-16 by flattening its outline. U+2718 is
636 - /// the HEAVY ballot X, so it has to sit above that and not far above it.
637 - /// The first attempt used a flat 1.5x the base stroke and put 213 units of
638 - /// ink across a 380-unit mark in Bold, which is the failure this bounds.
704 + /// Atkinson Hyperlegible Mono's `×` (U+00D7) fills 30.0% of its bounding box
705 + /// at `wght` 200 and 56.9% at 800, measured 2026-08-17 by flattening its
706 + /// outline. U+2718 is the HEAVY ballot X, so it has to sit above that and
707 + /// not far above it. The first attempt used a flat 1.5x the base stroke and
708 + /// put 213 units of ink across a 380-unit mark, which is the failure this
709 + /// bounds — and the coefficient is refitted per base rather than carried
710 + /// over, since Plex's numbers were 38.2% and 53.4% and its bar was heavier
711 + /// against its own cross than Atkinson's is.
639 712 #[test]
640 713 fn the_cross_is_heavier_than_the_bases_own_and_no_heavier() {
641 - for (params, base_fill) in [(plex_regular(), 0.382), (plex_bold(), 0.534)] {
714 + for (params, base_fill) in [(light(), 0.300), (heavy(), 0.569)] {
642 715 let drawing = draw(&shape("uni2718"), &params);
643 716 let ink = signed_area(&drawing.contours[0]).abs();
644 717 let (x0, y0, x1, y1) = bounds(&drawing);
M src/lib.rs +1
@@ -22,6 +22,7 @@
22 22 pub mod draw;
23 23 pub mod manifest;
24 24 pub mod pins;
25 + pub mod proof;
25 26 pub mod vary;
26 27 pub mod woff2;
27 28
M src/main.rs +82
@@ -7,6 +7,7 @@
7 7 use quasi_type::compose::{self, Identity};
8 8 use quasi_type::manifest::{Manifest, format_codepoint};
9 9 use quasi_type::pins::Pins;
10 + use quasi_type::proof;
10 11 use quasi_type::{Error, HOUSE_SET, PINS, assert, woff2};
11 12
12 13 const USAGE: &str = "\
@@ -16,11 +17,13 @@
16 17 quasi-type verify <slot> cut without writing; assert coverage only
17 18 quasi-type params <slot> print what each base face measures
18 19 quasi-type list print the house glyph set and the pinned slots
20 + quasi-type proof <slot> rasterise the cut face to out/, so it can be seen
19 21
20 22 Options
21 23 --out <dir> where faces are written (default: out/)
22 24 --offline fail rather than fetch a base that is not cached
23 25 --base <id> measure a pinned base directly, for a base no slot names yet
26 + --px <n> proof sheet type size in pixels (default: 24)
24 27 ";
25 28
26 29 fn main() -> ExitCode {
@@ -39,10 +42,17 @@
39 42 let mut out = PathBuf::from("out");
40 43 let mut offline = false;
41 44 let mut base_id: Option<&str> = None;
45 + let mut px = 24.0_f32;
42 46 let mut rest = args.iter();
43 47 while let Some(arg) = rest.next() {
44 48 match arg.as_str() {
45 49 "--offline" => offline = true,
50 + "--px" => {
51 + px = rest
52 + .next()
53 + .and_then(|n| n.parse().ok())
54 + .ok_or_else(|| Error::Pins("--px needs a size in pixels".into()))?;
55 + }
46 56 "--base" => {
47 57 base_id = Some(
48 58 rest.next()
@@ -134,6 +144,10 @@
134 144 let slot_id = tail.first().copied().unwrap_or("quasi-mono");
135 145 cut(&root, &out, &pins, &manifest, slot_id, offline, false)
136 146 }
147 + Some((&"proof", tail)) => {
148 + let slot_id = tail.first().copied().unwrap_or("quasi-mono");
149 + proof(&root, &out, &pins, &manifest, slot_id, offline, px)
150 + }
137 151 Some((other, _)) => {
138 152 print!("{USAGE}");
139 153 Err(Error::Pins(format!("no command `{other}`")))
@@ -245,6 +259,74 @@
245 259 Ok(())
246 260 }
247 261
262 + /// Cut the slot and rasterise it, one sheet per end of the axis.
263 + ///
264 + /// The last check in this pipeline is a person looking at the glyphs, and until
265 + /// this existed that meant installing the face. A variable cut gets a sheet at
266 + /// each master rather than at its default alone, because the defects the axis
267 + /// introduces — a mark whose stroke closes its own counter at the heavy end —
268 + /// are invisible at rest, which is exactly where every other check reads it.
269 + fn proof(
270 + root: &Path,
271 + out: &Path,
272 + pins: &Pins,
273 + manifest: &Manifest,
274 + slot_id: &str,
275 + offline: bool,
276 + px: f32,
277 + ) -> Result<(), Error> {
278 + let slot = pins.slot(slot_id)?;
279 + let base_pin = pins.base(&slot.base)?;
280 + let selected: Vec<&quasi_type::manifest::GlyphSpec> = manifest
281 + .glyphs
282 + .iter()
283 + .filter(|g| slot.glyphs.includes(g.codepoint))
284 + .collect();
285 + let faces = base::load(base_pin, &base::cache_dir(root), offline)?;
286 + let version = format!("{}.{}", manifest.set.version, base_pin.version);
287 + std::fs::create_dir_all(out).map_err(|e| Error::Io(out.to_path_buf(), e))?;
288 +
289 + println!("{} at {}px", slot.family, px);
290 + for face in &faces {
291 + let id = Identity {
292 + family: &slot.family,
293 + style: &face.style,
294 + version: &version,
295 + set_version: manifest.set.version,
296 + base: base_pin,
297 + };
298 + let built = compose::build(&face.bytes, &selected, &id)?;
299 + let coverage = compose::coverage(&built.bytes)?;
300 + let rows = proof::house_rows(&coverage);
301 +
302 + // The axis default is one of its ends often enough to be worth saying
303 + // once: Atkinson Mono's is its minimum, so a sheet per master is two
304 + // sheets rather than three.
305 + let mut locations: Vec<Option<f32>> = match &built.variation {
306 + Some(axis) => vec![Some(axis.min), Some(axis.default), Some(axis.max)],
307 + None => vec![None],
308 + };
309 + locations.dedup();
310 + for wght in locations {
311 + let page = proof::Page {
312 + rows: rows.clone(),
313 + px,
314 + wght,
315 + };
316 + let sheet = proof::render(&built.bytes, &page)?;
317 + let name = match wght {
318 + Some(w) => format!("proof-{slot_id}-wght{w:.0}.png"),
319 + None => format!("proof-{slot_id}-{}.png", face.style.replace(' ', "")),
320 + };
321 + let path = out.join(&name);
322 + write_file(&path, &proof::png(&sheet))?;
323 + println!(" {name:<28} {} x {}", sheet.width, sheet.height);
324 + }
325 + }
326 + println!("\nwritten to {}", out.display());
327 + Ok(())
328 + }
329 +
248 330 fn print_params(name: &str, p: &BaseParams) {
249 331 println!("{name}");
250 332 println!(" upem {}", p.upem);
M src/pins.rs +38 -6
@@ -255,32 +255,64 @@
255 255 }
256 256
257 257 #[test]
258 - fn quasi_mono_is_cut_from_the_pinned_plex() {
258 + fn quasi_mono_is_cut_from_the_pinned_atkinson() {
259 259 let pins = pins();
260 260 let slot = pins.slot("quasi-mono").unwrap();
261 261 assert_eq!(slot.family, "Quasi Mono");
262 262 let base = pins.base(&slot.base).unwrap();
263 - assert_eq!(base.family, "IBM Plex Mono");
264 - assert_eq!(base.faces.len(), 2, "Regular and Bold; italic is not cut");
263 + assert_eq!(base.family, "Atkinson Hyperlegible Mono");
264 + assert_eq!(
265 + base.faces.len(),
266 + 1,
267 + "one variable file covers the range; italic is not cut"
268 + );
265 269 }
266 270
271 + /// Atkinson declares no Reserved Font Name, so the gate this file documents
272 + /// has nothing to bite on for the base in the tree today. It is still the
273 + /// gate — a base that declares one is the common case, and Plex was one
274 + /// until 2026-08-17 — so it is exercised against a pin written here rather
275 + /// than dropped along with the base that made it necessary.
267 276 #[test]
268 277 fn the_house_name_clears_the_reserved_font_name() {
269 278 let pins = pins();
270 - let base = pins.base("plex-mono").unwrap();
279 + let base = pins.base("atkinson-mono").unwrap();
280 + assert!(base.reserved_font_name.is_none());
271 281 base.check_output_name("Quasi Mono")
272 282 .expect("Quasi clears it");
283 + base.check_output_name("Alloyed Atkinson")
284 + .expect("no RFN, so the general fork rule works on this base");
273 285 }
274 286
275 287 #[test]
276 288 fn reaching_for_the_bases_name_is_refused() {
277 - let pins = pins();
278 - let base = pins.base("plex-mono").unwrap();
289 + let source = r#"
290 + [[base]]
291 + id = "reserved"
292 + family = "IBM Plex Mono"
293 + version = "2.5.0"
294 + license = "OFL-1.1"
295 + reserved_font_name = "Plex"
296 + license_url = "https://example.invalid/OFL.txt"
297 + license_sha256 = "0000000000000000000000000000000000000000000000000000000000000000"
298 + copyright = "Copyright 2017 IBM Corp. All rights reserved."
299 + designer = "Mike Abbink"
300 + vendor_url = "http://www.ibm.com/plex"
301 +
302 + [[base.face]]
303 + style = "Regular"
304 + url = "https://example.invalid/IBMPlexMono-Regular.ttf"
305 + sha256 = "0000000000000000000000000000000000000000000000000000000000000000"
306 + "#;
307 + let pins = Pins::parse(source).unwrap();
308 + let base = pins.base("reserved").unwrap();
279 309 // The shape that made nerd-fonts ship "BlexMono": prefixing does not
280 310 // clear the clause, and neither does suffixing.
281 311 for barred in ["Alloyed Plex Mono", "Plex Mono Quasi", "quasi plex"] {
282 312 assert!(base.check_output_name(barred).is_err(), "{barred}");
283 313 }
314 + base.check_output_name("Quasi Mono")
315 + .expect("Quasi clears it");
284 316 }
285 317
286 318 #[test]
@@ -14,9 +14,9 @@
14 14 use quasi_type::compose::{self, Identity};
15 15 use quasi_type::manifest::{GlyphSpec, Manifest};
16 16 use quasi_type::pins::Pins;
17 + use quasi_type::proof;
17 18 use quasi_type::{HOUSE_SET, PINS, assert as coverage, woff2};
18 19
19 - use read_fonts::types::GlyphId;
20 20 use read_fonts::{FontRef, TableProvider};
21 21
22 22 fn root() -> std::path::PathBuf {
@@ -39,11 +39,27 @@
39 39 }
40 40 }
41 41
42 + /// The style the pin declares, which is the base's own default instance and is
43 + /// not always `Regular`: Atkinson Mono's is `ExtraLight`.
44 + ///
45 + /// Read rather than written out, because a hardcoded style is a test that stops
46 + /// testing when the base moves. This file said `"Regular"` in nine places and
47 + /// every one of them would have skipped silently the day the slot changed base.
48 + fn style() -> String {
49 + let pins = Pins::parse(PINS).unwrap();
50 + let slot = pins.slot("quasi-mono").unwrap();
51 + pins.base(&slot.base).unwrap().faces[0].style.clone()
52 + }
53 +
42 54 /// The cut face, the set that went in, and the codepoints the base already
43 55 /// drew so kept its own. See `manifest::Purpose`.
44 - fn cut_full(style: &str) -> Option<(Vec<u8>, Vec<GlyphSpec>, Vec<u32>)> {
56 + fn cut_full() -> Option<(Vec<u8>, Vec<GlyphSpec>, Vec<u32>)> {
45 57 let faces = base_faces()?;
46 - let face = faces.into_iter().find(|f| f.style == style)?;
58 + let style = style();
59 + let face = faces
60 + .into_iter()
61 + .find(|f| f.style == style)
62 + .expect("the pin's own style is one the base loads under");
47 63 let pins = Pins::parse(PINS).unwrap();
48 64 let slot = pins.slot("quasi-mono").unwrap();
49 65 let base = pins.base(&slot.base).unwrap();
@@ -52,8 +68,8 @@
52 68 let refs: Vec<&GlyphSpec> = specs.iter().collect();
53 69 let id = Identity {
54 70 family: &slot.family,
55 - style,
56 - version: "1.2.5.0",
71 + style: &style,
72 + version: &format!("1.{}", base.version),
57 73 set_version: 1,
58 74 base,
59 75 };
@@ -61,13 +77,13 @@
61 77 Some((built.bytes, specs, built.kept_by_base))
62 78 }
63 79
64 - fn cut(style: &str) -> Option<(Vec<u8>, Vec<GlyphSpec>)> {
65 - cut_full(style).map(|(bytes, specs, _)| (bytes, specs))
80 + fn cut() -> Option<(Vec<u8>, Vec<GlyphSpec>)> {
81 + cut_full().map(|(bytes, specs, _)| (bytes, specs))
66 82 }
67 83
68 84 #[test]
69 85 fn the_face_covers_everything_a_consumer_needs() {
70 - let Some((bytes, specs)) = cut("Regular") else {
86 + let Some((bytes, specs)) = cut() else {
71 87 return;
72 88 };
73 89 let house: Vec<u32> = specs.iter().map(|g| g.codepoint).collect();
@@ -97,9 +113,9 @@
97 113 #[test]
98 114 fn the_base_keeps_every_codepoint_it_had() {
99 115 let Some(faces) = base_faces() else { return };
100 - let base_face = faces.iter().find(|f| f.style == "Regular").unwrap();
116 + let base_face = faces.iter().find(|f| f.style == style()).unwrap();
101 117 let before = base::mappings(&base_face.bytes).unwrap();
102 - let (bytes, specs) = cut("Regular").unwrap();
118 + let (bytes, specs) = cut().unwrap();
103 119 let after = compose::coverage(&bytes).unwrap();
104 120 for (codepoint, gid) in &before {
105 121 assert_eq!(
@@ -108,20 +124,26 @@
108 124 "U+{codepoint:04X} moved or was dropped"
109 125 );
110 126 }
111 - // Only what we actually added. Plex draws all 128 box-drawing glyphs and
112 - // all four arrows itself, so most of the set is `Purpose::Coverage` already
113 - // met and the face gains the eleven marks it was missing.
127 + // Only what we actually added. Atkinson draws none of the cell furniture and
128 + // none of the arrows, so nearly the whole set is drawn here — which is what
129 + // the base change cost, and why `Purpose` exists: under Plex most of these
130 + // codepoints were coverage the base already met.
114 131 let added = specs
115 132 .iter()
116 133 .filter(|spec| !before.contains_key(&spec.codepoint))
117 134 .count();
118 135 assert_eq!(after.len(), before.len() + added);
119 - assert_eq!(added, 7, "Plex has everything but the seven house marks");
136 + assert_eq!(
137 + added,
138 + specs.len(),
139 + "Atkinson draws no box drawing, no block elements and no arrows, so every \
140 + entry in the set is drawn here"
141 + );
120 142 }
121 143
122 144 #[test]
123 145 fn the_marks_take_the_cell_and_sit_on_their_own_bearings() {
124 - let Some((bytes, specs)) = cut("Regular") else {
146 + let Some((bytes, specs)) = cut() else {
125 147 return;
126 148 };
127 149 let font = FontRef::new(&bytes).unwrap();
@@ -138,7 +160,7 @@
138 160 let gid = mappings[&spec.codepoint];
139 161 assert_eq!(
140 162 hmtx.advance(gid),
141 - Some(600),
163 + Some(632),
142 164 "{} does not take the monospace cell",
143 165 spec.name
144 166 );
@@ -147,7 +169,7 @@
147 169
148 170 #[test]
149 171 fn the_name_table_states_the_lineage_and_never_the_reserved_name() {
150 - let Some((bytes, _)) = cut("Regular") else {
172 + let Some((bytes, _)) = cut() else {
151 173 return;
152 174 };
153 175 let font = FontRef::new(&bytes).unwrap();
@@ -167,15 +189,24 @@
167 189 _ => {}
168 190 }
169 191 }
192 + // Name 1 carries the style when the default instance is not Regular, which
193 + // is how upstream names the same file: a variable face's family-plus-style
194 + // pair has to resolve to the weight it actually draws at rest. The clean
195 + // family name is name 16, the typographic record.
170 196 let family = family.expect("a family name");
171 - assert_eq!(family, "Quasi Mono");
172 - assert!(
173 - !family.to_lowercase().contains("plex"),
174 - "OFL 1.1 clause 3 bars the base's Reserved Font Name"
175 - );
197 + assert_eq!(family, format!("Quasi Mono {}", style()));
198 + // Atkinson declares no Reserved Font Name, so clause 3 does not bite on the
199 + // base in the tree today. The name is still the slot's rather than the
200 + // base's, on the grounds the wiki re-settled it on: fontconfig matches by
201 + // family, and Alloy installs upstream Atkinson beside this face.
202 + assert!(!family.to_lowercase().contains("atkinson"));
176 203 // The lineage the licence requires goes in the metadata instead.
177 - assert!(description.unwrap().contains("IBM Plex Mono"));
178 - assert!(copyright.unwrap().contains("IBM"));
204 + assert!(description.unwrap().contains("Atkinson Hyperlegible Mono"));
205 + assert!(
206 + copyright
207 + .unwrap()
208 + .contains("Atkinson Hyperlegible Mono Project Authors")
209 + );
179 210 }
180 211
181 212 #[test]
@@ -185,7 +216,7 @@
185 216 // is this face carrying" has to be answerable from the file rather than
186 217 // from knowing which build produced it — otherwise an installed face that
187 218 // predates a set version is indistinguishable from one that does not.
188 - let Some((bytes, _)) = cut("Regular") else {
219 + let Some((bytes, _)) = cut() else {
189 220 return;
190 221 };
191 222 let font = FontRef::new(&bytes).unwrap();
@@ -214,14 +245,15 @@
214 245 // than the packed `{set}.{base}` version, which cannot be split without
215 246 // already knowing where the base's version starts.
216 247 assert!(
217 - unique_id.contains("house glyph set v1") && unique_id.contains("IBM Plex Mono 2.5.0"),
248 + unique_id.contains("house glyph set v1")
249 + && unique_id.contains("Atkinson Hyperlegible Mono 2.001"),
218 250 "the unique id names both halves of the composition: {unique_id}"
219 251 );
220 252 }
221 253
222 254 #[test]
223 255 fn the_signature_over_the_old_bytes_is_dropped() {
224 - let Some((bytes, _)) = cut("Regular") else {
256 + let Some((bytes, _)) = cut() else {
225 257 return;
226 258 };
227 259 let font = FontRef::new(&bytes).unwrap();
@@ -230,79 +262,61 @@
230 262 .is_none(),
231 263 "a signature that no longer verifies is worse than none"
232 264 );
233 - // The hinting the base shipped is untouched.
234 - for tag in [b"cvt ", b"fpgm", b"prep", b"GPOS", b"GSUB"] {
265 + // Everything else the base shipped survives, hinting and layout alike.
266 + //
267 + // Read off the base rather than listed here: the list was `cvt `, `fpgm`,
268 + // `prep`, `GPOS`, `GSUB` and it was Plex's list. Atkinson ships `prep` and
269 + // no `cvt ` or `fpgm` at all, so a written-out list fails on a base that
270 + // hints differently while missing a table a third base does ship.
271 + let faces = base_faces().unwrap();
272 + let base_face = faces.iter().find(|f| f.style == style()).unwrap();
273 + for tag in tables(&base_face.bytes) {
274 + if tag.as_ref() == b"DSIG" {
275 + continue;
276 + }
235 277 assert!(
236 - font.table_data(read_fonts::types::Tag::new(tag)).is_some(),
237 - "{} was dropped",
238 - String::from_utf8_lossy(tag)
278 + font.table_data(tag).is_some(),
279 + "{tag} was dropped, and the base shipped it"
239 280 );
240 281 }
241 282 }
242 283
243 284 #[test]
244 285 fn the_same_checkout_cuts_the_same_bytes() {
245 - let Some((first, _)) = cut("Regular") else {
286 + let Some((first, _)) = cut() else {
246 287 return;
247 288 };
248 - let (second, _) = cut("Regular").unwrap();
289 + let (second, _) = cut().unwrap();
249 290 assert_eq!(first, second, "the build is not reproducible");
250 291 }
251 292
252 - /// Every mark is redrawn for Bold, which is the correction the base forced.
293 + /// Every mark rasterises to ink, at a real terminal size.
253 294 ///
254 - /// The first cut held the triangles still across weights, on the reasoning that
255 - /// Plex draws `block` and `light shade` identically in both. That generalised
256 - /// from the wrong glyphs: the blocks are cell-fill primitives, and every actual
257 - /// symbol in the base grows. A mark that did not would read light inside Bold.
295 + /// A glyph that draws nothing has a valid `cmap` entry, a valid advance and an
296 + /// empty outline, so coverage cannot see it and neither can a bounding box read
297 + /// off a drawing that was never spliced. Running the proof sheet's rasteriser
298 + /// over the built face is what closes that: it is the same path a screen takes.
258 299 #[test]
259 - fn bold_gets_its_own_drawing_of_every_mark() {
260 - let Some((regular, specs)) = cut("Regular") else {
261 - return;
262 - };
263 - let (bold, _) = cut("Bold").unwrap();
264 - let base_had = base::mappings(
265 - &base_faces()
266 - .unwrap()
267 - .into_iter()
268 - .find(|f| f.style == "Regular")
269 - .unwrap()
270 - .bytes,
271 - )
272 - .unwrap();
300 + fn every_mark_puts_ink_on_the_page() {
301 + let Some((bytes, specs)) = cut() else { return };
273 302 for spec in &specs {
274 - // Only the marks this pipeline drew. A codepoint the base already had
275 - // is the base's own drawing at both weights, and Plex deliberately
276 - // draws `█` and `░` identically in Regular and Bold — they are
277 - // cell-fill primitives, which is the observation the set's own weight
278 - // rule was built on.
279 - if base_had.contains_key(&spec.codepoint) {
280 - continue;
281 - }
282 - assert_ne!(
283 - outline(&regular, spec.codepoint),
284 - outline(&bold, spec.codepoint),
285 - "{} is the same outline at both weights",
303 + let page = proof::Page {
304 + rows: vec![vec![spec.codepoint]],
305 + px: 16.0,
306 + wght: None,
307 + };
308 + let sheet = proof::render(&bytes, &page).unwrap();
309 + assert!(
310 + sheet.ink() > 0,
311 + "{} rasterises to nothing at 16px, so the face carries an empty outline for it",
286 312 spec.name
287 313 );
288 314 }
289 315 }
290 316
291 - /// The raw `glyf` entry for a codepoint, which is the only comparison that
292 - /// distinguishes a redrawn outline from a resized bounding box.
293 - fn outline(bytes: &[u8], codepoint: u32) -> Vec<u8> {
294 - let font = FontRef::new(bytes).unwrap();
295 - let gid: GlyphId = compose::coverage(bytes).unwrap()[&codepoint];
296 - let loca = font.loca(None).unwrap();
297 - let glyf = font.glyf().unwrap();
298 - let start = loca.get_raw(gid.to_u32() as usize).unwrap() as usize;
299 - let end = loca.get_raw(gid.to_u32() as usize + 1).unwrap() as usize;
300 - glyf.offset_data().as_bytes()[start..end].to_vec()
301 - }
302 -
303 317 #[test]
304 318 fn the_woff2_carries_every_table_the_ttf_does() {
305 - let Some((bytes, _)) = cut("Regular") else {
319 + let Some((bytes, _)) = cut() else {
306 320 return;
307 321 };
308 322 let web = woff2::encode(&bytes).unwrap();
@@ -348,6 +362,17 @@
348 362 );
349 363 }
350 364
365 + /// Every table tag in a font, in directory order.
366 + fn tables(bytes: &[u8]) -> Vec<read_fonts::types::Tag> {
367 + FontRef::new(bytes)
368 + .unwrap()
369 + .table_directory()
370 + .table_records()
371 + .iter()
372 + .map(read_fonts::TableRecord::tag)
373 + .collect()
374 + }
375 +
351 376 fn read_base128(bytes: &[u8]) -> (u32, usize) {
352 377 let mut value = 0u32;
353 378 for (i, byte) in bytes.iter().enumerate() {
@@ -1,9 +1,10 @@
1 1 //! Cutting a face from a variable base, which is the capability itself.
2 2 //!
3 - //! The base is Atkinson Hyperlegible Mono: `wght` 200-800 in one file, pinned
4 - //! and not yet adopted. Moving `quasi-mono` onto it is GO `aab673c1` and waits
5 - //! on the box-drawing set; what is asserted here is that the pipeline can cut
6 - //! from it at all, and that the marks answer the axis once it has.
3 + //! The base is Atkinson Hyperlegible Mono: `wght` 200-800 in one file, and the
4 + //! base `quasi-mono` has been cut from since 2026-08-17. What is asserted here
5 + //! is what the axis adds — that it survives the cut, that the marks ride it,
6 + //! and that a mark calibrated at rest is still the right weight at the far end,
7 + //! which is the failure mode a static base could not have.
7 8 //!
8 9 //! Skipped rather than failed when the base is not cached, same as the static
9 10 //! cut. `cargo run -- params --base atkinson-mono` once and it runs for good.
@@ -22,8 +23,8 @@
22 23
23 24 const BASE: &str = "atkinson-mono";
24 25
25 - /// The family the slot decision names. Nothing is written here, and no slot in
26 - /// `pins.toml` points at this base yet: adopting it is `aab673c1`.
26 + /// The family the slot names. Read here rather than through `pins.slot` because
27 + /// this file is about the base and cuts from it directly.
27 28 const FAMILY: &str = "Quasi Mono";
28 29
29 30 fn root() -> std::path::PathBuf {
@@ -203,6 +204,63 @@
203 204 }
204 205 }
205 206
207 + /// The calibration holds at the far end of the axis, which is where adopting
208 + /// this base broke it.
209 + ///
210 + /// Both numbers here are read off the base at each end rather than written down
211 + /// as constants, because that is what the coefficients in the manifest are: a
212 + /// relationship to the base, refitted when the base moves. Recalibrated
213 + /// 2026-08-17 when `quasi-mono` moved onto Atkinson, whose stem nearly triples
214 + /// across `wght` 200-800 where Plex's grew 1.8x between two static cuts.
215 + ///
216 + /// - the ballot X is heavier than the base's own `×` and not far heavier. It
217 + /// came out *lighter* on the first cut from this base, at the coefficient
218 + /// Plex was calibrated against.
219 + /// - the space render keeps its counter open. At the old coefficients its
220 + /// risers took 278 units of a 413-unit box at `wght` 800 and it inked 90.6%
221 + /// of its own bounding box, which rasterises as a blob rather than a box.
222 + #[test]
223 + fn the_marks_are_still_calibrated_at_the_heavy_end() {
224 + let Some((bytes, _)) = cut() else { return };
225 + for wght in [200.0, 800.0] {
226 + let base_cross = base_fill(0x00D7, wght);
227 + let ours = fill(&bytes, 0x2718, wght);
228 + assert!(
229 + ours > base_cross && ours < base_cross + 0.04,
230 + "at wght {wght} the ballot X inks {ours:.3} of its box against the base's own \
231 + cross at {base_cross:.3}. HEAVY BALLOT X is heavier than `×`, and not far \
232 + heavier: a flat multiple of the base's bar read as a blob."
233 + );
234 +
235 + // The counter is what makes an open box a box. Measured off the drawing
236 + // rather than off a fill fraction, since fill cannot tell a thick box
237 + // from a closed one.
238 + let bar = base_stroke(wght);
239 + let across = width(&bytes, 0x2423, wght) - 2.0 * bar;
240 + let up = height(&bytes, 0x2423, wght) - bar;
241 + assert!(
242 + across / width(&bytes, 0x2423, wght) > 0.45 && up / height(&bytes, 0x2423, wght) > 0.45,
243 + "at wght {wght} the space render's counter is {across:.0} x {up:.0} units inside \
244 + a {:.0} x {:.0} box, which closes up rather than reading as a box",
245 + width(&bytes, 0x2423, wght),
246 + height(&bytes, 0x2423, wght),
247 + );
248 + }
249 + }
250 +
251 + /// The base's own fill for a codepoint, so a calibration is stated against the
252 + /// face it was fitted to rather than against a number somebody copied.
253 + fn base_fill(codepoint: u32, wght: f32) -> f32 {
254 + let (face, _) = base_face().expect("the base is cached, since the cut above needed it");
255 + fill(&face.bytes, codepoint, wght)
256 + }
257 +
258 + /// The base's horizontal stroke at a location, read off `-` the way the recipes
259 + /// read it.
260 + fn base_stroke(wght: f32) -> f32 {
261 + height(&base_face().expect("cached").0.bytes, 0x002D, wght)
262 + }
263 +
206 264 /// The done condition of the variable work, in the words it was written in: a
207 265 /// bold table border draws at the bold stroke weight.
208 266 ///
A src/proof.rs +500
@@ -1,0 +1,549 @@
1 + //! A proof sheet: the built face rasterised, so a shape can be looked at.
2 + //!
3 + //! Every assertion in this repo measures a bounding box or an ink area, and
4 + //! three real defects have already walked past all of them — arcs curling the
5 + //! wrong way, a dashed glyph drawn as two arms with a seam, and an up-down
6 + //! arrow whose heads met in the middle and read as a bowtie. Each had a correct
7 + //! bbox. So the last check is a person looking at the glyph, and this is what
8 + //! gives them something to look at without installing the face first.
9 + //!
10 + //! Deliberately no image dependency. A grayscale PNG is a signature, three
11 + //! chunks and a zlib stream, and the stream may be stored blocks, so the whole
12 + //! encoder is under a hundred lines and adds nothing to the dependency tree of
13 + //! a pipeline whose whole point is being reproducible from a checkout.
14 +
15 + use std::collections::BTreeMap;
16 +
17 + use read_fonts::types::GlyphId;
18 + use read_fonts::{FontRef, TableProvider};
19 + use skrifa::MetadataProvider;
20 + use skrifa::instance::Size;
21 +
22 + use crate::Error;
23 + use crate::compose;
24 +
25 + /// Cell rules are the lightest thing on the page, so the ink reads over them.
26 + const PAPER: u8 = 0xff;
27 + const RULE: u8 = 0xdc;
28 + const BASELINE: u8 = 0xc0;
29 +
30 + /// One rasterised page.
31 + pub struct Sheet {
32 + pub width: usize,
33 + pub height: usize,
34 + pixels: Vec<u8>,
35 + }
36 +
37 + impl Sheet {
38 + fn new(width: usize, height: usize) -> Self {
39 + Self {
40 + width,
41 + height,
42 + pixels: vec![PAPER; width * height],
43 + }
44 + }
45 +
46 + /// How many pixels carry ink darker than the cell rules, which is the
47 + /// question "did this glyph draw anything" in the form a sheet can answer.
48 + pub fn ink(&self) -> usize {
49 + self.pixels.iter().filter(|&&p| p < BASELINE).count()
50 + }
51 +
52 + fn darken(&mut self, x: usize, y: usize, value: u8) {
53 + if x < self.width && y < self.height {
54 + let at = y * self.width + x;
55 + self.pixels[at] = self.pixels[at].min(value);
56 + }
57 + }
58 +
59 + fn rule_v(&mut self, x: usize, y0: usize, y1: usize, value: u8) {
60 + for y in y0..y1 {
61 + self.darken(x, y, value);
62 + }
63 + }
64 +
65 + fn rule_h(&mut self, y: usize, x0: usize, x1: usize, value: u8) {
66 + for x in x0..x1 {
67 + self.darken(x, y, value);
68 + }
69 + }
70 + }
71 +
72 + /// What to draw, in the order it is drawn.
73 + ///
74 + /// A row is a run of codepoints set adjacently on the cell grid, which is the
75 + /// arrangement that shows a seam: box drawing that does not tile leaves a gap
76 + /// between two cells, and no single-glyph rendering can show that.
77 + pub struct Page {
78 + pub rows: Vec<Vec<u32>>,
79 + pub px: f32,
80 + pub wght: Option<f32>,
81 + }
82 +
83 + /// The rows a proof of the house set wants: the authored marks one per cell,
84 + /// then the cell furniture set adjacently so its seams show.
85 + pub fn house_rows(covered: &BTreeMap<u32, GlyphId>) -> Vec<Vec<u32>> {
86 + let present = |row: Vec<u32>| -> Vec<u32> {
87 + row.into_iter()
88 + .filter(|c| covered.contains_key(c))
89 + .collect()
90 + };
91 + let mut rows = vec![
92 + // The authored marks, beside the base's own glyphs they were fitted
93 + // against: the X against `x`, the triangles against `+`, the whitespace
94 + // renders among lowercase, which is where helix draws them.
95 + present(vec![
96 + 0x25B2, 0x25BC, 0x25B8, 0x25C2, 0x2718, 0x2423, 0x23CE, 0x2191, 0x2193, 0x2192, 0x2195,
97 + ]),
98 + present(vec![
99 + 0x0078, 0x00D7, 0x2718, 0x002B, 0x25B2, 0x003D, 0x2423, 0x0061, 0x0062, 0x0063, 0x23CE,
100 + ]),
101 + ];
102 +
103 + // Box drawing, tiled. Three boxes side by side in light, heavy and double,
104 + // which is where a wrong arm or a seam shows up at once.
105 + for weights in [
106 + [0x250C, 0x2500, 0x252C, 0x2510, 0x2502, 0x2524],
107 + [0x250F, 0x2501, 0x2533, 0x2513, 0x2503, 0x252B],
108 + [0x2554, 0x2550, 0x2566, 0x2557, 0x2551, 0x2563],
109 + ] {
110 + let [tl, h, t, tr, v, r] = weights;
111 + rows.push(present(vec![tl, h, t, h, tr, 0x0020, v, 0x0020, v, r]));
112 + }
113 + rows.push(present(vec![
114 + 0x2514, 0x2500, 0x2534, 0x2500, 0x2518, 0x0020, 0x2517, 0x2501, 0x251B,
115 + ]));
116 + // Arcs and dashes, the two that were drawn wrong and passed their tests.
117 + rows.push(present(vec![
118 + 0x256D, 0x2500, 0x256E, 0x0020, 0x2570, 0x2500, 0x256F, 0x0020, 0x2504, 0x2505, 0x2508,
119 + ]));
120 + // Block elements: a full cell run, the eighths in order, and the shades.
121 + rows.push(present(vec![
122 + 0x2588, 0x2588, 0x2588, 0x0020, 0x2580, 0x2584, 0x2580, 0x2584, 0x0020, 0x2591, 0x2592,
123 + 0x2593,
124 + ]));
125 + rows.push(present(vec![
126 + 0x258F, 0x258E, 0x258D, 0x258C, 0x258B, 0x258A, 0x2589, 0x2588, 0x0020, 0x2596, 0x2597,
127 + 0x2598, 0x259D,
128 + ]));
129 + rows.retain(|row| !row.is_empty());
130 + rows
131 + }
132 +
133 + /// Rasterise one page of a face.
134 + ///
135 + /// `wght` is a location on the axis for a variable face and is ignored by a
136 + /// static one. Cells are the face's own metrics rounded to whole pixels, which
137 + /// is what a terminal lays out with, so a glyph that only tiles at its exact
138 + /// design size shows its seam here the same way it would on a screen.
139 + pub fn render(bytes: &[u8], page: &Page) -> Result<Sheet, Error> {
140 + let font = FontRef::new(bytes).map_err(|e| Error::Font(e.to_string()))?;
141 + let coverage = compose::coverage(bytes)?;
142 + let upem = f32::from(
143 + font.head()
144 + .map_err(|e| Error::Font(e.to_string()))?
145 + .units_per_em(),
146 + );
147 + let hhea = font.hhea().map_err(|e| Error::Font(e.to_string()))?;
148 + let ascent = f32::from(i16::from(hhea.ascender()));
149 + let descent = f32::from(i16::from(hhea.descender()));
150 +
151 + let scale = page.px / upem;
152 + let advance = {
153 + let hmtx = font.hmtx().map_err(|e| Error::Font(e.to_string()))?;
154 + let widest = hmtx
155 + .h_metrics()
156 + .last()
157 + .map_or(upem / 2.0, |m| f32::from(m.advance()));
158 + (widest * scale).round().max(1.0)
159 + };
160 + let cell_h = ((ascent - descent) * scale).round().max(1.0);
161 + let baseline = (ascent * scale).round();
162 +
163 + let cols = page.rows.iter().map(Vec::len).max().unwrap_or(1);
164 + let pad = (page.px * 0.5).round().max(4.0);
165 + let width = (advance * cols as f32 + pad * 2.0) as usize;
166 + let height = (cell_h * page.rows.len() as f32 + pad * 2.0) as usize;
167 + let mut sheet = Sheet::new(width, height);
168 +
169 + // A static face has no axes, so an empty location is its only location and
170 + // asking for `wght` on one is answered rather than refused.
171 + let location = font
172 + .axes()
173 + .location(page.wght.map(|w| ("wght", w)).as_slice());
174 +
175 + for (r, row) in page.rows.iter().enumerate() {
176 + let top = pad + cell_h * r as f32;
177 + // The cell rules, under the ink: a glyph that overruns its cell or sits
178 + // off the baseline says so against them.
179 + sheet.rule_h(top as usize, pad as usize, width - pad as usize, RULE);
180 + sheet.rule_h(
181 + (top + baseline) as usize,
182 + pad as usize,
183 + width - pad as usize,
184 + BASELINE,
185 + );
186 + for c in 0..=row.len() {
187 + sheet.rule_v(
188 + (pad + advance * c as f32) as usize,
189 + top as usize,
190 + (top + cell_h) as usize,
191 + RULE,
192 + );
193 + }
194 + for (c, codepoint) in row.iter().enumerate() {
195 + let Some(gid) = coverage.get(codepoint) else {
196 + continue;
197 + };
198 + let origin = (pad + advance * c as f32, top + baseline);
199 + draw_glyph(&mut sheet, &font, *gid, &location, scale, origin)?;
200 + }
201 + }
202 + Ok(sheet)
203 + }
204 +
205 + /// Fill one glyph, 3x3 supersampled.
206 + fn draw_glyph(
207 + sheet: &mut Sheet,
208 + font: &FontRef,
209 + gid: GlyphId,
210 + location: &skrifa::instance::Location,
211 + scale: f32,
212 + origin: (f32, f32),
213 + ) -> Result<(), Error> {
214 + let mut pen = Flatten {
215 + scale,
216 + origin,
217 + ..Flatten::default()
218 + };
219 + font.outline_glyphs()
220 + .get(gid)
221 + .ok_or_else(|| Error::Font(format!("glyph {gid} has no outline")))?
222 + .draw(
223 + skrifa::outline::DrawSettings::unhinted(Size::unscaled(), location),
224 + &mut pen,
225 + )
226 + .map_err(|e| Error::Font(e.to_string()))?;
227 + pen.close_open();
228 + fill(sheet, &pen.edges);
229 + Ok(())
230 + }
231 +
232 + /// Nonzero-winding scanline fill over straight edges, three samples per pixel
233 + /// in each axis. Coverage is the share of the nine samples inside the outline,
234 + /// which is enough to judge a shape and far short of a hinted rasteriser.
235 + fn fill(sheet: &mut Sheet, edges: &[(f32, f32, f32, f32)]) {
236 + if edges.is_empty() {
237 + return;
238 + }
239 + const SUB: usize = 3;
240 + let step = 1.0 / SUB as f32;
241 + let (mut y0, mut y1) = (f32::MAX, f32::MIN);
242 + let (mut x0, mut x1) = (f32::MAX, f32::MIN);
243 + for &(ax, ay, bx, by) in edges {
244 + y0 = y0.min(ay).min(by);
245 + y1 = y1.max(ay).max(by);
246 + x0 = x0.min(ax).min(bx);
247 + x1 = x1.max(ax).max(bx);
248 + }
249 + let row0 = y0.floor().max(0.0) as usize;
250 + let row1 = (y1.ceil() as usize).min(sheet.height);
251 + let col0 = x0.floor().max(0.0) as usize;
252 + let col1 = (x1.ceil() as usize).min(sheet.width);
253 + if row0 >= row1 || col0 >= col1 {
254 + return;
255 + }
256 +
257 + let mut coverage = vec![0u8; (col1 - col0) * (row1 - row0)];
258 + let mut crossings: Vec<(f32, i32)> = Vec::new();
259 + for row in row0..row1 {
260 + for sy in 0..SUB {
261 + let y = row as f32 + (sy as f32 + 0.5) * step;
262 + crossings.clear();
263 + for &(ax, ay, bx, by) in edges {
264 + if (ay <= y) == (by <= y) {
265 + continue;
266 + }
267 + let t = (y - ay) / (by - ay);
268 + crossings.push((ax + t * (bx - ax), if by > ay { 1 } else { -1 }));
269 + }
270 + if crossings.len() < 2 {
271 + continue;
272 + }
273 + crossings.sort_by(|a, b| a.0.total_cmp(&b.0));
274 + let mut winding = 0;
275 + for pair in crossings.windows(2) {
276 + winding += pair[0].1;
277 + if winding == 0 {
278 + continue;
279 + }
280 + let (span0, span1) = (pair[0].0, pair[1].0);
281 + for col in col0..col1 {
282 + for sx in 0..SUB {
283 + let x = col as f32 + (sx as f32 + 0.5) * step;
284 + if x >= span0 && x < span1 {
285 + coverage[(row - row0) * (col1 - col0) + (col - col0)] += 1;
286 + }
287 + }
288 + }
289 + }
290 + }
291 + }
292 + for row in row0..row1 {
293 + for col in col0..col1 {
294 + let hits = coverage[(row - row0) * (col1 - col0) + (col - col0)];
295 + if hits > 0 {
296 + let value = PAPER as f32 * (1.0 - f32::from(hits) / (SUB * SUB) as f32);
297 + sheet.darken(col, row, value.round() as u8);
298 + }
299 + }
300 + }
301 + }
302 +
303 + /// Flattens an outline into straight edges in device space, y down.
304 + #[derive(Default)]
305 + struct Flatten {
306 + scale: f32,
307 + origin: (f32, f32),
308 + edges: Vec<(f32, f32, f32, f32)>,
309 + start: (f32, f32),
310 + at: (f32, f32),
311 + }
312 +
313 + impl Flatten {
314 + fn map(&self, x: f32, y: f32) -> (f32, f32) {
315 + (
316 + self.origin.0 + x * self.scale,
317 + self.origin.1 - y * self.scale,
318 + )
319 + }
320 +
321 + fn edge(&mut self, to: (f32, f32)) {
322 + self.edges.push((self.at.0, self.at.1, to.0, to.1));
323 + self.at = to;
324 + }
325 +
326 + /// Curves are flattened by subdivision. The house marks are all
327 + /// straight-edged, but a base's own glyphs are not and the sheet sets them
328 + /// beside the marks on purpose.
329 + fn curve(&mut self, points: &[(f32, f32)]) {
330 + const STEPS: usize = 16;
331 + let from = self.at;
332 + for step in 1..=STEPS {
333 + let t = step as f32 / STEPS as f32;
334 + let mut work: Vec<(f32, f32)> = std::iter::once(from)
335 + .chain(points.iter().copied())
336 + .collect();
337 + while work.len() > 1 {
338 + for i in 0..work.len() - 1 {
339 + work[i] = (
340 + work[i].0 + (work[i + 1].0 - work[i].0) * t,
341 + work[i].1 + (work[i + 1].1 - work[i].1) * t,
342 + );
343 + }
344 + work.pop();
345 + }
346 + self.edge(work[0]);
347 + }
348 + }
349 +
350 + fn close_open(&mut self) {
351 + if self.at != self.start {
352 + let start = self.start;
353 + self.edge(start);
354 + }
355 + }
356 + }
357 +
358 + impl skrifa::outline::OutlinePen for Flatten {
359 + fn move_to(&mut self, x: f32, y: f32) {
360 + self.close_open();
361 + let to = self.map(x, y);
362 + self.start = to;
363 + self.at = to;
364 + }
365 +
366 + fn line_to(&mut self, x: f32, y: f32) {
367 + let to = self.map(x, y);
368 + self.edge(to);
369 + }
370 +
371 + fn quad_to(&mut self, cx: f32, cy: f32, x: f32, y: f32) {
372 + let c = self.map(cx, cy);
373 + let to = self.map(x, y);
374 + self.curve(&[c, to]);
375 + }
376 +
377 + fn curve_to(&mut self, cx0: f32, cy0: f32, cx1: f32, cy1: f32, x: f32, y: f32) {
378 + let c0 = self.map(cx0, cy0);
379 + let c1 = self.map(cx1, cy1);
380 + let to = self.map(x, y);
381 + self.curve(&[c0, c1, to]);
382 + }
383 +
384 + fn close(&mut self) {
385 + self.close_open();
386 + }
387 + }
388 +
389 + /// Encode a grayscale sheet as a PNG.
390 + ///
391 + /// Stored deflate blocks rather than a compressor: the file is a build artifact
392 + /// somebody looks at once, and a stored stream is a correct zlib stream that
393 + /// costs no dependency.
394 + pub fn png(sheet: &Sheet) -> Vec<u8> {
395 + let mut raw = Vec::with_capacity((sheet.width + 1) * sheet.height);
396 + for row in sheet.pixels.chunks(sheet.width) {
397 + raw.push(0); // filter: none
398 + raw.extend_from_slice(row);
399 + }
400 +
401 + let mut out = Vec::new();
402 + out.extend_from_slice(&[0x89, b'P', b'N', b'G', 0x0d, 0x0a, 0x1a, 0x0a]);
403 +
404 + let mut ihdr = Vec::new();
405 + ihdr.extend_from_slice(&(sheet.width as u32).to_be_bytes());
406 + ihdr.extend_from_slice(&(sheet.height as u32).to_be_bytes());
407 + ihdr.extend_from_slice(&[8, 0, 0, 0, 0]); // 8-bit grayscale, no interlace
408 + chunk(&mut out, *b"IHDR", &ihdr);
409 +
410 + let mut zlib = vec![0x78, 0x01];
411 + for (i, block) in raw.chunks(0xffff).enumerate() {
412 + let last = u8::from((i + 1) * 0xffff >= raw.len());
413 + zlib.push(last);
414 + zlib.extend_from_slice(&(block.len() as u16).to_le_bytes());
415 + zlib.extend_from_slice(&(!(block.len() as u16)).to_le_bytes());
416 + zlib.extend_from_slice(block);
417 + }
418 + zlib.extend_from_slice(&adler32(&raw).to_be_bytes());
419 + chunk(&mut out, *b"IDAT", &zlib);
420 + chunk(&mut out, *b"IEND", &[]);
421 + out
422 + }
423 +
424 + fn chunk(out: &mut Vec<u8>, kind: [u8; 4], data: &[u8]) {
425 + out.extend_from_slice(&(data.len() as u32).to_be_bytes());
426 + out.extend_from_slice(&kind);
427 + out.extend_from_slice(data);
428 + let mut crc = crc32(kind.as_slice());
429 + crc = crc32_continue(crc, data);
430 + out.extend_from_slice(&crc.to_be_bytes());
431 + }
432 +
433 + fn adler32(data: &[u8]) -> u32 {
434 + let (mut a, mut b) = (1u32, 0u32);
435 + for &byte in data {
436 + a = (a + u32::from(byte)) % 65521;
437 + b = (b + a) % 65521;
438 + }
439 + (b << 16) | a
440 + }
441 +
442 + /// The CRC of one run, which is the running form started from zero.
443 + fn crc32(data: &[u8]) -> u32 {
444 + crc32_continue(0, data)
445 + }
446 +
447 + fn crc32_continue(previous: u32, data: &[u8]) -> u32 {
448 + let mut crc = previous ^ 0xffff_ffff;
449 + for &byte in data {
450 + crc ^= u32::from(byte);
451 + for _ in 0..8 {
452 + crc = if crc & 1 == 1 {
453 + (crc >> 1) ^ 0xedb8_8320
454 + } else {
455 + crc >> 1
456 + };
457 + }
458 + }
459 + crc ^ 0xffff_ffff
460 + }
461 +
462 + #[cfg(test)]
463 + mod tests {
464 + use super::*;
465 +
466 + /// A hand-decoded PNG, because an encoder nobody reads back is an encoder
467 + /// that ships a file no viewer opens. Stored deflate blocks make the check
468 + /// as short as the encoder: header, then length-prefixed literal runs.
469 + fn decode(png: &[u8]) -> (usize, usize, Vec<u8>) {
470 + assert_eq!(
471 + &png[0..8],
472 + &[0x89, b'P', b'N', b'G', 0x0d, 0x0a, 0x1a, 0x0a]
473 + );
474 + let mut at = 8;
475 + let (mut width, mut height, mut raw) = (0usize, 0usize, Vec::new());
476 + while at < png.len() {
477 + let length = u32::from_be_bytes(png[at..at + 4].try_into().unwrap()) as usize;
478 + let kind = &png[at + 4..at + 8];
479 + let data = &png[at + 8..at + 8 + length];
480 + let stated =
481 + u32::from_be_bytes(png[at + 8 + length..at + 12 + length].try_into().unwrap());
482 + let mut crc = crc32(kind);
483 + crc = crc32_continue(crc, data);
484 + assert_eq!(
485 + crc,
486 + stated,
487 + "chunk {} has a bad crc",
488 + String::from_utf8_lossy(kind)
489 + );
490 + match kind {
491 + b"IHDR" => {
492 + width = u32::from_be_bytes(data[0..4].try_into().unwrap()) as usize;
493 + height = u32::from_be_bytes(data[4..8].try_into().unwrap()) as usize;
494 + assert_eq!(&data[8..], &[8, 0, 0, 0, 0]);
495 + }
496 + b"IDAT" => {
497 + assert_eq!(&data[0..2], &[0x78, 0x01]);
498 + let mut cursor = 2;
499 + loop {
500 + let last = data[cursor];
Lines truncated