* 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:
parent
8bd3427012
commit
1b96e04b2c
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"] }
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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"] }
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
}
|
||||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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<_>>()}))
|
||||
}
|
||||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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"));
|
||||
}
|
||||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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(®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::<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(®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");
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)]
|
||||
|
|
|
|||
|
|
@ -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.
|
|
|
@ -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.
|
|
|
@ -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.
|
|
|
@ -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.
|
|
|
@ -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.
|
|
|
@ -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.
|
|
|
@ -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.
|
|
|
@ -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.
|
|
|
@ -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.
|
|
|
@ -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.
|
|
|
@ -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.
|
|
|
@ -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, ¶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<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]
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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| {
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
Loading…
Reference in New Issue