mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-27 03:06:57 -06:00
Merge pull request #754 from hamada147/feature/support-arabic-language
feat(text): load craft-fonts' Arabic families, fetch them on web
This commit is contained in:
Generated
+1
@@ -4769,6 +4769,7 @@ dependencies = [
|
||||
"rfd",
|
||||
"vectorcraft-engine",
|
||||
"vectorcraft-format",
|
||||
"vectorcraft-text",
|
||||
"vectorcraft-ui-egui",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
|
||||
+1
-1
File diff suppressed because one or more lines are too long
@@ -17,6 +17,7 @@ path = "src/main.rs"
|
||||
vectorcraft-engine = { workspace = true }
|
||||
vectorcraft-ui-egui = { workspace = true }
|
||||
vectorcraft-format = { workspace = true }
|
||||
vectorcraft-text = { workspace = true }
|
||||
egui = { workspace = true }
|
||||
log = { workspace = true }
|
||||
|
||||
@@ -27,4 +28,4 @@ rfd = { workspace = true }
|
||||
wasm-bindgen = "0.2"
|
||||
wasm-bindgen-futures = "0.4"
|
||||
js-sys = "0.3"
|
||||
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlIFrameElement", "HtmlAnchorElement", "Blob", "BlobPropertyBag", "ClipboardEvent", "DataTransfer", "File", "FileList", "Url", "Location", "CssStyleDeclaration", "Event", "EventTarget", "MouseEvent", "DragEvent", "Storage", "Navigator"] }
|
||||
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlIFrameElement", "HtmlAnchorElement", "Blob", "BlobPropertyBag", "ClipboardEvent", "DataTransfer", "File", "FileList", "Url", "Location", "CssStyleDeclaration", "Event", "EventTarget", "MouseEvent", "DragEvent", "Storage", "Navigator", "RequestInit", "Response", "AbortController", "AbortSignal"] }
|
||||
|
||||
@@ -8,3 +8,11 @@ filehash = true
|
||||
[serve]
|
||||
addresses = ["127.0.0.1"]
|
||||
port = 8766
|
||||
|
||||
# Copy the craft-fonts faces web builds fetch (crates/text/web-fonts.txt) into the site's fonts/
|
||||
# folder; a no-op without CRAFT_FONTS_DIR. Runs for `trunk serve` and `trunk build`, through cargo so
|
||||
# it needs no shell (xtask/src/web_fonts.rs).
|
||||
[[hooks]]
|
||||
stage = "post_build"
|
||||
command = "cargo"
|
||||
command_arguments = ["xtask", "web-fonts"]
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
//! Craft-fonts faces served beside the wasm (`fonts/<sha16>/…`, copied by
|
||||
//! `cargo xtask web-fonts`) instead of embedded: [`load_startup_fonts`] fetches the `startup`
|
||||
//! ones before the app starts, [`spawn_background_fonts`] the rest once it runs. The browser checks
|
||||
//! each file against its SHA-256 (Subresource Integrity). A face that fails is logged and skipped:
|
||||
//! the app runs without it. With `WEB_FONTS` empty (built without craft-fonts) both do nothing.
|
||||
|
||||
use std::cell::Cell;
|
||||
use std::rc::Rc;
|
||||
|
||||
use vectorcraft_text::{FontDb, WEB_FONTS, WebFont};
|
||||
use wasm_bindgen::JsCast as _;
|
||||
use wasm_bindgen::closure::Closure;
|
||||
use wasm_bindgen_futures::JsFuture;
|
||||
|
||||
/// Per startup face: the page shows its loading text meanwhile.
|
||||
const STARTUP_TIMEOUT_MS: i32 = 20_000;
|
||||
/// Per background face.
|
||||
const BACKGROUND_TIMEOUT_MS: i32 = 120_000;
|
||||
/// Background fetches at once, leaving the connection to the app's own requests.
|
||||
const BACKGROUND_PARALLEL: usize = 4;
|
||||
|
||||
thread_local! {
|
||||
/// The background fetch runs once per page, though a graphics restart creates the app again.
|
||||
static BACKGROUND_STARTED: Cell<bool> = const { Cell::new(false) };
|
||||
}
|
||||
|
||||
/// Fetch and register the `startup` faces (one after the other). Returns how many loaded.
|
||||
pub async fn load_startup_fonts() -> usize {
|
||||
let mut loaded = 0;
|
||||
for f in WEB_FONTS.iter().filter(|f| f.startup) {
|
||||
if load(f, STARTUP_TIMEOUT_MS).await {
|
||||
loaded += 1;
|
||||
}
|
||||
}
|
||||
loaded
|
||||
}
|
||||
|
||||
/// Fetch and register the `background` faces, [`BACKGROUND_PARALLEL`] at a time, asking `ctx` for
|
||||
/// a frame after each so the font menus and the canvas pick it up. Only the first call does
|
||||
/// anything.
|
||||
pub fn spawn_background_fonts(ctx: egui::Context) {
|
||||
if BACKGROUND_STARTED.with(|s| s.replace(true)) {
|
||||
return;
|
||||
}
|
||||
let queue: Rc<Vec<&'static WebFont>> = Rc::new(WEB_FONTS.iter().filter(|f| !f.startup).collect());
|
||||
let next = Rc::new(Cell::new(0usize));
|
||||
for _ in 0..BACKGROUND_PARALLEL.min(queue.len()) {
|
||||
let (queue, next, ctx) = (queue.clone(), next.clone(), ctx.clone());
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
loop {
|
||||
let i = next.get();
|
||||
next.set(i + 1);
|
||||
let Some(f) = queue.get(i) else { break };
|
||||
if load(f, BACKGROUND_TIMEOUT_MS).await {
|
||||
ctx.request_repaint();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetch one face and add it to the font database. False (and a console warning) on failure.
|
||||
async fn load(f: &WebFont, timeout_ms: i32) -> bool {
|
||||
let bytes = match fetch(f.url, f.integrity, timeout_ms).await {
|
||||
Ok(bytes) => bytes,
|
||||
Err(e) => {
|
||||
log::warn!("web font {} {}: {e}", f.family, f.style);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
if FontDb::global().add_font(bytes) == 0 {
|
||||
log::warn!("web font {} {}: not a font, or already loaded", f.family, f.style);
|
||||
return false;
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// GET `url` (relative to the page) with Subresource Integrity `integrity`, aborted after
|
||||
/// `timeout_ms`.
|
||||
async fn fetch(url: &str, integrity: &str, timeout_ms: i32) -> Result<Vec<u8>, String> {
|
||||
let window = web_sys::window().ok_or("no window")?;
|
||||
let abort = web_sys::AbortController::new().map_err(js)?;
|
||||
let init = web_sys::RequestInit::new();
|
||||
init.set_method("GET");
|
||||
init.set_integrity(integrity);
|
||||
init.set_signal(Some(&abort.signal()));
|
||||
let on_timeout = {
|
||||
let abort = abort.clone();
|
||||
Closure::<dyn FnMut()>::new(move || abort.abort())
|
||||
};
|
||||
let timer = window.set_timeout_with_callback_and_timeout_and_arguments_0(on_timeout.as_ref().unchecked_ref(), timeout_ms).map_err(js)?;
|
||||
let result = async {
|
||||
let response: web_sys::Response = JsFuture::from(window.fetch_with_str_and_init(url, &init)).await.map_err(js)?.dyn_into().map_err(js)?;
|
||||
if !response.ok() {
|
||||
return Err(format!("HTTP {} for {url}", response.status()));
|
||||
}
|
||||
let buffer = JsFuture::from(response.array_buffer().map_err(js)?).await.map_err(js)?;
|
||||
Ok(js_sys::Uint8Array::new(&buffer).to_vec())
|
||||
}
|
||||
.await;
|
||||
window.clear_timeout_with_handle(timer);
|
||||
// Kept alive until the timer can no longer fire.
|
||||
drop(on_timeout);
|
||||
result
|
||||
}
|
||||
|
||||
fn js(e: wasm_bindgen::JsValue) -> String {
|
||||
format!("{e:?}")
|
||||
}
|
||||
@@ -14,6 +14,8 @@
|
||||
//! URL query flags: `?webgl` forces the WebGL2 backend instead of WebGPU; `?lang=es` (any
|
||||
//! language code) stands in for the browser's languages when the interface language is Automatic.
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
mod fonts;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
mod locks;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
|
||||
@@ -64,6 +64,9 @@ pub fn start() {
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
// Before the first frame, which looks for copies a crash left behind.
|
||||
let locks = WebLocks::start(RECOVERY_PREFIX).await;
|
||||
// Fallback fonts the first frame needs (Noto Sans Arabic), fetched while the loading text
|
||||
// shows; the rest load in the background once the app runs.
|
||||
crate::fonts::load_startup_fonts().await;
|
||||
let host = Host {
|
||||
runner: eframe::WebRunner::new(),
|
||||
inbox: Arc::default(),
|
||||
@@ -99,6 +102,7 @@ async fn run(host: Host, canvas: web_sys::HtmlCanvasElement, moving: Option<Movi
|
||||
web_options(),
|
||||
Box::new(move |cc| {
|
||||
let ctx = &cc.egui_ctx;
|
||||
crate::fonts::spawn_background_fonts(ctx.clone());
|
||||
let loss = GraphicsLoss::default();
|
||||
watch_canvas(&page, &loss, ctx);
|
||||
if let Some(rs) = &cc.wgpu_render_state {
|
||||
|
||||
@@ -40,3 +40,8 @@ sync_github_name = true
|
||||
real_name = "Hal Goff"
|
||||
display_name = "Hal Goff, HG Studio"
|
||||
sync_github_name = false
|
||||
|
||||
[people.hamada147]
|
||||
real_name = "Ahmed Moussa"
|
||||
display_name = "Ahmed Moussa"
|
||||
sync_github_name = true
|
||||
|
||||
+65
-7
@@ -7,25 +7,49 @@
|
||||
//! Web (wasm32) builds embed only the UI font, BIZ UDPGothic Regular (~4.7 MB): the browser has no
|
||||
//! system fonts, and all four fonts (~24 MB) would push the `.wasm` past static hosts' per-file
|
||||
//! limits (Cloudflare Pages: 25 MiB).
|
||||
//!
|
||||
//! It also writes `WEB_FONTS`: the faces listed in `web-fonts.txt`, which web builds fetch from
|
||||
//! `fonts/<sha16>/` beside the wasm instead of embedding (`cargo xtask web-fonts` copies them
|
||||
//! there; `apps/vectorcraft-web/src/fonts.rs` fetches them). A list line the manifest lacks is left
|
||||
//! out with a warning, never an error, and never costs the desktop its fonts.
|
||||
|
||||
use std::fmt::Write as _;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
#[path = "build/web_fonts.rs"]
|
||||
mod web_fonts;
|
||||
|
||||
fn main() {
|
||||
println!("cargo::rerun-if-env-changed=CRAFT_FONTS_DIR");
|
||||
println!("cargo::rerun-if-env-changed=CRAFT_FONTS_REQUIRED");
|
||||
let mut src = String::from("pub static CRAFT_FONTS: &[CraftFont] = &[\n");
|
||||
if let Some(dir) = std::env::var_os("CRAFT_FONTS_DIR").map(PathBuf::from) {
|
||||
println!("cargo::rerun-if-changed=web-fonts.txt");
|
||||
println!("cargo::rerun-if-changed=build/web_fonts.rs");
|
||||
let required = std::env::var_os("CRAFT_FONTS_REQUIRED").is_some();
|
||||
let (mut fonts, mut web) = (String::new(), String::new());
|
||||
if let Some(dir) = std::env::var_os("CRAFT_FONTS_DIR").filter(|d| !d.is_empty()).map(PathBuf::from) {
|
||||
let dir = from_workspace(dir);
|
||||
match craft_fonts(&dir) {
|
||||
Ok(entries) => src.push_str(&entries),
|
||||
Err(e) if std::env::var_os("CRAFT_FONTS_REQUIRED").is_some() => {
|
||||
println!("cargo::error=CRAFT_FONTS_DIR={}: {e}", dir.display());
|
||||
}
|
||||
Ok(entries) => fonts = entries,
|
||||
Err(e) if required => println!("cargo::error=CRAFT_FONTS_DIR={}: {e}", dir.display()),
|
||||
Err(e) => println!("cargo::warning=building without craft-fonts: CRAFT_FONTS_DIR={}: {e}", dir.display()),
|
||||
}
|
||||
// Separately: a bad web list must not cost the desktop build its craft fonts. A listed face
|
||||
// the checkout lacks (an older craft-fonts pin) is left out with a warning, never an error,
|
||||
// so the apps don't depend on which craft-fonts commit CI pins: that build just fetches
|
||||
// fewer fonts.
|
||||
match web_font_entries(&dir) {
|
||||
Ok((entries, missing)) => {
|
||||
web = entries;
|
||||
if !missing.is_empty() {
|
||||
let msg = format!("{} not in CRAFT_FONTS_DIR={} (an older craft-fonts?)", missing.join(", "), dir.display());
|
||||
println!("cargo::warning=web build won't fetch: {msg}");
|
||||
}
|
||||
}
|
||||
Err(e) if required => println!("cargo::error=web-fonts.txt: {e}"),
|
||||
Err(e) => println!("cargo::warning=web build fetches no craft fonts: web-fonts.txt: {e}"),
|
||||
}
|
||||
}
|
||||
src.push_str("];\n");
|
||||
let src = format!("pub static CRAFT_FONTS: &[CraftFont] = &[\n{fonts}];\npub static WEB_FONTS: &[WebFont] = &[\n{web}];\n");
|
||||
let out = PathBuf::from(std::env::var_os("OUT_DIR").unwrap_or_default()).join("craft_fonts.rs");
|
||||
if let Err(e) = std::fs::write(&out, src) {
|
||||
println!("cargo::error=writing {}: {e}", out.display());
|
||||
@@ -72,3 +96,37 @@ fn craft_fonts(dir: &Path) -> Result<String, String> {
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// One `WebFont { .. }` initialiser per `web-fonts.txt` line, from its craft-fonts manifest row, and
|
||||
/// the listed faces the manifest lacks (an older craft-fonts checkout), which are left out.
|
||||
fn web_font_entries(dir: &Path) -> Result<(String, Vec<String>), String> {
|
||||
let list_path = PathBuf::from(std::env::var_os("CARGO_MANIFEST_DIR").unwrap_or_default()).join("web-fonts.txt");
|
||||
let list = std::fs::read_to_string(&list_path).map_err(|e| format!("{}: {e}", list_path.display()))?;
|
||||
let manifest_path = dir.join("fonts/manifest.txt");
|
||||
// WEB_FONTS follows the manifest (URLs and hashes), on every target.
|
||||
println!("cargo::rerun-if-changed={}", manifest_path.display());
|
||||
let manifest = std::fs::read_to_string(&manifest_path).map_err(|e| format!("{}: {e}", manifest_path.display()))?;
|
||||
let rows: Vec<Vec<&str>> =
|
||||
manifest.lines().map(str::trim).filter(|l| !l.is_empty() && !l.starts_with('#')).map(|l| l.split(" | ").map(str::trim).collect()).collect();
|
||||
let (mut out, mut missing) = (String::new(), Vec::new());
|
||||
for e in web_fonts::parse_list(&list)? {
|
||||
let Some(row) = rows.iter().find(|r| r.first() == Some(&e.family) && r.get(1) == Some(&e.style)) else {
|
||||
missing.push(format!("{} {}", e.family, e.style));
|
||||
continue;
|
||||
};
|
||||
let (Some(file), Some(sha)) = (row.get(2), row.get(6)) else {
|
||||
return Err(format!("malformed manifest row for {} {}", e.family, e.style));
|
||||
};
|
||||
let integrity = web_fonts::sri(sha).ok_or_else(|| format!("bad sha256 for {} {}", e.family, e.style))?;
|
||||
let _ = writeln!(
|
||||
out,
|
||||
" WebFont {{ family: {:?}, style: {:?}, startup: {}, url: {:?}, integrity: {:?} }},",
|
||||
e.family,
|
||||
e.style,
|
||||
e.startup,
|
||||
web_fonts::url(sha, file),
|
||||
integrity,
|
||||
);
|
||||
}
|
||||
Ok((out, missing))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
//! `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());
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
//! Fonts from the optional craft-fonts build input (<https://github.com/storytold/craft-fonts>).
|
||||
//!
|
||||
//! Built with `CRAFT_FONTS_DIR=<craft-fonts checkout>`, [`CRAFT_FONTS`] holds every font in its
|
||||
//! manifest (today the Japanese UI and document fonts); built without it, it is empty and Japanese
|
||||
//! manifest (Japanese UI and document fonts, and Arabic fonts); built without it, it is empty and Japanese
|
||||
//! text falls back to the installed system fonts. Release builds always set it. See `build.rs`
|
||||
//! and craftrules `standards/fonts.md`.
|
||||
|
||||
@@ -14,6 +14,20 @@ pub struct CraftFont {
|
||||
pub bytes: &'static [u8],
|
||||
}
|
||||
|
||||
/// A craft-fonts face the web (wasm32) build fetches from beside the wasm instead of embedding,
|
||||
/// listed in `crates/text/web-fonts.txt` (empty unless built with `CRAFT_FONTS_DIR`). Fetched by
|
||||
/// `apps/vectorcraft-web/src/fonts.rs`; copied into the site by `cargo xtask web-fonts`.
|
||||
pub struct WebFont {
|
||||
pub family: &'static str,
|
||||
pub style: &'static str,
|
||||
/// Fetched before the app starts (a fallback the first frame needs), else in the background.
|
||||
pub startup: bool,
|
||||
/// Site-relative URL, `fonts/<first 16 hex digits of the SHA-256>/<file name>`.
|
||||
pub url: &'static str,
|
||||
/// Subresource Integrity value, `sha256-<base64>`; the browser rejects other bytes.
|
||||
pub integrity: &'static str,
|
||||
}
|
||||
|
||||
include!(concat!(env!("OUT_DIR"), "/craft_fonts.rs"));
|
||||
|
||||
impl CraftFont {
|
||||
@@ -21,6 +35,10 @@ impl CraftFont {
|
||||
pub fn is_japanese(&self) -> bool {
|
||||
self.scripts.contains(&"Jpan")
|
||||
}
|
||||
/// Is the font for Arabic text?
|
||||
pub fn is_arabic(&self) -> bool {
|
||||
self.scripts.contains(&"Arab")
|
||||
}
|
||||
/// A serif (Mincho) family, for document text.
|
||||
pub fn is_mincho(&self) -> bool {
|
||||
self.family.contains("Mincho")
|
||||
@@ -43,6 +61,16 @@ pub fn japanese_document_fonts() -> Vec<&'static CraftFont> {
|
||||
v
|
||||
}
|
||||
|
||||
/// The craft-fonts faces the document font database loads: the Japanese ones in
|
||||
/// [`japanese_document_fonts`] order, then the Arabic ones in manifest order (Noto Sans Arabic
|
||||
/// first: the fallback for Arabic text; the other families are picked by name). Empty when built
|
||||
/// without craft-fonts.
|
||||
pub fn document_fonts() -> Vec<&'static CraftFont> {
|
||||
let mut v = japanese_document_fonts();
|
||||
v.extend(CRAFT_FONTS.iter().filter(|f| f.is_arabic() && !f.is_japanese()));
|
||||
v
|
||||
}
|
||||
|
||||
/// The craft-fonts faces for Japanese text, best for UI text first: Gothic (sans) families, then
|
||||
/// the others; `bold` puts bold faces before regular ones. Empty when built without craft-fonts.
|
||||
pub fn japanese_ui_fonts(bold: bool) -> Vec<&'static CraftFont> {
|
||||
@@ -50,3 +78,34 @@ pub fn japanese_ui_fonts(bold: bool) -> Vec<&'static CraftFont> {
|
||||
v.sort_by_key(|f| (!f.is_gothic(), f.is_bold() != bold));
|
||||
v
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn web_fonts_follow_the_list() {
|
||||
if CRAFT_FONTS.is_empty() {
|
||||
eprintln!("skipping: built without craft-fonts (set CRAFT_FONTS_DIR to a craft-fonts checkout)");
|
||||
return;
|
||||
}
|
||||
// The listed faces this craft-fonts checkout has (an older one lacks some; build.rs leaves
|
||||
// those out with a warning).
|
||||
let listed: Vec<_> = crate::web_fonts_build::parse_list(include_str!("../web-fonts.txt"))
|
||||
.unwrap()
|
||||
.into_iter()
|
||||
.filter(|l| CRAFT_FONTS.iter().any(|f| f.family == l.family && f.style == l.style))
|
||||
.collect();
|
||||
assert_eq!(WEB_FONTS.len(), listed.len());
|
||||
for (l, w) in listed.iter().zip(WEB_FONTS) {
|
||||
assert_eq!((l.family, l.style, l.startup), (w.family, w.style, w.startup));
|
||||
let (dir, name) = w.url.strip_prefix("fonts/").and_then(|r| r.split_once('/')).unwrap();
|
||||
assert!(dir.len() == 16 && dir.bytes().all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)), "{}", w.url);
|
||||
assert!(name.ends_with(".ttf") || name.ends_with(".otf"), "{}", w.url);
|
||||
assert!(w.integrity.starts_with("sha256-") && w.integrity.len() == "sha256-".len() + 44, "{}", w.integrity);
|
||||
}
|
||||
let startup: Vec<_> = WEB_FONTS.iter().filter(|w| w.startup).map(|w| w.family).collect();
|
||||
let noto = CRAFT_FONTS.iter().any(|f| f.family == "Noto Sans Arabic");
|
||||
assert_eq!(startup, if noto { vec!["Noto Sans Arabic"] } else { vec![] });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1130,9 +1130,10 @@ impl FontDb {
|
||||
|
||||
fn new(font_paths: FontPaths) -> Self {
|
||||
let mut faces = Vec::new();
|
||||
// The bundled fonts, then the Japanese craft-fonts faces (when built with them), Mincho
|
||||
// first: fallbacks for Japanese text after the requested and bundled fonts.
|
||||
let craft = crate::craft_fonts::japanese_document_fonts().into_iter().map(|f| f.bytes);
|
||||
// The bundled fonts, then the craft-fonts faces (when built with them): Japanese, Mincho
|
||||
// first, then Arabic with Noto Sans Arabic first. They are fallbacks for those scripts
|
||||
// after the requested and bundled fonts, and families users can pick by name.
|
||||
let craft = crate::craft_fonts::document_fonts().into_iter().map(|f| f.bytes);
|
||||
for data in BUNDLED.iter().copied().chain(craft) {
|
||||
for (i, spec) in enumerate_faces(data) {
|
||||
if let Some(f) = make_face(FontBytes::Static(data), i, spec, None) {
|
||||
|
||||
@@ -24,7 +24,7 @@ mod suitcase;
|
||||
pub mod test_fonts;
|
||||
pub mod thread;
|
||||
|
||||
pub use craft_fonts::{CRAFT_FONTS, CraftFont};
|
||||
pub use craft_fonts::{CRAFT_FONTS, CraftFont, WEB_FONTS, WebFont};
|
||||
pub use features::{LIGATURE_TRACKING_LIMITS, OtFeatures, explicit_ligatures, ligatures_suppressed_by};
|
||||
pub use fontdb::{
|
||||
FALLBACK_FAMILY, FontClass, FontDb, FontFace, FontMatch, FontTraits, IcfMargins, PlatformFontFiles, set_platform_font_files, set_user_font_dirs,
|
||||
@@ -523,3 +523,6 @@ mod tests_typo;
|
||||
mod tests_variable;
|
||||
#[cfg(test)]
|
||||
mod tests_vertical;
|
||||
#[cfg(test)]
|
||||
#[path = "../build/web_fonts.rs"]
|
||||
mod web_fonts_build;
|
||||
|
||||
@@ -344,3 +344,21 @@ fn paragraph_direction_is_per_paragraph() {
|
||||
assert!(l.lines[0].x1.abs() < 1e-6, "Auto aligns the right-to-left paragraph right: {}", l.lines[0].x1);
|
||||
assert!(l.lines[1].x0.abs() < 1e-6, "…and the left-to-right one left: {}", l.lines[1].x0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn craft_arabic_fonts_load_and_noto_sans_arabic_is_the_arabic_fallback() {
|
||||
let arabic: Vec<_> = crate::CRAFT_FONTS.iter().filter(|f| f.scripts.contains(&"Arab")).collect();
|
||||
if arabic.is_empty() {
|
||||
eprintln!("skipping: built without craft-fonts (set CRAFT_FONTS_DIR to a craft-fonts checkout)");
|
||||
return;
|
||||
}
|
||||
let db = FontDb::with_font_dirs(vec![]);
|
||||
for f in &arabic {
|
||||
assert!(db.has_family(f.family), "{} {} isn't loaded", f.family, f.style);
|
||||
}
|
||||
let beh = db.face_covering('ب').expect("some face covers U+0628");
|
||||
assert_eq!(beh.family, "Noto Sans Arabic");
|
||||
// Latin still falls back to a bundled face, not to one of the Arabic families.
|
||||
let a = db.face_covering('a').expect("some face covers 'a'");
|
||||
assert!(!arabic.iter().any(|f| f.family == a.family), "Latin now falls back to {}", a.family);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
# Craft-fonts faces the web (wasm32) build fetches from beside the wasm instead of embedding.
|
||||
# One face per line: when | family | style (family and style as in craft-fonts' fonts/manifest.txt).
|
||||
# startup = fetched before the app starts (fallbacks the first frame needs)
|
||||
# background = fetched after the app starts (fonts users pick by name)
|
||||
# Read by build.rs (the WEB_FONTS table) and `cargo xtask web-fonts` (copies the files, a Trunk hook).
|
||||
startup | Noto Sans Arabic | Regular
|
||||
background | Cairo | Regular
|
||||
background | Tajawal | ExtraLight
|
||||
background | Tajawal | Light
|
||||
background | Tajawal | Regular
|
||||
background | Tajawal | Medium
|
||||
background | Tajawal | Bold
|
||||
background | Tajawal | ExtraBold
|
||||
background | Tajawal | Black
|
||||
background | Almarai | Light
|
||||
background | Almarai | Regular
|
||||
background | Almarai | Bold
|
||||
background | Almarai | ExtraBold
|
||||
background | IBM Plex Sans Arabic | Thin
|
||||
background | IBM Plex Sans Arabic | ExtraLight
|
||||
background | IBM Plex Sans Arabic | Light
|
||||
background | IBM Plex Sans Arabic | Regular
|
||||
background | IBM Plex Sans Arabic | Medium
|
||||
background | IBM Plex Sans Arabic | SemiBold
|
||||
background | IBM Plex Sans Arabic | Bold
|
||||
background | Noto Kufi Arabic | Regular
|
||||
background | Amiri | Regular
|
||||
background | Amiri | Italic
|
||||
background | Amiri | Bold
|
||||
background | Amiri | Bold Italic
|
||||
background | Alexandria | Regular
|
||||
background | Fustat | Regular
|
||||
background | Reem Kufi | Regular
|
||||
background | Aref Ruqaa | Regular
|
||||
background | Aref Ruqaa | Bold
|
||||
background | Gulzar | Regular
|
||||
@@ -20,3 +20,4 @@ serde_json = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
image = { workspace = true }
|
||||
vectorcraft-text = { workspace = true, features = ["test-fonts"] }
|
||||
|
||||
@@ -59,6 +59,9 @@ struct Preedit {
|
||||
active: Option<Range<usize>>,
|
||||
}
|
||||
|
||||
/// The edited text's layout, keyed by the font database's generation and the text.
|
||||
type CachedLayout = ((u64, TextObject), Arc<TextLayout>);
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct TypeTool {
|
||||
vertical: bool,
|
||||
@@ -86,7 +89,7 @@ pub struct TypeTool {
|
||||
clicks: (Option<Point>, u8),
|
||||
/// Styled copy of the last copied range (pasting the same plain text keeps its styles).
|
||||
clipboard: Vec<TextRun>,
|
||||
cache: Mutex<Option<(TextObject, Arc<TextLayout>)>>,
|
||||
cache: Mutex<Option<CachedLayout>>,
|
||||
}
|
||||
|
||||
impl TypeTool {
|
||||
@@ -106,16 +109,19 @@ impl TypeTool {
|
||||
}
|
||||
}
|
||||
|
||||
/// Layout of `t`, cached by content (overlays run every frame).
|
||||
/// Layout of `t`, cached by content (overlays run every frame) and by the fonts available (the
|
||||
/// web build adds fonts after startup).
|
||||
fn layout(&self, t: &TextObject) -> Arc<TextLayout> {
|
||||
let fonts = FontDb::global().generation();
|
||||
let mut c = self.cache.lock().unwrap_or_else(|e| e.into_inner());
|
||||
if let Some((k, l)) = c.as_ref()
|
||||
if let Some(((g, k), l)) = c.as_ref()
|
||||
&& *g == fonts
|
||||
&& k == t
|
||||
{
|
||||
return l.clone();
|
||||
}
|
||||
let l = Arc::new(vectorcraft_text::layout(FontDb::global(), t));
|
||||
*c = Some((t.clone(), l.clone()));
|
||||
*c = Some(((fonts, t.clone()), l.clone()));
|
||||
l
|
||||
}
|
||||
|
||||
|
||||
@@ -359,3 +359,15 @@ fn new_type_snaps_to_smart_guides() {
|
||||
let area = json!({"width": 100.0, "height": 100.0});
|
||||
assert!(acts.iter().any(|a| matches!(a, Action::Exec(cmd, v) if cmd == "text.create" && v["x"] == 100.0 && v["area"] == area)), "{acts:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layout_cache_follows_fonts_added_later() {
|
||||
// The web build adds fonts after startup: a caret layout cached before one arrived is stale.
|
||||
let (d, id, tool) = editing("abc");
|
||||
let NodeKind::Text(t) = &d.node(id).unwrap().kind else { panic!("not text") };
|
||||
let before = tool.layout(t);
|
||||
assert!(Arc::ptr_eq(&before, &tool.layout(t)), "cached while the fonts don't change");
|
||||
let font = vectorcraft_text::test_fonts::variable_font().unwrap();
|
||||
assert!(FontDb::global().add_font(font) > 0, "the test font is new to this process");
|
||||
assert!(!Arc::ptr_eq(&before, &tool.layout(t)), "a font added after the layout must refresh it");
|
||||
}
|
||||
|
||||
@@ -12,6 +12,13 @@ trunk build --release # writes ../../dist/web (index.html, .js glue
|
||||
trunk serve --release # dev server on http://127.0.0.1:8766
|
||||
```
|
||||
|
||||
What the web build needs: `trunk`, the `wasm32-unknown-unknown` target and `cargo`, nothing else (no
|
||||
POSIX shell, so it works the same on Windows). Optionally, `CRAFT_FONTS_DIR` set to an absolute
|
||||
craft-fonts checkout adds the fonts the site serves beside the wasm: Trunk's `post_build` hook runs
|
||||
`cargo xtask web-fonts`, which copies them into `dist/web/fonts/` (it does nothing without
|
||||
`CRAFT_FONTS_DIR`, and skips with a warning any listed face an older checkout lacks). See
|
||||
[Arabic fonts and the web build](#arabic-fonts-and-the-web-build).
|
||||
|
||||
Any static file server works for `dist/web`, for example `python3 -m http.server 8766` inside that directory. The release `.wasm` is about 17.5 MB, or 7.1 MB gzipped, so serve it with compression.
|
||||
|
||||
URL flag: `?webgl` forces the WebGL2 backend.
|
||||
@@ -124,6 +131,25 @@ CRAFT_FONTS_DIR="$PWD/../craft-fonts" cargo xtask ci # also runs the Japanese-
|
||||
CI job and every release job (`release.yml`) check craft-fonts out at a pinned commit; release
|
||||
packages carry each embedded font's `OFL-<family>.txt`.
|
||||
|
||||
### Arabic fonts and the web build
|
||||
|
||||
Desktop builds load craft-fonts' Arabic families into the document font database (Noto Sans
|
||||
Arabic first, the fallback for Arabic text; the others are picked by name).
|
||||
|
||||
The web build doesn't embed them (wasm size). Instead `crates/text/web-fonts.txt` lists the faces
|
||||
it fetches from beside the wasm, `startup` (before the app starts) or `background` (after):
|
||||
|
||||
- `crates/text/build.rs` turns the list into `vectorcraft_text::WEB_FONTS` (URL
|
||||
`fonts/<sha16>/<file>` and the SRI hash, from the craft-fonts manifest).
|
||||
- `cargo xtask web-fonts` (`xtask/src/web_fonts.rs`), the Trunk `post_build` hook, copies the files
|
||||
there, checking each one's SHA-256. A listed face the checkout lacks is skipped with a warning, as
|
||||
in `build.rs`, so an older craft-fonts just means fewer fonts.
|
||||
- `apps/vectorcraft-web/src/fonts.rs` fetches and registers them, logging `web font …` for any
|
||||
that fail.
|
||||
|
||||
Try it: `cd apps/vectorcraft-web && CRAFT_FONTS_DIR=$PWD/../../../craft-fonts trunk serve`. To serve
|
||||
another craft-fonts face on the web, add a line to `web-fonts.txt`.
|
||||
|
||||
## Localisation
|
||||
|
||||
Strings in code stay English and are the default lookup keys. `crates/ui-egui/src/i18n` maps them to display
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
# Apache (see HOSTING.md). Needs mod_mime; mod_deflate and mod_headers are optional.
|
||||
AddType application/wasm .wasm
|
||||
AddType font/ttf .ttf
|
||||
AddType text/javascript .js
|
||||
|
||||
<IfModule mod_deflate.c>
|
||||
AddOutputFilterByType DEFLATE application/wasm text/javascript text/html
|
||||
AddOutputFilterByType DEFLATE application/wasm text/javascript text/html font/ttf
|
||||
</IfModule>
|
||||
|
||||
<IfModule mod_headers.c>
|
||||
<FilesMatch "\.(wasm|js)$">
|
||||
<FilesMatch "\.(wasm|js|ttf)$">
|
||||
Header set Cache-Control "public, max-age=31536000, immutable"
|
||||
</FilesMatch>
|
||||
<FilesMatch "^index\.html$">
|
||||
|
||||
@@ -50,6 +50,19 @@ location /vectorcraft/ {
|
||||
|
||||
Local test: `python3 -m http.server 8765` inside the folder, then open http://localhost:8765/.
|
||||
|
||||
## Fonts folder
|
||||
|
||||
Builds made with craft-fonts (`CRAFT_FONTS_DIR`, all releases) have a `fonts/` folder beside the
|
||||
wasm: the Arabic fonts the app fetches instead of embedding (about 8 MB, each next to its OFL
|
||||
licence). Serve it with the rest of the site. Noto Sans Arabic is fetched before the app starts;
|
||||
the other families load in the background and appear in the font menus as they arrive.
|
||||
|
||||
- Serve `.ttf` as `font/ttf` (the browser checks each file's SHA-256, so a wrong type or a
|
||||
rewritten file makes the app skip that font and log `web font …` in the console).
|
||||
- The folder names are the files' SHA-256 prefixes, so `fonts/*` can be cached forever
|
||||
(`Cache-Control: public, max-age=31536000, immutable`); `_headers` and `.htaccess` do this.
|
||||
- Without the folder the app still runs, but Arabic text shows as boxes.
|
||||
|
||||
## Embedding in a page (iframe)
|
||||
|
||||
```html
|
||||
|
||||
@@ -11,3 +11,7 @@
|
||||
|
||||
/index.html
|
||||
Cache-Control: no-cache
|
||||
|
||||
/fonts/*
|
||||
Content-Type: font/ttf
|
||||
Cache-Control: public, max-age=31536000, immutable
|
||||
|
||||
@@ -12,6 +12,7 @@ mod layers;
|
||||
mod sha256;
|
||||
mod stats;
|
||||
mod version;
|
||||
mod web_fonts;
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Command, ExitCode};
|
||||
@@ -32,6 +33,8 @@ commands:
|
||||
ico <out.ico> <png>...
|
||||
pack PNGs into a Windows .ico (used by packaging/icons.sh)
|
||||
stats [--exact] count tests and lines per crate (--exact: ask the test harness via `-- --list`)
|
||||
web-fonts copy the craft-fonts faces in crates/text/web-fonts.txt into the web site (the Trunk
|
||||
post_build hook; a no-op without CRAFT_FONTS_DIR)
|
||||
";
|
||||
|
||||
fn main() -> ExitCode {
|
||||
@@ -49,6 +52,7 @@ fn main() -> ExitCode {
|
||||
Some("ico") => ico::run(&rest),
|
||||
Some("version") => version::run(&root(), &rest),
|
||||
Some("stats") => stats::run(&root(), rest.contains(&"--exact")),
|
||||
Some("web-fonts") => web_fonts::run(&root()),
|
||||
Some("-h" | "--help" | "help") | None => {
|
||||
print!("{USAGE}");
|
||||
Ok(())
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
//! `cargo xtask web-fonts`: the Trunk `post_build` hook (`apps/*-web/Trunk.toml`). Copies the
|
||||
//! craft-fonts faces listed in `crates/text/web-fonts.txt` from `$CRAFT_FONTS_DIR` into the site,
|
||||
//! each at `fonts/<first 16 hex digits of its SHA-256>/<file>` with its licence beside it: the URLs
|
||||
//! `crates/text/build.rs` puts in `WEB_FONTS` (both use `crates/text/build/web_fonts.rs`).
|
||||
//!
|
||||
//! A no-op without `CRAFT_FONTS_DIR`. A listed face the checkout lacks (an older craft-fonts) is
|
||||
//! skipped with a warning, as `build.rs` does; a file whose SHA-256 doesn't match the manifest is an
|
||||
//! error (a corrupt checkout). Rust rather than a shell script, so the hook runs wherever cargo does.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use crate::sha256;
|
||||
|
||||
#[allow(dead_code)] // build.rs uses the rest (sri, base64)
|
||||
#[path = "../../crates/text/build/web_fonts.rs"]
|
||||
mod list;
|
||||
|
||||
/// The hook: reads `CRAFT_FONTS_DIR` and Trunk's `TRUNK_STAGING_DIR`.
|
||||
pub fn run(root: &Path) -> Result<(), String> {
|
||||
let var = |name| std::env::var_os(name).filter(|v| !v.is_empty()).map(PathBuf::from);
|
||||
run_with(root, var("CRAFT_FONTS_DIR"), var("TRUNK_STAGING_DIR"))
|
||||
}
|
||||
|
||||
fn run_with(root: &Path, craft_fonts: Option<PathBuf>, staging: Option<PathBuf>) -> Result<(), String> {
|
||||
let Some(dir) = craft_fonts else { return Ok(()) };
|
||||
// Relative like build.rs: from the workspace root, not the directory Trunk runs hooks in.
|
||||
let dir = if dir.is_absolute() { dir } else { root.join(dir) };
|
||||
let staging = staging.ok_or("TRUNK_STAGING_DIR is unset: run this as the Trunk post_build hook")?;
|
||||
let (copied, skipped) = copy(&root.join("crates/text/web-fonts.txt"), &dir, &staging)?;
|
||||
if !skipped.is_empty() {
|
||||
eprintln!("warning: web build won't fetch {} (not in CRAFT_FONTS_DIR={}; an older craft-fonts?)", skipped.join(", "), dir.display());
|
||||
}
|
||||
println!("web-fonts: copied {copied} font(s) into {}", staging.join("fonts").display());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Copy the faces `list_path` names from the craft-fonts checkout `dir` into `site` (at their
|
||||
/// `fonts/…` URLs). Returns how many were copied and the listed faces the checkout lacks.
|
||||
fn copy(list_path: &Path, dir: &Path, site: &Path) -> Result<(usize, Vec<String>), String> {
|
||||
let read = |p: &Path| std::fs::read_to_string(p).map_err(|e| format!("{}: {e}", p.display()));
|
||||
let list = read(list_path)?;
|
||||
let manifest = read(&dir.join("fonts/manifest.txt"))?;
|
||||
let rows: Vec<Vec<&str>> =
|
||||
manifest.lines().map(str::trim).filter(|l| !l.is_empty() && !l.starts_with('#')).map(|l| l.split(" | ").map(str::trim).collect()).collect();
|
||||
let (mut copied, mut skipped) = (0, Vec::new());
|
||||
for e in list::parse_list(&list)? {
|
||||
let Some(row) = rows.iter().find(|r| r.first() == Some(&e.family) && r.get(1) == Some(&e.style)) else {
|
||||
skipped.push(format!("{} {}", e.family, e.style));
|
||||
continue;
|
||||
};
|
||||
let (Some(file), Some(licence), Some(sha)) = (row.get(2), row.get(5), row.get(6)) else {
|
||||
return Err(format!("malformed manifest row for {} {}", e.family, e.style));
|
||||
};
|
||||
let bytes = std::fs::read(dir.join(file)).map_err(|err| format!("{file}: {err}"))?;
|
||||
let got = sha256::hex(&bytes);
|
||||
if got != *sha {
|
||||
return Err(format!("{file}: sha256 {got}, manifest says {sha}"));
|
||||
}
|
||||
let dest = site.join(list::url(sha, file));
|
||||
let folder = dest.parent().ok_or_else(|| format!("{}: no parent folder", dest.display()))?;
|
||||
std::fs::create_dir_all(folder).map_err(|err| format!("{}: {err}", folder.display()))?;
|
||||
std::fs::write(&dest, &bytes).map_err(|err| format!("{}: {err}", dest.display()))?;
|
||||
let licence_name = Path::new(licence).file_name().ok_or_else(|| format!("{licence}: not a file"))?;
|
||||
std::fs::copy(dir.join(licence), folder.join(licence_name)).map_err(|err| format!("{licence}: {err}"))?;
|
||||
copied += 1;
|
||||
}
|
||||
Ok((copied, skipped))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A fake craft-fonts checkout with one face, and a list naming it plus one it lacks.
|
||||
fn fixture(name: &str, manifest_sha: Option<&str>) -> (PathBuf, PathBuf, PathBuf, String) {
|
||||
let base = std::env::temp_dir().join(format!("xtask-web-fonts-{name}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&base);
|
||||
let dir = base.join("craft-fonts");
|
||||
std::fs::create_dir_all(dir.join("fonts/a")).unwrap();
|
||||
std::fs::write(dir.join("fonts/a/A-Regular.ttf"), b"font bytes").unwrap();
|
||||
std::fs::write(dir.join("fonts/a/OFL.txt"), b"licence").unwrap();
|
||||
let sha = sha256::hex(b"font bytes");
|
||||
let row_sha = manifest_sha.map_or(sha.clone(), str::to_string);
|
||||
std::fs::write(
|
||||
dir.join("fonts/manifest.txt"),
|
||||
format!("# family | style | file | scripts | licence | licence file | sha256 | source\nA Sans | Regular | fonts/a/A-Regular.ttf | Arab | OFL-1.1 | fonts/a/OFL.txt | {row_sha} | https://example.invalid\n"),
|
||||
)
|
||||
.unwrap();
|
||||
let list = base.join("web-fonts.txt");
|
||||
std::fs::write(&list, "startup | A Sans | Regular\nbackground | Missing Sans | Bold\n").unwrap();
|
||||
(list, dir, base.join("site"), sha)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn copies_listed_faces_with_their_licence_to_their_urls() {
|
||||
let (list, dir, site, sha) = fixture("copy", None);
|
||||
let (copied, skipped) = copy(&list, &dir, &site).unwrap();
|
||||
assert_eq!(copied, 1);
|
||||
let at = site.join("fonts").join(&sha[..16]);
|
||||
assert_eq!(std::fs::read(at.join("A-Regular.ttf")).unwrap(), b"font bytes");
|
||||
assert_eq!(std::fs::read(at.join("OFL.txt")).unwrap(), b"licence");
|
||||
assert_eq!(format!("fonts/{}/A-Regular.ttf", &sha[..16]), list::url(&sha, "fonts/a/A-Regular.ttf"));
|
||||
assert_eq!(skipped, ["Missing Sans Bold"], "a face the checkout lacks is skipped, not an error");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_file_that_does_not_match_its_manifest_hash_is_an_error() {
|
||||
let (list, dir, site, _) = fixture("mismatch", Some(&"0".repeat(64)));
|
||||
let e = copy(&list, &dir, &site).unwrap_err();
|
||||
assert!(e.contains("A-Regular.ttf") && e.contains("sha256"), "{e}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn without_craft_fonts_the_hook_does_nothing() {
|
||||
let (_, _, site, _) = fixture("noop", None);
|
||||
run_with(Path::new("/nonexistent"), None, None).unwrap();
|
||||
assert!(!site.exists());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user