From 1b96e04b2c8c8e75c68d26ebd15749f70f2d05fa Mon Sep 17 00:00:00 2001 From: Dmitrii Khlapov Date: Mon, 12 Oct 2026 06:38:20 +0200 Subject: [PATCH 01/25] =?UTF-8?q?Add=20fonts=20at=20run=20time:=20Add=20Fo?= =?UTF-8?q?nt=E2=80=A6,=20drop=20a=20font=20file,=20font.add=20(#582)=20(#?= =?UTF-8?q?668)?= 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 8bab53cfb..853b8a797 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 c0b3b4b02..438cbc9ee 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 3dc8c82ce..1b1dd3de8 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 befd8b77f..a3e53cc13 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 a49a04d83..87076beaf 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 000000000..3a7726406 --- /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 141b56928..bd587a975 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 a194c9642..c0c2c843c 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 8b39ae927..3222d2036 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 000000000..703a86858 --- /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 62042a29e..5e8161a83 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 1c4446bea..2ec789301 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 000000000..fdc54c030 --- /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 b7aafa34a..9107da44f 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 dea01f87a..5b21cb4c0 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 000000000..ff9cee66e --- /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 e5ac6f981..15c9c2c8f 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 5cb79f515..cc7607767 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 3e793791d..939a8fb9a 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 8bc4f8b7d..12094d3fd 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 b1f39d3ec..d6fdc45d8 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 2217bde16..eeb92894f 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 72d356b0e..508e078f2 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 5bd9516d6..4db24be1e 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 6567ef753..810226cd0 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 e20cccd28..48b3c3ef4 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 148da1956..bbf6b3751 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 4bb497928..f0cb28108 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 ff55829b5..baf7301bc 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 464268e98..dbb70d4b8 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 47b7c6f1c..81d54f74a 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 cc9a48a51..2aa352443 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 5127d10e0..1fc566eb6 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 edb8fad80..0b51858b4 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 659d0f3ce..ac028bb3b 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 9b941f37c..616c91548 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 | From 219b125eeb362ff8947d854039faf707b3c006c6 Mon Sep 17 00:00:00 2001 From: Dmitrii Khlapov Date: Mon, 12 Oct 2026 06:41:03 +0200 Subject: [PATCH 02/25] #661: Table of Authorities buttons labelled, with Update Table (#693) Co-authored-by: Dmitrii Khlapov <19991457+dkhlapov@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas --- crates/engine/src/catalog.rs | 1 + crates/engine/src/cmd/citations.rs | 22 ++++++++++++++++++++++ crates/ui-egui/src/ribbon.rs | 14 +++++++++++++- docs/ui-parity.md | 3 ++- 4 files changed, 38 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/catalog.rs b/crates/engine/src/catalog.rs index 7efd4d893..92d2cdf98 100644 --- a/crates/engine/src/catalog.rs +++ b/crates/engine/src/catalog.rs @@ -255,6 +255,7 @@ References|Index|Insert Index|references.index References|Index|Update Index|references.updateIndex References|Table of Authorities|Mark Citation|references.markCitation References|Table of Authorities|Insert Table of Authorities|references.tableOfAuthorities +References|Table of Authorities|Update Table|references.updateAuthorities References|Fields|Update Fields|references.updateFields Mailings|Create|Envelopes|mailings.envelopes Mailings|Create|Labels|mailings.labels diff --git a/crates/engine/src/cmd/citations.rs b/crates/engine/src/cmd/citations.rs index 4b40c93c4..37857ed35 100644 --- a/crates/engine/src/cmd/citations.rs +++ b/crates/engine/src/cmd/citations.rs @@ -334,6 +334,10 @@ pub fn specs() -> Vec { generated_list(s, "TOA", "Table of Authorities")?; sel_result(s) }), + CommandSpec::new("references.updateAuthorities", "Update Table", "References › Table of Authorities", |s, _| { + update_generated(s)?; + sel_result(s) + }), CommandSpec::new("insert.crossReference", "Cross-reference", "Insert › Links", cross_ref) .params(r#"{"to": "heading|bookmark|figure|table", "target": string (text / name / number), "show"?: "text|page|number|aboveBelow"} or {} to list targets"#), CommandSpec::new("references.noteOptions", "Footnote and Endnote", "References › Footnotes", super::references::note_options).params( @@ -999,6 +1003,24 @@ mod tests { assert!(fields(&s).contains(&(format!("REF {name} \\h"), "Figure 1".to_string())), "{:?}", fields(&s)); } + /// #661: References › Table of Authorities › Update Table picks up citations marked after the table. + #[test] + fn table_of_authorities_updates() { + let mut s = Session::new(wordcraft_doc::Document::from_text("Smith v. Jones held it.\nDoe v. Roe agreed.")); + s.run("select.text", &json!({"text": "Smith v. Jones"})).unwrap(); + s.run("references.markCitation", &json!({})).unwrap(); + s.run("caret.docEnd", &json!({})).unwrap(); + s.run("text.newParagraph", &json!({})).unwrap(); + s.run("references.tableOfAuthorities", &json!({})).unwrap(); + let t = s.doc.plain_text(StoryRef::Body); + assert!(t.contains("Smith v. Jones") && !t.contains("Doe v. Roe, "), "{t}"); + s.run("select.text", &json!({"text": "Doe v. Roe"})).unwrap(); + s.run("references.markCitation", &json!({})).unwrap(); + s.run("references.updateAuthorities", &json!({})).unwrap(); + let after = s.doc.plain_text(StoryRef::Body); + assert!(after.matches("Doe v. Roe").count() > t.matches("Doe v. Roe").count(), "{after}"); + } + /// Field instructions and results in the body, in order. fn fields(s: &Session) -> Vec<(String, String)> { let mut out = Vec::new(); diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index 0b51858b4..a6418e374 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -1278,7 +1278,19 @@ fn references(app: &mut WordApp, ui: &mut Ui) { }); group(ui, "Table of Authorities", None, app, |ui, app| { big(ui, app, "markCitation", "Mark\nCitation", "references.markCitation", json!({}), false); - small(ui, app, "tableOfAuthorities", None, "Insert Table of Authorities", "references.tableOfAuthorities", json!({}), false); + stack(ui, |ui| { + small( + ui, + app, + "tableOfAuthorities", + Some("Insert Table of Authorities"), + "Insert Table of Authorities", + "references.tableOfAuthorities", + json!({}), + false, + ); + small(ui, app, "update", Some("Update Table"), "Update Table", "references.updateAuthorities", json!({}), false); + }); }); } diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 616c91548..004ccdf2b 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 (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 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** trivial (Table of Authorities buttons labelled, with Update Table, #661; previously 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 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 @@ -54,6 +54,7 @@ own (`AGENTS.md`: no Microsoft iconography, ever). | 2026-10-11 | trivial | Accessibility pane (errors/warnings/tips, jump-to, fixes) and Alt Text pane, opened from Picture/Shape Format › Accessibility and right-click › View Alt Text… (#386) | | 2026-10-11 | trivial | References dialogs: Caption (New/Delete Label, Numbering with chapter numbers), Index and Mark Index Entry (Mark All), Custom Table of Contents (Options, TOC styles via Modify Style), Source Manager and Create/Edit Source; modal dialog count 34 (#402) | | 2026-10-11 | trivial | Font dialog: Asian text font and Latin text font boxes, each with its own list and type-to-find, shown for a Chinese/Japanese interface or East Asian text in the selection; OK sets only the fonts changed; `format.font {"eastAsia"}` (#505) | +| 2026-10-11 | trivial | References › Table of Authorities: Insert Table of Authorities is labelled and joined by Update Table (`references.updateAuthorities`), as in the Index group (#661) | | 2026-10-11 | trivial | Customize Ribbon (custom tabs and groups, hidden and reordered tabs) and a customizable Quick Access Toolbar with a working … menu (#379, #376) | | 2026-10-11 | trivial | Customize Keyboard dialog (Tools › Customize Keyboard, File › Options › Keyboard shortcuts): custom keys override built-ins and persist (#368) | | 2026-10-11 | minor | Keyboard shortcuts 101 → 125 commands (#330): Reset Paragraph/Character Formatting, symbols, DATE/TIME/PAGE fields, view switches, Word Count, mail merge, F4 Repeat now reachable | From b2491a28dd323dd0a8c2eaf6b308478eedcd82a6 Mon Sep 17 00:00:00 2001 From: Dmitrii Khlapov Date: Mon, 12 Oct 2026 06:43:19 +0200 Subject: [PATCH 03/25] #657: input borders reach 3:1 contrast in both themes (#696) Co-authored-by: Dmitrii Khlapov <19991457+dkhlapov@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas --- crates/ui-egui/src/theme.rs | 28 ++++++++++++++++++++++++++-- docs/ui-parity.md | 3 ++- 2 files changed, 28 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index a6d330de7..b4901f67e 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -194,7 +194,7 @@ impl Tokens { accent_text: APP_INK, on_accent: Color32::WHITE, input: Color32::WHITE, - input_border: Color32::from_rgb(0xC7, 0xC7, 0xC7), + input_border: Color32::from_rgb(0x8A, 0x8A, 0x8A), group_label: Color32::from_rgb(0x70, 0x70, 0x70), ruler: Color32::from_rgb(0xFF, 0xFF, 0xFF), ruler_margin: Color32::from_rgb(0xD4, 0xD4, 0xD4), @@ -234,7 +234,7 @@ impl Tokens { accent_text: Color32::from_rgb(0x9C, 0xB0, 0xF5), on_accent: Color32::WHITE, input: Color32::from_rgb(0x1E, 0x1E, 0x1E), - input_border: Color32::from_rgb(0x58, 0x58, 0x58), + input_border: Color32::from_rgb(0x7A, 0x7A, 0x7A), group_label: Color32::from_rgb(0xA0, 0xA0, 0xA0), ruler: Color32::from_rgb(0x2E, 0x2E, 0x2E), ruler_margin: Color32::from_rgb(0x45, 0x45, 0x45), @@ -555,6 +555,30 @@ pub fn c32(c: wordcraft_doc::Rgb) -> Color32 { mod tests { use std::collections::BTreeSet; + /// WCAG contrast ratio between two opaque colours. + pub(super) fn contrast(a: egui::Color32, b: egui::Color32) -> f32 { + let lum = |c: egui::Color32| { + let ch = |v: u8| { + let v = f32::from(v) / 255.0; + if v <= 0.040_45 { v / 12.92 } else { ((v + 0.055) / 1.055).powf(2.4) } + }; + 0.2126 * ch(c.r()) + 0.7152 * ch(c.g()) + 0.0722 * ch(c.b()) + }; + let (x, y) = (lum(a), lum(b)); + (x.max(y) + 0.05) / (x.min(y) + 0.05) + } + + /// #657: text field and combo outlines reach 3:1 against the field and everything it sits on. + #[test] + fn input_borders_reach_three_to_one() { + for t in [super::Tokens::light(), super::Tokens::dark()] { + for (name, bg) in [("input", t.input), ("ribbon", t.ribbon), ("panel", t.panel), ("menu", t.menu), ("title bar", t.title_bar)] { + let r = contrast(t.input_border, bg); + assert!(r >= 3.0, "dark={}: input_border on {name} is {r:.2}:1", t.dark); + } + } + } + /// #565: the composition of an input method (Bangla through ibus) drew as boxes because only the /// interface fonts were used. Faces found for the uncovered characters are added as fallbacks, /// once, and not for characters the interface fonts draw. diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 004ccdf2b..7ef0126df 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 (Table of Authorities buttons labelled, with Update Table, #661; previously 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 (input borders reach 3:1, #657; 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 @@ -53,6 +53,7 @@ own (`AGENTS.md`: no Microsoft iconography, ever). | 2026-10-11 | trivial | Line Numbers, Hyphenation (with Manual Hyphenation), Language, Envelopes and Labels dialogs (#407); the three duplicated modal-dialog rows merged into one | | 2026-10-11 | trivial | Accessibility pane (errors/warnings/tips, jump-to, fixes) and Alt Text pane, opened from Picture/Shape Format › Accessibility and right-click › View Alt Text… (#386) | | 2026-10-11 | trivial | References dialogs: Caption (New/Delete Label, Numbering with chapter numbers), Index and Mark Index Entry (Mark All), Custom Table of Contents (Options, TOC styles via Modify Style), Source Manager and Create/Edit Source; modal dialog count 34 (#402) | +| 2026-10-11 | trivial | Text field and combo outlines reach 3:1 against their fill and surroundings in both themes (light #8A8A8A, dark #7A7A7A); a test pins the ratios (#657) | | 2026-10-11 | trivial | Font dialog: Asian text font and Latin text font boxes, each with its own list and type-to-find, shown for a Chinese/Japanese interface or East Asian text in the selection; OK sets only the fonts changed; `format.font {"eastAsia"}` (#505) | | 2026-10-11 | trivial | References › Table of Authorities: Insert Table of Authorities is labelled and joined by Update Table (`references.updateAuthorities`), as in the Index group (#661) | | 2026-10-11 | trivial | Customize Ribbon (custom tabs and groups, hidden and reordered tabs) and a customizable Quick Access Toolbar with a working … menu (#379, #376) | From 05679497ea2669c8d038ed895fb8909f2c7d321b Mon Sep 17 00:00:00 2001 From: naxvog Date: Mon, 12 Oct 2026 06:45:53 +0200 Subject: [PATCH 04/25] Pictures: WMF drawings fill their frame like Word plays them (#739) Word plays a WMF into the picture's box the way a GDI player does: the placeable header's bounds are only the first window, mapped onto a viewport of the picture's size, and the file's own SETWINDOWORG and SETWINDOWEXT records replace that window. WordCraft mapped the header's bounds through the file's final mapping instead, which broke three pictures in docxide-pdf's fixtures: - bulgarian_farmland_allocation_order: the coat of arms draws in a window centred on the origin, nowhere near the header's bounds, so the lion was blown up into a solid black box. - ukrainian_municipal_heating_resolution: a portrait window replaced the landscape bounds, so the emblem was drawn at a fifth of its size. - A plain WMF in anisotropic mode that sets a window but no viewport kept the 1 x 1 default viewport and shrank to a point; its first window is now its target. Files that set their own viewport, or use the fixed-scale map modes, are read as before. SETROP2 is now read too: with R2_NOP, shapes and paths draw nothing (the coat of arms first draws its bounding rectangle that way, which showed as an outline). The mode is saved and restored with the DC. Co-authored-by: Naxvog <4963183+naxvog@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas --- crates/metafile/src/canvas.rs | 53 ++++++++++++++++++++- crates/metafile/src/emf.rs | 1 + crates/metafile/src/tests.rs | 89 +++++++++++++++++++++++++++++++++++ crates/metafile/src/wmf.rs | 21 ++++++++- 4 files changed, 162 insertions(+), 2 deletions(-) diff --git a/crates/metafile/src/canvas.rs b/crates/metafile/src/canvas.rs index d553c8195..fa8bfdcf9 100644 --- a/crates/metafile/src/canvas.rs +++ b/crates/metafile/src/canvas.rs @@ -8,6 +8,7 @@ use crate::{MAX_BYTES, MAX_ITEMS, MAX_OBJECTS, MAX_PATH_OPS, MAX_STACK}; pub(crate) const MM_TEXT: u32 = 1; const MM_ISOTROPIC: u32 = 7; +const MM_ANISOTROPIC: u32 = 8; const STOCK: u32 = 0x8000_0000; pub(crate) const PS_NULL: u32 = 5; /// Pen style bits; the rest of the style word holds end-cap, join and type flags. @@ -15,6 +16,8 @@ pub(crate) const PS_STYLE_MASK: u32 = 0x0F; const BS_SOLID: u32 = 0; pub(crate) const BS_NULL: u32 = 1; const WINDING: u32 = 2; +/// SETROP2 mix mode that leaves the destination alone: outlines and fills draw nothing. +const R2_NOP: u32 = 11; const PATCOPY: u32 = 0x00F0_0021; const BLACKNESS: u32 = 0x0000_0042; const WHITENESS: u32 = 0x00FF_0062; @@ -135,6 +138,19 @@ impl Mapping { det * (sx.abs() + sy.abs()) / 2.0 } + /// True when the window-to-viewport extents set the scale (the isotropic and anisotropic modes). + pub fn uses_extents(&self) -> bool { + matches!(self.mode, MM_ISOTROPIC | MM_ANISOTROPIC) + } + + pub fn window_ext(&self) -> P { + self.we + } + + pub fn viewport_ext(&self) -> P { + self.ve + } + /// The window in device units as `[x, y, w, h]` with positive extents, used as the picture frame /// of a plain WMF. pub fn window_rect(&self) -> [f64; 4] { @@ -160,6 +176,7 @@ struct Saved { pen: Pen, brush: Brush, even_odd: bool, + nop: bool, } pub(crate) struct Canvas { @@ -167,6 +184,8 @@ pub(crate) struct Canvas { pub map: Mapping, /// True once SETWINDOWEXT was seen, so a plain WMF has a frame to use. pub window_set: bool, + /// True once the file set the viewport extent itself (not the player, see [`Canvas::play_into`]). + pub viewport_set: bool, saved: Vec, /// Bytes held by the items so far (path ops and pixels), capped at [`MAX_BYTES`]. used: usize, @@ -174,6 +193,8 @@ pub(crate) struct Canvas { pen: Pen, brush: Brush, even_odd: bool, + /// The mix mode is R2_NOP: shapes draw nothing. + nop: bool, pos: P, in_path: bool, path: Vec, @@ -186,12 +207,14 @@ impl Canvas { items: Vec::new(), map: Mapping::identity(), window_set: false, + viewport_set: false, saved: Vec::new(), used: 0, objects: Vec::new(), pen: Pen { style: 0, width: 0.0, color: [0, 0, 0, 255] }, brush: Brush { style: BS_SOLID, color: [255, 255, 255, 255] }, even_odd: true, + nop: false, pos: (0.0, 0.0), in_path: false, path: Vec::new(), @@ -232,6 +255,22 @@ impl Canvas { pub fn set_viewport_ext(&mut self, e: P) { self.map.ve = e; + self.viewport_set = true; + } + + /// Sets up the mapping the way a player does before it plays a WMF into a target: anisotropic, + /// `window` (`[x, y, w, h]` in logical units) onto a viewport of `size` at the origin. The file's + /// own window records then replace the window; the viewport stays unless the file sets one. + /// Without a window (a plain WMF) only the viewport extent is set: the map mode and viewport + /// origin stay the file's. + pub fn play_into(&mut self, window: Option<[f64; 4]>, size: P) { + if let Some([x, y, w, h]) = window { + self.map.mode = MM_ANISOTROPIC; + self.map.wo = (x, y); + self.map.we = (w, h); + self.map.vo = (0.0, 0.0); + } + self.map.ve = size; } pub fn set_map_mode(&mut self, mode: u32) { @@ -249,6 +288,11 @@ impl Canvas { self.even_odd = mode != WINDING; } + /// SETROP2: only R2_NOP changes what is drawn (nothing); the other mix modes draw as copy. + pub fn set_rop2(&mut self, mode: u32) { + self.nop = mode == R2_NOP; + } + /// EMF SETWORLDTRANSFORM (`mode` 4) and MODIFYWORLDTRANSFORM (1 identity, 2 left, 3 right, 4 set). /// An unknown mode leaves the world transform as it was. pub fn modify_world(&mut self, x: Xform, mode: u32) { @@ -265,7 +309,7 @@ impl Canvas { /// could no longer match the file. pub fn save_dc(&mut self) { if self.saved.len() < MAX_STACK { - self.saved.push(Saved { map: self.map, pen: self.pen, brush: self.brush, even_odd: self.even_odd }); + self.saved.push(Saved { map: self.map, pen: self.pen, brush: self.brush, even_odd: self.even_odd, nop: self.nop }); } else { self.full = true; } @@ -281,6 +325,7 @@ impl Canvas { self.pen = s.pen; self.brush = s.brush; self.even_odd = s.even_odd; + self.nop = s.nop; } /// Stores an object in `slot` (EMF index), or in the first free slot (WMF handle table). @@ -381,6 +426,9 @@ impl Canvas { if self.in_path { return self.push_path_ops(dev); } + if self.nop { + return; + } let fill = if fill { self.fill_color() } else { None }; let stroke = if stroke { self.stroke_pen() } else { None }; if (fill.is_none() && stroke.is_none()) || dev.len() < 2 { @@ -460,6 +508,9 @@ impl Canvas { /// FILLPATH, STROKEPATH and STROKEANDFILLPATH: paints the bracketed path and empties it. pub fn paint_path(&mut self, fill: bool, stroke: bool) { let ops = std::mem::take(&mut self.path); + if self.nop { + return; + } let fill = if fill { self.fill_color() } else { None }; let stroke = if stroke { self.stroke_pen() } else { None }; if ops.len() < 2 || (fill.is_none() && stroke.is_none()) { diff --git a/crates/metafile/src/emf.rs b/crates/metafile/src/emf.rs index 6f3248697..027cfae9f 100644 --- a/crates/metafile/src/emf.rs +++ b/crates/metafile/src/emf.rs @@ -88,6 +88,7 @@ fn record(cv: &mut Canvas, typ: u32, r: Bytes) { 12 => xy32(r, 8).map(|p| cv.set_viewport_org(p)), 17 => r.u32(8).map(|m| cv.set_map_mode(m)), 19 => r.u32(8).map(|m| cv.set_polyfill(m)), + 20 => r.u32(8).map(|m| cv.set_rop2(m)), 33 => { cv.save_dc(); Some(()) diff --git a/crates/metafile/src/tests.rs b/crates/metafile/src/tests.rs index 9fab9bea7..4ee32578e 100644 --- a/crates/metafile/src/tests.rs +++ b/crates/metafile/src/tests.rs @@ -927,3 +927,92 @@ fn take_pixels_returns_only_drawable_bitmaps() { assert!(pic(1.0, 2.0).has_size()); assert!(!pic(0.0, 2.0).has_size() && !pic(f32::NAN, 2.0).has_size() && !pic(1.0, f32::INFINITY).has_size()); } + +/// The rectangle a WMF RECTANGLE record covers, from its path's corners. +fn rect_extent(item: &Item) -> (f32, f32, f32, f32) { + let (segs, ..) = path_segs(item); + let pts: Vec<(f32, f32)> = segs + .iter() + .filter_map(|s| match s { + Seg::Move(x, y) | Seg::Line(x, y) => Some((*x, *y)), + _ => None, + }) + .collect(); + let (xs, ys): (Vec, Vec) = pts.into_iter().unzip(); + let lo = |v: &[f32]| v.iter().copied().fold(f32::MAX, f32::min); + let hi = |v: &[f32]| v.iter().copied().fold(f32::MIN, f32::max); + (lo(&xs), lo(&ys), hi(&xs), hi(&ys)) +} + +#[test] +fn plain_anisotropic_wmf_without_a_viewport_fills_its_window() { + // Anisotropic, window 200 x 100, no viewport (a logo strip saved without a placeable header): the + // window is the target, not a 1 x 1 viewport that squeezes the picture to a point. + let recs = [ + wrec(0x0103, &h16(8)), + wrec(0x020B, &cat(vec![h16(0), h16(0)])), + wrec(0x020C, &cat(vec![h16(100), h16(200)])), + wrec(0x041B, &cat(vec![h16(100), h16(200), h16(0), h16(0)])), + ]; + let p = parse(&wmf(&recs, None)).unwrap(); + assert_eq!((p.width, p.height), (200.0, 100.0)); + let (x0, y0, x1, y1) = rect_extent(&p.items[0]); + assert!(close(x0, 0.0) && close(y0, 0.0) && close(x1, 200.0) && close(y1, 100.0), "{x0} {y0} {x1} {y1}"); +} + +#[test] +fn placeable_wmf_window_records_replace_the_bounds() { + // The placeable bounds say 1440 square (72 points); the file then draws in a window centred on the + // origin, as a coat of arms saved by a drawing program does. The window maps onto the 72-point + // target, so the drawing fills it instead of being blown up around the bounds. + let recs = [ + wrec(0x020C, &cat(vec![h16(2000), h16(2000)])), + wrec(0x020B, &cat(vec![h16(-1000), h16(-1000)])), + wrec(0x041B, &cat(vec![h16(1000), h16(1000), h16(-1000), h16(-1000)])), + ]; + let p = parse(&wmf(&recs, Some([238, 539, 1678, 1979]))).unwrap(); + assert_eq!((p.width, p.height), (72.0, 72.0)); + let (x0, y0, x1, y1) = rect_extent(&p.items[0]); + assert!(close(x0, 0.0) && close(y0, 0.0) && close(x1, 72.0) && close(y1, 72.0), "{x0} {y0} {x1} {y1}"); +} + +#[test] +fn placeable_wmf_keeps_its_target_when_the_window_changes_shape() { + // Bounds 600 x 300 (30 x 15 points), then a portrait window 100 x 200 for a bitmap-sized drawing: + // the drawing still fills the target (Word stretches it to the picture's box). + let recs = [wrec(0x0103, &h16(8)), wrec(0x020C, &cat(vec![h16(200), h16(100)])), wrec(0x041B, &cat(vec![h16(200), h16(100), h16(0), h16(0)]))]; + let p = parse(&wmf(&recs, Some([0, 0, 600, 300]))).unwrap(); + assert_eq!((p.width, p.height), (30.0, 15.0)); + let (x0, y0, x1, y1) = rect_extent(&p.items[0]); + assert!(close(x0, 0.0) && close(y0, 0.0) && close(x1, 30.0) && close(y1, 15.0), "{x0} {y0} {x1} {y1}"); +} + +#[test] +fn rop2_nop_draws_nothing_until_the_mode_changes() { + // A coat of arms that first draws its bounding rectangle with the R2_NOP mix mode (no output), + // then its shapes with R2_COPYPEN. Restoring a saved state brings R2_NOP back. + let rect = |n: i32| wrec(0x041B, &cat(vec![h16(n), h16(n), h16(0), h16(0)])); + let recs = [wrec(0x0104, &h16(11)), rect(100), wrec(0x001E, &[]), wrec(0x0104, &h16(13)), rect(50), wrec(0x0127, &h16(-1)), rect(20)]; + let p = parse(&wmf(&recs, Some([0, 0, 100, 100]))).unwrap(); + assert_eq!(p.items.len(), 1, "{:?}", p.items); + let (_, _, x1, y1) = rect_extent(&p.items[0]); + // 50 of 100 units in a 5-point picture. + assert!(close(x1, 2.5) && close(y1, 2.5), "{x1} {y1}"); +} + +#[test] +fn plain_wmf_target_comes_from_a_window_that_was_read() { + // A window record too short to read, then the real one: the target is the real window, not the + // 1 x 1 default the short record left in place. A viewport origin set before it is kept. + let recs = [ + wrec(0x0103, &h16(8)), + wrec(0x020D, &cat(vec![h16(0), h16(0)])), + wrec(0x020C, &[]), + wrec(0x020C, &cat(vec![h16(100), h16(200)])), + wrec(0x041B, &cat(vec![h16(100), h16(200), h16(0), h16(0)])), + ]; + let p = parse(&wmf(&recs, None)).unwrap(); + assert_eq!((p.width, p.height), (200.0, 100.0)); + let (x0, y0, x1, y1) = rect_extent(&p.items[0]); + assert!(close(x0, 0.0) && close(y0, 0.0) && close(x1, 200.0) && close(y1, 100.0), "{x0} {y0} {x1} {y1}"); +} diff --git a/crates/metafile/src/wmf.rs b/crates/metafile/src/wmf.rs index e051471d8..d221a7655 100644 --- a/crates/metafile/src/wmf.rs +++ b/crates/metafile/src/wmf.rs @@ -37,7 +37,13 @@ pub(crate) fn parse(input: Bytes) -> Result<(Canvas, Option), Error> { return Err(Error::BadHeader); } let mut pos = hdr.checked_add(hsize.checked_mul(2).ok_or(Error::BadHeader)?).ok_or(Error::BadHeader)?; + // Played into a target like Word does: the placeable bounds are the first window, the target (the + // picture size) the viewport. A plain WMF's target is its first window. let mut cv = Canvas::new(); + if let Some((c, size)) = bounds { + cv.play_into(Some([c[0], c[1], c[2] - c[0], c[3] - c[1]]), size); + } + let mut player_viewport = bounds.is_some(); let mut parsed = 0usize; while !cv.full() { let (Some(words), Some(func)) = (input.u32(pos), pos.checked_add(4).and_then(|o| input.u16(o))) else { break }; @@ -47,6 +53,11 @@ pub(crate) fn parse(input: Bytes) -> Result<(Canvas, Option), Error> { break; } record(&mut cv, func, Bytes(rec)); + if func == 0x020C && cv.window_set && !player_viewport && !cv.viewport_set { + let we = cv.map.window_ext(); + cv.play_into(None, (we.0.abs(), we.1.abs())); + player_viewport = true; + } parsed += 1; let Some(next) = pos.checked_add(len) else { break }; pos = next; @@ -55,12 +66,19 @@ pub(crate) fn parse(input: Bytes) -> Result<(Canvas, Option), Error> { return Err(Error::NoRecords); } let frame = match bounds { - // The drawing is in device units, so the logical bounds go through the final mapping. + // Played into its target: the frame is the target. + Some((_, size)) if !cv.viewport_set && cv.map.uses_extents() => Some(Frame { rect: [0.0, 0.0, size.0, size.1], size: Some(size) }), + // The file set its own viewport, so the drawing is in its device units and the logical bounds + // go through the final mapping. Some((c, size)) => { let (a, b) = (cv.map.device((c[0], c[1])), cv.map.device((c[2], c[3]))); let rect = [a.0.min(b.0), a.1.min(b.1), (b.0 - a.0).abs(), (b.1 - a.1).abs()]; (rect[2] > 0.0 && rect[3] > 0.0).then_some(Frame { rect, size: Some(size) }) } + None if player_viewport && !cv.viewport_set && cv.map.uses_extents() => { + let ve = cv.map.viewport_ext(); + Some(Frame { rect: [0.0, 0.0, ve.0, ve.1], size: None }) + } None if cv.window_set => Some(Frame { rect: cv.map.window_rect(), size: None }), None => None, }; @@ -74,6 +92,7 @@ fn record(cv: &mut Canvas, func: u16, r: Bytes) { let _ = match func { 0x0103 => r.u16(6).map(|m| cv.set_map_mode(u32::from(m))), 0x0106 => r.u16(6).map(|m| cv.set_polyfill(u32::from(m))), + 0x0104 => r.u16(6).map(|m| cv.set_rop2(u32::from(m))), // Y is stored before X in these records. 0x020B => xy16(r, 6).map(|p| cv.set_window_org(p)), 0x020C => xy16(r, 6).map(|p| cv.set_window_ext(p)), From 4fb9c2cb2ac390d588b164c62d1eca983bd03462 Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Mon, 12 Oct 2026 06:48:02 +0200 Subject: [PATCH 05/25] Ink: draw the live pen stroke in the dark-page colour while drawing, not only after lifting the pen (#730) (#746) Co-authored-by: Brandon Thomas --- crates/ui-egui/src/ink.rs | 30 +++++++++++++++++++++++++++--- 1 file changed, 27 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/ink.rs b/crates/ui-egui/src/ink.rs index a1b227382..2eebb6bf7 100644 --- a/crates/ui-egui/src/ink.rs +++ b/crates/ui-egui/src/ink.rs @@ -215,6 +215,14 @@ fn to_screen(rects: &[Rect], layout: &DocLayout, scale: f32, page: usize) -> Opt Some(move |[x, y]: [f32; 2]| pos2(r.min.x + x * s, r.min.y + y * s)) } +/// The on-screen colour of ink: on a dark page (View > Switch Modes) it is mapped like every other +/// document colour, so a stroke looks the same while it is drawn as after it is saved. +fn screen_ink(dark: bool, c: wordcraft_doc::Rgb, alpha: u8) -> Color32 { + let opts = wordcraft_render::RenderOptions { dark, ..Default::default() }; + let wordcraft_doc::Rgb(r, g, b) = opts.ink(c); + Color32::from_rgba_unmultiplied(r, g, b, alpha) +} + /// The live stroke, the lasso and its selection, and the replayed ink, drawn over the pages. pub fn paint(app: &WordApp, painter: &Painter, rects: &[Rect], layout: &DocLayout, scale: f32) { paint_replay(app, painter, rects, layout, scale); @@ -224,7 +232,7 @@ pub fn paint(app: &WordApp, painter: &Painter, rects: &[Rect], layout: &DocLayou let s = page_scale(rects, layout, scale, d.page).unwrap_or(scale); let set = app.session.view.draw.settings(d.tool); let alpha = (d.tool.alpha() * 255.0).round() as u8; - let color = Color32::from_rgba_unmultiplied(set.color.0, set.color.1, set.color.2, alpha); + let color = screen_ink(app.session.view.dark_mode, set.color, alpha); let pts: Vec = d.pts.iter().map(|p| map(*p)).collect(); let width = (set.width * s).max(1.0); if let [only] = pts.as_slice() { @@ -331,6 +339,8 @@ fn paint_replay(app: &WordApp, painter: &Painter, rects: &[Rect], layout: &DocLa let s = page_scale(rects, layout, scale, k.page).unwrap_or(scale); let frac = if k.dur > 0.0 { ((r.t - k.start) / k.dur).clamp(0.0, 1.0) } else { 1.0 }; let total: f32 = k.paths.iter().map(|p| path_len(p)).sum(); + let [cr, cg, cb, ca] = k.color.to_srgba_unmultiplied(); + let color = screen_ink(app.session.view.dark_mode, wordcraft_doc::Rgb(cr, cg, cb), ca); let mut left = total * frac; let width = (k.width * s).max(1.0); for path in &k.paths { @@ -353,11 +363,11 @@ fn paint_replay(app: &WordApp, painter: &Painter, rects: &[Rect], layout: &DocLa } match (pts.as_slice(), path.as_slice()) { (_, [only]) => { - painter.circle_filled(map(*only), width / 2.0, k.color); + painter.circle_filled(map(*only), width / 2.0, color); } ([], _) => {} _ => { - painter.add(egui::Shape::line(pts, Stroke::new(width, k.color))); + painter.add(egui::Shape::line(pts, Stroke::new(width, color))); } } if left <= 0.0 { @@ -428,3 +438,17 @@ pub fn replay_bar(app: &mut WordApp, ui: &Ui, area: Rect) { } ui.ctx().request_repaint(); } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn live_ink_follows_the_dark_page() { + let black = wordcraft_doc::Rgb(0, 0, 0); + assert_eq!(screen_ink(false, black, 255), Color32::from_rgba_unmultiplied(0, 0, 0, 255)); + // Black pen on a dark page turns light, like the saved stroke. + let dark = screen_ink(true, black, 128); + assert_eq!(dark, Color32::from_rgba_unmultiplied(255, 255, 255, 128)); + } +} From cad4138e7ed26da6d9c44bc07b8ebe39a9eb4d8f Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Mon, 12 Oct 2026 06:50:14 +0200 Subject: [PATCH 06/25] Backspace still erases while Shift is held (#707) (#747) Co-authored-by: Brandon Thomas --- crates/ui-egui/src/keys.rs | 25 ++++++++++++++++++++++--- 1 file changed, 22 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/keys.rs b/crates/ui-egui/src/keys.rs index 00d9a4bf7..26e78d019 100644 --- a/crates/ui-egui/src/keys.rs +++ b/crates/ui-egui/src/keys.rs @@ -112,17 +112,29 @@ fn dispatch(app: &mut WordApp, key: Key, m: Modifiers) -> bool { let _ = app.run(spec.id, json!({})); return true; } - // Shift extends caret movement. + // Shift extends caret movement; Shift+Backspace still erases (#707). if m.shift && let Some(spec) = app.session.keymap.resolve(®, &combo(m, name, false)) - && spec.id.starts_with("caret.") + && let Some(params) = shift_variant_params(spec.id) { - let _ = app.run(spec.id, json!({"extend": true})); + let _ = app.run(spec.id, params); return true; } false } +/// How a command bound to a plain key runs when Shift is also held and nothing is bound to the +/// shifted combo: caret moves extend the selection, Backspace erases as usual (#707). +fn shift_variant_params(id: &str) -> Option { + if id.starts_with("caret.") { + Some(json!({"extend": true})) + } else if id == "text.backspace" { + Some(json!({})) + } else { + None + } +} + /// F1–F12: shortcuts on these work without the canvas focused. fn is_function_key(key: Key) -> bool { matches!(key, Key::F1 | Key::F2 | Key::F3 | Key::F4 | Key::F5 | Key::F6 | Key::F7 | Key::F8 | Key::F9 | Key::F10 | Key::F11 | Key::F12) @@ -322,6 +334,13 @@ pub fn global_shortcuts(app: &mut WordApp, ctx: &egui::Context) { mod tests { use super::*; + #[test] + fn shift_backspace_still_erases() { + assert_eq!(shift_variant_params("text.backspace"), Some(json!({}))); + assert_eq!(shift_variant_params("caret.left"), Some(json!({"extend": true}))); + assert_eq!(shift_variant_params("text.delete"), None); + } + #[test] fn only_plain_paste_releases_paste_a_clipboard_picture() { let m = |command, shift, alt| egui::Modifiers { command, shift, alt, ..Default::default() }; From 1490d4e594093846c403bf3a6fdb5165b2db6823 Mon Sep 17 00:00:00 2001 From: Dmitrii Khlapov Date: Mon, 12 Oct 2026 06:51:53 +0200 Subject: [PATCH 07/25] Dark theme: readable text on the accent (#655) (#704) * #657: input borders reach 3:1 contrast in both themes Co-Authored-By: Claude Opus 5.5 * #655: dark-theme accent controls use dark on_accent text (6:1) 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 --- crates/ui-egui/src/chrome.rs | 4 ++-- crates/ui-egui/src/ribbon.rs | 6 +++--- crates/ui-egui/src/theme.rs | 15 ++++++++++++++- docs/ui-parity.md | 3 ++- 4 files changed, 21 insertions(+), 7 deletions(-) diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs index 69c4090cd..a93a22cb6 100644 --- a/crates/ui-egui/src/chrome.rs +++ b/crates/ui-egui/src/chrome.rs @@ -132,7 +132,7 @@ pub fn title_bar(app: &mut WordApp, ui: &mut Ui) { let (r, resp) = ui.allocate_exact_size(vec2(30.0, 16.0), Sense::click()); let (fill, stroke, knob_fill) = match (&block, on) { (Some(_), _) => (t.input.gamma_multiply(0.5), t.border.gamma_multiply(0.7), t.text_dim.gamma_multiply(0.45)), - (None, true) => (t.accent, t.accent, egui::Color32::WHITE), + (None, true) => (t.accent, t.accent, t.on_accent), (None, false) => (t.input, t.border_strong, t.text_dim), }; ui.painter().rect(r, 8.0, fill, Stroke::new(1.0, stroke), egui::StrokeKind::Inside); @@ -161,7 +161,7 @@ pub fn title_bar(app: &mut WordApp, ui: &mut Ui) { let (r, resp) = ui.allocate_exact_size(vec2(control_h, control_h), Sense::click()); ui.painter().circle_filled(r.center(), control_h / 2.0 - 1.0, t.accent); let initials: String = app.session.user_initials().chars().take(2).collect(); - ui.painter().text(r.center(), Align2::CENTER_CENTER, initials, semibold(10.5), egui::Color32::WHITE); + ui.painter().text(r.center(), Align2::CENTER_CENTER, initials, semibold(10.5), t.on_accent); resp.on_hover_text(format!("{} — set your name in File › Options", app.session.author)); ui.add_space(8.0); if app.ui.show_discord { diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index a6418e374..374ebf914 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -163,10 +163,10 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) { ui.painter(), Rect::from_center_size(pos2(r.min.x + 14.0, r.center().y), vec2(14.0, 14.0)), "share", - egui::Color32::WHITE, - egui::Color32::WHITE, + t.on_accent, + t.on_accent, ); - ui.painter().text(pos2(r.min.x + 26.0, r.center().y), Align2::LEFT_CENTER, share, medium(12.0), egui::Color32::WHITE); + ui.painter().text(pos2(r.min.x + 26.0, r.center().y), Align2::LEFT_CENTER, share, medium(12.0), t.on_accent); if resp.on_hover_text(tl!("Export a copy to share (PDF, Word document)")).clicked() { let _ = app.run("ui.backstage", json!({"value": true, "page": "export"})); } diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index b4901f67e..27c18e399 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -232,7 +232,8 @@ impl Tokens { checked: Color32::from_rgb(0x2E, 0x3A, 0x66), accent: Color32::from_rgb(0x7A, 0x93, 0xF0), accent_text: Color32::from_rgb(0x9C, 0xB0, 0xF5), - on_accent: Color32::WHITE, + // Dark text on the light dark-theme accent: white on #7a93f0 is only 2.9:1 (#655). + on_accent: Color32::from_rgb(0x11, 0x16, 0x2E), input: Color32::from_rgb(0x1E, 0x1E, 0x1E), input_border: Color32::from_rgb(0x7A, 0x7A, 0x7A), group_label: Color32::from_rgb(0xA0, 0xA0, 0xA0), @@ -568,6 +569,18 @@ mod tests { (x.max(y) + 0.05) / (x.min(y) + 0.05) } + /// #655: text and knobs on filled accent controls (Share, avatar, AutoSave, keytips) reach 4.5:1 + /// on the accent and on its hover shade, in both themes. + #[test] + fn on_accent_reads_on_the_accent() { + for t in [super::Tokens::light(), super::Tokens::dark()] { + for (name, bg) in [("accent", t.accent), ("hover", t.accent_text)] { + let r = contrast(t.on_accent, bg); + assert!(r >= 4.5, "dark={}: on_accent on {name} is {r:.2}:1", t.dark); + } + } + } + /// #657: text field and combo outlines reach 3:1 against the field and everything it sits on. #[test] fn input_borders_reach_three_to_one() { diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 7ef0126df..eda6b7ca2 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 (input borders reach 3:1, #657; 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 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** trivial (dark-theme accent controls readable, #655; previously trivial: input borders reach 3:1, #657; 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 @@ -53,6 +53,7 @@ own (`AGENTS.md`: no Microsoft iconography, ever). | 2026-10-11 | trivial | Line Numbers, Hyphenation (with Manual Hyphenation), Language, Envelopes and Labels dialogs (#407); the three duplicated modal-dialog rows merged into one | | 2026-10-11 | trivial | Accessibility pane (errors/warnings/tips, jump-to, fixes) and Alt Text pane, opened from Picture/Shape Format › Accessibility and right-click › View Alt Text… (#386) | | 2026-10-11 | trivial | References dialogs: Caption (New/Delete Label, Numbering with chapter numbers), Index and Mark Index Entry (Mark All), Custom Table of Contents (Options, TOC styles via Modify Style), Source Manager and Create/Edit Source; modal dialog count 34 (#402) | +| 2026-10-11 | trivial | Dark theme: Share, the avatar initials, the AutoSave knob and keytips use dark `on_accent` text on the light accent (6.2:1, 8.4:1 on hover; white was 2.9:1) (#655) | | 2026-10-11 | trivial | Text field and combo outlines reach 3:1 against their fill and surroundings in both themes (light #8A8A8A, dark #7A7A7A); a test pins the ratios (#657) | | 2026-10-11 | trivial | Font dialog: Asian text font and Latin text font boxes, each with its own list and type-to-find, shown for a Chinese/Japanese interface or East Asian text in the selection; OK sets only the fonts changed; `format.font {"eastAsia"}` (#505) | | 2026-10-11 | trivial | References › Table of Authorities: Insert Table of Authorities is labelled and joined by Update Table (`references.updateAuthorities`), as in the Index group (#661) | From 92d6165d4cdcc61546888408b2d1e01f6b45e0b4 Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Mon, 12 Oct 2026 06:54:25 +0200 Subject: [PATCH 08/25] Let dialogs be dragged by their title bar instead of pinning them to the screen centre (#732) (#748) * Let dialogs be dragged by their title bar instead of pinning them to the screen centre (#732) * rustfmt after merging main --------- Co-authored-by: Brandon Thomas --- crates/ui-egui/src/dialogs.rs | 79 +++++++++++++++++++++++++++-------- 1 file changed, 62 insertions(+), 17 deletions(-) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 8f4d340e1..d6654a884 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -1242,35 +1242,41 @@ pub fn table_grid_picker(ui: &mut Ui, app: &mut WordApp) { } } -/// Where the open dialog's top edge sits, as (window id, offset below the top of the screen). +/// The open dialog, as (window id, frames it has been centred for). fn dialog_top_key() -> egui::Id { egui::Id::new("dialog_top") } -/// Anchors a dialog: centred on the frame it opens, then with its top edge held where that put -/// it, so a taller or shorter tab (Paragraph, Font, Borders and Shading…) only moves the bottom -/// edge and the tab strip stays under the mouse (#507). egui keeps it on screen if it grows past -/// the bottom. +/// Frames a dialog stays centred: the first one measures it, the second centres that size. +const DIALOG_CENTRED_FRAMES: u8 = 2; + +/// Places a dialog: centred for its first frames, then left where it is with its top-centre point +/// as the pivot, so a taller or shorter tab (Paragraph, Font, Borders and Shading…) only moves +/// the bottom edge and the tab strip stays under the mouse (#507). It is no longer anchored after +/// that, so dragging the title bar moves it (#732). egui keeps it on screen if it grows past the +/// bottom. fn place_dialog<'a>(ctx: &egui::Context, id: egui::Id, w: egui::Window<'a>) -> egui::Window<'a> { - match ctx.data(|d| d.get_temp::<(egui::Id, f32)>(dialog_top_key())) { - Some((open, top)) if open == id => w.anchor(egui::Align2::CENTER_TOP, vec2(0.0, top)), + let w = w.pivot(egui::Align2::CENTER_TOP); + match ctx.data(|d| d.get_temp::<(egui::Id, u8)>(dialog_top_key())) { + Some((open, frames)) if open == id && frames >= DIALOG_CENTRED_FRAMES => w, _ => w.anchor(egui::Align2::CENTER_CENTER, vec2(0.0, -40.0)), } } -/// After a dialog's first frame: holds its top edge where centring its measured size put it. -fn remember_dialog_top(ctx: &egui::Context, id: egui::Id, rect: egui::Rect) { - if ctx.data(|d| d.get_temp::<(egui::Id, f32)>(dialog_top_key())).is_some_and(|(open, _)| open == id) { - return; +/// After a frame of a dialog: counts the frames it has been centred for. +fn count_dialog_frame(ctx: &egui::Context, id: egui::Id) { + let frames = match ctx.data(|d| d.get_temp::<(egui::Id, u8)>(dialog_top_key())) { + Some((open, frames)) if open == id => frames, + _ => 0, + }; + if frames < DIALOG_CENTRED_FRAMES { + ctx.data_mut(|d| d.insert_temp(dialog_top_key(), (id, frames + 1))); } - let screen = ctx.content_rect(); - let top = (screen.center().y - 40.0 - rect.height() / 2.0 - screen.top()).max(0.0); - ctx.data_mut(|d| d.insert_temp(dialog_top_key(), (id, top))); } pub fn show(app: &mut WordApp, ctx: &egui::Context) { let Some(mut d) = app.dialog.take() else { - ctx.data_mut(|d| d.remove::<(egui::Id, f32)>(dialog_top_key())); + ctx.data_mut(|d| d.remove::<(egui::Id, u8)>(dialog_top_key())); return; }; let mut open = true; @@ -1333,8 +1339,8 @@ pub fn show(app: &mut WordApp, ctx: &egui::Context) { ui.set_min_width(340.0); close = body(app, ui, &mut d); }); - if let Some(r) = shown { - remember_dialog_top(ctx, id, r.response.rect); + if shown.is_some() { + count_dialog_frame(ctx, id); } if open && !close { app.dialog = Some(d); @@ -3414,4 +3420,43 @@ mod tests { let c = app.session.doc.para_at(&wordcraft_doc::Pos::body(0, 0)).map(|p| p.props_of_char(0).clone()).unwrap_or_default(); assert_eq!((c.small_caps, c.caps, c.size), (Some(false), Some(true), Some(14.0))); } + + /// A dialog was pinned by its anchor, so dragging its title bar did nothing (#732). + #[test] + fn dragging_a_dialog_title_bar_moves_it() { + let mut app = app_with("Some text"); + app.dialog = Dialog::open("paragraph", &mut app); + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1440.0, 900.0)).build_ui_state( + |ui, app: &mut WordApp| { + let ctx = ui.ctx().clone(); + app.logic(&ctx); + app.ui(ui); + }, + app, + ); + for _ in 0..4 { + h.step(); + } + let id = egui::Id::new(("dialog", "Paragraph")); + let before = h.ctx.memory(|m| m.area_rect(id)).expect("dialog is open"); + let screen = h.ctx.content_rect(); + assert!((before.center().x - screen.center().x).abs() <= 1.0, "opens centred: {before:?} in {screen:?}"); + let grab = egui::pos2(before.center().x, before.top() + 10.0); + let drop = grab + vec2(-200.0, 120.0); + let button = + |pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE }; + h.event(egui::Event::PointerMoved(grab)); + h.step(); + h.event(button(grab, true)); + h.step(); + h.event(egui::Event::PointerMoved(drop)); + h.step(); + h.event(button(drop, false)); + for _ in 0..4 { + h.step(); + } + let after = h.ctx.memory(|m| m.area_rect(id)).expect("dialog is still open"); + assert!((after.min.x - (before.min.x - 200.0)).abs() <= 1.0, "moved left: {before:?} -> {after:?}"); + assert!((after.min.y - (before.min.y + 120.0)).abs() <= 1.0, "moved down: {before:?} -> {after:?}"); + } } From 37318985b570527bad2c5d908dd2e718609585fc Mon Sep 17 00:00:00 2001 From: naxvog Date: Mon, 12 Oct 2026 06:58:37 +0200 Subject: [PATCH 09/25] Layout: multiple line spacing adds its leading below the text, like Word (#719) Benchmarked against docxide-pdf's 390 Word-referenced fixtures, the largest systematic gap was the vertical position of text: WordCraft put the baseline one descent above the bottom of the line box, so all the extra leading of 1.15 or double spacing went above the text (1.8pt at 1.15-spaced 12pt). Word keeps the text one ascent below the line top and puts the extra leading below it; below single spacing it shrinks the whole box. The hhea line gap now counts toward the ascent instead of being split half above, half below. Line heights stay the same, but every descent is half a line gap smaller, so text in exact, at-least and grid-snapped lines (which keep the descent below the baseline or centre the glyph box) sits up to half a line gap lower: 0.25pt for 12pt Times New Roman. On the suite that brings those lines closer to Word as well (exact: 5 fixtures better, 1 worse; at-least: 5 better, none worse). On the suite: mean Jaccard 29.2 -> 33.5%, SSIM 49.4 -> 50.3%, median baseline offset from Word +0.68 -> -0.06pt, documents within 1pt 35 -> 51%. Co-authored-by: Naxvog <4963183+naxvog@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas --- crates/fonts/src/word.rs | 30 ++++++++++++++++++++++-------- crates/layout/src/para.rs | 16 ++++++++++++++-- crates/layout/src/tests.rs | 34 ++++++++++++++++++++++++++++++++++ 3 files changed, 70 insertions(+), 10 deletions(-) diff --git a/crates/fonts/src/word.rs b/crates/fonts/src/word.rs index cc7607767..66ef6a7ac 100644 --- a/crates/fonts/src/word.rs +++ b/crates/fonts/src/word.rs @@ -235,9 +235,15 @@ pub fn symbol_font_char(family: &str, c: char) -> Option { Some(u) } +/// A line's (ascent, descent) from hhea values, both positive: the line gap goes above the ascent. +fn hhea_line(ascender: i16, descender: i16, line_gap: i16) -> (f64, f64) { + (f64::from(ascender) + f64::from(line_gap.max(0)), f64::from(descender).abs()) +} + /// Line metrics for a face in font units: (ascent, descent), both positive, the way word -/// processors measure single line spacing (OS/2 usWinAscent/usWinDescent, falling back to the -/// hhea values plus line gap). +/// processors measure single line spacing: the hhea values with the line gap above the ascent +/// (Word puts the first baseline that far below the line top), falling back to OS/2 +/// usWinAscent/usWinDescent. pub fn line_metrics(face: &crate::FontFace) -> (f64, f64) { static CACHE: OnceLock>> = OnceLock::new(); let cache = CACHE.get_or_init(|| Mutex::new(HashMap::new())); @@ -246,12 +252,9 @@ pub fn line_metrics(face: &crate::FontFace) -> (f64, f64) { } let mut v = (face.ascent.max(1.0), face.descent.abs()); if let Some(f) = face.skrifa() { - // hhea ascender + descender + line gap (Word on macOS); OS/2 win metrics only when the - // hhea values are missing, and never more than twice the em. - let hhea = f.hhea().ok().map(|h| { - let gap = h.line_gap().to_i16().max(0) as f64; - (h.ascender().to_i16() as f64 + gap / 2.0, (h.descender().to_i16() as f64).abs() + gap / 2.0) - }); + // hhea ascender + descender + line gap (Word on macOS), the gap above the ascent; OS/2 + // win metrics only when the hhea values are missing, and never more than twice the em. + let hhea = f.hhea().ok().map(|h| hhea_line(h.ascender().to_i16(), h.descender().to_i16(), h.line_gap().to_i16())); let win = f.os2().ok().map(|o| (o.us_win_ascent() as f64, o.us_win_descent() as f64)); let ok = |m: &(f64, f64)| m.0 > 0.0 && m.0 + m.1 <= face.upem * 2.0; if let Some(m) = hhea.filter(ok) { @@ -268,6 +271,17 @@ pub fn line_metrics(face: &crate::FontFace) -> (f64, f64) { mod tests { use super::*; + #[test] + fn the_line_gap_goes_above_the_ascent() { + // Times New Roman (hhea 1825 / -443, gap 87), and a font with a large gap: Word puts the + // first baseline the ascent plus the whole gap below the line top. + assert_eq!(hhea_line(1825, -443, 87), (1912.0, 443.0)); + assert_eq!(hhea_line(1536, -512, 452), (1988.0, 512.0)); + // A negative gap adds nothing; the most negative descender doesn't overflow. + assert_eq!(hhea_line(1000, -300, -50), (1000.0, 300.0)); + assert_eq!(hhea_line(1000, i16::MIN, 0), (1000.0, 32768.0)); + } + #[test] fn unknown_family_substitutes() { let r = resolve("Definitely Not A Font 123", false, false); diff --git a/crates/layout/src/para.rs b/crates/layout/src/para.rs index c2a14486c..93483ea3a 100644 --- a/crates/layout/src/para.rs +++ b/crates/layout/src/para.rs @@ -1711,7 +1711,7 @@ fn break_lines(pl: &mut ParaLayout, env: &ParaEnv, mark_style: u16, level: Optio let up = asc.max(obj_asc); top + ((height - up - desc) / 2.0).max(0.0) + up } else { - top + height - desc + top + baseline_offset(rp.line_spacing, asc, height, desc).max(obj_asc.min(height - desc)) }; // Alignment / justification (trailing spaces hang). @@ -1840,7 +1840,7 @@ fn break_lines(pl: &mut ParaLayout, env: &ParaEnv, mark_style: u16, level: Optio lines.push(Line { top, height: h, - baseline: top + h - desc, + baseline: top + if grid.is_some() { h - desc } else { baseline_offset(rp.line_spacing, asc, h, desc) }, c0: n, c1: n, xs: vec![x0], @@ -1958,6 +1958,18 @@ fn reorder_line(pl: &ParaLayout, line: &mut Line, width: f32) { /// paragraph beside a floating table in a 21.3pt gap but not in an 18.6pt one. const MIN_SPAN: f32 = 20.0; +/// How far below a line's top its baseline sits. Multiple spacing at or above single keeps the +/// text at the top of the line and puts the extra leading below the baseline; below single it +/// shrinks the whole box, ascent included (Word's output for docxide-pdf's fixtures: 1.15-spaced +/// Times New Roman 12pt sits one ascent below the line top, not 1.8pt lower). Exact and at-least +/// spacing keep the descent below the baseline and the rest above it. +fn baseline_offset(spacing: LineSpacing, asc: f32, height: f32, desc: f32) -> f32 { + match spacing { + LineSpacing::Multiple(m) => asc * m.min(1.0), + LineSpacing::AtLeast(_) | LineSpacing::Exactly(_) => height - desc, + } +} + /// The spans of a row at `top` (about `h` tall) between `lo` and `hi` that text can use around /// the exclusions, left to right (Square wrapping uses both sides of an object). `Err(y)`: none /// here, try again at `y` (below what's in the way). diff --git a/crates/layout/src/tests.rs b/crates/layout/src/tests.rs index bd29cffff..5714b3ca7 100644 --- a/crates/layout/src/tests.rs +++ b/crates/layout/src/tests.rs @@ -2010,6 +2010,40 @@ fn line_spacing_does_not_scale_pictures() { assert!((line.baseline - line.top - 144.0).abs() < 0.01, "the picture stands on the baseline"); } +#[test] +fn multiple_line_spacing_adds_its_leading_below_the_text() { + use wordcraft_doc::props::LineSpacing; + // (spacing, expected baseline below the line top as a share of the ascent) + for (spacing, share) in [(LineSpacing::Multiple(1.15), 1.0), (LineSpacing::Multiple(2.0), 1.0), (LineSpacing::Multiple(0.8), 0.8)] { + let mut d = Document::from_text(&"Several words wrap onto more lines. ".repeat(30)); + if let Some(wordcraft_doc::Block::Para(p)) = d.body.get_mut(0).map(std::sync::Arc::make_mut) { + p.props.line_spacing = Some(spacing); + } + let l = lay(&d); + let Some(Placed::Lines { para, .. }) = l.pages[0].items.iter().find(|i| matches!(i, Placed::Lines { .. })) else { panic!() }; + let asc = para.styles[0].ascent; + assert!(para.lines.len() > 1); + for line in ¶.lines { + // Word's 1.15-spaced Times New Roman 12pt sits one ascent below the line top; the + // 0.15 extra goes below the descent (docxide-pdf's hyphenation fixtures). + assert!( + (line.baseline - line.top - asc * share).abs() < 0.01, + "{spacing:?}: baseline {} below the top, ascent {asc}", + line.baseline - line.top + ); + } + } + // Exact spacing still keeps the descent below the baseline. + let mut d = Document::from_text("Exact"); + if let Some(wordcraft_doc::Block::Para(p)) = d.body.get_mut(0).map(std::sync::Arc::make_mut) { + p.props.line_spacing = Some(LineSpacing::Exactly(30.0)); + } + let l = lay(&d); + let Some(Placed::Lines { para, .. }) = l.pages[0].items.iter().find(|i| matches!(i, Placed::Lines { .. })) else { panic!() }; + let line = ¶.lines[0]; + assert!((line.top + line.height - line.baseline - para.styles[0].descent).abs() < 0.01); +} + #[test] fn body_starts_right_below_a_tall_header() { let mut d = Document::from_text("Body"); From 9a58b5ba331511927def98ef889fa49c8abe6e75 Mon Sep 17 00:00:00 2001 From: Debarghaya Mitra <175645893+GhostMitra@users.noreply.github.com> Date: Mon, 12 Oct 2026 10:34:33 +0530 Subject: [PATCH 10/25] Web: add wordcraft.ico as favicon via Trunk (#717) Registers assets/app-icon/wordcraft.ico in apps/wordcraft-web/index.html using Trunk's rel=icon asset pipeline. Co-authored-by: Brandon Thomas --- apps/wordcraft-web/index.html | 1 + contributors/people.toml | 2 ++ 2 files changed, 3 insertions(+) diff --git a/apps/wordcraft-web/index.html b/apps/wordcraft-web/index.html index 83a02c281..ca6ad4693 100644 --- a/apps/wordcraft-web/index.html +++ b/apps/wordcraft-web/index.html @@ -4,6 +4,7 @@ WordCraft +