mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-14 19:26:56 -04:00
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:
23 files changed
+527
-20
No files matched your search
Generated
+1
@@ -4755,6 +4755,7 @@ dependencies = [
|
||||
"rfd",
|
||||
"vectorcraft-engine",
|
||||
"vectorcraft-format",
|
||||
"vectorcraft-text",
|
||||
"vectorcraft-ui-egui",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
|
||||
+1
-1
@@ -145,7 +145,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: craft-fonts' 12 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) are 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 back, keeping its width (a Narrow face stays Narrow); a family without that face says so (#724).
|
||||
- **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, 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.
|
||||
|
||||
@@ -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,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"]
|
||||
@@ -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:?}")
|
||||
}
|
||||
@@ -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 (`packaging/web/copy-fonts.sh` copies them
|
||||
//! there; `apps/vectorcraft-web/src/fonts.rs` fetches them). A list line the manifest lacks is a
|
||||
//! warning, or an error with `CRAFT_FONTS_REQUIRED=1`, 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). `packaging/web/copy-fonts.sh` must agree with [`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 `packaging/web/copy-fonts.sh`.
|
||||
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![] });
|
||||
}
|
||||
}
|
||||
@@ -1097,9 +1097,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) {
|
||||
|
||||
@@ -22,7 +22,7 @@ mod shape;
|
||||
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,
|
||||
@@ -521,3 +521,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 packaging/web/copy-fonts.sh (copies the files).
|
||||
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");
|
||||
}
|
||||
@@ -124,6 +124,23 @@ 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).
|
||||
- `packaging/web/copy-fonts.sh`, a Trunk `post_build` hook, copies the files there.
|
||||
- `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
|
||||
Executable
+31
@@ -0,0 +1,31 @@
|
||||
#!/bin/sh
|
||||
# Trunk post_build hook (apps/vectorcraft-web/Trunk.toml): copy 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. A no-op without CRAFT_FONTS_DIR.
|
||||
set -eu
|
||||
[ -n "${CRAFT_FONTS_DIR:-}" ] || exit 0
|
||||
: "${TRUNK_STAGING_DIR:?run me as a Trunk post_build hook (TRUNK_STAGING_DIR unset)}"
|
||||
ROOT=$(cd "$(dirname "$0")/../.." && pwd)
|
||||
LIST="$ROOT/crates/text/web-fonts.txt"
|
||||
MANIFEST="$CRAFT_FONTS_DIR/fonts/manifest.txt"
|
||||
[ -f "$MANIFEST" ] || { echo "copy-fonts: $MANIFEST not found" >&2; exit 1; }
|
||||
|
||||
sha256() {
|
||||
if command -v sha256sum >/dev/null 2>&1; then sha256sum "$1" | cut -d' ' -f1
|
||||
else shasum -a 256 "$1" | cut -d' ' -f1; fi
|
||||
}
|
||||
|
||||
grep -v '^[[:space:]]*#' "$LIST" | grep -v '^[[:space:]]*$' | while IFS= read -r line; do
|
||||
family=$(printf '%s\n' "$line" | awk -F' [|] ' '{print $2}')
|
||||
style=$(printf '%s\n' "$line" | awk -F' [|] ' '{print $3}')
|
||||
row=$(awk -F' [|] ' -v f="$family" -v s="$style" '$1 == f && $2 == s { print $3 "|" $6 "|" $7; exit }' "$MANIFEST")
|
||||
[ -n "$row" ] || { echo "copy-fonts: $family $style is not in $MANIFEST" >&2; exit 1; }
|
||||
file=${row%%|*}; rest=${row#*|}; licence=${rest%%|*}; sha=${rest#*|}
|
||||
got=$(sha256 "$CRAFT_FONTS_DIR/$file")
|
||||
[ "$got" = "$sha" ] || { echo "copy-fonts: $file: sha256 $got, manifest says $sha" >&2; exit 1; }
|
||||
dest="$TRUNK_STAGING_DIR/fonts/$(printf '%s' "$sha" | cut -c1-16)"
|
||||
mkdir -p "$dest"
|
||||
cp "$CRAFT_FONTS_DIR/$file" "$dest/"
|
||||
cp "$CRAFT_FONTS_DIR/$licence" "$dest/"
|
||||
done
|
||||
Reference in new issue
Block a user