Add fonts at run time: Add Font…, drop a font file, font.add (#582) (#668)

* 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 <noreply@anthropic.com>

* #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 <noreply@anthropic.com>

---------

Co-authored-by: Dmitrii Khlapov <19991457+dkhlapov@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
This commit is contained in:
Dmitrii Khlapov 2026-10-12 06:38:20 +02:00 committed by GitHub
parent 8bd3427012
commit 1b96e04b2c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
36 changed files with 1300 additions and 18 deletions

14
Cargo.lock generated
View File

@ -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"

View File

@ -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"] }

View File

@ -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 |

View File

@ -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"] }

View File

@ -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<Cell<bool>>) -> 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<Cell<bool>>) -> 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()
}
}

View File

@ -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<JsValue, String> {
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<IdbDatabase, String> {
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::<dyn FnMut()>::new(move || {
if let Ok(db) = upgrade_req.result().and_then(|r| r.dyn_into::<IdbDatabase>().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::<IdbDatabase>().map_err(js)
}
async fn store_async(name: String, bytes: Vec<u8>) -> 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::<js_sys::Uint8Array>() 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();
});
}

View File

@ -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 }

View File

@ -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 {

View File

@ -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<WindowGeometry> {
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);

View File

@ -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<CommandSpec> {
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::<Vec<_>>(),
"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<Vec<u8>, 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::<Vec<_>>(),
"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::<Vec<_>>()}))
}

View File

@ -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());

View File

@ -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;

View File

@ -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<u8> {
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::<Vec<u8>>();
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::<Vec<_>>()
};
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"));
}

View File

@ -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 }

View File

@ -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<FallbackCache>,
#[cfg(not(target_arch = "wasm32"))]
catalog: RwLock<Vec<CatalogEntry>>,
/// Fonts the user added at run time, and the folder they are stored in (#582).
user: RwLock<user::UserFonts>,
/// 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<std::path::PathBuf>,
@ -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(),

View File

@ -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<String>),
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<PathBuf>,
face_ids: Vec<u32>,
}
impl UserFont {
/// The families it added (typographic names, in file order, each once).
pub fn families(&self) -> Vec<String> {
let mut v: Vec<String> = 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<String>,
}
#[derive(Default)]
pub(super) struct UserFonts {
fonts: Vec<UserFont>,
dir: Option<PathBuf>,
}
/// 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<FontFormat> {
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<Vec<u8>, 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<Vec<u8>, wuff::WuffErr>) -> Result<Vec<u8>, 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<UserFont> {
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<PathBuf> {
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<Installed, UserFontError> {
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<u8>, path: Option<PathBuf>) -> Result<UserFont, UserFontError> {
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<super::FontFace>]| {
let mut v: Vec<String> = 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<Arc<super::FontFace>> = {
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<Vec<UserFont>, UserFontError> {
let mut user = self.write_user();
let ids: Vec<u32> = {
let faces = self.read_faces();
faces.iter().filter(|f| f.named(family)).map(|f| f.id).collect()
};
let (gone, kept): (Vec<UserFont>, Vec<UserFont>) = 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<u32> = 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<UserFont>, 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<PathBuf> = 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<PathBuf> {
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<u8> {
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::<Vec<u8>>();
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<u8>)> {
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<u8> {
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<u8> {
struct Bits {
out: Vec<u8>,
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<u8>) {
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<u8> {
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::<usize>();
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<u8>> = 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(&regular), woff2(&regular)] {
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::<usize>());
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(&regular)), ("a.woff2", woff2(&regular))] {
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");
}
}

View File

@ -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;

View File

@ -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<Mutex<HashMap<(String, bool, bool), Resolved>>> = 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<Mutex<Memo>> = 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
}

View File

@ -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)]

View File

@ -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

Can't render this file because it contains an unexpected character in line 6 and column 25.

View File

@ -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

Can't render this file because it has a wrong number of fields in line 12.

View File

@ -1521,6 +1521,9 @@
New Group 新しいグループ
Rename… 名前の変更…
Ribbon and Quick Access Toolbar: リボンとクイック アクセス ツール バー:
Add Font… フォントの追加…
Added Fonts 追加したフォント
Remove Added Font 追加したフォントの削除
Recent Colors 最近使用した色
More Colors… その他の色…
Standard 標準

Can't render this file because it has a wrong number of fields in line 3.

View File

@ -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

Can't render this file because it contains an unexpected character in line 6 and column 25.

View File

@ -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 Последние цвета

Can't render this file because it contains an unexpected character in line 7 and column 25.

View File

@ -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

Can't render this file because it contains an unexpected character in line 6 and column 25.

View File

@ -1535,6 +1535,9 @@
New Group Нова група
Rename… Преименуј…
Ribbon and Quick Access Toolbar: Трака и трака са алаткама за брзи приступ:
Add Font… Додај фонт…
Added Fonts Додати фонтови
Remove Added Font Уклони додати фонт
Recent Colors Недавне боје
More Colors… Још боја…
Standard Стандардне

Can't render this file because it contains an unexpected character in line 11 and column 25.

View File

@ -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

Can't render this file because it has a wrong number of fields in line 18.

View File

@ -1533,6 +1533,9 @@
New Group Нова група
Rename… Перейменувати…
Ribbon and Quick Access Toolbar: Стрічка й панель швидкого доступу:
Add Font… Додати шрифт…
Added Fonts Додані шрифти
Remove Added Font Видалити доданий шрифт
Recent Colors Останні кольори
More Colors… Інші кольори…
Standard Стандартні

Can't render this file because it contains an unexpected character in line 6 and column 31.

View File

@ -1533,6 +1533,9 @@
New Group 新建组
Rename… 重命名…
Ribbon and Quick Access Toolbar: 功能区和快速访问工具栏:
Add Font… 添加字体…
Added Fonts 已添加的字体
Remove Added Font 删除已添加的字体
Recent Colors 最近使用的颜色
More Colors… 其他颜色…
Standard 标准

Can't render this file because it contains an unexpected character in line 7 and column 25.

View File

@ -1522,6 +1522,9 @@
New Group 新增群組
Rename… 重新命名…
Ribbon and Quick Access Toolbar: 功能區和快速存取工具列:
Add Font… 新增字型…
Added Fonts 已新增的字型
Remove Added Font 移除已新增的字型
Recent Colors 最近使用的色彩
More Colors… 其他色彩…
Standard 標準

Can't render this file because it has a wrong number of fields in line 4.

View File

@ -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<Box<dyn Fn() -> Option<paste_picture::ClipboardPicture>>>,
/// 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<Box<dyn Fn(&str, &[u8])>>,
/// Web: forget a stored font (its stored file name) once the user removes it.
pub forget_font: Option<Box<dyn Fn(&str)>>,
}
/// 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<String>,
@ -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, &params, 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<Value, String> {
@ -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::<Vec<u8>>();
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]

View File

@ -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();

View File

@ -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<Item = String>) {
self.families = None;
ctx.data_mut(|d| {
for f in families {
d.remove::<TextureHandle>(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<dyn Fn(&mut Ui, &str) -> Response> {
Box::new(|ui: &mut Ui, name: &str| {

View File

@ -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")

View File

@ -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();

View File

@ -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 |