//! The house font pipeline. //! //! A pinned base face plus the house glyph set in, a `Quasi ` face out, //! re-runnable when the upstream moves or when a slot's base changes. //! //! It exists because the faces worth setting text in ship minimal mark //! inventories on principle: IBM Plex Mono has one glyph in the whole //! geometric-shapes block, and Plex Sans and Lato have no `▲` either. So the //! marks a UI depends on get drawn once, here, and patched into every face the //! house adopts. Letters stay the base's job and are meant to differ per slot; //! marks carry meaning, and the meaning is the same in a terminal, a webview //! and an egui panel. //! //! use std::path::PathBuf; pub mod assert; pub mod base; pub mod cells; pub mod compose; pub mod draw; pub mod manifest; pub mod pins; pub mod proof; pub mod vary; pub mod woff2; /// The house glyph set, shipped with the pipeline that consumes it. pub const HOUSE_SET: &str = include_str!("../glyphs/manifest.toml"); /// The pinned bases and the slots cut from them. pub const PINS: &str = include_str!("../bases/pins.toml"); #[derive(Debug)] pub enum Error { Manifest(String), Pins(String), UnknownSlot { asked: String, known: Vec, }, ReservedFontName { family: String, reserved: String, base: String, }, UnmeasurableBase { missing: char, what: String, }, DefaultInstance { declared: String, actual: String, tag: String, at: f32, }, AlreadyDrawn { codepoint: u32, base: String, gid: u32, }, ArchiveChecksum { path: PathBuf, url: String, expected: String, found: String, }, FaceChecksum { path: String, expected: String, found: String, }, Offline { wanted: PathBuf, url: String, }, Coverage(String), Archive(String), Fetch(String), Font(String), Draw(String), Woff2(String), Io(PathBuf, std::io::Error), } impl std::fmt::Display for Error { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { Error::Manifest(m) => write!(f, "the house glyph set is malformed: {m}"), Error::Pins(m) => write!(f, "the pins are malformed: {m}"), Error::UnknownSlot { asked, known } => { write!(f, "no slot `{asked}`. Pinned slots: {}", known.join(", ")) } Error::ReservedFontName { family, reserved, base, } => write!( f, "`{family}` carries {base}'s Reserved Font Name \"{reserved}\", which OFL 1.1 \ clause 3 bars as a prefix and as a suffix alike. Name the slot instead." ), Error::UnmeasurableBase { missing, what } => write!( f, "the base does not draw `{missing}`, so {what} cannot be measured. \ A base that cannot be measured cannot be refitted against, and guessing \ would give the marks a weight that does not match their neighbours." ), Error::DefaultInstance { declared, actual, tag, at, } => write!( f, "the pin calls this face `{declared}` and the base's default instance is \ `{actual}` ({tag} {at}). A variable cut keeps the base's axis, so it also \ keeps the base's default, and a face labelled with a weight it does not \ draw at rest is one every naive `@font-face` and every `fc-match` will \ believe. Name the face `{actual}` in the pin, or instance the base first." ), Error::AlreadyDrawn { codepoint, base, gid, } => write!( f, "{} is already drawn by {base} (glyph {gid}). The pipeline patches gaps and \ does not overwrite a base's own marks; drop it from the set for this slot.", manifest::format_codepoint(*codepoint) ), Error::ArchiveChecksum { path, url, expected, found, } => write!( f, "{} does not match its pin.\n expected {expected}\n found {found}\n\ Upstream moved, or the cached copy is damaged. Re-read {url} before \ repinning: a base that changed under us redraws every mark that refits \ against it.", path.display() ), Error::FaceChecksum { path, expected, found, } => write!( f, "`{path}` inside the archive does not match its pin.\n expected {expected}\n\ \x20 found {found}" ), Error::Offline { wanted, url } => write!( f, "{} is not cached and --offline was given. Fetch it with:\n curl -L -o {} {url}", wanted.display(), wanted.display() ), Error::Coverage(missing) => { write!(f, "the built face does not cover: {missing}") } Error::Archive(m) => write!(f, "{m}"), Error::Fetch(m) => write!(f, "{m}"), Error::Font(m) => write!(f, "{m}"), Error::Draw(m) => write!(f, "could not draw: {m}"), Error::Woff2(m) => write!(f, "woff2: {m}"), Error::Io(path, e) => write!(f, "{}: {e}", path.display()), } } } impl std::error::Error for Error {} /// One cut face, ready to write or to embed. pub struct Face { /// The style the pin declares, which is the base's default instance. pub style: String, /// The file name without an extension: `QuasiMono[wght]` for a variable /// face, `QuasiMono-Regular` for a static one. A face is named for its axis /// when it has one, the way upstream names the file it came from. pub stem: String, pub ttf: Vec, pub woff2: Vec, /// What the cut added to the base, as `(codepoint, glyph name)`. pub added: Vec<(u32, String)>, pub variation: Option, /// The coverage verdict, already asserted. Carried so a caller can print it /// rather than re-derive it. pub verdict: String, } /// A slot, cut. pub struct Cut { pub family: String, pub base_family: String, pub base_version: String, /// How many glyphs of the house set this slot selected. pub selected: usize, pub faces: Vec, /// The base's licence text, which OFL requires to travel with the build. pub license: Vec, } /// Cut a slot from its pinned base: the whole pipeline, in one call. /// /// This is what a consumer runs. `shop` cuts its bundled face from a `build.rs` /// and Alloy's image cuts both faces into `/usr/share/fonts`, and neither should /// have to reassemble the steps `main.rs` walks — a second caller doing its own /// version string or skipping the coverage assertion is how two builds of the /// same slot stop being the same face. /// /// `cache` is where pinned bases are downloaded to and verified in. It is a /// parameter rather than a constant because a consumer's is not this repo's: /// a `build.rs` wants `OUT_DIR`, and a checkout wants `bases/cache`. pub fn cut(slot_id: &str, cache: &std::path::Path, offline: bool) -> Result { let pins = pins::Pins::parse(PINS)?; let manifest = manifest::Manifest::parse(HOUSE_SET)?; let slot = pins.slot(slot_id)?; let base_pin = pins.base(&slot.base)?; // The licence gate, before anything is built: an output name may not carry // the base's Reserved Font Name. base_pin.check_output_name(&slot.family)?; let selected: Vec<&manifest::GlyphSpec> = manifest .glyphs .iter() .filter(|g| slot.glyphs.includes(g)) .collect(); let house: Vec = selected.iter().map(|g| g.codepoint).collect(); // The floor is the role's, and the slot's own selection is what says which // role it fills: a slot that takes no cell furniture is not the face that // will answer `monospace`, so asking it for `│ █ ▏ ░` asks it to carry // glyphs that cannot tile in it. let floor: &[u32] = if selected.iter().any(|g| g.shape.is_cell_furniture()) { &assert::ALLOY_SURFACE } else { &assert::BODY_SURFACE }; let version = format!("{}.{}", manifest.set.version, base_pin.version); let mut faces = Vec::new(); for face in base::load(base_pin, cache, offline)? { let id = compose::Identity { family: &slot.family, style: &face.style, version: &version, set_version: manifest.set.version, base: base_pin, }; let built = compose::build(&face.bytes, &selected, &id)?; let verdict = assert::describe(&assert::check( floor, &compose::coverage(&built.bytes)?, &house, ))?; let stem = match &built.variation { Some(axis) => format!("{}[{}]", slot.family.replace(' ', ""), axis.tag), None => format!( "{}-{}", slot.family.replace(' ', ""), face.style.replace(' ', "") ), }; faces.push(Face { style: face.style, stem, woff2: woff2::encode(&built.bytes)?, ttf: built.bytes, added: built.added, variation: built.variation, verdict, }); } Ok(Cut { family: slot.family.clone(), base_family: base_pin.family.clone(), base_version: base_pin.version.clone(), selected: selected.len(), license: base::license_text(base_pin, cache, offline)?, faces, }) } /// Cut slots straight into a directory a web server serves, as woff2. /// /// `slots` pairs a slot id with the filename it lands under, and the filename /// is the caller's because it is half of an agreement this crate cannot see: /// `makeover` emits the `@font-face` that fetches these, and its /// `WEBFONT_MONO_FILE` / `WEBFONT_SANS_FILE` are the other half. makeover /// cannot call this — it is on crates.io and this crate is `publish = false` — /// so the two meet in a consumer's build script, and this is the part of that /// meeting worth having once instead of three times. /// /// woff2 only. Every browser that can run a Make Creative frontend has /// supported it since 2018, so a ttf beside it is two to three times the bytes /// for a case that does not arrive. /// /// The licence text travels as `OFL-.txt` beside each face, which is /// what OFL requires of a build that ships the face. /// /// Nothing is rewritten if it is already byte-identical. A build script that /// rewrites its outputs every run bumps their mtimes, and everything watching /// those files then believes the fonts moved. /// /// # Errors /// /// If a slot cannot be cut, or a file cannot be written. pub fn cut_web( dir: &std::path::Path, cache: &std::path::Path, offline: bool, slots: &[(&str, &str)], ) -> Result<(), Error> { std::fs::create_dir_all(dir).map_err(|e| Error::Io(dir.to_path_buf(), e))?; for (slot_id, filename) in slots { let cut = cut(slot_id, cache, offline)?; let face = cut .faces .first() .ok_or_else(|| Error::Pins(format!("slot `{slot_id}` cut no faces")))?; write_if_changed(&dir.join(filename), &face.woff2)?; write_if_changed( &dir.join(format!("OFL-{}.txt", cut.family.replace(' ', ""))), &cut.license, )?; } Ok(()) } /// One slot, cut and written for a native consumer. /// /// Carries what a `build.rs` has to export beside the file itself. A native /// renderer registers a face by family and style and picks a weight to draw at, /// and none of the three can be assumed from the filename: the style is the /// base's default instance, and Atkinson Mono's is `ExtraLight` rather than /// `Regular`. pub struct NativeFace { /// The slot id that was cut, as it appears in the pins. pub slot: String, /// The output family, e.g. `Quasi Mono`. pub family: String, /// The style of the face that was written. pub style: String, /// The weight to draw at unless the consumer says otherwise: the variable /// axis default where there is an axis, and 400 where there is not. pub default_weight: f32, /// The ttf that was written. pub path: std::path::PathBuf, /// The licence text that was written beside it. pub license_path: std::path::PathBuf, } /// Cut slots into a directory a native build points at, as ttf. /// /// The native counterpart of [`cut_web`], and the same agreement in a different /// shape: `cut_web` writes woff2 for a browser to fetch, this writes ttf for a /// font loader to register. `slots` pairs a slot id with the filename it lands /// under, the filename being the caller's for the same reason as there — it is /// half of an agreement this crate cannot see, and the other half lives in /// whatever the consumer does with `OUT_DIR`. /// /// ttf rather than woff2: nothing native decodes woff2 without being told how, /// and `cut` already returns both, so this is packaging rather than a second /// pipeline. /// /// This exists because the house tier has to reach native renderers without /// going through `makeover`. `makeover::FontSlot::house_face` names the *web* /// copies, and a native consumer cuts its own; before this it copied about /// thirty lines out of `shop/crates/shop-font/build.rs` to do it, which is a /// second caller doing its own file naming and its own weight defaulting. /// /// The licence text travels as `OFL-.txt` beside each face, which is /// what OFL requires of a build that ships the face. /// /// Nothing is rewritten if it is already byte-identical, for the reason /// [`cut_web`] gives: a build script that rewrites its outputs every run bumps /// their mtimes, and cargo then rebuilds everything watching them. /// /// # Errors /// /// If a slot cannot be cut, or a file cannot be written. pub fn cut_native( dir: &std::path::Path, cache: &std::path::Path, offline: bool, slots: &[(&str, &str)], ) -> Result, Error> { std::fs::create_dir_all(dir).map_err(|e| Error::Io(dir.to_path_buf(), e))?; let mut written = Vec::with_capacity(slots.len()); for (slot_id, filename) in slots { let cut = cut(slot_id, cache, offline)?; let face = cut .faces .first() .ok_or_else(|| Error::Pins(format!("slot `{slot_id}` cut no faces")))?; let path = dir.join(filename); let license_path = dir.join(format!("OFL-{}.txt", cut.family.replace(' ', ""))); write_if_changed(&path, &face.ttf)?; write_if_changed(&license_path, &cut.license)?; written.push(NativeFace { slot: (*slot_id).to_string(), family: cut.family.clone(), style: face.style.clone(), default_weight: face.variation.as_ref().map_or(400.0, |axis| axis.default), path, license_path, }); } Ok(written) } fn write_if_changed(path: &std::path::Path, bytes: &[u8]) -> Result<(), Error> { if std::fs::read(path).is_ok_and(|existing| existing == bytes) { return Ok(()); } std::fs::write(path, bytes).map_err(|e| Error::Io(path.to_path_buf(), e)) }