Skip to main content

max / quasi-type

11.8 KB · 339 lines History Blame Raw
1 //! The pinned bases and the slots cut from them.
2
3 use serde::Deserialize;
4
5 use crate::Error;
6
7 #[derive(Debug, Deserialize)]
8 pub struct Pins {
9 #[serde(default, rename = "base")]
10 pub bases: Vec<Base>,
11 #[serde(default, rename = "slot")]
12 pub slots: Vec<Slot>,
13 }
14
15 #[derive(Debug, Deserialize)]
16 pub struct Base {
17 pub id: String,
18 /// The upstream family name. Never reused in an output name.
19 pub family: String,
20 pub version: String,
21 /// The archive upstream publishes, when it publishes one. Absent for a base
22 /// pinned file by file; see `Face::url`.
23 #[serde(default)]
24 pub url: Option<String>,
25 /// sha256 of the archive. A moved upstream fails here.
26 #[serde(default)]
27 pub sha256: Option<String>,
28 pub license: String,
29 /// Path to the licence inside the archive.
30 #[serde(default)]
31 pub license_path: Option<String>,
32 /// Where the licence is fetched from, for a base with no archive.
33 #[serde(default)]
34 pub license_url: Option<String>,
35 #[serde(default)]
36 pub license_sha256: Option<String>,
37 /// OFL 1.1 clause 3, recorded so the naming gate is data rather than lore.
38 #[serde(default)]
39 pub reserved_font_name: Option<String>,
40 pub copyright: String,
41 pub designer: String,
42 #[serde(default)]
43 pub vendor_url: Option<String>,
44 #[serde(rename = "face")]
45 pub faces: Vec<Face>,
46 }
47
48 #[derive(Debug, Deserialize)]
49 pub struct Face {
50 /// The style this face carries. For a variable face it is the style of the
51 /// **default instance**, which is not always `Regular`: Atkinson Mono's
52 /// default is `wght` 200 and its own name table reads `ExtraLight`. The
53 /// build asserts this against the base rather than trusting it, so the
54 /// ExtraLight default cannot arrive by accident.
55 pub style: String,
56 /// Path inside the archive, for an archive-pinned base.
57 #[serde(default)]
58 pub path: Option<String>,
59 /// Where the face is fetched from, for a base with no archive.
60 #[serde(default)]
61 pub url: Option<String>,
62 pub sha256: String,
63 /// Declares that this face carries an `fvar` axis. Asserted against the
64 /// face, so a base that stops being variable fails rather than quietly
65 /// cutting a static instance.
66 #[serde(default)]
67 pub variable: bool,
68 }
69
70 impl Face {
71 /// What the cached copy is called. A path inside an archive keeps its file
72 /// name; a file-pinned face is named for its pin, since two bases may
73 /// publish files with the same name.
74 pub fn cache_name(&self, base_id: &str, base_version: &str) -> String {
75 let leaf = self
76 .path
77 .as_deref()
78 .or(self.url.as_deref())
79 .unwrap_or(&self.style)
80 .rsplit('/')
81 .next()
82 .unwrap_or(&self.style);
83 format!("{base_id}-{base_version}-{leaf}")
84 }
85 }
86
87 #[derive(Debug, Deserialize)]
88 pub struct Slot {
89 pub id: String,
90 /// The output family. Tracks the slot, never the base.
91 pub family: String,
92 pub base: String,
93 /// `"*"` for the whole house set, `"marks"` for the authored tier only, or
94 /// a list of codepoints.
95 #[serde(default = "everything")]
96 pub glyphs: GlyphSelection,
97 }
98
99 fn everything() -> GlyphSelection {
100 GlyphSelection::All
101 }
102
103 #[derive(Debug)]
104 pub enum GlyphSelection {
105 All,
106 /// The authored tier: the marks, and not the generated cell furniture.
107 ///
108 /// Box drawing and block elements are sized against the cell and have to be
109 /// cell-exact to tile, which is a property a proportional face does not
110 /// have and cannot be given: its glyphs carry their own widths, so nothing
111 /// set in it tiles. A body face carrying a `┌` would be carrying a glyph
112 /// that draws a corner joining nothing.
113 Marks,
114 Some(Vec<u32>),
115 }
116
117 impl<'de> Deserialize<'de> for GlyphSelection {
118 fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
119 #[derive(Deserialize)]
120 #[serde(untagged)]
121 enum Raw {
122 Star(String),
123 List(Vec<u32>),
124 }
125 match Raw::deserialize(deserializer)? {
126 Raw::Star(s) if s == "*" => Ok(GlyphSelection::All),
127 Raw::Star(s) if s == "marks" => Ok(GlyphSelection::Marks),
128 Raw::Star(s) => Err(serde::de::Error::custom(format!(
129 "expected \"*\", \"marks\" or a list of codepoints, got {s:?}"
130 ))),
131 Raw::List(list) => Ok(GlyphSelection::Some(list)),
132 }
133 }
134 }
135
136 impl GlyphSelection {
137 pub fn includes(&self, glyph: &crate::manifest::GlyphSpec) -> bool {
138 match self {
139 GlyphSelection::All => true,
140 GlyphSelection::Marks => !glyph.shape.is_cell_furniture(),
141 GlyphSelection::Some(list) => list.contains(&glyph.codepoint),
142 }
143 }
144 }
145
146 impl Pins {
147 pub fn parse(source: &str) -> Result<Self, Error> {
148 let pins: Pins = toml::from_str(source).map_err(|e| Error::Pins(e.to_string()))?;
149 for base in &pins.bases {
150 base.check_pinning()?;
151 }
152 for slot in &pins.slots {
153 if !pins.bases.iter().any(|b| b.id == slot.base) {
154 return Err(Error::Pins(format!(
155 "slot `{}` names base `{}`, which is not pinned",
156 slot.id, slot.base
157 )));
158 }
159 }
160 Ok(pins)
161 }
162
163 pub fn slot(&self, id: &str) -> Result<&Slot, Error> {
164 self.slots
165 .iter()
166 .find(|s| s.id == id)
167 .ok_or_else(|| Error::UnknownSlot {
168 asked: id.to_owned(),
169 known: self.slots.iter().map(|s| s.id.clone()).collect(),
170 })
171 }
172
173 pub fn base(&self, id: &str) -> Result<&Base, Error> {
174 self.bases
175 .iter()
176 .find(|b| b.id == id)
177 .ok_or_else(|| Error::Pins(format!("base `{id}` is not pinned")))
178 }
179 }
180
181 impl Base {
182 /// Whether the base is fetched as one archive or as pinned files.
183 ///
184 /// Both shapes exist upstream and neither is a preference. IBM publishes a
185 /// release zip; the Atkinson repositories publish no releases at all, so the
186 /// only stable thing to pin is a file at a commit. A source tarball would be
187 /// the third option and is the worst of them: GitHub generates it on demand
188 /// and has changed the bytes it generates before, which would read here as
189 /// "upstream moved" on a repository nobody touched.
190 pub fn is_archive(&self) -> bool {
191 self.url.is_some()
192 }
193
194 /// A base is pinned one way or the other, never half of each.
195 fn check_pinning(&self) -> Result<(), Error> {
196 let id = &self.id;
197 if self.url.is_some() != self.sha256.is_some() {
198 return Err(Error::Pins(format!(
199 "base `{id}` gives an archive url without its sha256, or the reverse"
200 )));
201 }
202 for face in &self.faces {
203 match (self.is_archive(), face.path.is_some(), face.url.is_some()) {
204 (true, true, false) | (false, false, true) => {}
205 (true, _, _) => {
206 return Err(Error::Pins(format!(
207 "base `{id}` is pinned as an archive, so face `{}` needs a `path` \
208 inside it and no `url` of its own",
209 face.style
210 )));
211 }
212 (false, _, _) => {
213 return Err(Error::Pins(format!(
214 "base `{id}` has no archive, so face `{}` needs its own `url`",
215 face.style
216 )));
217 }
218 }
219 }
220 match (
221 self.is_archive(),
222 self.license_path.is_some(),
223 self.license_url.is_some(),
224 ) {
225 (true, true, false) | (false, false, true) => {}
226 (true, _, _) => {
227 return Err(Error::Pins(format!(
228 "base `{id}` is pinned as an archive, so its licence needs a `license_path`"
229 )));
230 }
231 (false, _, _) => {
232 return Err(Error::Pins(format!(
233 "base `{id}` has no archive, so its licence needs a `license_url`. \
234 The OFL requires the text to travel with a modified build, so a base \
235 with nowhere to read it from cannot be cut."
236 )));
237 }
238 }
239 Ok(())
240 }
241
242 /// The one licence gate that is mechanical: an output name must not carry
243 /// the base's Reserved Font Name. OFL 1.1 clause 3 bars it as a prefix and
244 /// as a suffix alike, so this is a substring test and not a word test.
245 pub fn check_output_name(&self, family: &str) -> Result<(), Error> {
246 let Some(reserved) = &self.reserved_font_name else {
247 return Ok(());
248 };
249 if family.to_lowercase().contains(&reserved.to_lowercase()) {
250 return Err(Error::ReservedFontName {
251 family: family.to_owned(),
252 reserved: reserved.clone(),
253 base: self.family.clone(),
254 });
255 }
256 Ok(())
257 }
258 }
259
260 #[cfg(test)]
261 mod tests {
262 use super::*;
263
264 fn pins() -> Pins {
265 Pins::parse(crate::PINS).expect("the shipped pins parse")
266 }
267
268 #[test]
269 fn quasi_mono_is_cut_from_the_pinned_atkinson() {
270 let pins = pins();
271 let slot = pins.slot("quasi-mono").unwrap();
272 assert_eq!(slot.family, "Quasi Mono");
273 let base = pins.base(&slot.base).unwrap();
274 assert_eq!(base.family, "Atkinson Hyperlegible Mono");
275 assert_eq!(
276 base.faces.len(),
277 1,
278 "one variable file covers the range; italic is not cut"
279 );
280 }
281
282 /// Atkinson declares no Reserved Font Name, so the gate this file
283 /// documents has nothing to bite on for the base in the tree today. It is
284 /// still the gate so it is exercised against a pin written here rather
285 /// than dropped along with the base that made it necessary.
286 #[test]
287 fn the_house_name_clears_the_reserved_font_name() {
288 let pins = pins();
289 let base = pins.base("atkinson-mono").unwrap();
290 assert!(base.reserved_font_name.is_none());
291 base.check_output_name("Quasi Mono")
292 .expect("Quasi clears it");
293 base.check_output_name("Alloyed Atkinson")
294 .expect("no RFN, so the general fork rule works on this base");
295 }
296
297 #[test]
298 fn reaching_for_the_bases_name_is_refused() {
299 let source = r#"
300 [[base]]
301 id = "reserved"
302 family = "IBM Plex Mono"
303 version = "2.5.0"
304 license = "OFL-1.1"
305 reserved_font_name = "Plex"
306 license_url = "https://example.invalid/OFL.txt"
307 license_sha256 = "0000000000000000000000000000000000000000000000000000000000000000"
308 copyright = "Copyright 2017 IBM Corp. All rights reserved."
309 designer = "Mike Abbink"
310 vendor_url = "http://www.ibm.com/plex"
311
312 [[base.face]]
313 style = "Regular"
314 url = "https://example.invalid/IBMPlexMono-Regular.ttf"
315 sha256 = "0000000000000000000000000000000000000000000000000000000000000000"
316 "#;
317 let pins = Pins::parse(source).unwrap();
318 let base = pins.base("reserved").unwrap();
319 // The shape that made nerd-fonts ship "BlexMono": prefixing does not
320 // clear the clause, and neither does suffixing.
321 for barred in ["Alloyed Plex Mono", "Plex Mono Quasi", "quasi plex"] {
322 assert!(base.check_output_name(barred).is_err(), "{barred}");
323 }
324 base.check_output_name("Quasi Mono")
325 .expect("Quasi clears it");
326 }
327
328 #[test]
329 fn a_slot_naming_an_unpinned_base_is_refused() {
330 let source = r#"
331 [[slot]]
332 id = "quasi-body"
333 family = "Quasi Body"
334 base = "nothing"
335 "#;
336 assert!(Pins::parse(source).is_err());
337 }
338 }
339