Skip to main content

max / quasi-type

13.9 KB · 405 lines History Blame Raw
1 //! The house glyph set, as data.
2 //!
3 //! The set is the durable artifact and this module is only its schema. Shapes
4 //! are named recipes with ratio parameters; nothing here knows about a font.
5
6 use serde::Deserialize;
7
8 use crate::Error;
9
10 /// A dimension, in the set's only unit: a share of the base's symbol band plus
11 /// a term that follows the base's stroke weight.
12 ///
13 /// ```text
14 /// value = band_extent * band + base_stroke * weight
15 /// ```
16 ///
17 /// Plex Mono redraws its own symbols heavier in Bold rather than merely
18 /// thickening them in place: `•` goes 244 to 308 units wide, `×` 424 to 460,
19 /// `↑` 414 to 478. Only the block elements hold still, and those are cell-fill
20 /// primitives rather than symbols. A mark sized off the band alone would
21 /// therefore stay Regular's size inside a Bold face and read light among its
22 /// neighbours.
23 ///
24 /// Written as a bare number when the weight term is zero.
25 #[derive(Debug, Clone, Copy)]
26 pub struct Dim {
27 pub band: f64,
28 pub weight: f64,
29 }
30
31 impl Dim {
32 pub fn resolve(self, band_extent: f64, base_stroke: f64) -> f64 {
33 band_extent * self.band + base_stroke * self.weight
34 }
35 }
36
37 impl<'de> Deserialize<'de> for Dim {
38 fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
39 #[derive(Deserialize)]
40 #[serde(untagged)]
41 enum Raw {
42 Flat(f64),
43 Pair {
44 band: f64,
45 #[serde(default)]
46 weight: f64,
47 },
48 }
49 Ok(match Raw::deserialize(deserializer)? {
50 Raw::Flat(band) => Dim { band, weight: 0.0 },
51 Raw::Pair { band, weight } => Dim { band, weight },
52 })
53 }
54 }
55
56 #[derive(Debug, Deserialize)]
57 pub struct Manifest {
58 pub set: SetMeta,
59 #[serde(default, rename = "glyph")]
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,
74 }
75
76 #[derive(Debug, Deserialize)]
77 pub struct SetMeta {
78 pub version: u32,
79 pub name: String,
80 }
81
82 #[derive(Debug, Deserialize)]
83 pub struct GlyphSpec {
84 /// The codepoint the mark is mapped at.
85 pub codepoint: u32,
86 /// The glyph's production name. `uniXXXX` by convention.
87 pub name: String,
88 /// What the mark is for, in one line. Read by `quasi-type list`.
89 pub role: String,
90 /// Where the requirement came from, when there is a decision behind it.
91 #[serde(default)]
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,
97 #[serde(flatten)]
98 pub shape: Shape,
99 }
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
131 /// A drawing recipe. Every dimension is a ratio of something measured off the
132 /// base face, so a shape is refitted to the next base rather than redrawn.
133 #[derive(Debug, Deserialize)]
134 #[serde(tag = "shape", rename_all = "kebab-case")]
135 pub enum Shape {
136 /// A solid triangle.
137 Triangle {
138 direction: Direction,
139 /// Extent across the base edge.
140 span: Dim,
141 /// Apex distance, as a ratio of `span`. A pure ratio, so 0.866 stays
142 /// equilateral at every weight.
143 depth: f64,
144 #[serde(default)]
145 anchor: Anchor,
146 },
147 /// Two crossed strokes.
148 Cross {
149 width: Dim,
150 height: Dim,
151 /// Stroke as a multiple of the base's own bar.
152 stroke: f64,
153 },
154 /// A `U` open at the top: U+2423's usual drawing.
155 OpenBox {
156 width: Dim,
157 height: Dim,
158 /// Distance above the baseline, as a ratio of the band's height.
159 bottom: f64,
160 /// Stroke as a multiple of the base's own, since the mark is a stroke.
161 stroke: f64,
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),
192 /// A left-pointing arrow with a riser at its right end: U+23CE.
193 ReturnArrow {
194 /// The riser's flat cap, as a ratio of the band's height.
195 top: f64,
196 /// The shaft's centre, as a ratio of the band's height above its floor.
197 shaft: f64,
198 /// Arrowhead extent across the shaft, off the band's height.
199 head_span: Dim,
200 /// Arrowhead depth along the shaft, off the band's width.
201 head_depth: Dim,
202 /// Stroke as a multiple of the base's own.
203 stroke: f64,
204 },
205 }
206
207 impl Shape {
208 pub fn kind(&self) -> &'static str {
209 match self {
210 Shape::Arrow { .. } => "arrow",
211 Shape::Cell(cell) => cell.kind(),
212 Shape::Triangle { .. } => "triangle",
213 Shape::Cross { .. } => "cross",
214 Shape::OpenBox { .. } => "open-box",
215 Shape::ReturnArrow { .. } => "return-arrow",
216 }
217 }
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
231 /// How a mark answers a heavier base.
232 ///
233 /// Everything in the set answers; the two kinds differ in what moves. A
234 /// solid mark has no stroke to thicken, so it grows, exactly as the base
235 /// grows `•` and `×`. A stroked mark holds its extent and thickens, which
236 /// is what the base does to `+`: same bbox in both weights, heavier bars.
237 pub fn weight_response(&self) -> WeightResponse {
238 match self {
239 Shape::Cell(cell) => cell.weight_response(),
240 Shape::Triangle { .. } => WeightResponse::Grows,
241 Shape::Arrow { .. }
242 | Shape::Cross { .. }
243 | Shape::OpenBox { .. }
244 | Shape::ReturnArrow { .. } => WeightResponse::Thickens,
245 }
246 }
247 }
248
249 /// What a heavier base moves in a mark.
250 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
251 pub enum WeightResponse {
252 /// The mark's extent grows; it has no stroke to thicken.
253 Grows,
254 /// The mark holds its extent and its stroke thickens.
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,
263 }
264
265 #[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
266 #[serde(rename_all = "kebab-case")]
267 pub enum Direction {
268 Up,
269 Down,
270 Left,
271 Right,
272 }
273
274 #[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize)]
275 #[serde(rename_all = "kebab-case")]
276 pub enum Anchor {
277 /// Centred on the symbol band, which is where the base puts `+`.
278 #[default]
279 BandCenter,
280 /// Centred on the x-height, for marks that sit among lowercase.
281 XHeight,
282 }
283
284 impl Manifest {
285 pub fn parse(source: &str) -> Result<Self, Error> {
286 let mut manifest: Manifest =
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);
292 let mut seen: Vec<u32> = Vec::new();
293 for glyph in &manifest.glyphs {
294 if char::from_u32(glyph.codepoint).is_none() {
295 return Err(Error::Manifest(format!(
296 "{} is not a Unicode scalar value",
297 format_codepoint(glyph.codepoint)
298 )));
299 }
300 if seen.contains(&glyph.codepoint) {
301 return Err(Error::Manifest(format!(
302 "{} appears twice in the set",
303 format_codepoint(glyph.codepoint)
304 )));
305 }
306 seen.push(glyph.codepoint);
307 }
308 Ok(manifest)
309 }
310 }
311
312 pub fn format_codepoint(codepoint: u32) -> String {
313 format!("U+{codepoint:04X}")
314 }
315
316 #[cfg(test)]
317 mod tests {
318 use super::*;
319
320 #[test]
321 fn the_shipped_set_parses() {
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!(
329 authored, 16,
330 "the seven marks, five arrows and four scroll markers"
331 );
332 assert_eq!(
333 manifest.glyphs.len(),
334 176,
335 "plus both generated blocks, 160 of them"
336 );
337 }
338
339 #[test]
340 fn a_repeated_codepoint_is_rejected() {
341 let source = r#"
342 [set]
343 version = 1
344 name = "t"
345 [[glyph]]
346 codepoint = 0x25B2
347 name = "a"
348 role = "r"
349 shape = "triangle"
350 direction = "up"
351 span = 1.0
352 depth = 1.0
353 [[glyph]]
354 codepoint = 0x25B2
355 name = "b"
356 role = "r"
357 shape = "triangle"
358 direction = "down"
359 span = 1.0
360 depth = 1.0
361 "#;
362 let err = Manifest::parse(source).unwrap_err();
363 assert!(err.to_string().contains("twice"), "{err}");
364 }
365
366 #[test]
367 fn solid_marks_grow_and_stroked_marks_thicken() {
368 let manifest = Manifest::parse(crate::HOUSE_SET).unwrap();
369 for glyph in manifest
370 .glyphs
371 .iter()
372 .filter(|glyph| !glyph.shape.is_cell_furniture())
373 {
374 let expected = match glyph.shape.kind() {
375 "triangle" => WeightResponse::Grows,
376 _ => WeightResponse::Thickens,
377 };
378 assert_eq!(
379 glyph.shape.weight_response(),
380 expected,
381 "{} ({})",
382 glyph.name,
383 glyph.shape.kind()
384 );
385 }
386 }
387
388 #[test]
389 fn a_dimension_reads_as_a_bare_number_or_as_a_pair() {
390 let flat: Dim = toml::from_str::<toml::Value>("v = 0.5").unwrap()["v"]
391 .clone()
392 .try_into()
393 .unwrap();
394 assert!((flat.band - 0.5).abs() < 1e-9 && flat.weight == 0.0);
395 let pair: Dim = toml::from_str::<toml::Value>("v = { band = 0.5, weight = 0.6 }").unwrap()
396 ["v"]
397 .clone()
398 .try_into()
399 .unwrap();
400 assert!((pair.band - 0.5).abs() < 1e-9 && (pair.weight - 0.6).abs() < 1e-9);
401 // 476 units of band and an 84-unit stroke: Plex Mono Regular.
402 assert!((pair.resolve(476.0, 84.0) - (476.0 * 0.5 + 84.0 * 0.6)).abs() < 1e-9);
403 }
404 }
405