From b37040cb9358ea3361aca0ff512ba347d7b2e2bc Mon Sep 17 00:00:00 2001 From: aiongg Date: Wed, 21 Oct 2026 06:16:08 +0000 Subject: [PATCH] Add craft-fonts-build: embed craft-fonts faces from an app's build.rs --- Cargo.lock | 14 ++ Cargo.toml | 1 + README.md | 3 + crates/fonts-build/Cargo.toml | 14 ++ crates/fonts-build/README.md | 73 ++++++ crates/fonts-build/src/embed.rs | 228 ++++++++++++++++++ crates/fonts-build/src/lib.rs | 108 +++++++++ crates/fonts-build/src/manifest.rs | 103 ++++++++ crates/fonts-build/src/tests.rs | 139 +++++++++++ crates/fonts-build/tests/consumer/Cargo.toml | 18 ++ crates/fonts-build/tests/consumer/build.rs | 16 ++ crates/fonts-build/tests/consumer/src/lib.rs | 7 + .../fixture/craft-fonts/fonts/manifest.txt | 10 + .../craft-fonts/fonts/sans-arabic/OFL.txt | 1 + .../fonts/sans-arabic/SansArabic.ttf | 1 + .../craft-fonts/fonts/sans-cjk/OFL.txt | 1 + .../craft-fonts/fonts/sans-cjk/SansCJK.ttc | 1 + .../fixture/craft-fonts/fonts/sans-jp/OFL.txt | 1 + .../craft-fonts/fonts/sans-jp/SansJP-Bold.ttf | 1 + .../fonts/sans-jp/SansJP-Regular.ttf | 1 + .../craft-fonts/fonts/sans-thai/OFL.txt | 1 + .../craft-fonts/fonts/sans-thai/SansThai.ttf | 1 + crates/fonts-build/tests/generated.rs | 16 ++ 23 files changed, 759 insertions(+) create mode 100644 crates/fonts-build/Cargo.toml create mode 100644 crates/fonts-build/README.md create mode 100644 crates/fonts-build/src/embed.rs create mode 100644 crates/fonts-build/src/lib.rs create mode 100644 crates/fonts-build/src/manifest.rs create mode 100644 crates/fonts-build/src/tests.rs create mode 100644 crates/fonts-build/tests/consumer/Cargo.toml create mode 100644 crates/fonts-build/tests/consumer/build.rs create mode 100644 crates/fonts-build/tests/consumer/src/lib.rs create mode 100644 crates/fonts-build/tests/fixture/craft-fonts/fonts/manifest.txt create mode 100644 crates/fonts-build/tests/fixture/craft-fonts/fonts/sans-arabic/OFL.txt create mode 100644 crates/fonts-build/tests/fixture/craft-fonts/fonts/sans-arabic/SansArabic.ttf create mode 100644 crates/fonts-build/tests/fixture/craft-fonts/fonts/sans-cjk/OFL.txt create mode 100644 crates/fonts-build/tests/fixture/craft-fonts/fonts/sans-cjk/SansCJK.ttc create mode 100644 crates/fonts-build/tests/fixture/craft-fonts/fonts/sans-jp/OFL.txt create mode 100644 crates/fonts-build/tests/fixture/craft-fonts/fonts/sans-jp/SansJP-Bold.ttf create mode 100644 crates/fonts-build/tests/fixture/craft-fonts/fonts/sans-jp/SansJP-Regular.ttf create mode 100644 crates/fonts-build/tests/fixture/craft-fonts/fonts/sans-thai/OFL.txt create mode 100644 crates/fonts-build/tests/fixture/craft-fonts/fonts/sans-thai/SansThai.ttf create mode 100644 crates/fonts-build/tests/generated.rs diff --git a/Cargo.lock b/Cargo.lock index 4d9de428..35c43199 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -652,6 +652,20 @@ dependencies = [ name = "craft-docpath" version = "0.1.0" +[[package]] +name = "craft-fonts-build" +version = "0.1.0" +dependencies = [ + "craft-fonts-build-consumer", +] + +[[package]] +name = "craft-fonts-build-consumer" +version = "0.1.0" +dependencies = [ + "craft-fonts-build", +] + [[package]] name = "craft-i18n" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index ca53f61a..77042f9e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -23,6 +23,7 @@ thiserror = "2" craft-atomic = { path = "crates/atomic" } craft-cms = { path = "crates/cms" } craft-docpath = { path = "crates/docpath" } +craft-fonts-build = { path = "crates/fonts-build" } craft-i18n = { path = "crates/i18n" } craft-media = { path = "crates/media" } craft-print = { path = "crates/print" } diff --git a/README.md b/README.md index 3289aba6..3272416e 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,9 @@ craft- = { git = "https://github.com/storytold/craft-libs", rev = "` names face `n` of a font +collection (`fonts/noto-sans-cjk/NotoSansCJK.ttc#2`); without it the index is 0. Fields after the +eighth are ignored. diff --git a/crates/fonts-build/src/embed.rs b/crates/fonts-build/src/embed.rs new file mode 100644 index 00000000..bfa37d63 --- /dev/null +++ b/crates/fonts-build/src/embed.rs @@ -0,0 +1,228 @@ +//! The build-script side: read `CRAFT_FONTS_DIR`, select faces, write the generated table. + +use std::ffi::OsString; +use std::fmt::{self, Write as _}; +use std::io; +use std::path::{Path, PathBuf}; + +use crate::manifest::{ManifestEntry, ParseError, parse_manifest, script_matches}; + +const DIR_VAR: &str = "CRAFT_FONTS_DIR"; +const REQUIRED_VAR: &str = "CRAFT_FONTS_REQUIRED"; +const EMPTY_TABLE: &str = "&[]\n"; + +/// Embed every face in `CRAFT_FONTS_DIR`'s manifest into `out_file` (relative paths are under +/// `OUT_DIR`). Shorthand for `Embed::new().write(out_file)`. +pub fn embed(out_file: impl AsRef) -> Result, Error> { + Embed::new().write(out_file) +} + +/// Whether the build script is building for wasm32 (web builds usually embed fewer faces). +pub fn target_is_wasm() -> bool { + std::env::var("CARGO_CFG_TARGET_ARCH").is_ok_and(|arch| arch == "wasm32") +} + +type Filter = Box bool>; + +/// Which faces to embed, and from where. [`Embed::write`] does the work. +#[derive(Default)] +pub struct Embed { + scripts: Option>, + filter: Option, + dir: Option, + required: Option, +} + +impl Embed { + /// Every face, from `CRAFT_FONTS_DIR`, required when `CRAFT_FONTS_REQUIRED` is set. + pub fn new() -> Self { + Self::default() + } + + /// Embed only faces with at least one of these script tags (see + /// [`ManifestEntry::has_script`]). With `CRAFT_FONTS_REQUIRED`, each tag must match a face. + pub fn scripts(mut self, tags: &[&str]) -> Self { + self.scripts = Some(tags.iter().map(|t| (*t).to_owned()).collect()); + self + } + + /// Embed only faces for which `keep` returns true (applied after [`Embed::scripts`]). + pub fn filter(mut self, keep: impl Fn(&ManifestEntry) -> bool + 'static) -> Self { + self.filter = Some(Box::new(keep)); + self + } + + /// Read this checkout instead of `CRAFT_FONTS_DIR`. + pub fn dir(mut self, dir: impl Into) -> Self { + self.dir = Some(dir.into()); + self + } + + /// Override `CRAFT_FONTS_REQUIRED`. + pub fn required(mut self, required: bool) -> Self { + self.required = Some(required); + self + } + + /// Write the face table to `out_file` and print the `cargo::` lines; returns the faces + /// embedded. + /// + /// Without a checkout the table is empty. A checkout that can't be read is a + /// `cargo::warning` and an empty table, or an [`Error`] when required. The table is written + /// in every case, so on an error the build fails only on the message the caller prints. + pub fn write(&self, out_file: impl AsRef) -> Result, Error> { + let env = Env { dir: std::env::var_os(DIR_VAR), required: std::env::var_os(REQUIRED_VAR), out_dir: std::env::var_os("OUT_DIR") }; + let mut cargo = Vec::new(); + let result = self.run(&env, out_file.as_ref(), &mut cargo); + for line in cargo { + println!("{line}"); + } + result + } + + pub(crate) fn run(&self, env: &Env, out_file: &Path, cargo: &mut Vec) -> Result, Error> { + cargo.push(format!("cargo::rerun-if-env-changed={DIR_VAR}")); + cargo.push(format!("cargo::rerun-if-env-changed={REQUIRED_VAR}")); + let out = match (out_file.is_absolute(), &env.out_dir) { + (true, _) => out_file.to_path_buf(), + (false, Some(out_dir)) => Path::new(out_dir).join(out_file), + (false, None) => return Err(Error::NoOutDir), + }; + let required = self.required.unwrap_or_else(|| env.required.as_ref().is_some_and(|v| !v.is_empty() && v != "0")); + let dir = self.dir.clone().or_else(|| env.dir.clone().filter(|d| !d.is_empty()).map(PathBuf::from)); + + let selected = match &dir { + None => Err(Error::NotSet), + Some(dir) => self.select(dir, cargo).and_then(|faces| if required { self.check_required(dir, faces) } else { Ok(faces) }), + }; + let (table, faces) = match selected { + Ok(faces) => (table(&faces), faces.into_iter().map(|(entry, _)| entry).collect()), + Err(e) if required => { + write(&out, EMPTY_TABLE)?; + return Err(e); + } + Err(Error::NotSet) => (EMPTY_TABLE.to_owned(), Vec::new()), + Err(e) => { + cargo.push(format!("cargo::warning=building without craft-fonts: {e}")); + (EMPTY_TABLE.to_owned(), Vec::new()) + } + }; + write(&out, &table)?; + Ok(faces) + } + + /// The wanted manifest entries with their canonical file paths. + fn select(&self, dir: &Path, cargo: &mut Vec) -> Result, Error> { + let manifest = dir.join("fonts/manifest.txt"); + cargo.push(format!("cargo::rerun-if-changed={}", manifest.display())); + let text = std::fs::read_to_string(&manifest).map_err(|source| Error::Read { path: manifest.clone(), source })?; + let entries = parse_manifest(&text).map_err(|error| Error::Parse { manifest: manifest.clone(), error })?; + let mut faces = Vec::new(); + for entry in entries { + let wanted = self.scripts.as_ref().is_none_or(|tags| tags.iter().any(|t| entry.has_script(t))); + if !wanted || self.filter.as_ref().is_some_and(|keep| !keep(&entry)) { + continue; + } + let file = dir.join(&entry.file); + // `include_bytes!` resolves relative paths from the including source file. + let path = file.canonicalize().map_err(|source| Error::Read { path: file, source })?; + cargo.push(format!("cargo::rerun-if-changed={}", path.display())); + faces.push((entry, path)); + } + Ok(faces) + } + + fn check_required(&self, dir: &Path, faces: Vec<(ManifestEntry, PathBuf)>) -> Result, Error> { + let manifest = dir.join("fonts/manifest.txt"); + if faces.is_empty() { + return Err(Error::NoFaces { manifest }); + } + let missing: Vec = self + .scripts + .iter() + .flatten() + .filter(|tag| !faces.iter().any(|(entry, _)| entry.scripts.iter().any(|s| script_matches(s, tag)))) + .cloned() + .collect(); + if missing.is_empty() { Ok(faces) } else { Err(Error::MissingScripts { manifest, scripts: missing }) } + } +} + +/// The environment a build script sees, separated out for tests. +pub(crate) struct Env { + pub dir: Option, + pub required: Option, + pub out_dir: Option, +} + +fn table(faces: &[(ManifestEntry, PathBuf)]) -> String { + let mut src = String::from("// Generated by craft-fonts-build. Do not edit.\n&[\n"); + for (entry, path) in faces { + let scripts: Vec = entry.scripts.iter().map(|s| format!("{s:?}")).collect(); + let _ = writeln!( + src, + " EmbeddedFace {{ family: {:?}, style: {:?}, scripts: &[{}], index: {}, bytes: include_bytes!({:?}) }},", + entry.family, + entry.style, + scripts.join(", "), + entry.index, + path.display().to_string(), + ); + } + src.push_str("]\n"); + src +} + +fn write(path: &Path, contents: &str) -> Result<(), Error> { + std::fs::write(path, contents).map_err(|source| Error::Write { path: path.to_path_buf(), source }) +} + +/// Why the faces could not be embedded. Print it as `cargo::error={error}`. +#[derive(Debug)] +#[non_exhaustive] +pub enum Error { + /// `CRAFT_FONTS_REQUIRED` is set but `CRAFT_FONTS_DIR` is unset or empty. + NotSet, + /// The manifest or a font file it lists can't be read. + Read { path: PathBuf, source: io::Error }, + /// The manifest has a malformed line. + Parse { manifest: PathBuf, error: ParseError }, + /// Required, but no face in the manifest was selected. + NoFaces { manifest: PathBuf }, + /// Required, but no face in the manifest has these requested script tags. + MissingScripts { manifest: PathBuf, scripts: Vec }, + /// The generated table can't be written. + Write { path: PathBuf, source: io::Error }, + /// `out_file` is relative and `OUT_DIR` is unset (not running as a build script). + NoOutDir, +} + +impl fmt::Display for Error { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::NotSet => { + write!(f, "{REQUIRED_VAR} is set but {DIR_VAR} is not: set {DIR_VAR} to the absolute path of a storytold/craft-fonts checkout") + } + Self::Read { path, source } => { + write!(f, "cannot read {} ({DIR_VAR} must be the absolute path of a storytold/craft-fonts checkout): {source}", path.display()) + } + Self::Parse { manifest, error } => write!(f, "{}: {error}", manifest.display()), + Self::NoFaces { manifest } => write!(f, "{REQUIRED_VAR} is set but no face in {} was selected", manifest.display()), + Self::MissingScripts { manifest, scripts } => { + write!(f, "{REQUIRED_VAR} is set but {} has no face for {}", manifest.display(), scripts.join(", ")) + } + Self::Write { path, source } => write!(f, "cannot write {}: {source}", path.display()), + Self::NoOutDir => write!(f, "OUT_DIR is not set: call craft-fonts-build from a build script, or pass an absolute out_file"), + } + } +} + +impl std::error::Error for Error { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + Self::Read { source, .. } | Self::Write { source, .. } => Some(source), + Self::Parse { error, .. } => Some(error), + _ => None, + } + } +} diff --git a/crates/fonts-build/src/lib.rs b/crates/fonts-build/src/lib.rs new file mode 100644 index 00000000..1b8d1363 --- /dev/null +++ b/crates/fonts-build/src/lib.rs @@ -0,0 +1,108 @@ +#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] + +//! Embed the optional [craft-fonts](https://github.com/storytold/craft-fonts) faces from an app's +//! `build.rs`. +//! +//! Apps never depend on craft-fonts in `Cargo.toml` (Cargo would fetch the font repository for +//! every build). The build reads a local checkout named by `CRAFT_FONTS_DIR` instead: +//! +//! - **Unset or empty:** the table is empty and the app uses its own and system fonts. +//! - **Set:** every selected face in `fonts/manifest.txt` is embedded with `include_bytes!`. +//! - **Set, but not readable as a checkout:** a `cargo::warning` and an empty table. +//! - **`CRAFT_FONTS_REQUIRED=1`** (release builds): a missing or unreadable checkout, no selected +//! face, or a requested script without a face is an [`Error`]. +//! +//! Add this crate both as a build-dependency (for [`embed`]) and as a dependency (for +//! [`EmbeddedFace`]). In `build.rs`'s `main`: +//! +//! ```no_run +//! println!("cargo::rerun-if-changed=build.rs"); +//! let wasm = craft_fonts_build::target_is_wasm(); +//! let embedded = craft_fonts_build::Embed::new() +//! // Web builds have a size budget: embed only the Japanese UI face there. +//! .filter(move |face| !wasm || (face.family.as_str(), face.style.as_str()) == ("BIZ UDPGothic", "Regular")) +//! .write("craft_fonts.rs"); +//! if let Err(e) = embedded { +//! println!("cargo::error={e}"); +//! } +//! ``` +//! +//! and in the crate: +//! +//! ```ignore +//! use craft_fonts_build::EmbeddedFace; +//! +//! pub static CRAFT_FONTS: &[EmbeddedFace] = include!(concat!(env!("OUT_DIR"), "/craft_fonts.rs")); +//! ``` +//! +//! The generated file is a slice expression of `EmbeddedFace { .. }` literals, so any struct with +//! the same fields and that name in scope works too. + +mod embed; +mod manifest; +#[cfg(test)] +mod tests; + +pub use embed::{Embed, Error, embed, target_is_wasm}; +pub use manifest::{ManifestEntry, ParseError, parse_manifest}; + +/// A craft-fonts face embedded by [`embed`]: one line of `fonts/manifest.txt`. +#[derive(Clone, Copy)] +pub struct EmbeddedFace { + /// Family name, e.g. `"Noto Sans Arabic"`. + pub family: &'static str, + /// Style, e.g. `"Regular"` or `"Bold"`. + pub style: &'static str, + /// Script tags: ISO 15924 codes such as `Arab`, `Jpan`, `Hans`, `Hant`, optionally with a + /// region subtag (`Hant-HK` for Hong Kong forms, which have no ISO 15924 code). + pub scripts: &'static [&'static str], + /// Face index in a font collection (`.ttc`); `0` for a single font. + pub index: u32, + pub bytes: &'static [u8], +} + +impl EmbeddedFace { + /// `"