diff --git a/crates/text/build.rs b/crates/text/build.rs index ba087983c..fec5fb51a 100644 --- a/crates/text/build.rs +++ b/crates/text/build.rs @@ -3,22 +3,25 @@ //! `CRAFT_FONTS` is empty and native apps use system fonts. Required web releases enforce Inter //! Regular/SemiBold and Noto Sans Georgian Regular after filtering. Recipe: craft-fonts `docs/integration.md`. //! Noto Sans CJK only with `FIGCRAFT_EMBED_CJK` set (the web build's only way to show CJK UI text). +//! `FIGCRAFT_WEB_FONTS` (comma-separated families) adds manifest families to the web build. use std::fmt::Write as _; use std::path::PathBuf; /// Font build script: emit `craft_fonts.rs` from `input_fonts`, passing the -/// `CRAFT_FONTS_DIR`, `CRAFT_FONTS_REQUIRED`, wasm target and `FIGCRAFT_EMBED_CJK` inputs; +/// `CRAFT_FONTS_DIR`, `CRAFT_FONTS_REQUIRED`, wasm target, `FIGCRAFT_EMBED_CJK` and `FIGCRAFT_WEB_FONTS` inputs; /// report generation and write failures with `cargo::error`. fn main() { println!("cargo::rerun-if-env-changed=CRAFT_FONTS_DIR"); println!("cargo::rerun-if-env-changed=CRAFT_FONTS_REQUIRED"); println!("cargo::rerun-if-env-changed=FIGCRAFT_EMBED_CJK"); + println!("cargo::rerun-if-env-changed=FIGCRAFT_WEB_FONTS"); let mut src = String::from("pub static CRAFT_FONTS: &[CraftFont] = &[\n"); let dir = std::env::var_os("CRAFT_FONTS_DIR").map(PathBuf::from); let required = std::env::var_os("CRAFT_FONTS_REQUIRED").is_some(); let wasm = std::env::var("CARGO_CFG_TARGET_ARCH").is_ok_and(|a| a == "wasm32"); let embed_cjk = std::env::var_os("FIGCRAFT_EMBED_CJK").is_some(); - match input_fonts(dir.as_deref(), wasm, required, embed_cjk) { + let web_fonts = web_fonts(&std::env::var("FIGCRAFT_WEB_FONTS").unwrap_or_default()); + match input_fonts(dir.as_deref(), wasm, required, embed_cjk, &web_fonts) { Ok(entries) => src.push_str(&entries), Err(e) => println!("cargo::error={e}"), } @@ -29,21 +32,37 @@ fn main() { } } +/// The families `FIGCRAFT_WEB_FONTS` names: comma-separated, trimmed, empty names dropped. +fn web_fonts(list: &str) -> Vec { + list.split(',').map(str::trim).filter(|f| !f.is_empty()).map(str::to_owned).collect() +} + +/// The families in `web_fonts` that `faces` has no face of, in the order named. +fn not_embedded<'a>(web_fonts: &'a [String], faces: &[(String, String)]) -> Vec<&'a str> { + web_fonts.iter().map(String::as_str).filter(|w| !faces.iter().any(|(family, _)| family == w)).collect() +} + /// Collect the effective catalog and enforce the required web release faces after filtering. -fn input_fonts(dir: Option<&std::path::Path>, wasm: bool, required: bool, embed_cjk: bool) -> Result { +/// Families named in `web_fonts` that nothing embedded get a warning (most likely misspelt). +fn input_fonts(dir: Option<&std::path::Path>, wasm: bool, required: bool, embed_cjk: bool, web_fonts: &[String]) -> Result { let Some(dir) = dir else { + for family in web_fonts { + println!("cargo::warning=FIGCRAFT_WEB_FONTS: {family:?} isn't embedded: this build has no craft-fonts"); + } return if required { Err("CRAFT_FONTS_REQUIRED is set but CRAFT_FONTS_DIR is missing".into()) } else { Ok(String::new()) }; }; let mut out = String::new(); let mut faces = Vec::new(); + let mut loaded = false; for manifest in ["fonts/latin-manifest.txt", "fonts/manifest.txt"] { let path = dir.join(manifest); if manifest == "fonts/latin-manifest.txt" && !path.exists() { watch(&path); continue; } - match craft_fonts(dir, manifest, wasm, embed_cjk) { + match craft_fonts(dir, manifest, wasm, embed_cjk, web_fonts) { Ok(catalog) => { + loaded = true; out.push_str(&catalog.entries); faces.extend(catalog.faces); } @@ -51,6 +70,11 @@ fn input_fonts(dir: Option<&std::path::Path>, wasm: bool, required: bool, embed_ Err(e) => println!("cargo::warning=craft-fonts {manifest} skipped: CRAFT_FONTS_DIR={}: {e}", dir.display()), } } + let why = + if loaded { "no manifest row was kept for it (misspelt? CJK also needs FIGCRAFT_EMBED_CJK)" } else { "no craft-fonts manifest was read" }; + for family in not_embedded(web_fonts, &faces) { + println!("cargo::warning=FIGCRAFT_WEB_FONTS: {family:?} isn't embedded: {why}"); + } if required && wasm { for (family, style) in [("Inter", "Regular"), ("Inter", "SemiBold"), ("Noto Sans Georgian", "Regular")] { if !faces.iter().any(|(f, s)| f == family && s == style) { @@ -82,9 +106,9 @@ struct Catalog { /// Parse a font manifest of `family | style | file | scripts` rows into `CraftFont` /// entries: skip Adobe-authored families, skip CJK unless requested, keep the web catalog -/// to its budgeted families, canonicalize and watch each face, and emit `include_bytes!` -/// items. -fn craft_fonts(dir: &std::path::Path, manifest_rel: &str, wasm: bool, embed_cjk: bool) -> Result { +/// to its budgeted families and the ones `web_fonts` adds, canonicalize and watch each face, and +/// emit `include_bytes!` items. +fn craft_fonts(dir: &std::path::Path, manifest_rel: &str, wasm: bool, embed_cjk: bool, web_fonts: &[String]) -> Result { let manifest = dir.join(manifest_rel); watch(&manifest); let text = std::fs::read_to_string(&manifest).map_err(|e| format!("{}: {e}", manifest.display()))?; @@ -105,9 +129,11 @@ fn craft_fonts(dir: &std::path::Path, manifest_rel: &str, wasm: bool, embed_cjk: continue; } // Web builds have a size budget: Inter, Noto Sans Georgian (small, for the Georgian UI), only the - // SemiBold wordmark, and optionally Noto Sans CJK (every CJK script, for CJK UI text). + // SemiBold wordmark, optionally Noto Sans CJK (every CJK script, for CJK UI text), and the + // families a distribution names in `FIGCRAFT_WEB_FONTS` (a Thai face for Thai text, say). let wordmark = *family == "Archivo" && *style == "SemiBold"; - if wasm && !matches!(*family, "Inter" | "Noto Sans Georgian") && !wordmark && !cjk { + let added = web_fonts.iter().any(|f| f == family); + if wasm && !matches!(*family, "Inter" | "Noto Sans Georgian") && !wordmark && !cjk && !added { continue; } let path = dir.join(file).canonicalize().map_err(|e| format!("{file}: {e}"))?; @@ -161,9 +187,9 @@ mod tests { fn restored_latin_catalog_preserves_permitted_native_faces_and_web_budget() { let f = Fixture::new(); std::fs::write(f.0.join("fonts/latin-manifest.txt"), include_str!("tests/fixtures/latin-families.txt")).unwrap(); - let native = craft_fonts(&f.0, "fonts/latin-manifest.txt", false, false).unwrap(); + let native = craft_fonts(&f.0, "fonts/latin-manifest.txt", false, false, &[]).unwrap(); assert_eq!(native.faces.len(), 43, "all prior permitted presentation faces remain available"); - let web = craft_fonts(&f.0, "fonts/latin-manifest.txt", true, false).unwrap(); + let web = craft_fonts(&f.0, "fonts/latin-manifest.txt", true, false, &[]).unwrap(); assert_eq!(web.faces.len(), 4, "the web catalog keeps only the four Inter faces"); for catalog in [&native, &web] { assert!(!catalog.entries.contains("Source Sans 3")); @@ -171,16 +197,16 @@ mod tests { } assert!(native.faces.iter().any(|(family, _)| family == "Carlito")); assert!(native.faces.iter().any(|(family, _)| family == "Liberation Serif")); - assert!(input_fonts(Some(&f.0), true, true, false).is_ok()); + assert!(input_fonts(Some(&f.0), true, true, false, &[]).is_ok()); } /// Asserts required web input rejects a catalog missing Inter Regular or Inter SemiBold. #[test] fn required_web_rejects_core_only_and_partial_latin_catalogs() { let f = Fixture::new(); - assert!(input_fonts(Some(&f.0), true, true, false).unwrap_err().contains("Inter Regular")); + assert!(input_fonts(Some(&f.0), true, true, false, &[]).unwrap_err().contains("Inter Regular")); f.manifest("latin-manifest.txt", &[("Inter", "Regular"), ("Unrelated", "SemiBold")]); - assert!(input_fonts(Some(&f.0), true, true, false).unwrap_err().contains("Inter SemiBold")); + assert!(input_fonts(Some(&f.0), true, true, false, &[]).unwrap_err().contains("Inter SemiBold")); } /// Asserts required web input embeds the Inter and Georgian catalogs (three entries) and @@ -189,12 +215,12 @@ mod tests { fn required_web_embeds_both_catalogs_and_requires_georgian() { let f = Fixture::new(); f.manifest("latin-manifest.txt", &[("Inter", "Regular"), ("Inter", "SemiBold")]); - let entries = input_fonts(Some(&f.0), true, true, false).unwrap(); + let entries = input_fonts(Some(&f.0), true, true, false, &[]).unwrap(); assert_eq!(entries.matches("CraftFont {").count(), 3); assert!(entries.contains("family: \"Inter\"")); assert!(entries.contains("family: \"Noto Sans Georgian\"")); f.manifest("manifest.txt", &[("Other", "Regular")]); - assert!(input_fonts(Some(&f.0), true, true, false).unwrap_err().contains("Noto Sans Georgian Regular")); + assert!(input_fonts(Some(&f.0), true, true, false, &[]).unwrap_err().contains("Noto Sans Georgian Regular")); } /// Asserts absent font input is empty when optional and an error when required, while present @@ -202,10 +228,10 @@ mod tests { #[test] fn optional_and_native_inputs_keep_their_existing_policy() { let f = Fixture::new(); - assert!(input_fonts(None, true, false, false).unwrap().is_empty()); - assert!(input_fonts(None, true, true, false).unwrap_err().contains("CRAFT_FONTS_DIR is missing")); - assert!(input_fonts(Some(&f.0), true, false, false).unwrap().contains("Noto Sans Georgian")); - assert!(input_fonts(Some(&f.0), false, true, false).unwrap().contains("Noto Sans Georgian")); + assert!(input_fonts(None, true, false, false, &[]).unwrap().is_empty()); + assert!(input_fonts(None, true, true, false, &[]).unwrap_err().contains("CRAFT_FONTS_DIR is missing")); + assert!(input_fonts(Some(&f.0), true, false, false, &[]).unwrap().contains("Noto Sans Georgian")); + assert!(input_fonts(Some(&f.0), false, true, false, &[]).unwrap().contains("Noto Sans Georgian")); } /// Asserts required web input rejects a manifest whose face file is missing, even when every @@ -215,6 +241,24 @@ mod tests { let f = Fixture::new(); f.manifest("latin-manifest.txt", &[("Inter", "Regular"), ("Inter", "SemiBold")]); std::fs::remove_file(f.0.join("fonts/face.ttf")).unwrap(); - assert!(input_fonts(Some(&f.0), true, true, false).unwrap_err().contains("fonts/face.ttf")); + assert!(input_fonts(Some(&f.0), true, true, false, &[]).unwrap_err().contains("fonts/face.ttf")); + } + + /// Asserts `FIGCRAFT_WEB_FONTS` (split on commas, trimmed) adds the families it names to the web + /// catalog only, leaves native and unset builds as they were, and reports a family nothing + /// embedded. + #[test] + fn web_fonts_add_the_families_they_name_to_the_web_catalog() { + let f = Fixture::new(); + f.manifest("manifest.txt", &[("Noto Sans Georgian", "Regular"), ("Noto Sans Thai", "Regular"), ("Noto Sans Hebrew", "Regular")]); + let named = web_fonts(" Noto Sans Thai, ,Noto Sans Thia "); + assert_eq!(named, ["Noto Sans Thai", "Noto Sans Thia"]); + let families = |c: &Catalog| c.faces.iter().map(|(family, _)| family.clone()).collect::>(); + let web = craft_fonts(&f.0, "fonts/manifest.txt", true, false, &named).unwrap(); + assert_eq!(families(&web), ["Noto Sans Georgian", "Noto Sans Thai"], "the named family joins; the others stay out"); + assert_eq!(families(&craft_fonts(&f.0, "fonts/manifest.txt", true, false, &[]).unwrap()), ["Noto Sans Georgian"], "unset: as before"); + assert_eq!(craft_fonts(&f.0, "fonts/manifest.txt", false, false, &[]).unwrap().faces.len(), 3, "native builds embed every family"); + assert_eq!(not_embedded(&named, &web.faces), ["Noto Sans Thia"], "a misspelt family is reported"); + assert!(input_fonts(Some(&f.0), true, false, false, &named).unwrap().contains("family: \"Noto Sans Thai\"")); } } diff --git a/docs/releasing.md b/docs/releasing.md index ddd71aa41..e1e2ad1d9 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -121,8 +121,11 @@ a missing required face, style or asset fails the required web build. Excluded A are skipped before resolving their asset paths. Native builds retain the permitted Latin presentation faces. Source Sans 3 and Source Serif 4 are filtered on every target under FigCraft's asset policy. The web build keeps its smaller -allowlist: Inter, Noto Sans Georgian and Archivo SemiBold when supplied. Packages carry each -embedded font's licence as `OFL-.txt` +allowlist: Inter, Noto Sans Georgian and Archivo SemiBold when supplied. A distribution whose +users write another script adds the manifest families it needs, comma-separated, with +`FIGCRAFT_WEB_FONTS` (`FIGCRAFT_WEB_FONTS="Noto Sans Thai" trunk build --release`); a named family +the build doesn't embed gets a warning, and each one adds its size to the wasm, which must stay +under the size gate in `packaging/web/package.sh`. Packages carry each embedded font's licence as `OFL-.txt` (`copy_font_licences` in `packaging/env.sh`; `Contents/Resources/Licenses` in the macOS app). ### macOS