//! Typography — layer 0, the app override. //! //! Wiki `typography-standard`, GO makeover `174ab3c1`. Layer 1 above is what //! every product shares; this is the one declaration a product is allowed to //! make for itself: //! //! ```text //! layer 0 app override per product, optional MNW display -> Young Serif //! layer 1 house default the quasi-* slot font quasi-mono -> Quasi Mono //! layer 2 system generic one hop, no further monospace / sans-serif //! ``` //! //! The brand tier was already exempt by decision (`cdf8ac09`), and the exemption //! was enforced by those faces simply not being in the vocabulary — so each //! product reached its own face through a hardcoded `font-family` and an //! `@font-face` block it maintained by hand, which is the exact shape the //! unification is deleting everywhere else. This turns the carve-out into a //! mechanism: the per-product face is declared once, in the build script that //! already writes the typography layer, and is readable as an override rather //! than as a stylesheet nobody unified. //! //! It permits overriding `mono` and `sans` too. No product wants that today, //! and a layer that only allows overriding the slot nobody describes is not a //! layer, it is the exemption restated. //! //! **One declaration per product per slot.** [`Typography::with_override`] //! panics on a second override of the same slot rather than letting the last //! one win: a product with two answers for a slot has the vocabulary wrong, and //! that is the thing to fix. //! //! # What a renderer does when it cannot honour one //! //! Declare once, renderers honour what they can. Today only the webview surface //! has a face to honour at all — neither `makeover-tui` nor `makeover-immediate` //! emits a `font-family` from anywhere, because the terminal owns the face in //! one and the app loads its own font stack in the other. So an override is //! honoured by the generated stylesheet and ignored, silently and correctly, by //! the other two. That last clause was too strong and 2.10.0 corrected it: egui //! can reach a face perfectly well, it just needs the file rather than a stack. //! audiofiles honours its override with no stylesheet anywhere in the path. A renderer that gains font control later reads //! [`Typography::resolve`] rather than the CSS, which is why the resolution is //! a method on the data and not a string-building detail. Loading a file needs //! one thing more than the stack — the family name and the source to load it //! from — so [`Typography::faces`] is the same data read the other way, and //! between them an egui or TUI surface can honour an override without a //! stylesheet anywhere in the path. audiofiles is the first to do it. use crate::{ FONT_MONO, FONT_SANS, HOUSE_MONO_FAMILY, HOUSE_SANS_FAMILY, HOUSE_WEIGHT_RANGE, WEBFONT_MONO_FILE, WEBFONT_SANS_FILE, font_face_css, }; // Names this module's prose links to, resolved for rustdoc. #[allow(unused_imports)] use crate::typography_css_vars; /// A slot in the house font vocabulary — the unit an override replaces. /// /// Three, and the third is deliberately empty by default: `display` is the /// brand tier, it has no house answer, and a product that does not override it /// leaves the token undefined so whatever the consumer wrote as a fallback /// renders. The MNW embeds rely on exactly that. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum FontSlot { /// Code, data, identifiers, cell grids. [`FONT_MONO`] by default. Mono, /// Body and UI text: everything that is not mono or brand. [`FONT_SANS`]. Sans, /// The brand / display tier. No house default. Display, } impl FontSlot { /// Every slot, in the order they are emitted. pub const ALL: [FontSlot; 3] = [FontSlot::Mono, FontSlot::Sans, FontSlot::Display]; /// The custom property this slot is read through. pub fn token(self) -> &'static str { match self { FontSlot::Mono => "--font-mono", FontSlot::Sans => "--font-sans", FontSlot::Display => "--font-display", } } /// The house stack, or `None` for the brand tier. pub fn house_default(self) -> Option<&'static str> { match self { FontSlot::Mono => Some(FONT_MONO), FontSlot::Sans => Some(FONT_SANS), FontSlot::Display => None, } } /// The house face behind that stack, or `None` for the brand tier. /// /// The counterpart of [`house_default`](Self::house_default), and the same /// split as [`Typography::resolve`] against [`Typography::faces`]: one /// names the family that wins, the other names the file behind it. The /// house tier was a format string until this existed, so it could be /// emitted and not read — which made [`Typography::faces`] answer for the /// brand tier and stay silent about the other two. /// /// The sources are the **web** copies. A native loader wants a `ttf` and /// cuts its own through `quasi-type`, whose `cut_native` writes the file /// and hands back the family, style and default weight to register it /// under; there is no house `ttf` named here, and there should not be. This /// crate is on crates.io and `quasi-type` is `publish = false`, so naming a /// native file here would assert a path the web build does not write and /// save the consumer nothing, since it still has to run the pipeline. /// /// One hazard travels with that arrangement and is not solved: a native /// consumer takes `quasi-type` as a git dep and pins a rev, so a stale pin /// ships an older glyph set silently. Advance it deliberately. pub fn house_face(self) -> Option { let (family, file) = match self { FontSlot::Mono => (HOUSE_MONO_FAMILY, WEBFONT_MONO_FILE), FontSlot::Sans => (HOUSE_SANS_FAMILY, WEBFONT_SANS_FILE), FontSlot::Display => return None, }; Some( FontFace::new(family, [file]) .with_weight(HOUSE_WEIGHT_RANGE) .with_style("normal"), ) } } /// One `@font-face` an override brings with it. /// /// A product overriding a slot usually has to ship the face too, and the two /// halves have to agree on a family name. Declaring them together is what /// makes that agreement structural rather than a string typed twice. #[derive(Debug, Clone)] pub struct FontFace { family: String, sources: Vec, weight: Option, style: Option, } impl FontFace { /// A face named `family`, fetched from `sources`. /// /// Each source is either a bare filename, resolved against the /// [`Typography`] base URL, or an absolute one (`/…` or `https://…`) taken /// as written. The `format()` hint is inferred from the extension — /// `woff2`, `woff`, `ttf`, `otf` — and omitted for anything else rather /// than guessed, since a wrong hint is worse than none. pub fn new>( family: impl Into, sources: impl IntoIterator, ) -> Self { Self { family: family.into(), sources: sources.into_iter().map(Into::into).collect(), weight: None, style: None, } } /// `font-weight`, as CSS writes it: `"700"`, or `"200 800"` for a variable /// axis. Omitted when unset, which means `normal`. /// /// A variable face MUST name its range here for the same reason the house /// faces do: a `@font-face` with no range makes the browser resolve every /// weight to the file's default instance. #[must_use] pub fn with_weight(mut self, weight: impl Into) -> Self { self.weight = Some(weight.into()); self } /// `font-style`. Omitted when unset, which means `normal`. #[must_use] pub fn with_style(mut self, style: impl Into) -> Self { self.style = Some(style.into()); self } /// The declared `font-weight`, or `None` when the face never named one. /// /// A renderer loading a variable face directly has to name a weight — the /// file's own default instance is whatever the base shipped, which for the /// house faces is ExtraLight — so this is the half of the declaration that /// stops the load from being a guess. pub fn weight(&self) -> Option<&str> { self.weight.as_deref() } /// The declared `font-style`, or `None`, which means `normal`. pub fn style(&self) -> Option<&str> { self.style.as_deref() } /// The family name, as the stack has to spell it. /// /// For a renderer that loads faces rather than emitting CSS this is the /// name it registers the file under, and reading it here is what keeps /// that name from being typed a second time. pub fn family(&self) -> &str { &self.family } /// The sources, unresolved — bare filenames as they were declared, not /// joined to any base URL. A renderer loading from disk or from an /// `include_bytes!` wants the filename; only the CSS wants the URL. pub fn sources(&self) -> &[String] { &self.sources } pub(crate) fn css(&self, base: &str) -> String { use std::fmt::Write as _; let src = self .sources .iter() .map(|s| { let url = if s.starts_with('/') || s.contains("://") { s.clone() } else { format!("{base}/{s}") }; match font_format(s) { Some(fmt) => format!("url(\"{url}\") format(\"{fmt}\")"), None => format!("url(\"{url}\")"), } }) .collect::>() .join(",\n "); let mut out = format!( "@font-face {{\n font-family: \"{}\";\n src: {src};\n", self.family ); if let Some(w) = &self.weight { let _ = writeln!(out, " font-weight: {w};"); } if let Some(s) = &self.style { let _ = writeln!(out, " font-style: {s};"); } out.push_str(" font-display: swap;\n}\n\n"); out } } /// The `format()` hint for a source, by extension. `None` when unrecognised. fn font_format(source: &str) -> Option<&'static str> { match source.rsplit('.').next()?.to_ascii_lowercase().as_str() { "woff2" => Some("woff2"), "woff" => Some("woff"), "ttf" => Some("truetype"), "otf" => Some("opentype"), _ => None, } } /// One product's answer for one slot: the stack, and any faces it ships. #[derive(Debug, Clone)] pub struct FontOverride { slot: FontSlot, stack: String, faces: Vec, } impl FontOverride { /// Point `slot` at `stack`. /// /// `stack` is the CSS value the token takes, written the way the house /// stacks are: the family, then one hop to a system generic. Layer 2 is /// still one hop and no further — an override is a different answer to the /// slot, not a licence to write the fallback chain the standard deleted. pub fn new(slot: FontSlot, stack: impl Into) -> Self { Self { slot, stack: stack.into(), faces: Vec::new(), } } /// Ship a face with the override. #[must_use] pub fn with_face(mut self, face: FontFace) -> Self { self.faces.push(face); self } /// The slot this answers. pub fn slot(&self) -> FontSlot { self.slot } /// The stack it resolves to. pub fn stack(&self) -> &str { &self.stack } /// The faces it ships, in declaration order. pub fn faces(&self) -> &[FontFace] { &self.faces } } /// The whole typography layer for one product: the house defaults, plus /// whatever it overrides. /// /// This is what a build script composes and what /// `makeover_build::typography_css_from` writes. [`typography_css_vars`] and /// [`font_face_css`] are the no-override case of it and stay for callers that /// have nothing to declare. #[derive(Debug, Clone)] pub struct Typography { base_url: String, overrides: Vec, } impl Typography { /// The house layer alone, fetching faces from `base_url` — the directory /// the consumer serves fonts from, with or without a trailing slash. pub fn house(base_url: impl Into) -> Self { Self { base_url: base_url.into(), overrides: Vec::new(), } } /// Add one product override. /// /// # Panics /// /// If the slot is already overridden. One declaration per product per /// slot: a second is not a merge to resolve, it is two answers to a /// question that has one, and the vocabulary is what wants fixing. #[must_use] pub fn with_override(mut self, ov: FontOverride) -> Self { assert!( !self.overrides.iter().any(|o| o.slot == ov.slot), "{} is overridden twice; one declaration per product per slot", ov.slot.token() ); self.overrides.push(ov); self } /// What `slot` resolves to under this layer, or `None` for a brand slot /// nobody overrode. /// /// The resolution, for a renderer that has a face to choose rather than a /// stylesheet to emit. pub fn resolve(&self, slot: FontSlot) -> Option<&str> { self.overrides .iter() .find(|o| o.slot == slot) .map(|o| o.stack.as_str()) .or_else(|| slot.house_default()) } /// The faces a product ships for `slot`, in declaration order, or an /// empty slice for a slot it did not override. /// /// The other half of [`resolve`](Self::resolve), for a renderer that has /// to load a file rather than name a stack: `resolve` says which family /// wins, this says where the bytes come from, what to call them, and at /// what weight. The /// house faces are not here — they belong to the slot rather than to any /// one product, and [`FontSlot::house_face`] is where they answer. pub fn faces(&self, slot: FontSlot) -> &[FontFace] { self.overrides .iter() .find(|o| o.slot == slot) .map_or(&[], |o| o.faces()) } /// The `@font-face` rules: the two house faces, then each override's. pub fn font_face_css(&self) -> String { let base = self.base_url.trim_end_matches('/'); let mut out = font_face_css(base); for ov in &self.overrides { for face in &ov.faces { out.push_str(&face.css(base)); } } out } /// The resolved tokens as CSS declarations, no selector. pub fn css_declarations(&self) -> String { use std::fmt::Write as _; let mut out = String::new(); for slot in FontSlot::ALL { if let Some(stack) = self.resolve(slot) { let _ = writeln!(out, " {}: {stack};", slot.token()); } } out } /// The resolved tokens as a `:root { … }` block. pub fn css_vars(&self) -> String { format!(":root {{\n{}}}\n", self.css_declarations()) } /// Faces then tokens, in the order a stylesheet wants them. pub fn css(&self) -> String { format!("{}{}", self.font_face_css(), self.css_vars()) } } #[cfg(test)] mod tests { use super::*; // ---- typography, layer 0 ---- /// The live case: MNW's Young Serif, which reached the page through a /// hand-maintained `@font-face` and a `--font-heading` nothing else knew /// about. fn young_serif() -> FontOverride { FontOverride::new(FontSlot::Display, "\"Young Serif\", serif") .with_face(FontFace::new("Young Serif", ["ysrf.woff2", "ysrf.ttf"])) } #[test] fn the_house_layer_alone_is_exactly_what_the_free_functions_emit() { let t = Typography::house("/static/fonts"); assert_eq!(t.font_face_css(), font_face_css("/static/fonts")); assert_eq!(t.css_vars(), typography_css_vars()); } #[test] fn an_unoverridden_display_slot_defines_no_token_at_all() { // Not "defined empty": undefined, so the consumer's own fallback in // `var(--font-display, …)` renders. The MNW embeds depend on it. let t = Typography::house("fonts"); assert!(!t.css_vars().contains("--font-display")); assert_eq!(t.resolve(FontSlot::Display), None); assert_eq!(t.css_vars().matches("--font-").count(), 2); } #[test] fn an_override_adds_its_token_and_its_face_without_touching_the_house_two() { let t = Typography::house("/static/fonts").with_override(young_serif()); assert!( t.css_vars() .contains(" --font-display: \"Young Serif\", serif;\n") ); assert!( t.css_vars() .contains(" --font-mono: \"Quasi Mono\", monospace;\n") ); assert!( t.css_vars() .contains(" --font-sans: \"Quasi Body\", sans-serif;\n") ); assert_eq!(t.resolve(FontSlot::Display), Some("\"Young Serif\", serif")); let faces = t.font_face_css(); assert_eq!(faces.matches("@font-face").count(), 3); assert!(faces.contains("font-family: \"Young Serif\";")); assert!(faces.contains("url(\"/static/fonts/ysrf.woff2\") format(\"woff2\")")); assert!(faces.contains("url(\"/static/fonts/ysrf.ttf\") format(\"truetype\")")); // The house faces still come first, so a product face never shadows a // slot it did not claim. assert!(faces.find("Quasi Mono").unwrap() < faces.find("Young Serif").unwrap()); } #[test] fn overriding_mono_or_sans_replaces_the_house_stack_rather_than_adding_to_it() { // Nobody wants this today. A layer that only permits overriding the // slot nobody describes is the exemption restated, not a layer. let t = Typography::house("fonts").with_override(FontOverride::new( FontSlot::Mono, "\"Departure Mono\", monospace", )); assert!( t.css_vars() .contains(" --font-mono: \"Departure Mono\", monospace;\n") ); assert!(!t.css_vars().contains("Quasi Mono")); assert_eq!(t.css_vars().matches("--font-").count(), 2); } #[test] #[should_panic(expected = "--font-display is overridden twice")] fn a_second_override_of_one_slot_is_a_vocabulary_bug_and_says_so() { let _ = Typography::house("fonts") .with_override(young_serif()) .with_override(FontOverride::new(FontSlot::Display, "\"Reglo\", serif")); } #[test] fn an_absolute_source_is_taken_as_written_and_a_relative_one_joins_the_base() { let t = Typography::house("/static/fonts").with_override( FontOverride::new(FontSlot::Display, "\"Reglo\", serif").with_face( FontFace::new( "Reglo", ["Reglo-Bold.woff2", "https://cdn.example/reglo.woff2"], ) .with_weight("700"), ), ); let faces = t.font_face_css(); assert!(faces.contains("url(\"/static/fonts/Reglo-Bold.woff2\")")); assert!(faces.contains("url(\"https://cdn.example/reglo.woff2\")")); assert!(faces.contains(" font-weight: 700;\n")); } #[test] fn the_house_tier_renders_byte_for_byte_what_the_format_string_wrote() { // The house faces became `FontFace` values so they could be read as // well as emitted. Nothing about the sheet was meant to move, and this // is the whole of that claim: the literal the format string produced. let expected = concat!( "@font-face {\n", " font-family: \"Quasi Mono\";\n", " src: url(\"/static/fonts/QuasiMono.woff2\") format(\"woff2\");\n", " font-weight: 200 800;\n", " font-style: normal;\n", " font-display: swap;\n", "}\n\n", "@font-face {\n", " font-family: \"Quasi Body\";\n", " src: url(\"/static/fonts/QuasiBody.woff2\") format(\"woff2\");\n", " font-weight: 200 800;\n", " font-style: normal;\n", " font-display: swap;\n", "}\n\n", ); assert_eq!(font_face_css("/static/fonts"), expected); } #[test] fn a_house_slot_names_the_same_family_in_its_stack_and_in_its_face() { // The family is spelled once as a bare name and once inside a CSS // stack, because a stack cannot be built from a const at compile time. // A face whose family is not the one the stack names loads and is // never asked for. for (slot, family) in [ (FontSlot::Mono, HOUSE_MONO_FAMILY), (FontSlot::Sans, HOUSE_SANS_FAMILY), ] { let face = slot.house_face().expect("a house slot has a house face"); assert_eq!(face.family(), family); assert!( slot.house_default() .unwrap() .starts_with(&format!("\"{family}\"")) ); } } #[test] fn the_brand_tier_has_no_house_face_the_way_it_has_no_house_stack() { assert!(FontSlot::Display.house_face().is_none()); assert!(FontSlot::Display.house_default().is_none()); } #[test] fn a_face_loading_renderer_reads_the_family_and_the_source_off_the_layer() { // The egui case, which has no stylesheet in the path at all: the // renderer registers the file under a name, and the name has to be // the one the stack spells or the two halves drift. let t = Typography::house("fonts").with_override( FontOverride::new(FontSlot::Display, "\"RecursiveMono\", monospace").with_face( FontFace::new("RecursiveMono", ["RecursiveMonoLnrSt-Bold.ttf"]).with_weight("700"), ), ); let [face] = t.faces(FontSlot::Display) else { panic!("the display slot ships exactly one face"); }; assert_eq!(face.family(), "RecursiveMono"); assert_eq!(face.sources(), ["RecursiveMonoLnrSt-Bold.ttf"]); assert!( t.resolve(FontSlot::Display) .unwrap() .contains(face.family()) ); } #[test] fn a_weight_and_style_are_readable_now_that_the_builders_are_not_using_the_names() { let bold = FontFace::new("Reglo", ["Reglo-Bold.woff2"]).with_weight("700"); assert_eq!(bold.weight(), Some("700")); assert_eq!( bold.style(), None, "unset means normal, not a stated normal" ); let italic = FontFace::new("Odd", ["odd.woff2"]).with_style("italic"); assert_eq!(italic.weight(), None); assert_eq!(italic.style(), Some("italic")); } #[test] fn the_house_faces_state_the_variable_range_a_direct_loader_has_to_name() { // The trap this closes: a variable face's own default instance is // whatever the base shipped, which for these is ExtraLight. A loader // that does not name a weight gets that and nothing says so. for slot in [FontSlot::Mono, FontSlot::Sans] { let face = slot.house_face().unwrap(); assert_eq!(face.weight(), Some(HOUSE_WEIGHT_RANGE)); assert_eq!(face.style(), Some("normal")); } } #[test] fn a_source_is_read_back_unresolved_because_only_the_css_wants_a_url() { let t = Typography::house("/static/fonts").with_override(young_serif()); assert_eq!( t.faces(FontSlot::Display)[0].sources(), ["ysrf.woff2", "ysrf.ttf"] ); // The same face, joined to the base, in the sheet. assert!( t.font_face_css() .contains("url(\"/static/fonts/ysrf.woff2\")") ); } #[test] fn a_slot_nobody_overrode_ships_no_faces_including_the_house_two() { let t = Typography::house("fonts").with_override(young_serif()); assert!(t.faces(FontSlot::Mono).is_empty()); assert!(t.faces(FontSlot::Sans).is_empty()); assert_eq!(t.faces(FontSlot::Display).len(), 1); } #[test] fn an_unrecognised_extension_gets_no_format_hint_rather_than_a_guessed_one() { let t = Typography::house("fonts").with_override( FontOverride::new(FontSlot::Display, "\"Odd\", serif") .with_face(FontFace::new("Odd", ["odd.eot"])), ); assert!(t.font_face_css().contains("url(\"fonts/odd.eot\");")); assert!(!t.font_face_css().contains("format(\"eot\")")); } #[test] fn css_puts_the_faces_before_the_tokens_that_name_them() { let t = Typography::house("fonts").with_override(young_serif()); let css = t.css(); assert!(css.starts_with("@font-face")); assert!(css.find("@font-face").unwrap() < css.find(":root").unwrap()); } }