File › New: personal templates from a user templates folder (#2852) (#3133)

* #2852: personal templates in File › New, Save as Word Template

File › New lists the .dotx/.dotm files in the user templates folder under
Personal (a subfolder per category, search covers them); a click makes an
untitled document attached to the template. Save As › Save as Word Template
starts in that folder (<settings>/Templates, made on first use; File ›
Options › Advanced › File locations › User templates overrides it).
Engine: file.listTemplates (bounded, network folders refused, read root
applies), file.newFromTemplate sets the attached template, file.options
userTemplatesFolder, file.saveTemplate keeps a .dotm name. UI:
ui.saveAsTemplate; Save dialog requests carry a start folder.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012WEhZXSxzS8orX7f9J3qpr

* #2852: review — a personal template asks about unsaved changes; network templates folder refused before it's looked at; New lists the folder once per visit with Refresh; .dotx/.dotm labels alike

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012WEhZXSxzS8orX7f9J3qpr

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-authored-by: aiongg <aiongtaigi@gmail.com>
This commit is contained in:
authored and GitHub committed 2026-10-21 20:18:50 -04:00
1 parent dbebb2eca7
commit 5a6d2b6070
44 files changed
+1261 -50

No files matched your search

+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-21 · **Change:** trivial (progress log: legacy menu keys, #1697; previously trivial: progress log: shape gradients and transparency, #1500; previously trivial: progress log: Focus fills the screen, and Full Screen View shows the pages alone, #1678; previously trivial: progress log: text above a float's anchor wraps around it, #2048; previously trivial: progress log: File › Recover follows the app theme, #3243; previously trivial: progress log: caret inside ligatures, #1974; previously trivial: text typed into a content control takes the control's formatting; localized placeholder styles recognised; previously trivial: forms protection keeps its content controls: no inserting, Properties or Remove Content Control; previously trivial: long navigation headings wrap onto further lines within the pane, #3015; previously trivial: clearing a no-match font query keeps the recent heading visible, #2068; previously trivial: a word after a slash wraps whole, like Word, #2963; previously trivial: progress log: a header-cell picture watermark sits behind the text, #1775; previously trivial: symbols keep their font when the text font changes, #2036; previously trivial: opening a DOCX no longer copies its pictures; previously 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 Line truncated
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-21 · **Change:** trivial (personal templates in File › New and Save as Word Template, #2852; previously trivial: progress log: legacy menu keys, #1697; previously trivial: progress log: shape gradients and transparency, #1500; previously trivial: progress log: Focus fills the screen, and Full Screen View shows the pages alone, #1678; previously trivial: progress log: text above a float's anchor wraps around it, #2048; previously trivial: progress log: File › Recover follows the app theme, #3243; previously trivial: progress log: caret inside ligatures, #1974; previously trivial: text typed into a content control takes the control's formatting; localized placeholder styles recognised; previously trivial: forms protection keeps its content controls: no inserting, Properties or Remove Content Control; previously trivial: long navigation headings wrap onto further lines within the pane, #3015; previously trivial: clearing a no-match font query keeps the recent heading visible, #2068; previously trivial: a word after a slash wraps whole, like Word, #2963; previously trivial: progress log: a header-cell picture watermark sits behind the text, #1775; previously trivial: symbols keep their font when the text font changes, #2036; previously trivial: opening a DOCX no longer copies its pictures; previously 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 perLine truncated
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-18 · **Change:** trivial (English dialects in proofing, #1281; previously trivial: triple borders draw three lines, #1740; previously trivial: progress log: double-clicking a later page's footer no longer scrolls up, #2103; previously trivial: long navigation headings wrap onto further lines within the pane, #3015; previously trivial: clearing a no-match font query keeps the recent heading visible, #2068; previously trivial: a word after a slash wraps whole, like Word, #2963; previously 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.
@@ -142,6 +142,7 @@ Imported Word 97–2003 `.doc`/`.dot` files now ask for a supported DOCX target
- 2026-10-15: Find opens Navigation on Results; editing the query restores Results after manual Headings/Pages navigation. Typed and selected-text searches retain document contents (#2096).
| Date | What landed |
| 2026-10-19 | Personal templates (#2852): File › New lists the `.dotx` and `.dotm` files in your templates folder under Personal (a subfolder per category; the search box covers them), and one opens as a new untitled document attached to it, as in Word. Save As › Save as Word Template starts in that folder, made on first use in the settings folder; File › Options › Advanced › File locations › User templates points it elsewhere. Agents list them with `file.listTemplates`. |
| 2026-10-15 | A picture anchored further down a page but placed higher up it (fixed on the page, or moved up from its paragraph) has the paragraphs above its anchor wrap around it too, instead of running behind it, also when that wrap pushes the anchor onto the next page (#2048) |
| 2026-10-19 | Save As, Export and Open no longer do nothing on Linux desktops whose file chooser portal fails to show its dialog (#2999): a "cancel" quicker than anyone could make one asks again without the parent window, then opens the built-in file browser. |
| 2026-10-18 | Long navigation headings wrap onto further lines inside the resizable pane instead of ending in an ellipsis: the row grows with the text and the pane keeps its width (#759, #3015). |
@@ -792,3 +793,4 @@ Footer page-table height and body wrapping are corrected. Other pagination gaps
| 2026-10-15 | trivial | Progress log: mixed Hebrew/Latin text keeps its order in Word (#1543) |
| 2026-10-15 | trivial | Progress log: ruler numbers by zoom, rulers follow the caret's page (#1878) |
| 2026-10-17 | trivial | Progress log: saving into a folder that refuses a temporary file (#2138) |
| 2026-10-19 | trivial | Progress log: personal templates in File › New, Save as Word Template (#2852) |
+5 -1
View File
@@ -93,8 +93,12 @@ fn picker(dialog: rfd::AsyncFileDialog, req: &FileDialogRequest) -> Picking {
match req.clone() {
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 } => {
FileDialogRequest::Save { name, dir } => {
let mut dialog = dialog.set_file_name(name.clone());
// Save as Word Template starts in the user templates folder (#2852).
if let Some(dir) = dir.filter(|d| std::path::Path::new(d).is_dir()) {
dialog = dialog.set_directory(dir);
}
for (label, exts) in save_filters(&name) {
dialog = dialog.add_filter(label, &exts);
}
+2
View File
@@ -244,6 +244,8 @@ fn services(file_dialog: file_dialogs::Hook, ctx: &egui::Context) -> Services {
print_native: Some(print::submit()),
clipboard_picture: Some(Box::new(clipboard::picture)),
make_default_app: default_apps::hook(),
// Personal templates live in `Templates` in the settings folder (#2852), made on first save.
templates_dir: wordcraft_control_key::settings_dir().map(|d| d.join("Templates")),
animations_enabled: Some(animations::hook(ctx)),
clipboard_text: Some(Box::new(clipboard::text)),
clipboard_html: Some(Box::new(clipboard::html)),
+16
View File
@@ -52,6 +52,22 @@ pub fn read_xml(bytes: &[u8]) -> Result<wordcraft_doc::Document, DocxError> {
}
}
/// The title (`dc:title`) in a package's core properties part (`docProps/core.xml`, ECMA-376
/// Part 2 §11), trimmed; `None` when the part can't be read or has no title. Only the package's
/// directory and at most `max_part` bytes of that part are read: for listing templates by name
/// without reading the whole package.
pub fn package_title<R: std::io::Read + std::io::Seek>(package: R, max_part: u64) -> Option<String> {
use std::io::Read;
let mut zip = zip::ZipArchive::new(package).ok()?;
let part = zip.by_name("docProps/core.xml").ok()?;
let mut xml = Vec::new();
part.take(max_part).read_to_end(&mut xml).ok()?;
let root = xml::parse(&xml).ok()?;
let title = root.child("dc:title")?.text();
let title = title.trim();
(!title.is_empty()).then(|| title.to_string())
}
/// What [`DocxError::PasswordRequired`] says.
pub const PASSWORD_REQUIRED: &str = "this document is protected with a password";
/// What [`DocxError::WrongPassword`] says.
+1
View File
@@ -30,6 +30,7 @@ File|Backstage|Version History|file.versions
File|Backstage|Recover Unsaved|file.recover
File|Backstage|New from Template|file.newFromTemplate
File|Backstage|Save as Template|file.saveTemplate
File|Backstage|Personal Templates|file.listTemplates
Quick Access|Toolbar|Undo|edit.undo
Quick Access|Toolbar|Undo List|edit.undoTo
Quick Access|Toolbar|Redo|edit.redo
+2
View File
@@ -47,6 +47,7 @@ pub mod table_style;
pub mod templates;
pub mod text;
pub mod tools;
pub mod user_templates;
pub mod view;
pub mod wordart;
@@ -99,6 +100,7 @@ pub fn registry() -> Registry {
v.extend(connectors::specs());
v.extend(tools::specs());
v.extend(templates::specs());
v.extend(user_templates::specs());
v.extend(protect::specs());
v.extend(speech::specs());
v.extend(controls::specs());
+24 -8
View File
@@ -11,7 +11,7 @@ pub const PANES: [&str; 8] = ["general", "display", "proofing", "save", "languag
/// Longest initials kept, in characters.
pub const MAX_INITIALS_CHARS: usize = 9;
/// Longest dictionaries folder path taken, in characters.
/// Longest folder path (dictionaries, user templates) taken, in characters.
const MAX_FOLDER_CHARS: usize = 4096;
/// Longest word the custom dictionary takes, in characters.
pub const MAX_WORD_CHARS: usize = 64;
@@ -22,7 +22,7 @@ pub fn specs() -> Vec<CommandSpec> {
vec![
CommandSpec::new("file.options", "Options", "File", options)
.params(
r#"{"pane"?: "general|display|proofing|save|language|accessibility|advanced|agents", "author"?: string, "initials"?: string, "units"?: "inches|centimeters|millimeters|points|picas|auto" (auto: the region's), "region"?: string (a locale tag like pl-PL, saved: the paper File › New starts on, the default units and the decimal separator; auto: the system's), "typingReplacesSelection"?: bool, "insertKeyOvertype"?: bool, "overtype"?: bool, "marks"?: {"tabs"?: bool, "spaces"?: bool, "paragraphs"?: bool, "hidden"?: bool}, "checkSpelling"?: bool, "markGrammar"?: bool, "ignoreUppercase"?: bool, "ignoreNumbers"?: bool, "ignoreInternet"?: bool, "dictionariesFolder"?: string (a folder of Hunspell .aff/.dic files searched first, saved; auto: the system locations only), "addWords"?: [string], "removeWords"?: [string]} — with any setting it changes them and opens nothing; without, it opens File › Options (at `pane`). Returns the settings."#,
r#"{"pane"?: "general|display|proofing|save|language|accessibility|advanced|agents", "author"?: string, "initials"?: string, "units"?: "inches|centimeters|millimeters|points|picas|auto" (auto: the region's), "region"?: string (a locale tag like pl-PL, saved: the paper File › New starts on, the default units and the decimal separator; auto: the system's), "typingReplacesSelection"?: bool, "insertKeyOvertype"?: bool, "overtype"?: bool, "marks"?: {"tabs"?: bool, "spaces"?: bool, "paragraphs"?: bool, "hidden"?: bool}, "checkSpelling"?: bool, "markGrammar"?: bool, "ignoreUppercase"?: bool, "ignoreNumbers"?: bool, "ignoreInternet"?: bool, "dictionariesFolder"?: string (a folder of Hunspell .aff/.dic files searched first, saved; auto: the system locations only), "userTemplatesFolder"?: string (the folder File › New lists personal templates from, saved; auto: the app's own Templates folder; not a network folder), "addWords"?: [string], "removeWords"?: [string]} — with any setting it changes them and opens nothing; without, it opens File › Options (at `pane`). Returns the settings."#,
)
.pure(),
CommandSpec::new("text.overtype", "Overtype", "File › Options › Advanced", |s, v| {
@@ -117,9 +117,15 @@ fn options(s: &mut Session, v: &Value) -> CmdResult {
};
let folder = match v.get("dictionariesFolder") {
None | Some(Value::Null) => None,
Some(Value::String(f)) => Some(dictionaries_folder(f)?),
Some(Value::String(f)) => Some(folder_setting("dictionariesFolder", f, false)?),
Some(_) => return Err(CmdError::Params("`dictionariesFolder` must be a folder path, or auto".into())),
};
let templates_folder = match v.get("userTemplatesFolder") {
None | Some(Value::Null) => None,
// Never a folder on another machine (#2852): listing it would make Windows authenticate there.
Some(Value::String(f)) => Some(folder_setting("userTemplatesFolder", f, true)?),
Some(_) => return Err(CmdError::Params("`userTemplatesFolder` must be a folder path, or auto".into())),
};
let mut flag_values = Vec::new();
for (k, f) in flags() {
if let Some(b) = bool_param(v, k)? {
@@ -147,6 +153,7 @@ fn options(s: &mut Session, v: &Value) -> CmdResult {
|| units.is_some()
|| region.is_some()
|| folder.is_some()
|| templates_folder.is_some()
|| !flag_values.is_empty()
|| !mark_values.is_empty()
|| check_spelling.is_some()
@@ -168,6 +175,9 @@ fn options(s: &mut Session, v: &Value) -> CmdResult {
use_dictionaries_folder(f.as_deref());
s.prefs.dictionaries_folder = f;
}
if let Some(f) = templates_folder {
s.prefs.user_templates_folder = f;
}
for (f, b) in flag_values {
*f(&mut s.prefs) = b;
}
@@ -221,6 +231,7 @@ pub fn current(s: &Session) -> Value {
"ignoreInternet": pr.ignore_internet,
"dictionary": wordcraft_proof::user_dictionary(),
"dictionariesFolder": pr.dictionaries_folder,
"userTemplatesFolder": pr.user_templates_folder,
"dictionaries": wordcraft_proof::installed_dictionaries()
.into_iter()
.map(|(lang, dic)| json!({"language": lang.code(), "file": dic.map(|d| d.to_string_lossy().into_owned())}))
@@ -228,23 +239,28 @@ pub fn current(s: &Session) -> Value {
})
}
/// The `dictionariesFolder` setting: `auto` (or nothing) forgets it; otherwise an existing
/// folder, given as an absolute path or one starting with `~/`.
fn dictionaries_folder(f: &str) -> Result<Option<String>, CmdError> {
/// A folder setting (`dictionariesFolder`, `userTemplatesFolder`): `auto` (or nothing) forgets
/// it; otherwise an existing folder, given as an absolute path or one starting with `~/`. With
/// `local_only`, a network folder is refused before anything looks at it (#2852): even checking
/// that it exists would make Windows authenticate to that host.
fn folder_setting(key: &str, f: &str, local_only: bool) -> Result<Option<String>, CmdError> {
let f = f.trim();
if f.is_empty() || f.eq_ignore_ascii_case("auto") {
return Ok(None);
}
if f.chars().count() > MAX_FOLDER_CHARS || f.chars().any(char::is_control) {
return Err(CmdError::Params("`dictionariesFolder` isn't a folder path".into()));
return Err(CmdError::Params(format!("`{key}` isn't a folder path")));
}
let home = std::env::var_os("HOME").filter(|h| !h.is_empty()).map(std::path::PathBuf::from);
let path = match (f.strip_prefix("~/"), home) {
(Some(rest), Some(h)) => h.join(rest),
_ => std::path::PathBuf::from(f),
};
if local_only && (super::templates::is_network_path(f) || super::templates::is_network_path(&path.to_string_lossy())) {
return Err(CmdError::Params(format!("`{key}` can't be a network folder")));
}
if !path.is_absolute() {
return Err(CmdError::Params(format!("`dictionariesFolder`: {f:?} isn't a full path")));
return Err(CmdError::Params(format!("`{key}`: {f:?} isn't a full path")));
}
if !path.is_dir() {
return Err(CmdError::Failed(format!("no folder at {}", path.display())));
+1 -1
View File
@@ -240,7 +240,7 @@ pub fn local_path(target: &str, base: Option<&Path>) -> Option<PathBuf> {
Some(p)
}
fn is_network_path(p: &str) -> bool {
pub(crate) fn is_network_path(p: &str) -> bool {
let b = p.as_bytes();
matches!((b.first(), b.get(1)), (Some(b'\\' | b'/'), Some(b'\\' | b'/')))
}
+6 -3
View File
@@ -489,17 +489,20 @@ pub fn specs() -> Vec<CommandSpec> {
.pure(),
CommandSpec::new("file.newFromTemplate", "New from Template", "File › New", |s, v| {
let path = p::req_str(v, "path")?;
let doc = crate::io::open_path(std::path::Path::new(path)).map_err(CmdError::Failed)?;
let mut doc = crate::io::open_path(std::path::Path::new(path)).map_err(CmdError::Failed)?;
// As in Word, the new document is attached to the template it came from (#2852).
doc.settings.attached_template = Some(path.to_string());
s.set_document(doc);
s.path = None;
s.dirty = true;
sel_result(s)
})
.params(r#"{"path": string}"#)
.params(r#"{"path": string} → a new, untitled document from the template, attached to it"#)
.pure(),
CommandSpec::new("file.saveTemplate", "Save as Template", "File › Save As", |s, v| {
let path = p::req_str(v, "path")?;
let path = if path.ends_with(".dotx") { path.to_string() } else { format!("{path}.dotx") };
// A .dotm keeps its name (#2852); anything else becomes a .dotx.
let path = if super::user_templates::is_template_name(path) { path.to_string() } else { format!("{path}.dotx") };
crate::io::save_path(std::path::Path::new(&path), &s.doc).map_err(CmdError::Failed)?;
Ok(json!({"path": path}))
})
+393
View File
@@ -0,0 +1,393 @@
//! Personal templates (#2852): the `.dotx`/`.dotm` files in the user templates folder, which
//! File › New lists next to the built-in templates.
//!
//! The folder is the user's own (File › Options › Advanced › File locations, else the app's
//! `Templates` folder, which the front end passes as `dir`). As in Word, the templates at its top
//! level are listed, and each folder directly inside it is a category of the templates it holds;
//! deeper folders are not looked into. A template is named by its file name, as in Word's
//! gallery; its title (the core properties' `dc:title`), when it has one, comes along for search
//! (a template saved from a document often keeps that document's title, so it can't be the name).
//!
//! Everything read here comes from the disk, so it is bounded: the number of entries looked at,
//! of categories and of templates listed, the size of a template whose title is read and of the
//! part it is read from. A file that can't be read is listed by its name or skipped, never an
//! error. A network folder (`\\host\share`) is refused, as attached templates are
//! ([`super::templates`]), and the automation read root applies to the folder and each file.
use serde_json::{Value, json};
use crate::{CmdError, CmdResult, CommandSpec, Session};
/// Most templates one listing returns.
pub const MAX_TEMPLATES: usize = 500;
/// Most category folders looked into.
pub const MAX_CATEGORIES: usize = 64;
/// Most directory entries looked at in one folder.
const MAX_ENTRIES: usize = 10_000;
/// Longest folder path taken, in bytes.
const MAX_DIR: usize = 32 * 1024;
/// Largest template whose title is read (bigger ones are listed by file name).
#[cfg(not(target_arch = "wasm32"))]
const MAX_TITLE_FILE: u64 = 64 << 20;
/// Most bytes of the core properties part read for the title.
#[cfg(not(target_arch = "wasm32"))]
const MAX_CORE_XML: u64 = 64 << 10;
pub fn specs() -> Vec<CommandSpec> {
vec![
CommandSpec::new("file.listTemplates", "Personal Templates", "File › New", list)
.params(r#"{"dir"?: string (the user templates folder; default: the one saved in File › Options)} → {"dir", "templates": [{"label" (the file name without its extension), "title": string|null (dc:title), "path", "category" ("" at the top level, else the subfolder's name), "modified": ISO-8601|null}] (.dotx and .dotm, at most 500, by category then label), "truncated": bool}; a network folder is refused"#)
.pure(),
]
}
/// One template in the folder.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct PersonalTemplate {
/// Its file name without the extension.
pub label: String,
/// The title in its core properties, if any.
pub title: Option<String>,
pub path: String,
/// The subfolder it is in; empty at the top level.
pub category: String,
/// When the file last changed (ISO-8601, UTC), if the system says.
pub modified: Option<String>,
}
/// What [`list_templates`] found.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct Listing {
pub templates: Vec<PersonalTemplate>,
/// Some were left out (a cap was reached).
pub truncated: bool,
}
fn list(s: &mut Session, v: &Value) -> CmdResult {
let dir = match v.get("dir") {
None | Some(Value::Null) => {
s.prefs.user_templates_folder.clone().ok_or_else(|| CmdError::Params("give `dir`: no user templates folder is saved".into()))?
}
Some(Value::String(d)) => d.clone(),
Some(_) => return Err(CmdError::Params("`dir` must be a folder path".into())),
};
let found = list_templates(&dir)?;
Ok(json!({
"dir": dir,
"templates": found.templates.iter().map(|t| json!({"label": t.label, "title": t.title, "path": t.path, "category": t.category, "modified": t.modified})).collect::<Vec<_>>(),
"truncated": found.truncated,
}))
}
/// Check that `dir` may be listed: a full local path, not too long, not on another machine.
pub fn check_dir(dir: &str) -> Result<(), CmdError> {
let d = dir.trim();
if d.is_empty() {
return Err(CmdError::Params("`dir` is empty".into()));
}
if d.len() > MAX_DIR || d.chars().any(char::is_control) {
return Err(CmdError::Params("`dir` isn't a folder path".into()));
}
// A folder on another machine: listing it would make Windows authenticate to that host.
if super::templates::is_network_path(d) {
return Err(CmdError::Params("templates aren't listed from a network folder".into()));
}
if !std::path::Path::new(d).is_absolute() {
return Err(CmdError::Params(format!("`dir`: {d:?} isn't a full path")));
}
Ok(())
}
/// The personal templates in `dir` (see the module docs).
#[cfg(not(target_arch = "wasm32"))]
pub fn list_templates(dir: &str) -> Result<Listing, CmdError> {
use std::path::Path;
check_dir(dir)?;
let dir = dir.trim();
crate::file_access::check_read(dir).map_err(CmdError::Failed)?;
let root = Path::new(dir);
if !root.is_dir() {
return Err(CmdError::Failed(format!("no folder at {dir}")));
}
let mut out = Listing::default();
let (files, folders, cut) = entries(root);
out.truncated |= cut;
let mut found: Vec<(String, crate::io::DirEntry)> = files.into_iter().map(|f| (String::new(), f)).collect();
let mut folders = folders;
folders.sort();
if folders.len() > MAX_CATEGORIES {
folders.truncate(MAX_CATEGORIES);
out.truncated = true;
}
for folder in folders {
let Some(category) = folder.file_name().map(|n| n.to_string_lossy().into_owned()) else { continue };
if crate::file_access::check_read(&folder.to_string_lossy()).is_err() {
continue;
}
let (files, _, cut) = entries(&folder);
out.truncated |= cut;
found.extend(files.into_iter().map(|f| (category.clone(), f)));
}
// The cap applies to what is listed; titles are read only for those.
found.sort_by(|a, b| a.0.to_lowercase().cmp(&b.0.to_lowercase()).then_with(|| a.1.path.cmp(&b.1.path)));
if found.len() > MAX_TEMPLATES {
found.truncate(MAX_TEMPLATES);
out.truncated = true;
}
for (category, e) in found {
let title = title_of(&e.path);
let modified = e.modified.map(super::iso_from_unix_secs);
out.templates.push(PersonalTemplate { label: label_of(&e.name), title, path: e.path.to_string_lossy().into_owned(), category, modified });
}
// Two templates of one category that would show the same name (`Memo.dotx`, `Memo.dotm`)
// show their file names instead, so they can be told apart.
let key = |t: &PersonalTemplate| (t.category.to_lowercase(), t.label.to_lowercase());
let mut seen = std::collections::HashMap::<(String, String), usize>::new();
for t in &out.templates {
*seen.entry(key(t)).or_default() += 1;
}
for t in &mut out.templates {
if seen.get(&key(t)).is_some_and(|n| *n > 1)
&& let Some(name) = std::path::Path::new(&t.path).file_name()
{
t.label = name.to_string_lossy().into_owned();
}
}
// The top level first, then each category; by label within them.
out.templates.sort_by_cached_key(|t| (!t.category.is_empty(), t.category.to_lowercase(), t.label.to_lowercase(), t.path.clone()));
Ok(out)
}
#[cfg(target_arch = "wasm32")]
pub fn list_templates(dir: &str) -> Result<Listing, CmdError> {
check_dir(dir)?;
Err(CmdError::Failed("templates folders can't be listed on the web".into()))
}
/// The template files and the folders directly in `dir` (hidden ones left out), and whether the
/// entry cap cut the listing short. An unreadable folder has none.
#[cfg(not(target_arch = "wasm32"))]
fn entries(dir: &std::path::Path) -> (Vec<crate::io::DirEntry>, Vec<std::path::PathBuf>, bool) {
let Ok((list, cut)) = crate::io::list_dir(dir, MAX_ENTRIES) else { return (Vec::new(), Vec::new(), false) };
let (mut files, mut folders) = (Vec::new(), Vec::new());
for e in list {
if e.name.starts_with('.') || e.name.starts_with("~$") {
continue;
}
if e.dir {
folders.push(e.path);
} else if is_template_name(&e.name) {
files.push(e);
}
}
(files, folders, cut)
}
/// The name a template shows: its file name without the template extension, every one of them
/// (`Contract.dotm.dotx`, as older Save as Template made, is `Contract`); the whole name if
/// nothing would be left.
fn label_of(name: &str) -> String {
let mut label = name;
while is_template_name(label) {
let Some(stem) = label.get(..label.len().saturating_sub(5)) else { break };
label = stem;
}
let label = label.trim_end();
if label.is_empty() { name.to_string() } else { label.to_string() }
}
/// A Word template by its name: `.dotx` or `.dotm`, in any case.
pub fn is_template_name(name: &str) -> bool {
let lower = name.to_ascii_lowercase();
(lower.ends_with(".dotx") || lower.ends_with(".dotm")) && lower.len() > 5
}
/// The template's title from its core properties, read from the package's directory and at most
/// [`MAX_CORE_XML`] bytes of the part; `None` for anything else.
#[cfg(not(target_arch = "wasm32"))]
fn title_of(path: &std::path::Path) -> Option<String> {
let title = crate::io::package_title(path, MAX_TITLE_FILE, MAX_CORE_XML)?;
// One line of a sensible length for a gallery caption.
let title: String = title.chars().map(|c| if c.is_control() { ' ' } else { c }).take(200).collect();
let title = title.trim();
(!title.is_empty()).then(|| title.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn scratch(tag: &str) -> std::path::PathBuf {
let d = std::env::temp_dir().join(format!("wc-personal-templates-{tag}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&d);
std::fs::create_dir_all(&d).unwrap();
d
}
/// A real template written by the engine, with `title` as its core title (empty: none).
fn template(path: &std::path::Path, text: &str, title: &str) {
let mut s = Session::new(wordcraft_doc::Document::new());
s.run("document.setText", &json!({"text": text})).unwrap();
s.doc.core.title = title.into();
crate::io::save_path(path, &s.doc).unwrap();
}
fn labels(r: &Value) -> Vec<(String, String)> {
r["templates"]
.as_array()
.unwrap()
.iter()
.map(|t| (t["category"].as_str().unwrap().to_string(), t["label"].as_str().unwrap().to_string()))
.collect()
}
#[test]
fn lists_dotx_and_dotm_with_subfolders_as_categories() {
let d = scratch("list");
template(&d.join("Weekly report.dotx"), "Report body", "");
template(&d.join("letterhead.dotx"), "Letter body", "Company letterhead");
template(&d.join("Macros.dotm"), "Macro body", "");
template(&d.join("Not a template.docx"), "Doc body", "");
std::fs::write(d.join("notes.txt"), "x").unwrap();
std::fs::write(d.join("Broken.dotx"), b"this is no zip").unwrap();
std::fs::write(d.join(".hidden.dotx"), b"x").unwrap();
std::fs::create_dir_all(d.join("Legal/Deeper")).unwrap();
template(&d.join("Legal/NDA.DOTX"), "NDA body", "");
template(&d.join("Legal/Deeper/Too deep.dotx"), "x", "");
std::fs::create_dir_all(d.join("Empty")).unwrap();
let mut s = Session::new(wordcraft_doc::Document::new());
let r = s.run("file.listTemplates", &json!({"dir": d.to_string_lossy()})).unwrap();
assert_eq!(
labels(&r),
[
("".into(), "Broken".into()),
("".into(), "letterhead".into()),
("".into(), "Macros".into()),
("".into(), "Weekly report".into()),
("Legal".into(), "NDA".into()),
],
"{r}"
);
assert_eq!(r["truncated"], json!(false));
let first = &r["templates"][1];
assert_eq!(first["title"], json!("Company letterhead"));
assert_eq!(r["templates"][0]["title"], json!(null), "a broken file has no title");
assert!(first["path"].as_str().unwrap().ends_with("letterhead.dotx"));
assert!(first["modified"].as_str().is_some_and(|m| m.ends_with('Z')));
let _ = std::fs::remove_dir_all(&d);
}
#[test]
fn refuses_network_relative_and_missing_folders() {
let mut s = Session::new(wordcraft_doc::Document::new());
for dir in [r"\\host\share\Templates", "//host/share/Templates", "relative/Templates", "", " "] {
assert!(matches!(s.run("file.listTemplates", &json!({"dir": dir})), Err(CmdError::Params(_))), "{dir:?}");
}
let missing = std::env::temp_dir().join(format!("wc-no-such-templates-{}", std::process::id()));
assert!(s.run("file.listTemplates", &json!({"dir": missing.to_string_lossy()})).is_err());
// Without `dir` the saved folder is used; none saved is an error.
assert!(matches!(s.run("file.listTemplates", &json!({})), Err(CmdError::Params(_))));
for junk in [json!(null), json!({"dir": 5}), json!({"dir": []}), json!({"dir": "x".repeat(40_000)}), json!({"dir": "/a\u{0}b"})] {
let _ = s.run("file.listTemplates", &junk);
}
}
#[test]
fn the_saved_folder_is_listed_without_dir() {
let d = scratch("saved");
template(&d.join("Saved.dotx"), "x", "");
let mut s = Session::new(wordcraft_doc::Document::new());
s.run("file.options", &json!({"userTemplatesFolder": d.to_string_lossy()})).unwrap();
let r = s.run("file.listTemplates", &json!({})).unwrap();
assert_eq!(labels(&r), [("".to_string(), "Saved".to_string())]);
let _ = std::fs::remove_dir_all(&d);
}
#[test]
fn the_listing_is_capped() {
let d = scratch("cap");
for i in 0..MAX_TEMPLATES + 5 {
std::fs::write(d.join(format!("t{i:04}.dotx")), b"").unwrap();
}
let found = list_templates(&d.to_string_lossy()).unwrap();
assert_eq!(found.templates.len(), MAX_TEMPLATES);
assert!(found.truncated);
assert_eq!(found.templates.first().map(|t| t.label.as_str()), Some("t0000"));
let _ = std::fs::remove_dir_all(&d);
}
/// A document made from a personal template is untitled, unsaved and attached to the
/// template, as in Word.
#[test]
fn new_from_template_is_untitled_and_attached() {
let d = scratch("new");
let path = d.join("Memo.dotx");
template(&path, "Memo text", "");
let mut s = Session::new(wordcraft_doc::Document::new());
s.path = Some(d.join("Earlier.docx"));
s.run("file.newFromTemplate", &json!({"path": path.to_string_lossy()})).unwrap();
assert_eq!(s.path, None);
assert!(s.dirty);
assert_eq!(s.doc.settings.attached_template.as_deref(), Some(path.to_string_lossy().as_ref()));
assert!(s.doc.plain_text(wordcraft_doc::StoryRef::Body).contains("Memo text"));
let _ = std::fs::remove_dir_all(&d);
}
#[test]
fn the_templates_folder_option_is_saved_and_checked() {
let d = scratch("option");
let mut s = Session::new(wordcraft_doc::Document::new());
for bad in [json!(r"\\host\share"), json!("//host/share"), json!("relative/x"), json!(7)] {
assert!(s.run("file.options", &json!({"userTemplatesFolder": bad})).is_err(), "{bad}");
}
assert!(s.run("file.options", &json!({"userTemplatesFolder": d.join("missing").to_string_lossy()})).is_err());
assert_eq!(s.prefs.user_templates_folder, None, "a refused folder changes nothing");
let r = s.run("file.options", &json!({"userTemplatesFolder": d.to_string_lossy()})).unwrap();
assert_eq!(r["userTemplatesFolder"], json!(d.to_string_lossy()));
assert_eq!(serde_json::to_value(&s.prefs).unwrap()["userTemplatesFolder"], json!(d.to_string_lossy()));
s.run("file.options", &json!({"userTemplatesFolder": "auto"})).unwrap();
assert_eq!(s.prefs.user_templates_folder, None);
let _ = std::fs::remove_dir_all(&d);
}
/// .dotx and .dotm templates show their names the same way, without the extension; two that
/// would look alike in one category show their file names.
#[test]
fn labels_drop_the_extension_alike_and_tell_twins_apart() {
let d = scratch("labels");
template(&d.join("Service contract.dotm"), "a", "");
template(&d.join("Old contract.dotm.dotx"), "b", "");
template(&d.join("Memo.dotx"), "c", "");
template(&d.join("Memo.DOTM"), "d", "");
std::fs::create_dir_all(d.join("Other")).unwrap();
template(&d.join("Other/Memo.dotx"), "e", "");
let found = list_templates(&d.to_string_lossy()).unwrap();
let got: Vec<(&str, &str)> = found.templates.iter().map(|t| (t.category.as_str(), t.label.as_str())).collect();
assert_eq!(got, [("", "Memo.DOTM"), ("", "Memo.dotx"), ("", "Old contract"), ("", "Service contract"), ("Other", "Memo")]);
assert_eq!(label_of(".dotx.dotm"), ".dotx", "never empty");
assert_eq!(label_of(" .dotx"), " .dotx", "nothing left: the whole name");
assert_eq!(label_of("é.dotx"), "é");
let _ = std::fs::remove_dir_all(&d);
}
/// Save as Template keeps a .dotm (any case) and makes anything else a .dotx.
#[test]
fn save_template_keeps_a_template_extension() {
let d = scratch("save");
let mut s = Session::new(wordcraft_doc::Document::new());
for (asked, saved) in [("A.dotm", "A.dotm"), ("B.DOTX", "B.DOTX"), ("C", "C.dotx"), ("D.docx", "D.docx.dotx")] {
let r = s.run("file.saveTemplate", &json!({"path": d.join(asked).to_string_lossy()})).unwrap();
assert_eq!(r["path"], json!(d.join(saved).to_string_lossy()));
assert!(d.join(saved).is_file(), "{saved}");
}
let _ = std::fs::remove_dir_all(&d);
}
#[test]
fn template_names() {
assert!(is_template_name("a.dotx") && is_template_name("B.DOTM"));
assert!(!is_template_name(".dotx") && !is_template_name("a.docx") && !is_template_name("a.dot"));
}
}
+53
View File
@@ -561,6 +561,59 @@ pub fn read_file_head(path: &str, max: u64) -> Result<Vec<u8>, String> {
Ok(head)
}
/// An entry of [`list_dir`]: a file or folder (links followed).
#[cfg(not(target_arch = "wasm32"))]
#[derive(Clone, Debug)]
pub struct DirEntry {
pub name: String,
pub path: std::path::PathBuf,
pub dir: bool,
/// The file's size in bytes (0 for a folder).
pub len: u64,
/// When it last changed, in seconds since the Unix epoch, if the system says.
pub modified: Option<u64>,
}
/// The files and folders in the folder at `path`, inside the automation read root (entries
/// leading outside it, and ones that can't be read, are left out); at most `max` entries are
/// looked at, and `true` says the listing was cut short there.
#[cfg(not(target_arch = "wasm32"))]
pub fn list_dir(path: &std::path::Path, max: usize) -> Result<(Vec<DirEntry>, bool), String> {
crate::file_access::check_read(&path.to_string_lossy())?;
let read = std::fs::read_dir(path).map_err(|e| format!("{}: {e}", path.display()))?;
let mut out = Vec::new();
for (seen, entry) in read.enumerate() {
if seen >= max {
return Ok((out, true));
}
let Ok(entry) = entry else { continue };
let path = entry.path();
if crate::file_access::check_read(&path.to_string_lossy()).is_err() {
continue;
}
let Ok(meta) = std::fs::metadata(&path) else { continue };
if !meta.is_dir() && !meta.is_file() {
continue;
}
let modified = meta.modified().ok().and_then(|m| m.duration_since(std::time::UNIX_EPOCH).ok()).map(|d| d.as_secs());
let name = entry.file_name().to_string_lossy().into_owned();
out.push(DirEntry { name, path, dir: meta.is_dir(), len: if meta.is_file() { meta.len() } else { 0 }, modified });
}
Ok((out, false))
}
/// The title in the core properties of the Word package at `path` (a file of at most `max_file`
/// bytes; at most `max_part` bytes of the part are read), inside the automation read root.
#[cfg(not(target_arch = "wasm32"))]
pub fn package_title(path: &std::path::Path, max_file: u64, max_part: u64) -> Option<String> {
crate::file_access::check_read(&path.to_string_lossy()).ok()?;
let file = std::fs::File::open(path).ok()?;
if file.metadata().ok()?.len() > max_file {
return None;
}
wordcraft_docx::package_title(std::io::BufReader::new(file), max_part)
}
/// Rename without replacing another file, respecting both automation access roots.
#[cfg(not(target_arch = "wasm32"))]
pub fn rename_file(source: &std::path::Path, destination: &std::path::Path) -> Result<(), String> {
+5
View File
@@ -552,6 +552,10 @@ pub struct Prefs {
/// Proofing, #1063): an app started from Finder or the Dock doesn't see
/// `WORDCRAFT_DICTIONARIES`. `None` searches the system locations only.
pub dictionaries_folder: Option<String>,
/// The user templates folder File › New lists personal templates from and Save as Word
/// Template starts in (File › Options › Advanced › File locations, #2852). `None`: the app's
/// own `Templates` folder in its settings folder.
pub user_templates_folder: Option<String>,
/// Formatting marks shown even while Show/Hide ¶ is off.
pub marks: wordcraft_layout::display::FormattingMarks,
/// Mark grammar errors as you type (with Check spelling as you type on).
@@ -577,6 +581,7 @@ impl Default for Prefs {
units: Default::default(),
region: None,
dictionaries_folder: None,
user_templates_folder: None,
marks: Default::default(),
mark_grammar: true,
ignore_uppercase: true,
+1
View File
@@ -102,6 +102,7 @@ fn commands_that_read_a_path_are_held_to_the_read_root() {
("insert.picture", json!({"path": s(&base.join("outside/pic.png"))})),
("mailings.recipients", json!({"path": outside})),
("file.newFromTemplate", json!({"path": outside})),
("file.listTemplates", json!({"dir": s(&base.join("outside"))})),
("review.compare", json!({"path": outside})),
("review.compare", json!({"text": "x", "original": outside})),
("file.open", json!({"path": outside})),
+351 -7
View File
@@ -317,7 +317,8 @@ fn home_page(app: &mut WordApp, ui: &mut Ui) {
open_list(app, ui);
}
/// New: the whole template gallery with a search box; no Recent list (that is on Home and Open).
/// New: the whole template gallery with a search box, then the personal templates (#2852); no
/// Recent list (that is on Home and Open). The search covers both.
fn new_page(app: &mut WordApp, ui: &mut Ui) {
let t = Tokens::get(ui.ctx());
heading(ui, "New");
@@ -329,16 +330,173 @@ fn new_page(app: &mut WordApp, ui: &mut Ui) {
}
ui.add_space(18.0);
let found = matching_templates(&query);
if found.is_empty() {
let (folder, personal) = personal_templates(app, ui);
let personal_found = matching_personal(&query, &personal);
if found.is_empty() && personal_found.is_empty() {
ui.label(egui::RichText::new(tl!("No templates match your search.")).color(t.text_dim));
return;
}
ui.horizontal_wrapped(|ui| {
ui.spacing_mut().item_spacing = vec2(18.0, 18.0);
for (label, template) in found {
template_tile(ui, app, label, template);
if !found.is_empty() {
ui.horizontal_wrapped(|ui| {
ui.spacing_mut().item_spacing = vec2(18.0, 18.0);
for (label, template) in found {
template_tile(ui, app, label, template);
}
});
}
// Personal: the user templates folder's templates; a folder per category, as in Word.
let Some(folder) = folder else { return };
if !query.trim().is_empty() && personal_found.is_empty() {
return;
}
ui.add_space(24.0);
ui.horizontal(|ui| {
ui.label(egui::RichText::new(tl!("Personal")).font(semibold(16.0)));
ui.add_space(8.0);
if ui.small_button(tl!("Refresh")).on_hover_text(folder.to_string_lossy()).clicked() {
refresh_personal_templates(ui.ctx());
}
});
ui.add_space(8.0);
let error = ui.ctx().data(|d| d.get_temp::<String>(personal_error_id())).unwrap_or_default();
if !error.is_empty() {
ui.label(egui::RichText::new(error).color(ui.visuals().error_fg_color));
ui.add_space(6.0);
}
if personal.is_empty() {
ui.label(egui::RichText::new(tl!("Templates you save in your templates folder show up here.")).color(t.text_dim));
ui.label(egui::RichText::new(folder.to_string_lossy()).font(regular(11.5)).color(t.text_dim));
return;
}
let mut categories: Vec<&str> = Vec::new();
for p in &personal_found {
if !categories.contains(&p.category.as_str()) {
categories.push(&p.category);
}
}
for category in categories {
if !category.is_empty() {
ui.add_space(12.0);
ui.label(egui::RichText::new(category).font(medium(13.5)).color(t.text));
ui.add_space(6.0);
}
ui.horizontal_wrapped(|ui| {
ui.spacing_mut().item_spacing = vec2(18.0, 18.0);
for p in personal_found.iter().filter(|p| p.category == category) {
personal_tile(ui, app, p);
}
});
}
}
/// A personal template: what the gallery needs of a [`PersonalTemplate`] listing entry.
type PersonalTemplate = wordcraft_engine::cmd::user_templates::PersonalTemplate;
/// The templates folder setting and listing File › New shows, and the pass it was last shown in.
#[derive(Clone)]
struct PersonalListing {
key: (Option<String>, Option<std::path::PathBuf>),
pass: u64,
folder: Option<std::path::PathBuf>,
templates: Vec<PersonalTemplate>,
}
fn personal_listing_id() -> egui::Id {
egui::Id::new("backstage_personal_templates")
}
#[cfg(test)]
thread_local! {
/// How many times this thread has listed the templates folder (tests check it isn't every frame).
static PERSONAL_LISTINGS: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
}
/// The user templates folder and its templates (#2852). The folder is read when New is shown (so
/// a template saved meanwhile, or a folder chosen in Options, appears), when its setting changes
/// and on Refresh; never on every frame, as reading a big or synced folder would stall the window.
/// A folder that isn't there lists nothing.
fn personal_templates(app: &WordApp, ui: &Ui) -> (Option<std::path::PathBuf>, Vec<PersonalTemplate>) {
let key = (app.session.prefs.user_templates_folder.clone(), app.services.templates_dir.clone());
let pass = ui.ctx().cumulative_pass_nr();
let cached = ui.ctx().data(|d| d.get_temp::<PersonalListing>(personal_listing_id()));
// Still on New since the last pass: the listing stands.
if let Some(mut l) = cached
&& l.key == key
&& l.pass.saturating_add(1) >= pass
{
l.pass = pass;
let out = (l.folder.clone(), l.templates.clone());
ui.ctx().data_mut(|d| d.insert_temp(personal_listing_id(), l));
return out;
}
let folder = app.user_templates_dir();
let templates = match &folder {
Some(f) if f.is_dir() => {
#[cfg(test)]
PERSONAL_LISTINGS.with(|n| n.set(n.get().saturating_add(1)));
wordcraft_engine::cmd::user_templates::list_templates(&f.to_string_lossy()).map(|l| l.templates).unwrap_or_else(|e| {
log::debug!("personal templates: {e}");
Vec::new()
})
}
_ => Vec::new(),
};
let l = PersonalListing { key, pass, folder: folder.clone(), templates: templates.clone() };
ui.ctx().data_mut(|d| d.insert_temp(personal_listing_id(), l));
(folder, templates)
}
/// Read the templates folder again on the next pass (File › New › Personal's Refresh).
fn refresh_personal_templates(ctx: &egui::Context) {
ctx.data_mut(|d| d.remove::<PersonalListing>(personal_listing_id()));
ctx.request_repaint();
}
/// The personal templates whose name, title or category contains `query` (case-insensitive);
/// all of them for an empty query.
fn matching_personal(query: &str, list: &[PersonalTemplate]) -> Vec<PersonalTemplate> {
let q = query.trim().to_lowercase();
let has = |s: &str| s.to_lowercase().contains(&q);
list.iter().filter(|p| q.is_empty() || has(&p.label) || has(&p.category) || p.title.as_deref().is_some_and(has)).cloned().collect()
}
/// A personal template's tile: a page with the template mark (no thumbnail) and its name; a
/// click makes a new document from it.
fn personal_tile(ui: &mut Ui, app: &mut WordApp, p: &PersonalTemplate) {
let t = Tokens::get(ui.ctx());
ui.vertical(|ui| {
ui.spacing_mut().item_spacing.y = 0.0;
ui.set_max_width(150.0);
let (r, resp) = ui.allocate_exact_size(vec2(150.0, 194.0), Sense::click());
ui.painter().rect(
r,
2.0,
egui::Color32::WHITE,
Stroke::new(if resp.hovered() { 2.0 } else { 1.0 }, if resp.hovered() { t.accent } else { t.border_strong }),
egui::StrokeKind::Inside,
);
let mark = egui::Color32::from_gray(150);
icons::paint(ui.painter(), Rect::from_center_size(r.center(), vec2(44.0, 44.0)), "documentTemplate", mark, t.accent);
let resp = resp.on_hover_text(&p.path);
let caption = ui.add(egui::Label::new(egui::RichText::new(&p.label).font(regular(12.5))).truncate().sense(Sense::click()));
if resp.clicked() || caption.clicked() {
let failed = open_personal(app, &p.path).err();
ui.ctx().data_mut(|d| d.insert_temp(personal_error_id(), failed.unwrap_or_default()));
}
});
}
/// Make a new document from the personal template at `path` and close File; a template that
/// can't be read keeps File open so the gallery can say why.
fn open_personal(app: &mut WordApp, path: &str) -> Result<(), String> {
app.run("file.newFromTemplate", json!({"path": path}))?;
app.ui.backstage = false;
app.canvas.want_focus = true;
Ok(())
}
fn personal_error_id() -> egui::Id {
egui::Id::new("backstage_personal_error")
}
fn open_list(app: &mut WordApp, ui: &mut Ui) {
@@ -375,7 +533,10 @@ fn open_error_id() -> egui::Id {
/// Drop the Recent list's open error once File is closed, so it doesn't come back next time.
pub(crate) fn forget_open_error(ctx: &egui::Context) {
ctx.data_mut(|d| d.remove::<String>(open_error_id()));
ctx.data_mut(|d| {
d.remove::<String>(open_error_id());
d.remove::<String>(personal_error_id());
});
}
/// Open a file from the Recent list. A file that can't be opened (deleted, moved, unreadable)
@@ -413,6 +574,15 @@ fn save_as_page(app: &mut WordApp, ui: &mut Ui) {
if ui.button(egui::RichText::new(tl!("Browse…")).font(medium(14.0))).clicked() {
app.save_as_dialog();
}
// A Word template in the user templates folder, which File › New lists under Personal (#2852).
ui.add_space(8.0);
if ui
.button(egui::RichText::new(tl!("Save as Word Template…")).font(medium(14.0)))
.on_hover_text(tl!("Saves a .dotx template in your templates folder. It shows up in File › New under Personal."))
.clicked()
{
let _ = app.run("ui.saveAsTemplate", json!({}));
}
}
fn info_page(app: &mut WordApp, ui: &mut Ui) {
@@ -733,6 +903,180 @@ mod tests {
assert!(ids("no such template").is_empty());
}
fn personal_dir(tag: &str) -> std::path::PathBuf {
let d = std::env::temp_dir().join(format!("wc-ui-personal-{tag}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&d);
std::fs::create_dir_all(&d).unwrap();
d
}
fn write_template(path: &std::path::Path, text: &str) {
let mut s = wordcraft_engine::Session::new(wordcraft_doc::Document::new());
s.run("document.setText", &json!({"text": text})).unwrap();
wordcraft_engine::io::save_path(path, &s.doc).unwrap();
}
fn new_page_harness(templates: Option<std::path::PathBuf>) -> egui_kittest::Harness<'static, WordApp> {
let services = Services { templates_dir: templates, ..Default::default() };
let mut a = WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), services);
a.run("ui.backstage", json!({"value": true, "page": "new"})).unwrap();
let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1440.0, 1100.0)).build_ui_state(
|ui, app: &mut WordApp| {
let ctx = ui.ctx().clone();
app.logic(&ctx);
app.ui(ui);
},
a,
);
h.run_steps(4);
h
}
/// File › New lists the user templates folder's templates under Personal (#2852), by
/// category; the search covers them; a click makes an untitled document attached to the
/// template.
#[test]
fn new_lists_personal_templates_and_opens_one() {
let dir = personal_dir("gallery");
let memo = dir.join("Team memo.dotx");
write_template(&memo, "Memo from the template");
std::fs::create_dir_all(dir.join("Legal")).unwrap();
write_template(&dir.join("Legal").join("Contract.dotx"), "Contract");
let mut h = new_page_harness(Some(dir.clone()));
assert!(h.query_by_label("Personal").is_some(), "New has a Personal section");
assert!(h.query_by_label("Team memo").is_some(), "{:?}", h.state().user_templates_dir());
assert!(h.query_by_label("Legal").is_some(), "a subfolder is a category");
assert!(h.query_by_label("Report").is_some(), "the built-in templates stay");
// The search covers both.
h.ctx.data_mut(|d| d.insert_temp(egui::Id::new("backstage_template_search"), "memo".to_string()));
h.run_steps(3);
assert!(h.query_by_label("Team memo").is_some(), "search finds a personal template");
assert!(h.query_by_label("Contract").is_none() && h.query_by_label("Report").is_none());
h.ctx.data_mut(|d| d.insert_temp(egui::Id::new("backstage_template_search"), "zzz".to_string()));
h.run_steps(3);
assert!(h.query_by_label("No templates match your search.").is_some());
h.ctx.data_mut(|d| d.insert_temp(egui::Id::new("backstage_template_search"), String::new()));
h.run_steps(3);
h.get_by_label("Team memo").click();
h.run_steps(4);
let a = h.state();
assert!(!a.ui.backstage, "File closes");
assert_eq!(a.session.path, None, "an untitled document");
assert_eq!(a.session.doc.settings.attached_template.as_deref(), Some(memo.to_string_lossy().as_ref()));
assert!(a.session.doc.plain_text(wordcraft_doc::StoryRef::Body).contains("Memo from the template"));
drop(h);
let _ = std::fs::remove_dir_all(&dir);
}
/// An empty (or not yet made) templates folder says where templates come from; no folder (the
/// web) shows no Personal section. Home keeps only the built-ins.
#[test]
fn personal_section_empty_state_and_no_folder() {
let dir = personal_dir("empty").join("Templates");
let h = new_page_harness(Some(dir.clone()));
assert!(h.query_by_label("Templates you save in your templates folder show up here.").is_some());
assert!(!dir.exists(), "listing doesn't make the folder");
drop(h);
let h = new_page_harness(None);
assert!(h.query_by_label("Personal").is_none());
}
/// A folder chosen in File › Options › Advanced › File locations replaces the app's own.
#[test]
fn the_options_folder_replaces_the_default() {
let own = personal_dir("own");
write_template(&own.join("Own.dotx"), "x");
let chosen = personal_dir("chosen");
write_template(&chosen.join("Chosen.dotx"), "y");
let mut h = new_page_harness(Some(own.clone()));
assert!(h.query_by_label("Own").is_some());
h.state_mut().run("file.options", json!({"userTemplatesFolder": chosen.to_string_lossy()})).unwrap();
h.run_steps(3);
assert!(h.query_by_label("Chosen").is_some(), "the chosen folder is listed");
assert!(h.query_by_label("Own").is_none());
drop(h);
let _ = std::fs::remove_dir_all(&own);
let _ = std::fs::remove_dir_all(&chosen);
}
/// A personal template replaces the document like New does: unsaved changes are asked about
/// first, and nothing is replaced until that is answered.
#[test]
fn a_personal_template_asks_about_unsaved_changes_first() {
let dir = personal_dir("dirty");
let memo = dir.join("Memo.dotx");
write_template(&memo, "From the template");
let mut h = new_page_harness(Some(dir.clone()));
h.state_mut().run("document.setText", json!({"text": "Unsaved work"})).unwrap();
assert!(h.state().session.dirty);
h.get_by_label("Memo").click();
h.run_steps(3);
let body = |h: &egui_kittest::Harness<'static, WordApp>| h.state().session.doc.plain_text(wordcraft_doc::StoryRef::Body);
assert!(matches!(h.state().dialog, Some(crate::dialogs::Dialog::SaveChanges { .. })), "Save changes is asked");
assert_eq!(body(&h), "Unsaved work", "the document stays until the question is answered");
h.state_mut().run("ui.saveChanges", json!({"answer": "cancel"})).unwrap();
h.run_steps(2);
assert_eq!(body(&h), "Unsaved work", "Cancel keeps it");
assert_eq!(h.state().session.doc.settings.attached_template, None);
h.state_mut().run("ui.backstage", json!({"value": true, "page": "new"})).unwrap();
h.run_steps(3);
h.get_by_label("Memo").click();
h.run_steps(3);
h.state_mut().run("ui.saveChanges", json!({"answer": "dontSave"})).unwrap();
h.run_steps(2);
assert!(body(&h).contains("From the template"), "Don't Save makes the document from the template");
assert_eq!(h.state().session.doc.settings.attached_template.as_deref(), Some(memo.to_string_lossy().as_ref()));
drop(h);
let _ = std::fs::remove_dir_all(&dir);
}
/// The templates folder is read when New is shown and on Refresh, not on every frame.
#[test]
fn personal_templates_are_listed_once_per_visit_and_on_refresh() {
let dir = personal_dir("refresh");
write_template(&dir.join("First.dotx"), "x");
let count = || super::PERSONAL_LISTINGS.with(std::cell::Cell::get);
let before = count();
let mut h = new_page_harness(Some(dir.clone()));
let after_open = count();
assert_eq!(after_open, before + 1, "listed once when New is shown");
write_template(&dir.join("Second.dotx"), "y");
h.run_steps(20);
assert_eq!(count(), after_open, "not listed again while New stays open");
assert!(h.query_by_label("Second").is_none());
h.get_by_label("Refresh").click();
h.run_steps(3);
assert_eq!(count(), after_open + 1, "Refresh lists once");
assert!(h.query_by_label("Second").is_some(), "Refresh shows the new template");
// Leaving New and coming back reads the folder again.
h.state_mut().run("ui.backstage", json!({"value": true, "page": "open"})).unwrap();
h.run_steps(2);
write_template(&dir.join("Third.dotx"), "z");
h.state_mut().run("ui.backstage", json!({"value": true, "page": "new"})).unwrap();
h.run_steps(3);
assert_eq!(count(), after_open + 2);
assert!(h.query_by_label("Third").is_some());
drop(h);
let _ = std::fs::remove_dir_all(&dir);
}
/// A saved templates folder that is a network path, or gone, gives way to the app's own.
#[test]
fn an_unusable_saved_folder_falls_back_to_the_apps_own() {
let own = personal_dir("fallback");
let services = Services { templates_dir: Some(own.clone()), ..Default::default() };
let mut a = WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), services);
for bad in [r"\\host\share".to_string(), "//host/share".into(), own.join("gone").to_string_lossy().into_owned(), "relative".into()] {
a.session.prefs.user_templates_folder = Some(bad.clone());
assert_eq!(a.user_templates_dir(), Some(own.clone()), "{bad}");
}
a.session.prefs.user_templates_folder = Some(own.to_string_lossy().into_owned());
assert_eq!(a.user_templates_dir(), Some(own.clone()));
let _ = std::fs::remove_dir_all(&own);
}
/// Home and New are different pages (#141): Home has the short template row, the link to New
/// and Recent; New has every template and no Recent list.
#[test]
+10 -8
View File
@@ -150,7 +150,7 @@ impl FileBrowser {
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 } => {
FileDialogRequest::Save { name, .. } => {
let ext = Path::new(name).extension().and_then(|e| e.to_str()).filter(|e| !e.is_empty());
let mut f: Vec<(String, Vec<String>)> = ext.map(|e| (format!("*.{e}"), vec![e.to_string()])).into_iter().collect();
f.push(("All files".into(), vec!["*".into()]));
@@ -258,11 +258,13 @@ impl FileBrowser {
impl WordApp {
/// Ask for a file in the built-in browser and do `after` with the answer when it closes.
pub(crate) fn show_file_browser(&mut self, req: FileDialogRequest, after: AfterPick, unavailable: bool) {
let start = self
.file_browser
.last_dir
.clone()
.filter(|d| d.is_dir())
// A folder the request names (the user templates folder, #2852) comes first.
let asked = match &req {
FileDialogRequest::Save { dir: Some(d), .. } => Some(PathBuf::from(d)).filter(|d| d.is_dir()),
_ => None,
};
let start = asked
.or_else(|| self.file_browser.last_dir.clone().filter(|d| d.is_dir()))
.or_else(|| self.session.path.as_ref().and_then(|p| p.parent()).map(Path::to_path_buf).filter(|d| d.is_dir()))
.or_else(home)
.or_else(|| std::env::current_dir().ok())
@@ -281,7 +283,7 @@ impl WordApp {
"picture" => (FileDialogRequest::Open { purpose: "picture".into() }, AfterPick::Picture),
"recipients" => (FileDialogRequest::Open { purpose: "recipients".into() }, AfterPick::Recipients),
"save" => (
FileDialogRequest::Save { name: self.default_save_name() },
FileDialogRequest::Save { name: self.default_save_name(), dir: None },
AfterPick::SaveAs { after: crate::file_dialogs::AfterSave::Nothing, document: self.session.document_id() },
),
_ => (FileDialogRequest::Open { purpose: "document".into() }, AfterPick::OpenDocument),
@@ -526,7 +528,7 @@ mod tests {
let dir = scratch("save");
std::fs::create_dir(dir.join("drafts")).unwrap();
std::fs::write(dir.join("drafts").join("old.docx"), b"x").unwrap();
let req = FileDialogRequest::Save { name: "Document1.docx".into() };
let req = FileDialogRequest::Save { name: "Document1.docx".into(), dir: None };
let mut b = FileBrowser::new(req, AfterPick::OpenDocument, dir.clone(), false);
b.name = "drafts".into();
assert_eq!(b.accept(), Step::Stay);
+88 -13
View File
@@ -25,8 +25,9 @@ pub enum FileDialogRequest {
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 },
/// Pick a path to save to, given a suggested file name and, if it matters, the folder to
/// start in (Save as Word Template starts in the user templates folder, #2852).
Save { name: String, dir: Option<String> },
}
/// The host's answer to a [`FileDialogRequest`]: the picked paths (one for everything but
@@ -145,7 +146,7 @@ impl WordApp {
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)),
FileDialogRequest::Save { name, .. } => self.services.pick_save.as_ref().and_then(|f| f(name)),
};
Asked::Done(self.finish_pick(after, picked))
}
@@ -354,14 +355,52 @@ impl WordApp {
/// Ask where to save, save there, then do `after`.
pub(crate) fn save_as(&mut self, after: AfterSave) -> Asked {
let name = self.default_save_name();
self.ask_file(FileDialogRequest::Save { name }, AfterPick::SaveAs { after, document: self.session.document_id() })
self.ask_file(FileDialogRequest::Save { name, dir: None }, AfterPick::SaveAs { after, document: self.session.document_id() })
}
/// The user templates folder (#2852): the one File › Options names, else the app's own.
/// `None` on the web, which has no folders.
pub fn user_templates_dir(&self) -> Option<std::path::PathBuf> {
// The saved folder is checked again (the settings file may have been edited, the folder
// removed): a network folder is never looked at, and one that's gone gives way to the
// app's own.
let saved = self.session.prefs.user_templates_folder.as_deref().map(str::trim).filter(|f| !f.is_empty());
match saved {
Some(f) if wordcraft_engine::cmd::user_templates::check_dir(f).is_ok() && std::path::Path::new(f).is_dir() => {
Some(std::path::PathBuf::from(f))
}
Some(f) => {
log::debug!("user templates folder {f:?} isn't usable; using the app's own");
self.services.templates_dir.clone()
}
None => self.services.templates_dir.clone(),
}
}
/// File › Save As › Save as Word Template (#2852): ask where to save a `.dotx`, starting in
/// the user templates folder (the app's own is made on first use), then save there as Save As
/// does. Returns the folder the dialog starts in.
pub(crate) fn save_as_template_dialog(&mut self) -> Option<String> {
let dir = self.user_templates_dir();
#[cfg(not(target_arch = "wasm32"))]
if let Some(d) = &dir
&& !d.is_dir()
&& let Err(e) = std::fs::create_dir_all(d)
{
log::warn!("couldn't make the templates folder {}: {e}", d.display());
}
let dir = dir.filter(|d| d.is_dir()).map(|d| d.to_string_lossy().into_owned());
let name = format!("{}.dotx", self.title_stem());
let after = AfterPick::SaveAs { after: AfterSave::Nothing, document: self.session.document_id() };
let _ = self.ask_file(FileDialogRequest::Save { name, dir: dir.clone() }, after);
dir
}
/// File › Export: ask where to save a copy in this format, then write it.
pub(crate) fn export_dialog(&mut self, ext: &str) {
let file_ext = if ext == crate::print_page::EXPORT_WORD_2003 { "xml" } else { ext };
let name = format!("{}.{file_ext}", self.title_stem());
let _ = self.ask_file(FileDialogRequest::Save { name }, AfterPick::Export { ext: ext.to_string() });
let _ = self.ask_file(FileDialogRequest::Save { name, dir: None }, AfterPick::Export { ext: ext.to_string() });
}
/// File › Print › Save these pages as PDF: ask where, then write the pages the print
@@ -369,7 +408,7 @@ impl WordApp {
pub(crate) fn print_pdf_dialog(&mut self) {
let name = format!("{}.pdf", self.title_stem());
let settings = self.ui.print.params();
let _ = self.ask_file(FileDialogRequest::Save { name }, AfterPick::PrintPdf { settings });
let _ = self.ask_file(FileDialogRequest::Save { name, dir: None }, AfterPick::PrintPdf { settings });
}
pub(crate) fn open_dialog(&mut self) {
@@ -465,7 +504,7 @@ mod tests {
let revision = a.session.rev();
let result = a.run("file.save", json!({})).unwrap();
assert_eq!(result["pending"], "saveAs");
assert_eq!(shown.borrow()[0].0, FileDialogRequest::Save { name: "contract.docx".into() });
assert_eq!(shown.borrow()[0].0, FileDialogRequest::Save { name: "contract.docx".into(), dir: None });
answer(&shown, None);
assert_eq!(a.poll_file_dialog().unwrap().unwrap()["saved"], false);
assert_eq!(a.session.doc, draft);
@@ -614,7 +653,7 @@ mod tests {
h.get_by_label("Save").click();
h.run_steps(3);
assert!(h.state().file_dialog_open(), "the Save button asks for a supported target");
assert_eq!(shown.borrow()[0].0, FileDialogRequest::Save { name: "contract.docx".into() });
assert_eq!(shown.borrow()[0].0, FileDialogRequest::Save { name: "contract.docx".into(), dir: None });
answer(&shown, None);
h.run_steps(3);
@@ -625,7 +664,7 @@ mod tests {
h.event(egui::Event::Key { key: egui::Key::S, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::COMMAND });
h.run_steps(3);
assert!(h.state().file_dialog_open(), "the focused editor's Mod+S asks too");
assert_eq!(shown.borrow()[0].0, FileDialogRequest::Save { name: "contract.docx".into() });
assert_eq!(shown.borrow()[0].0, FileDialogRequest::Save { name: "contract.docx".into(), dir: None });
answer(&shown, None);
h.run_steps(3);
assert!(h.state().session.dirty);
@@ -682,6 +721,42 @@ mod tests {
wordcraft_engine::io::open_path(path).unwrap().plain_text(wordcraft_doc::StoryRef::Body)
}
/// Save as Word Template (#2852) suggests a .dotx in the user templates folder, which is made
/// on first use; the picked path is saved as a template. A folder named in File › Options
/// replaces the app's own.
#[test]
fn save_as_template_starts_in_the_templates_folder() {
let dir = scratch("save-template");
let own = dir.join("Templates");
let (mut a, shown) = async_app();
a.services.templates_dir = Some(own.clone());
typed(&mut a);
let r = a.run("ui.saveAsTemplate", json!({})).unwrap();
assert!(own.is_dir(), "the app's templates folder is made on first use");
assert_eq!(r["dir"], json!(own.to_string_lossy()));
assert_eq!(shown.borrow()[0].0, FileDialogRequest::Save { name: "Document1.dotx".into(), dir: Some(own.to_string_lossy().into_owned()) });
let path = own.join("Memo.dotx");
answer(&shown, Some(&path.to_string_lossy()));
let ctx = egui::Context::default();
ctx.run_ui(egui::RawInput::default(), |ui| a.logic(ui.ctx())).drop_without_applying_deltas();
assert!(path.is_file(), "saved as a template");
assert_eq!(saved_text(&path), UNSAVED);
let listed = wordcraft_engine::cmd::user_templates::list_templates(&own.to_string_lossy()).unwrap();
assert_eq!(listed.templates.iter().map(|t| t.label.as_str()).collect::<Vec<_>>(), ["Memo"]);
// A folder chosen in Options wins.
let chosen = dir.join("Chosen");
std::fs::create_dir_all(&chosen).unwrap();
a.run("file.options", json!({"userTemplatesFolder": chosen.to_string_lossy()})).unwrap();
assert_eq!(a.user_templates_dir(), Some(chosen.clone()));
a.run("ui.saveAsTemplate", json!({})).unwrap();
assert!(
matches!(&shown.borrow()[0].0, FileDialogRequest::Save { name, dir: Some(d) } if name == "Memo.dotx" && *d == chosen.to_string_lossy())
);
answer(&shown, None);
let _ = std::fs::remove_dir_all(&dir);
}
/// Save As returns at once (the UI keeps running); the frame loop saves once the answer arrives.
#[test]
fn save_as_answer_arriving_later_is_saved() {
@@ -691,7 +766,7 @@ mod tests {
typed(&mut a);
a.save_as_dialog();
assert!(a.file_dialog_open());
assert_eq!(shown.borrow()[0].0, FileDialogRequest::Save { name: "Document1.docx".into() });
assert_eq!(shown.borrow()[0].0, FileDialogRequest::Save { name: "Document1.docx".into(), dir: None });
let ctx = egui::Context::default();
let frame = |a: &mut WordApp| ctx.run_ui(egui::RawInput::default(), |ui| a.logic(ui.ctx())).drop_without_applying_deltas();
frame(&mut a);
@@ -729,7 +804,7 @@ mod tests {
let r = a.run("file.saveAs", json!({})).unwrap();
assert_eq!(r, json!({"saved": false}));
assert!(a.file_dialog_open(), "F12's saveAs request opens the dialog");
assert_eq!(shown.borrow()[0].0, FileDialogRequest::Save { name: "two.docx".into() });
assert_eq!(shown.borrow()[0].0, FileDialogRequest::Save { name: "two.docx".into(), dir: None });
let _ = std::fs::remove_dir_all(&dir);
}
@@ -759,7 +834,7 @@ mod tests {
let (mut a, shown) = async_app();
let r = a.run("file.exportPdf", json!({})).unwrap();
assert_eq!(r, json!({"exported": false}));
assert_eq!(shown.borrow()[0].0, FileDialogRequest::Save { name: format!("{}.pdf", a.title_stem()) });
assert_eq!(shown.borrow()[0].0, FileDialogRequest::Save { name: format!("{}.pdf", a.title_stem()), dir: None });
let dir = scratch("export-pdf");
let picked = dir.join("out.pdf");
answer(&shown, Some(&picked.to_string_lossy()));
@@ -784,7 +859,7 @@ mod tests {
answer(&shown, None);
a.poll_file_dialog();
a.run("file.exportPng", json!({})).unwrap();
assert!(matches!(shown.borrow()[0].0, FileDialogRequest::Save { ref name } if name.ends_with(".png")));
assert!(matches!(shown.borrow()[0].0, FileDialogRequest::Save { ref name, .. } if name.ends_with(".png")));
}
/// #1445: "Save changes?" → Save on a new document while another file dialog is still open:
+11
View File
@@ -2697,6 +2697,17 @@
Type a size in any unit, such as 5 cm or 300 px. The other side follows, so the picture keeps its proportions. Въведете размер в произволна единица, например 5 cm или 300 px. Другата страна се променя съответно, така че картината запазва пропорциите си.
Import… Импортиране…
# Personal templates (#2852)
Personal Лични
Personal Templates Лични шаблони
Templates you save in your templates folder show up here. Шаблоните, които запишете в папката си с шаблони, се показват тук.
Save as Word Template… Запиши като шаблон на Word…
Saves a .dotx template in your templates folder. It shows up in File › New under Personal. Записва шаблон .dotx в папката ви с шаблони. Той се показва във Файл › Нов под Лични.
File locations Местоположения на файлове
User templates: Потребителски шаблони:
Not set Не е зададено
File › New lists the .dotx and .dotm templates here under Personal; Save as Word Template starts here. Файл › Нов показва шаблоните .dotx и .dotm оттук под Лични; „Запиши като шаблон на Word“ започва оттук.
Format Shape Pane Екран „Форматиране на фигура“
Format Shape Форматиране на фигура
Format Shape… Форматиране на фигура…
Can't render this file because it contains an unexpected character in line 7 and column 25.
+11
View File
@@ -2700,6 +2700,17 @@ preview Title Ukázka
Type a size in any unit, such as 5 cm or 300 px. The other side follows, so the picture keeps its proportions. Zadejte velikost v libovolné jednotce, například 5 cm nebo 300 px. Druhá strana se přizpůsobí, takže obrázek zachová své proporce.
Import… Importovat…
# Personal templates (#2852)
Personal Osobní
Personal Templates Osobní šablony
Templates you save in your templates folder show up here. Zde se zobrazí šablony, které uložíte do složky šablon.
Save as Word Template… Uložit jako šablonu Wordu…
Saves a .dotx template in your templates folder. It shows up in File › New under Personal. Uloží šablonu .dotx do vaší složky šablon. Zobrazí se v nabídce Soubor › Nový v části Osobní.
File locations Umístění souborů
User templates: Uživatelské šablony:
Not set Nenastaveno
File › New lists the .dotx and .dotm templates here under Personal; Save as Word Template starts here. Soubor › Nový zobrazuje šablony .dotx a .dotm odsud v části Osobní; Uložit jako šablonu Wordu začíná zde.
Format Shape Pane Podokno Formát obrazce
Format Shape Formát obrazce
Format Shape… Formátovat obrazec…
Can't render this file because it contains an unexpected character in line 7 and column 25.
+11
View File
@@ -2296,6 +2296,17 @@
Type a size in any unit, such as 5 cm or 300 px. The other side follows, so the picture keeps its proportions. Geben Sie eine Größe in einer beliebigen Einheit ein, z. B. 5 cm oder 300 px. Die andere Seite passt sich an, sodass das Bild seine Proportionen behält.
Import… Importieren…
# Personal templates (#2852)
Personal Persönlich
Personal Templates Persönliche Vorlagen
Templates you save in your templates folder show up here. Vorlagen, die Sie in Ihrem Vorlagenordner speichern, werden hier angezeigt.
Save as Word Template… Als Word-Vorlage speichern…
Saves a .dotx template in your templates folder. It shows up in File › New under Personal. Speichert eine .dotx-Vorlage in Ihrem Vorlagenordner. Sie erscheint unter Datei › Neu bei Persönlich.
File locations Dateispeicherorte
User templates: Benutzervorlagen:
Not set Nicht festgelegt
File › New lists the .dotx and .dotm templates here under Personal; Save as Word Template starts here. Datei › Neu zeigt die .dotx- und .dotm-Vorlagen von hier unter Persönlich; Als Word-Vorlage speichern beginnt hier.
Format Shape Pane Bereich „Form formatieren“
Format Shape Form formatieren
Format Shape… Form formatieren…
Can't render this file because it contains an unexpected character in line 6 and column 25.
+11
View File
@@ -2663,6 +2663,17 @@ preview Title Título
Type a size in any unit, such as 5 cm or 300 px. The other side follows, so the picture keeps its proportions. Escribe un tamaño en cualquier unidad, como 5 cm o 300 px. El otro lado se ajusta, así que la imagen conserva sus proporciones.
Import… Importar…
# Personal templates (#2852)
Personal Personal
Personal Templates Plantillas personales
Templates you save in your templates folder show up here. Las plantillas que guarde en su carpeta de plantillas aparecen aquí.
Save as Word Template… Guardar como plantilla de Word…
Saves a .dotx template in your templates folder. It shows up in File › New under Personal. Guarda una plantilla .dotx en su carpeta de plantillas. Aparece en Archivo › Nuevo, en Personal.
File locations Ubicación de archivos
User templates: Plantillas de usuario:
Not set Sin definir
File › New lists the .dotx and .dotm templates here under Personal; Save as Word Template starts here. Archivo › Nuevo muestra aquí las plantillas .dotx y .dotm en Personal; Guardar como plantilla de Word empieza aquí.
Format Shape Pane Panel Formato de forma
Format Shape Formato de forma
Format Shape… Formato de forma…
Can't render this file because it contains an unexpected character in line 6 and column 25.
+11
View File
@@ -2331,6 +2331,17 @@
Type a size in any unit, such as 5 cm or 300 px. The other side follows, so the picture keeps its proportions. Sisesta suurus mis tahes ühikus, näiteks 5 cm või 300 px. Teine külg muutub kaasa, nii et pilt säilitab oma proportsioonid.
Import… Impordi…
# Personal templates (#2852)
Personal Isiklik
Personal Templates Isiklikud mallid
Templates you save in your templates folder show up here. Siin kuvatakse mallid, mille salvestate oma mallide kausta.
Save as Word Template… Salvesta Wordi mallina…
Saves a .dotx template in your templates folder. It shows up in File › New under Personal. Salvestab .dotx-malli teie mallide kausta. See kuvatakse jaotises Fail › Uus all Isiklik.
File locations Failide asukohad
User templates: Kasutaja mallid:
Not set Määramata
File › New lists the .dotx and .dotm templates here under Personal; Save as Word Template starts here. Fail › Uus loetleb siinsed .dotx- ja .dotm-mallid jaotises Isiklik; Salvesta Wordi mallina alustab siit.
Format Shape Pane Kujundi vormindamise paan
Format Shape Kujundi vormindamine
Format Shape… Vorminda kujund…
Can't render this file because it contains an unexpected character in line 850 and column 23.
+11
View File
@@ -2709,6 +2709,17 @@ template Letter Lettre
Type a size in any unit, such as 5 cm or 300 px. The other side follows, so the picture keeps its proportions. Tapez une taille dans n'importe quelle unité, par exemple 5 cm ou 300 px. L'autre côté suit, et l'image garde ses proportions.
Import… Importer…
# Personal templates (#2852)
Personal Personnel
Personal Templates Modèles personnels
Templates you save in your templates folder show up here. Les modèles que vous enregistrez dans votre dossier de modèles apparaissent ici.
Save as Word Template… Enregistrer comme modèle Word…
Saves a .dotx template in your templates folder. It shows up in File › New under Personal. Enregistre un modèle .dotx dans votre dossier de modèles. Il apparaît dans Fichier › Nouveau, sous Personnel.
File locations Dossiers par défaut
User templates: Modèles utilisateur :
Not set Non défini
File › New lists the .dotx and .dotm templates here under Personal; Save as Word Template starts here. Fichier › Nouveau affiche les modèles .dotx et .dotm de ce dossier sous Personnel ; Enregistrer comme modèle Word commence ici.
Format Shape Pane Volet Mise en forme de la forme
Format Shape Mise en forme de la forme
Format Shape… Mise en forme de la forme…
Can't render this file because it contains an unexpected character in line 7 and column 25.
+11
View File
@@ -2708,6 +2708,17 @@ preview Title Naslov
Type a size in any unit, such as 5 cm or 300 px. The other side follows, so the picture keeps its proportions. Upišite veličinu u bilo kojoj jedinici, npr. 5 cm ili 300 px. Druga se strana prilagođava pa slika zadržava svoje proporcije.
Import… Uvezi…
# Personal templates (#2852)
Personal Osobno
Personal Templates Osobni predlošci
Templates you save in your templates folder show up here. Ovdje se prikazuju predlošci koje spremite u mapu predložaka.
Save as Word Template… Spremi kao Wordov predložak…
Saves a .dotx template in your templates folder. It shows up in File › New under Personal. Sprema predložak .dotx u vašu mapu predložaka. Prikazuje se u Datoteka › Novo pod Osobno.
File locations Mjesta datoteka
User templates: Korisnički predlošci:
Not set Nije postavljeno
File › New lists the .dotx and .dotm templates here under Personal; Save as Word Template starts here. Datoteka › Novo prikazuje predloške .dotx i .dotm odavde pod Osobno; Spremi kao Wordov predložak počinje ovdje.
Format Shape Pane Okno Oblikovanje oblika
Format Shape Oblikovanje oblika
Format Shape… Oblikuj oblik…
Can't render this file because it contains an unexpected character in line 6 and column 25.
+11
View File
@@ -2704,6 +2704,17 @@
Type a size in any unit, such as 5 cm or 300 px. The other side follows, so the picture keeps its proportions. Írj be egy méretet bármilyen mértékegységben, például 5 cm vagy 300 px. A másik oldal követi, így a kép megtartja az arányait.
Import… Importálás…
# Personal templates (#2852)
Personal Saját
Personal Templates Saját sablonok
Templates you save in your templates folder show up here. A sablonmappájába mentett sablonok itt jelennek meg.
Save as Word Template… Mentés Word-sablonként…
Saves a .dotx template in your templates folder. It shows up in File › New under Personal. Egy .dotx sablont ment a sablonmappájába. A Fájl › Új lapon, a Saját csoportban jelenik meg.
File locations Fájlok helye
User templates: Felhasználói sablonok:
Not set Nincs megadva
File › New lists the .dotx and .dotm templates here under Personal; Save as Word Template starts here. A Fájl › Új a Saját csoportban sorolja fel az itteni .dotx és .dotm sablonokat; a Mentés Word-sablonként innen indul.
Format Shape Pane Alakzat formázása munkaablak
Format Shape Alakzat formázása
Format Shape… Alakzat formázása…
Can't render this file because it contains an unexpected character in line 6 and column 25.
+11
View File
@@ -2717,6 +2717,17 @@ preview Title Esempio
Type a size in any unit, such as 5 cm or 300 px. The other side follows, so the picture keeps its proportions. Scrivi una dimensione in qualsiasi unità, ad esempio 5 cm o 300 px. L'altro lato si adegua, così l'immagine mantiene le proporzioni.
Import… Importa…
# Personal templates (#2852)
Personal Personali
Personal Templates Modelli personali
Templates you save in your templates folder show up here. I modelli che salvi nella cartella dei modelli compaiono qui.
Save as Word Template… Salva come modello di Word…
Saves a .dotx template in your templates folder. It shows up in File › New under Personal. Salva un modello .dotx nella cartella dei modelli. Compare in File › Nuovo, in Personali.
File locations Percorsi dei file
User templates: Modelli utente:
Not set Non impostato
File › New lists the .dotx and .dotm templates here under Personal; Save as Word Template starts here. File › Nuovo elenca qui i modelli .dotx e .dotm in Personali; Salva come modello di Word parte da qui.
Format Shape Pane Riquadro Formato forma
Format Shape Formato forma
Format Shape… Formato forma…
Can't render this file because it contains an unexpected character in line 6 and column 25.
+11
View File
@@ -2297,6 +2297,17 @@
Type a size in any unit, such as 5 cm or 300 px. The other side follows, so the picture keeps its proportions. 5 cm や 300 px など、任意の単位でサイズを入力します。もう一方の辺も合わせて変わるため、図の縦横比は保たれます。
Import… インポート…
# Personal templates (#2852)
Personal 個人用
Personal Templates 個人用テンプレート
Templates you save in your templates folder show up here. テンプレート フォルダーに保存したテンプレートがここに表示されます。
Save as Word Template… Word テンプレートとして保存…
Saves a .dotx template in your templates folder. It shows up in File › New under Personal. .dotx テンプレートをテンプレート フォルダーに保存します。[ファイル] › [新規] の [個人用] に表示されます。
File locations ファイルの場所
User templates: ユーザー テンプレート:
Not set 未設定
File › New lists the .dotx and .dotm templates here under Personal; Save as Word Template starts here. [ファイル] › [新規] の [個人用] に、ここにある .dotx と .dotm のテンプレートが表示されます。[Word テンプレートとして保存] はここから始まります。
Format Shape Pane 図形の書式設定ウィンドウ
Format Shape 図形の書式設定
Format Shape… 図形の書式設定…
Can't render this file because it contains an unexpected character in line 928 and column 23.
+11
View File
@@ -2551,6 +2551,17 @@ preview Title 제목
Type a size in any unit, such as 5 cm or 300 px. The other side follows, so the picture keeps its proportions. 5 cm 또는 300 px처럼 원하는 단위로 크기를 입력하세요. 다른 쪽도 함께 바뀌어 그림의 비율이 유지됩니다.
Import… 가져오기…
# Personal templates (#2852)
Personal 개인
Personal Templates 개인 서식 파일
Templates you save in your templates folder show up here. 서식 파일 폴더에 저장한 서식 파일이 여기에 표시됩니다.
Save as Word Template… Word 서식 파일로 저장…
Saves a .dotx template in your templates folder. It shows up in File › New under Personal. .dotx 서식 파일을 서식 파일 폴더에 저장합니다. 파일 › 새로 만들기의 개인 아래에 표시됩니다.
File locations 파일 위치
User templates: 사용자 서식 파일:
Not set 설정 안 됨
File › New lists the .dotx and .dotm templates here under Personal; Save as Word Template starts here. 파일 › 새로 만들기의 개인 아래에 이 폴더의 .dotx 및 .dotm 서식 파일이 표시됩니다. Word 서식 파일로 저장은 여기에서 시작합니다.
Format Shape Pane 도형 서식 창
Format Shape 도형 서식
Format Shape… 도형 서식…
Can't render this file because it contains an unexpected character in line 934 and column 23.
+11
View File
@@ -2713,6 +2713,17 @@ preview Title Tytuł
Type a size in any unit, such as 5 cm or 300 px. The other side follows, so the picture keeps its proportions. Wpisz rozmiar w dowolnej jednostce, np. 5 cm lub 300 px. Druga strona dopasuje się, więc obraz zachowa proporcje.
Import… Importuj…
# Personal templates (#2852)
Personal Osobiste
Personal Templates Szablony osobiste
Templates you save in your templates folder show up here. Tutaj pojawiają się szablony zapisane w folderze szablonów.
Save as Word Template… Zapisz jako szablon programu Word…
Saves a .dotx template in your templates folder. It shows up in File › New under Personal. Zapisuje szablon .dotx w folderze szablonów. Pojawia się w Plik › Nowy w sekcji Osobiste.
File locations Lokalizacje plików
User templates: Szablony użytkownika:
Not set Nie ustawiono
File › New lists the .dotx and .dotm templates here under Personal; Save as Word Template starts here. Plik › Nowy pokazuje stąd szablony .dotx i .dotm w sekcji Osobiste; Zapisz jako szablon programu Word zaczyna tutaj.
Format Shape Pane Okienko Formatowanie kształtu
Format Shape Formatowanie kształtu
Format Shape… Formatuj kształt…
Can't render this file because it contains an unexpected character in line 6 and column 25.
+11
View File
@@ -2309,6 +2309,17 @@
Type a size in any unit, such as 5 cm or 300 px. The other side follows, so the picture keeps its proportions. Digite um tamanho em qualquer unidade, como 5 cm ou 300 px. O outro lado acompanha, e a imagem mantém as proporções.
Import… Importar…
# Personal templates (#2852)
Personal Pessoal
Personal Templates Modelos pessoais
Templates you save in your templates folder show up here. Os modelos que você salvar na sua pasta de modelos aparecem aqui.
Save as Word Template… Salvar como modelo do Word…
Saves a .dotx template in your templates folder. It shows up in File › New under Personal. Salva um modelo .dotx na sua pasta de modelos. Ele aparece em Arquivo › Novo, em Pessoal.
File locations Locais de arquivos
User templates: Modelos do usuário:
Not set Não definido
File › New lists the .dotx and .dotm templates here under Personal; Save as Word Template starts here. Arquivo › Novo lista os modelos .dotx e .dotm daqui em Pessoal; Salvar como modelo do Word começa aqui.
Format Shape Pane Painel Formatar Forma
Format Shape Formatar Forma
Format Shape… Formatar Forma…
Can't render this file because it contains an unexpected character in line 6 and column 25.
+11
View File
@@ -2706,6 +2706,17 @@ preview Title Пример
Type a size in any unit, such as 5 cm or 300 px. The other side follows, so the picture keeps its proportions. Введите размер в любых единицах, например 5 cm или 300 px. Другая сторона изменится вместе с ней, и рисунок сохранит пропорции.
Import… Импорт…
# Personal templates (#2852)
Personal Личные
Personal Templates Личные шаблоны
Templates you save in your templates folder show up here. Здесь появляются шаблоны, сохранённые в папку шаблонов.
Save as Word Template… Сохранить как шаблон Word…
Saves a .dotx template in your templates folder. It shows up in File › New under Personal. Сохраняет шаблон .dotx в папку шаблонов. Он появится в разделе Файл › Создать, в группе «Личные».
File locations Расположение файлов
User templates: Шаблоны пользователя:
Not set Не задано
File › New lists the .dotx and .dotm templates here under Personal; Save as Word Template starts here. Файл › Создать показывает отсюда шаблоны .dotx и .dotm в группе «Личные»; «Сохранить как шаблон Word» начинает здесь.
Format Shape Pane Область «Формат фигуры»
Format Shape Формат фигуры
Format Shape… Формат фигуры…
Can't render this file because it contains an unexpected character in line 7 and column 25.
+11
View File
@@ -2307,6 +2307,17 @@
Type a size in any unit, such as 5 cm or 300 px. The other side follows, so the picture keeps its proportions. Unesite veličinu u bilo kojoj jedinici, na primer 5 cm ili 300 px. Druga strana se prilagođava, pa slika zadržava svoje proporcije.
Import… Uvezi…
# Personal templates (#2852)
Personal Lični
Personal Templates Lični šabloni
Templates you save in your templates folder show up here. Ovde se prikazuju šabloni koje sačuvate u fascikli šablona.
Save as Word Template… Sačuvaj kao Word šablon…
Saves a .dotx template in your templates folder. It shows up in File › New under Personal. Čuva .dotx šablon u fascikli šablona. Prikazuje se u Fajl › Novo pod Lični.
File locations Lokacije datoteka
User templates: Korisnički šabloni:
Not set Nije postavljeno
File › New lists the .dotx and .dotm templates here under Personal; Save as Word Template starts here. Fajl › Novo prikazuje .dotx i .dotm šablone odavde pod Lični; Sačuvaj kao Word šablon počinje ovde.
Format Shape Pane Okno „Format oblika“
Format Shape Format oblika
Format Shape… Formatiraj oblik…
Can't render this file because it contains an unexpected character in line 6 and column 25.
+11
View File
@@ -2311,6 +2311,17 @@
Type a size in any unit, such as 5 cm or 300 px. The other side follows, so the picture keeps its proportions. Унесите величину у било којој јединици, на пример 5 cm или 300 px. Друга страна се прилагођава, па слика задржава своје пропорције.
Import… Увези…
# Personal templates (#2852)
Personal Лични
Personal Templates Лични шаблони
Templates you save in your templates folder show up here. Овде се приказују шаблони које сачувате у фасцикли шаблона.
Save as Word Template… Сачувај као Word шаблон…
Saves a .dotx template in your templates folder. It shows up in File › New under Personal. Чува .dotx шаблон у фасцикли шаблона. Приказује се у Фајл › Ново под Лични.
File locations Локације датотека
User templates: Кориснички шаблони:
Not set Није постављено
File › New lists the .dotx and .dotm templates here under Personal; Save as Word Template starts here. Фајл › Ново приказује .dotx и .dotm шаблоне одавде под Лични; Сачувај као Word шаблон почиње овде.
Format Shape Pane Окно „Формат облика“
Format Shape Формат облика
Format Shape… Форматирај облик…
Can't render this file because it contains an unexpected character in line 11 and column 25.
+11
View File
@@ -2723,6 +2723,17 @@ preview Title Titel
Type a size in any unit, such as 5 cm or 300 px. The other side follows, so the picture keeps its proportions. Skriv en storlek i valfri enhet, till exempel 5 cm eller 300 px. Den andra sidan följer med, så att bilden behåller sina proportioner.
Import… Importera…
# Personal templates (#2852)
Personal Personliga
Personal Templates Personliga mallar
Templates you save in your templates folder show up here. Mallar som du sparar i din mallmapp visas här.
Save as Word Template… Spara som Word-mall…
Saves a .dotx template in your templates folder. It shows up in File › New under Personal. Sparar en .dotx-mall i din mallmapp. Den visas i Arkiv › Ny under Personliga.
File locations Filplatser
User templates: Användarmallar:
Not set Inte angivet
File › New lists the .dotx and .dotm templates here under Personal; Save as Word Template starts here. Arkiv › Ny visar .dotx- och .dotm-mallarna härifrån under Personliga; Spara som Word-mall börjar här.
Format Shape Pane Fönstret Formatera form
Format Shape Formatera form
Format Shape… Formatera form…
Can't render this file because it contains an unexpected character in line 7 and column 25.
+11
View File
@@ -2705,6 +2705,17 @@ preview Title Örnek
Type a size in any unit, such as 5 cm or 300 px. The other side follows, so the picture keeps its proportions. Herhangi bir birimde bir boyut yazın, örneğin 5 cm veya 300 px. Diğer kenar da buna uyar, böylece resim oranlarını korur.
Import… İçe aktar…
# Personal templates (#2852)
Personal Kişisel
Personal Templates Kişisel şablonlar
Templates you save in your templates folder show up here. Şablon klasörünüze kaydettiğiniz şablonlar burada görünür.
Save as Word Template… Word şablonu olarak kaydet…
Saves a .dotx template in your templates folder. It shows up in File › New under Personal. Şablon klasörünüze bir .dotx şablonu kaydeder. Dosya › Yeni altında Kişisel bölümünde görünür.
File locations Dosya konumları
User templates: Kullanıcı şablonları:
Not set Ayarlanmadı
File › New lists the .dotx and .dotm templates here under Personal; Save as Word Template starts here. Dosya › Yeni, buradaki .dotx ve .dotm şablonlarını Kişisel altında listeler; Word şablonu olarak kaydet buradan başlar.
Format Shape Pane Şekli Biçimlendir bölmesi
Format Shape Şekli Biçimlendir
Format Shape… Şekli Biçimlendir…
Can't render this file because it contains an unexpected character in line 802 and column 23.
+11
View File
@@ -2309,6 +2309,17 @@
Type a size in any unit, such as 5 cm or 300 px. The other side follows, so the picture keeps its proportions. Введіть розмір у будь-яких одиницях, наприклад 5 cm або 300 px. Інша сторона зміниться разом із нею, тож зображення збереже пропорції.
Import… Імпортувати…
# Personal templates (#2852)
Personal Особисті
Personal Templates Особисті шаблони
Templates you save in your templates folder show up here. Тут з’являються шаблони, які ви зберігаєте в теці шаблонів.
Save as Word Template… Зберегти як шаблон Word…
Saves a .dotx template in your templates folder. It shows up in File › New under Personal. Зберігає шаблон .dotx у вашій теці шаблонів. Він з’являється в розділі Файл › Новий, у групі «Особисті».
File locations Розташування файлів
User templates: Шаблони користувача:
Not set Не задано
File › New lists the .dotx and .dotm templates here under Personal; Save as Word Template starts here. Файл › Новий показує звідси шаблони .dotx і .dotm у групі «Особисті»; «Зберегти як шаблон Word» починає тут.
Format Shape Pane Область «Формат фігури»
Format Shape Формат фігури
Format Shape… Формат фігури…
Can't render this file because it contains an unexpected character in line 6 and column 31.
+11
View File
@@ -2748,6 +2748,17 @@ template Letter Thư
Type a size in any unit, such as 5 cm or 300 px. The other side follows, so the picture keeps its proportions. Nhập kích thước theo đơn vị bất kỳ, ví dụ 5 cm hoặc 300 px. Cạnh còn lại thay đổi theo, nên ảnh giữ nguyên tỷ lệ.
Import… Nhập…
# Personal templates (#2852)
Personal Cá nhân
Personal Templates Mẫu cá nhân
Templates you save in your templates folder show up here. Các mẫu bạn lưu vào thư mục mẫu sẽ xuất hiện ở đây.
Save as Word Template… Lưu dưới dạng mẫu Word…
Saves a .dotx template in your templates folder. It shows up in File › New under Personal. Lưu một mẫu .dotx vào thư mục mẫu của bạn. Mẫu xuất hiện trong Tệp › Mới, mục Cá nhân.
File locations Vị trí tệp
User templates: Mẫu người dùng:
Not set Chưa đặt
File › New lists the .dotx and .dotm templates here under Personal; Save as Word Template starts here. Tệp › Mới liệt kê các mẫu .dotx và .dotm ở đây trong mục Cá nhân; Lưu dưới dạng mẫu Word bắt đầu từ đây.
Format Shape Pane Ngăn Định dạng hình
Format Shape Định dạng hình
Format Shape… Định dạng hình…
Can't render this file because it contains an unexpected character in line 7 and column 25.
+11
View File
@@ -2309,6 +2309,17 @@
Type a size in any unit, such as 5 cm or 300 px. The other side follows, so the picture keeps its proportions. 可用任意单位输入大小,例如 5 cm 或 300 px。另一边随之调整,图片保持原有比例。
Import… 导入…
# Personal templates (#2852)
Personal 个人
Personal Templates 个人模板
Templates you save in your templates folder show up here. 保存到模板文件夹中的模板会显示在这里。
Save as Word Template… 另存为 Word 模板…
Saves a .dotx template in your templates folder. It shows up in File › New under Personal. 将 .dotx 模板保存到你的模板文件夹。它会显示在“文件 › 新建”的“个人”下。
File locations 文件位置
User templates: 用户模板:
Not set 未设置
File › New lists the .dotx and .dotm templates here under Personal; Save as Word Template starts here. “文件 › 新建”在“个人”下列出此处的 .dotx 和 .dotm 模板;“另存为 Word 模板”从这里开始。
Format Shape Pane 设置形状格式窗格
Format Shape 设置形状格式
Format Shape… 设置形状格式…
Can't render this file because it contains an unexpected character in line 7 and column 25.
+11
View File
@@ -2298,6 +2298,17 @@
Type a size in any unit, such as 5 cm or 300 px. The other side follows, so the picture keeps its proportions. 可用任何單位輸入大小,例如 5 cm 或 300 px。另一邊隨之調整,圖片保持原有比例。
Import… 匯入…
# Personal templates (#2852)
Personal 個人
Personal Templates 個人範本
Templates you save in your templates folder show up here. 儲存到範本資料夾的範本會顯示在這裡。
Save as Word Template… 另存為 Word 範本…
Saves a .dotx template in your templates folder. It shows up in File › New under Personal. 將 .dotx 範本儲存到你的範本資料夾。它會顯示在「檔案 › 新增」的「個人」下。
File locations 檔案位置
User templates: 使用者範本:
Not set 未設定
File › New lists the .dotx and .dotm templates here under Personal; Save as Word Template starts here. 「檔案 › 新增」會在「個人」下列出此處的 .dotx 和 .dotm 範本;「另存為 Word 範本」從這裡開始。
Format Shape Pane 設定圖形格式窗格
Format Shape 設定圖形格式
Format Shape… 設定圖形格式…
Can't render this file because it contains an unexpected character in line 929 and column 23.
+14 -2
View File
@@ -173,6 +173,10 @@ pub struct Services {
/// `xdg-mime`. `None` (web, macOS) hides the button; macOS shows how to do it in Finder
/// instead. An error means nothing changed.
pub make_default_app: Option<Box<dyn Fn() -> Result<DefaultApp, String>>>,
/// Desktop: the app's own user templates folder (`Templates` in the settings folder, #2852),
/// used while File › Options names none. File › New lists personal templates from it and Save
/// as Word Template starts in it. `None` (web) hides personal templates.
pub templates_dir: Option<std::path::PathBuf>,
}
/// How Save in the Save Changes prompt went.
@@ -1676,6 +1680,12 @@ impl WordApp {
self.open_dialog();
json!({})
}
// File › Save As › Save as Word Template (#2852): the Save dialog for a .dotx, in the
// user templates folder. Opens system UI, like `ui.openFileDialog`.
"ui.saveAsTemplate" => {
let dir = self.save_as_template_dialog();
json!({"dir": dir})
}
// The built-in file browser (#576), for when the system's file dialog doesn't show up
// (desktop only): `purpose` is document (default), picture, recipients or save. A system
// dialog still waiting for an answer is given up as cancelled.
@@ -2629,6 +2639,8 @@ pub(crate) fn works_without_document(id: &str) -> bool {
matches!(
id,
"file.new"
| "file.newFromTemplate"
| "file.listTemplates"
| "file.open"
| "file.close"
| "file.options"
@@ -2685,13 +2697,13 @@ fn flushes_comment_drafts(id: &str, params: &Value) -> bool {
) || discards_document(id, params)
}
/// User commands that replace or close the document (`file.open` without a path only shows the
/// User commands that replace or close the document (New, also from a template; `file.open` without a path only shows the
/// file picker; the open that follows is checked). Envelopes, Labels and Finish & Merge make a
/// new document in its place; a merge written to a file (`path`) and an envelope added to this
/// document (`add`) leave it alone.
pub(crate) fn discards_document(id: &str, params: &Value) -> bool {
match id {
"file.new" | "file.close" | "mailings.labels" => true,
"file.new" | "file.newFromTemplate" | "file.close" | "mailings.labels" => true,
"mailings.envelopes" => params.get("add").and_then(Value::as_bool) != Some(true),
"file.open" => params.get("path").is_some(),
"file.recover" => params.get("restore").is_some(),
+36
View File
@@ -531,6 +531,12 @@ fn advanced(app: &mut WordApp, ui: &mut Ui) {
{
let _ = app.run("ui.caretAnimation", json!({"value": caret}));
}
// The web app has no folders to name.
if !cfg!(target_arch = "wasm32") {
ui.add_space(8.0);
section(ui, "File locations");
templates_folder(app, ui);
}
ui.add_space(8.0);
section(ui, "Customize");
ui.horizontal(|ui| {
@@ -547,6 +553,36 @@ fn advanced(app: &mut WordApp, ui: &mut Ui) {
});
}
/// The user templates folder (#2852): where File › New finds personal templates and Save as Word
/// Template starts. Empty is the app's own folder, shown as the hint.
fn templates_folder(app: &mut WordApp, ui: &mut Ui) {
let saved = app.session.prefs.user_templates_folder.clone().unwrap_or_default();
let id = egui::Id::new("options_user_templates_folder");
let mut folder = ui.data(|d| d.get_temp::<String>(id)).unwrap_or_else(|| saved.clone());
let default = app.services.templates_dir.as_ref().map(|d| d.to_string_lossy().into_owned());
ui.label(tl!("User templates:"));
ui.horizontal(|ui| {
let hint = default.clone().unwrap_or_else(|| tl!("Not set").to_string());
let r = ui.add(egui::TextEdit::singleline(&mut folder).desired_width(320.0).hint_text(hint));
let enter = r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter));
let changed = folder.trim() != saved;
if ui.add_enabled(changed, egui::Button::new(tl!("Apply"))).clicked() || (enter && changed) {
let value = if folder.trim().is_empty() { "auto" } else { folder.trim() };
match app.run("file.options", json!({"userTemplatesFolder": value})) {
Ok(_) => folder = app.session.prefs.user_templates_folder.clone().unwrap_or_default(),
Err(e) => app.status(e),
}
}
if ui.add_enabled(!saved.is_empty(), egui::Button::new(tl!("Clear"))).clicked()
&& app.run("file.options", json!({"userTemplatesFolder": "auto"})).is_ok()
{
folder.clear();
}
});
ui.data_mut(|d| d.insert_temp(id, folder));
note(ui, "File › New lists the .dotx and .dotm templates here under Personal; Save as Word Template starts here.");
}
fn agents(ui: &mut Ui) {
section(ui, "Agents");
ui.label(tl!("Every command is available to scripts and AI agents: run `wordcraft-cli mcp` for an MCP server, or start the app with `--control <port>` for the JSON control channel."));
+1 -1
View File
@@ -52,7 +52,7 @@ and set `WORDCRAFT_CONTROL_KEY` to that app's key.
| Method | Params | Result |
|---|---|---|
| `engine.execute` | `command`, `params` | the command's result (any id from `engine.commands`, including UI commands `ui.tab`, `ui.dialog` (`{"name": dialog, "part"?: table style part}`; `part` opens New / Modify Table Style (`newTableStyle` / `modifyTableStyle`) on that part: `wholeTable`, `headerRow`, `firstColumn`, `bandedRows`, `nwCell`… as `table.modifyStyle` names them), `ui.backstage`, `ui.zotero.*` (see `docs/zotero.md`), `ui.language` (`{"value": "auto"|"en"|"zh-hans"|"zh-hant"|"ja"|"pt-br"|"es"|"uk"|"sr"|"sr-latn"|"et"|"tr"|"ru"|"pl"|"de"|"it"|"cs"|"hu"|"fr"}`), `ui.theme` (`{"value": "system"|"light"|"dark"}`; `system` follows the OS appearance, light when it reports none), `ui.interfaceSize` (`{"value": "auto"}` or `{"percent": 50–300}`, clamped; no value reads it; `auto` keeps the system display scale, a percentage is absolute, so 100 is one screen pixel per point (#822); the result's `percent` is the size drawn at and `auto` whether it follows the system; document zoom stays `view.zoom`), `ui.animations` (`{"value": "system"|"on"|"off"}`; no value reads it; `system` follows the system's animation setting, which the desktop app reads on Windows, macOS, Linux and the BSDs and the web app from `prefers-reduced-motion`, and means on where the system doesn't say; the result's `enabled` is what the setting comes to and `system` what the system reports, `null` when it doesn't say), `ui.fileBrowser` (desktop: `{"purpose": "document"|"picture"|"recipients"|"save"}` opens WordCraft's own file browser, for when the system file dialog doesn't show up, #576), `ui.caretAnimation` (`{"value": true|false}`; no value reads it; off, the caret jumps and blinks on and off instead of gliding and fading), `ui.startScreen` (`{"value": true|false}`; no value reads it; on, the default, the desktop and web apps start on File › Home when launched with nothing to open, #766), `ui.alignmentGuides` (`{"value": true|false}`; no value reads it; on, the default, moving a picture or shape snaps it to the page, the margins and other objects, with guide lines while dragging; Alt moves freely), `ui.quickAccessToolbar` (`{"value": true|false}`; no value toggles it; false takes the Quick Access Toolbar out of the title bar, as its menu's Hide Quick Access Toolbar does, and true brings it back, #1945), `ui.focusRibbon` (`{"value": true|false}`; no value toggles it; in Focus, true drops the ribbon down over the page, as the bar's ⋯ does, and false puts it away, as a click on the page does; outside Focus it stays put away, #1678)…) |
| `engine.execute` | `command`, `params` | the command's result (any id from `engine.commands`, including UI commands `ui.tab`, `ui.dialog` (`{"name": dialog, "part"?: table style part}`; `part` opens New / Modify Table Style (`newTableStyle` / `modifyTableStyle`) on that part: `wholeTable`, `headerRow`, `firstColumn`, `bandedRows`, `nwCell`… as `table.modifyStyle` names them), `ui.backstage`, `ui.zotero.*` (see `docs/zotero.md`), `ui.language` (`{"value": "auto"|"en"|"zh-hans"|"zh-hant"|"ja"|"pt-br"|"es"|"uk"|"sr"|"sr-latn"|"et"|"tr"|"ru"|"pl"|"de"|"it"|"cs"|"hu"|"fr"}`), `ui.theme` (`{"value": "system"|"light"|"dark"}`; `system` follows the OS appearance, light when it reports none), `ui.interfaceSize` (`{"value": "auto"}` or `{"percent": 50–300}`, clamped; no value reads it; `auto` keeps the system display scale, a percentage is absolute, so 100 is one screen pixel per point (#822); the result's `percent` is the size drawn at and `auto` whether it follows the system; document zoom stays `view.zoom`), `ui.animations` (`{"value": "system"|"on"|"off"}`; no value reads it; `system` follows the system's animation setting, which the desktop app reads on Windows, macOS, Linux and the BSDs and the web app from `prefers-reduced-motion`, and means on where the system doesn't say; the result's `enabled` is what the setting comes to and `system` what the system reports, `null` when it doesn't say), `ui.fileBrowser` (desktop: `{"purpose": "document"|"picture"|"recipients"|"save"}` opens WordCraft's own file browser, for when the system file dialog doesn't show up, #576), `ui.saveAsTemplate` (File › Save As › Save as Word Template: the Save dialog for a `.dotx`, starting in the user templates folder; the result's `dir` is that folder, `null` on the web, #2852), `ui.caretAnimation` (`{"value": true|false}`; no value reads it; off, the caret jumps and blinks on and off instead of gliding and fading), `ui.startScreen` (`{"value": true|false}`; no value reads it; on, the default, the desktop and web apps start on File › Home when launched with nothing to open, #766), `ui.alignmentGuides` (`{"value": true|false}`; no value reads it; on, the default, moving a picture or shape snaps it to the page, the margins and other objects, with guide lines while dragging; Alt moves freely), `ui.quickAccessToolbar` (`{"value": true|false}`; no value toggles it; false takes the Quick Access Toolbar out of the title bar, as its menu's Hide Quick Access Toolbar does, and true brings it back, #1945), `ui.focusRibbon` (`{"value": true|false}`; no value toggles it; in Focus, true drops the ribbon down over the page, as the bar's ⋯ does, and false puts it away, as a click on the page does; outside Focus it stays put away, #1678)…) |
| `engine.execute` | `command`, `params` | the command's result (any id from `engine.commands`, including UI commands `ui.tab`, `ui.dialog` (`{"name": dialog, "part"?: table style part}`; `part` opens New / Modify Table Style (`newTableStyle` / `modifyTableStyle`) on that part: `wholeTable`, `headerRow`, `firstColumn`, `bandedRows`, `nwCell`… as `table.modifyStyle` names them), `ui.backstage`, `ui.zotero.*` (see `docs/zotero.md`), `ui.language` (`{"value": "auto"|"en"|"zh-hans"|"zh-hant"|"ja"|"pt-br"|"es"|"uk"|"sr"|"sr-latn"|"et"|"tr"|"ru"}`), `ui.theme` (`{"value": "system"|"light"|"dark"}`; `system` follows the OS appearance, light when it reports none), `ui.interfaceSize` (`{"value": "auto"}` or `{"percent": 50–300}`, clamped; no value reads it; `auto` keeps the system display scale, a percentage is absolute, so 100 is one screen pixel per point (#822); the result's `percent` is the size drawn at and `auto` whether it follows the system; document zoom stays `view.zoom`), `ui.animations` (`{"value": "system"|"on"|"off"}`; no value reads it; `system` follows the system's animation setting, which the desktop app reads on Windows, macOS, Linux and the BSDs and the web app from `prefers-reduced-motion`, and means on where the system doesn't say; the result's `enabled` is what the setting comes to and `system` what the system reports, `null` when it doesn't say), `ui.fileBrowser` (desktop: `{"purpose": "document"|"picture"|"recipients"|"save"}` opens WordCraft's own file browser, for when the system file dialog doesn't show up, #576), `ui.caretAnimation` (`{"value": true|false}`; no value reads it; off, the caret jumps and blinks on and off instead of gliding and fading), `ui.contextMenu` (opens the page's context menu below the caret, as Shift+F10 does)…) |
| `engine.commands` | — | every command: id, label, location, shortcut, params, enabled |
| `document.inspect` | `text?` | blocks (text, style, runs, lists, tables), parts, sections, selection, pages |
+2 -1
View File
@@ -1,6 +1,6 @@
# Where WordCraft falls short of Word
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-19 · **Change:** trivial (#8: English dialects — en-GB, en-CA, en-AU… flag each other's spellings, #1281; previously trivial: #1: shape gradients and transparency, #1500; previously trivial: text typed into a content control takes the control's formatting; localized placeholder styles recognised; previously trivial: forms protection keeps its content controls: no inserting, Properties or Remove Content Control; previously trivial: clearing a no-match font query keeps the recent heading visible, #2068; previously trivial: line breaking: a slash opening a word travels whole to the next line, #2963; previously trivial: recipient list search and merged-only view, #1576; previously trivial: #2 impact restated: only ink rendering and OLE editing remain; previously trivial: #2 narrowed: native ink nested in groups and drawing canvases kept on save; previously trivial: opening a DOCX no longer copies its pictures; previously trivial: RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: automatic Asian/Latin and Asian/number spacing laid out, #2358; previously trivial: hint-reliant CJK outlines; previously trivial: numbered headings, #1793; previously trivial: legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: #8 narrowed: Indonesian proofing from the installed Hunspell dictionary, #498; previously trivial: glossary/building blocks closed; previously trivial: Split Cells and Convert Text to Table follow the Word check, #2592; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717); previously trivial: #29: a copied equation also goes in the MathML clipboard formats, #1297; previously trivial: #24: the Spike, Ctrl+F3 and Ctrl+Shift+F3; previously trivial: VML freeforms read and kept on save; previously trivial: 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: floating-table overlap ignores empty line-break lines; previously trivial: copies keep their formatting on the clipboard as HTML, #1523; previously trivial: sample-text formulas on Enter, #1067; previously trivial: #29: copied equations as MathML, OMML open as #1297, #816; previously trivial: the Font list no longer keeps every previewed font in memory, #848; 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 Table and Eraser landed, #303; previously minor: beta distance restated: ~15 points, ~150–230 h; previously: first version; every known shortfall from the 2026-10-10 re-measure, ranked) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-19 · **Change:** trivial (personal templates in File › New and Save as Word Template, #2852; previously trivial: #8: English dialects — en-GB, en-CA, en-AU… flag each other's spellings, #1281; previously trivial: #1: shape gradients and transparency, #1500; previously trivial: text typed into a content control takes the control's formatting; localized placeholder styles recognised; previously trivial: forms protection keeps its content controls: no inserting, Properties or Remove Content Control; previously trivial: clearing a no-match font query keeps the recent heading visible, #2068; previously trivial: line breaking: a slash opening a word travels whole to the next line, #2963; previously trivial: recipient list search and merged-only view, #1576; previously trivial: #2 impact restated: only ink rendering and OLE editing remain; previously trivial: #2 narrowed: native ink nested in groups and drawing canvases kept on save; previously trivial: opening a DOCX no longer copies its pictures; previously trivial: RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: automatic Asian/Latin and Asian/number spacing laid out, #2358; previously trivial: hint-reliant CJK outlines; previously trivial: numbered headings, #1793; previously trivial: legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: #8 narrowed: Indonesian proofing from the installed Hunspell dictionary, #498; previously trivial: glossary/building blocks closed; previously trivial: Split Cells and Convert Text to Table follow the Word check, #2592; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717); previously trivial: #29: a copied equation also goes in the MathML clipboard formats, #1297; previously trivial: #24: the Spike, Ctrl+F3 and Ctrl+Shift+F3; previously trivial: VML freeforms read and kept on save; previously trivial: 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: floating-table overlap ignores empty line-break lines; previously trivial: copies keep their formatting on the clipboard as HTML, #1523; previously trivial: sample-text formulas on Enter, #1067; previously trivial: #29: copied equations as MathML, OMML open as #1297, #816; previously trivial: the Font list no longer keeps every previewed font in memory, #848; 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 Table and Eraser landed, #303; previously minor: beta distance restated: ~15 points, ~150–230 h; previously: first version; every known shortfall from the 2026-10-10 re-measure, ranked) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4
> **Printing update:** 2026-10-12 · direct desktop printing through craft-print; details below.
@@ -262,3 +262,4 @@ Footer page-table height and body wrapping are corrected. Other pagination gaps
| 2026-10-12 | trivial | #21: .xls workbooks and GBK/Big5/Shift_JIS/UTF-16 text lists as recipients; Filter and Sort in Edit Recipient List (#778) |
| 2026-10-12 | trivial | #811: large-table scrolling fixed; performance gap (#20) narrowed |
| 2026-10-12 | trivial | #13: radar, stock and other undrawn chart types show a placeholder instead of blank space (#998) |
| 2026-10-19 | trivial | Personal templates in File › New and Save as Word Template (#2852); no gap entry tracked it |
+2 -2
View File
@@ -2,11 +2,11 @@
Generated by `cargo xtask parity` (`wordcraft-cli parity --markdown`): the word-processor feature catalog (`crates/engine/src/catalog.rs`) compared with the live command registry.
**495 of 510 catalog features have commands (97.1%).**
**496 of 511 catalog features have commands (97.1%).**
| Tab | Live | Total |
|---|---|---|
| File | 22 | 22 |
| File | 23 | 23 |
| Quick Access | 5 | 5 |
| Title Bar | 1 | 1 |
| Home | 103 | 104 |
+3 -2
View File
@@ -1,6 +1,6 @@
# UI parity with Microsoft Word
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-21 · **Change:** trivial (legacy menu keys, #1697; previously trivial: Shape Fill › Gradient and the Format Shape pane, #1500; previously trivial: Focus fills the screen, and Full Screen View shows the pages alone, #1678; previously trivial: the text pointer over the page is drawn black or white on Windows, #1746; previously trivial: a click in the header/footer band from the body no longer scrolls to the body text, #2103; previously trivial: File › Recover follows the app theme, #3243; previously trivial: long navigation headings wrap onto further lines within the pane, #3015; previously trivial: clearing a no-match font query keeps the recent heading visible, #2068; previously trivial: dragging the vertical scrollbar names the page at the top of the window, #2551; previously 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
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-21 · **Change:** trivial (personal templates in File › New and Save as Word Template, #2852; previously trivial: legacy menu keys, #1697; previously trivial: Shape Fill › Gradient and the Format Shape pane, #1500; previously trivial: Focus fills the screen, and Full Screen View shows the pages alone, #1678; previously trivial: the text pointer over the page is drawn black or white on Windows, #1746; previously trivial: a click in the header/footer band from the body no longer scrolls to the body text, #2103; previously trivial: File › Recover follows the app theme, #3243; previously trivial: long navigation headings wrap onto further lines within the pane, #3015; previously trivial: clearing a no-match font query keeps the recent heading visible, #2068; previously trivial: dragging the vertical scrollbar names the page at the top of the window, #2551; previously 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
@@ -33,7 +33,7 @@ Document title opens Rename Document; the account badge opens Options › Genera
| Task panes (Navigation, Styles, Comments, Reviewing, Format Picture/Shape, Thesaurus, Accessibility, Clipboard, Selection) | Navigation (long headings wrap onto further lines within the pane instead of expanding it, #759, #3015), Styles, Comments (balloons and cards read in both themes, #2045), Thesaurus, Restrict Editing, Accessibility (grouped findings, jump-to, fixes) and Alt Text (#386), Read Aloud, Zotero; Style Inspector (#236); Clipboard pane (#264) open | all | 70% | 4–6 |
| Context menus (right-click) | text, spelling suggestions, fields (Update Field, Edit Field…, Toggle Field Codes, #339; Update Field on a table of contents entry updates the table, #1197, and after a save and reopen, #2182); right-click selects a picture, shape or text box and offers Insert Caption… (#1230) and Wrap Text (styles, Move with Text / Fix Position on Page, #513); tables, the items following the selection as in Word (Insert, Delete › Cells, Columns, Rows or Table whatever is selected (#1198), Merge and Split Cells, Distribute, AutoFit, Text Direction, Insert Caption…, Table Properties…; no Border Styles yet) | rich per context | 65% | 2–4 |
| Task panes (Navigation, Styles, Comments, Reviewing, Format Picture/Shape, Thesaurus, Accessibility, Clipboard, Selection) | Navigation (long headings wrap onto further lines within the pane instead of expanding it, #759, #3015), Styles, Comments (typed comment and reply text stays with its document, is written before Save, Export, Print, Close, New or Open, Escape discards it, and the fields are read-only where comments can't change, #1348), Thesaurus, Restrict Editing, Accessibility (grouped findings, jump-to, fixes) and Alt Text (#386), Read Aloud, Zotero; Style Inspector (#236); Clipboard pane (#264) open | all | 70% | 4–6 |
| Backstage (Home, New, Open, Info, Save As, Print, Share, Export, Options) | Home and New separate (#254), Info properties kept (#268), Options in Word's panes with working settings (#485), Print page with preview, page range and copies (#641); launched with nothing to open, the app starts on Home (the Start screen: Blank document, templates, Recent; no document is open until one is picked or opened, so Esc or Back shows an empty window with the ribbon greyed out but for Help, #1129), with File › Options › General › Start-up to turn it off (#766); Info › Inspect Document finds and removes hidden text by its effective formatting (direct, character or paragraph style) in every story: body, headers, footers, notes, comments and text boxes, and lists what it couldn't reach (#1344); File › Close (Mod+W, Mod+F4) closes the document and keeps the window, showing no page, as Word does; the window's close button quits (#1712); each side-menu item has an icon, drawn in code (#1714) | | 68% | 4–6 |
| Backstage (Home, New, Open, Info, Save As, Print, Share, Export, Options) | Home and New separate (#254), Info properties kept (#268), Options in Word's panes with working settings (#485), Print page with preview, page range and copies (#641); launched with nothing to open, the app starts on Home (the Start screen: Blank document, templates, Recent; no document is open until one is picked or opened, so Esc or Back shows an empty window with the ribbon greyed out but for Help, #1129), with File › Options › General › Start-up to turn it off (#766); Info › Inspect Document finds and removes hidden text by its effective formatting (direct, character or paragraph style) in every story: body, headers, footers, notes, comments and text boxes, and lists what it couldn't reach (#1344); File › Close (Mod+W, Mod+F4) closes the document and keeps the window, showing no page, as Word does; the window's close button quits (#1712); each side-menu item has an icon, drawn in code (#1714); New lists personal templates (`.dotx`/`.dotm`) from the user templates folder under Personal, a subfolder per category, and its search covers them; a click makes an untitled document attached to the template; Save As › Save as Word Template starts in that folder; the folder is set in Options › Advanced › File locations (#2852; no thumbnails yet, no workgroup templates) | | 68% | 4–6 |
| Rulers: indents, tabs, margins, table columns | indents draggable (one undo, #62); tab stops: corner selector (Left/Center/Right/Decimal/Bar), click to set, drag to move, drag off to clear, per-kind markers, Alt skips the snap, one undo step per gesture, double-click opens the Tabs dialog (#320); table column and row markers drag their borders (one undo) and in a cell the indents are measured from it (#700); the numbers thin out as the page shrinks on screen (every 1, 2, 4… units, never closer than 30 px, the small ticks never closer than 4 px, the text keeps its size), give way to an indent, tab or column marker on them, and the margins get ticks only; both rulers span the caret's page at its own scale in every layout (one column, One Page, Multiple Pages) and follow the caret to another page (#1878) | ✅ | 92% | 1 |
| On-canvas objects: select, drag, resize handles, nudge | ✅ tested (#228); a picture or shape behind text can be dragged across the overlapping text (body and header/footer), whether or not it was selected first, while a click without movement still edits that text | ✅ | 75% | — |
| On-canvas objects: select, drag, Ctrl+drag to copy, resize handles, nudge | ✅ tested (#228, #1014) | ✅ | 75% | — |
@@ -262,4 +262,5 @@ labels unambiguous. Those extra-number assignments have not been compared with W
| 2026-10-13 | trivial | Double-clicking the top or bottom margin of a page with no header or footer creates the one that page shows and edits it, as in Word; one left empty is taken away again on leaving; `insert.editHeader`/`insert.editFooter {"page"}` for agents (#960); making one is an undo step (`insert.createHeader`/`insert.createFooter`), refused read-only, linked from the first section |
| 2026-10-15 | trivial | Narrow windows: the tab strip keeps the room of Comments / Editing / Share and gives way in order (tab padding, Comments and Editing labels, Share label, plain tabs into », active and contextual labels cut with …, last the active tab into »); File never leaves the strip. Public sources say Word keeps those buttons in view and shrinks the tabs; the » menu is ours (#2026) |
| 2026-10-15 | trivial | Interface Theme as a searchable View › Dark Mode command (light/dark/system, toggles light and dark without a value), driving the same setting as Options; ribbon menu rewired to it (#1861) |
| 2026-10-19 | trivial | Personal templates (#2852): File › New › Personal lists the user templates folder (`<settings>/Templates`, or File › Options › Advanced › File locations › User templates), subfolders as categories, searchable; a template opens as an untitled document attached to it; Save As › Save as Word Template starts there (`file.listTemplates`, `ui.saveAsTemplate`) |
| 2026-10-15 | trivial | Double-clicking a page number in a later page's footer opens the footer there: the first click no longer moves the caret to the body text and scrolls the view up to it (#2103) |