mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-14 19:26:56 -04:00
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]>
119 lines
4.6 KiB
Rust
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());
|
|
}
|
|
}
|