Skip to main content

max / quasi-type

26.9 KB · 734 lines History Blame Raw
1 //! The parametric primitives.
2 //!
3 //! Every shape is built from the base's own measurements, so the same recipe
4 //! produces a mark tuned to whichever face it is cut into. That is what "same
5 //! design, not byte-identical" means in practice: `▲` in Quasi Mono and in a
6 //! future Quasi Body will not share an outline, and both read as the mark.
7 //!
8 //! All seven marks are straight-edged, so every contour is a polygon of
9 //! on-curve points. Nothing here emits a curve, and the day a recipe needs one
10 //! it gets a quadratic rather than a cubic, because that is what `glyf` stores.
11
12 use kurbo::BezPath;
13
14 use crate::base::BaseParams;
15 use crate::manifest::{Anchor, Dim, Direction, Shape};
16
17 /// A drawn mark, before it becomes a glyph.
18 pub struct Drawing {
19 pub contours: Vec<Vec<(f64, f64)>>,
20 }
21
22 impl Drawing {
23 /// A `BezPath` with every contour closed and wound the way `glyf` fills.
24 ///
25 /// TrueType fills non-zero with y up, so an outer contour runs clockwise,
26 /// which is a negative shoelace area. Winding is enforced here rather than
27 /// asked of each recipe: a mark that comes out inside-out is a bug nobody
28 /// sees until a renderer drops it.
29 pub fn to_bezpath(&self) -> BezPath {
30 let mut path = BezPath::new();
31 for contour in &self.contours {
32 let mut points = contour.clone();
33 if signed_area(&points) > 0.0 {
34 points.reverse();
35 }
36 let Some(&(x, y)) = points.first() else {
37 continue;
38 };
39 path.move_to((x, y));
40 for &(x, y) in &points[1..] {
41 path.line_to((x, y));
42 }
43 path.close_path();
44 }
45 path
46 }
47 }
48
49 /// The base's horizontal stroke weight, which is the set's one weight signal.
50 fn stroke_of(params: &BaseParams) -> f64 {
51 f64::from(params.stroke)
52 }
53
54 fn signed_area(points: &[(f64, f64)]) -> f64 {
55 let n = points.len();
56 let mut area = 0.0;
57 for i in 0..n {
58 let (x0, y0) = points[i];
59 let (x1, y1) = points[(i + 1) % n];
60 area += x0 * y1 - x1 * y0;
61 }
62 area / 2.0
63 }
64
65 pub fn draw(shape: &Shape, params: &BaseParams) -> Drawing {
66 match shape {
67 // 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),
71 Shape::Triangle {
72 direction,
73 span,
74 depth,
75 anchor,
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 ),
93 Shape::Cross {
94 width,
95 height,
96 stroke,
97 } => cross(params, *width, *height, *stroke),
98 Shape::OpenBox {
99 width,
100 height,
101 bottom,
102 stroke,
103 } => open_box(params, *width, *height, *bottom, *stroke),
104 Shape::ReturnArrow {
105 top,
106 shaft,
107 head_span,
108 head_depth,
109 stroke,
110 } => return_arrow(params, *top, *shaft, *head_span, *head_depth, *stroke),
111 }
112 }
113
114 fn anchor_y(params: &BaseParams, anchor: Anchor) -> f64 {
115 match anchor {
116 Anchor::BandCenter => params.band_center_y(),
117 Anchor::XHeight => params.x_height_center_y(),
118 }
119 }
120
121 /// A solid triangle, sized off the band and centred on the cell.
122 ///
123 /// `span` is measured across the base edge and `depth` from that edge to the
124 /// apex, both regardless of which way the mark points, so a left-pointing and
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
197 fn triangle(
198 params: &BaseParams,
199 direction: Direction,
200 span: Dim,
201 depth: f64,
202 anchor: Anchor,
203 ) -> Drawing {
204 let cx = params.center_x();
205 let cy = anchor_y(params, anchor);
206 let (span_px, depth_px) = match direction {
207 // A horizontal mark's span runs up the cell, so it comes off the band's
208 // height; a vertical mark's runs across, off the band's width.
209 Direction::Up | Direction::Down => {
210 let s = span.resolve(params.band_width(), stroke_of(params));
211 (s, s * depth)
212 }
213 Direction::Left | Direction::Right => {
214 let s = span.resolve(params.band_height(), stroke_of(params));
215 (s, s * depth)
216 }
217 };
218 let half_span = span_px / 2.0;
219 let half_depth = depth_px / 2.0;
220
221 let points = match direction {
222 Direction::Up => vec![
223 (cx - half_span, cy - half_depth),
224 (cx + half_span, cy - half_depth),
225 (cx, cy + half_depth),
226 ],
227 Direction::Down => vec![
228 (cx - half_span, cy + half_depth),
229 (cx + half_span, cy + half_depth),
230 (cx, cy - half_depth),
231 ],
232 Direction::Right => vec![
233 (cx - half_depth, cy - half_span),
234 (cx - half_depth, cy + half_span),
235 (cx + half_depth, cy),
236 ],
237 Direction::Left => vec![
238 (cx + half_depth, cy - half_span),
239 (cx + half_depth, cy + half_span),
240 (cx - half_depth, cy),
241 ],
242 };
243 Drawing {
244 contours: vec![points],
245 }
246 }
247
248 /// Two crossed strokes as one contour: the twelve-point X.
249 ///
250 /// Drawn as a single outline rather than two overlapping bars so the fill is
251 /// correct under any fill rule and the join at the centre is a real join.
252 fn cross(params: &BaseParams, width: Dim, height: Dim, stroke: f64) -> Drawing {
253 let cx = params.center_x();
254 let cy = params.band_center_y();
255 let base_stroke = stroke_of(params);
256 let half_w = width.resolve(params.band_width(), base_stroke) / 2.0;
257 let half_h = height.resolve(params.band_height(), base_stroke) / 2.0;
258 let weight = base_stroke * stroke;
259
260 // The arms only sit at 45 degrees when the extents are square, so both
261 // offsets are derived from the diagonal rather than assumed equal. `gap_y`
262 // is where the arms' inner edges meet above and below the centre; `gap_x`
263 // is the same meeting left and right. The arm ends are cut across the
264 // corner, which puts the same two offsets at each end.
265 let diagonal = half_w.hypot(half_h);
266 let gap_y = weight / 2.0 * diagonal / half_w;
267 let gap_x = weight / 2.0 * diagonal / half_h;
268
269 let points = vec![
270 (cx - half_w, cy + half_h - gap_y),
271 (cx - half_w + gap_x, cy + half_h),
272 (cx, cy + gap_y),
273 (cx + half_w - gap_x, cy + half_h),
274 (cx + half_w, cy + half_h - gap_y),
275 (cx + gap_x, cy),
276 (cx + half_w, cy - half_h + gap_y),
277 (cx + half_w - gap_x, cy - half_h),
278 (cx, cy - gap_y),
279 (cx - half_w + gap_x, cy - half_h),
280 (cx - half_w, cy - half_h + gap_y),
281 (cx - gap_x, cy),
282 ];
283 Drawing {
284 contours: vec![points],
285 }
286 }
287
288 /// `U+2423`, a box open at the top: two risers and a floor, one contour.
289 fn open_box(params: &BaseParams, width: Dim, height: Dim, bottom: f64, stroke: f64) -> Drawing {
290 let cx = params.center_x();
291 let base_stroke = stroke_of(params);
292 let half_w = width.resolve(params.band_width(), base_stroke) / 2.0;
293 let h = height.resolve(params.band_height(), base_stroke);
294 let y0 = params.band_height() * bottom;
295 let y1 = y0 + h;
296 // The risers take the vertical stroke weight and the floor the horizontal
297 // one, which is what the base does with every other box it draws.
298 let riser = f64::from(params.stem) * stroke;
299 let floor = f64::from(params.stroke) * stroke;
300
301 let points = vec![
302 (cx - half_w, y1),
303 (cx - half_w + riser, y1),
304 (cx - half_w + riser, y0 + floor),
305 (cx + half_w - riser, y0 + floor),
306 (cx + half_w - riser, y1),
307 (cx + half_w, y1),
308 (cx + half_w, y0),
309 (cx - half_w, y0),
310 ];
311 Drawing {
312 contours: vec![points],
313 }
314 }
315
316 /// `U+23CE`: a left-pointing arrow along the bottom with a riser at its right
317 /// end, drawn as one contour so the elbow is a join rather than an overlap.
318 fn return_arrow(
319 params: &BaseParams,
320 top: f64,
321 shaft: f64,
322 head_span: Dim,
323 head_depth: Dim,
324 stroke: f64,
325 ) -> Drawing {
326 let x0 = f64::from(params.band_x0);
327 let x1 = f64::from(params.band_x1);
328 let y_floor = f64::from(params.band_y0);
329 let band_h = params.band_height();
330
331 let weight = f64::from(params.stroke) * stroke;
332 let riser_weight = f64::from(params.stem) * stroke;
333 let half = weight / 2.0;
334 let shaft_y = y_floor + band_h * shaft;
335 let cap_y = y_floor + band_h * top;
336 // The head is solid geometry, so it comes off the band and holds still
337 // across weights. Sizing it in multiples of the stroke instead would grow
338 // it by two thirds into Bold and push its back past the riser.
339 let head_half = head_span.resolve(band_h, f64::from(params.stroke)) / 2.0;
340 let head_x = x0 + head_depth.resolve(x1 - x0, f64::from(params.stroke));
341
342 let points = vec![
343 // The tip, then up the head's back and into the shaft.
344 (x0, shaft_y),
345 (head_x, shaft_y + head_half),
346 (head_x, shaft_y + half),
347 // Along the shaft's top edge to the riser, then up it.
348 (x1 - riser_weight, shaft_y + half),
349 (x1 - riser_weight, cap_y),
350 (x1, cap_y),
351 // Down the riser's right edge and back along the shaft's underside.
352 (x1, shaft_y - half),
353 (head_x, shaft_y - half),
354 (head_x, shaft_y - head_half),
355 ];
356 Drawing {
357 contours: vec![points],
358 }
359 }
360
361 #[cfg(test)]
362 mod tests {
363 use super::*;
364 use crate::manifest::{Manifest, WeightResponse};
365
366 /// Atkinson Hyperlegible Mono at `wght` 200, its own default instance from
367 /// the pinned file (`quasi-type params`).
368 ///
369 /// These were Plex Mono Regular and Bold until the base moved. The two ends
370 /// of one axis are a wider span than two static cuts were — 200 to 800
371 /// against 400 to 700 — which is why the coefficients they check had to be
372 /// refitted rather than carried over.
373 fn light() -> BaseParams {
374 BaseParams {
375 upem: 1000,
376 advance: 632,
377 cap_height: 668,
378 x_height: 496,
379 stem: 54,
380 stroke: 55,
381 band_x0: 68,
382 band_x1: 564,
383 band_y0: 0,
384 band_y1: 496,
385 ascent: 984,
386 descent: -316,
387 }
388 }
389
390 /// The far end of the axis, `wght` 800. The cell holds and the strokes
391 /// nearly triple, which is the measurement the set's weight rule rests on:
392 /// the base's stem runs 54 units to 158 where Plex's ran 70 to 126.
393 fn heavy() -> BaseParams {
394 BaseParams {
395 stem: 158,
396 stroke: 139,
397 // The band widens with the weight; its height does not.
398 band_x0: 58,
399 band_x1: 574,
400 ..light()
401 }
402 }
403
404 fn bounds(drawing: &Drawing) -> (f64, f64, f64, f64) {
405 let points = drawing.contours.concat();
406 let xs: Vec<f64> = points.iter().map(|p| p.0).collect();
407 let ys: Vec<f64> = points.iter().map(|p| p.1).collect();
408 (
409 xs.iter().copied().fold(f64::MAX, f64::min),
410 ys.iter().copied().fold(f64::MAX, f64::min),
411 xs.iter().copied().fold(f64::MIN, f64::max),
412 ys.iter().copied().fold(f64::MIN, f64::max),
413 )
414 }
415
416 /// How much of its own bounding box a mark inks in.
417 ///
418 /// Contours are summed rather than unioned, so a shaft running into its own
419 /// head counts twice. That is the same reading the built face is measured
420 /// with, and both are comparing a mark against itself at another weight.
421 fn fill_of(drawing: &Drawing) -> f64 {
422 let ink: f64 = drawing.contours.iter().map(|c| signed_area(c).abs()).sum();
423 let (x0, y0, x1, y1) = bounds(drawing);
424 ink / ((x1 - x0) * (y1 - y0))
425 }
426
427 fn shape(name: &str) -> Shape {
428 let manifest = Manifest::parse(crate::HOUSE_SET).unwrap();
429 let mut glyphs = manifest.glyphs;
430 let index = glyphs
431 .iter()
432 .position(|g| g.name == name)
433 .unwrap_or_else(|| panic!("no glyph {name}"));
434 glyphs.swap_remove(index).shape
435 }
436
437 /// Two heads on one shaft have to leave a shaft between them.
438 ///
439 /// At the single-ended arrows' proportions they do not: two heads take 82%
440 /// of the length, meet in the middle, and the mark reads as a bowtie. That
441 /// is why `↕` carries its own smaller head rather than the set's, and this
442 /// is the measurement behind it rather than the eye that caught it.
443 #[test]
444 fn an_arrow_with_two_heads_still_has_a_shaft() {
445 let params = light();
446 let drawing = draw(&shape("uni2195"), &params);
447 let heads: Vec<&Vec<(f64, f64)>> =
448 drawing.contours.iter().filter(|c| c.len() == 3).collect();
449 assert_eq!(heads.len(), 2, "`↕` has a head at each end");
450 let top = heads
451 .iter()
452 .map(|c| c.iter().map(|p| p.1).fold(f64::MIN, f64::max))
453 .fold(f64::MIN, f64::max);
454 let bottom = heads
455 .iter()
456 .map(|c| c.iter().map(|p| p.1).fold(f64::MAX, f64::min))
457 .fold(f64::MAX, f64::min);
458 let head_depth = heads
459 .iter()
460 .map(|c| {
461 let ys: Vec<f64> = c.iter().map(|p| p.1).collect();
462 ys.iter().copied().fold(f64::MIN, f64::max)
463 - ys.iter().copied().fold(f64::MAX, f64::min)
464 })
465 .fold(f64::MIN, f64::max);
466 let shaft = (top - bottom) - head_depth * 2.0;
467 assert!(
468 shaft > (top - bottom) * 0.25,
469 "the heads leave {shaft:.0} units of shaft in {:.0} of arrow",
470 top - bottom
471 );
472 }
473
474 #[test]
475 fn every_mark_fits_inside_the_cell() {
476 let manifest = Manifest::parse(crate::HOUSE_SET).unwrap();
477 for params in [light(), heavy()] {
478 // Band-relative marks only. Cell furniture is sized against the cell
479 // instead and obeys different rules, which `crate::cells` asserts.
480 for glyph in manifest
481 .glyphs
482 .iter()
483 .filter(|glyph| !glyph.shape.is_cell_furniture())
484 {
485 let drawing = draw(&glyph.shape, &params);
486 let (x0, _, x1, _) = bounds(&drawing);
487 assert!(
488 x0 >= 0.0 && x1 <= f64::from(params.advance),
489 "{} runs outside the cell: x[{x0}, {x1}] in {}",
490 glyph.name,
491 params.advance
492 );
493 }
494 }
495 }
496
497 #[test]
498 fn every_mark_sits_above_the_descender() {
499 let manifest = Manifest::parse(crate::HOUSE_SET).unwrap();
500 let params = light();
501 // Band-relative marks only. Cell furniture is sized against the cell
502 // instead and obeys different rules, which `crate::cells` asserts.
503 for glyph in manifest
504 .glyphs
505 .iter()
506 .filter(|glyph| !glyph.shape.is_cell_furniture())
507 {
508 let (_, y0, _, y1) = bounds(&draw(&glyph.shape, &params));
509 assert!(
510 y0 > -350.0,
511 "{} dips below the base's descender",
512 glyph.name
513 );
514 assert!(
515 y1 <= f64::from(params.cap_height) + 60.0,
516 "{} rides above cap height",
517 glyph.name
518 );
519 }
520 }
521
522 #[test]
523 fn marks_are_centred_on_the_cell_not_the_band() {
524 let params = light();
525 for name in ["uni25B2", "uni25BC", "uni25B8", "uni25C2", "uni2718"] {
526 let (x0, _, x1, _) = bounds(&draw(&shape(name), &params));
527 let centre = f64::midpoint(x0, x1);
528 assert!(
529 (centre - params.center_x()).abs() < 0.51,
530 "{name} centres at {centre}, not {}",
531 params.center_x()
532 );
533 }
534 }
535
536 #[test]
537 fn every_mark_answers_a_heavier_base() {
538 let manifest = Manifest::parse(crate::HOUSE_SET).unwrap();
539 // Band-relative marks only. Cell furniture is sized against the cell
540 // instead and obeys different rules, which `crate::cells` asserts.
541 for glyph in manifest
542 .glyphs
543 .iter()
544 .filter(|glyph| !glyph.shape.is_cell_furniture())
545 {
546 let light = draw(&glyph.shape, &light()).to_bezpath();
547 let bold = draw(&glyph.shape, &heavy()).to_bezpath();
548 assert_ne!(
549 light.to_svg(),
550 bold.to_svg(),
551 "{} is drawn identically at both weights, so it will read light \
552 inside the Bold face",
553 glyph.name
554 );
555 }
556 }
557
558 /// The discriminator is fill, not extent.
559 ///
560 /// It was extent until the base moved, and that read the two responses apart
561 /// only because Plex's two static cuts were 400 and 700 apart. Over a whole
562 /// 200-800 axis a stroked mark's own band term carries it 1.15x wider on its
563 /// own, so every mark grows and the test said nothing. What the two
564 /// responses actually mean is how much of its own box a mark inks: a solid
565 /// mark scales, so it inks the same share of a larger box, and a stroked one
566 /// thickens inside a box that barely moves.
567 #[test]
568 fn a_solid_mark_scales_and_a_stroked_one_thickens() {
569 let manifest = Manifest::parse(crate::HOUSE_SET).unwrap();
570 // Band-relative marks only. Cell furniture is sized against the cell
571 // instead and obeys different rules, which `crate::cells` asserts.
572 for glyph in manifest
573 .glyphs
574 .iter()
575 .filter(|glyph| !glyph.shape.is_cell_furniture())
576 {
577 let thin = fill_of(&draw(&glyph.shape, &light()));
578 let thick = fill_of(&draw(&glyph.shape, &heavy()));
579 match glyph.shape.weight_response() {
580 WeightResponse::Grows => {
581 let light_bounds = bounds(&draw(&glyph.shape, &light()));
582 let heavy_bounds = bounds(&draw(&glyph.shape, &heavy()));
583 assert!(
584 (thick - thin).abs() < 0.01,
585 "{} inks {thin:.3} of its box at the light end and {thick:.3} at the \
586 heavy one. A solid mark scales, so its fill is the shape's own constant.",
587 glyph.name
588 );
589 assert!(
590 (heavy_bounds.2 - heavy_bounds.0) > (light_bounds.2 - light_bounds.0) + 0.5,
591 "{} has no stroke to thicken, so it has to grow",
592 glyph.name
593 );
594 }
595 // Only the generated cell fills hold, and the manifest's own
596 // marks are never one.
597 WeightResponse::Holds => unreachable!("{} is an authored mark", glyph.name),
598 WeightResponse::Thickens => assert!(
599 thick > thin * 1.3,
600 "{} inks {thin:.3} of its box at the light end and {thick:.3} at the heavy \
601 one, so its stroke is not following the base's",
602 glyph.name
603 ),
604 }
605 }
606 }
607
608 /// An open box has to stay open, which is the rule its recalibration holds.
609 ///
610 /// The counter is what makes the mark read as a box rather than as a blob,
611 /// and it is the base's stroke that eats it: Atkinson's nearly triples
612 /// across the axis where Plex's grew 1.8x between two static cuts. So this
613 /// is checked at the heavy end, where the aperture is scarce, and it is
614 /// checked as a share of the box rather than in units, so it refits.
615 #[test]
616 fn the_space_render_keeps_its_counter_open() {
617 let Shape::OpenBox {
618 width,
619 height,
620 stroke,
621 ..
622 } = shape("uni2423")
623 else {
624 panic!("uni2423 is not an open box");
625 };
626 for params in [light(), heavy()] {
627 let bar = f64::from(params.stroke) * stroke;
628 let box_width = width.resolve(params.band_width(), f64::from(params.stroke));
629 let box_height = height.resolve(params.band_height(), f64::from(params.stroke));
630 // Two risers across, and one bar up: the box is open at the top.
631 let across = (box_width - 2.0 * bar) / box_width;
632 let up = (box_height - bar) / box_height;
633 assert!(
634 across > 0.45 && up > 0.45,
635 "the space render's counter is {:.0}% of its width and {:.0}% of its height at \
636 stroke {}, which closes up into a blob rather than reading as a box",
637 across * 100.0,
638 up * 100.0,
639 params.stroke
640 );
641 }
642 }
643
644 /// A head that does not clear its own shaft is not an arrowhead.
645 ///
646 /// The bound is the shaft's stroke: the head has to stand at least half a
647 /// stroke proud on each side at every weight. Bold is where this bites,
648 /// since the shaft thickens by 69% and a band-sized head does not.
649 #[test]
650 fn the_return_arrows_head_clears_its_shaft() {
651 let Shape::ReturnArrow {
652 head_span, stroke, ..
653 } = shape("uni23CE")
654 else {
655 panic!("uni23CE is not a return arrow");
656 };
657 for params in [light(), heavy()] {
658 let band = params.band_height();
659 let weight = f64::from(params.stroke) * stroke;
660 let head = head_span.resolve(band, f64::from(params.stroke));
661 let proud = (head - weight) / 2.0;
662 assert!(
663 proud >= weight * 0.5,
664 "the head stands {proud:.0} proud of a {weight:.0} shaft"
665 );
666 }
667 }
668
669 #[test]
670 fn the_carets_are_reflections_of_each_other() {
671 let params = light();
672 let up = bounds(&draw(&shape("uni25B2"), &params));
673 let down = bounds(&draw(&shape("uni25BC"), &params));
674 assert!((up.0 - down.0).abs() < 0.01 && (up.2 - down.2).abs() < 0.01);
675 let cy = params.band_center_y();
676 assert!(
677 ((up.1 - cy) + (down.3 - cy)).abs() < 0.01,
678 "▲ and ▼ are not mirrored about the band"
679 );
680 }
681
682 #[test]
683 fn every_contour_comes_out_wound_for_glyf() {
684 let manifest = Manifest::parse(crate::HOUSE_SET).unwrap();
685 let params = light();
686 for glyph in &manifest.glyphs {
687 let drawing = draw(&glyph.shape, &params);
688 for contour in &drawing.contours {
689 let mut points = contour.clone();
690 if signed_area(&points) > 0.0 {
691 points.reverse();
692 }
693 assert!(
694 signed_area(&points) < 0.0,
695 "{} has a degenerate contour",
696 glyph.name
697 );
698 }
699 }
700 }
701
702 /// The X is calibrated against the base's own cross rather than by eye.
703 ///
704 /// Atkinson Hyperlegible Mono's `×` (U+00D7) fills 30.0% of its bounding
705 /// box at `wght` 200 and 56.9% at 800 by flattening its outline. U+2718 is
706 /// the HEAVY ballot X, so it has to sit above that and not far above it.
707 /// The first attempt used a flat 1.5x the base stroke and put 213 units of
708 /// ink across a 380-unit mark, which is the failure this bounds — and the
709 /// coefficient is refitted per base rather than carried over, since Plex's
710 /// numbers were 38.2% and 53.4% and its bar was heavier against its own
711 /// cross than Atkinson's is.
712 #[test]
713 fn the_cross_is_heavier_than_the_bases_own_and_no_heavier() {
714 for (params, base_fill) in [(light(), 0.300), (heavy(), 0.569)] {
715 let drawing = draw(&shape("uni2718"), &params);
716 let ink = signed_area(&drawing.contours[0]).abs();
717 let (x0, y0, x1, y1) = bounds(&drawing);
718 let fill = ink / ((x1 - x0) * (y1 - y0));
719 assert!(
720 fill > base_fill,
721 "the X fills {:.1}%, lighter than the base's own cross at {:.1}%",
722 fill * 100.0,
723 base_fill * 100.0
724 );
725 assert!(
726 fill < base_fill + 0.04,
727 "the X fills {:.1}%, well past the base's {:.1}%",
728 fill * 100.0,
729 base_fill * 100.0
730 );
731 }
732 }
733 }
734