Skip to main content

max / quasi-type

33.0 KB · 940 lines History Blame Raw
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 //! A base need not agree with itself here: a face can draw its box glyphs over
29 //! one vertical range while its own `hhea` says another, so its borders do not
30 //! line up with a cell laid out from its metrics, which is why terminals so
31 //! often stretch box-drawing glyphs to the cell. Ours is built on the metrics a
32 //! terminal actually lays out with, so it needs no stretching.
33 //!
34 //! ## The stroke vocabulary
35 //!
36 //! One light bar is the base's own: `-`'s height for horizontals, `|`'s width
37 //! for verticals, so a border sits at the weight of the text it surrounds.
38 //! Heavy is twice that. Double is two light rails with a light gap, which is a
39 //! total of three light bars and is what Plex draws (its double horizontal
40 //! spans 204 units against the light bar's 68).
41
42 mod table;
43
44 use crate::base::BaseParams;
45 use crate::draw::Drawing;
46 use crate::manifest::{GlyphSpec, Purpose, Shape, WeightResponse, format_codepoint};
47
48 /// How heavy one arm of a box-drawing glyph is.
49 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
50 pub enum Arm {
51 /// No arm in this direction. Spelled `N` in the generated table.
52 N,
53 L,
54 H,
55 D,
56 }
57
58 impl Arm {
59 fn present(self) -> bool {
60 self != Self::N
61 }
62
63 /// Half the arm's total extent across itself, in units.
64 ///
65 /// What a crossing arm has to reach past to close a joint, and what a rail
66 /// stops at. Double counts the whole three-bar span, not one rail.
67 fn half_extent(self, light: f64) -> f64 {
68 match self {
69 Self::N => 0.0,
70 Self::L => light / 2.0,
71 Self::H => light,
72 Self::D => light * 1.5,
73 }
74 }
75 }
76
77 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
78 pub enum Dash {
79 None,
80 Double,
81 Triple,
82 Quadruple,
83 }
84
85 impl Dash {
86 /// How many dashes the bar is broken into.
87 const fn count(self) -> usize {
88 match self {
89 Self::None => 1,
90 Self::Double => 2,
91 Self::Triple => 3,
92 Self::Quadruple => 4,
93 }
94 }
95 }
96
97 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
98 pub enum Diagonal {
99 /// `╱`, lower left to upper right.
100 Rising,
101 /// `╲`, upper left to lower right.
102 Falling,
103 /// `╳`, both.
104 Cross,
105 }
106
107 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
108 pub enum Shade {
109 Light,
110 Medium,
111 Dark,
112 }
113
114 impl Shade {
115 /// Filled squares out of the [`SHADE_GRID`] squared, so the three read as a
116 /// progression rather than three unrelated textures.
117 const fn filled(self) -> u32 {
118 match self {
119 Self::Light => 1,
120 Self::Medium => 2,
121 Self::Dark => 3,
122 }
123 }
124 }
125
126 /// The shade pattern's grid, per axis.
127 ///
128 /// Eight, so a shade's texture lines up with the eighths the block elements are
129 /// already cut on: `░` beside `▄` should not look like a different unit.
130 const SHADE_GRID: u32 = 8;
131
132 /// One generated cell primitive.
133 #[derive(Debug, Clone, Copy)]
134 pub enum Cell {
135 /// Arms from the cell's edges to its centre, in the order left, right, up,
136 /// down.
137 Stems {
138 arms: [Arm; 4],
139 dash: Dash,
140 },
141 /// A rounded corner: the same two arms, turned through a quarter circle.
142 Arc {
143 arms: [Arm; 4],
144 },
145 Diagonal(Diagonal),
146 /// Filled rectangles, in eighths of the cell, `y` up.
147 Fill(&'static [(u8, u8, u8, u8)]),
148 Shade(Shade),
149 }
150
151 impl Cell {
152 pub fn kind(self) -> &'static str {
153 match self {
154 Self::Stems { .. } => "stems",
155 Self::Arc { .. } => "arc",
156 Self::Diagonal(_) => "diagonal",
157 Self::Fill(_) => "fill",
158 Self::Shade(_) => "shade",
159 }
160 }
161
162 /// Cell furniture divides on this exactly where the manifest's marks do.
163 ///
164 /// A stem is a stroke and thickens with the base, the way the base thickens
165 /// `+`. A fill has no stroke and no extent to grow into — it is already the
166 /// whole cell or an exact fraction of it — so it holds, which is what the
167 /// base does with `█` and `░` and the reason the manifest's weight term
168 /// exists at all.
169 pub fn weight_response(self) -> WeightResponse {
170 match self {
171 Self::Stems { .. } | Self::Arc { .. } | Self::Diagonal(_) => WeightResponse::Thickens,
172 Self::Fill(_) | Self::Shade(_) => WeightResponse::Holds,
173 }
174 }
175 }
176
177 /// The whole generated tier, in codepoint order.
178 pub fn cells() -> Vec<(u32, Cell)> {
179 let mut out: Vec<(u32, Cell)> = Vec::with_capacity(160);
180 for &(cp, arms, dash) in &table::STEMS {
181 out.push((cp, Cell::Stems { arms, dash }));
182 }
183 for &(cp, arms) in &table::ARCS {
184 out.push((cp, Cell::Arc { arms }));
185 }
186 for &(cp, kind) in &table::DIAGONALS {
187 out.push((cp, Cell::Diagonal(kind)));
188 }
189 for &(cp, rects) in &table::FILLS {
190 out.push((cp, Cell::Fill(rects)));
191 }
192 for &(cp, level) in &table::SHADES {
193 out.push((cp, Cell::Shade(level)));
194 }
195 out.sort_by_key(|&(cp, _)| cp);
196 out
197 }
198
199 /// The generated tier as manifest entries, so nothing downstream has to know
200 /// these were not authored one by one.
201 pub fn specs(block: Block) -> Vec<GlyphSpec> {
202 cells()
203 .into_iter()
204 .filter(|&(cp, _)| block.contains(cp))
205 .map(|(codepoint, cell)| GlyphSpec {
206 codepoint,
207 name: format!("uni{codepoint:04X}"),
208 role: format!("{} ({})", block.role(), cell.kind()),
209 source: Some(format!(
210 "generated from the Unicode name of {}",
211 format_codepoint(codepoint)
212 )),
213 // Never house: a base that ships box drawing ships a designed set
214 // of it, and ours exists for the bases that do not.
215 purpose: Purpose::Coverage,
216 shape: Shape::Cell(cell),
217 })
218 .collect()
219 }
220
221 /// A block a schema may ask for by name.
222 #[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Deserialize)]
223 #[serde(rename_all = "kebab-case")]
224 pub enum Block {
225 BoxDrawing,
226 BlockElements,
227 }
228
229 impl Block {
230 const fn range(self) -> (u32, u32) {
231 match self {
232 Self::BoxDrawing => (0x2500, 0x257F),
233 Self::BlockElements => (0x2580, 0x259F),
234 }
235 }
236
237 fn contains(self, codepoint: u32) -> bool {
238 let (lo, hi) = self.range();
239 (lo..=hi).contains(&codepoint)
240 }
241
242 const fn role(self) -> &'static str {
243 match self {
244 Self::BoxDrawing => "box drawing",
245 Self::BlockElements => "block element",
246 }
247 }
248 }
249
250 // ---------------------------------------------------------------------------
251 // Drawing
252 // ---------------------------------------------------------------------------
253
254 /// The cell a primitive is drawn into: the advance, and ascender to descender.
255 struct CellBox {
256 x0: f64,
257 x1: f64,
258 y0: f64,
259 y1: f64,
260 cx: f64,
261 cy: f64,
262 /// A light horizontal bar's thickness: the base's own `-`.
263 light_h: f64,
264 /// A light vertical bar's thickness: the base's own `|`.
265 light_v: f64,
266 }
267
268 impl CellBox {
269 fn of(params: &BaseParams) -> Self {
270 Self {
271 x0: 0.0,
272 x1: f64::from(params.advance),
273 y0: f64::from(params.descent),
274 y1: f64::from(params.ascent),
275 cx: params.center_x(),
276 cy: params.cell_center_y(),
277 light_h: f64::from(params.stroke),
278 light_v: f64::from(params.stem),
279 }
280 }
281
282 fn rect(x0: f64, y0: f64, x1: f64, y1: f64) -> Vec<(f64, f64)> {
283 vec![(x0, y0), (x1, y0), (x1, y1), (x0, y1)]
284 }
285 }
286
287 pub fn draw(cell: Cell, params: &BaseParams) -> Drawing {
288 let c = CellBox::of(params);
289 let contours = match cell {
290 Cell::Stems { arms, dash } => stems(&c, arms, dash),
291 Cell::Arc { arms } => arc(&c, arms),
292 Cell::Diagonal(kind) => diagonal(&c, kind),
293 Cell::Fill(rects) => fill(&c, rects),
294 Cell::Shade(level) => shade(&c, level),
295 };
296 Drawing { contours }
297 }
298
299 /// Arms in the table's order.
300 const LEFT: usize = 0;
301 const RIGHT: usize = 1;
302 const UP: usize = 2;
303 const DOWN: usize = 3;
304
305 fn stems(c: &CellBox, arms: [Arm; 4], dash: Dash) -> Vec<Vec<(f64, f64)>> {
306 let mut out = Vec::new();
307
308 // A dashed glyph is one bar across the whole cell rather than two arms
309 // meeting in the middle. Unicode has no dashed junction — every dashed
310 // codepoint in the block is a plain horizontal or vertical — and drawing it
311 // as two arms would dash each half separately, so `┄` would come out with
312 // six marks and a seam in the middle instead of the three it names.
313 if dash != Dash::None {
314 let horizontal = arms[LEFT].present();
315 let arm = if horizontal { arms[LEFT] } else { arms[UP] };
316 let light = if horizontal { c.light_h } else { c.light_v };
317 let thickness = if arm == Arm::H { light * 2.0 } else { light };
318 let (from, to, across) = if horizontal {
319 (c.x0, c.x1, c.cy)
320 } else {
321 (c.y0, c.y1, c.cx)
322 };
323 return bar(c, horizontal, from, to, across, thickness, dash);
324 }
325
326 // What a horizontal arm has to reach past to close a joint, and the other
327 // way round. The heavier of the two arms on the crossing axis wins: a
328 // junction is as wide as the widest thing passing through it.
329 let vertical_half = arms[UP]
330 .half_extent(c.light_v)
331 .max(arms[DOWN].half_extent(c.light_v));
332 let horizontal_half = arms[LEFT]
333 .half_extent(c.light_h)
334 .max(arms[RIGHT].half_extent(c.light_h));
335
336 for (index, arm) in arms.iter().enumerate() {
337 if !arm.present() {
338 continue;
339 }
340 let horizontal = index == LEFT || index == RIGHT;
341 let light = if horizontal { c.light_h } else { c.light_v };
342 // Which way the arm runs, and how far past the centre it reaches.
343 let sign = if index == LEFT || index == DOWN {
344 -1.0
345 } else {
346 1.0
347 };
348 let reach = if horizontal {
349 vertical_half
350 } else {
351 horizontal_half
352 };
353 let edge = match index {
354 LEFT => c.x0,
355 RIGHT => c.x1,
356 UP => c.y1,
357 _ => c.y0,
358 };
359 let centre = if horizontal { c.cx } else { c.cy };
360 let across = if horizontal { c.cy } else { c.cx };
361
362 match arm {
363 Arm::N => {}
364 Arm::L | Arm::H => {
365 let thickness = if *arm == Arm::H { light * 2.0 } else { light };
366 // A single arm runs through the joint and out the far side of
367 // whatever crosses it, so the corner is square rather than
368 // notched. With nothing crossing, `reach` is zero and the arm
369 // stops at the centre, which is what a half-line is.
370 let stop = centre - reach * sign;
371 out.extend(bar(c, horizontal, edge, stop, across, thickness, dash));
372 }
373 Arm::D => {
374 // Two rails, and each one stops in a different place. A rail
375 // whose side is occupied by a crossing arm stops short of it;
376 // the other continues to the far rail and closes the corner.
377 // That single rule draws every corner, tee and cross in the
378 // double family, including `╬`, whose four arms all stop short
379 // and leave the middle open.
380 let gap = light;
381 for side in [1.0, -1.0] {
382 let blocked = if horizontal {
383 arms[if side > 0.0 { UP } else { DOWN }].present()
384 } else {
385 arms[if side > 0.0 { RIGHT } else { LEFT }].present()
386 };
387 let stop = if blocked {
388 centre + reach * sign
389 } else {
390 centre - reach * sign
391 };
392 out.extend(bar(
393 c,
394 horizontal,
395 edge,
396 stop,
397 across + side * gap,
398 light,
399 dash,
400 ));
401 }
402 }
403 }
404 }
405 out
406 }
407
408 /// One bar from `edge` to `stop`, `thickness` across, centred on `across`.
409 ///
410 /// Dashes are cut here rather than by the caller so a dashed arm and a solid
411 /// one are the same code with a different count. The gap is one light bar wide,
412 /// which keeps the dash reading as the same weight as its neighbours.
413 fn bar(
414 c: &CellBox,
415 horizontal: bool,
416 edge: f64,
417 stop: f64,
418 across: f64,
419 thickness: f64,
420 dash: Dash,
421 ) -> Vec<Vec<(f64, f64)>> {
422 let half = thickness / 2.0;
423 let (lo, hi) = if edge < stop {
424 (edge, stop)
425 } else {
426 (stop, edge)
427 };
428 let count = dash.count();
429 let gap = if count > 1 {
430 if horizontal { c.light_h } else { c.light_v }
431 } else {
432 0.0
433 };
434 // Length is shared between the dashes and the gaps between them, so a
435 // quadruple dash is four shorter marks rather than four of the triple's and
436 // a longer glyph.
437 let span = (hi - lo - gap * (count as f64 - 1.0)) / count as f64;
438 (0..count)
439 .map(|i| {
440 let a = lo + (span + gap) * i as f64;
441 let b = a + span;
442 if horizontal {
443 CellBox::rect(a, across - half, b, across + half)
444 } else {
445 CellBox::rect(across - half, a, across + half, b)
446 }
447 })
448 .collect()
449 }
450
451 /// Segments in a quarter turn.
452 ///
453 /// Twelve: enough that the curve reads as one at a terminal's size, and a fixed
454 /// count so every master has the same points, which `gvar` requires.
455 const ARC_SEGMENTS: usize = 12;
456
457 fn arc(c: &CellBox, arms: [Arm; 4]) -> Vec<Vec<(f64, f64)>> {
458 // Built as a centre line and then given a thickness, rather than by
459 // computing the two edges directly. The direct version needs a sign per
460 // quadrant per edge and gets one of them wrong in a way a bounding box
461 // cannot see, which is what happened here first time.
462 //
463 // The geometry comes off the tangent points rather than off an angle
464 // sweep, for the same reason. The two straight runs lie on `y = cy` and
465 // `x = cx` and cross at the cell's centre; a circle tangent to both has its
466 // own centre one radius along each of them, and touches them at
467 // `(ox, cy)` and `(cx, oy)`. Those two points are where the straights stop
468 // and the turn starts, so nothing has to be reasoned about twice.
469 let horizontal = if arms[LEFT].present() { LEFT } else { RIGHT };
470 let vertical = if arms[UP].present() { UP } else { DOWN };
471 let x_edge = if horizontal == LEFT { c.x0 } else { c.x1 };
472 let y_edge = if vertical == UP { c.y1 } else { c.y0 };
473 let x_sign = if horizontal == LEFT { -1.0 } else { 1.0 };
474 let y_sign = if vertical == UP { 1.0 } else { -1.0 };
475 // A third of the half-cell, so the turn reads as a corner rather than a
476 // bow and both arms keep a straight run to meet their neighbours squarely.
477 let radius = ((c.cx - c.x0).abs() / 3.0).min((c.cy - c.y0).abs() / 3.0);
478 let (ox, oy) = (c.cx + radius * x_sign, c.cy + radius * y_sign);
479
480 let mut centre: Vec<(f64, f64)> = vec![(x_edge, c.cy)];
481 for i in 0..=ARC_SEGMENTS {
482 let t = i as f64 / ARC_SEGMENTS as f64 * std::f64::consts::FRAC_PI_2;
483 let (sin, cos) = t.sin_cos();
484 centre.push((ox - radius * x_sign * sin, oy - radius * y_sign * cos));
485 }
486 centre.push((c.cx, y_edge));
487 vec![stroke(&centre, c.light_h / 2.0)]
488 }
489
490 /// A polyline given a thickness: one closed contour, out along one side and
491 /// back along the other.
492 ///
493 /// The offset at each point is perpendicular to the average of the segments
494 /// meeting there, which keeps the width even around a turn instead of pinching
495 /// on the inside of it.
496 fn stroke(centre: &[(f64, f64)], half: f64) -> Vec<(f64, f64)> {
497 let normals: Vec<(f64, f64)> = (0..centre.len())
498 .map(|i| {
499 let before = i.saturating_sub(1);
500 let after = (i + 1).min(centre.len() - 1);
501 let (dx, dy) = (
502 centre[after].0 - centre[before].0,
503 centre[after].1 - centre[before].1,
504 );
505 let len = dx.hypot(dy);
506 if len == 0.0 {
507 (0.0, 0.0)
508 } else {
509 (-dy / len, dx / len)
510 }
511 })
512 .collect();
513 let side = |sign: f64| -> Vec<(f64, f64)> {
514 centre
515 .iter()
516 .zip(&normals)
517 .map(|(&(x, y), &(nx, ny))| (x + nx * half * sign, y + ny * half * sign))
518 .collect()
519 };
520 let mut contour = side(1.0);
521 let mut back = side(-1.0);
522 back.reverse();
523 contour.extend(back);
524 contour
525 }
526
527 fn diagonal(c: &CellBox, kind: Diagonal) -> Vec<Vec<(f64, f64)>> {
528 let mut out = Vec::new();
529 let rising = matches!(kind, Diagonal::Rising | Diagonal::Cross);
530 let falling = matches!(kind, Diagonal::Falling | Diagonal::Cross);
531 // Thickness measured across the stroke rather than along an axis, so a
532 // diagonal reads the same weight as the horizontal beside it instead of
533 // thinner by the angle.
534 let dx = c.x1 - c.x0;
535 let dy = c.y1 - c.y0;
536 let length = dx.hypot(dy);
537 let half = c.light_h / 2.0 * length / dy;
538 if rising {
539 out.push(vec![
540 (c.x0 - half, c.y0),
541 (c.x0 + half, c.y0),
542 (c.x1 + half, c.y1),
543 (c.x1 - half, c.y1),
544 ]);
545 }
546 if falling {
547 out.push(vec![
548 (c.x0 - half, c.y1),
549 (c.x0 + half, c.y1),
550 (c.x1 + half, c.y0),
551 (c.x1 - half, c.y0),
552 ]);
553 }
554 out
555 }
556
557 fn fill(c: &CellBox, rects: &[(u8, u8, u8, u8)]) -> Vec<Vec<(f64, f64)>> {
558 rects
559 .iter()
560 .map(|&(x0, y0, x1, y1)| {
561 CellBox::rect(
562 eighth(c.x0, c.x1, x0),
563 eighth(c.y0, c.y1, y0),
564 eighth(c.x0, c.x1, x1),
565 eighth(c.y0, c.y1, y1),
566 )
567 })
568 .collect()
569 }
570
571 /// The `n`th eighth between two edges, rounded to a whole unit.
572 ///
573 /// Rounded through one function so adjacent fills agree: `▀` and `▄` meet
574 /// exactly because both ask this for the fourth eighth and get the same answer,
575 /// which subtracting one from the other would not guarantee.
576 fn eighth(lo: f64, hi: f64, n: u8) -> f64 {
577 (lo + (hi - lo) * f64::from(n) / 8.0).round()
578 }
579
580 fn shade(c: &CellBox, level: Shade) -> Vec<Vec<(f64, f64)>> {
581 // A regular grid rather than a stipple. The three shades are the same
582 // pattern at three densities so they read as a progression, and the squares
583 // land on the eighths the block elements are cut on so a shade beside a
584 // bar does not look like a different unit.
585 let mut out = Vec::new();
586 let filled = level.filled();
587 for row in 0..SHADE_GRID {
588 for col in 0..SHADE_GRID {
589 // Diagonal phase, so no shade reads as rows or columns of dots.
590 if (row * 3 + col * 5) % 4 >= filled {
591 continue;
592 }
593 out.push(CellBox::rect(
594 eighth(c.x0, c.x1, col as u8),
595 eighth(c.y0, c.y1, row as u8),
596 eighth(c.x0, c.x1, col as u8 + 1),
597 eighth(c.y0, c.y1, row as u8 + 1),
598 ));
599 }
600 }
601 out
602 }
603
604 #[cfg(test)]
605 mod tests {
606 use super::*;
607
608 /// Atkinson Hyperlegible Mono at `wght` 200, measured.
609 fn params() -> BaseParams {
610 BaseParams {
611 upem: 1000,
612 advance: 632,
613 cap_height: 668,
614 x_height: 496,
615 stem: 54,
616 stroke: 55,
617 band_x0: 68,
618 band_x1: 564,
619 band_y0: 0,
620 band_y1: 496,
621 ascent: 984,
622 descent: -316,
623 }
624 }
625
626 fn cell(codepoint: u32) -> Cell {
627 cells()
628 .into_iter()
629 .find(|&(cp, _)| cp == codepoint)
630 .unwrap_or_else(|| {
631 panic!(
632 "{} is not in the generated tier",
633 format_codepoint(codepoint)
634 )
635 })
636 .1
637 }
638
639 /// Exact by construction: every cell edge is rounded to a whole unit, so
640 /// these compare integers that happen to be typed `f64`. A tolerance here
641 /// would hide the one thing the tier has to get right.
642 #[track_caller]
643 fn same(a: f64, b: f64) {
644 assert!((a - b).abs() < f64::EPSILON, "{a} is not {b}");
645 }
646
647 fn bbox(codepoint: u32) -> (f64, f64, f64, f64) {
648 let drawing = draw(cell(codepoint), &params());
649 let points: Vec<(f64, f64)> = drawing.contours.concat();
650 let xs: Vec<f64> = points.iter().map(|p| p.0).collect();
651 let ys: Vec<f64> = points.iter().map(|p| p.1).collect();
652 (
653 xs.iter().copied().fold(f64::MAX, f64::min),
654 ys.iter().copied().fold(f64::MAX, f64::min),
655 xs.iter().copied().fold(f64::MIN, f64::max),
656 ys.iter().copied().fold(f64::MIN, f64::max),
657 )
658 }
659
660 #[test]
661 fn the_tier_is_both_blocks_and_nothing_else() {
662 let cells = cells();
663 assert_eq!(cells.len(), 160);
664 assert_eq!(cells.first().unwrap().0, 0x2500);
665 assert_eq!(cells.last().unwrap().0, 0x259F);
666 for (index, &(cp, _)) in cells.iter().enumerate() {
667 assert_eq!(cp, 0x2500 + index as u32, "the range has a hole in it");
668 }
669 }
670
671 // The property the whole tier exists for. A vertical bar has to reach the
672 // top and bottom of the cell, or every row boundary shows a seam.
673 #[test]
674 fn a_vertical_meets_the_cell_above_and_below() {
675 let (_, y0, _, y1) = bbox(0x2502);
676 same(y0, -316.0);
677 same(y1, 984.0);
678 }
679
680 #[test]
681 fn a_horizontal_meets_the_cell_either_side() {
682 let (x0, _, x1, _) = bbox(0x2500);
683 same(x0, 0.0);
684 same(x1, 632.0);
685 }
686
687 // And the two have to cross at the same place, or a `+` junction is not
688 // where the lines that make it are.
689 #[test]
690 fn the_rule_sits_at_the_cells_middle_not_the_bands() {
691 let (_, y0, _, y1) = bbox(0x2500);
692 let params = params();
693 assert!((y0.midpoint(y1) - params.cell_center_y()).abs() < 1.0);
694 assert!(
695 (y0.midpoint(y1) - params.band_center_y()).abs() > 50.0,
696 "the band's centre is near the x-height and is the wrong place"
697 );
698 }
699
700 #[test]
701 fn heavy_is_twice_light_and_double_is_three_times() {
702 let light = bbox(0x2500).3 - bbox(0x2500).1;
703 let heavy = bbox(0x2501).3 - bbox(0x2501).1;
704 let double = bbox(0x2550).3 - bbox(0x2550).1;
705 assert!((heavy - light * 2.0).abs() < 1.0, "{heavy} vs {light}");
706 assert!((double - light * 3.0).abs() < 1.0, "{double} vs {light}");
707 }
708
709 // A half-line stops at the centre; a through-line does not.
710 #[test]
711 fn a_half_line_stops_where_a_junction_would_be() {
712 let (x0, _, x1, _) = bbox(0x2574);
713 same(x0, 0.0);
714 assert!((x1 - params().center_x()).abs() < 1.0, "ends at the centre");
715 }
716
717 // The corner has to be square. A right arm that stopped at the centre
718 // rather than at the far side of the vertical would leave a notch.
719 #[test]
720 fn a_corner_closes_rather_than_notching() {
721 let params = params();
722 let (x0, _, x1, y1) = bbox(0x250C);
723 assert!(
724 x0 < params.center_x(),
725 "the arm reaches back through the joint"
726 );
727 same(x1, 632.0);
728 assert!((y1 - (params.cell_center_y() + f64::from(params.stroke) / 2.0)).abs() < 1.0);
729 }
730
731 // `╬` is the case the double rule exists for: all four arms stop short and
732 // the middle stays open.
733 #[test]
734 fn a_double_cross_leaves_its_middle_open() {
735 let drawing = draw(cell(0x256C), &params());
736 let params = params();
737 let (cx, cy) = (params.center_x(), params.cell_center_y());
738 for contour in &drawing.contours {
739 let xs: Vec<f64> = contour.iter().map(|p| p.0).collect();
740 let ys: Vec<f64> = contour.iter().map(|p| p.1).collect();
741 let covers_centre = xs.iter().copied().fold(f64::MAX, f64::min) < cx
742 && xs.iter().copied().fold(f64::MIN, f64::max) > cx
743 && ys.iter().copied().fold(f64::MAX, f64::min) < cy
744 && ys.iter().copied().fold(f64::MIN, f64::max) > cy;
745 assert!(!covers_centre, "a rail runs through the middle of `╬`");
746 }
747 }
748
749 // `╔` is the other half of the rule: the outer rail of each arm has to
750 // reach the far rail of the other, or the corner is open.
751 #[test]
752 fn a_double_corner_closes() {
753 let drawing = draw(cell(0x2554), &params());
754 let params = params();
755 let (cx, cy) = (params.center_x(), params.cell_center_y());
756 let gap = f64::from(params.stroke);
757 // The outer corner is up and to the left of the centre by one gap.
758 let corner = (cx - gap, cy + gap);
759 let covered = drawing.contours.iter().any(|contour| {
760 let xs: Vec<f64> = contour.iter().map(|p| p.0).collect();
761 let ys: Vec<f64> = contour.iter().map(|p| p.1).collect();
762 xs.iter().copied().fold(f64::MAX, f64::min) <= corner.0
763 && xs.iter().copied().fold(f64::MIN, f64::max) >= corner.0
764 && ys.iter().copied().fold(f64::MAX, f64::min) <= corner.1
765 && ys.iter().copied().fold(f64::MIN, f64::max) >= corner.1
766 });
767 assert!(covered, "`╔`'s outer corner is open");
768 }
769
770 #[test]
771 fn the_full_block_is_the_whole_cell_and_the_halves_meet_in_it() {
772 let full = bbox(0x2588);
773 same(full.0, 0.0);
774 same(full.1, -316.0);
775 same(full.2, 632.0);
776 same(full.3, 984.0);
777 let upper = bbox(0x2580);
778 let lower = bbox(0x2584);
779 // The halves meet with no seam and no overlap.
780 same(upper.1, lower.3);
781 same(lower.0, 0.0);
782 same(lower.1, -316.0);
783 same(upper.2, 632.0);
784 same(upper.3, 984.0);
785 }
786
787 #[test]
788 fn the_eighths_are_a_progression_and_the_ends_are_exact() {
789 same(bbox(0x258F).2, bbox(0x2588).2 / 8.0);
790 // From the cell floor, which is below the baseline: an eighth of a
791 // block is not a positive coordinate.
792 let mut last = f64::from(params().descent);
793 for cp in [
794 0x2581, 0x2582, 0x2583, 0x2584, 0x2585, 0x2586, 0x2587, 0x2588,
795 ] {
796 let top = bbox(cp).3;
797 assert!(top > last, "{cp:#06X} is not taller than the one before");
798 last = top;
799 }
800 }
801
802 #[test]
803 fn a_quadrant_is_a_quarter_in_the_right_corner() {
804 let params = params();
805 let (x0, y0, x1, y1) = bbox(0x2598); // upper left
806 same(x0, 0.0);
807 same(y1, f64::from(params.ascent));
808 assert!((x1 - params.center_x()).abs() <= 1.0);
809 assert!((y0 - params.cell_center_y()).abs() <= 1.0);
810 }
811
812 // A dashed line is one bar across the cell, not two arms meeting in the
813 // middle. Drawn as arms, `┄` came out with six marks and a seam where the
814 // junction would have been, which is not what its name says.
815 #[test]
816 fn a_dash_has_the_number_of_marks_its_name_says() {
817 for (cp, marks) in [
818 (0x2504, 3), // light triple dash horizontal
819 (0x2505, 3), // heavy triple dash horizontal
820 (0x2506, 3), // light triple dash vertical
821 (0x2508, 4), // light quadruple dash horizontal
822 (0x254C, 2), // light double dash horizontal
823 (0x254E, 2), // light double dash vertical
824 ] {
825 let drawing = draw(cell(cp), &params());
826 assert_eq!(
827 drawing.contours.len(),
828 marks,
829 "{} draws {} marks",
830 format_codepoint(cp),
831 drawing.contours.len()
832 );
833 }
834 // And it still spans the whole cell, or a dashed rule would not meet
835 // the one in the next cell along.
836 same(bbox(0x2504).0, 0.0);
837 same(bbox(0x2504).2, 632.0);
838 }
839
840 // The arc's two straight runs have to reach their own edges and its turn
841 // has to join them. Checked as coverage along each run rather than as a
842 // bounding box, which an arc that curled the wrong way would also satisfy —
843 // and one did, until it was rasterised.
844 #[test]
845 fn an_arc_runs_to_both_its_edges_and_turns_between_them() {
846 let params = params();
847 let contour = &draw(cell(0x256D), &params).contours[0]; // `╭`, right and down
848 let on_x = |x: f64| contour.iter().any(|p| (p.0 - x).abs() < 1.0);
849 let on_y = |y: f64| contour.iter().any(|p| (p.1 - y).abs() < 1.0);
850 assert!(on_x(f64::from(params.advance)), "no run to the right edge");
851 assert!(on_y(f64::from(params.descent)), "no run to the bottom edge");
852 // The turn is between the two, so points exist off both centre lines.
853 assert!(
854 contour.iter().any(|p| p.0 < f64::from(params.advance) - 1.0
855 && p.0 > params.center_x() + 1.0
856 && p.1 < params.cell_center_y() - 1.0),
857 "the corner is square rather than turned"
858 );
859 }
860
861 #[test]
862 fn the_shades_are_a_progression_in_coverage() {
863 let area = |cp: u32| -> f64 {
864 draw(cell(cp), &params())
865 .contours
866 .iter()
867 .map(|contour| {
868 let (x0, y0) = contour[0];
869 let (x1, y1) = contour[2];
870 (x1 - x0).abs() * (y1 - y0).abs()
871 })
872 .sum()
873 };
874 let (light, medium, dark) = (area(0x2591), area(0x2592), area(0x2593));
875 assert!(light < medium && medium < dark, "{light} {medium} {dark}");
876 let cell_area = 632.0 * 1300.0;
877 assert!(dark < cell_area, "dark shade is not a full block");
878 assert!(light > 0.0);
879 }
880
881 // `gvar` needs the same points at every master, so a recipe must not change
882 // its topology with the base's weight. Checked against a heavier params
883 // rather than argued.
884 #[test]
885 fn every_recipe_keeps_its_topology_when_the_base_gets_heavier() {
886 let light = params();
887 let heavy = BaseParams {
888 stem: 158,
889 stroke: 139,
890 band_x0: 58,
891 band_x1: 574,
892 ..light
893 };
894 for (cp, cell) in cells() {
895 let a = draw(cell, &light);
896 let b = draw(cell, &heavy);
897 assert_eq!(
898 a.contours.len(),
899 b.contours.len(),
900 "{} changes contour count with weight",
901 format_codepoint(cp)
902 );
903 for (i, (ca, cb)) in a.contours.iter().zip(b.contours.iter()).enumerate() {
904 assert_eq!(
905 ca.len(),
906 cb.len(),
907 "{} contour {i} changes point count with weight",
908 format_codepoint(cp)
909 );
910 }
911 }
912 }
913
914 // Nothing may spill outside the cell, or a glyph paints over its neighbour.
915 #[test]
916 fn nothing_draws_outside_its_own_cell() {
917 let params = params();
918 for (cp, cell) in cells() {
919 if matches!(cell, Cell::Diagonal(_)) {
920 // The diagonals are the one exception and are drawn to overrun
921 // deliberately: a stroke at an angle has to pass the corner to
922 // meet the one in the next cell.
923 continue;
924 }
925 for (x, y) in draw(cell, &params).contours.concat() {
926 assert!(
927 x >= -1.0 && x <= f64::from(params.advance) + 1.0,
928 "{} runs to x={x}",
929 format_codepoint(cp)
930 );
931 assert!(
932 y >= f64::from(params.descent) - 1.0 && y <= f64::from(params.ascent) + 1.0,
933 "{} runs to y={y}",
934 format_codepoint(cp)
935 );
936 }
937 }
938 }
939 }
940