From 1b96e04b2c8c8e75c68d26ebd15749f70f2d05fa Mon Sep 17 00:00:00 2001 From: Dmitrii Khlapov Date: Mon, 12 Oct 2026 06:38:20 +0200 Subject: [PATCH] =?UTF-8?q?Add=20fonts=20at=20run=20time:=20Add=20Font?= =?UTF-8?q?=E2=80=A6,=20drop=20a=20font=20file,=20font.add=20(#582)=20(#66?= =?UTF-8?q?8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add fonts at run time: Add Font…, drop a font file, font.add (#582) Rebased onto main as one commit (the branch had merge commits from main). Co-Authored-By: Claude Opus 5.5 * #582: font test no longer depends on the system's fallback font The FreeBSD runner has no system fonts, so a missing family fell back to Source Serif 4, the font the test compared against. The test now adds a renamed JetBrains Mono under a name without Serif/Sans/Mono (which picks a fallback of that kind), with narrow-letter sample text: monospaced, it measures unlike any proportional fallback. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Dmitrii Khlapov <19991457+dkhlapov@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas --- Cargo.lock | 14 + Cargo.toml | 3 + ROADMAP.md | 3 +- apps/wordcraft-web/Cargo.toml | 3 +- apps/wordcraft-web/src/web.rs | 7 + apps/wordcraft-web/src/web/font_store.rs | 116 ++++ apps/wordcraft/Cargo.toml | 2 + apps/wordcraft/src/file_dialogs.rs | 2 + apps/wordcraft/src/main.rs | 14 + crates/engine/src/cmd/font.rs | 107 ++++ crates/engine/src/cmd/mod.rs | 2 + crates/engine/src/lib.rs | 2 + crates/engine/src/tests_fonts.rs | 107 ++++ crates/fonts/Cargo.toml | 1 + crates/fonts/src/fontdb.rs | 11 +- crates/fonts/src/fontdb/user.rs | 699 +++++++++++++++++++++++ crates/fonts/src/lib.rs | 5 +- crates/fonts/src/word.rs | 28 +- crates/ui-egui/src/file_dialogs.rs | 17 +- crates/ui-egui/src/i18n/es.tsv | 3 + crates/ui-egui/src/i18n/et.tsv | 3 + crates/ui-egui/src/i18n/ja.tsv | 3 + crates/ui-egui/src/i18n/pt-br.tsv | 3 + crates/ui-egui/src/i18n/ru.tsv | 3 + crates/ui-egui/src/i18n/sr-latn.tsv | 3 + crates/ui-egui/src/i18n/sr.tsv | 3 + crates/ui-egui/src/i18n/tr.tsv | 3 + crates/ui-egui/src/i18n/uk.tsv | 3 + crates/ui-egui/src/i18n/zh-hans.tsv | 3 + crates/ui-egui/src/i18n/zh-hant.tsv | 3 + crates/ui-egui/src/lib.rs | 100 ++++ crates/ui-egui/src/mini_toolbar.rs | 8 +- crates/ui-egui/src/previews.rs | 11 + crates/ui-egui/src/ribbon.rs | 8 +- crates/ui-egui/src/widgets.rs | 12 + docs/ui-parity.md | 3 +- 36 files changed, 1300 insertions(+), 18 deletions(-) create mode 100644 apps/wordcraft-web/src/web/font_store.rs create mode 100644 crates/engine/src/cmd/font.rs create mode 100644 crates/engine/src/tests_fonts.rs create mode 100644 crates/fonts/src/fontdb/user.rs diff --git a/Cargo.lock b/Cargo.lock index 8bab53c..853b8a7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5014,6 +5014,7 @@ dependencies = [ "wordcraft-control-key", "wordcraft-doc", "wordcraft-engine", + "wordcraft-fonts", "wordcraft-ui-egui", ] @@ -5116,6 +5117,7 @@ dependencies = [ "skrifa 0.47.0", "unicode-bidi", "unicode-script", + "wuff", ] [[package]] @@ -5258,6 +5260,7 @@ dependencies = [ "web-sys", "wordcraft-doc", "wordcraft-engine", + "wordcraft-fonts", "wordcraft-ui-egui", ] @@ -5294,6 +5297,17 @@ version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" +[[package]] +name = "wuff" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "200a7b806377b65f1ff9a985874332f15357c87e5dd7855f35f06dade95b8b3d" +dependencies = [ + "brotli-decompressor", + "bytes", + "flate2", +] + [[package]] name = "x11-dl" version = "2.21.0" diff --git a/Cargo.toml b/Cargo.toml index c0b3b4b..438cbc9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -47,6 +47,9 @@ regex-automata = { version = "0.4", default-features = false, features = ["std", # text / rendering / formats skrifa = "0.47" harfrust = "0.13" +# WOFF / WOFF2 web fonts added by the user (#582): pure Rust (MIT); its brotli and zlib decoders +# (brotli-decompressor, flate2) are already in the tree. +wuff = "0.2.9" vello_cpu = "0.2" image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp", "gif", "bmp", "tiff"] } krilla = { version = "0.8", features = ["pdf"] } diff --git a/ROADMAP.md b/ROADMAP.md index 3dc8c82..1b1dd3d 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~15 points (60% → 75% ready for real work) and ~150–230 h away -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** trivial (security review fixes landed; previously trivial: Turkish interface landed; previously trivial: formatting revisions landed, #41; previously trivial: Draw tab ink, #307, and Draw Table and Eraser, #303, landed: catalog 402/434; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** trivial (adding fonts at run time, #582; previously trivial: Insert › SmartArt landed, #497: catalog 429/451 → 437/458 (95.4%); previously trivial: Turkish interface landed; previously trivial: formatting revisions landed, #41; previously trivial: Draw tab ink, #307, and Draw Table and Eraser, #303, landed: catalog 402/434; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 WordCraft aims for complete parity with Microsoft Word, then goes further on speed, openness and agent control. This page is the summary; the assessment is @@ -149,6 +149,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | 2026-10-11 | Right-click Wrap Text submenu on pictures, shapes and text boxes: wrapping styles, Move with Text / Fix Position on Page (#513) | | 2026-10-11 | Paragraph dialog options (#512): mirror indents (`w:mirrorIndents`, laid out on even pages), same-style spacing, suppress line numbers, don't hyphenate | | 2026-10-11 | Screen readers can read the document canvas (#488): paragraphs, headings, lists, tables, links, pictures and the caret exposed through AccessKit | +| 2026-10-11 | Add fonts without rebuilding (#582): Home › Font › Add Font… (end of the font list), dropping a TTF/OTF/TTC/WOFF/WOFF2 file, or `font.add`/`font.list`/`font.remove` for agents. Files are checked (50 MB cap, every face must parse and map characters), never replace a bundled or loaded face, and are kept between runs (desktop: `fonts` in the settings folder; web: IndexedDB). Linking a font by URL is still to come | | 2026-10-11 | Print Markup (#589): File › Print/Export checkbox, `file.printMarkup`, `markup` on save/export commands and `wordcraft-cli convert --markup` print comments (with replies) in balloons beside each page, scaled to the paper like Word's "Document showing markup"; HTML, Markdown and text copies end with the comments | | 2026-10-11 | Custom colours (#606): every colour drop-down gains Recent Colors and More Colors…, which opens a Colors dialog with a Standard honeycomb and a Custom hue/saturation field, lightness slider, RGB/HSL numbers and a hex box | | 2026-10-11 | Complex scripts (#604): Myanmar, Khmer and Indic text shaped one script at a time in OpenType fallback fonts (regular weight, not AAT-only fonts), the caret and line breaks never split a syllable, Myanmar wraps between syllables, Backspace removes one sign at a time | diff --git a/apps/wordcraft-web/Cargo.toml b/apps/wordcraft-web/Cargo.toml index befd8b7..a3e53cc 100644 --- a/apps/wordcraft-web/Cargo.toml +++ b/apps/wordcraft-web/Cargo.toml @@ -20,6 +20,7 @@ egui = { workspace = true } log = { workspace = true } serde_json = { workspace = true } wordcraft-doc = { workspace = true } +wordcraft-fonts = { workspace = true } [target.'cfg(target_arch = "wasm32")'.dependencies] # `wgpu` pulls egui-wgpu's defaults: WebGPU plus the WebGL2 fallback. @@ -28,4 +29,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", "HtmlAnchorElement", "HtmlIFrameElement", "Blob", "BlobPropertyBag", "Url", "Location", "CssStyleDeclaration", "Event", "EventTarget", "BeforeUnloadEvent", "ClipboardEvent", "DataTransfer", "FileList", "File"] } +web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlAnchorElement", "HtmlIFrameElement", "Blob", "BlobPropertyBag", "Url", "Location", "CssStyleDeclaration", "Event", "EventTarget", "BeforeUnloadEvent", "ClipboardEvent", "DataTransfer", "FileList", "File", "DomStringList", "IdbFactory", "IdbOpenDbRequest", "IdbRequest", "IdbDatabase", "IdbObjectStore", "IdbTransaction", "IdbTransactionMode"] } diff --git a/apps/wordcraft-web/src/web.rs b/apps/wordcraft-web/src/web.rs index a49a04d..87076be 100644 --- a/apps/wordcraft-web/src/web.rs +++ b/apps/wordcraft-web/src/web.rs @@ -11,6 +11,7 @@ use wordcraft_ui_egui::{Inbox, Services, WordApp}; const DOC_EXTS: &[&str] = &["docx", "docm", "dotx", "dotm", "doc", "dot", "odt", "rtf", "txt", "md", "html", "htm", "tex", "json", "xml"]; const IMAGE_EXTS: &[&str] = &["png", "jpg", "jpeg", "gif", "webp", "bmp"]; const RECIPIENT_EXTS: &[&str] = &["csv", "tsv", "txt", "xlsx", "xlsm", "ods"]; +mod font_store; const CANVAS_ID: &str = "wordcraft_canvas"; const LOADING_ID: &str = "wordcraft_loading"; @@ -43,6 +44,8 @@ pub fn start() { if let Some(rs) = &cc.wgpu_render_state { log::info!("wordcraft-web: wgpu backend {:?}", rs.adapter.get_info().backend); } + // Fonts the user added on an earlier visit (#582); the app notices them arrive. + font_store::load_all(cc.egui_ctx.clone()); let inbox: Inbox = Inbox::default(); if let Err(e) = listen_for_pasted_pictures(inbox.clone(), cc.egui_ctx.clone()) { log::error!("pasting pictures is unavailable: {e}"); @@ -158,6 +161,8 @@ fn services(inbox: Inbox, ctx: egui::Context, dirty: Rc>) -> Services let ctx = ctx.clone(); let dialog = if purpose == "picture" { rfd::AsyncFileDialog::new().add_filter("Pictures", IMAGE_EXTS) + } else if purpose == "font" { + rfd::AsyncFileDialog::new().add_filter("Fonts", wordcraft_fonts::FONT_FILE_EXTS) } else if purpose == "recipients" { rfd::AsyncFileDialog::new().add_filter("Recipient lists", RECIPIENT_EXTS) } else { @@ -178,6 +183,8 @@ fn services(inbox: Inbox, ctx: egui::Context, dirty: Rc>) -> Services print: Some(Box::new(print_pdf)), inbox: Some(inbox), on_dirty: Some(Box::new(move |d| dirty.set(d))), + store_font: Some(Box::new(font_store::store)), + forget_font: Some(Box::new(font_store::forget)), ..Default::default() } } diff --git a/apps/wordcraft-web/src/web/font_store.rs b/apps/wordcraft-web/src/web/font_store.rs new file mode 100644 index 0000000..3a77264 --- /dev/null +++ b/apps/wordcraft-web/src/web/font_store.rs @@ -0,0 +1,116 @@ +//! Fonts the user added in the browser (#582), kept in IndexedDB (`localStorage` holds about +//! 5 MB, less than one CJK font) so they are there again after a reload. Every failure is logged +//! and skipped: a font that can't be stored still works for the session. + +use wasm_bindgen::JsCast as _; +use wasm_bindgen::JsValue; +use wasm_bindgen::closure::Closure; +use web_sys::{IdbDatabase, IdbRequest, IdbTransactionMode}; + +const DB_NAME: &str = "wordcraft"; +const STORE: &str = "fonts"; + +fn js(e: JsValue) -> String { + format!("{e:?}") +} + +/// Wait for an IndexedDB request; its result. +async fn done(req: &IdbRequest) -> Result { + let promise = js_sys::Promise::new(&mut |resolve: js_sys::Function, reject: js_sys::Function| { + let ok_req = req.clone(); + let ok = Closure::once_into_js(move || { + let _ = resolve.call1(&JsValue::NULL, &ok_req.result().unwrap_or(JsValue::UNDEFINED)); + }); + let err = Closure::once_into_js(move || { + let _ = reject.call1(&JsValue::NULL, &JsValue::from_str("IndexedDB request failed")); + }); + req.set_onsuccess(Some(ok.unchecked_ref())); + req.set_onerror(Some(err.unchecked_ref())); + }); + wasm_bindgen_futures::JsFuture::from(promise).await.map_err(js) +} + +/// The database, with its font store made on first use. +async fn open() -> Result { + let factory = web_sys::window().ok_or("no window")?.indexed_db().map_err(js)?.ok_or("IndexedDB is unavailable")?; + let req = factory.open_with_u32(DB_NAME, 1).map_err(js)?; + let upgrade_req = req.clone(); + let upgrade = Closure::::new(move || { + if let Ok(db) = upgrade_req.result().and_then(|r| r.dyn_into::().map_err(JsValue::from)) + && !db.object_store_names().contains(STORE) + && let Err(e) = db.create_object_store(STORE) + { + log::error!("couldn't make the font store: {e:?}"); + } + }); + req.set_onupgradeneeded(Some(upgrade.as_ref().unchecked_ref())); + let db = done(&req).await; + req.set_onupgradeneeded(None); + drop(upgrade); + db?.dyn_into::().map_err(js) +} + +async fn store_async(name: String, bytes: Vec) -> Result<(), String> { + let db = open().await?; + let store = db.transaction_with_str_and_mode(STORE, IdbTransactionMode::Readwrite).map_err(js)?.object_store(STORE).map_err(js)?; + let req = store.put_with_key(&js_sys::Uint8Array::from(bytes.as_slice()), &JsValue::from_str(&name)).map_err(js)?; + done(&req).await.map(|_| ()) +} + +async fn forget_async(name: String) -> Result<(), String> { + let db = open().await?; + let store = db.transaction_with_str_and_mode(STORE, IdbTransactionMode::Readwrite).map_err(js)?.object_store(STORE).map_err(js)?; + let req = store.delete(&JsValue::from_str(&name)).map_err(js)?; + done(&req).await.map(|_| ()) +} + +/// Keep `bytes` (a font file as the user gave it) under `name` (its stored file name). +pub fn store(name: &str, bytes: &[u8]) { + let (name, bytes) = (name.to_string(), bytes.to_vec()); + wasm_bindgen_futures::spawn_local(async move { + if let Err(e) = store_async(name.clone(), bytes).await { + log::error!("couldn't keep the font {name} for the next visit: {e}"); + } + }); +} + +/// Forget the font stored under `name`. +pub fn forget(name: &str) { + let name = name.to_string(); + wasm_bindgen_futures::spawn_local(async move { + if let Err(e) = forget_async(name.clone()).await { + log::error!("couldn't forget the stored font {name}: {e}"); + } + }); +} + +/// Add every stored font to the font database (each checked like a new one; one that fails is +/// logged and skipped), then repaint: the app lays out again when fonts change. +pub fn load_all(ctx: egui::Context) { + wasm_bindgen_futures::spawn_local(async move { + let r: Result<(), String> = async { + let db = open().await?; + let store = db.transaction_with_str(STORE).map_err(js)?.object_store(STORE).map_err(js)?; + let keys = done(&store.get_all_keys().map_err(js)?).await?; + let values = done(&store.get_all().map_err(js)?).await?; + let (keys, values) = (js_sys::Array::from(&keys), js_sys::Array::from(&values)); + for i in 0..keys.length().min(values.length()) { + let Some(name) = keys.get(i).as_string() else { continue }; + let Ok(bytes) = values.get(i).dyn_into::() else { continue }; + if bytes.length() as usize > wordcraft_fonts::MAX_FONT_FILE_BYTES { + log::warn!("stored font {name} is too large; skipped"); + continue; + } + if let Err(e) = wordcraft_fonts::FontDb::global().install_font(&name, &bytes.to_vec()) { + log::warn!("stored font {name} skipped: {e}"); + } + } + Ok(()) + } + .await; + if let Err(e) = r { + log::warn!("no stored fonts: {e}"); + } + ctx.request_repaint(); + }); +} diff --git a/apps/wordcraft/Cargo.toml b/apps/wordcraft/Cargo.toml index 141b569..bd587a9 100644 --- a/apps/wordcraft/Cargo.toml +++ b/apps/wordcraft/Cargo.toml @@ -13,6 +13,8 @@ workspace = true [dependencies] wordcraft-doc = { workspace = true } wordcraft-engine = { workspace = true } +# Fonts the user added (#582) load from the settings folder at start-up. +wordcraft-fonts = { workspace = true } wordcraft-ui-egui = { workspace = true } # The control key file's location (shared with the MCP bridge). wordcraft-control-key = { workspace = true } diff --git a/apps/wordcraft/src/file_dialogs.rs b/apps/wordcraft/src/file_dialogs.rs index a194c96..c0c2c84 100644 --- a/apps/wordcraft/src/file_dialogs.rs +++ b/apps/wordcraft/src/file_dialogs.rs @@ -72,6 +72,8 @@ impl Launcher { fn open_filters(d: rfd::AsyncFileDialog, purpose: &str) -> rfd::AsyncFileDialog { if purpose == "picture" { d.add_filter("Pictures", &["png", "jpg", "jpeg", "gif", "webp", "bmp"]) + } else if purpose == "font" { + d.add_filter("Fonts", wordcraft_fonts::FONT_FILE_EXTS) } else if purpose == "recipients" { d.add_filter("Recipient lists", &["csv", "tsv", "txt", "xlsx", "xlsm", "ods"]).add_filter("All files", &["*"]) } else { diff --git a/apps/wordcraft/src/main.rs b/apps/wordcraft/src/main.rs index 8b39ae9..3222d20 100644 --- a/apps/wordcraft/src/main.rs +++ b/apps/wordcraft/src/main.rs @@ -97,6 +97,19 @@ fn load_prefs(app: &mut WordApp) { } } +/// Fonts the user added (#582) live in `fonts` in the settings folder: load them, and store fonts +/// added later there. Runs without preferences neither read nor keep them. +fn load_user_fonts() { + if !prefs_enabled() { + return; + } + let Some(dir) = wordcraft_control_key::settings_dir().map(|d| d.join("fonts")) else { return }; + let (loaded, skipped) = wordcraft_fonts::FontDb::global().load_user_fonts(&dir); + if !loaded.is_empty() || !skipped.is_empty() { + log::info!("user fonts: {} loaded, {} skipped from {}", loaded.len(), skipped.len(), dir.display()); + } +} + /// The saved window geometry, read before the window opens (`load_prefs` runs after). fn saved_window() -> Option { if !prefs_enabled() { @@ -211,6 +224,7 @@ fn main() -> eframe::Result { // DLL of ours (#603; see graphics.rs). #[cfg(target_os = "windows")] graphics::configure(&mut options, eframe::wgpu::Backends::from_env()); + load_user_fonts(); let restored = saved_window(); if let Some(window) = restored { options.viewport = window.apply(options.viewport); diff --git a/crates/engine/src/cmd/font.rs b/crates/engine/src/cmd/font.rs new file mode 100644 index 0000000..703a868 --- /dev/null +++ b/crates/engine/src/cmd/font.rs @@ -0,0 +1,107 @@ +//! Fonts the user adds without rebuilding (#582): `font.add`, `font.list`, `font.remove`. +//! +//! An added font joins the process-wide font database on top of the bundled and system fonts +//! (never replacing a face already there), so it shows in the font lists, lays out, renders and +//! embeds in PDFs like any other. Where it is kept between runs is [`FontDb::load_user_fonts`]'s +//! folder (desktop); without one it lasts for the session (the web shell stores it itself). + +use serde_json::{Value, json}; +use wordcraft_fonts::{FontDb, UserFont}; + +use crate::cmd::insert::base64_decode; +use crate::{CmdError, CmdResult, CommandSpec, Session, p}; + +pub fn specs() -> Vec { + vec![ + CommandSpec::new("font.add", "Add Font…", "Home › Font", add) + .params(r#"{"path"?: string, "data"?: base64, "name"?: string (file name for "data")} — a TTF, OTF, TTC, WOFF or WOFF2 file; none: pick a file"#) + .pure(), + CommandSpec::new("font.list", "Added Fonts", "Home › Font", list).params(r#"{"all"?: bool (also every available family)}"#).pure(), + CommandSpec::new("font.remove", "Remove Added Font", "Home › Font", remove).params(r#"{"family": string}"#).pure(), + ] +} + +fn font_json(f: &UserFont) -> Value { + json!({ + "file": f.file, + "families": f.families(), + "faces": f.faces.iter().map(|(family, style)| json!({"family": family, "style": style})).collect::>(), + "path": f.path.as_ref().map(|p| p.to_string_lossy().to_string()), + }) +} + +/// Read a font file named by the user, refusing one larger than the cap before reading it. +#[cfg(not(target_arch = "wasm32"))] +fn read_font_file(path: &str) -> Result, CmdError> { + let meta = std::fs::metadata(path).map_err(|e| CmdError::Failed(format!("{path}: {e}")))?; + if !meta.is_file() { + return Err(CmdError::Failed(format!("{path}: not a file"))); + } + if meta.len() > wordcraft_fonts::MAX_FONT_FILE_BYTES as u64 { + return Err(CmdError::Failed(format!("{path}: the font file is larger than {} MB", wordcraft_fonts::MAX_FONT_FILE_BYTES >> 20))); + } + std::fs::read(path).map_err(|e| CmdError::Failed(format!("{path}: {e}"))) +} + +fn add(s: &mut Session, v: &Value) -> CmdResult { + if p::str(v, "url").is_some() { + return Err(CmdError::Failed( + "adding a font from a link isn't available yet: download the font file and add it with {\"path\"} or {\"data\"}".into(), + )); + } + let (name, bytes) = if let Some(d) = p::str(v, "data") { + // Base64 is a third larger than the file: refuse before decoding an oversized one. + if d.len() / 4 * 3 > wordcraft_fonts::MAX_FONT_FILE_BYTES + 3 { + return Err(CmdError::Failed(format!("the font file is larger than {} MB", wordcraft_fonts::MAX_FONT_FILE_BYTES >> 20))); + } + let bytes = base64_decode(d).ok_or_else(|| CmdError::Params("bad base64 data".into()))?; + (p::str(v, "name").unwrap_or("font").to_string(), bytes) + } else if let Some(path) = p::str(v, "path") { + #[cfg(not(target_arch = "wasm32"))] + { + (path.to_string(), read_font_file(path)?) + } + #[cfg(target_arch = "wasm32")] + { + let _ = path; + return Err(CmdError::Failed("reading files by path isn't available on the web".into())); + } + } else { + // From the ribbon: the UI shows a file picker and runs this again with the file. + s.ui_requests.push(json!({"open": "addFont"})); + return Ok(json!({"pending": true})); + }; + let r = FontDb::global().install_font(&name, &bytes).map_err(|e| CmdError::Failed(e.to_string()))?; + // Text already set in the family (shown in a fallback until now) takes the new font. + s.relayout(); + let families = r.font.families().join(", "); + s.status = match &r.warning { + Some(w) => format!("Added {families} ({w})"), + None => format!("Added {families}"), + }; + let mut out = font_json(&r.font); + if let Some(w) = r.warning { + out["warning"] = json!(w); + } + Ok(out) +} + +fn list(_: &mut Session, v: &Value) -> CmdResult { + let db = FontDb::global(); + let mut out = json!({ + "fonts": db.user_fonts().iter().map(font_json).collect::>(), + "folder": db.user_font_dir().map(|p| p.to_string_lossy().to_string()), + }); + if p::bool(v, "all") == Some(true) { + out["families"] = json!(db.families()); + } + Ok(out) +} + +fn remove(s: &mut Session, v: &Value) -> CmdResult { + let family = p::str(v, "family").filter(|f| !f.trim().is_empty()).ok_or_else(|| CmdError::Params("family is required".into()))?; + let gone = FontDb::global().remove_user_font(family.trim()).map_err(|e| CmdError::Failed(e.to_string()))?; + s.relayout(); + s.status = format!("Removed {}", family.trim()); + Ok(json!({"removed": gone.iter().map(font_json).collect::>()})) +} diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 62042a2..5e8161a 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -16,6 +16,7 @@ pub mod fields; pub mod file; pub mod find_pattern; pub mod fmt_revisions; +pub mod font; pub mod format; pub mod index; pub mod ink_shape; @@ -60,6 +61,7 @@ pub fn registry() -> Registry { v.extend(edit::specs()); v.extend(paste::specs()); v.extend(format::specs()); + v.extend(font::specs()); v.extend(phonetic::specs()); v.extend(para::specs()); v.extend(lists::specs()); diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 1c4446b..2ec7893 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -165,6 +165,8 @@ mod tests; #[cfg(test)] mod tests_bidi; #[cfg(test)] +mod tests_fonts; +#[cfg(test)] mod tests_typing; #[cfg(test)] mod tests_wordart; diff --git a/crates/engine/src/tests_fonts.rs b/crates/engine/src/tests_fonts.rs new file mode 100644 index 0000000..fdc54c0 --- /dev/null +++ b/crates/engine/src/tests_fonts.rs @@ -0,0 +1,107 @@ +//! Fonts added at run time (#582): `font.add`, `font.list`, `font.remove`. + +use serde_json::json; +use wordcraft_doc::Pos; + +use crate::Session; +use crate::cmd::insert::base64_encode; + +/// The bundled face `file` of family `original` renamed `family` (as long as `original`), so it +/// is a font the database doesn't have. +fn renamed(file: &str, original: &str, family: &str) -> Vec { + assert_eq!(family.len(), original.len()); + let mut data = std::fs::read(std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../assets/fonts").join(file)).unwrap(); + let utf16 = |s: &str| s.encode_utf16().flat_map(u16::to_be_bytes).collect::>(); + for (from, to) in [(utf16(original), utf16(family)), (original.as_bytes().to_vec(), family.as_bytes().to_vec())] { + let mut i = 0; + while let Some(at) = data[i..].windows(from.len()).position(|w| w == from) { + data[i + at..i + at + to.len()].copy_from_slice(&to); + i += at + to.len(); + } + } + data +} + +/// The caret's x after `text` set in `font`. +fn text_end_x(s: &mut Session, font: &str, text: &str) -> f32 { + s.run("document.setText", &json!({"text": text})).unwrap(); + s.run("select.all", &json!({})).unwrap(); + s.run("format.font", &json!({"name": font})).unwrap(); + s.layout().caret_on(&Pos::body(0, text.len()), 0).unwrap().x +} + +#[test] +fn an_added_font_is_listed_and_lays_out_text_already_set_in_it() { + // A monospaced face, so it measures unlike whatever proportional font the system falls back + // to (Source Serif 4 itself where no system fonts are installed, as on the FreeBSD runner). + // The name has no Serif/Sans/Mono in it, which would pick a fallback of that kind. + const FAMILY: &str = "Cmdfont Face 4"; + // Narrow letters: set monospaced they take far more room than in any proportional font. + const TEXT: &str = "illicit little lilies fill tiny jill"; + let mut s = Session::new(wordcraft_doc::Document::new()); + let mono = text_end_x(&mut s, "JetBrains Mono", TEXT); + // Not installed yet: the text falls back to another font. + let missing = text_end_x(&mut s, FAMILY, TEXT); + assert!((missing - mono).abs() > 1.0, "the fallback measures differently: {missing} vs {mono}"); + + let data = base64_encode(&renamed("JetBrainsMono-Regular.ttf", "JetBrains Mono", FAMILY)); + let r = s.run("font.add", &json!({"data": data, "name": "CmdfontFace4-Regular.ttf"})).unwrap(); + assert_eq!(r["families"], json!([FAMILY])); + assert_eq!(r["faces"][0]["style"], "Regular"); + // The text already set in it re-lays out with it, without touching the document. + let x = s.layout().caret_on(&Pos::body(0, TEXT.len()), 0).unwrap().x; + assert!((x - mono).abs() < 0.01, "measured with the added font: {x} vs {mono} (missing {missing})"); + + let list = s.run("font.list", &json!({"all": true})).unwrap(); + assert!(list["fonts"].as_array().unwrap().iter().any(|f| f["families"] == json!([FAMILY]))); + assert!(list["families"].as_array().unwrap().iter().any(|f| f == FAMILY)); + // It exports to PDF like any other font. + assert!(crate::io::save_bytes("out.pdf", &s.doc).unwrap().starts_with(b"%PDF")); + + // Adding it again changes nothing. + let again = s.run("font.add", &json!({"data": data, "name": "again.ttf"})); + assert!(again.unwrap_err().to_string().contains("already available")); + + let removed = s.run("font.remove", &json!({"family": FAMILY})).unwrap(); + assert_eq!(removed["removed"].as_array().unwrap().len(), 1); + let x = s.layout().caret_on(&Pos::body(0, TEXT.len()), 0).unwrap().x; + assert!((x - missing).abs() < 0.01, "falls back again once removed"); + assert!(s.run("font.remove", &json!({"family": FAMILY})).is_err()); +} + +#[test] +fn adding_a_font_never_changes_text_that_does_not_use_it() { + const FAMILY: &str = "Metric Serif 4"; + let mut s = Session::new(crate::sample::sample_document()); + let before = s.layout(); + let positions = |s: &mut Session| { + let l = s.layout(); + (0..s.doc.body.len().min(12)).filter_map(|b| l.caret_on(&Pos::body(b, 0), 0).map(|c| (c.page, c.x, c.top))).collect::>() + }; + let a = positions(&mut s); + s.run("font.add", &json!({"data": base64_encode(&renamed("SourceSerif4-Bold.ttf", "Source Serif 4", FAMILY)), "name": "m.ttf"})).unwrap(); + assert_eq!(positions(&mut s), a); + assert_eq!(s.layout().pages.len(), before.pages.len()); +} + +#[test] +fn bad_font_input_is_an_error() { + let mut s = Session::new(wordcraft_doc::Document::new()); + for p in [ + json!({"data": base64_encode(b"definitely not a font")}), + json!({"data": "%%% not base64"}), + json!({"data": base64_encode(b"wOF2\0\x01\0\0\xff\xff\xff\xff")}), + json!({"path": "/nonexistent/font.ttf"}), + json!({"path": env!("CARGO_MANIFEST_DIR")}), + json!({"path": concat!(env!("CARGO_MANIFEST_DIR"), "/Cargo.toml")}), + json!({"url": "https://fonts.googleapis.com/css2?family=Roboto"}), + ] { + assert!(s.run("font.add", &p).is_err(), "{p}"); + } + assert!(s.run("font.remove", &json!({})).is_err()); + assert!(s.run("font.remove", &json!({"family": "Source Sans 3"})).is_err(), "the bundled fonts stay"); + // No file: the UI is asked for a picker. + let r = s.run("font.add", &json!({})).unwrap(); + assert_eq!(r["pending"], true); + assert!(s.ui_requests.iter().any(|r| r["open"] == "addFont")); +} diff --git a/crates/fonts/Cargo.toml b/crates/fonts/Cargo.toml index b7aafa3..9107da4 100644 --- a/crates/fonts/Cargo.toml +++ b/crates/fonts/Cargo.toml @@ -12,6 +12,7 @@ workspace = true kurbo = { workspace = true } skrifa = { workspace = true } harfrust = { workspace = true } +wuff = { workspace = true } unicode-bidi = { workspace = true } unicode-script = { workspace = true } log = { workspace = true } diff --git a/crates/fonts/src/fontdb.rs b/crates/fonts/src/fontdb.rs index dea01f8..5b21cb4 100644 --- a/crates/fonts/src/fontdb.rs +++ b/crates/fonts/src/fontdb.rs @@ -2,7 +2,7 @@ //! time a lookup needs them, native only), outline cache. use std::collections::HashMap; -use std::sync::atomic::{AtomicU32, Ordering}; +use std::sync::atomic::{AtomicU32, AtomicU64, Ordering}; use std::sync::{Arc, Mutex, RwLock}; use kurbo::BezPath; @@ -12,6 +12,9 @@ use skrifa::raw::FileRef; use skrifa::string::StringId; use skrifa::{GlyphId, MetadataProvider}; +mod user; +pub use user::{FONT_FILE_EXTS, FontFormat, Installed, MAX_FONT_FILE_BYTES, UserFont, UserFontError, decode_font_file, is_font_file_name, sniff}; + /// The family used when a requested family is unknown (the UI sans). pub const FALLBACK_FAMILY: &str = "Source Sans 3"; @@ -293,6 +296,10 @@ pub struct FontDb { fallbacks: RwLock, #[cfg(not(target_arch = "wasm32"))] catalog: RwLock>, + /// Fonts the user added at run time, and the folder they are stored in (#582). + user: RwLock, + /// Bumped whenever user fonts are added or removed ([`FontDb::generation`]). + generation: AtomicU64, /// The folders the system font scan reads (the platform's font folders for [`FontDb::global`]). #[cfg_attr(target_arch = "wasm32", allow(dead_code))] font_dirs: Vec, @@ -839,6 +846,8 @@ impl FontDb { fallbacks: RwLock::new(FallbackCache::default()), #[cfg(not(target_arch = "wasm32"))] catalog: RwLock::new(Vec::new()), + user: RwLock::new(user::UserFonts::default()), + generation: AtomicU64::new(0), font_dirs, #[cfg(not(target_arch = "wasm32"))] cataloged: std::sync::OnceLock::new(), diff --git a/crates/fonts/src/fontdb/user.rs b/crates/fonts/src/fontdb/user.rs new file mode 100644 index 0000000..ff9cee6 --- /dev/null +++ b/crates/fonts/src/fontdb/user.rs @@ -0,0 +1,699 @@ +//! Fonts an end user adds at run time, without rebuilding (#582). +//! +//! The bundled faces (and the system fonts) stay the baseline: a user font only adds faces, never +//! replaces one (a face whose family and style are already loaded is skipped), so documents that +//! don't name it keep their metrics. A file is checked before anything is added: its size is +//! capped, WOFF and WOFF2 are unwrapped, and every face must parse and map characters. +//! +//! Desktop: [`FontDb::load_user_fonts`] names a folder (the app's `fonts` folder); the fonts there +//! load at start-up and each font added later is copied there (unwrapped from WOFF/WOFF2). Without +//! a folder (the web, tests, `WORDCRAFT_NO_PREFS`) added fonts last for the session; the web +//! shell keeps them in the browser itself. + +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use std::sync::atomic::Ordering; + +use skrifa::MetadataProvider; +use skrifa::raw::FileRef; + +use super::{FontBytes, FontDb, enumerate_faces, make_face}; + +/// Largest font file accepted (as given: TTF, OTF, TTC, WOFF or WOFF2). +pub const MAX_FONT_FILE_BYTES: usize = 50 << 20; +/// Largest font a WOFF/WOFF2 file may unwrap to. +const MAX_DECODED_BYTES: usize = 128 << 20; +/// Most faces one file may add (collections, variable fonts' named instances). +const MAX_FACES_PER_FILE: usize = 256; +/// Most user fonts at once. +const MAX_USER_FONTS: usize = 1000; +/// File extensions [`FontDb::load_user_fonts`] reads (fonts are stored unwrapped). +const STORED_EXTS: &[&str] = &["ttf", "otf", "ttc", "otc"]; +/// Extensions a font file the user picks or drops may have. +pub const FONT_FILE_EXTS: &[&str] = &["ttf", "otf", "ttc", "otc", "woff", "woff2"]; + +/// Is `name` (a file name or path) named like a font file? +pub fn is_font_file_name(name: &str) -> bool { + let ext = name.rsplit_once('.').map(|(_, e)| e.to_ascii_lowercase()); + ext.is_some_and(|e| FONT_FILE_EXTS.contains(&e.as_str())) +} + +/// Why a font couldn't be added or removed. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum UserFontError { + Empty, + TooLarge { + bytes: usize, + }, + /// Not a font file this reads (TrueType, OpenType, collections, WOFF, WOFF2). + NotAFont, + /// A WOFF/WOFF2 file that couldn't be unwrapped. + BadWebFont(String), + /// A font file that doesn't parse, or has no usable face. + Unusable(String), + /// Every face in the file is already available. + AlreadyAvailable(Vec), + TooMany, + /// The family isn't one the user added (bundled and system fonts can't be removed). + NotUserFont(String), +} + +impl std::fmt::Display for UserFontError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + UserFontError::Empty => write!(f, "the font file is empty"), + UserFontError::TooLarge { bytes } => { + write!(f, "the font file is too large ({} MB; at most {} MB)", bytes.div_ceil(1 << 20), MAX_FONT_FILE_BYTES >> 20) + } + UserFontError::NotAFont => write!(f, "not a font file (TrueType, OpenType, TTC, WOFF or WOFF2)"), + UserFontError::BadWebFont(e) => write!(f, "the web font couldn't be read: {e}"), + UserFontError::Unusable(e) => write!(f, "the font couldn't be read: {e}"), + UserFontError::AlreadyAvailable(families) => write!(f, "already available: {}", families.join(", ")), + UserFontError::TooMany => write!(f, "too many added fonts (at most {MAX_USER_FONTS}); remove some first"), + UserFontError::NotUserFont(family) => write!(f, "{family:?} isn't a font you added"), + } + } +} + +impl std::error::Error for UserFontError {} + +/// A font file the user added. +#[derive(Clone, Debug, PartialEq)] +pub struct UserFont { + /// Its file name in the user font folder (unique among the user fonts), also the key the web + /// shell stores it under. + pub file: String, + /// The faces it added: (typographic family, style). + pub faces: Vec<(String, String)>, + /// Where it is stored (desktop); `None` when it lasts for the session only. + pub path: Option, + face_ids: Vec, +} + +impl UserFont { + /// The families it added (typographic names, in file order, each once). + pub fn families(&self) -> Vec { + let mut v: Vec = Vec::new(); + for (family, _) in &self.faces { + if !v.iter().any(|f| f.eq_ignore_ascii_case(family)) { + v.push(family.clone()); + } + } + v + } +} + +/// What [`FontDb::install_font`] did. +#[derive(Clone, Debug, PartialEq)] +pub struct Installed { + pub font: UserFont, + /// The font was added for this session but couldn't be stored for the next one. + pub warning: Option, +} + +#[derive(Default)] +pub(super) struct UserFonts { + fonts: Vec, + dir: Option, +} + +/// The kind of font file `bytes` holds, from its signature. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum FontFormat { + TrueType, + OpenType, + Collection, + Woff, + Woff2, +} + +pub fn sniff(bytes: &[u8]) -> Option { + match bytes.get(..4)? { + [0, 1, 0, 0] | b"true" | b"typ1" => Some(FontFormat::TrueType), + b"OTTO" => Some(FontFormat::OpenType), + b"ttcf" => Some(FontFormat::Collection), + b"wOFF" => Some(FontFormat::Woff), + b"wOF2" => Some(FontFormat::Woff2), + _ => None, + } +} + +/// The font file in `bytes` as plain OpenType (TTF/OTF/TTC) bytes: WOFF and WOFF2 are unwrapped. +/// Checks the size and the signature only; [`FontDb::install_font`] checks the faces. +pub fn decode_font_file(bytes: &[u8]) -> Result, UserFontError> { + if bytes.is_empty() { + return Err(UserFontError::Empty); + } + if bytes.len() > MAX_FONT_FILE_BYTES { + return Err(UserFontError::TooLarge { bytes: bytes.len() }); + } + let decoded = match sniff(bytes).ok_or(UserFontError::NotAFont)? { + FontFormat::TrueType | FontFormat::OpenType | FontFormat::Collection => return Ok(bytes.to_vec()), + FontFormat::Woff => unwrap_web_font(|| wuff::decompress_woff1(bytes))?, + FontFormat::Woff2 => unwrap_web_font(|| wuff::decompress_woff2(bytes))?, + }; + if decoded.len() > MAX_DECODED_BYTES { + return Err(UserFontError::TooLarge { bytes: decoded.len() }); + } + match sniff(&decoded) { + Some(FontFormat::TrueType | FontFormat::OpenType | FontFormat::Collection) => Ok(decoded), + _ => Err(UserFontError::BadWebFont("it doesn't hold a TrueType or OpenType font".into())), + } +} + +/// Run a WOFF/WOFF2 decoder. The decoder checks its input, but it is third-party code reading +/// hostile bytes: a panic in it is caught (native) and reported as a damaged file. +fn unwrap_web_font(decode: impl FnOnce() -> Result, wuff::WuffErr>) -> Result, UserFontError> { + match std::panic::catch_unwind(std::panic::AssertUnwindSafe(decode)) { + Ok(Ok(v)) => Ok(v), + Ok(Err(e)) => Err(UserFontError::BadWebFont(format!("{e:?}"))), + Err(_) => Err(UserFontError::BadWebFont("damaged file".into())), + } +} + +/// A file name for a user font from the name it came with: letters, digits, spaces, `-` and `_` +/// of its stem (others become `_`, at most 64), and the extension of its unwrapped format. +fn stored_name(name: &str, sfnt: &[u8]) -> String { + // The last component, whichever separator the name came with. + let base = name.rsplit(['/', '\\']).next().unwrap_or(name); + let stem = base.rsplit_once('.').map(|(s, _)| s).unwrap_or(base); + let mut clean: String = stem.chars().take(64).map(|c| if c.is_alphanumeric() || matches!(c, ' ' | '-' | '_') { c } else { '_' }).collect(); + clean = clean.trim().to_string(); + if clean.is_empty() || clean.starts_with('-') { + clean = format!("font{clean}"); + } + let ext = match sniff(sfnt) { + Some(FontFormat::OpenType) => "otf", + Some(FontFormat::Collection) => "ttc", + _ => "ttf", + }; + format!("{clean}.{ext}") +} + +impl FontDb { + /// A counter that changes whenever user fonts are added or removed: a UI caching the font + /// list or a layout redraws when it moves. + pub fn generation(&self) -> u64 { + self.generation.load(Ordering::Acquire) + } + + fn read_user(&self) -> std::sync::RwLockReadGuard<'_, UserFonts> { + self.user.read().unwrap_or_else(|e| e.into_inner()) + } + + fn write_user(&self) -> std::sync::RwLockWriteGuard<'_, UserFonts> { + self.user.write().unwrap_or_else(|e| e.into_inner()) + } + + /// The fonts the user added, in the order added. + pub fn user_fonts(&self) -> Vec { + self.read_user().fonts.clone() + } + + /// The folder user fonts are stored in, if any ([`FontDb::load_user_fonts`]). + pub fn user_font_dir(&self) -> Option { + self.read_user().dir.clone() + } + + /// Add the font file `bytes` (TTF, OTF, TTC, WOFF or WOFF2) named `name` (its file name, for + /// the stored copy). Every face is checked first; faces whose family and style are already + /// available are skipped (the bundled baseline is never replaced). With a user font folder the + /// font is stored there too, for the next start. + pub fn install_font(&self, name: &str, bytes: &[u8]) -> Result { + let sfnt = decode_font_file(bytes)?; + let mut font = self.add_user_faces(name, sfnt.clone(), None)?; + let warning = match self.user_font_dir() { + Some(dir) => match write_unique(&dir, &font.file, &sfnt) { + Ok(path) => { + font.file = path.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or(font.file); + font.path = Some(path); + None + } + Err(e) => { + log::warn!("couldn't store the font {name} in {}: {e}", dir.display()); + Some(format!("added for this session only: couldn't store it in {}: {e}", dir.display())) + } + }, + None => None, + }; + if let Some(f) = self.write_user().fonts.iter_mut().find(|f| f.face_ids == font.face_ids) { + f.file.clone_from(&font.file); + f.path.clone_from(&font.path); + } + Ok(Installed { font, warning }) + } + + /// Check the faces in `sfnt` and add the new ones, recording them as a user font. + fn add_user_faces(&self, name: &str, sfnt: Vec, path: Option) -> Result { + if self.read_user().fonts.len() >= MAX_USER_FONTS { + return Err(UserFontError::TooMany); + } + if FileRef::new(&sfnt).is_err() { + return Err(UserFontError::Unusable("not a valid OpenType file".into())); + } + let found = enumerate_faces(&sfnt); + if found.is_empty() { + return Err(UserFontError::Unusable("no face with a family name".into())); + } + if found.len() > MAX_FACES_PER_FILE { + return Err(UserFontError::Unusable(format!("more than {MAX_FACES_PER_FILE} faces"))); + } + let file = stored_name(name, &sfnt); + let data = Arc::new(sfnt); + let mut new = Vec::new(); + for f in found { + let label = format!("{} {}", f.family, f.style); + let face = make_face(FontBytes::Owned(data.clone()), f.index, f.family, f.style, f.legacy, f.coords) + .ok_or_else(|| UserFontError::Unusable(format!("{label} doesn't parse")))?; + if face.skrifa().is_none_or(|s| s.charmap().mappings().next().is_none()) { + return Err(UserFontError::Unusable(format!("{label} maps no characters"))); + } + new.push(Arc::new(face)); + } + let families = |faces: &[Arc]| { + let mut v: Vec = faces.iter().map(|f| f.family.clone()).collect(); + v.dedup(); + v + }; + let mut user = self.write_user(); + // Unique among the user fonts (the folder is checked when writing). + let file = unique_name(&file, |n| user.fonts.iter().any(|f| f.file.eq_ignore_ascii_case(n))); + let added: Vec> = { + let mut faces = self.faces.write().unwrap_or_else(|e| e.into_inner()); + let fresh: Vec<_> = new + .iter() + .filter(|f| !faces.iter().any(|g| g.family.eq_ignore_ascii_case(&f.family) && g.style.eq_ignore_ascii_case(&f.style))) + .cloned() + .collect(); + faces.extend(fresh.iter().cloned()); + fresh + }; + if added.is_empty() { + return Err(UserFontError::AlreadyAvailable(families(&new))); + } + let font = UserFont { + file, + faces: added.iter().map(|f| (f.family.clone(), f.style.clone())).collect(), + path, + face_ids: added.iter().map(|f| f.id).collect(), + }; + user.fonts.push(font.clone()); + drop(user); + self.fonts_changed(); + Ok(font) + } + + /// Forget cached fallback choices and tell watchers ([`FontDb::generation`]). + fn fonts_changed(&self) { + self.fallbacks.write().unwrap_or_else(|e| e.into_inner()).map.clear(); + self.generation.fetch_add(1, Ordering::AcqRel); + } + + /// Remove the user fonts that added `family` (a typographic or legacy family name): their + /// faces leave the font list and their stored files are deleted. Text set in the family falls + /// back as for any font that isn't installed. Bundled and system fonts can't be removed. + pub fn remove_user_font(&self, family: &str) -> Result, UserFontError> { + let mut user = self.write_user(); + let ids: Vec = { + let faces = self.read_faces(); + faces.iter().filter(|f| f.named(family)).map(|f| f.id).collect() + }; + let (gone, kept): (Vec, Vec) = std::mem::take(&mut user.fonts) + .into_iter() + .partition(|u| u.faces.iter().any(|(f, _)| f.eq_ignore_ascii_case(family)) || u.face_ids.iter().any(|i| ids.contains(i))); + user.fonts = kept; + drop(user); + if gone.is_empty() { + return Err(UserFontError::NotUserFont(family.to_string())); + } + let drop_ids: Vec = gone.iter().flat_map(|u| u.face_ids.iter().copied()).collect(); + self.faces.write().unwrap_or_else(|e| e.into_inner()).retain(|f| !drop_ids.contains(&f.id)); + for u in &gone { + if let Some(p) = &u.path + && let Err(e) = std::fs::remove_file(p) + && e.kind() != std::io::ErrorKind::NotFound + { + log::warn!("couldn't delete the font file {}: {e}", p.display()); + } + } + self.fonts_changed(); + Ok(gone) + } + + /// Use `dir` as the user font folder: load the fonts stored there (each checked like an added + /// font; one that fails is logged and skipped) and store fonts added later there. The folder is + /// made when the first font is stored. Returns the fonts loaded and the files skipped (with + /// why). + pub fn load_user_fonts(&self, dir: &Path) -> (Vec, Vec<(String, String)>) { + self.write_user().dir = Some(dir.to_path_buf()); + let mut loaded = Vec::new(); + let mut skipped = Vec::new(); + let Ok(rd) = std::fs::read_dir(dir) else { return (loaded, skipped) }; + let mut paths: Vec = rd + .flatten() + .map(|e| e.path()) + .filter(|p| p.is_file()) + .filter(|p| p.extension().and_then(|e| e.to_str()).is_some_and(|e| STORED_EXTS.contains(&e.to_ascii_lowercase().as_str()))) + .collect(); + paths.sort(); + for p in paths { + let file = p.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default(); + let read = match std::fs::metadata(&p) { + Ok(m) if m.len() > MAX_FONT_FILE_BYTES as u64 => { + Err(UserFontError::TooLarge { bytes: usize::try_from(m.len()).unwrap_or(usize::MAX) }) + } + Ok(_) => std::fs::read(&p).map_err(|e| UserFontError::Unusable(e.to_string())), + Err(e) => Err(UserFontError::Unusable(e.to_string())), + }; + let r = read.and_then(|b| decode_font_file(&b)).and_then(|sfnt| self.add_user_faces(&file, sfnt, Some(p.clone()))); + match r { + Ok(mut f) => { + // Its own file name, so removing it deletes this file. + if let Some(u) = self.write_user().fonts.iter_mut().find(|u| u.face_ids == f.face_ids) { + u.file.clone_from(&file); + } + f.file = file; + loaded.push(f); + } + Err(e) => { + log::warn!("skipped the user font {}: {e}", p.display()); + skipped.push((file, e.to_string())); + } + } + } + (loaded, skipped) + } +} + +/// `name`, or `name` with `-2`, `-3`… before its extension, the first that `taken` refuses. +fn unique_name(name: &str, taken: impl Fn(&str) -> bool) -> String { + if !taken(name) { + return name.to_string(); + } + let (stem, ext) = name.rsplit_once('.').unwrap_or((name, "")); + (2..10_000).map(|n| format!("{stem}-{n}.{ext}")).find(|n| !taken(n)).unwrap_or_else(|| format!("{stem}-x.{ext}")) +} + +/// Write `bytes` to a new file in `dir` named `name` (or a numbered variant when that exists), +/// through a temporary file so a crash never leaves half a font. +fn write_unique(dir: &Path, name: &str, bytes: &[u8]) -> std::io::Result { + std::fs::create_dir_all(dir)?; + let name = unique_name(name, |n| dir.join(n).exists()); + let path = dir.join(&name); + let tmp = dir.join(format!(".{name}.part")); + std::fs::write(&tmp, bytes)?; + if let Err(e) = std::fs::rename(&tmp, &path) { + let _ = std::fs::remove_file(&tmp); + return Err(e); + } + Ok(path) +} + +#[cfg(test)] +mod tests { + use super::*; + + const FAMILY: &str = "Userfont Sans"; + + /// A bundled Source Sans 3 file renamed `family` (as long as the original name). + pub(crate) fn renamed(file: &str, family: &str) -> Vec { + assert_eq!(family.len(), "Source Sans 3".len()); + let mut data = std::fs::read(Path::new(env!("CARGO_MANIFEST_DIR")).join("../../assets/fonts").join(file)).unwrap(); + let utf16 = |s: &str| s.encode_utf16().flat_map(u16::to_be_bytes).collect::>(); + for (from, to) in [(utf16("Source Sans 3"), utf16(family)), (b"Source Sans 3".to_vec(), family.as_bytes().to_vec())] { + let mut i = 0; + while let Some(at) = data[i..].windows(from.len()).position(|w| w == from) { + data[i + at..i + at + to.len()].copy_from_slice(&to); + i += at + to.len(); + } + } + data + } + + fn db() -> FontDb { + FontDb::with_font_dirs(vec![]) + } + + fn temp_dir(name: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!("wc-userfonts-{}-{name}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + dir + } + + /// The tables of a TrueType/OpenType font: (tag, data), in directory order. + fn tables(font: &[u8]) -> Vec<([u8; 4], Vec)> { + let n = u16::from_be_bytes([font[4], font[5]]) as usize; + (0..n) + .map(|i| { + let r = &font[12 + i * 16..28 + i * 16]; + let off = u32::from_be_bytes(r[8..12].try_into().unwrap()) as usize; + let len = u32::from_be_bytes(r[12..16].try_into().unwrap()) as usize; + (r[..4].try_into().unwrap(), font[off..off + len].to_vec()) + }) + .collect() + } + + /// `font` as a WOFF 1.0 file with every table stored uncompressed (the format allows it). + fn woff1(font: &[u8]) -> Vec { + let mut t = tables(font); + t.sort_by_key(|x| x.0); + let mut out = Vec::new(); + let dir_end = 44 + 20 * t.len(); + let mut body = Vec::new(); + let mut dir = Vec::new(); + let mut sfnt_size = 12 + 16 * t.len(); + for (tag, data) in &t { + let off = dir_end + body.len(); + dir.extend_from_slice(tag); + dir.extend_from_slice(&(off as u32).to_be_bytes()); + dir.extend_from_slice(&(data.len() as u32).to_be_bytes()); + dir.extend_from_slice(&(data.len() as u32).to_be_bytes()); + dir.extend_from_slice(&0u32.to_be_bytes()); + body.extend_from_slice(data); + body.resize(body.len().next_multiple_of(4), 0); + sfnt_size += data.len().next_multiple_of(4); + } + out.extend_from_slice(b"wOFF"); + out.extend_from_slice(&font[..4]); + out.extend_from_slice(&((dir_end + body.len()) as u32).to_be_bytes()); + out.extend_from_slice(&(t.len() as u16).to_be_bytes()); + out.extend_from_slice(&0u16.to_be_bytes()); + out.extend_from_slice(&(sfnt_size as u32).to_be_bytes()); + out.extend_from_slice(&[0, 1, 0, 0]); // version 1.0 + out.extend_from_slice(&[0; 20]); // no metadata, no private data + out.extend(dir); + out.extend(body); + out + } + + /// `data` as a brotli stream of uncompressed meta-blocks (RFC 7932 §9.2). + fn brotli_stored(data: &[u8]) -> Vec { + struct Bits { + out: Vec, + acc: u64, + n: u32, + } + impl Bits { + fn put(&mut self, v: u64, bits: u32) { + self.acc |= v << self.n; + self.n += bits; + while self.n >= 8 { + self.out.push(self.acc as u8); + self.acc >>= 8; + self.n -= 8; + } + } + fn align(&mut self) { + if self.n > 0 { + self.put(0, 8 - self.n); + } + } + } + let mut b = Bits { out: Vec::new(), acc: 0, n: 0 }; + b.put(0, 1); // WBITS = 16 + for chunk in data.chunks(1 << 16) { + b.put(0, 1); // ISLAST = 0 + b.put(0, 2); // MNIBBLES = 4 + b.put(chunk.len() as u64 - 1, 16); + b.put(1, 1); // ISUNCOMPRESSED + b.align(); + b.out.extend_from_slice(chunk); + } + b.put(1, 1); // ISLAST + b.put(1, 1); // ISLASTEMPTY + b.align(); + b.out + } + + /// WOFF2's UIntBase128. + fn base128(mut v: u32, out: &mut Vec) { + let mut bytes = vec![(v & 0x7f) as u8]; + v >>= 7; + while v > 0 { + bytes.push((v & 0x7f) as u8 | 0x80); + v >>= 7; + } + out.extend(bytes.into_iter().rev()); + } + + /// `font` (TrueType) as a WOFF2 file with no table transformed (glyf/loca use the null + /// transform, version 3) and the table data in stored brotli blocks. + fn woff2(font: &[u8]) -> Vec { + let t = tables(font); + let mut dir = Vec::new(); + let mut stream = Vec::new(); + for (tag, data) in &t { + let null = if tag == b"glyf" || tag == b"loca" { 3 << 6 } else { 0 }; + dir.push(63 | null); + dir.extend_from_slice(tag); + base128(data.len() as u32, &mut dir); + stream.extend_from_slice(data); + } + let compressed = brotli_stored(&stream); + let mut out = Vec::new(); + out.extend_from_slice(b"wOF2"); + out.extend_from_slice(&font[..4]); + let total = (48 + dir.len() + compressed.len()).next_multiple_of(4); + out.extend_from_slice(&(total as u32).to_be_bytes()); + out.extend_from_slice(&(t.len() as u16).to_be_bytes()); + out.extend_from_slice(&0u16.to_be_bytes()); + let sfnt: usize = 12 + 16 * t.len() + t.iter().map(|(_, d)| d.len().next_multiple_of(4)).sum::(); + out.extend_from_slice(&(sfnt as u32).to_be_bytes()); + out.extend_from_slice(&(compressed.len() as u32).to_be_bytes()); + out.extend_from_slice(&[0, 1, 0, 0]); + out.extend_from_slice(&[0; 20]); + out.extend(dir); + out.extend(compressed); + out.resize(total, 0); + out + } + + #[test] + fn an_added_font_is_listed_resolved_and_measured() { + let db = db(); + let gen0 = db.generation(); + assert!(!db.has_family(FAMILY)); + let r = db.install_font("Userfont-Regular.ttf", &renamed("SourceSans3-Regular.ttf", FAMILY)).unwrap(); + assert_eq!(r.font.families(), vec![FAMILY.to_string()]); + assert_eq!(r.font.file, "Userfont-Regular.ttf"); + assert!(r.font.path.is_none() && r.warning.is_none(), "no folder: kept for the session only"); + assert!(db.generation() > gen0); + assert!(db.families().iter().any(|f| f == FAMILY)); + let face = db.face(FAMILY, "Regular"); + assert_eq!(face.family, FAMILY); + // Measured with its own glyphs: the copy has Source Sans 3's metrics. + let src = db.face("Source Sans 3", "Regular"); + let g = face.glyph_for('W'); + assert!(g != 0); + assert_eq!(face.advance(g), src.advance(src.glyph_for('W'))); + assert_eq!(db.user_fonts().len(), 1); + } + + #[test] + fn the_bundled_baseline_is_never_replaced() { + let db = db(); + let before = db.face("Source Sans 3", "Regular").id(); + let bytes = std::fs::read(Path::new(env!("CARGO_MANIFEST_DIR")).join("../../assets/fonts/SourceSans3-Regular.ttf")).unwrap(); + assert!(matches!(db.install_font("x.ttf", &bytes), Err(UserFontError::AlreadyAvailable(_)))); + assert_eq!(db.face("Source Sans 3", "Regular").id(), before); + assert!(db.user_fonts().is_empty()); + assert!(matches!(db.remove_user_font("Source Sans 3"), Err(UserFontError::NotUserFont(_)))); + assert!(db.has_family("Source Sans 3")); + } + + #[test] + fn junk_is_rejected_without_adding_anything() { + let db = db(); + let n = db.families().len(); + let regular = renamed("SourceSans3-Regular.ttf", FAMILY); + let mut cases: Vec> = vec![ + vec![], + b"not a font at all".to_vec(), + b"\0\x01\0\0".to_vec(), + b"\0\x01\0\0\xff\xff\0\0\0\0\0\0".to_vec(), + b"ttcf\0\x01\0\0\xff\xff\xff\xff".to_vec(), + b"OTTO\0\x01".to_vec(), + b"wOFF\0\x01\0\0\0\0\0\x08".to_vec(), + b"wOF2\0\x01\0\0\xff\xff\xff\xff\0\x05".to_vec(), + regular[..regular.len() / 3].to_vec(), + ]; + // Damaged web fonts: every prefix, and flipped bytes through the headers and directory. + for packed in [woff1(®ular), woff2(®ular)] { + for cut in [8, 20, 44, 47, 48, 60, 100, 300, packed.len() / 2, packed.len() - 1] { + cases.push(packed[..cut.min(packed.len())].to_vec()); + } + for at in 4..200 { + let mut b = packed.clone(); + b[at] ^= 0xA5; + cases.push(b); + } + } + for (i, c) in cases.iter().enumerate() { + let r = db.install_font("junk.ttf", c); + // A flipped byte inside a table can leave a usable font; the cut-off files can't. + assert!(i >= 9 || r.is_err(), "case {i}"); + } + assert_eq!(db.families().len(), n + db.user_fonts().iter().map(|u| u.families().len()).sum::()); + let mut big = regular.clone(); + big.resize(MAX_FONT_FILE_BYTES + 1, 0); + assert!(matches!(db.install_font("big.ttf", &big), Err(UserFontError::TooLarge { .. }))); + } + + #[test] + fn woff_and_woff2_are_unwrapped() { + let regular = renamed("SourceSans3-Regular.ttf", FAMILY); + for (name, packed) in [("a.woff", woff1(®ular)), ("a.woff2", woff2(®ular))] { + assert!(is_font_file_name(name)); + let db = db(); + let r = db.install_font(name, &packed).unwrap_or_else(|e| panic!("{name}: {e}")); + assert_eq!(r.font.file, "a.ttf", "stored unwrapped"); + let face = db.face(FAMILY, "Regular"); + assert_eq!(face.family, FAMILY); + assert!(face.glyph_for('g') != 0); + } + } + + #[test] + fn user_fonts_are_stored_loaded_and_removed() { + let dir = temp_dir("store"); + let db = db(); + let (loaded, skipped) = db.load_user_fonts(&dir); + assert!(loaded.is_empty() && skipped.is_empty(), "a missing folder is empty"); + let r = db.install_font("../../evil/Userfont Bold?.woff2", &woff2(&renamed("SourceSans3-Bold.ttf", FAMILY))).unwrap(); + assert!(r.warning.is_none()); + let path = r.font.path.clone().unwrap(); + assert_eq!(path.parent(), Some(dir.as_path()), "a file name can't leave the folder"); + assert_eq!(r.font.file, "Userfont Bold_.ttf"); + assert!(path.exists()); + db.install_font("Userfont-Regular.ttf", &renamed("SourceSans3-Regular.ttf", FAMILY)).unwrap(); + // A damaged file in the folder is skipped, never fatal. + std::fs::write(dir.join("damaged.ttf"), b"ttcf\0\x01\0\0\xff\xff\xff\xff").unwrap(); + std::fs::write(dir.join("notes.txt"), b"hello").unwrap(); + + // The next start loads both from the folder. + let next = super::super::FontDb::with_font_dirs(vec![]); + let (loaded, skipped) = next.load_user_fonts(&dir); + assert_eq!(loaded.len(), 2, "{skipped:?}"); + assert_eq!(skipped.len(), 1); + assert_eq!(next.styles(FAMILY), vec!["Regular".to_string(), "Bold".to_string()]); + assert_eq!(next.face(FAMILY, "Bold").style, "Bold"); + + // Removing the family deletes both files; text in it falls back. + let gone = next.remove_user_font(FAMILY).unwrap(); + assert_eq!(gone.len(), 2); + assert!(!next.has_family(FAMILY)); + assert_eq!(next.face(FAMILY, "Regular").family, super::super::FALLBACK_FAMILY); + assert!(!path.exists()); + assert!(next.user_fonts().is_empty()); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn stored_names_are_safe_and_unique() { + let ttf = renamed("SourceSans3-Regular.ttf", FAMILY); + assert_eq!(stored_name("C:\\x\\..\\a.b.TTF", &ttf), "a_b.ttf"); + assert_eq!(stored_name("", &ttf), "font.ttf"); + assert_eq!(stored_name("-rf.otf", b"OTTO"), "font-rf.otf"); + assert_eq!(unique_name("a.ttf", |n| n == "a.ttf" || n == "a-2.ttf"), "a-3.ttf"); + } +} diff --git a/crates/fonts/src/lib.rs b/crates/fonts/src/lib.rs index e5ac6f9..15c9c2c 100644 --- a/crates/fonts/src/lib.rs +++ b/crates/fonts/src/lib.rs @@ -13,7 +13,10 @@ mod script; mod ui_fallback; pub mod word; -pub use fontdb::{FALLBACK_FAMILY, FaceRef, FontDb, FontFace, LocalName, base_style, bundled, name_language_matches, system_font_dirs}; +pub use fontdb::{ + FALLBACK_FAMILY, FONT_FILE_EXTS, FaceRef, FontDb, FontFace, FontFormat, Installed, LocalName, MAX_FONT_FILE_BYTES, UserFont, UserFontError, + base_style, bundled, decode_font_file, is_font_file_name, name_language_matches, sniff, system_font_dirs, +}; pub use harfrust::Feature; use harfrust::{Direction, ShapeOptions, Tag, UnicodeBuffer}; pub use kurbo::BezPath; diff --git a/crates/fonts/src/word.rs b/crates/fonts/src/word.rs index 5cb79f5..cc76077 100644 --- a/crates/fonts/src/word.rs +++ b/crates/fonts/src/word.rs @@ -174,15 +174,24 @@ fn style_name(bold: bool, italic: bool) -> &'static str { } } -/// Resolve `family` with bold/italic to a face (memoised). +/// Resolve `family` with bold/italic to a face (memoised until user fonts are added or removed, +/// [`FontDb::generation`]). pub fn resolve(family: &str, bold: bool, italic: bool) -> Resolved { - static CACHE: OnceLock>> = OnceLock::new(); - let cache = CACHE.get_or_init(|| Mutex::new(HashMap::new())); - let key = (family.to_string(), bold, italic); - if let Some(r) = cache.lock().unwrap_or_else(|e| e.into_inner()).get(&key) { - return *r; - } + type Memo = (u64, HashMap<(String, bool, bool), Resolved>); + static CACHE: OnceLock> = OnceLock::new(); + let cache = CACHE.get_or_init(|| Mutex::new((0, HashMap::new()))); let db = FontDb::global(); + let generation = db.generation(); + let key = (family.to_string(), bold, italic); + { + let mut c = cache.lock().unwrap_or_else(|e| e.into_inner()); + if c.0 != generation { + *c = (generation, HashMap::new()); + } + if let Some(r) = c.1.get(&key) { + return *r; + } + } let (fam, substituted) = if db.has_family(family) { (family.to_string(), false) } else { @@ -191,7 +200,10 @@ pub fn resolve(family: &str, bold: bool, italic: bool) -> Resolved { }; let face = db.face(&fam, style_name(bold, italic)); let r = Resolved { synth_bold: bold && face.weight < 600.0, synth_italic: italic && !face.italic, substituted, face: FaceRef::of(&face) }; - cache.lock().unwrap_or_else(|e| e.into_inner()).insert(key, r); + let mut c = cache.lock().unwrap_or_else(|e| e.into_inner()); + if c.0 == generation { + c.1.insert(key, r); + } r } diff --git a/crates/ui-egui/src/file_dialogs.rs b/crates/ui-egui/src/file_dialogs.rs index 3e79379..939a8fb 100644 --- a/crates/ui-egui/src/file_dialogs.rs +++ b/crates/ui-egui/src/file_dialogs.rs @@ -16,7 +16,7 @@ use crate::WordApp; /// A file dialog the app asks the host to show ([`Services::file_dialog`](crate::Services::file_dialog)). #[derive(Clone, Debug, PartialEq, Eq)] pub enum FileDialogRequest { - /// Pick a file to open; `purpose` says what for (`document`, `picture`, `recipients`), as for + /// Pick a file to open; `purpose` says what for (`document`, `picture`, `recipients`, `font`), as for /// `pick_open`. Open { purpose: String }, /// Pick a path to save to, given a suggested file name. @@ -37,6 +37,8 @@ pub(crate) enum AfterPick { PrintPdf { settings: Value }, /// Load the picked CSV/TSV/text file or spreadsheet as the mail-merge recipients (#240, #334). Recipients, + /// Add the picked font file (#582). + Font, /// Hand the picked path to the open dialog (`WordApp::dialog_pick`): Templates and Add-ins, /// Organizer (#377). ForDialog, @@ -130,6 +132,10 @@ impl WordApp { }; self.insert_or_change_picture(json!({"path": path})) } + AfterPick::Font => { + let Some(path) = picked else { return Ok(json!({"cancelled": true})) }; + self.run("font.add", json!({"path": path})) + } AfterPick::ForDialog => { let picked = picked.filter(|p| !p.is_empty()); let r = json!({"picked": picked}); @@ -226,6 +232,15 @@ impl WordApp { } let _ = self.ask_file(FileDialogRequest::Open { purpose: "picture".into() }, AfterPick::Picture); } + + /// Pick a font file to add (Add Font…; web: it arrives through the inbox). + pub(crate) fn pick_font(&mut self) { + if let Some(f) = &self.services.open_async { + f("font"); + return; + } + let _ = self.ask_file(FileDialogRequest::Open { purpose: "font".into() }, AfterPick::Font); + } } #[cfg(test)] diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 8bc4f8b..12094d3 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -1537,6 +1537,9 @@ New Group Nuevo grupo Rename… Cambiar nombre… Ribbon and Quick Access Toolbar: Cinta de opciones y barra de acceso rápido: + Add Font… Agregar fuente… + Added Fonts Fuentes agregadas + Remove Added Font Quitar fuente agregada Recent Colors Colores recientes More Colors… Más colores… Standard Estándar diff --git a/crates/ui-egui/src/i18n/et.tsv b/crates/ui-egui/src/i18n/et.tsv index b1f39d3..d6fdc45 100644 --- a/crates/ui-egui/src/i18n/et.tsv +++ b/crates/ui-egui/src/i18n/et.tsv @@ -1523,6 +1523,9 @@ Ribbon Lint Ribbon and Quick Access Toolbar: Lint ja kiirpääsuriba: Ribbon tabs Lindi vahekaardid + Add Font… Lisa font… + Added Fonts Lisatud fondid + Remove Added Font Eemalda lisatud font Recent Colors Viimati kasutatud värvid More Colors… Rohkem värve… Standard Standard diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 2217bde..eeb9289 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -1521,6 +1521,9 @@ New Group 新しいグループ Rename… 名前の変更… Ribbon and Quick Access Toolbar: リボンとクイック アクセス ツール バー: + Add Font… フォントの追加… + Added Fonts 追加したフォント + Remove Added Font 追加したフォントの削除 Recent Colors 最近使用した色 More Colors… その他の色… Standard 標準 diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 72d356b..508e078 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -1533,6 +1533,9 @@ New Group Novo Grupo Rename… Renomear… Ribbon and Quick Access Toolbar: Faixa de Opções e Barra de Acesso Rápido: + Add Font… Adicionar fonte… + Added Fonts Fontes adicionadas + Remove Added Font Remover fonte adicionada Recent Colors Cores recentes More Colors… Mais cores… Standard Padrão diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 5bd9516..4db24be 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -1790,6 +1790,9 @@ preview Title Пример Left Only Только слева Right Only Только справа Largest Only Только по большей стороне + Add Font… Добавить шрифт… + Added Fonts Добавленные шрифты + Remove Added Font Удалить добавленный шрифт Print Markup Печатать исправления и примечания Comments beside each page and tracked changes in PDFs, prints and images; a comment list at the end of web pages, Markdown and text. Примечания рядом с каждой страницей и отслеживаемые изменения в PDF, при печати и в изображениях; список примечаний в конце веб-страниц, Markdown и текста. Recent Colors Последние цвета diff --git a/crates/ui-egui/src/i18n/sr-latn.tsv b/crates/ui-egui/src/i18n/sr-latn.tsv index 6567ef7..810226c 100644 --- a/crates/ui-egui/src/i18n/sr-latn.tsv +++ b/crates/ui-egui/src/i18n/sr-latn.tsv @@ -1531,6 +1531,9 @@ New Group Nova grupa Rename… Preimenuj… Ribbon and Quick Access Toolbar: Traka i traka sa alatkama za brzi pristup: + Add Font… Dodaj font… + Added Fonts Dodati fontovi + Remove Added Font Ukloni dodati font Recent Colors Nedavne boje More Colors… Još boja… Standard Standardne diff --git a/crates/ui-egui/src/i18n/sr.tsv b/crates/ui-egui/src/i18n/sr.tsv index e20cccd..48b3c3e 100644 --- a/crates/ui-egui/src/i18n/sr.tsv +++ b/crates/ui-egui/src/i18n/sr.tsv @@ -1535,6 +1535,9 @@ New Group Нова група Rename… Преименуј… Ribbon and Quick Access Toolbar: Трака и трака са алаткама за брзи приступ: + Add Font… Додај фонт… + Added Fonts Додати фонтови + Remove Added Font Уклони додати фонт Recent Colors Недавне боје More Colors… Још боја… Standard Стандардне diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 148da19..bbf6b37 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -1408,6 +1408,9 @@ Left Only Yalnızca Sol Right Only Yalnızca Sağ Largest Only Yalnızca En Geniş + Add Font… Yazı Tipi Ekle… + Added Fonts Eklenen Yazı Tipleri + Remove Added Font Eklenen Yazı Tipini Kaldır Print Markup İşaretlemeyi Yazdır Comments beside each page and tracked changes in PDFs, prints and images; a comment list at the end of web pages, Markdown and text. PDF'lerde, baskılarda ve görüntülerde her sayfanın yanında açıklamalar ve izlenen değişiklikler; web sayfalarının, Markdown ve metnin sonunda açıklama listesi. Recent Colors Son Kullanılan Renkler diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 4bb4979..f0cb281 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -1533,6 +1533,9 @@ New Group Нова група Rename… Перейменувати… Ribbon and Quick Access Toolbar: Стрічка й панель швидкого доступу: + Add Font… Додати шрифт… + Added Fonts Додані шрифти + Remove Added Font Видалити доданий шрифт Recent Colors Останні кольори More Colors… Інші кольори… Standard Стандартні diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index ff55829..baf7301 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -1533,6 +1533,9 @@ New Group 新建组 Rename… 重命名… Ribbon and Quick Access Toolbar: 功能区和快速访问工具栏: + Add Font… 添加字体… + Added Fonts 已添加的字体 + Remove Added Font 删除已添加的字体 Recent Colors 最近使用的颜色 More Colors… 其他颜色… Standard 标准 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 464268e..dbb70d4 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -1522,6 +1522,9 @@ New Group 新增群組 Rename… 重新命名… Ribbon and Quick Access Toolbar: 功能區和快速存取工具列: + Add Font… 新增字型… + Added Fonts 已新增的字型 + Remove Added Font 移除已新增的字型 Recent Colors 最近使用的色彩 More Colors… 其他色彩… Standard 標準 diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 47b7c6f..81d54f7 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -97,6 +97,11 @@ pub struct Services { /// Desktop: the picture on the system clipboard, if any — egui's paste only carries text /// (#45). Read when Paste finds no text; see [`paste_picture`]. pub clipboard_picture: Option Option>>, + /// Web: keep a font the user added (stored file name, original bytes) for the next visit + /// (#582). Desktop fonts are stored by the font database itself, in the app's font folder. + pub store_font: Option>, + /// Web: forget a stored font (its stored file name) once the user removes it. + pub forget_font: Option>, } /// Files delivered asynchronously. @@ -258,6 +263,9 @@ pub struct WordApp { /// Web: a recipient list was asked for (Select Recipients › Use an Existing List…), so the /// next text file from the picker loads as recipients instead of opening (#240). pub recipient_list_pending: bool, + /// The font database's [`generation`](wordcraft_fonts::FontDb::generation) last seen: when + /// fonts are added or removed (by any route) the font lists and the layout are refreshed. + font_generation: u64, /// A file picked for the open dialog (Templates and Add-ins › Attach…, Organizer › /// Browse…); the dialog takes it on its next frame. pub(crate) dialog_pick: Option, @@ -377,6 +385,7 @@ impl WordApp { autosave_path: None, change_picture_target: None, recipient_list_pending: false, + font_generation: wordcraft_fonts::FontDb::global().generation(), dialog_pick: None, file_dialog: None, info_editing: None, @@ -559,6 +568,9 @@ impl WordApp { { self.autosave_path = self.session.path.clone(); } + if let Ok(v) = &r { + self.keep_web_font(id, ¶ms, v); + } self.after_command(id); if let Err(e) = &r { self.status(e.clone()); @@ -566,6 +578,44 @@ impl WordApp { r } + /// Web: store a font added from bytes, or forget the removed ones ([`Services::store_font`]). + fn keep_web_font(&self, id: &str, params: &Value, result: &Value) { + match id { + "font.add" => { + if let (Some(store), Some(file), Some(data)) = + (&self.services.store_font, result.get("file").and_then(Value::as_str), params.get("data").and_then(Value::as_str)) + && let Some(bytes) = wordcraft_engine::cmd::insert::base64_decode(data) + { + store(file, &bytes); + } + } + "font.remove" => { + if let Some(forget) = &self.services.forget_font { + for f in result.get("removed").and_then(Value::as_array).into_iter().flatten() { + if let Some(file) = f.get("file").and_then(Value::as_str) { + forget(file); + } + } + } + } + _ => {} + } + } + + /// Fonts were added or removed (a command, the web shell loading stored fonts): lay out + /// again and refresh the font lists and their previews. + fn watch_fonts(&mut self, ctx: &egui::Context) { + let db = wordcraft_fonts::FontDb::global(); + let generation = db.generation(); + if generation == self.font_generation { + return; + } + self.font_generation = generation; + self.session.relayout(); + self.previews.fonts_changed(ctx, db.user_fonts().iter().flat_map(|f| f.families())); + ctx.request_repaint(); + } + /// Answer the Save Changes prompt: Save (through Save As for a new document) and carry on, /// carry on without saving, or cancel. A failed or cancelled save cancels too. fn answer_save_changes(&mut self, choice: SaveChoice) -> Result { @@ -673,6 +723,7 @@ impl WordApp { self.save_as_dialog(); } "insertPicture" => self.pick_picture(), + "addFont" => self.pick_font(), "print" => { self.ui.backstage = true; self.ui.backstage_page = "print".into(); @@ -1003,6 +1054,7 @@ impl WordApp { self.clear_stale_change_picture(); let _ = self.poll_file_dialog(); self.drain_inbox(); + self.watch_fonts(ctx); self.autosave_tick(now_ms()); if self.autosaves() && self.session.dirty { ctx.request_repaint_after(std::time::Duration::from_millis(1000)); @@ -1024,6 +1076,8 @@ impl WordApp { } else if sheet { // A spreadsheet isn't a document: it's a recipient list (#334). self.load_recipients(json!({"path": path})) + } else if wordcraft_fonts::is_font_file_name(&lp) { + self.run("font.add", json!({"path": path})) } else { self.run("file.open", json!({"path": path})) }; @@ -1170,6 +1224,8 @@ impl WordApp { self.recipient_list_pending = asked && !list && !sheet; let r = if sheet { self.load_recipients(json!({"path": name, "data": wordcraft_engine::cmd::insert::base64_encode(&bytes)})) + } else if wordcraft_fonts::is_font_file_name(&lower) { + self.run("font.add", json!({"name": name, "data": wordcraft_engine::cmd::insert::base64_encode(&bytes)})) } else if list { self.load_recipients(json!({"csv": wordcraft_engine::io::decode_text(&bytes)})) } else if img { @@ -2911,6 +2967,50 @@ mod tests { let _ = std::fs::remove_dir_all(&dir); } + /// Add Font… (#582), web: the picker asks for fonts, the picked file arrives through the + /// inbox and is added, listed in the font menu and kept in the browser; removing it forgets it. + #[test] + fn a_font_picked_on_the_web_is_added_listed_and_kept() { + const FAMILY: &str = "Uifont Sans 3"; + let mut font = std::fs::read(concat!(env!("CARGO_MANIFEST_DIR"), "/../../assets/fonts/SourceSans3-Regular.ttf")).unwrap(); + let utf16 = |s: &str| s.encode_utf16().flat_map(u16::to_be_bytes).collect::>(); + for (from, to) in [(utf16("Source Sans 3"), utf16(FAMILY)), (b"Source Sans 3".to_vec(), FAMILY.as_bytes().to_vec())] { + let mut i = 0; + while let Some(at) = font[i..].windows(from.len()).position(|w| w == from) { + font[i + at..i + at + to.len()].copy_from_slice(&to); + i += at + to.len(); + } + } + let mut a = typed(); + let asked = std::rc::Rc::new(std::cell::RefCell::new(String::new())); + let seen = asked.clone(); + a.services.open_async = Some(Box::new(move |purpose| *seen.borrow_mut() = purpose.to_string())); + let kept = std::rc::Rc::new(std::cell::RefCell::new(Vec::<(String, usize)>::new())); + let (k1, k2) = (kept.clone(), kept.clone()); + a.services.store_font = Some(Box::new(move |name, bytes| k1.borrow_mut().push((name.to_string(), bytes.len())))); + a.services.forget_font = Some(Box::new(move |name| k2.borrow_mut().retain(|(n, _)| n != name))); + let inbox: Inbox = std::sync::Arc::new(std::sync::Mutex::new(Vec::new())); + a.services.inbox = Some(inbox.clone()); + let ctx = egui::Context::default(); + assert!(!a.previews.families().iter().any(|f| f == FAMILY)); + a.run("font.add", json!({})).unwrap(); + assert_eq!(*asked.borrow(), "font"); + inbox.lock().unwrap().push(("Uifont-Regular.ttf".into(), font.clone())); + a.drain_inbox(); + a.watch_fonts(&ctx); + assert!(a.previews.families().iter().any(|f| f == FAMILY), "listed in the font menu at once"); + assert_eq!(*kept.borrow(), vec![("Uifont-Regular.ttf".to_string(), font.len())]); + assert!(body_text(&a).contains(UNSAVED), "the document stays open"); + // Junk named like a font is refused, and nothing is kept. + inbox.lock().unwrap().push(("junk.woff2".into(), b"wOF2junk".to_vec())); + a.drain_inbox(); + assert_eq!(kept.borrow().len(), 1); + a.run("font.remove", json!({"family": FAMILY})).unwrap(); + a.watch_fonts(&ctx); + assert!(kept.borrow().is_empty()); + assert!(!a.previews.families().iter().any(|f| f == FAMILY)); + } + /// Web: the picked file arrives through the inbox and loads as recipients, even as .txt; a /// CSV dropped on the page does too. Anything else afterwards opens as before. #[test] diff --git a/crates/ui-egui/src/mini_toolbar.rs b/crates/ui-egui/src/mini_toolbar.rs index cc9a48a..2aa3524 100644 --- a/crates/ui-egui/src/mini_toolbar.rs +++ b/crates/ui-egui/src/mini_toolbar.rs @@ -132,8 +132,12 @@ fn content(app: &mut WordApp, ui: &mut egui::Ui, t: &Tokens) { let fams = app.previews.families(); let prev = app.previews.font_preview_fn(); if let Some(f) = widgets::font_combo(ui, "mini_font", COMBO_FONT_W, &font, &fams, Some(&*prev)) { - let f = app.previews.font_stored(&f); - let _ = app.run("format.font", json!({"name": f})); + let _ = if f == crate::widgets::ADD_FONT { + app.run("font.add", json!({})) + } else { + let f = app.previews.font_stored(&f); + app.run("format.font", json!({"name": f})) + }; } let size = st.get("size").and_then(Value::as_f64).map(|s| if s.fract() == 0.0 { format!("{s:.0}") } else { s.to_string() }).unwrap_or_default(); diff --git a/crates/ui-egui/src/previews.rs b/crates/ui-egui/src/previews.rs index 5127d10..1fc566e 100644 --- a/crates/ui-egui/src/previews.rs +++ b/crates/ui-egui/src/previews.rs @@ -84,6 +84,17 @@ impl Previews { self.english_names.get(shown).cloned().unwrap_or_else(|| shown.to_string()) } + /// Fonts were added or removed: list the families again and redraw the entries of + /// `families` (an entry drawn before its font was added shows the fallback face). + pub fn fonts_changed(&mut self, ctx: &egui::Context, families: impl Iterator) { + self.families = None; + ctx.data_mut(|d| { + for f in families { + d.remove::(egui::Id::new(format!("font:{f}"))); + } + }); + } + /// A closure drawing a font-menu entry in its own face. pub fn font_preview_fn(&mut self) -> Box Response> { Box::new(|ui: &mut Ui, name: &str| { diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index edb8fad..0b51858 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -414,8 +414,12 @@ fn home(app: &mut WordApp, ui: &mut Ui) { let fams = app.previews.families(); let prev = app.previews.font_preview_fn(); if let Some(f) = font_combo(ui, "font", 150.0, &font, &fams, Some(&*prev)) { - let f = app.previews.font_stored(&f); - let _ = app.run("format.font", json!({"name": f})); + let _ = if f == crate::widgets::ADD_FONT { + app.run("font.add", json!({})) + } else { + let f = app.previews.font_stored(&f); + app.run("format.font", json!({"name": f})) + }; } let size = st .get("size") diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 659d0f3..ac028bb 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -425,7 +425,12 @@ pub fn combo( combo_impl(ui, id, width, current, items, preview, false) } +/// What [`font_combo`] returns when its list's last entry, Add Font…, is chosen (#582): the +/// caller runs `font.add`, which shows a file picker. Not a family name anyone can type. +pub const ADD_FONT: &str = "\u{1}font.add"; + /// The font box: a [`combo`] where typing lists the matching families under the field (#359). +/// Its list ends with Add Font… ([`ADD_FONT`]). /// Up/Down move the highlight, Enter or a click applies the highlighted family ("arial" applies /// "Arial"), Escape restores the current font. Text matching no family is applied as typed, as /// Word allows fonts that are not installed. @@ -566,6 +571,13 @@ fn combo_impl( out = Some(v); ui.close(); } + if search { + ui.separator(); + if ui.button(tl!("Add Font…")).clicked() { + out = Some(ADD_FONT.to_string()); + ui.close(); + } + } }); if s.open && !s.matches.is_empty() { let matches = s.matches.clone(); diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 9b941f3..616c915 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI parity with Microsoft Word -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** trivial (More Colors… and the Colors dialog, #606; previously trivial: Find and Replace special characters and wildcards, #543; previously trivial: Customize Ribbon and the Quick Access Toolbar, #379; previously: major, first version; ribbon, dialogs, shortcuts and on-canvas interaction measured from `crates/ui-egui` and the command registry) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** trivial (Add Font… ends the font list, #582; previously trivial: Outlining tab and outline keys, #493; previously trivial: Customize Ribbon and the Quick Access Toolbar, #379; previously: major, first version; ribbon, dialogs, shortcuts and on-canvas interaction measured from `crates/ui-egui` and the command registry) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 How WordCraft looks and feels next to Word: ribbon, dialogs, panes, keyboard, mouse and touch. Typing behaviour has its own checklist ([`typing-parity.md`](typing-parity.md)). All assets are our @@ -60,6 +60,7 @@ own (`AGENTS.md`: no Microsoft iconography, ever). | 2026-10-11 | trivial | Collapsed duplicate rows left by parallel merges | | 2026-10-11 | trivial | Dialogs keep their top edge in place when switching tabs (Paragraph, Font, Borders and Shading…): only the bottom edge moves (#507) | | 2026-10-11 | trivial | Interface size setting in File › Options › General (80–200 %), kept between runs and applied on top of the system's display scale; `ui.interfaceSize` for agents (#475) | +| 2026-10-11 | trivial | Font list ends with Add Font… (TTF, OTF, TTC, WOFF, WOFF2; also by dropping the file); added fonts are listed at once and kept between runs (#582) | | 2026-10-11 | trivial | Font box (ribbon and mini toolbar): typing lists the matching fonts (prefix first, then any part of the name); Up/Down, Enter or a click picks one, Escape cancels (#359) | | 2026-10-11 | trivial | Templates and Add-ins (template path, Attach…, automatically update document styles) and Organizer (copy styles from another file, delete, rename) dialogs (#377); styles update from the template only when the user asks (OK with the box ticked, or Update Styles), never on open; the three Modal dialogs rows merged into one | | 2026-10-11 | trivial | Field codes (#339): View › Field Codes (Alt+F9), Toggle Field Codes (Shift+F9), a field-code popup (double-click, right-click › Edit Field…; Ctrl+F9 opens the Field dialog), unlink/lock/unlock and next/previous field keys; field items in the context menu |