Skip to main content

max / quasi-type

Grow the house set to cover box drawing and block elements Atkinson Hyperlegible Mono is 359 codepoints with 0/128 box drawing, 0/32 block elements and 0/7 of the house marks, measured here rather than taken on faith. That is not a defect: a face chosen for the quality of its text is chosen from someone who declined to ship UI symbols. But it means moving the mono slot onto it needs the set to cover the cell, and fallback cannot -- a terminal takes its baseline from the primary face alone, so borders arriving from a fallback face with unrelated cell metrics draw off the line. Broken borders, not tofu. GENERATED, NOT DRAWN, and that is the correct method rather than the cheap one. These have to tile: a vertical bar meets the bar in the cell above it exactly or a table border seams at every join. That is arithmetic on the cell, which a generator is good at and a hand is bad at. It is also the only method that answers the axis, since the recipes are evaluated at each gvar master. THE TABLE IS DERIVED FROM UNICODE'S OWN NAMES. "BOX DRAWINGS DOWN LIGHT AND RIGHT HEAVY" already says which arms a glyph has and how heavy each is; transcribing 160 of those by hand is 160 chances to transpose two arms in a way nothing catches until one corner looks wrong. scripts/derive-cell-table.py parses them all or fails, and the result is committed. The cell is hhea ascender to descender, not the symbol band. Worth knowing because the old base disagrees with itself here: Plex draws its box glyphs over -350..950 while its own hhea says -275..1025, so Plex's borders do not line up with a cell laid out from its metrics, which is why terminals so often stretch box glyphs. Ours needs no stretching. FOUR ARROWS TOO. The coverage floor is what Alloy's surfaces emit, and Atkinson has none of the four. One recipe, and its head is the same drawing as the sort caret beside it in a status line. `Purpose` replaces a rule that only worked for one base. An authored mark must be ours, so a base that draws it is an error somebody has to look at. A coverage entry only has to exist, and whether the base has one varies by base and nothing we decide -- Plex draws all 128 box glyphs and all four arrows, Atkinson draws none. Erroring would mean the same set worked against one base and not the other, and the fix would be a per-slot list mirroring each base's coverage, which rots the first time an upstream adds a glyph. RASTERISING THE BUILT FACE FOUND THREE THINGS THE TESTS DID NOT. The arcs curled the wrong way, because their parametrisation put both endpoints on the same line -- a bounding box cannot see that, so they are built from the tangent points now and stroked off a centre line. Dashes were drawn as two arms, so `┄` came out with six marks and a seam where a junction would be. And `↕`'s two heads met in the middle and read as a bowtie. Each is now a test.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-17 02:40 UTC
Signed with PGP, not checked
Commit: 3d080a66ed30550111006d871f6d221bfecb1717
Parent: 0829849
14 files changed, +1489 insertions, -45 deletions
M Cargo.lock +4 -4
@@ -526,6 +526,10 @@
526 526 "simd-adler32",
527 527 ]
528 528
529 + [[patch.unused]]
530 + name = "docengine"
531 + version = "0.7.0"
532 +
529 533 [[patch.unused]]
530 534 name = "quasi-axum"
531 535 version = "0.18.0"
@@ -558,10 +562,6 @@
558 562 name = "quasi-webview"
559 563 version = "0.18.0"
560 564
561 - [[patch.unused]]
562 - name = "docengine"
563 - version = "0.7.0"
564 -
565 565 [[patch.unused]]
566 566 name = "synckit-client"
567 567 version = "0.8.0"
M README.md +10 -5
@@ -9,9 +9,9 @@
9 9 ```
10 10
11 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
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
15 15 OFL.txt the base's licence, as OFL requires
16 16 ```
17 17
@@ -32,8 +32,13 @@
32 32
33 33 ## What is in the box
34 34
35 - - `glyphs/manifest.toml` — the house glyph set. Seven marks today. This is the
36 - durable artifact; the pipeline is what consumes it.
35 + - `glyphs/manifest.toml` — the house glyph set. Eleven authored marks, plus the
36 + two Unicode blocks of cell furniture it asks for by name. This is the durable
37 + artifact; the pipeline is what consumes it.
38 + - `src/cells/` — box drawing and block elements, generated rather than drawn.
39 + Their 160 recipes are already written down in Unicode's own character names,
40 + and they have to be cell-exact to tile, which is arithmetic. The table is
41 + derived from those names by `scripts/derive-cell-table.py` and committed.
37 42 - `bases/pins.toml` — the bases, pinned by version and sha256, and the slots cut
38 43 from them.
39 44 - `out/` — built faces. Not committed: they rebuild from the checkout, and a
@@ -162,3 +162,96 @@
162 162 head_span = { band = 0.20, weight = 1.35 }
163 163 head_depth = { band = 0.26, weight = 0.20 }
164 164 stroke = 1.0
165 +
166 + # ---------------------------------------------------------------------------
167 + # The arrows. Alloy's own surfaces emit these four (docs/FONTS.md, measured
168 + # against the built image), and a base can ship a full text face without one:
169 + # Plex Mono draws all four, Atkinson Hyperlegible Mono draws none.
170 + #
171 + # One recipe, four entries. The head is the same drawing as the sort caret two
172 + # sections up, which is the point of a house set: a triangle in a status line
173 + # and a triangle on an arrow should not be two people's triangles.
174 + # ---------------------------------------------------------------------------
175 +
176 + [[glyph]]
177 + codepoint = 0x2191
178 + name = "uni2191"
179 + role = "upwards arrow"
180 + source = "alloy docs/FONTS.md, the measured surface"
181 + purpose = "coverage"
182 + shape = "arrow"
183 + direction = "up"
184 + length = 0.92
185 + head_span = 0.42
186 + head_depth = 0.9
187 + stroke = 1.0
188 +
189 + [[glyph]]
190 + codepoint = 0x2193
191 + name = "uni2193"
192 + role = "downwards arrow"
193 + source = "alloy docs/FONTS.md, the measured surface"
194 + purpose = "coverage"
195 + shape = "arrow"
196 + direction = "down"
197 + length = 0.92
198 + head_span = 0.42
199 + head_depth = 0.9
200 + stroke = 1.0
201 +
202 + [[glyph]]
203 + codepoint = 0x2192
204 + name = "uni2192"
205 + role = "rightwards arrow"
206 + source = "alloy docs/FONTS.md, the measured surface"
207 + purpose = "coverage"
208 + shape = "arrow"
209 + direction = "right"
210 + length = 0.92
211 + head_span = 0.42
212 + head_depth = 0.9
213 + stroke = 1.0
214 +
215 + # Two heads on one shaft. The only member of the set that is not a single
216 + # gesture, and the reason `both_ends` exists rather than a second shape.
217 + #
218 + # Its head is smaller than the single-ended arrows above, and that is the
219 + # double end paying for itself rather than a taste call: at their proportions
220 + # two heads take 82% of the length and meet in the middle, so the mark reads as
221 + # a bowtie and not as an arrow with a shaft. Measured off the drawing, not
222 + # guessed — see `an_arrow_with_two_heads_still_has_a_shaft`.
223 + [[glyph]]
224 + codepoint = 0x2195
225 + name = "uni2195"
226 + role = "up-down arrow"
227 + source = "alloy docs/FONTS.md, the measured surface"
228 + purpose = "coverage"
229 + shape = "arrow"
230 + direction = "up"
231 + both_ends = true
232 + length = 0.98
233 + head_span = 0.34
234 + head_depth = 0.75
235 + stroke = 1.0
236 +
237 + # ---------------------------------------------------------------------------
238 + # The cell tier. Box drawing and block elements, generated rather than authored:
239 + # their 160 recipes are already written down in Unicode's own character names,
240 + # and they must be cell-exact to tile, which is arithmetic rather than drawing.
241 + # See `src/cells` for the geometry and `scripts/derive-cell-table.py` for how
242 + # the table is derived. Asked for by block so this file stays a record of what
243 + # somebody chose rather than 160 stanzas of transcription.
244 + #
245 + # They are here because a base can be chosen for the quality of its text and
246 + # ship neither block: Atkinson Hyperlegible Mono has 0/128 box drawing and 0/32
247 + # block elements, measured. Fallback cannot cover the gap, because a terminal
248 + # takes its baseline from the primary face alone, so borders arriving from a
249 + # fallback face with unrelated cell metrics draw off the line — broken borders
250 + # rather than tofu.
251 + # ---------------------------------------------------------------------------
252 +
253 + [[generated]]
254 + block = "box-drawing"
255 +
256 + [[generated]]
257 + block = "block-elements"
M src/base.rs +37
@@ -43,6 +43,20 @@
43 43 pub band_x1: i16,
44 44 pub band_y0: i16,
45 45 pub band_y1: i16,
46 + /// The cell's top, from `hhea`. Positive.
47 + ///
48 + /// The band is where the base fits its *symbols*; this is the box a
49 + /// terminal gives a character. Cell primitives — box drawing, block
50 + /// elements — are sized off this and never off the band, because their
51 + /// whole job is to meet the cell above and the cell beside them exactly.
52 + /// A vertical bar drawn to the band's height would leave a gap at every row
53 + /// boundary.
54 + ///
55 + /// `hhea` rather than OS/2, because `hhea` is what a terminal lays lines
56 + /// out with, and it is what `shop`'s shaper takes the baseline from.
57 + pub ascent: i16,
58 + /// The cell's bottom, from `hhea`. Negative, as the table stores it.
59 + pub descent: i16,
46 60 }
47 61
48 62 impl BaseParams {
@@ -67,6 +81,20 @@
67 81 pub fn x_height_center_y(&self) -> f64 {
68 82 f64::from(self.x_height) / 2.0
69 83 }
84 +
85 + /// The cell's height: ascender to descender.
86 + pub fn cell_height(&self) -> f64 {
87 + f64::from(self.ascent - self.descent)
88 + }
89 +
90 + /// Where a horizontal rule sits: the middle of the cell, not of the band.
91 + ///
92 + /// A rule on the band's centre would be near the x-height, so a table
93 + /// border would run through the middle of the text beside it rather than
94 + /// between the lines.
95 + pub fn cell_center_y(&self) -> f64 {
96 + f64::from(self.ascent + self.descent) / 2.0
97 + }
70 98 }
71 99
72 100 /// The reference glyphs a base must have before it can be refitted against.
@@ -80,6 +108,7 @@
80 108 pub fn measure(bytes: &[u8]) -> Result<BaseParams, Error> {
81 109 let font = FontRef::new(bytes).map_err(|e| Error::Font(format!("base is unreadable: {e}")))?;
82 110 let head = font.head().map_err(table_err("head"))?;
111 + let hhea = font.hhea().map_err(table_err("hhea"))?;
83 112 let os2 = font.os2().map_err(table_err("OS/2"))?;
84 113 let hmtx = font.hmtx().map_err(table_err("hmtx"))?;
85 114 let cmap = font.cmap().map_err(table_err("cmap"))?;
@@ -119,6 +148,8 @@
119 148 band_x1: plus.2,
120 149 band_y0: plus.1,
121 150 band_y1: plus.3,
151 + ascent: hhea.ascender().to_i16(),
152 + descent: hhea.descender().to_i16(),
122 153 })
123 154 }
124 155
@@ -297,6 +328,12 @@
297 328 band_x1: round_i16(plus.x1),
298 329 band_y0: round_i16(plus.y0),
299 330 band_y1: round_i16(plus.y1),
331 + // The cell does not vary with the axis, and must not: a face whose line
332 + // height moved with its weight would reflow a terminal on a bold
333 + // heading. Taken from the same metrics call as everything else so it
334 + // stays one read of the face.
335 + ascent: round_i16(metrics.ascent),
336 + descent: round_i16(metrics.descent),
300 337 })
301 338 }
302 339
M src/compose.rs +17 -5
@@ -19,7 +19,7 @@
19 19 use crate::Error;
20 20 use crate::base::{self, BaseParams, Variation};
21 21 use crate::draw;
22 - use crate::manifest::GlyphSpec;
22 + use crate::manifest::{GlyphSpec, Purpose};
23 23 use crate::pins::Base;
24 24 use crate::vary;
25 25
@@ -50,6 +50,10 @@
50 50 /// The axis the face carries, when its base had one. A cut keeps the axis
51 51 /// rather than instancing it away, so this is the base's `fvar` unchanged.
52 52 pub variation: Option<Variation>,
53 + /// Generated cell primitives the base already drew, so it kept its own.
54 + /// Reported rather than silent: "167 marks" and "167 marks, 160 of which
55 + /// the base already had" are very different builds.
56 + pub kept_by_base: Vec<u32>,
53 57 }
54 58
55 59 /// Tables the pipeline rebuilds. Everything else is copied verbatim.
@@ -105,10 +109,14 @@
105 109
106 110 let mut appended: Vec<(GlyphId, &GlyphSpec, Vec<u8>, Bounds)> = Vec::new();
107 111 let mut variations: Vec<Vec<u8>> = Vec::new();
108 - for (index, spec) in glyphs.iter().enumerate() {
112 + let mut skipped: Vec<u32> = Vec::new();
113 + for spec in glyphs {
109 114 if let Some(existing) = mappings.get(&spec.codepoint) {
110 - // Not a merge tool. A base that already draws a mark keeps its own,
111 - // and the set says so rather than quietly winning.
115 + // Which of the two this is decides the answer. See `Purpose`.
116 + if spec.purpose == Purpose::Coverage {
117 + skipped.push(spec.codepoint);
118 + continue;
119 + }
112 120 return Err(Error::AlreadyDrawn {
113 121 codepoint: spec.codepoint,
114 122 base: id.base.family.clone(),
@@ -137,7 +145,10 @@
137 145 }
138 146 let bytes = write_fonts::dump_table(&Glyph::Simple(simple))
139 147 .map_err(|e| Error::Draw(format!("{}: {e}", spec.name)))?;
140 - let gid = GlyphId::from(base_glyph_count + index as u16);
148 + // Counted off what has actually been appended rather than off the
149 + // loop index, which stopped being the same number the moment a glyph
150 + // could be skipped.
151 + let gid = GlyphId::from(base_glyph_count + appended.len() as u16);
141 152 mappings.insert(spec.codepoint, gid);
142 153 appended.push((gid, spec, bytes, bounds));
143 154 }
@@ -312,6 +323,7 @@
312 323 .collect(),
313 324 params,
314 325 variation,
326 + kept_by_base: skipped,
315 327 })
316 328 }
317 329
M src/draw.rs +161 -4
@@ -64,12 +64,32 @@
64 64
65 65 pub fn draw(shape: &Shape, params: &BaseParams) -> Drawing {
66 66 match shape {
67 + // The generated tier draws itself: it is sized off the cell rather than
68 + // the band, so it shares this module's output type and none of its
69 + // arithmetic.
70 + Shape::Cell(cell) => crate::cells::draw(*cell, params),
67 71 Shape::Triangle {
68 72 direction,
69 73 span,
70 74 depth,
71 75 anchor,
72 76 } => triangle(params, *direction, *span, *depth, *anchor),
77 + Shape::Arrow {
78 + direction,
79 + both_ends,
80 + length,
81 + head_span,
82 + head_depth,
83 + stroke,
84 + } => arrow(
85 + params,
86 + *direction,
87 + *both_ends,
88 + *length,
89 + *head_span,
90 + *head_depth,
91 + *stroke,
92 + ),
73 93 Shape::Cross {
74 94 width,
75 95 height,
@@ -103,6 +123,77 @@
103 123 /// `span` is measured across the base edge and `depth` from that edge to the
104 124 /// apex, both regardless of which way the mark points, so a left-pointing and
105 125 /// an up-pointing triangle of the same numbers are the same triangle rotated.
126 + /// A shaft with a solid head at one or both ends.
127 + ///
128 + /// Built along an axis and then mapped onto the cell, so `↑` and `→` are one
129 + /// recipe rather than four, and the head is the same drawing as the sort caret
130 + /// it sits beside in a status line.
131 + #[allow(clippy::too_many_arguments)]
132 + fn arrow(
133 + params: &BaseParams,
134 + direction: Direction,
135 + both_ends: bool,
136 + length: Dim,
137 + head_span: Dim,
138 + head_depth: f64,
139 + stroke: f64,
140 + ) -> Drawing {
141 + let vertical = matches!(direction, Direction::Up | Direction::Down);
142 + // Along the arrow, and across it: a vertical arrow's length comes off the
143 + // band's height and its head's span off the band's width, and the other way
144 + // round for a horizontal one.
145 + let (along, across) = if vertical {
146 + (params.band_height(), params.band_width())
147 + } else {
148 + (params.band_width(), params.band_height())
149 + };
150 + let len = length.resolve(along, stroke_of(params));
151 + let span = head_span.resolve(across, stroke_of(params));
152 + let depth = span * head_depth;
153 + let half_shaft = stroke_of(params) * stroke / 2.0;
154 +
155 + let cx = params.center_x();
156 + let cy = params.band_center_y();
157 + // In arrow space: `u` runs along the arrow towards its head, `v` across.
158 + let place = |u: f64, v: f64| -> (f64, f64) {
159 + match direction {
160 + Direction::Up => (cx + v, cy + u),
161 + Direction::Down => (cx + v, cy - u),
162 + Direction::Right => (cx + u, cy + v),
163 + Direction::Left => (cx - u, cy + v),
164 + }
165 + };
166 +
167 + let half = len / 2.0;
168 + // The shaft stops inside the head rather than at its base, so the two read
169 + // as one mark at a terminal's size instead of a bar touching a triangle.
170 + let overlap = depth / 2.0;
171 + let tail = if both_ends {
172 + -half + depth - overlap
173 + } else {
174 + -half
175 + };
176 + let mut contours = vec![vec![
177 + place(tail, -half_shaft),
178 + place(half - depth + overlap, -half_shaft),
179 + place(half - depth + overlap, half_shaft),
180 + place(tail, half_shaft),
181 + ]];
182 + contours.push(vec![
183 + place(half - depth, -span / 2.0),
184 + place(half, 0.0),
185 + place(half - depth, span / 2.0),
186 + ]);
187 + if both_ends {
188 + contours.push(vec![
189 + place(-half + depth, -span / 2.0),
190 + place(-half, 0.0),
191 + place(-half + depth, span / 2.0),
192 + ]);
193 + }
194 + Drawing { contours }
195 + }
196 +
106 197 fn triangle(
107 198 params: &BaseParams,
108 199 direction: Direction,
@@ -285,6 +376,8 @@
285 376 band_x1: 538,
286 377 band_y0: 62,
287 378 band_y1: 548,
379 + ascent: 1025,
380 + descent: -275,
288 381 }
289 382 }
290 383
@@ -320,11 +413,54 @@
320 413 glyphs.swap_remove(index).shape
321 414 }
322 415
416 + /// Two heads on one shaft have to leave a shaft between them.
417 + ///
418 + /// At the single-ended arrows' proportions they do not: two heads take 82%
419 + /// of the length, meet in the middle, and the mark reads as a bowtie. That
420 + /// is why `↕` carries its own smaller head rather than the set's, and this
421 + /// is the measurement behind it rather than the eye that caught it.
422 + #[test]
423 + fn an_arrow_with_two_heads_still_has_a_shaft() {
424 + let params = plex_regular();
425 + let drawing = draw(&shape("uni2195"), &params);
426 + let heads: Vec<&Vec<(f64, f64)>> =
427 + drawing.contours.iter().filter(|c| c.len() == 3).collect();
428 + assert_eq!(heads.len(), 2, "`↕` has a head at each end");
429 + let top = heads
430 + .iter()
431 + .map(|c| c.iter().map(|p| p.1).fold(f64::MIN, f64::max))
432 + .fold(f64::MIN, f64::max);
433 + let bottom = heads
434 + .iter()
435 + .map(|c| c.iter().map(|p| p.1).fold(f64::MAX, f64::min))
436 + .fold(f64::MAX, f64::min);
437 + let head_depth = heads
438 + .iter()
439 + .map(|c| {
440 + let ys: Vec<f64> = c.iter().map(|p| p.1).collect();
441 + ys.iter().copied().fold(f64::MIN, f64::max)
442 + - ys.iter().copied().fold(f64::MAX, f64::min)
443 + })
444 + .fold(f64::MIN, f64::max);
445 + let shaft = (top - bottom) - head_depth * 2.0;
446 + assert!(
447 + shaft > (top - bottom) * 0.25,
448 + "the heads leave {shaft:.0} units of shaft in {:.0} of arrow",
449 + top - bottom
450 + );
451 + }
452 +
323 453 #[test]
324 454 fn every_mark_fits_inside_the_cell() {
325 455 let manifest = Manifest::parse(crate::HOUSE_SET).unwrap();
326 456 for params in [plex_regular(), plex_bold()] {
327 - for glyph in &manifest.glyphs {
457 + // Band-relative marks only. Cell furniture is sized against the cell
458 + // instead and obeys different rules, which `crate::cells` asserts.
459 + for glyph in manifest
460 + .glyphs
461 + .iter()
462 + .filter(|glyph| !glyph.shape.is_cell_furniture())
463 + {
328 464 let drawing = draw(&glyph.shape, &params);
329 465 let (x0, _, x1, _) = bounds(&drawing);
330 466 assert!(
@@ -341,7 +477,13 @@
341 477 fn every_mark_sits_above_the_descender() {
342 478 let manifest = Manifest::parse(crate::HOUSE_SET).unwrap();
343 479 let params = plex_regular();
344 - for glyph in &manifest.glyphs {
480 + // Band-relative marks only. Cell furniture is sized against the cell
481 + // instead and obeys different rules, which `crate::cells` asserts.
482 + for glyph in manifest
483 + .glyphs
484 + .iter()
485 + .filter(|glyph| !glyph.shape.is_cell_furniture())
486 + {
345 487 let (_, y0, _, y1) = bounds(&draw(&glyph.shape, &params));
346 488 assert!(
347 489 y0 > -350.0,
@@ -373,7 +515,13 @@
373 515 #[test]
374 516 fn every_mark_answers_a_heavier_base() {
375 517 let manifest = Manifest::parse(crate::HOUSE_SET).unwrap();
376 - for glyph in &manifest.glyphs {
518 + // Band-relative marks only. Cell furniture is sized against the cell
519 + // instead and obeys different rules, which `crate::cells` asserts.
520 + for glyph in manifest
521 + .glyphs
522 + .iter()
523 + .filter(|glyph| !glyph.shape.is_cell_furniture())
524 + {
377 525 let light = draw(&glyph.shape, &plex_regular()).to_bezpath();
378 526 let bold = draw(&glyph.shape, &plex_bold()).to_bezpath();
379 527 assert_ne!(
@@ -389,7 +537,13 @@
389 537 #[test]
390 538 fn a_solid_mark_grows_and_a_stroked_one_holds_its_extent() {
391 539 let manifest = Manifest::parse(crate::HOUSE_SET).unwrap();
392 - for glyph in &manifest.glyphs {
540 + // Band-relative marks only. Cell furniture is sized against the cell
541 + // instead and obeys different rules, which `crate::cells` asserts.
542 + for glyph in manifest
543 + .glyphs
544 + .iter()
545 + .filter(|glyph| !glyph.shape.is_cell_furniture())
546 + {
393 547 let light = bounds(&draw(&glyph.shape, &plex_regular()));
394 548 let bold = bounds(&draw(&glyph.shape, &plex_bold()));
395 549 let grew = (bold.2 - bold.0) > (light.2 - light.0) + 0.5;
@@ -399,6 +553,9 @@
399 553 "{} has no stroke to thicken, so it has to grow",
400 554 glyph.name
401 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),
402 559 WeightResponse::Thickens => {
403 560 // The box and the arrow do carry a small extent term, the
404 561 // way the base grows `+`'s bars inside a fixed bbox; what
M src/lib.rs +1
@@ -17,6 +17,7 @@
17 17
18 18 pub mod assert;
19 19 pub mod base;
20 + pub mod cells;
20 21 pub mod compose;
21 22 pub mod draw;
22 23 pub mod manifest;
@@ -257,6 +257,14 @@
257 257 " band (+) x[{}, {}] y[{}, {}]",
258 258 p.band_x0, p.band_x1, p.band_y0, p.band_y1
259 259 );
260 + println!(
261 + " cell {} x {} (asc {}, desc {}, rule at {})",
262 + p.advance,
263 + p.cell_height(),
264 + p.ascent,
265 + p.descent,
266 + p.cell_center_y()
267 + );
260 268 }
261 269
262 270 fn write_file(path: &Path, bytes: &[u8]) -> Result<(), Error> {
M src/manifest.rs +120 -7
@@ -58,6 +58,19 @@
58 58 pub set: SetMeta,
59 59 #[serde(default, rename = "glyph")]
60 60 pub glyphs: Vec<GlyphSpec>,
61 + /// Whole Unicode blocks taken as generated cell primitives.
62 + ///
63 + /// A block is asked for by name rather than written out, because its 128
64 + /// recipes are already written down in Unicode's own character names and
65 + /// transcribing them here would be 128 chances to transpose two arms. See
66 + /// [`crate::cells`].
67 + #[serde(default, rename = "generated")]
68 + pub generated: Vec<Generated>,
69 + }
70 +
71 + #[derive(Debug, Deserialize)]
72 + pub struct Generated {
73 + pub block: crate::cells::Block,
61 74 }
62 75
63 76 #[derive(Debug, Deserialize)]
@@ -77,10 +90,44 @@
77 90 /// Where the requirement came from, when there is a decision behind it.
78 91 #[serde(default)]
79 92 pub source: Option<String>,
93 + /// Why the set carries this entry, which decides what happens when the
94 + /// base turns out to draw it too.
95 + #[serde(default)]
96 + pub purpose: Purpose,
80 97 #[serde(flatten)]
81 98 pub shape: Shape,
82 99 }
83 100
101 + /// Why an entry is in the set.
102 + ///
103 + /// The set holds two kinds of thing and they want opposite answers to one
104 + /// question: what should happen when the base already draws this codepoint?
105 + ///
106 + /// A **house** mark is here because the drawing has to be the same in a
107 + /// terminal, a webview and an egui panel. A base that turns out to draw it is a
108 + /// fact somebody has to look at, because our drawing is about to be dropped in
109 + /// favour of one nobody compared it to. So it is an error, and the run says
110 + /// which glyph and which base.
111 + ///
112 + /// A **coverage** entry is here because a consumer needs the codepoint to
113 + /// exist, and any competent drawing of it will do. Whether the base has one
114 + /// varies by base and not by anything we decide: Plex Mono draws all 128
115 + /// box-drawing glyphs and all four arrows, Atkinson Hyperlegible Mono draws
116 + /// none of them. Erroring would mean the same set worked against one base and
117 + /// not the other, and the fix would be a per-slot list of codepoints mirroring
118 + /// each base's coverage — bookkeeping that rots the first time an upstream adds
119 + /// a glyph. So the base keeps its own, which is also the better drawing, and
120 + /// the build reports how many it kept.
121 + #[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize)]
122 + #[serde(rename_all = "kebab-case")]
123 + pub enum Purpose {
124 + /// The house drawing, and it must be ours.
125 + #[default]
126 + House,
127 + /// The codepoint has to exist; the base's own drawing is welcome to it.
128 + Coverage,
129 + }
130 +
84 131 /// A drawing recipe. Every dimension is a ratio of something measured off the
85 132 /// base face, so a shape is refitted to the next base rather than redrawn.
86 133 #[derive(Debug, Deserialize)]
@@ -113,6 +160,35 @@
113 160 /// Stroke as a multiple of the base's own, since the mark is a stroke.
114 161 stroke: f64,
115 162 },
163 + /// A shaft with a solid head, and optionally a second head at the other
164 + /// end: the arrow set Alloy's own configs emit.
165 + ///
166 + /// A stroked mark rather than a solid one, so it holds its extent and
167 + /// thickens, the way the base does `+`. The base's own `↑` grows about 15%
168 + /// across its weights as well; the set does not follow it there, because an
169 + /// arrow whose shaft thickens and whose length also grew would be two
170 + /// responses at once and neither one legible as the rule.
171 + Arrow {
172 + direction: Direction,
173 + /// A head at the tail as well, for `↕`.
174 + #[serde(default)]
175 + both_ends: bool,
176 + /// Overall length along the arrow's own axis.
177 + length: Dim,
178 + /// The head's extent across the shaft.
179 + head_span: Dim,
180 + /// The head's depth along the shaft, as a ratio of `head_span`.
181 + head_depth: f64,
182 + /// Shaft thickness, as a multiple of the base's own bar.
183 + stroke: f64,
184 + },
185 + /// A generated cell primitive: box drawing or a block element.
186 + ///
187 + /// Not written in the manifest and not deserializable from it. It arrives
188 + /// through a `[[generated]]` block, which is what keeps 160 recipes out of
189 + /// a file whose job is to hold the ones somebody chose.
190 + #[serde(skip)]
191 + Cell(crate::cells::Cell),
116 192 /// A left-pointing arrow with a riser at its right end: U+23CE.
117 193 ReturnArrow {
118 194 /// The riser's flat cap, as a ratio of the band's height.
@@ -131,6 +207,8 @@
131 207 impl Shape {
132 208 pub fn kind(&self) -> &'static str {
133 209 match self {
210 + Shape::Arrow { .. } => "arrow",
211 + Shape::Cell(cell) => cell.kind(),
134 212 Shape::Triangle { .. } => "triangle",
135 213 Shape::Cross { .. } => "cross",
136 214 Shape::OpenBox { .. } => "open-box",
@@ -138,6 +216,18 @@
138 216 }
139 217 }
140 218
219 + /// Whether this is cell furniture rather than a band-relative mark.
220 + ///
221 + /// The two tiers are sized against different things and obey different
222 + /// rules — a vertical bar runs to the descender and triples in width with
223 + /// the base's stem, and both would be defects in an authored mark. So the
224 + /// assertions that hold for the manifest's own marks are scoped with this
225 + /// rather than loosened to accommodate furniture. See [`crate::cells`],
226 + /// which carries the cell tier's own, stricter set.
227 + pub fn is_cell_furniture(&self) -> bool {
228 + matches!(self, Shape::Cell(_))
229 + }
230 +
141 231 /// How a mark answers a heavier base.
142 232 ///
143 233 /// Everything in the set answers; the two kinds differ in what moves. A
@@ -146,10 +236,12 @@
146 236 /// is what the base does to `+`: same bbox in both weights, heavier bars.
147 237 pub fn weight_response(&self) -> WeightResponse {
148 238 match self {
239 + Shape::Cell(cell) => cell.weight_response(),
149 240 Shape::Triangle { .. } => WeightResponse::Grows,
150 - Shape::Cross { .. } | Shape::OpenBox { .. } | Shape::ReturnArrow { .. } => {
151 - WeightResponse::Thickens
152 - }
241 + Shape::Arrow { .. }
242 + | Shape::Cross { .. }
243 + | Shape::OpenBox { .. }
244 + | Shape::ReturnArrow { .. } => WeightResponse::Thickens,
153 245 }
154 246 }
155 247 }
@@ -161,6 +253,13 @@
161 253 Grows,
162 254 /// The mark holds its extent and its stroke thickens.
163 255 Thickens,
256 + /// The mark does not answer weight at all.
257 + ///
258 + /// Only the cell fills. `█` is the whole cell and `▄` is exactly half of
259 + /// it at every weight, which is what the base does with its own: they are
260 + /// cell-fill primitives rather than symbols, and this is the observation
261 + /// the set's weight term was first, wrongly, generalised from.
262 + Holds,
164 263 }
165 264
166 265 #[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
@@ -184,8 +283,12 @@
184 283
185 284 impl Manifest {
186 285 pub fn parse(source: &str) -> Result<Self, Error> {
187 - let manifest: Manifest =
286 + let mut manifest: Manifest =
188 287 toml::from_str(source).map_err(|e| Error::Manifest(e.to_string()))?;
288 + for generated in &manifest.generated {
289 + manifest.glyphs.extend(crate::cells::specs(generated.block));
290 + }
291 + manifest.glyphs.sort_by_key(|glyph| glyph.codepoint);
189 292 let mut seen: Vec<u32> = Vec::new();
190 293 for glyph in &manifest.glyphs {
191 294 if char::from_u32(glyph.codepoint).is_none() {
@@ -217,10 +320,16 @@
217 320 #[test]
218 321 fn the_shipped_set_parses() {
219 322 let manifest = Manifest::parse(crate::HOUSE_SET).expect("the shipped set parses");
323 + let authored = manifest
324 + .glyphs
325 + .iter()
326 + .filter(|glyph| !glyph.shape.is_cell_furniture())
327 + .count();
328 + assert_eq!(authored, 11, "seven marks and four arrows");
220 329 assert_eq!(
221 330 manifest.glyphs.len(),
222 - 7,
223 - "the house set is seven marks today"
331 + 171,
332 + "plus both generated blocks, 160 of them"
224 333 );
225 334 }
226 335
@@ -254,7 +363,11 @@
254 363 #[test]
255 364 fn solid_marks_grow_and_stroked_marks_thicken() {
256 365 let manifest = Manifest::parse(crate::HOUSE_SET).unwrap();
257 - for glyph in &manifest.glyphs {
366 + for glyph in manifest
367 + .glyphs
368 + .iter()
369 + .filter(|glyph| !glyph.shape.is_cell_furniture())
370 + {
258 371 let expected = match glyph.shape.kind() {
259 372 "triangle" => WeightResponse::Grows,
260 373 _ => WeightResponse::Thickens,
@@ -39,7 +39,9 @@
39 39 }
40 40 }
41 41
42 - fn cut(style: &str) -> Option<(Vec<u8>, Vec<GlyphSpec>)> {
42 + /// The cut face, the set that went in, and the codepoints the base already
43 + /// drew so kept its own. See `manifest::Purpose`.
44 + fn cut_full(style: &str) -> Option<(Vec<u8>, Vec<GlyphSpec>, Vec<u32>)> {
43 45 let faces = base_faces()?;
44 46 let face = faces.into_iter().find(|f| f.style == style)?;
45 47 let pins = Pins::parse(PINS).unwrap();
@@ -56,7 +58,11 @@
56 58 base,
57 59 };
58 60 let built = compose::build(&face.bytes, &refs, &id).expect("the face cuts");
59 - Some((built.bytes, specs))
61 + Some((built.bytes, specs, built.kept_by_base))
62 + }
63 +
64 + fn cut(style: &str) -> Option<(Vec<u8>, Vec<GlyphSpec>)> {
65 + cut_full(style).map(|(bytes, specs, _)| (bytes, specs))
60 66 }
61 67
62 68 #[test]
@@ -76,8 +82,16 @@
76 82 .map(|c| format!("U+{c:04X}"))
77 83 .collect::<Vec<_>>()
78 84 );
79 - // The done condition names the number: Alloy's eighteen plus the two carets.
80 - assert_eq!(result.required.len(), 20);
85 + // Alloy's eighteen, the two carets, and the whole house set, deduplicated.
86 + // The number moved when the set grew a cell tier; what it is checking is
87 + // that the floor is the union of the two and not one of them.
88 + assert_eq!(result.required.len(), 176);
89 + assert!(
90 + coverage::ALLOY_SURFACE
91 + .iter()
92 + .all(|cp| result.required.contains(cp)),
93 + "the consumer floor is not a subset of what is required"
94 + );
81 95 }
82 96
83 97 #[test]
@@ -94,7 +108,15 @@
94 108 "U+{codepoint:04X} moved or was dropped"
95 109 );
96 110 }
97 - assert_eq!(after.len(), before.len() + specs.len());
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.
114 + let added = specs
115 + .iter()
116 + .filter(|spec| !before.contains_key(&spec.codepoint))
117 + .count();
118 + assert_eq!(after.len(), before.len() + added);
119 + assert_eq!(added, 7, "Plex has everything but the seven house marks");
98 120 }
99 121
100 122 #[test]
@@ -239,7 +261,24 @@
239 261 return;
240 262 };
241 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();
242 273 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 + }
243 282 assert_ne!(
244 283 outline(&regular, spec.codepoint),
245 284 outline(&bold, spec.codepoint),
@@ -10,6 +10,7 @@
10 10
11 11 use quasi_type::base::{self, BaseFace};
12 12 use quasi_type::compose::{self, Identity};
13 + use quasi_type::manifest::WeightResponse;
13 14 use quasi_type::manifest::{GlyphSpec, Manifest};
14 15 use quasi_type::pins::{Base, Pins};
15 16 use quasi_type::{HOUSE_SET, PINS, assert as coverage, woff2};
@@ -92,10 +93,16 @@
92 93 "the base's seven named weights"
93 94 );
94 95
95 - // Every mark has variation data, and it is data the base did not have.
96 + // Every mark that answers weight has variation data, and it is data the
97 + // base did not have. A cell fill is exactly half the cell at every weight,
98 + // so it correctly carries none: `gvar` says "does not vary" with an empty
99 + // entry, and that is the honest record rather than a gap.
96 100 let mappings = compose::coverage(&bytes).unwrap();
97 101 let gvar = font.gvar().unwrap();
98 102 for spec in &specs {
103 + if spec.shape.weight_response() == WeightResponse::Holds {
104 + continue;
105 + }
99 106 let gid = mappings[&spec.codepoint];
100 107 let data = gvar
101 108 .glyph_variation_data(gid)
@@ -117,6 +124,12 @@
117 124 fn a_mark_at_the_heavy_end_is_heavier_than_at_the_light_end() {
118 125 let Some((bytes, specs)) = cut() else { return };
119 126 for spec in &specs {
127 + // Except the cell fills, which are the one thing in the set that
128 + // deliberately does not answer weight: `▀` is exactly half the cell at
129 + // wght 200 and at 800, and a half that grew would stop tiling.
130 + if spec.shape.weight_response() == WeightResponse::Holds {
131 + continue;
132 + }
120 133 let light = ink(&bytes, spec.codepoint, 200.0);
121 134 let heavy = ink(&bytes, spec.codepoint, 800.0);
122 135 assert!(
@@ -144,9 +157,26 @@
144 157
145 158 let Some((bytes, specs)) = cut() else { return };
146 159 for spec in &specs {
160 + // Cell furniture is measured by the test below instead. A bar's
161 + // bounding box *is* the bar, so it inks all of it at every weight, and
162 + // a fill fraction says nothing about whether it thickened.
163 + if spec.shape.is_cell_furniture() {
164 + continue;
165 + }
147 166 let light = fill(&bytes, spec.codepoint, 200.0);
148 167 let heavy = fill(&bytes, spec.codepoint, 800.0);
149 168 match spec.shape.weight_response() {
169 + // Cell fills are exact fractions of the cell at every weight, so
170 + // they neither grow nor thicken. Nothing to assert but that.
171 + WeightResponse::Holds => {
172 + assert!(
173 + (width(&bytes, spec.codepoint, 800.0) - width(&bytes, spec.codepoint, 200.0))
174 + .abs()
175 + < 1.0,
176 + "{} is a cell fill and must not move with the axis",
177 + spec.name
178 + );
179 + }
150 180 WeightResponse::Grows => {
151 181 assert!(
152 182 (heavy - light).abs() < 0.01,
@@ -173,6 +203,50 @@
173 203 }
174 204 }
175 205
206 + /// The done condition of the variable work, in the words it was written in: a
207 + /// bold table border draws at the bold stroke weight.
208 + ///
209 + /// Measured on the bar itself rather than on a fill fraction. A box-drawing
210 + /// stem is a rectangle, so its bounding box is the bar and it inks all of it at
211 + /// every weight; what has to move is the bar's own thickness, and it has to
212 + /// move by what the base moved — `-` goes 55 units to 139 across this axis.
213 + #[test]
214 + fn a_bold_border_draws_at_the_bold_stroke_weight() {
215 + let Some((bytes, _)) = cut() else { return };
216 + // A horizontal rule's thickness is its height; a vertical's is its width.
217 + let thin = height(&bytes, 0x2500, 200.0);
218 + let thick = height(&bytes, 0x2500, 800.0);
219 + let ratio = thick / thin;
220 + let base_ratio = 139.0 / 55.0;
221 + assert!(
222 + (ratio - base_ratio).abs() < 0.15,
223 + "the light horizontal goes {thin:.0} to {thick:.0} ({ratio:.2}x) while the base's \
224 + own bar goes 55 to 139 ({base_ratio:.2}x)"
225 + );
226 + let thin = width(&bytes, 0x2502, 200.0);
227 + let thick = width(&bytes, 0x2502, 800.0);
228 + assert!(
229 + thick > thin * 2.0,
230 + "the light vertical goes {thin:.0} to {thick:.0}, and the base's stem goes 54 to 158"
231 + );
232 + // And heavy stays twice light at both ends of the axis, or the two weights
233 + // of border stop being distinguishable at one end.
234 + for wght in [200.0, 800.0] {
235 + let light = height(&bytes, 0x2500, wght);
236 + let heavy = height(&bytes, 0x2501, wght);
237 + assert!(
238 + (heavy / light - 2.0).abs() < 0.1,
239 + "at wght {wght} heavy is {:.2}x light",
240 + heavy / light
241 + );
242 + }
243 + // A cell fill holds: `█` is the whole cell at every weight.
244 + assert!(
245 + (height(&bytes, 0x2588, 800.0) - height(&bytes, 0x2588, 200.0)).abs() < 1.0,
246 + "the full block moved with the axis"
247 + );
248 + }
249 +
176 250 /// The ExtraLight trap, as a gate rather than as a warning in a comment.
177 251 ///
178 252 /// Keeping the axis means keeping the base's default instance, and this base's
@@ -225,30 +299,36 @@
225 299 assert!(names(10).contains("Atkinson Hyperlegible Mono 2.001"));
226 300 }
227 301
228 - /// What the box-drawing task is for, stated as a measurement rather than as a
229 - /// note. Atkinson ships no box drawing and no block elements, so a face cut from
230 - /// it today fails the floor every consumer is entitled to assume — which is why
231 - /// `aab673c1` waits on `872fd945` rather than pointing the slot at this base.
302 + /// The done condition of `872fd945`, stated as a measurement rather than as a
303 + /// note. Atkinson ships no box drawing, no block elements and none of the four
304 + /// arrows; a face cut from it now covers the whole floor a consumer is entitled
305 + /// to assume, which is what unblocks pointing the mono slot at this base.
306 + ///
307 + /// This test used to assert the opposite — that the cut was *not* yet fit — and
308 + /// carried a message saying it would go stale when the gap closed. It has.
232 309 #[test]
233 - fn the_cut_face_is_not_yet_fit_for_the_mono_slot() {
310 + fn the_cut_face_is_fit_for_the_mono_slot() {
234 311 let Some((bytes, specs)) = cut() else { return };
235 312 let house: Vec<u32> = specs.iter().map(|g| g.codepoint).collect();
236 313 let result = coverage::check(&compose::coverage(&bytes).unwrap(), &house);
237 314 assert!(
238 - !result.ok(),
239 - "the base grew box drawing; this test and its blocker are stale"
315 + result.ok(),
316 + "missing {:?}",
317 + result
318 + .missing
319 + .iter()
320 + .map(|c| format!("U+{c:04X}"))
321 + .collect::<Vec<_>>()
240 322 );
323 + // The four the base has none of, one from each tier that had to be built:
324 + // a box-drawing stem, a fill, an eighth, and a shade.
241 325 for cell_furniture in [0x2502, 0x2588, 0x258F, 0x2591] {
242 326 assert!(
243 - result.missing.contains(&cell_furniture),
244 - "U+{cell_furniture:04X} is covered, so the gap has moved"
327 + house.contains(&cell_furniture),
328 + "U+{cell_furniture:04X} is not in the set, so the base must have it \
329 + and Atkinson does not"
245 330 );
246 331 }
247 - // The seven marks are drawn, so the gap is the base's cell furniture and
248 - // nothing the pipeline was asked to draw.
249 - for mark in &house {
250 - assert!(!result.missing.contains(mark));
251 - }
252 332 }
253 333
254 334 #[test]
@@ -307,6 +387,10 @@
307 387 outline(bytes, codepoint, wght).0
308 388 }
309 389
390 + fn height(bytes: &[u8], codepoint: u32, wght: f32) -> f32 {
391 + outline(bytes, codepoint, wght).1
392 + }
393 +
310 394 fn ink(bytes: &[u8], codepoint: u32, wght: f32) -> f32 {
311 395 outline(bytes, codepoint, wght).2
312 396 }
@@ -1,0 +1,209 @@
1 + #!/usr/bin/env python3
2 + """Derive src/cells/table.rs from Unicode's own character names.
3 +
4 + The Box Drawing and Block Elements blocks are named systematically, so the 160
5 + recipes are already written down: BOX DRAWINGS DOWN LIGHT AND RIGHT HEAVY says
6 + which arms a glyph has and how heavy each one is, and LEFT THREE EIGHTHS BLOCK
7 + says which fraction of the cell is filled. Typing that out by hand would be 160
8 + chances to transpose two arms in a way nothing catches until a border looks
9 + wrong in one corner.
10 +
11 + So the table is derived rather than authored, from the `unicodedata` module in
12 + the Python standard library, and the result is committed. Re-run it when the
13 + shape vocabulary changes:
14 +
15 + python3 scripts/derive-cell-table.py > src/cells/table.rs
16 +
17 + Every name must parse. The script fails rather than emitting a partial table,
18 + because a name it did not understand is a glyph that would silently come out
19 + blank.
20 + """
21 +
22 + import sys
23 + import unicodedata as ud
24 +
25 + WEIGHT = {"LIGHT": "L", "HEAVY": "H", "DOUBLE": "D", "SINGLE": "L"}
26 + DIRS = {
27 + "UP": ["up"],
28 + "DOWN": ["down"],
29 + "LEFT": ["left"],
30 + "RIGHT": ["right"],
31 + "HORIZONTAL": ["left", "right"],
32 + "VERTICAL": ["up", "down"],
33 + }
34 + ARMS = ["left", "right", "up", "down"]
35 + FRACTION = {
36 + "ONE": 1, "TWO": 2, "THREE": 3, "FOUR": 4,
37 + "FIVE": 5, "SIX": 6, "SEVEN": 7, "EIGHT": 8,
38 + }
39 +
40 +
41 + def die(msg):
42 + print(f"derive-cell-table: {msg}", file=sys.stderr)
43 + sys.exit(1)
44 +
45 +
46 + def parse_box(cp):
47 + """(kind, dash, arms) for a Box Drawing codepoint."""
48 + name = ud.name(chr(cp))
49 + body = name.replace("BOX DRAWINGS ", "")
50 +
51 + if "DIAGONAL" in body:
52 + if "UPPER RIGHT TO LOWER LEFT" in body:
53 + return ("diagonal", "Rising", {})
54 + if "UPPER LEFT TO LOWER RIGHT" in body:
55 + return ("diagonal", "Falling", {})
56 + if "CROSS" in body:
57 + return ("diagonal", "Cross", {})
58 + die(f"U+{cp:04X} {name}: unrecognised diagonal")
59 +
60 + kind = "stems"
61 + if "ARC " in body:
62 + kind = "arc"
63 + body = body.replace("ARC ", "")
64 +
65 + dash = "None"
66 + for spelling, variant in [
67 + ("QUADRUPLE DASH", "Quadruple"),
68 + ("TRIPLE DASH", "Triple"),
69 + ("DOUBLE DASH", "Double"),
70 + ]:
71 + if spelling in body:
72 + dash = variant
73 + body = body.replace(spelling + " ", "")
74 +
75 + arms = {}
76 + for clause in body.split(" AND "):
77 + tokens = clause.split()
78 + weights = [t for t in tokens if t in WEIGHT]
79 + directions = [t for t in tokens if t in DIRS]
80 + leftover = [t for t in tokens if t not in WEIGHT and t not in DIRS]
81 + if leftover or not directions:
82 + die(f"U+{cp:04X} {name}: cannot read clause {clause!r}")
83 + weight = WEIGHT[weights[0]] if weights else None
84 + for direction in directions:
85 + for arm in DIRS[direction]:
86 + arms[arm] = weight
87 +
88 + # A leading weight applies to every clause that named none of its own:
89 + # "LIGHT DOWN AND RIGHT" weighs both arms, "DOWN LIGHT AND RIGHT HEAVY"
90 + # weighs them separately.
91 + stated = [w for w in arms.values() if w]
92 + if not stated:
93 + die(f"U+{cp:04X} {name}: no weight anywhere")
94 + for arm, weight in arms.items():
95 + if weight is None:
96 + arms[arm] = stated[0]
97 + return (kind, dash, arms)
98 +
99 +
100 + def parse_block(cp):
101 + """(kind, payload) for a Block Elements codepoint."""
102 + name = ud.name(chr(cp))
103 + if name == "FULL BLOCK":
104 + return ("fill", [(0, 0, 8, 8)])
105 + if name.endswith("SHADE"):
106 + return ("shade", name.split()[0].capitalize())
107 + if name.startswith("QUADRANT"):
108 + quads = {
109 + "UPPER LEFT": (0, 4, 4, 8),
110 + "UPPER RIGHT": (4, 4, 8, 8),
111 + "LOWER LEFT": (0, 0, 4, 4),
112 + "LOWER RIGHT": (4, 0, 8, 4),
113 + }
114 + rects = []
115 + for part in name[len("QUADRANT "):].split(" AND "):
116 + if part not in quads:
117 + die(f"U+{cp:04X} {name}: unknown quadrant {part!r}")
118 + rects.append(quads[part])
119 + return ("fill", rects)
120 +
121 + tokens = name.split()
122 + side, count, unit = tokens[0], tokens[1], tokens[2]
123 + if unit.startswith("QUARTER"):
124 + eighths = FRACTION[count] * 2
125 + elif unit.startswith("EIGHTH"):
126 + eighths = FRACTION[count]
127 + elif count == "HALF":
128 + eighths = 4
129 + unit = "HALF"
130 + else:
131 + die(f"U+{cp:04X} {name}: unknown fraction {count} {unit}")
132 + if count == "HALF":
133 + eighths = 4
134 +
135 + if side == "UPPER":
136 + return ("fill", [(0, 8 - eighths, 8, 8)])
137 + if side == "LOWER":
138 + return ("fill", [(0, 0, 8, eighths)])
139 + if side == "LEFT":
140 + return ("fill", [(0, 0, eighths, 8)])
141 + if side == "RIGHT":
142 + return ("fill", [(8 - eighths, 0, 8, 8)])
143 + die(f"U+{cp:04X} {name}: unknown side {side!r}")
144 +
145 +
146 + def main():
147 + stems, arcs, diagonals, fills, shades = [], [], [], [], []
148 +
149 + for cp in range(0x2500, 0x2580):
150 + kind, payload, arms = parse_box(cp)
151 + if kind == "diagonal":
152 + diagonals.append((cp, payload))
153 + continue
154 + row = (cp, [arms.get(a, "N") for a in ARMS], payload)
155 + (arcs if kind == "arc" else stems).append(row)
156 +
157 + for cp in range(0x2580, 0x25A0):
158 + kind, payload = parse_block(cp)
159 + (shades if kind == "shade" else fills).append((cp, payload))
160 +
161 + counts = (len(stems), len(arcs), len(diagonals), len(fills), len(shades))
162 + if sum(counts) != 160:
163 + die(f"expected 160 codepoints, derived {sum(counts)}")
164 +
165 + out = []
166 + w = out.append
167 + w("// Generated by scripts/derive-cell-table.py from Unicode character names.")
168 + w("// Do not edit by hand: re-run the script instead. See its header for why")
169 + w("// the table is derived rather than authored.")
170 + w("//")
171 + w(f"// {counts[0]} stems, {counts[1]} arcs, {counts[2]} diagonals,"
172 + f" {counts[3]} fills, {counts[4]} shades.")
173 + w("")
174 + w("use super::Arm::{D, H, L, N};")
175 + w("use super::{Dash, Diagonal, Shade};")
176 + w("")
177 + w("/// Arms in the order left, right, up, down.")
178 + w(f"pub(super) const STEMS: [(u32, [super::Arm; 4], Dash); {counts[0]}] = [")
179 + for cp, arms, dash in stems:
180 + w(f" (0x{cp:04X}, [{', '.join(arms)}], Dash::{dash}),")
181 + w("];")
182 + w("")
183 + w("/// The rounded corners. Same arms as a stem, drawn as a quarter turn.")
184 + w(f"pub(super) const ARCS: [(u32, [super::Arm; 4]); {counts[1]}] = [")
185 + for cp, arms, _ in arcs:
186 + w(f" (0x{cp:04X}, [{', '.join(arms)}]),")
187 + w("];")
188 + w("")
189 + w(f"pub(super) const DIAGONALS: [(u32, Diagonal); {counts[2]}] = [")
190 + for cp, kind in diagonals:
191 + w(f" (0x{cp:04X}, Diagonal::{kind}),")
192 + w("];")
193 + w("")
194 + w("/// Filled rectangles in eighths of the cell: x0, y0, x1, y1, with y up.")
195 + w(f"pub(super) const FILLS: [(u32, &[(u8, u8, u8, u8)]); {counts[3]}] = [")
196 + for cp, rects in fills:
197 + body = ", ".join(f"({a}, {b}, {c}, {d})" for a, b, c, d in rects)
198 + w(f" (0x{cp:04X}, &[{body}]),")
199 + w("];")
200 + w("")
201 + w(f"pub(super) const SHADES: [(u32, Shade); {counts[4]}] = [")
202 + for cp, level in shades:
203 + w(f" (0x{cp:04X}, Shade::{level}),")
204 + w("];")
205 + print("\n".join(out))
206 +
207 +
208 + if __name__ == "__main__":
209 + main()
@@ -1,0 +1,940 @@
1 + //! Cell primitives: box drawing and block elements.
2 + //!
3 + //! The second tier of the house set, and a different kind of thing from the
4 + //! first. The marks in `glyphs/manifest.toml` are *symbols*: they sit in the
5 + //! band the base fits its own symbols into, they are sized against it, and each
6 + //! one is authored. These are *cell furniture*: they fill the box a terminal
7 + //! gives a character, they are sized against that box and never against the
8 + //! band, and there are 160 of them.
9 + //!
10 + //! ## Why they are generated rather than drawn
11 + //!
12 + //! Because they must tile. A vertical bar has to meet the bar in the cell above
13 + //! it exactly, and a horizontal one has to meet the cell beside it, or a table
14 + //! border shows a seam at every join. That is arithmetic on the cell, and
15 + //! arithmetic is what a generator is good at and what a hand is bad at.
16 + //! Generating is the correct method here rather than the cheap one.
17 + //!
18 + //! It is also the only method that answers the axis. The recipes are evaluated
19 + //! at each `gvar` master, so a bold border draws at the bold stroke weight
20 + //! without a second drawing existing anywhere.
21 + //!
22 + //! ## The cell
23 + //!
24 + //! `hhea` ascender to descender, and the full advance. Not the band, which is
25 + //! near the x-height: a rule on the band's centre would run through the middle
26 + //! of the text beside it rather than between the lines.
27 + //!
28 + //! Worth knowing, because it differs from what the old base did. Plex Mono
29 + //! draws its box glyphs over y -350..950 while its own `hhea` says -275..1025 —
30 + //! the same 1300-unit height, positioned 75 units lower. So Plex's borders do
31 + //! not line up with a cell laid out from its own metrics, which is why
32 + //! terminals so often stretch box-drawing glyphs to the cell. Ours is built on
33 + //! the metrics a terminal actually lays out with, so it needs no stretching.
34 + //!
35 + //! ## The stroke vocabulary
36 + //!
37 + //! One light bar is the base's own: `-`'s height for horizontals, `|`'s width
38 + //! for verticals, so a border sits at the weight of the text it surrounds.
39 + //! Heavy is twice that. Double is two light rails with a light gap, which is a
40 + //! total of three light bars and is what Plex draws (its double horizontal
41 + //! spans 204 units against the light bar's 68).
42 +
43 + mod table;
44 +
45 + use crate::base::BaseParams;
46 + use crate::draw::Drawing;
47 + use crate::manifest::{GlyphSpec, Purpose, Shape, WeightResponse, format_codepoint};
48 +
49 + /// How heavy one arm of a box-drawing glyph is.
50 + #[derive(Debug, Clone, Copy, PartialEq, Eq)]
51 + pub enum Arm {
52 + /// No arm in this direction. Spelled `N` in the generated table.
53 + N,
54 + L,
55 + H,
56 + D,
57 + }
58 +
59 + impl Arm {
60 + fn present(self) -> bool {
61 + self != Self::N
62 + }
63 +
64 + /// Half the arm's total extent across itself, in units.
65 + ///
66 + /// What a crossing arm has to reach past to close a joint, and what a rail
67 + /// stops at. Double counts the whole three-bar span, not one rail.
68 + fn half_extent(self, light: f64) -> f64 {
69 + match self {
70 + Self::N => 0.0,
71 + Self::L => light / 2.0,
72 + Self::H => light,
73 + Self::D => light * 1.5,
74 + }
75 + }
76 + }
77 +
78 + #[derive(Debug, Clone, Copy, PartialEq, Eq)]
79 + pub enum Dash {
80 + None,
81 + Double,
82 + Triple,
83 + Quadruple,
84 + }
85 +
86 + impl Dash {
87 + /// How many dashes the bar is broken into.
88 + const fn count(self) -> usize {
89 + match self {
90 + Self::None => 1,
91 + Self::Double => 2,
92 + Self::Triple => 3,
93 + Self::Quadruple => 4,
94 + }
95 + }
96 + }
97 +
98 + #[derive(Debug, Clone, Copy, PartialEq, Eq)]
99 + pub enum Diagonal {
100 + /// `╱`, lower left to upper right.
101 + Rising,
102 + /// `╲`, upper left to lower right.
103 + Falling,
104 + /// `╳`, both.
105 + Cross,
106 + }
107 +
108 + #[derive(Debug, Clone, Copy, PartialEq, Eq)]
109 + pub enum Shade {
110 + Light,
111 + Medium,
112 + Dark,
113 + }
114 +
115 + impl Shade {
116 + /// Filled squares out of the [`SHADE_GRID`] squared, so the three read as a
117 + /// progression rather than three unrelated textures.
118 + const fn filled(self) -> u32 {
119 + match self {
120 + Self::Light => 1,
121 + Self::Medium => 2,
122 + Self::Dark => 3,
123 + }
124 + }
125 + }
126 +
127 + /// The shade pattern's grid, per axis.
128 + ///
129 + /// Eight, so a shade's texture lines up with the eighths the block elements are
130 + /// already cut on: `░` beside `▄` should not look like a different unit.
131 + const SHADE_GRID: u32 = 8;
132 +
133 + /// One generated cell primitive.
134 + #[derive(Debug, Clone, Copy)]
135 + pub enum Cell {
136 + /// Arms from the cell's edges to its centre, in the order left, right, up,
137 + /// down.
138 + Stems {
139 + arms: [Arm; 4],
140 + dash: Dash,
141 + },
142 + /// A rounded corner: the same two arms, turned through a quarter circle.
143 + Arc {
144 + arms: [Arm; 4],
145 + },
146 + Diagonal(Diagonal),
147 + /// Filled rectangles, in eighths of the cell, `y` up.
148 + Fill(&'static [(u8, u8, u8, u8)]),
149 + Shade(Shade),
150 + }
151 +
152 + impl Cell {
153 + pub fn kind(self) -> &'static str {
154 + match self {
155 + Self::Stems { .. } => "stems",
156 + Self::Arc { .. } => "arc",
157 + Self::Diagonal(_) => "diagonal",
158 + Self::Fill(_) => "fill",
159 + Self::Shade(_) => "shade",
160 + }
161 + }
162 +
163 + /// Cell furniture divides on this exactly where the manifest's marks do.
164 + ///
165 + /// A stem is a stroke and thickens with the base, the way the base thickens
166 + /// `+`. A fill has no stroke and no extent to grow into — it is already the
167 + /// whole cell or an exact fraction of it — so it holds, which is what the
168 + /// base does with `█` and `░` and the reason the manifest's weight term
169 + /// exists at all.
170 + pub fn weight_response(self) -> WeightResponse {
171 + match self {
172 + Self::Stems { .. } | Self::Arc { .. } | Self::Diagonal(_) => WeightResponse::Thickens,
173 + Self::Fill(_) | Self::Shade(_) => WeightResponse::Holds,
174 + }
175 + }
176 + }
177 +
178 + /// The whole generated tier, in codepoint order.
179 + pub fn cells() -> Vec<(u32, Cell)> {
180 + let mut out: Vec<(u32, Cell)> = Vec::with_capacity(160);
181 + for &(cp, arms, dash) in &table::STEMS {
182 + out.push((cp, Cell::Stems { arms, dash }));
183 + }
184 + for &(cp, arms) in &table::ARCS {
185 + out.push((cp, Cell::Arc { arms }));
186 + }
187 + for &(cp, kind) in &table::DIAGONALS {
188 + out.push((cp, Cell::Diagonal(kind)));
189 + }
190 + for &(cp, rects) in &table::FILLS {
191 + out.push((cp, Cell::Fill(rects)));
192 + }
193 + for &(cp, level) in &table::SHADES {
194 + out.push((cp, Cell::Shade(level)));
195 + }
196 + out.sort_by_key(|&(cp, _)| cp);
197 + out
198 + }
199 +
200 + /// The generated tier as manifest entries, so nothing downstream has to know
201 + /// these were not authored one by one.
202 + pub fn specs(block: Block) -> Vec<GlyphSpec> {
203 + cells()
204 + .into_iter()
205 + .filter(|&(cp, _)| block.contains(cp))
206 + .map(|(codepoint, cell)| GlyphSpec {
207 + codepoint,
208 + name: format!("uni{codepoint:04X}"),
209 + role: format!("{} ({})", block.role(), cell.kind()),
210 + source: Some(format!(
211 + "generated from the Unicode name of {}",
212 + format_codepoint(codepoint)
213 + )),
214 + // Never house: a base that ships box drawing ships a designed set
215 + // of it, and ours exists for the bases that do not.
216 + purpose: Purpose::Coverage,
217 + shape: Shape::Cell(cell),
218 + })
219 + .collect()
220 + }
221 +
222 + /// A block a schema may ask for by name.
223 + #[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Deserialize)]
224 + #[serde(rename_all = "kebab-case")]
225 + pub enum Block {
226 + BoxDrawing,
227 + BlockElements,
228 + }
229 +
230 + impl Block {
231 + const fn range(self) -> (u32, u32) {
232 + match self {
233 + Self::BoxDrawing => (0x2500, 0x257F),
234 + Self::BlockElements => (0x2580, 0x259F),
235 + }
236 + }
237 +
238 + fn contains(self, codepoint: u32) -> bool {
239 + let (lo, hi) = self.range();
240 + (lo..=hi).contains(&codepoint)
241 + }
242 +
243 + const fn role(self) -> &'static str {
244 + match self {
245 + Self::BoxDrawing => "box drawing",
246 + Self::BlockElements => "block element",
247 + }
248 + }
249 + }
250 +
251 + // ---------------------------------------------------------------------------
252 + // Drawing
253 + // ---------------------------------------------------------------------------
254 +
255 + /// The cell a primitive is drawn into: the advance, and ascender to descender.
256 + struct CellBox {
257 + x0: f64,
258 + x1: f64,
259 + y0: f64,
260 + y1: f64,
261 + cx: f64,
262 + cy: f64,
263 + /// A light horizontal bar's thickness: the base's own `-`.
264 + light_h: f64,
265 + /// A light vertical bar's thickness: the base's own `|`.
266 + light_v: f64,
267 + }
268 +
269 + impl CellBox {
270 + fn of(params: &BaseParams) -> Self {
271 + Self {
272 + x0: 0.0,
273 + x1: f64::from(params.advance),
274 + y0: f64::from(params.descent),
275 + y1: f64::from(params.ascent),
276 + cx: params.center_x(),
277 + cy: params.cell_center_y(),
278 + light_h: f64::from(params.stroke),
279 + light_v: f64::from(params.stem),
280 + }
281 + }
282 +
283 + fn rect(x0: f64, y0: f64, x1: f64, y1: f64) -> Vec<(f64, f64)> {
284 + vec![(x0, y0), (x1, y0), (x1, y1), (x0, y1)]
285 + }
286 + }
287 +
288 + pub fn draw(cell: Cell, params: &BaseParams) -> Drawing {
289 + let c = CellBox::of(params);
290 + let contours = match cell {
291 + Cell::Stems { arms, dash } => stems(&c, arms, dash),
292 + Cell::Arc { arms } => arc(&c, arms),
293 + Cell::Diagonal(kind) => diagonal(&c, kind),
294 + Cell::Fill(rects) => fill(&c, rects),
295 + Cell::Shade(level) => shade(&c, level),
296 + };
297 + Drawing { contours }
298 + }
299 +
300 + /// Arms in the table's order.
301 + const LEFT: usize = 0;
302 + const RIGHT: usize = 1;
303 + const UP: usize = 2;
304 + const DOWN: usize = 3;
305 +
306 + fn stems(c: &CellBox, arms: [Arm; 4], dash: Dash) -> Vec<Vec<(f64, f64)>> {
307 + let mut out = Vec::new();
308 +
309 + // A dashed glyph is one bar across the whole cell rather than two arms
310 + // meeting in the middle. Unicode has no dashed junction — every dashed
311 + // codepoint in the block is a plain horizontal or vertical — and drawing it
312 + // as two arms would dash each half separately, so `┄` would come out with
313 + // six marks and a seam in the middle instead of the three it names.
314 + if dash != Dash::None {
315 + let horizontal = arms[LEFT].present();
316 + let arm = if horizontal { arms[LEFT] } else { arms[UP] };
317 + let light = if horizontal { c.light_h } else { c.light_v };
318 + let thickness = if arm == Arm::H { light * 2.0 } else { light };
319 + let (from, to, across) = if horizontal {
320 + (c.x0, c.x1, c.cy)
321 + } else {
322 + (c.y0, c.y1, c.cx)
323 + };
324 + return bar(c, horizontal, from, to, across, thickness, dash);
325 + }
326 +
327 + // What a horizontal arm has to reach past to close a joint, and the other
328 + // way round. The heavier of the two arms on the crossing axis wins: a
329 + // junction is as wide as the widest thing passing through it.
330 + let vertical_half = arms[UP]
331 + .half_extent(c.light_v)
332 + .max(arms[DOWN].half_extent(c.light_v));
333 + let horizontal_half = arms[LEFT]
334 + .half_extent(c.light_h)
335 + .max(arms[RIGHT].half_extent(c.light_h));
336 +
337 + for (index, arm) in arms.iter().enumerate() {
338 + if !arm.present() {
339 + continue;
340 + }
341 + let horizontal = index == LEFT || index == RIGHT;
342 + let light = if horizontal { c.light_h } else { c.light_v };
343 + // Which way the arm runs, and how far past the centre it reaches.
344 + let sign = if index == LEFT || index == DOWN {
345 + -1.0
346 + } else {
347 + 1.0
348 + };
349 + let reach = if horizontal {
350 + vertical_half
351 + } else {
352 + horizontal_half
353 + };
354 + let edge = match index {
355 + LEFT => c.x0,
356 + RIGHT => c.x1,
357 + UP => c.y1,
358 + _ => c.y0,
359 + };
360 + let centre = if horizontal { c.cx } else { c.cy };
361 + let across = if horizontal { c.cy } else { c.cx };
362 +
363 + match arm {
364 + Arm::N => {}
365 + Arm::L | Arm::H => {
366 + let thickness = if *arm == Arm::H { light * 2.0 } else { light };
367 + // A single arm runs through the joint and out the far side of
368 + // whatever crosses it, so the corner is square rather than
369 + // notched. With nothing crossing, `reach` is zero and the arm
370 + // stops at the centre, which is what a half-line is.
371 + let stop = centre - reach * sign;
372 + out.extend(bar(c, horizontal, edge, stop, across, thickness, dash));
373 + }
374 + Arm::D => {
375 + // Two rails, and each one stops in a different place. A rail
376 + // whose side is occupied by a crossing arm stops short of it;
377 + // the other continues to the far rail and closes the corner.
378 + // That single rule draws every corner, tee and cross in the
379 + // double family, including `╬`, whose four arms all stop short
380 + // and leave the middle open.
381 + let gap = light;
382 + for side in [1.0, -1.0] {
383 + let blocked = if horizontal {
384 + arms[if side > 0.0 { UP } else { DOWN }].present()
385 + } else {
386 + arms[if side > 0.0 { RIGHT } else { LEFT }].present()
387 + };
388 + let stop = if blocked {
389 + centre + reach * sign
390 + } else {
391 + centre - reach * sign
392 + };
393 + out.extend(bar(
394 + c,
395 + horizontal,
396 + edge,
397 + stop,
398 + across + side * gap,
399 + light,
400 + dash,
401 + ));
402 + }
403 + }
404 + }
405 + }
406 + out
407 + }
408 +
409 + /// One bar from `edge` to `stop`, `thickness` across, centred on `across`.
410 + ///
411 + /// Dashes are cut here rather than by the caller so a dashed arm and a solid
412 + /// one are the same code with a different count. The gap is one light bar wide,
413 + /// which keeps the dash reading as the same weight as its neighbours.
414 + fn bar(
415 + c: &CellBox,
416 + horizontal: bool,
417 + edge: f64,
418 + stop: f64,
419 + across: f64,
420 + thickness: f64,
421 + dash: Dash,
422 + ) -> Vec<Vec<(f64, f64)>> {
423 + let half = thickness / 2.0;
424 + let (lo, hi) = if edge < stop {
425 + (edge, stop)
426 + } else {
427 + (stop, edge)
428 + };
429 + let count = dash.count();
430 + let gap = if count > 1 {
431 + if horizontal { c.light_h } else { c.light_v }
432 + } else {
433 + 0.0
434 + };
435 + // Length is shared between the dashes and the gaps between them, so a
436 + // quadruple dash is four shorter marks rather than four of the triple's and
437 + // a longer glyph.
438 + let span = (hi - lo - gap * (count as f64 - 1.0)) / count as f64;
439 + (0..count)
440 + .map(|i| {
441 + let a = lo + (span + gap) * i as f64;
442 + let b = a + span;
443 + if horizontal {
444 + CellBox::rect(a, across - half, b, across + half)
445 + } else {
446 + CellBox::rect(across - half, a, across + half, b)
447 + }
448 + })
449 + .collect()
450 + }
451 +
452 + /// Segments in a quarter turn.
453 + ///
454 + /// Twelve: enough that the curve reads as one at a terminal's size, and a fixed
455 + /// count so every master has the same points, which `gvar` requires.
456 + const ARC_SEGMENTS: usize = 12;
457 +
458 + fn arc(c: &CellBox, arms: [Arm; 4]) -> Vec<Vec<(f64, f64)>> {
459 + // Built as a centre line and then given a thickness, rather than by
460 + // computing the two edges directly. The direct version needs a sign per
461 + // quadrant per edge and gets one of them wrong in a way a bounding box
462 + // cannot see, which is what happened here first time.
463 + //
464 + // The geometry comes off the tangent points rather than off an angle
465 + // sweep, for the same reason. The two straight runs lie on `y = cy` and
466 + // `x = cx` and cross at the cell's centre; a circle tangent to both has its
467 + // own centre one radius along each of them, and touches them at
468 + // `(ox, cy)` and `(cx, oy)`. Those two points are where the straights stop
469 + // and the turn starts, so nothing has to be reasoned about twice.
470 + let horizontal = if arms[LEFT].present() { LEFT } else { RIGHT };
471 + let vertical = if arms[UP].present() { UP } else { DOWN };
472 + let x_edge = if horizontal == LEFT { c.x0 } else { c.x1 };
473 + let y_edge = if vertical == UP { c.y1 } else { c.y0 };
474 + let x_sign = if horizontal == LEFT { -1.0 } else { 1.0 };
475 + let y_sign = if vertical == UP { 1.0 } else { -1.0 };
476 + // A third of the half-cell, so the turn reads as a corner rather than a
477 + // bow and both arms keep a straight run to meet their neighbours squarely.
478 + let radius = ((c.cx - c.x0).abs() / 3.0).min((c.cy - c.y0).abs() / 3.0);
479 + let (ox, oy) = (c.cx + radius * x_sign, c.cy + radius * y_sign);
480 +
481 + let mut centre: Vec<(f64, f64)> = vec![(x_edge, c.cy)];
482 + for i in 0..=ARC_SEGMENTS {
483 + let t = i as f64 / ARC_SEGMENTS as f64 * std::f64::consts::FRAC_PI_2;
484 + let (sin, cos) = t.sin_cos();
485 + centre.push((ox - radius * x_sign * sin, oy - radius * y_sign * cos));
486 + }
487 + centre.push((c.cx, y_edge));
488 + vec![stroke(&centre, c.light_h / 2.0)]
489 + }
490 +
491 + /// A polyline given a thickness: one closed contour, out along one side and
492 + /// back along the other.
493 + ///
494 + /// The offset at each point is perpendicular to the average of the segments
495 + /// meeting there, which keeps the width even around a turn instead of pinching
496 + /// on the inside of it.
497 + fn stroke(centre: &[(f64, f64)], half: f64) -> Vec<(f64, f64)> {
498 + let normals: Vec<(f64, f64)> = (0..centre.len())
499 + .map(|i| {
500 + let before = i.saturating_sub(1);
Lines truncated
@@ -1,0 +1,186 @@
1 + // Generated by scripts/derive-cell-table.py from Unicode character names.
2 + // Do not edit by hand: re-run the script instead. See its header for why
3 + // the table is derived rather than authored.
4 + //
5 + // 121 stems, 4 arcs, 3 diagonals, 29 fills, 3 shades.
6 +
7 + use super::Arm::{D, H, L, N};
8 + use super::{Dash, Diagonal, Shade};
9 +
10 + /// Arms in the order left, right, up, down.
11 + pub(super) const STEMS: [(u32, [super::Arm; 4], Dash); 121] = [
12 + (0x2500, [L, L, N, N], Dash::None),
13 + (0x2501, [H, H, N, N], Dash::None),
14 + (0x2502, [N, N, L, L], Dash::None),
15 + (0x2503, [N, N, H, H], Dash::None),
16 + (0x2504, [L, L, N, N], Dash::Triple),
17 + (0x2505, [H, H, N, N], Dash::Triple),
18 + (0x2506, [N, N, L, L], Dash::Triple),
19 + (0x2507, [N, N, H, H], Dash::Triple),
20 + (0x2508, [L, L, N, N], Dash::Quadruple),
21 + (0x2509, [H, H, N, N], Dash::Quadruple),
22 + (0x250A, [N, N, L, L], Dash::Quadruple),
23 + (0x250B, [N, N, H, H], Dash::Quadruple),
24 + (0x250C, [N, L, N, L], Dash::None),
25 + (0x250D, [N, H, N, L], Dash::None),
26 + (0x250E, [N, L, N, H], Dash::None),
27 + (0x250F, [N, H, N, H], Dash::None),
28 + (0x2510, [L, N, N, L], Dash::None),
29 + (0x2511, [H, N, N, L], Dash::None),
30 + (0x2512, [L, N, N, H], Dash::None),
31 + (0x2513, [H, N, N, H], Dash::None),
32 + (0x2514, [N, L, L, N], Dash::None),
33 + (0x2515, [N, H, L, N], Dash::None),
34 + (0x2516, [N, L, H, N], Dash::None),
35 + (0x2517, [N, H, H, N], Dash::None),
36 + (0x2518, [L, N, L, N], Dash::None),
37 + (0x2519, [H, N, L, N], Dash::None),
38 + (0x251A, [L, N, H, N], Dash::None),
39 + (0x251B, [H, N, H, N], Dash::None),
40 + (0x251C, [N, L, L, L], Dash::None),
41 + (0x251D, [N, H, L, L], Dash::None),
42 + (0x251E, [N, L, H, L], Dash::None),
43 + (0x251F, [N, L, L, H], Dash::None),
44 + (0x2520, [N, L, H, H], Dash::None),
45 + (0x2521, [N, H, H, L], Dash::None),
46 + (0x2522, [N, H, L, H], Dash::None),
47 + (0x2523, [N, H, H, H], Dash::None),
48 + (0x2524, [L, N, L, L], Dash::None),
49 + (0x2525, [H, N, L, L], Dash::None),
50 + (0x2526, [L, N, H, L], Dash::None),
51 + (0x2527, [L, N, L, H], Dash::None),
52 + (0x2528, [L, N, H, H], Dash::None),
53 + (0x2529, [H, N, H, L], Dash::None),
54 + (0x252A, [H, N, L, H], Dash::None),
55 + (0x252B, [H, N, H, H], Dash::None),
56 + (0x252C, [L, L, N, L], Dash::None),
57 + (0x252D, [H, L, N, L], Dash::None),
58 + (0x252E, [L, H, N, L], Dash::None),
59 + (0x252F, [H, H, N, L], Dash::None),
60 + (0x2530, [L, L, N, H], Dash::None),
61 + (0x2531, [H, L, N, H], Dash::None),
62 + (0x2532, [L, H, N, H], Dash::None),
63 + (0x2533, [H, H, N, H], Dash::None),
64 + (0x2534, [L, L, L, N], Dash::None),
65 + (0x2535, [H, L, L, N], Dash::None),
66 + (0x2536, [L, H, L, N], Dash::None),
67 + (0x2537, [H, H, L, N], Dash::None),
68 + (0x2538, [L, L, H, N], Dash::None),
69 + (0x2539, [H, L, H, N], Dash::None),
70 + (0x253A, [L, H, H, N], Dash::None),
71 + (0x253B, [H, H, H, N], Dash::None),
72 + (0x253C, [L, L, L, L], Dash::None),
73 + (0x253D, [H, L, L, L], Dash::None),
74 + (0x253E, [L, H, L, L], Dash::None),
75 + (0x253F, [H, H, L, L], Dash::None),
76 + (0x2540, [L, L, H, L], Dash::None),
77 + (0x2541, [L, L, L, H], Dash::None),
78 + (0x2542, [L, L, H, H], Dash::None),
79 + (0x2543, [H, L, H, L], Dash::None),
80 + (0x2544, [L, H, H, L], Dash::None),
81 + (0x2545, [H, L, L, H], Dash::None),
82 + (0x2546, [L, H, L, H], Dash::None),
83 + (0x2547, [H, H, H, L], Dash::None),
84 + (0x2548, [H, H, L, H], Dash::None),
85 + (0x2549, [H, L, H, H], Dash::None),
86 + (0x254A, [L, H, H, H], Dash::None),
87 + (0x254B, [H, H, H, H], Dash::None),
88 + (0x254C, [L, L, N, N], Dash::Double),
89 + (0x254D, [H, H, N, N], Dash::Double),
90 + (0x254E, [N, N, L, L], Dash::Double),
91 + (0x254F, [N, N, H, H], Dash::Double),
92 + (0x2550, [D, D, N, N], Dash::None),
93 + (0x2551, [N, N, D, D], Dash::None),
94 + (0x2552, [N, D, N, L], Dash::None),
95 + (0x2553, [N, L, N, D], Dash::None),
96 + (0x2554, [N, D, N, D], Dash::None),
97 + (0x2555, [D, N, N, L], Dash::None),
98 + (0x2556, [L, N, N, D], Dash::None),
99 + (0x2557, [D, N, N, D], Dash::None),
100 + (0x2558, [N, D, L, N], Dash::None),
101 + (0x2559, [N, L, D, N], Dash::None),
102 + (0x255A, [N, D, D, N], Dash::None),
103 + (0x255B, [D, N, L, N], Dash::None),
104 + (0x255C, [L, N, D, N], Dash::None),
105 + (0x255D, [D, N, D, N], Dash::None),
106 + (0x255E, [N, D, L, L], Dash::None),
107 + (0x255F, [N, L, D, D], Dash::None),
108 + (0x2560, [N, D, D, D], Dash::None),
109 + (0x2561, [D, N, L, L], Dash::None),
110 + (0x2562, [L, N, D, D], Dash::None),
111 + (0x2563, [D, N, D, D], Dash::None),
112 + (0x2564, [D, D, N, L], Dash::None),
113 + (0x2565, [L, L, N, D], Dash::None),
114 + (0x2566, [D, D, N, D], Dash::None),
115 + (0x2567, [D, D, L, N], Dash::None),
116 + (0x2568, [L, L, D, N], Dash::None),
117 + (0x2569, [D, D, D, N], Dash::None),
118 + (0x256A, [D, D, L, L], Dash::None),
119 + (0x256B, [L, L, D, D], Dash::None),
120 + (0x256C, [D, D, D, D], Dash::None),
121 + (0x2574, [L, N, N, N], Dash::None),
122 + (0x2575, [N, N, L, N], Dash::None),
123 + (0x2576, [N, L, N, N], Dash::None),
124 + (0x2577, [N, N, N, L], Dash::None),
125 + (0x2578, [H, N, N, N], Dash::None),
126 + (0x2579, [N, N, H, N], Dash::None),
127 + (0x257A, [N, H, N, N], Dash::None),
128 + (0x257B, [N, N, N, H], Dash::None),
129 + (0x257C, [L, H, N, N], Dash::None),
130 + (0x257D, [N, N, L, H], Dash::None),
131 + (0x257E, [H, L, N, N], Dash::None),
132 + (0x257F, [N, N, H, L], Dash::None),
133 + ];
134 +
135 + /// The rounded corners. Same arms as a stem, drawn as a quarter turn.
136 + pub(super) const ARCS: [(u32, [super::Arm; 4]); 4] = [
137 + (0x256D, [N, L, N, L]),
138 + (0x256E, [L, N, N, L]),
139 + (0x256F, [L, N, L, N]),
140 + (0x2570, [N, L, L, N]),
141 + ];
142 +
143 + pub(super) const DIAGONALS: [(u32, Diagonal); 3] = [
144 + (0x2571, Diagonal::Rising),
145 + (0x2572, Diagonal::Falling),
146 + (0x2573, Diagonal::Cross),
147 + ];
148 +
149 + /// Filled rectangles in eighths of the cell: x0, y0, x1, y1, with y up.
150 + pub(super) const FILLS: [(u32, &[(u8, u8, u8, u8)]); 29] = [
151 + (0x2580, &[(0, 4, 8, 8)]),
152 + (0x2581, &[(0, 0, 8, 1)]),
153 + (0x2582, &[(0, 0, 8, 2)]),
154 + (0x2583, &[(0, 0, 8, 3)]),
155 + (0x2584, &[(0, 0, 8, 4)]),
156 + (0x2585, &[(0, 0, 8, 5)]),
157 + (0x2586, &[(0, 0, 8, 6)]),
158 + (0x2587, &[(0, 0, 8, 7)]),
159 + (0x2588, &[(0, 0, 8, 8)]),
160 + (0x2589, &[(0, 0, 7, 8)]),
161 + (0x258A, &[(0, 0, 6, 8)]),
162 + (0x258B, &[(0, 0, 5, 8)]),
163 + (0x258C, &[(0, 0, 4, 8)]),
164 + (0x258D, &[(0, 0, 3, 8)]),
165 + (0x258E, &[(0, 0, 2, 8)]),
166 + (0x258F, &[(0, 0, 1, 8)]),
167 + (0x2590, &[(4, 0, 8, 8)]),
168 + (0x2594, &[(0, 7, 8, 8)]),
169 + (0x2595, &[(7, 0, 8, 8)]),
170 + (0x2596, &[(0, 0, 4, 4)]),
171 + (0x2597, &[(4, 0, 8, 4)]),
172 + (0x2598, &[(0, 4, 4, 8)]),
173 + (0x2599, &[(0, 4, 4, 8), (0, 0, 4, 4), (4, 0, 8, 4)]),
174 + (0x259A, &[(0, 4, 4, 8), (4, 0, 8, 4)]),
175 + (0x259B, &[(0, 4, 4, 8), (4, 4, 8, 8), (0, 0, 4, 4)]),
176 + (0x259C, &[(0, 4, 4, 8), (4, 4, 8, 8), (4, 0, 8, 4)]),
177 + (0x259D, &[(4, 4, 8, 8)]),
178 + (0x259E, &[(4, 4, 8, 8), (0, 0, 4, 4)]),
179 + (0x259F, &[(4, 4, 8, 8), (0, 0, 4, 4), (4, 0, 8, 4)]),
180 + ];
181 +
182 + pub(super) const SHADES: [(u32, Shade); 3] = [
183 + (0x2591, Shade::Light),
184 + (0x2592, Shade::Medium),
185 + (0x2593, Shade::Dark),
186 + ];