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:
BFlat authored and GitHub committed 2026-10-10 02:08:29 +07:00
commit b43e633329
24 files changed
+629 -20

No files matched your search

Generated
+1
View File
@@ -4769,6 +4769,7 @@ dependencies = [
"rfd",
"vectorcraft-engine",
"vectorcraft-format",
"vectorcraft-text",
"vectorcraft-ui-egui",
"wasm-bindgen",
"wasm-bindgen-futures",
+1 -1
View File
@@ -147,7 +147,7 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist
- **Geometry and effects:**
- Pathfinder (10 exact curve booleans), Offset, Outline Stroke, Simplify, Clean Up, Split Into Grid, Divide Objects Below. Object › Path › Remove Anchor Points (`path.removeAnchors`) removes the direct-selected anchors without opening their paths (the Delete key still deletes them with their segments): the neighbours keep their handle directions and their facing handles are refitted so one cubic follows the two old segments, and straight sides stay straight. The Delete Anchor Point tool uses the same refit. The Control bar, the contextual task bar, the Properties quick actions and the canvas context menu offer it when anchors are selected. With anchors selected, the Control bar and the Properties panel also convert them to corner or smooth (`path.convertAnchors`), connect end points (Join) and cut the path at them (`path.cutAtAnchors`, also on the task bar and the context menu): a closed path opens there and an open one becomes one path per piece, each cut leaving one of its two coincident anchors selected so a drag pulls the path apart.
- Live effects with previewing dialogs: Distort & Transform, Path, Convert to Shape, 15 Warp styles, Round Corners, Scribble, Effect → Pathfinder (all 10 operations, live on groups; several loose objects are grouped first, in one undo step), Color Adjustments (Brightness/Contrast, Curves, Levels, Hue/Saturation, Shift to Color, Temperature/Tint, on vectors, live type and embedded images), and raster drop shadow, glows and feather. SVG and PDF export keep live effects (geometry baked; SVG raster effects as filters).
- **Type:** Text Wrap, Type on a Path effects (Rainbow/Skew/3D Ribbon/Stair Step/Gravity) and Options (Flip, Align to Path, Spacing; OK keeps the type where it is), type on a path's start, centre and end brackets (Selection and Direct Selection drag them to set where the type runs, slide it along its path and, across the path, flip it: #429), Character and Paragraph Styles (override-preserving redefine), paragraph attributes per paragraph (alignment, indents, spacing, hyphenation, hanging punctuation, tab stops, paragraph style, direction, mojikumi and leading model; the Paragraph, Tabs and Paragraph Styles panels act on the paragraphs the Type tool's selection or caret touches, Return continues the paragraph's style, threads and SVG keep them), Area Type Options (rows/columns/inset/first baseline, vertical Align Top/Center/Bottom/Justify per row/column, reflowing non-rectangular frames and text wrap), threaded text across any closed shapes, Fit Headline, the Every-line Composer for justified and ragged text (paragraph-wide breaks for an even rag, Single-line per object from the Paragraph panel menu or `text.setFormat {composer}`). Area type resizes like Illustrator's type areas: a bounding-box handle drag (Selection tool) or Area Type Options Width/Height size the frame and the text reflows at its size (threads too); Direct Selection drags a frame corner or edge (`text.reshapeArea`). Point type still scales. Auto Size: the frame height follows the text after every edit, in the same undo step (rectangular frames of horizontal type in one row; columns get the least height that holds them; a height set by hand or threading turns it off), and the "Auto Size New Area Type" preference gives it to new area type. Shrink Text to Fit (beyond Illustrator): overflowing area type scales its sizes, leading and baseline shifts down by the largest factor (in 0.1 pt steps of the first run, down to a minimum percentage) that fits, at layout time so every export sees it. `text.areaOptions` and `document.inspect` report overflow and the shrink factor. The Selection tool shows the type widget beside selected type's bounding box (hollow on point type, filled on area type); double-clicking it converts between them (Type › Convert To Area Type / Convert To Point Type), keeping the text and its styles in place. Variable fonts list every named instance as a style of their family (Figtree Light to Black and their italics, Bahnschrift, Segoe UI Variable), and the instance chosen is shaped, drawn, outlined and measured at its axis settings; PDF and SVG embed it as that instance. Variation axis sliders are not done. Beyond Illustrator: inline graphics (`text.insertInline`, Type ▸ Insert Inline Symbol) set a document symbol in a run of type as one character that flows, wraps and justifies like a glyph (InDesign-style inline objects, e.g. mana symbols in card rules text): sized to a multiple of the font size, centred on the cap height, opening up tall lines, following Redefine Symbol; a character of its run's size for Character Alignment and Top-to-Top leading, placed by the bidi reordering in right-to-left text (a neutral U+FFFC); drawn on the canvas, kept as live text plus a `<use>` in SVG and as vector art in PDF; Backspace removes it whole. Hebrew and Arabic are laid out right to left with the Unicode bidirectional algorithm (embedded Latin and numbers keep their direction, lines put in visual order after wrapping) in point, area and path type, with caret, selection and arrow keys in visual order; each paragraph's direction comes from its first strong character unless Paragraph Direction sets it (Paragraph panel with Show Indic Options, `text.setFormat {direction}`), and new type aligns to the start of its direction. SVG export outlines right-to-left text. Font Size and Leading are combo boxes in the Character and Properties panels (Font Size in the Control bar too, beside the Character link, font family and style): type, step with ↑/↓ or the spinner, scrub the label, or pick a preset (6–72 pt, shown in the type unit; Leading also Auto), each pick one undo step; Kerning and Tracking list -100…200; a size, leading, kerning or tracking the selected text doesn't share shows blank (#458). Type › Bold and Italic (beyond Illustrator, as page layout apps have them; Cmd+Shift+B / Cmd+Shift+I while the Type tool edits text) switch the selected type to its family's own Bold or Italic face and back, keeping its width (a Narrow face stays Narrow); a family without that face says so (#724).
- **Type:** Text Wrap, Type on a Path effects (Rainbow/Skew/3D Ribbon/Stair Step/Gravity) and Options (Flip, Align to Path, Spacing; OK keeps the type where it is), type on a path's start, centre and end brackets (Selection and Direct Selection drag them to set where the type runs, slide it along its path and, across the path, flip it: #429), Character and Paragraph Styles (override-preserving redefine), paragraph attributes per paragraph (alignment, indents, spacing, hyphenation, hanging punctuation, tab stops, paragraph style, direction, mojikumi and leading model; the Paragraph, Tabs and Paragraph Styles panels act on the paragraphs the Type tool's selection or caret touches, Return continues the paragraph's style, threads and SVG keep them), Area Type Options (rows/columns/inset/first baseline, vertical Align Top/Center/Bottom/Justify per row/column, reflowing non-rectangular frames and text wrap), threaded text across any closed shapes, Fit Headline, the Every-line Composer for justified and ragged text (paragraph-wide breaks for an even rag, Single-line per object from the Paragraph panel menu or `text.setFormat {composer}`). Area type resizes like Illustrator's type areas: a bounding-box handle drag (Selection tool) or Area Type Options Width/Height size the frame and the text reflows at its size (threads too); Direct Selection drags a frame corner or edge (`text.reshapeArea`). Point type still scales. Auto Size: the frame height follows the text after every edit, in the same undo step (rectangular frames of horizontal type in one row; columns get the least height that holds them; a height set by hand or threading turns it off), and the "Auto Size New Area Type" preference gives it to new area type. Shrink Text to Fit (beyond Illustrator): overflowing area type scales its sizes, leading and baseline shifts down by the largest factor (in 0.1 pt steps of the first run, down to a minimum percentage) that fits, at layout time so every export sees it. `text.areaOptions` and `document.inspect` report overflow and the shrink factor. The Selection tool shows the type widget beside selected type's bounding box (hollow on point type, filled on area type); double-clicking it converts between them (Type › Convert To Area Type / Convert To Point Type), keeping the text and its styles in place. Variable fonts list every named instance as a style of their family (Figtree Light to Black and their italics, Bahnschrift, Segoe UI Variable), and the instance chosen is shaped, drawn, outlined and measured at its axis settings; PDF and SVG embed it as that instance. Variation axis sliders are not done. Beyond Illustrator: inline graphics (`text.insertInline`, Type ▸ Insert Inline Symbol) set a document symbol in a run of type as one character that flows, wraps and justifies like a glyph (InDesign-style inline objects, e.g. mana symbols in card rules text): sized to a multiple of the font size, centred on the cap height, opening up tall lines, following Redefine Symbol; a character of its run's size for Character Alignment and Top-to-Top leading, placed by the bidi reordering in right-to-left text (a neutral U+FFFC); drawn on the canvas, kept as live text plus a `<use>` in SVG and as vector art in PDF; Backspace removes it whole. Hebrew and Arabic are laid out right to left with the Unicode bidirectional algorithm (embedded Latin and numbers keep their direction, lines put in visual order after wrapping) in point, area and path type, with caret, selection and arrow keys in visual order; each paragraph's direction comes from its first strong character unless Paragraph Direction sets it (Paragraph panel with Show Indic Options, `text.setFormat {direction}`), and new type aligns to the start of its direction. SVG export outlines right-to-left text. Arabic typefaces: builds made with a craft-fonts checkout that holds its Arabic families (Noto Sans Arabic, the Arabic fallback, plus Cairo, Tajawal, Almarai, IBM Plex Sans Arabic, Noto Kufi Arabic, Amiri, Alexandria, Fustat, Reem Kufi, Aref Ruqaa and Gulzar once craft-fonts adds them, storytold/craft-fonts#11; releases get them when their craft-fonts pin moves) list them in the font menus on desktop; the web build fetches them from beside the wasm (Noto Sans Arabic before start, so Arabic never shows as boxes, the rest in the background). Font Size and Leading are combo boxes in the Character and Properties panels (Font Size in the Control bar too, beside the Character link, font family and style): type, step with ↑/↓ or the spinner, scrub the label, or pick a preset (6–72 pt, shown in the type unit; Leading also Auto), each pick one undo step; Kerning and Tracking list -100…200; a size, leading, kerning or tracking the selected text doesn't share shows blank (#458). Type › Bold and Italic (beyond Illustrator, as page layout apps have them; Cmd+Shift+B / Cmd+Shift+I while the Type tool edits text) switch the selected type to its family's own Bold or Italic face and Line truncated
- **Interface languages, vertical type:** VectorCraft ▸ Language (or Preferences ▸ User Interface) picks Automatic (the system locale) or a registered language, persisted as `interfaceLanguage`. Translations come from per-language catalogs (`i18n/*.tsv`): Traditional and Simplified Chinese, Japanese, Spanish (neutral international Spanish, for every `es-*` locale), French (every `fr-*` locale), Italian (every `it-*` locale) Russian (every `ru-*` locale) and Ukrainian (every `uk-*` locale) cover the whole interface, Czech and Brazilian Portuguese every menu label, Brazilian Portuguese the shell's `tl!` literals too (tested); Spanish, French, Italian, Japanese, Russian and Ukrainian also translate status-bar and error messages as they are shown (`@msg` templates, every message literal tested; the messages themselves stay English for agents); untranslated text stays English. Japanese and Chinese glyphs come from the craft-fonts build input (release builds) or the installed fonts, Czech, Spanish, French, Italian, Russian and Ukrainian ones from the bundled UI fonts. The web app follows the browser's languages (or `?lang=`). The Basic toolbar's Type button holds Vertical Type in its flyout. Vertical Type, Vertical Area Type and Vertical Type on a Path create vertical text (columns right to left, upright CJK glyphs and marks with `vert` alternates, Latin and longer numbers on their side, two- and three-digit numbers set as tate-chu-yoko in one em of the column (kept whole when justified), upright glyphs centred on the column (tracked or justified too), point type's anchor on the first column's centre line); kinsoku applies to horizontal and vertical Japanese (Paragraph panel › Kinsoku Set, `text.setFormat {kinsoku}`: Hard, the default, keeps closing marks, iteration marks, the prolonged sound mark and small kana off the line start and opening brackets off its end; Soft lets 々, ー and small kana start a line as JLREQ's level 3 rules do; None turns it off), and justified Japanese lines spread their room between characters (not inside Latin words); Paragraph panel › Mojikumi Set (shown with Preferences › Type › Show East Asian Options, `text.setFormat {mojikumi}`): None or Line-end Punctuation Half Width (new type's default, as in Illustrator; older documents and imported text keep None) shares one half-em space between consecutive punctuation, sets a closing mark ending a line half width and an opening bracket starting a line flush with its start (a paragraph's first line at its first-line indent, JIS X 4051's principle in JLREQ 3.1.5), and puts a quarter em between Japanese and Latin letters or digits; Paragraph panel menu › Top-to-Top / Bottom-to-Bottom Leading measures leading from em box top to top (area type's first line touches the frame) or from baseline to baseline (new type in a Japanese interface starts top to top, with em box centre character alignment, #432); Paragraph panel menu › Burasagari None / Regular / Force (`text.setFormat {burasagari: "none"|"standard"|"forced"}`) hangs an East Asian comma or full stop (、。,.、。) ending an area type line outside it, Regular (new type's default) when it doesn't fit and Force always (the rest of the line then fills the measure; closing brackets and Latin punctuation don't hang); Character panel menu › Character Alignment (with the East Asian options; `text.setFormat` or `text.setRangeStyle {charAlign}`) lines characters smaller than the largest on their line up on the Roman baseline, the em box top/right, centre or bottom/left, or the ICF (ideographic character face) top/right or bottom/left, from the font's BASE table or, without one, the average ink box of some ideographs and kana; Type ▸ Type Orientation switches existing text; caret, selection, hit testing and arrow keys follow the writing direction. Vertical text exports to PDF as real text and to SVG as outlines.
- **Transparency:** opacity masks (clip/invert/disable/link), exported as SVG `<mask>` and PDF soft masks.
- **Image Trace Flat Logo mode:** a fourth mode beside Black and White, Grayscale and Color, for flat-colour logos. Each pixel is read as a blend of the logo's colours (found automatically or named), the blend is interpolated smoothly and every outline is placed where two colours are equally present, so edges are accurate to a fraction of a pixel. Outlines are then cleaned: corners the blur rounded are put back, near-straight runs and near-circular arcs become exact lines and arcs, the rest is fitted with as few Béziers as the tolerance allows, touching colours overlap by a hair so no gap opens, and an outline that strays from the raw contour is fitted plainly instead. Gradients and photos are refused with advice to use Color. *Measured* (a local experiment, not run in CI): a flat-colour logo's vector original was rendered down to a soft 199 × 100 px transparent PNG, traced, and the result compared with the original at 1600 px wide. The built-in colour presets (6 Colors, 16 Colors, High Fidelity Photo, Low Fidelity Photo) had a mean edge error of 2.5 to 3.4 px and region overlap of 0.88 to 0.91; Flat Logo had 0.78 px and 0.966 (0.48 px and 0.975 from a crisp source), in about 0.1 s. Limits: flat-colour art only; needle-thin sharp tips are shortened by about 1.7 source pixels because the blur erases them; it works at no more than 1024 px on the long side (larger images are averaged down first); only one logo family has been scored against a vector original.
+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"] }
+8
View File
@@ -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"]
+109
View File
@@ -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:?}")
}
+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 {
+5
View File
@@ -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
View File
@@ -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))
}
+118
View File
@@ -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());
}
}
+60 -1
View File
@@ -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![] });
}
}
+4 -3
View File
@@ -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) {
+4 -1
View File
@@ -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;
+18
View File
@@ -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);
}
+36
View File
@@ -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
+1
View File
@@ -20,3 +20,4 @@ serde_json = { workspace = true }
[dev-dependencies]
image = { workspace = true }
vectorcraft-text = { workspace = true, features = ["test-fonts"] }
+10 -4
View File
@@ -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
}
+12
View File
@@ -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");
}
+26
View File
@@ -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
+3 -2
View File
@@ -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$">
+13
View File
@@ -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
+4
View File
@@ -11,3 +11,7 @@
/index.html
Cache-Control: no-cache
/fonts/*
Content-Type: font/ttf
Cache-Control: public, max-age=31536000, immutable
+4
View File
@@ -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(())
+119
View File
@@ -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());
}
}