Files
vectorcraft/crates/text/build/web_fonts.rs
T
Ahmed Moussa 3e20c2cf29 build(web): copy web fonts with cargo xtask, not sh
Replace the Trunk post_build hook packaging/web/copy-fonts.sh with `cargo xtask web-fonts` (xtask/src/web_fonts.rs), so the web build only needs trunk, the wasm32 target and cargo, with no POSIX shell, and works the same on Windows.

The new task reuses crates/text/build/web_fonts.rs for list parsing and URLs, so build.rs and the copier can't disagree on fonts/<sha16>/<file>. It still does nothing without CRAFT_FONTS_DIR, checks each file's SHA-256 against the manifest, and copies the licence next to each face. A listed face that an older craft-fonts checkout lacks is now skipped with a warning, as build.rs does, instead of failing the build. A relative CRAFT_FONTS_DIR is resolved from the workspace root.

Update the references in Trunk.toml, fonts.rs, build.rs, craft_fonts.rs and web-fonts.txt, and document the web build's requirements in docs/development.md. Add tests for the copy, a hash mismatch and the no-op case.

Signed-off-by: Ahmed Moussa <[email protected]>
2026-10-09 17:18:54 +03:00

119 lines
4.6 KiB
Rust

//! `web-fonts.txt` (the craft-fonts faces web builds fetch instead of embedding) and the
//! `WEB_FONTS` fields built from it. Shared by `build.rs` and the crate's tests (`#[path]`-included
//! by both, so it has no dependencies), and by `xtask/src/web_fonts.rs`, which copies the files to [`url`].
/// One line of `web-fonts.txt`: `when | family | style`.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Listed<'a> {
/// `startup` (fetched before the app starts) rather than `background`.
pub startup: bool,
pub family: &'a str,
pub style: &'a str,
}
/// Parse `web-fonts.txt`. Blank lines and `#` comments are skipped; line numbers are 1-based.
pub fn parse_list(text: &str) -> Result<Vec<Listed<'_>>, String> {
let mut out = Vec::new();
for (i, raw) in text.lines().enumerate() {
let line = raw.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
let fields: Vec<&str> = line.split(" | ").map(str::trim).collect();
let [when, family, style] = fields.as_slice() else {
return Err(format!("web-fonts.txt line {}: expected `when | family | style`", i + 1));
};
let startup = match *when {
"startup" => true,
"background" => false,
other => return Err(format!("web-fonts.txt line {}: `{other}` is neither startup nor background", i + 1)),
};
if family.is_empty() || style.is_empty() {
return Err(format!("web-fonts.txt line {}: empty family or style", i + 1));
}
out.push(Listed { startup, family, style });
}
Ok(out)
}
/// Standard base64 (RFC 4648, with padding).
pub fn base64(data: &[u8]) -> String {
const TABLE: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
let mut s = String::with_capacity(data.len().div_ceil(3) * 4);
for chunk in data.chunks(3) {
let b = |i: usize| u32::from(chunk.get(i).copied().unwrap_or(0));
let n = (b(0) << 16) | (b(1) << 8) | b(2);
for k in 0..4 {
if k <= chunk.len() {
let six = ((n >> (18 - 6 * k)) & 63) as usize;
s.push(TABLE.get(six).map_or('=', |&c| char::from(c)));
} else {
s.push('=');
}
}
}
s
}
/// The Subresource Integrity value (`sha256-<base64>`) for a 64-digit hex SHA-256; `None` if `hex`
/// isn't one.
pub fn sri(hex: &str) -> Option<String> {
if hex.len() != 64 {
return None;
}
let bytes: Option<Vec<u8>> = (0..32).map(|i| hex.get(2 * i..2 * i + 2).and_then(|h| u8::from_str_radix(h, 16).ok())).collect();
Some(format!("sha256-{}", base64(&bytes?)))
}
/// The site-relative URL a face is served at: `fonts/<first 16 hex digits of its SHA-256>/<file
/// name>`. Relative, so the site works under any path; content-addressed, so it can be cached
/// forever.
pub fn url(sha256: &str, file: &str) -> String {
let name = file.rsplit('/').next().unwrap_or(file);
format!("fonts/{}/{name}", sha256.get(..16).unwrap_or(sha256))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn base64_matches_rfc_4648_vectors() {
for (input, want) in
[("", ""), ("f", "Zg=="), ("fo", "Zm8="), ("foo", "Zm9v"), ("foob", "Zm9vYg=="), ("fooba", "Zm9vYmE="), ("foobar", "Zm9vYmFy")]
{
assert_eq!(base64(input.as_bytes()), want, "{input:?}");
}
}
#[test]
fn sri_of_the_empty_files_sha256() {
let empty = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
assert_eq!(sri(empty).as_deref(), Some("sha256-47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU="));
assert_eq!(sri("abc"), None);
assert_eq!(sri(&"zz".repeat(32)), None);
}
#[test]
fn url_is_relative_and_content_addressed() {
let sha = "63111b5b2e074dd48cc67692e0a2726d86ee94c1c37fe8598257b7b4e87e869e";
assert_eq!(url(sha, "fonts/noto-sans-arabic/NotoSansArabic.ttf"), "fonts/63111b5b2e074dd4/NotoSansArabic.ttf");
assert!(!url(sha, "x.ttf").starts_with('/'));
}
#[test]
fn list_parsing() {
let l = parse_list("# c\n\nstartup | Noto Sans Arabic | Regular\nbackground | Amiri | Bold Italic\n").unwrap();
assert_eq!(
l,
[
Listed { startup: true, family: "Noto Sans Arabic", style: "Regular" },
Listed { startup: false, family: "Amiri", style: "Bold Italic" }
]
);
assert!(parse_list("soon | A | B").unwrap_err().contains("line 1"));
assert!(parse_list("startup | A").is_err());
assert!(parse_list("startup | | B").is_err());
}
}