feat(text): load craft-fonts' Arabic families, fetch them on web

Desktop builds made with CRAFT_FONTS_DIR now load craft-fonts' 12 Arabic families into the document font database. Noto Sans Arabic comes first and is the fallback for Arabic text. Cairo, Tajawal, Almarai, IBM Plex Sans Arabic, Noto Kufi Arabic, Amiri, Alexandria, Fustat, Reem Kufi, Aref Ruqaa and Gulzar can be picked by name from the font menus.

The web build doesn't embed these fonts because they would make the wasm too large. It fetches them from beside the wasm instead:
- crates/text/web-fonts.txt lists each face as startup or background.
- build.rs turns that list into vectorcraft_text::WEB_FONTS, with each face's URL (fonts/<sha16>/<file>) and SRI hash taken from the craft-fonts manifest.
- packaging/web/copy-fonts.sh, a Trunk post_build hook, copies the font files and their OFL licences there.
- apps/vectorcraft-web/src/fonts.rs fetches Noto Sans Arabic before the app starts, so Arabic text never shows as boxes. It loads the other faces in the background and logs `web font …` for any that fail.

Because fonts can now arrive after startup, the Type tool's layout cache also checks the font database's generation, so text lays out again when new fonts arrive.

_headers and .htaccess serve fonts/* as font/ttf and cache it as immutable. packaging/web/README.md and docs/development.md explain the fonts folder, and ROADMAP.md lists the Arabic typefaces under Type. Also adds Ahmed Moussa to contributors/people.toml.

Signed-off-by: Ahmed Moussa <[email protected]>
This commit is contained in:
Ahmed Moussa committed 2026-10-09 16:09:34 +03:00
1 parent c2711df30b
commit 3c85f3f9e3
23 files changed
+527 -20

No files matched your search

+2 -1
View File
@@ -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"] }
+7
View File
@@ -8,3 +8,10 @@ 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`.
[[hooks]]
stage = "post_build"
command = "sh"
command_arguments = ["../../packaging/web/copy-fonts.sh"]
+109
View File
@@ -0,0 +1,109 @@
//! Craft-fonts faces served beside the wasm (`fonts/<sha16>/…`, copied by
//! `packaging/web/copy-fonts.sh`) 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:?}")
}
+2
View File
@@ -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")]
+4
View File
@@ -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 {