//! The pinned bases and the slots cut from them. use serde::Deserialize; use crate::Error; #[derive(Debug, Deserialize)] pub struct Pins { #[serde(default, rename = "base")] pub bases: Vec, #[serde(default, rename = "slot")] pub slots: Vec, } #[derive(Debug, Deserialize)] pub struct Base { pub id: String, /// The upstream family name. Never reused in an output name. pub family: String, pub version: String, /// The archive upstream publishes, when it publishes one. Absent for a base /// pinned file by file; see `Face::url`. #[serde(default)] pub url: Option, /// sha256 of the archive. A moved upstream fails here. #[serde(default)] pub sha256: Option, pub license: String, /// Path to the licence inside the archive. #[serde(default)] pub license_path: Option, /// Where the licence is fetched from, for a base with no archive. #[serde(default)] pub license_url: Option, #[serde(default)] pub license_sha256: Option, /// OFL 1.1 clause 3, recorded so the naming gate is data rather than lore. #[serde(default)] pub reserved_font_name: Option, pub copyright: String, pub designer: String, #[serde(default)] pub vendor_url: Option, #[serde(rename = "face")] pub faces: Vec, } #[derive(Debug, Deserialize)] pub struct Face { /// The style this face carries. For a variable face it is the style of the /// **default instance**, which is not always `Regular`: Atkinson Mono's /// default is `wght` 200 and its own name table reads `ExtraLight`. The /// build asserts this against the base rather than trusting it, so the /// ExtraLight default cannot arrive by accident. pub style: String, /// Path inside the archive, for an archive-pinned base. #[serde(default)] pub path: Option, /// Where the face is fetched from, for a base with no archive. #[serde(default)] pub url: Option, pub sha256: String, /// Declares that this face carries an `fvar` axis. Asserted against the /// face, so a base that stops being variable fails rather than quietly /// cutting a static instance. #[serde(default)] pub variable: bool, } impl Face { /// What the cached copy is called. A path inside an archive keeps its file /// name; a file-pinned face is named for its pin, since two bases may /// publish files with the same name. pub fn cache_name(&self, base_id: &str, base_version: &str) -> String { let leaf = self .path .as_deref() .or(self.url.as_deref()) .unwrap_or(&self.style) .rsplit('/') .next() .unwrap_or(&self.style); format!("{base_id}-{base_version}-{leaf}") } } #[derive(Debug, Deserialize)] pub struct Slot { pub id: String, /// The output family. Tracks the slot, never the base. pub family: String, pub base: String, /// `"*"` for the whole house set, `"marks"` for the authored tier only, or /// a list of codepoints. #[serde(default = "everything")] pub glyphs: GlyphSelection, } fn everything() -> GlyphSelection { GlyphSelection::All } #[derive(Debug)] pub enum GlyphSelection { All, /// The authored tier: the marks, and not the generated cell furniture. /// /// Box drawing and block elements are sized against the cell and have to be /// cell-exact to tile, which is a property a proportional face does not /// have and cannot be given: its glyphs carry their own widths, so nothing /// set in it tiles. A body face carrying a `┌` would be carrying a glyph /// that draws a corner joining nothing. Marks, Some(Vec), } impl<'de> Deserialize<'de> for GlyphSelection { fn deserialize>(deserializer: D) -> Result { #[derive(Deserialize)] #[serde(untagged)] enum Raw { Star(String), List(Vec), } match Raw::deserialize(deserializer)? { Raw::Star(s) if s == "*" => Ok(GlyphSelection::All), Raw::Star(s) if s == "marks" => Ok(GlyphSelection::Marks), Raw::Star(s) => Err(serde::de::Error::custom(format!( "expected \"*\", \"marks\" or a list of codepoints, got {s:?}" ))), Raw::List(list) => Ok(GlyphSelection::Some(list)), } } } impl GlyphSelection { pub fn includes(&self, glyph: &crate::manifest::GlyphSpec) -> bool { match self { GlyphSelection::All => true, GlyphSelection::Marks => !glyph.shape.is_cell_furniture(), GlyphSelection::Some(list) => list.contains(&glyph.codepoint), } } } impl Pins { pub fn parse(source: &str) -> Result { let pins: Pins = toml::from_str(source).map_err(|e| Error::Pins(e.to_string()))?; for base in &pins.bases { base.check_pinning()?; } for slot in &pins.slots { if !pins.bases.iter().any(|b| b.id == slot.base) { return Err(Error::Pins(format!( "slot `{}` names base `{}`, which is not pinned", slot.id, slot.base ))); } } Ok(pins) } pub fn slot(&self, id: &str) -> Result<&Slot, Error> { self.slots .iter() .find(|s| s.id == id) .ok_or_else(|| Error::UnknownSlot { asked: id.to_owned(), known: self.slots.iter().map(|s| s.id.clone()).collect(), }) } pub fn base(&self, id: &str) -> Result<&Base, Error> { self.bases .iter() .find(|b| b.id == id) .ok_or_else(|| Error::Pins(format!("base `{id}` is not pinned"))) } } impl Base { /// Whether the base is fetched as one archive or as pinned files. /// /// Both shapes exist upstream and neither is a preference. IBM publishes a /// release zip; the Atkinson repositories publish no releases at all, so the /// only stable thing to pin is a file at a commit. A source tarball would be /// the third option and is the worst of them: GitHub generates it on demand /// and has changed the bytes it generates before, which would read here as /// "upstream moved" on a repository nobody touched. pub fn is_archive(&self) -> bool { self.url.is_some() } /// A base is pinned one way or the other, never half of each. fn check_pinning(&self) -> Result<(), Error> { let id = &self.id; if self.url.is_some() != self.sha256.is_some() { return Err(Error::Pins(format!( "base `{id}` gives an archive url without its sha256, or the reverse" ))); } for face in &self.faces { match (self.is_archive(), face.path.is_some(), face.url.is_some()) { (true, true, false) | (false, false, true) => {} (true, _, _) => { return Err(Error::Pins(format!( "base `{id}` is pinned as an archive, so face `{}` needs a `path` \ inside it and no `url` of its own", face.style ))); } (false, _, _) => { return Err(Error::Pins(format!( "base `{id}` has no archive, so face `{}` needs its own `url`", face.style ))); } } } match ( self.is_archive(), self.license_path.is_some(), self.license_url.is_some(), ) { (true, true, false) | (false, false, true) => {} (true, _, _) => { return Err(Error::Pins(format!( "base `{id}` is pinned as an archive, so its licence needs a `license_path`" ))); } (false, _, _) => { return Err(Error::Pins(format!( "base `{id}` has no archive, so its licence needs a `license_url`. \ The OFL requires the text to travel with a modified build, so a base \ with nowhere to read it from cannot be cut." ))); } } Ok(()) } /// The one licence gate that is mechanical: an output name must not carry /// the base's Reserved Font Name. OFL 1.1 clause 3 bars it as a prefix and /// as a suffix alike, so this is a substring test and not a word test. pub fn check_output_name(&self, family: &str) -> Result<(), Error> { let Some(reserved) = &self.reserved_font_name else { return Ok(()); }; if family.to_lowercase().contains(&reserved.to_lowercase()) { return Err(Error::ReservedFontName { family: family.to_owned(), reserved: reserved.clone(), base: self.family.clone(), }); } Ok(()) } } #[cfg(test)] mod tests { use super::*; fn pins() -> Pins { Pins::parse(crate::PINS).expect("the shipped pins parse") } #[test] fn quasi_mono_is_cut_from_the_pinned_atkinson() { let pins = pins(); let slot = pins.slot("quasi-mono").unwrap(); assert_eq!(slot.family, "Quasi Mono"); let base = pins.base(&slot.base).unwrap(); assert_eq!(base.family, "Atkinson Hyperlegible Mono"); assert_eq!( base.faces.len(), 1, "one variable file covers the range; italic is not cut" ); } /// Atkinson declares no Reserved Font Name, so the gate this file /// documents has nothing to bite on for the base in the tree today. It is /// still the gate so it is exercised against a pin written here rather /// than dropped along with the base that made it necessary. #[test] fn the_house_name_clears_the_reserved_font_name() { let pins = pins(); let base = pins.base("atkinson-mono").unwrap(); assert!(base.reserved_font_name.is_none()); base.check_output_name("Quasi Mono") .expect("Quasi clears it"); base.check_output_name("Alloyed Atkinson") .expect("no RFN, so the general fork rule works on this base"); } #[test] fn reaching_for_the_bases_name_is_refused() { let source = r#" [[base]] id = "reserved" family = "IBM Plex Mono" version = "2.5.0" license = "OFL-1.1" reserved_font_name = "Plex" license_url = "https://example.invalid/OFL.txt" license_sha256 = "0000000000000000000000000000000000000000000000000000000000000000" copyright = "Copyright 2017 IBM Corp. All rights reserved." designer = "Mike Abbink" vendor_url = "http://www.ibm.com/plex" [[base.face]] style = "Regular" url = "https://example.invalid/IBMPlexMono-Regular.ttf" sha256 = "0000000000000000000000000000000000000000000000000000000000000000" "#; let pins = Pins::parse(source).unwrap(); let base = pins.base("reserved").unwrap(); // The shape that made nerd-fonts ship "BlexMono": prefixing does not // clear the clause, and neither does suffixing. for barred in ["Alloyed Plex Mono", "Plex Mono Quasi", "quasi plex"] { assert!(base.check_output_name(barred).is_err(), "{barred}"); } base.check_output_name("Quasi Mono") .expect("Quasi clears it"); } #[test] fn a_slot_naming_an_unpinned_base_is_refused() { let source = r#" [[slot]] id = "quasi-body" family = "Quasi Body" base = "nothing" "#; assert!(Pins::parse(source).is_err()); } }