#1789: Add Font imports every selected font file (#2404)

* Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* #1789: cap font bytes waiting in the web inbox

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Dmitrii Khlapov <19991457+dkhlapov@users.noreply.github.com>
Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
This commit is contained in:
Dmitrii Khlapov
2026-10-18 10:24:26 -04:00
committed by GitHub
co-authored by Claude Sonnet 5.5 Dmitrii Khlapov Brandon Thomas
parent a492189a84
commit 28d6260a81
28 changed files with 335 additions and 26 deletions
+3 -1
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-18 · **Change:** trivial (RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: Read Aloud speaks each sentence in its own language, #1843; previously trivial: hint-reliant CJK outlines; previously trivial: Ctrl+wheel zoom no longer scrolls the document, #1743; previously trivial: progress log: Turkish case mapping in AutoCorrect, #1923; previously trivial: a mouse drag on a table leaves the view where it is, #1738; previously trivial: progress log: Hebrew field results and smart quotes, #1670; previously trivial: Backspace removes a selected table, rows or columns, #1844; previously trivial: progress log: mixed Hebrew/Latin text keeps its order in Word, #1543; previously trivial: progress log: web typing in non-Latin text no longer repeats, #1480; previously trivial: progress log: font family caches bounded and math font lookups follow font changes, #1354; previously trivial: `-->` AutoCorrect replacement, #2223; previously trivial: numbered headings, #1793; previously trivial: Navigation Find exposes Results, #2096; previously trivial: progress log: Columns on selected text, #2024; previously trivial: progress log: legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: progress log: Indonesian proofing, #498; previously trivial: progress log: DOCX glossary documents (building blocks); previously trivial: progress log: Read Mode keeps the keyboard and shows whole, unobscured columns, #1772); previously trivial: progress log: font list entries of tall-ascent fonts no longer cut off, #2570); previously trivial: progress log: Split Cells and Convert Text to Table dialogs follow the Word check, #2592; previously trivial: a new source's Tag is made like Word's, three letters of the author and two digits of the year with a number added when taken, and an edit keeps the Tag, #2094; previously trivial: Web Layout fills the window width, #1876; previously trivial: progress log: Different First Page headers reachable from page 1, #2826; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717); previously trivial: progress log: web preferences kept between visits, #2132; previously trivial: progress log: undrawable private-use list bullets show the level's default bullet, #1683; previously trivial: progress log: `w:eastAsianLayout` kept on save; previously trivial: table style editor formats every part, #714); previously trivial: VML freeforms read and kept on save; previously trivial: progress log: consistent preferred cell widths; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: progress log: grouped `flowChartOr` shapes draw their circle and crossing spokes; previously trivial: progress log: copies keep their formatting in other programs, #1523; previously trivial: progress log: privacy options on save, #1062; previously trivial: progress log: equation pictures in theme colours on Windows, #1002; previously trivial: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; 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-18 · **Change:** trivial (Add Font imports every selected font file, #1789); previously trivial (RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: Read Aloud speaks each sentence in its own language, #1843; previously trivial: hint-reliant CJK outlines; previously trivial: Ctrl+wheel zoom no longer scrolls the document, #1743; previously trivial: progress log: Turkish case mapping in AutoCorrect, #1923; previously trivial: a mouse drag on a table leaves the view where it is, #1738; previously trivial: progress log: Hebrew field results and smart quotes, #1670; previously trivial: Backspace removes a selected table, rows or columns, #1844; previously trivial: progress log: mixed Hebrew/Latin text keeps its order in Word, #1543; previously trivial: progress log: web typing in non-Latin text no longer repeats, #1480; previously trivial: progress log: font family caches bounded and math font lookups follow font changes, #1354; previously trivial: `-->` AutoCorrect replacement, #2223; previously trivial: numbered headings, #1793; previously trivial: Navigation Find exposes Results, #2096; previously trivial: progress log: Columns on selected text, #2024; previously trivial: progress log: legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: progress log: Indonesian proofing, #498; previously trivial: progress log: DOCX glossary documents (building blocks); previously trivial: progress log: Read Mode keeps the keyboard and shows whole, unobscured columns, #1772); previously trivial: progress log: font list entries of tall-ascent fonts no longer cut off, #2570); previously trivial: progress log: Split Cells and Convert Text to Table dialogs follow the Word check, #2592; previously trivial: a new source's Tag is made like Word's, three letters of the author and two digits of the year with a number added when taken, and an edit keeps the Tag, #2094; previously trivial: Web Layout fills the window width, #1876; previously trivial: progress log: Different First Page headers reachable from page 1, #2826; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717); previously trivial: progress log: web preferences kept between visits, #2132; previously trivial: progress log: undrawable private-use list bullets show the level's default bullet, #1683; previously trivial: progress log: `w:eastAsianLayout` kept on save; previously trivial: table style editor formats every part, #714); previously trivial: VML freeforms read and kept on save; previously trivial: progress log: consistent preferred cell widths; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: progress log: grouped `flowChartOr` shapes draw their circle and crossing spokes; previously trivial: progress log: copies keep their formatting in other programs, #1523; previously trivial: progress log: privacy options on save, #1062; previously trivial: progress log: equation pictures in theme colours on Windows, #1002; previously trivial: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; 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-17 · **Change:** trivial (RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: progress log: pasted blockquotes are indented paragraphs, #1270; previously trivial: progress log: print-area strings translated in every catalog; previously trivial: progress log: comments readable in the dark theme, #2045; previously trivial: progress log: ODT sections, #1379; previously trivial: progress log: table deletes, Change Case and Unlink Fields respect Track Changes and locked content controls, #1329; previously trivial: progress log: rounded rectangle corners, #1476; previously trivial: progress log: Save As type list, #1399; previously trivial: progress log: privacy options on save, #1062; previously trivial: progress log: equation pictures in theme colours on Windows, #1002; previously trivial: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; 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
> **Printing update:** 2026-10-12 · direct desktop printing through craft-print; details below.
@@ -156,6 +156,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
| 2026-10-15 | Web: typing in Cyrillic or another non-Latin script no longer repeats text. WordCraft now honours the input method's request to erase around the caret before retyping (#1480). Chrome can still move the hidden text field's caret, so later letters may still land out of order. That cause is still open |
| 2026-10-15 | Equations: typing `V_dd` or `x^ab` and pressing Space keeps `dd` and `ab` whole in the subscript or superscript, like `V_(dd)`, as Word for Mac does; Linear format shows the bare form |
| 2026-10-15 | Plain text pasted from another program keeps the formatting of the paragraph it lands in: Paste inserts it as Keep Text Only does, so every pasted line, empty lines too, becomes a paragraph with that paragraph's direction, style, alignment, indents and list, and the text after the caret keeps them. The lines after the first had default formatting, so pasting several lines into a right-to-left paragraph turned all but the first left-to-right |
| 2026-10-14 | Add Font… imports every file selected in the file chooser (#1789), not just the first: the desktop dialog and the web picker are multi-select, each file is added, a bad file is reported ("Added 11 fonts; 1 couldn't be read: X.ttf") without stopping the rest, and one pick adds at most 2,000 (the status line counts the skipped). On the web each font is added and reported on its own, with no single summary line. The built-in file browser still picks one file. |
|---|---|
| 2026-10-15 | The font family caches are bounded (4096 families for text, 1024 for math, names over 256 bytes aren't remembered), so a document naming thousands of missing fonts can't grow memory; equation fonts are looked up again after a font is added or removed instead of keeping the old choice (#1354) |
| 2026-10-15 | AutoCorrect recognizes `-->` as `→` (#2223), respecting replacement settings and undo without changing paragraph-start arrow bullets. |
@@ -498,6 +499,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
| 2026-10-14 | trivial | Progress log: RTF export writes sections (gap 19) |
| 2026-10-15 | trivial | Progress log: multi-letter equation scripts (#1609) |
| 2026-10-15 | trivial | Progress log: plain-text paste keeps the paragraph formatting where it lands |
| 2026-10-14 | trivial | Progress log: Add Font imports every selected font file (#1789) |
| 2026-10-14 | trivial | Progress log: row table border exceptions |
| 2026-10-12 | minor | Czech proofing review: short-word splits rank after deletion, NOSUGGEST excludes suggestions while accepting typed forms, affix comments apply only to headers, ratios keep spaces and repeated-word messages use Czech quotes; synthetic engine end-to-end and latency checks |
| 2026-10-12 | minor | Czech spelling from the installed Hunspell dictionary, runtime-table suggestions and generic checks; Czech interface writes cs-CZ documents; no Czech hyphenation patterns bundled; estimates unchanged |
+20 -6
View File
@@ -224,13 +224,23 @@ fn services(inbox: Inbox, ctx: egui::Context, dirty: Rc<Cell<bool>>, saves: Asyn
} else {
rfd::AsyncFileDialog::new().add_filter("Documents", DOC_EXTS)
};
let many = purpose == "font";
wasm_bindgen_futures::spawn_local(async move {
let Some(file) = dialog.pick_file().await else {
return;
};
let bytes = file.read().await;
inbox.lock().unwrap_or_else(|e| e.into_inner()).push((file.file_name(), bytes));
ctx.request_repaint();
// Add Font takes every selected file (#1789); the others one.
let files = if many { dialog.pick_files().await.unwrap_or_default() } else { dialog.pick_file().await.into_iter().collect() };
for file in files.into_iter().take(MAX_PICKED_FILES) {
let mut bytes = file.read().await;
let mut pending = inbox.lock().unwrap_or_else(|e| e.into_inner());
// Fonts waiting for the app to drain them stay under a byte cap. A file
// over it is queued empty, so Add Font reports that file on its own.
let held: usize = pending.iter().map(|(_, b)| b.len()).fold(0, usize::saturating_add);
if many && held.saturating_add(bytes.len()) > MAX_PENDING_PICKED_BYTES {
bytes = Vec::new();
}
pending.push((file.file_name(), bytes));
drop(pending);
ctx.request_repaint();
}
});
})),
// Exports ask for a name; the browser decides where the download goes.
@@ -250,6 +260,10 @@ fn services(inbox: Inbox, ctx: egui::Context, dirty: Rc<Cell<bool>>, saves: Asyn
}
}
/// Most files one multi-select pick (Add Font) reads.
const MAX_PICKED_FILES: usize = 2000;
/// Most bytes of picked fonts held in the inbox before the app drains them.
const MAX_PENDING_PICKED_BYTES: usize = 256 << 20;
/// Most local fonts one Load System Installed Fonts installs (a designer's machine has thousands).
const MAX_SYSTEM_FONTS: usize = 500;
/// Most font bytes one Load System Installed Fonts reads into memory (wasm memory is small).
+9 -4
View File
@@ -27,7 +27,7 @@ type Request = (FileDialogRequest, Sender<FileDialogAnswer>);
pub type Hook = Box<dyn Fn(FileDialogRequest) -> Receiver<FileDialogAnswer>>;
/// A picker's answer, waited for off the UI thread.
type Picking = Pin<Box<dyn Future<Output = Option<rfd::FileHandle>> + Send>>;
type Picking = Pin<Box<dyn Future<Output = Vec<rfd::FileHandle>> + Send>>;
/// Shows the dialogs the app queued; lives in the eframe app, which has the window.
pub struct Launcher {
@@ -56,13 +56,18 @@ impl Launcher {
// Built here, on the UI thread: macOS shows the panel as a sheet on the window now.
let dialog = rfd::AsyncFileDialog::new().set_parent(parent);
let picking: Picking = match req {
FileDialogRequest::Open { purpose } => Box::pin(open_filters(dialog, &purpose).pick_file()),
FileDialogRequest::Open { purpose } => {
Box::pin(async move { open_filters(dialog, &purpose).pick_file().await.into_iter().collect() })
}
FileDialogRequest::OpenMany { purpose } => {
Box::pin(async move { open_filters(dialog, &purpose).pick_files().await.unwrap_or_default() })
}
FileDialogRequest::Save { name } => {
let mut dialog = dialog.set_file_name(name.clone());
for (label, exts) in save_filters(&name) {
dialog = dialog.add_filter(label, &exts);
}
Box::pin(dialog.save_file())
Box::pin(async move { dialog.save_file().await.into_iter().collect() })
}
};
let ctx = self.ctx.clone();
@@ -70,7 +75,7 @@ impl Launcher {
// The picker only starts when first polled: without a system dialog it never does.
let picked = match no_system_dialog() {
Some(reason) => Err(reason),
None => Ok(pollster::block_on(picking).map(|f| f.path().to_string_lossy().to_string())),
None => Ok(pollster::block_on(picking).iter().map(|f| f.path().to_string_lossy().to_string()).collect()),
};
let _ = answer.send(picked);
ctx.request_repaint();
+4 -4
View File
@@ -147,7 +147,7 @@ pub(crate) struct FileBrowser {
impl FileBrowser {
pub(crate) fn new(req: FileDialogRequest, after: AfterPick, start: PathBuf, unavailable: bool) -> Self {
let (name, filters) = match &req {
FileDialogRequest::Open { purpose } => {
FileDialogRequest::Open { purpose } | FileDialogRequest::OpenMany { purpose } => {
(String::new(), open_filters(purpose).iter().map(|(l, e)| ((*l).to_string(), e.iter().map(|x| (*x).to_string()).collect())).collect())
}
FileDialogRequest::Save { name } => {
@@ -209,7 +209,7 @@ impl FileBrowser {
return Step::Stay;
}
match self.req {
FileDialogRequest::Open { .. } => {
FileDialogRequest::Open { .. } | FileDialogRequest::OpenMany { .. } => {
if path.is_file() {
return Step::Pick(path);
}
@@ -242,14 +242,14 @@ impl FileBrowser {
fn title(&self) -> &'static str {
match self.req {
FileDialogRequest::Open { .. } => "Open",
FileDialogRequest::Open { .. } | FileDialogRequest::OpenMany { .. } => "Open",
FileDialogRequest::Save { .. } => "Save As",
}
}
fn ok_label(&self) -> &'static str {
match self.req {
FileDialogRequest::Open { .. } => "Open",
FileDialogRequest::Open { .. } | FileDialogRequest::OpenMany { .. } => "Open",
FileDialogRequest::Save { .. } => "Save",
}
}
+165 -10
View File
@@ -23,13 +23,19 @@ pub enum FileDialogRequest {
/// Pick a file to open; `purpose` says what for (`document`, `picture`, `recipients`, `font`), as for
/// `pick_open`.
Open { purpose: String },
/// Pick one or more files (Add Font…); the answer lists every one picked.
OpenMany { purpose: String },
/// Pick a path to save to, given a suggested file name.
Save { name: String },
}
/// The host's answer to a [`FileDialogRequest`]: the picked path, `Ok(None)` when the user
/// cancelled, or an error when no system dialog could be shown (the app then shows its own).
pub type FileDialogAnswer = Result<Option<String>, String>;
/// The host's answer to a [`FileDialogRequest`]: the picked paths (one for everything but
/// [`FileDialogRequest::OpenMany`]), empty when the user cancelled, or an error when no system
/// dialog could be shown (the app then shows its own).
pub type FileDialogAnswer = Result<Vec<String>, String>;
/// Most font files one Add Font pick tries; the rest of a bigger selection is skipped.
const MAX_FONTS_PER_PICK: usize = 2000;
const PICTURES: &[&str] = &["png", "jpg", "jpeg", "gif", "webp", "bmp", "wmf", "emf"];
const DOCUMENTS: &[&str] = &["docx", "docm", "dotx", "dotm", "doc", "dot", "odt", "rtf", "txt", "md", "html", "htm", "tex", "json", "xml"];
@@ -60,7 +66,7 @@ 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).
/// Add every picked font file (#582, #1789).
Font,
/// Hand the picked path to the open dialog (`WordApp::dialog_pick`): Templates and Add-ins,
/// Organizer (#377).
@@ -131,7 +137,9 @@ impl WordApp {
return self.poll_file_dialog().map_or(Asked::Pending, Asked::Done);
}
let picked = match &req {
FileDialogRequest::Open { purpose } => self.services.pick_open.as_ref().and_then(|f| f(purpose)),
FileDialogRequest::Open { purpose } | FileDialogRequest::OpenMany { purpose } => {
self.services.pick_open.as_ref().and_then(|f| f(purpose))
}
FileDialogRequest::Save { name } => self.services.pick_save.as_ref().and_then(|f| f(name)),
};
Asked::Done(self.finish_pick(after, picked))
@@ -144,11 +152,11 @@ impl WordApp {
None | Some(Err(TryRecvError::Empty)) => return None,
Some(Ok(answer)) => answer,
// The host dropped the dialog without an answer: as good as cancelled.
Some(Err(TryRecvError::Disconnected)) => Ok(None),
Some(Err(TryRecvError::Disconnected)) => Ok(Vec::new()),
};
let d = self.file_dialog.take()?;
match answer {
Ok(picked) => Some(self.finish_pick(d.after, picked)),
Ok(picked) => Some(self.finish_picks(d.after, picked)),
Err(reason) => {
log::warn!("no system file dialog ({reason}); showing the built-in file browser");
self.dialog_unavailable(d.req, d.after)
@@ -178,6 +186,53 @@ impl WordApp {
Some(self.finish_pick(after, None))
}
/// Do what the dialog was for with the picked paths (none: the user cancelled). Only Add Font
/// takes more than one; the others use the first.
fn finish_picks(&mut self, after: AfterPick, picked: Vec<String>) -> Result<Value, String> {
match after {
AfterPick::Font if !picked.is_empty() => self.add_fonts(picked),
after => self.finish_pick(after, picked.into_iter().next()),
}
}
/// Add each picked font file, going on past the ones that fail, and say how it went in one
/// status line (#1789). One file keeps `font.add`'s own result and message.
fn add_fonts(&mut self, mut paths: Vec<String>) -> Result<Value, String> {
if paths.len() == 1 {
return self.run("font.add", json!({"path": paths.remove(0)}));
}
let skipped = paths.len().saturating_sub(MAX_FONTS_PER_PICK);
paths.truncate(MAX_FONTS_PER_PICK);
let (mut added, mut failed) = (0usize, Vec::new());
for path in paths {
match self.run("font.add", json!({"path": path})) {
Ok(_) => added += 1,
Err(e) => {
log::warn!("couldn't add font {path}: {e}");
let name = std::path::Path::new(&path).file_name().map_or(path.clone(), |n| n.to_string_lossy().into_owned());
failed.push(name);
}
}
}
let n = added.to_string();
let mut msg = if added == 1 { tl!("Added 1 font").to_string() } else { crate::i18n::fmt(tl!("Added {n} fonts"), &[("n", &n)]) };
if !failed.is_empty() {
let count = failed.len().to_string();
let mut names = failed.iter().take(3).cloned().collect::<Vec<_>>().join(", ");
if failed.len() > 3 {
let more = (failed.len() - 3).to_string();
names = crate::i18n::fmt(tl!("{names} and {n} more"), &[("names", &names), ("n", &more)]);
}
msg = crate::i18n::fmt(tl!("{added}; {count} couldn't be read: {names}"), &[("added", &msg), ("count", &count), ("names", &names)]);
}
if skipped > 0 {
let (n, max) = (skipped.to_string(), MAX_FONTS_PER_PICK.to_string());
msg = crate::i18n::fmt(tl!("{added}; {n} skipped (limit {max})"), &[("added", &msg), ("n", &n), ("max", &max)]);
}
self.status(msg);
Ok(json!({"added": added, "failed": failed, "skipped": skipped}))
}
/// Do what the dialog was for with the picked path (`None`: the user cancelled).
pub(crate) fn finish_pick(&mut self, after: AfterPick, picked: Option<String>) -> Result<Value, String> {
match after {
@@ -196,7 +251,7 @@ impl WordApp {
}
AfterPick::Font => {
let Some(path) = picked else { return Ok(json!({"cancelled": true})) };
self.run("font.add", json!({"path": path}))
self.add_fonts(vec![path])
}
AfterPick::ForDialog => {
let picked = picked.filter(|p| !p.is_empty());
@@ -315,7 +370,7 @@ impl WordApp {
f("font");
return;
}
let _ = self.ask_file(FileDialogRequest::Open { purpose: "font".into() }, AfterPick::Font);
let _ = self.ask_file(FileDialogRequest::OpenMany { purpose: "font".into() }, AfterPick::Font);
}
/// Request loading local system installed fonts (web: queryLocalFonts).
@@ -360,8 +415,13 @@ mod tests {
/// Answer the dialog the host is showing (the last one asked for).
fn answer(shown: &Shown, picked: Option<&str>) {
answer_all(shown, picked.as_slice());
}
/// Answer the dialog with several picked paths (none: cancelled).
fn answer_all(shown: &Shown, picked: &[&str]) {
let (_, tx) = shown.borrow_mut().pop().unwrap();
tx.send(Ok(picked.map(str::to_string))).unwrap();
tx.send(Ok(picked.iter().map(|p| p.to_string()).collect())).unwrap();
}
fn typed(a: &mut WordApp) {
@@ -619,4 +679,99 @@ mod tests {
a.run("font.loadSystem", json!({})).unwrap();
assert_eq!(a.status_msg.as_ref().map(|(s, _)| s.as_str()), Some("All system fonts are already available."));
}
/// 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_font(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.get(i..).and_then(|d| d.windows(from.len()).position(|w| w == from)) {
data[i + at..i + at + to.len()].copy_from_slice(&to);
i += at + to.len();
}
}
data
}
fn font_families(a: &mut WordApp) -> Vec<String> {
let list = a.run("font.list", json!({"all": true})).unwrap();
list["families"].as_array().unwrap().iter().filter_map(|f| f.as_str().map(str::to_string)).collect()
}
/// #1789: Add Font asks for several files, and every one picked is added; an unreadable file
/// is reported and the rest still go in.
#[test]
fn add_font_imports_every_selected_file() {
const ALPHA: &str = "Mfpick Alpha 1";
const BRAVO: &str = "Mfpick Bravo 2";
let dir = scratch("fonts");
let alpha = dir.join("MfpickAlpha1-Regular.ttf");
let junk = dir.join("Junk.ttf");
let bravo = dir.join("MfpickBravo2-Regular.ttf");
std::fs::write(&alpha, renamed_font("JetBrainsMono-Regular.ttf", "JetBrains Mono", ALPHA)).unwrap();
std::fs::write(&junk, b"this is not a font").unwrap();
std::fs::write(&bravo, renamed_font("JetBrainsMono-Regular.ttf", "JetBrains Mono", BRAVO)).unwrap();
let (mut a, shown) = async_app();
a.pick_font();
assert_eq!(shown.borrow()[0].0, FileDialogRequest::OpenMany { purpose: "font".into() }, "Add Font is multi-select");
let paths: Vec<String> = [&alpha, &junk, &bravo].iter().map(|p| p.to_string_lossy().into_owned()).collect();
answer_all(&shown, &paths.iter().map(String::as_str).collect::<Vec<_>>());
let r = a.poll_file_dialog().unwrap().unwrap();
assert_eq!(r["added"], 2);
assert_eq!(r["failed"].as_array().map(Vec::len), Some(1));
let families = font_families(&mut a);
assert!(families.iter().any(|f| f == ALPHA) && families.iter().any(|f| f == BRAVO), "both valid fonts were added");
let msg = a.status_msg.as_ref().map(|(m, _)| m.clone()).unwrap_or_default();
assert_eq!(msg, "Added 2 fonts; 1 couldn't be read: Junk.ttf");
for f in [ALPHA, BRAVO] {
a.run("font.remove", json!({"family": f})).unwrap();
}
let _ = std::fs::remove_dir_all(&dir);
}
/// Only Add Font is multi-select; a cancelled pick adds nothing.
#[test]
fn only_add_font_asks_for_several_files() {
let (mut a, shown) = async_app();
a.open_dialog();
assert_eq!(shown.borrow()[0].0, FileDialogRequest::Open { purpose: "document".into() });
answer(&shown, None);
a.poll_file_dialog();
a.pick_picture();
assert_eq!(shown.borrow()[0].0, FileDialogRequest::Open { purpose: "picture".into() });
answer(&shown, None);
a.poll_file_dialog();
a.pick_font();
assert!(matches!(shown.borrow()[0].0, FileDialogRequest::OpenMany { .. }));
answer_all(&shown, &[]);
assert_eq!(a.poll_file_dialog().unwrap().unwrap(), json!({"cancelled": true}));
assert!(!a.file_dialog_open());
}
/// A hostile selection is bounded: only the first MAX_FONTS_PER_PICK are tried.
#[test]
fn a_huge_font_selection_is_capped() {
let (mut a, _) = async_app();
let paths: Vec<String> = (0..MAX_FONTS_PER_PICK + 50).map(|i| format!("/nonexistent/f{i}.ttf")).collect();
let r = a.add_fonts(paths).unwrap();
assert_eq!(r["added"], 0);
assert_eq!(r["failed"].as_array().map(Vec::len), Some(MAX_FONTS_PER_PICK));
assert_eq!(r["skipped"], 50);
let msg = a.status_msg.as_ref().map(|(m, _)| m.clone()).unwrap_or_default();
assert_eq!(msg, "Added 0 fonts; 2000 couldn't be read: f0.ttf, f1.ttf, f2.ttf and 1997 more; 50 skipped (limit 2000)");
}
/// Past three failures the status line names three and counts the rest.
#[test]
fn many_failed_fonts_end_with_a_count() {
let (mut a, _) = async_app();
let paths: Vec<String> = (0..5).map(|i| format!("/nonexistent/f{i}.ttf")).collect();
a.add_fonts(paths).unwrap();
let msg = a.status_msg.as_ref().map(|(m, _)| m.clone()).unwrap_or_default();
assert_eq!(msg, "Added 0 fonts; 5 couldn't be read: f0.ttf, f1.ttf, f2.ttf and 2 more");
}
}
+6
View File
@@ -2645,3 +2645,9 @@
All Fonts Всички шрифтове
Spike Натрупване
Insert Spike Вмъкване на натрупаното
# Add Font status (#1789)
Added 1 font Добавен 1 шрифт
Added {n} fonts Добавени шрифтове: {n}
{added}; {count} couldn't be read: {names} {added}; не можаха да се прочетат ({count}): {names}
{names} and {n} more {names} и още {n}
{added}; {n} skipped (limit {max}) {added}; пропуснати: {n} (ограничение {max})
Can't render this file because it contains an unexpected character in line 7 and column 25.
+6
View File
@@ -2648,3 +2648,9 @@ preview Title Ukázka
All Fonts Všechna písma
Spike Sběrač
Insert Spike Vložit ze sběrače
# Add Font status (#1789)
Added 1 font Přidáno 1 písmo
Added {n} fonts Přidaná písma: {n}
{added}; {count} couldn't be read: {names} {added}; nelze přečíst ({count}): {names}
{names} and {n} more {names} a dalších {n}
{added}; {n} skipped (limit {max}) {added}; přeskočeno: {n} (limit {max})
Can't render this file because it contains an unexpected character in line 7 and column 25.
+6
View File
@@ -2256,3 +2256,9 @@
All Fonts Alle Schriftarten
Spike Sammlung
Insert Spike Sammlung einfügen
# Add Font status (#1789)
Added 1 font 1 Schriftart hinzugefügt
Added {n} fonts {n} Schriftarten hinzugefügt
{added}; {count} couldn't be read: {names} {added}; {count} nicht lesbar: {names}
{names} and {n} more {names} und {n} weitere
{added}; {n} skipped (limit {max}) {added}; {n} übersprungen (Limit {max})
Can't render this file because it contains an unexpected character in line 6 and column 25.
+6
View File
@@ -2623,3 +2623,9 @@ preview Title Título
All Fonts Todas las fuentes
Spike Acumulador
Insert Spike Insertar acumulador
# Add Font status (#1789)
Added 1 font 1 fuente añadida
Added {n} fonts {n} fuentes añadidas
{added}; {count} couldn't be read: {names} {added}; no se pudieron leer {count}: {names}
{names} and {n} more {names} y {n} más
{added}; {n} skipped (limit {max}) {added}; {n} omitidas (límite {max})
Can't render this file because it contains an unexpected character in line 6 and column 25.
+6
View File
@@ -2291,3 +2291,9 @@
All Fonts Kõik fondid
Spike Koguja
Insert Spike Lisa kogujast
# Add Font status (#1789)
Added 1 font Lisati 1 font
Added {n} fonts Lisati {n} fonti
{added}; {count} couldn't be read: {names} {added}; {count} ei õnnestunud lugeda: {names}
{names} and {n} more {names} ja veel {n}
{added}; {n} skipped (limit {max}) {added}; {n} jäeti vahele (piir {max})
Can't render this file because it contains an unexpected character in line 845 and column 23.
+6
View File
@@ -2264,3 +2264,9 @@
All Fonts Toutes les polices
Spike Pique-notes
Insert Spike Insérer le pique-notes
# Add Font status (#1789)
Added 1 font 1 police ajoutée
Added {n} fonts {n} polices ajoutées
{added}; {count} couldn't be read: {names} {added} ; {count} illisibles : {names}
{names} and {n} more {names} et {n} autres
{added}; {n} skipped (limit {max}) {added} ; {n} ignorées (limite {max})
Can't render this file because it contains an unexpected character in line 7 and column 25.
+6
View File
@@ -2656,3 +2656,9 @@ preview Title Naslov
Moved to: Premješteno u:
Spike Skupljač
Insert Spike Umetni iz skupljača
# Add Font status (#1789)
Added 1 font Dodan 1 font
Added {n} fonts Dodano fontova: {n}
{added}; {count} couldn't be read: {names} {added}; nije moguće pročitati ({count}): {names}
{names} and {n} more {names} i još {n}
{added}; {n} skipped (limit {max}) {added}; preskočeno: {n} (ograničenje {max})
Can't render this file because it contains an unexpected character in line 6 and column 25.
+6
View File
@@ -2652,3 +2652,9 @@
All Fonts Összes betűtípus
Spike Gyűjtő
Insert Spike Beszúrás a gyűjtőből
# Add Font status (#1789)
Added 1 font 1 betűtípus hozzáadva
Added {n} fonts {n} betűtípus hozzáadva
{added}; {count} couldn't be read: {names} {added}; {count} nem olvasható: {names}
{names} and {n} more {names} és még {n}
{added}; {n} skipped (limit {max}) {added}; {n} kihagyva (korlát: {max})
Can't render this file because it contains an unexpected character in line 6 and column 25.
+6
View File
@@ -2665,3 +2665,9 @@ preview Title Esempio
All Fonts Tutti i caratteri
Spike Raccoglitore
Insert Spike Inserisci raccoglitore
# Add Font status (#1789)
Added 1 font 1 tipo di carattere aggiunto
Added {n} fonts {n} tipi di carattere aggiunti
{added}; {count} couldn't be read: {names} {added}; {count} non leggibili: {names}
{names} and {n} more {names} e altri {n}
{added}; {n} skipped (limit {max}) {added}; {n} saltati (limite {max})
Can't render this file because it contains an unexpected character in line 6 and column 25.
+6
View File
@@ -2257,3 +2257,9 @@
All Fonts すべてのフォント
Spike スパイク
Insert Spike スパイクの挿入
# Add Font status (#1789)
Added 1 font フォントを 1 件追加しました
Added {n} fonts フォントを {n} 件追加しました
{added}; {count} couldn't be read: {names} {added}。読み込めなかったもの {count} 件: {names}
{names} and {n} more {names} ほか {n} 件
{added}; {n} skipped (limit {max}) {added}。{n} 件をスキップしました (上限 {max})
Can't render this file because it contains an unexpected character in line 923 and column 23.
+6
View File
@@ -2510,3 +2510,9 @@ preview Title 제목
All Fonts 모든 글꼴
Spike 스파이크
Insert Spike 스파이크 삽입
# Add Font status (#1789)
Added 1 font 글꼴 1개를 추가했습니다
Added {n} fonts 글꼴 {n}개를 추가했습니다
{added}; {count} couldn't be read: {names} {added}; 읽을 수 없는 파일 {count}개: {names}
{names} and {n} more {names} 외 {n}개
{added}; {n} skipped (limit {max}) {added}; {n}개 건너뜀 (한도 {max})
Can't render this file because it contains an unexpected character in line 929 and column 23.
+6
View File
@@ -2661,3 +2661,9 @@ preview Title Tytuł
All Fonts Wszystkie czcionki
Spike Zbieracz
Insert Spike Wstaw ze zbieracza
# Add Font status (#1789)
Added 1 font Dodano 1 czcionkę
Added {n} fonts Dodano czcionek: {n}
{added}; {count} couldn't be read: {names} {added}; nie udało się odczytać ({count}): {names}
{names} and {n} more {names} i {n} więcej
{added}; {n} skipped (limit {max}) {added}; pominięto: {n} (limit {max})
Can't render this file because it contains an unexpected character in line 6 and column 25.
+6
View File
@@ -2269,3 +2269,9 @@
All Fonts Todas as fontes
Spike Coletor
Insert Spike Inserir coletor
# Add Font status (#1789)
Added 1 font 1 fonte adicionada
Added {n} fonts {n} fontes adicionadas
{added}; {count} couldn't be read: {names} {added}; {count} não puderam ser lidas: {names}
{names} and {n} more {names} e mais {n}
{added}; {n} skipped (limit {max}) {added}; {n} ignoradas (limite de {max})
Can't render this file because it contains an unexpected character in line 6 and column 25.
+6
View File
@@ -2654,3 +2654,9 @@ preview Title Пример
All Fonts Все шрифты
Spike Копилка
Insert Spike Вставить из копилки
# Add Font status (#1789)
Added 1 font Добавлен 1 шрифт
Added {n} fonts Добавлено шрифтов: {n}
{added}; {count} couldn't be read: {names} {added}; не удалось прочитать ({count}): {names}
{names} and {n} more {names} и ещё {n}
{added}; {n} skipped (limit {max}) {added}; пропущено: {n} (лимит {max})
Can't render this file because it contains an unexpected character in line 7 and column 25.
+6
View File
@@ -2267,3 +2267,9 @@
All Fonts Svi fontovi
Spike Sakupljač
Insert Spike Umetni iz sakupljača
# Add Font status (#1789)
Added 1 font Dodat je 1 font
Added {n} fonts Dodato fontova: {n}
{added}; {count} couldn't be read: {names} {added}; nije moguće pročitati ({count}): {names}
{names} and {n} more {names} i još {n}
{added}; {n} skipped (limit {max}) {added}; preskočeno: {n} (ograničenje {max})
Can't render this file because it contains an unexpected character in line 6 and column 25.
+6
View File
@@ -2271,3 +2271,9 @@
All Fonts Сви фонтови
Spike Сакупљач
Insert Spike Уметни из сакупљача
# Add Font status (#1789)
Added 1 font Додат је 1 фонт
Added {n} fonts Додато фонтова: {n}
{added}; {count} couldn't be read: {names} {added}; није могуће прочитати ({count}): {names}
{names} and {n} more {names} и још {n}
{added}; {n} skipped (limit {max}) {added}; прескочено: {n} (ограничење {max})
Can't render this file because it contains an unexpected character in line 11 and column 25.
+6
View File
@@ -2671,3 +2671,9 @@ preview Title Titel
Moved to: Flyttat till:
Spike Samlare
Insert Spike Infoga samlare
# Add Font status (#1789)
Added 1 font 1 teckensnitt har lagts till
Added {n} fonts {n} teckensnitt har lagts till
{added}; {count} couldn't be read: {names} {added}; {count} kunde inte läsas: {names}
{names} and {n} more {names} och {n} till
{added}; {n} skipped (limit {max}) {added}; {n} hoppades över (gräns {max})
Can't render this file because it contains an unexpected character in line 7 and column 25.
+6
View File
@@ -2653,3 +2653,9 @@ preview Title Örnek
All Fonts Tüm Yazı Tipleri
Spike Biriktirici
Insert Spike Biriktiriciyi Ekle
# Add Font status (#1789)
Added 1 font 1 yazı tipi eklendi
Added {n} fonts {n} yazı tipi eklendi
{added}; {count} couldn't be read: {names} {added}; {count} okunamadı: {names}
{names} and {n} more {names} ve {n} tane daha
{added}; {n} skipped (limit {max}) {added}; {n} atlandı (sınır {max})
Can't render this file because it contains an unexpected character in line 797 and column 23.
+6
View File
@@ -2269,3 +2269,9 @@
All Fonts Усі шрифти
Spike Накопичувач
Insert Spike Вставити з накопичувача
# Add Font status (#1789)
Added 1 font Додано 1 шрифт
Added {n} fonts Додано шрифтів: {n}
{added}; {count} couldn't be read: {names} {added}; не вдалося прочитати ({count}): {names}
{names} and {n} more {names} та ще {n}
{added}; {n} skipped (limit {max}) {added}; пропущено: {n} (ліміт {max})
Can't render this file because it contains an unexpected character in line 6 and column 31.
+6
View File
@@ -2696,3 +2696,9 @@ template Letter Thư
All Fonts Tất cả phông chữ
Spike Bộ gom
Insert Spike Chèn bộ gom
# Add Font status (#1789)
Added 1 font Đã thêm 1 phông chữ
Added {n} fonts Đã thêm {n} phông chữ
{added}; {count} couldn't be read: {names} {added}; không đọc được {count}: {names}
{names} and {n} more {names} và {n} tệp nữa
{added}; {n} skipped (limit {max}) {added}; đã bỏ qua {n} (giới hạn {max})
Can't render this file because it contains an unexpected character in line 7 and column 25.
+6
View File
@@ -2269,3 +2269,9 @@
All Fonts 所有字体
Spike 图文场
Insert Spike 插入图文场
# Add Font status (#1789)
Added 1 font 已添加 1 个字体
Added {n} fonts 已添加 {n} 个字体
{added}; {count} couldn't be read: {names} {added};{count} 个无法读取:{names}
{names} and {n} more {names} 等另外 {n} 个
{added}; {n} skipped (limit {max}) {added};已跳过 {n} 个(上限 {max})
Can't render this file because it contains an unexpected character in line 7 and column 25.
+6
View File
@@ -2258,3 +2258,9 @@
All Fonts 所有字型
Spike 收集區
Insert Spike 插入收集區
# Add Font status (#1789)
Added 1 font 已新增 1 個字型
Added {n} fonts 已新增 {n} 個字型
{added}; {count} couldn't be read: {names} {added};{count} 個無法讀取:{names}
{names} and {n} more {names} 等另外 {n} 個
{added}; {n} skipped (limit {max}) {added};已略過 {n} 個(上限 {max})
Can't render this file because it contains an unexpected character in line 924 and column 23.
+2 -1
View File
@@ -1,6 +1,6 @@
# UI parity with Microsoft Word
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-18 · **Change:** trivial (File › Close keeps the window, #1712; previously trivial: the Spike: Ctrl+F3 and Ctrl+Shift+F3; previously trivial: Ctrl+wheel zoom no longer scrolls the document, #1743; previously trivial: a mouse drag on a table leaves the view where it is, #1738; previously trivial: numbered headings, #1793; previously trivial: Navigation Find exposes Results, #2096; previously trivial: the tab strip never draws tabs over Comments / Editing / Share, #2026; previously trivial: Columns on selected text, #2024; previously trivial: Different First Page headers reachable from page 1, #2826; previously trivial: a copied equation also goes in the clipboard's MathML formats, #1297; previously trivial: Read Mode keeps the keyboard, shows whole columns and keeps text clear of the page-turn bar, #1772); previously trivial: font list entries of tall-ascent fonts sit in their row, not cut off, #2570); previously trivial: Split Cells and Convert Text to Table dialogs follow Word's merged-cell limits and Initial column width, #2592; previously trivial: a new source's Tag is made like Word's, three letters of the author and two digits of the year with a number added when taken, and an edit keeps the Tag, #2094; previously trivial: Web Layout text fills the window width, #1876; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717)); previously trivial: comment balloons readable in the dark theme, #2045; previously trivial: Recently Used Fonts section at the top of the font list, #2068; previously trivial: web preferences kept between visits, #2132; previously trivial: dialog close control: a Windows-style box at the title bar's right end, the whole box clickable, red on hover, #1885); previously trivial (Modify Table Style formats every table style part, #714); previously trivial: a click on the ribbon chrome hands the keyboard to the page in the same frame, #1093 follow-up; previously trivial: Page Setup vertical alignment, #1320; previously trivial: the app opens on the Home tab every launch, as in Word; previously trivial: no document behind the Start screen, #1129; previously trivial: equation pictures share a few textures, #1002; previously trivial: selection bar in the left margin, #856; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; 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-18 · **Change:** trivial (Add Font imports every selected font file, #1789); previously trivial (File › Close keeps the window, #1712; previously trivial: the Spike: Ctrl+F3 and Ctrl+Shift+F3; previously trivial: Ctrl+wheel zoom no longer scrolls the document, #1743; previously trivial: a mouse drag on a table leaves the view where it is, #1738; previously trivial: numbered headings, #1793; previously trivial: Navigation Find exposes Results, #2096; previously trivial: the tab strip never draws tabs over Comments / Editing / Share, #2026; previously trivial: Columns on selected text, #2024; previously trivial: Different First Page headers reachable from page 1, #2826; previously trivial: a copied equation also goes in the clipboard's MathML formats, #1297; previously trivial: Read Mode keeps the keyboard, shows whole columns and keeps text clear of the page-turn bar, #1772); previously trivial: font list entries of tall-ascent fonts sit in their row, not cut off, #2570); previously trivial: Split Cells and Convert Text to Table dialogs follow Word's merged-cell limits and Initial column width, #2592; previously trivial: a new source's Tag is made like Word's, three letters of the author and two digits of the year with a number added when taken, and an edit keeps the Tag, #2094; previously trivial: Web Layout text fills the window width, #1876; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717)); previously trivial: comment balloons readable in the dark theme, #2045; previously trivial: Recently Used Fonts section at the top of the font list, #2068; previously trivial: web preferences kept between visits, #2132; previously trivial: dialog close control: a Windows-style box at the title bar's right end, the whole box clickable, red on hover, #1885); previously trivial (Modify Table Style formats every table style part, #714); previously trivial: a click on the ribbon chrome hands the keyboard to the page in the same frame, #1093 follow-up; previously trivial: Page Setup vertical alignment, #1320; previously trivial: the app opens on the Home tab every launch, as in Word; previously trivial: no document behind the Start screen, #1129; previously trivial: equation pictures share a few textures, #1002; previously trivial: selection bar in the left margin, #856; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; 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
@@ -89,6 +89,7 @@ Find selects the Results tab, including when it prefills selected text. Editing
| 2026-10-16 | trivial | The Spike: Ctrl+F3 (⌘F3 on macOS) cuts the selection to the end of the Spike, Ctrl+Shift+F3 (⌘⇧F3) inserts everything collected and empties it; it is the AutoText entry "Spike", so Quick Parts inserts it without emptying it. Per session, not saved between launches |
| 2026-10-15 | trivial | Table Layout › Cell Margins opens with current table defaults; unchanged margins and per-cell overrides stay intact until edits are confirmed (#1960) |
| 2026-10-15 | trivial | Paste (Ctrl+V, Home › Paste) of plain text from another program: every pasted line takes the formatting of the paragraph it lands in, as with Keep Text Only, so a right-to-left paragraph stays right-to-left after the first line. The lines after the first had default formatting |
| 2026-10-14 | trivial | Add Font… takes several files at once (desktop dialog and web picker are multi-select); each is added, a bad file is reported and skipped, at most 2,000 per pick (the status line counts the skipped); on the web each font is added and reported on its own, with no single summary line; the built-in browser still picks one file (#1789) |
| 2026-10-13 | trivial | New/Modify Table Style: No Border on a part that inherits its lines hides them; emptying the font box makes the part's font inherited again (#1288) |
| 2026-10-14 | trivial | Tab stops edited on the ruler: corner kind selector, click to set, drag to move or off to clear, per-kind markers mirrored for RTL; each gesture one `para.tabs` call and one undo step; Rulers 85% → 90% |
| 2026-10-12 | trivial | Tables on the page: one selected cell (Select Cell, or a click along its left edge) is a cell selection when its border is dragged: the border moves in its row only (#962) |