//! 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());
}
}