From e46133817a3edf72f6bdaa2f4b1059b98cf1b617 Mon Sep 17 00:00:00 2001 From: Valentin Klein Date: Mon, 12 Oct 2026 01:35:06 +0200 Subject: [PATCH] Templates and Add-ins: attached template, update styles, Organizer (#377) (#382) * Templates and Add-ins: attached template, update styles, Organizer (#377) - Model + DOCX: Settings keep the attached template (w:attachedTemplate's external relationship of the settings part) and w:linkStyles; both round-trip, written in CT_Settings order (after w:mirrorMargins, before w:trackRevisions). - Engine: tools.templates (state), tools.attachTemplate, tools.detachTemplate, tools.linkStyles, tools.updateStyles (copy the template's styles and defaults, one undo step; path or base64 data), styles.organizer (list), styles.copyFrom (copies missing bases, linked halves and the lists a style numbers with), styles.rename. The template path comes from the document, so it is never read automatically (not on open either): only an explicit tools.updateStyles / attach with update reads it. - UI: View > Templates > Templates and Add-ins dialog (path, Attach..., automatically update document styles, Organizer...) and a two-list Organizer (Copy, Delete, Rename...); strings in every catalog, Estonian included. Co-Authored-By: Claude Opus 5.5 * Review fixes for #382: never follow a template path to another machine A document's w:attachedTemplate can name \\host\share\T.dotx, //host/share or file://host/share. Opening it (Update Styles, Organizer) would make Windows authenticate to that host over SMB and leak the user's NTLM credentials. local_path now returns None for network paths; regression test added. Co-Authored-By: Claude Opus 5.5 * #377: refresh catalog count and parity checklist after rebase --------- Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas --- ROADMAP.md | 2 +- crates/doc/src/lib.rs | 8 + crates/docx/src/package.rs | 1 + crates/docx/src/read/mod.rs | 12 + crates/docx/src/write/mod.rs | 18 +- crates/docx/tests/roundtrip.rs | 32 ++ crates/engine/src/cmd/mod.rs | 2 + crates/engine/src/cmd/templates.rs | 513 ++++++++++++++++++++++++ crates/ui-egui/src/dialogs.rs | 16 + crates/ui-egui/src/dialogs_templates.rs | 314 +++++++++++++++ crates/ui-egui/src/file_dialogs.rs | 9 + crates/ui-egui/src/i18n/es.tsv | 13 + crates/ui-egui/src/i18n/et.tsv | 13 + crates/ui-egui/src/i18n/ja.tsv | 13 + crates/ui-egui/src/i18n/pt-br.tsv | 13 + crates/ui-egui/src/i18n/ru.tsv | 13 + crates/ui-egui/src/i18n/sr-latn.tsv | 13 + crates/ui-egui/src/i18n/sr.tsv | 13 + crates/ui-egui/src/i18n/tr.tsv | 13 + crates/ui-egui/src/i18n/uk.tsv | 13 + crates/ui-egui/src/i18n/zh-hans.tsv | 13 + crates/ui-egui/src/i18n/zh-hant.tsv | 13 + crates/ui-egui/src/icons.rs | 6 + crates/ui-egui/src/lib.rs | 8 + crates/ui-egui/src/ribbon.rs | 3 + docs/file-format-parity.md | 3 +- docs/gaps.md | 2 +- docs/parity-checklist.md | 8 +- docs/target-app-parity.md | 2 +- docs/ui-parity.md | 3 +- 30 files changed, 1091 insertions(+), 14 deletions(-) create mode 100644 crates/engine/src/cmd/templates.rs create mode 100644 crates/ui-egui/src/dialogs_templates.rs diff --git a/ROADMAP.md b/ROADMAP.md index 9fd65a2..2d71563 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -14,7 +14,7 @@ agent control. This page is the summary; the assessment is | Number | Value | Kind | |---|---|---| -| Ribbon/menu catalog coverage | **458 / 475 (96.4%)** | measured (`cargo xtask parity` → [`docs/parity-checklist.md`](docs/parity-checklist.md)) | +| Ribbon/menu catalog coverage | **459 / 475 (96.6%)** | measured (`cargo xtask parity` → [`docs/parity-checklist.md`](docs/parity-checklist.md)) | | **Feature breadth** (weighted, incl. dialog options, styles, languages beyond the ribbon) | **~80%** | estimated | | **Ready for real work** (full target) | **~60%** (55–63%) | estimated, additive weighted sum over the dimensions | | **Mainstream practitioner** | **~55%** | estimated: weekly-work depth × discounts for interaction (×0.93), stability (×0.90), file exchange (×0.90) | diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 1bd4c6e..74f9d37 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -441,6 +441,12 @@ pub struct Settings { /// Equation options (`m:mathPr`), when the document has them. #[serde(skip_serializing_if = "Option::is_none")] pub math: Option, + /// The template the document is attached to (Tools › Templates and Add-ins): the target of + /// `w:attachedTemplate`'s relationship, a path or `file:` URL as the file gives it. + #[serde(skip_serializing_if = "Option::is_none")] + pub attached_template: Option, + /// Update the document's styles from the attached template when it is opened (`w:linkStyles`). + pub link_styles: bool, } /// Word's compatibility mode for documents that don't state one. @@ -515,6 +521,8 @@ impl Default for Settings { grid_h: DEFAULT_GRID, grid_v: DEFAULT_GRID, math: None, + attached_template: None, + link_styles: false, } } } diff --git a/crates/docx/src/package.rs b/crates/docx/src/package.rs index c3e3ebc..120893c 100644 --- a/crates/docx/src/package.rs +++ b/crates/docx/src/package.rs @@ -22,6 +22,7 @@ pub mod rt { pub const STYLES: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles"; pub const NUMBERING: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/numbering"; pub const SETTINGS: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/settings"; + pub const ATTACHED_TEMPLATE: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/attachedTemplate"; pub const THEME: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/theme"; pub const FOOTNOTES: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/footnotes"; pub const ENDNOTES: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/endnotes"; diff --git a/crates/docx/src/read/mod.rs b/crates/docx/src/read/mod.rs index 8a1f5ca..f08ed21 100644 --- a/crates/docx/src/read/mod.rs +++ b/crates/docx/src/read/mod.rs @@ -35,6 +35,8 @@ const MAX_PARTS: usize = 50_000; /// Most graphic work (items plus path segments, see `story::graphic_work`) built for one file's /// charts and diagrams; later ones are left empty. const MAX_GRAPHIC_WORK: usize = 2_000_000; +/// Longest attached-template path we keep (bytes); a longer one is not a real path. +const MAX_TEMPLATE_PATH: usize = 32 * 1024; pub(crate) struct Reader<'p> { pkg: &'p Package, @@ -809,9 +811,19 @@ impl Reader<'_> { fn read_settings(&mut self, path: &str) -> Result<(), DocxError> { let Some(root) = self.xml(path)? else { return Ok(()) }; + let rels = self.pkg.rels(path); let s = &mut self.doc.settings; for k in root.els() { match k.name.as_str() { + // §17.15.1.6: the template, by a relationship (usually external) of the settings part. + "w:attachedTemplate" => { + s.attached_template = k + .attr("r:id") + .and_then(|id| rels.by_id(id)) + .map(|r| r.target.trim().to_string()) + .filter(|t| !t.is_empty() && t.len() <= MAX_TEMPLATE_PATH); + } + "w:linkStyles" => s.link_styles = on_off(k), "w:compat" => { // Only Word's own setting: another `w:uri` names a different application's. let mode = k diff --git a/crates/docx/src/write/mod.rs b/crates/docx/src/write/mod.rs index 62d2cd8..9ab3066 100644 --- a/crates/docx/src/write/mod.rs +++ b/crates/docx/src/write/mod.rs @@ -339,13 +339,17 @@ pub fn write_as(doc: &Document, flavor: Flavor) -> Result, DocxError> { push_part(&mut entries, &mut overrides, "word/numbering.xml", numbering_xml(doc), &format!("{CT_WML}numbering+xml"), PartRels::default()); } rels.add(rt::SETTINGS, "settings.xml", false); + // The attached template is an external relationship of the settings part (§17.15.1.6). + let mut settings_rels = PartRels::default(); + let template = doc.settings.attached_template.as_deref().map(str::trim).filter(|t| !t.is_empty()); + let template_rel = template.map(|t| settings_rels.add(rt::ATTACHED_TEMPLATE, t, true)); push_part( &mut entries, &mut overrides, "word/settings.xml", - settings_xml(doc, notes_part(doc, &wr, true), notes_part(doc, &wr, false)), + settings_xml(doc, notes_part(doc, &wr, true), notes_part(doc, &wr, false), template_rel.as_deref()), &format!("{CT_WML}settings+xml"), - PartRels::default(), + settings_rels, ); rels.add(rt::THEME, "theme/theme1.xml", false); push_part( @@ -845,7 +849,9 @@ fn notes_part(doc: &Document, wr: &Writer<'_>, foot: bool) -> bool { !ids.is_empty() || !seps.is_empty() } -fn settings_xml(doc: &Document, footnotes: bool, endnotes: bool) -> Vec { +/// `w:settings`, its children in the schema's order (CT_Settings is a sequence). `template_rel` +/// is the id of the attached template's relationship, when there is one. +fn settings_xml(doc: &Document, footnotes: bool, endnotes: bool, template_rel: Option<&str>) -> Vec { let s = &doc.settings; let s_notes = (&doc.footnote_separators, &doc.endnote_separators); let mut w = W::new(); @@ -859,6 +865,12 @@ fn settings_xml(doc: &Document, footnotes: bool, endnotes: bool) -> Vec { if s.mirror_margins { w.empty("w:mirrorMargins", &[]); } + if let Some(id) = template_rel { + w.empty("w:attachedTemplate", &[("r:id", id)]); + } + if s.link_styles { + w.empty("w:linkStyles", &[]); + } if s.track_changes { w.empty("w:trackRevisions", &[]); } diff --git a/crates/docx/tests/roundtrip.rs b/crates/docx/tests/roundtrip.rs index 5da8a57..e2c8c47 100644 --- a/crates/docx/tests/roundtrip.rs +++ b/crates/docx/tests/roundtrip.rs @@ -1139,6 +1139,38 @@ fn compatibility_mode_round_trips() { } } +/// Tools › Templates and Add-ins: the attached template (an external relationship of the settings +/// part) and "Automatically update document styles" (`w:linkStyles`) round-trip, written in the +/// schema's order (after `w:mirrorMargins`, before `w:trackRevisions`). +#[test] +fn attached_template_and_link_styles_round_trip() { + let mut d = Document::new(); + d.settings.attached_template = Some("file:///C:/Templates/Report & Co.dotx".into()); + d.settings.link_styles = true; + d.settings.mirror_margins = true; + d.settings.track_changes = true; + let bytes = wordcraft_docx::write(&d).expect("write"); + let mut zip = zip::ZipArchive::new(std::io::Cursor::new(&bytes)).unwrap(); + let mut xml = String::new(); + std::io::Read::read_to_string(&mut zip.by_name("word/settings.xml").unwrap(), &mut xml).unwrap(); + let at = |tag: &str| xml.find(tag).unwrap_or_else(|| panic!("{tag} missing: {xml}")); + assert!(at(" Registry { v.extend(wordart::specs()); v.extend(connectors::specs()); v.extend(tools::specs()); + v.extend(templates::specs()); v.extend(protect::specs()); v.extend(speech::specs()); v.extend(controls::specs()); diff --git a/crates/engine/src/cmd/templates.rs b/crates/engine/src/cmd/templates.rs new file mode 100644 index 0000000..f8033d0 --- /dev/null +++ b/crates/engine/src/cmd/templates.rs @@ -0,0 +1,513 @@ +//! Tools › Templates and Add-ins: the template a document is attached to, updating the +//! document's styles from it, and the Organizer (copy styles from another document or template, +//! delete and rename styles in this one). +//! +//! The attached template is kept as the file gives it (`w:attachedTemplate`, ECMA-376 Part 1 +//! §17.15.1.6), and "Automatically update document styles" is `w:linkStyles` (§17.15.1.56). +//! The template path comes from the document, so it is never read on its own (not on open +//! either): only an explicit `tools.updateStyles` or `tools.attachTemplate` with `update` reads it. + +use std::collections::{HashMap, HashSet}; +use std::path::{Path, PathBuf}; + +use serde_json::{Value, json}; +use wordcraft_doc::Document; +use wordcraft_doc::numbering::{AbstractNum, Num}; +use wordcraft_doc::styles::{Style, StyleKind}; + +use super::para::touch_all; +use crate::{CmdError, CmdResult, CommandSpec, Session, p}; + +/// Longest template path or style name list we take from params. +const MAX_PATH: usize = 32 * 1024; +/// Most styles one copy takes (with the styles they are based on). +const MAX_COPY: usize = 10_000; +/// Longest style name (Word's limit). +const MAX_NAME: usize = 253; + +pub fn specs() -> Vec { + const LOC: &str = "View › Templates › Templates and Add-ins"; + vec![ + CommandSpec::new("tools.templates", "Templates and Add-ins", "View › Templates", |s, _| Ok(state(s))) + .params(r#"{} → {"template": path|null, "linkStyles": bool} (from the ribbon, the app shows the dialog)"#) + .pure(), + CommandSpec::new("tools.attachTemplate", "Attach Template", LOC, attach) + .params(r#"{"path": string, "linkStyles"?: bool, "update"?: bool (update the styles from it now), "data"?: base64 (the template's bytes, for "update")}"#), + CommandSpec::new("tools.detachTemplate", "Detach Template", LOC, |s, _| { + s.doc.settings.attached_template = None; + s.doc.settings.link_styles = false; + Ok(state(s)) + }), + CommandSpec::new("tools.linkStyles", "Automatically Update Document Styles", LOC, |s, v| { + s.doc.settings.link_styles = p::bool(v, "value").unwrap_or(!s.doc.settings.link_styles); + Ok(state(s)) + }) + .params(r#"{"value"?: bool (default: toggle)}"#), + CommandSpec::new("tools.updateStyles", "Update Styles from Template", LOC, update_styles) + .params(r#"{"path"?: string (default: the attached template), "data"?: base64 (the file's bytes; `path` then only names it)} → {"added", "replaced"}"#), + CommandSpec::new("styles.organizer", "Organizer", "View › Templates › Organizer", organizer) + .params(r#"{"path"?: string, "data"?: base64} → {"document": [style], "file"?: {"path", "styles": [style]}}"#) + .pure(), + CommandSpec::new("styles.copyFrom", "Copy Styles", "View › Templates › Organizer", copy_from) + .params(r#"{"path": string, "data"?: base64, "styles": [name or id]} → {"added", "replaced", "skipped"}"#), + CommandSpec::new("styles.rename", "Rename Style", "View › Templates › Organizer", rename) + .params(r#"{"style": string (name or id; custom styles only), "name": string}"#), + ] +} + +fn state(s: &Session) -> Value { + json!({"template": s.doc.settings.attached_template, "linkStyles": s.doc.settings.link_styles}) +} + +fn attach(s: &mut Session, v: &Value) -> CmdResult { + let path = p::req_str(v, "path")?.trim(); + if path.is_empty() { + return Err(CmdError::Params("`path` is empty".into())); + } + if path.len() > MAX_PATH { + return Err(CmdError::Params("`path` is too long".into())); + } + let path = path.to_string(); + s.doc.settings.attached_template = Some(path.clone()); + if let Some(on) = p::bool(v, "linkStyles") { + s.doc.settings.link_styles = on; + } + let mut out = state(s); + if p::bool(v, "update").unwrap_or(false) { + let mut params = json!({"path": path}); + if let (Some(d), Some(o)) = (p::str(v, "data"), params.as_object_mut()) { + o.insert("data".into(), json!(d)); + } + let r = update_styles(s, ¶ms)?; + if let Some(o) = out.as_object_mut() { + o.insert("updated".into(), r); + } + } + Ok(out) +} + +/// Copy every style of the template (and its document defaults) into the document, replacing +/// styles of the same name. +fn update_styles(s: &mut Session, v: &Value) -> CmdResult { + let attached = s.doc.settings.attached_template.clone(); + let path = p::str(v, "path").map(str::to_string).or(attached).ok_or_else(|| CmdError::Params("no template is attached; give `path`".into()))?; + let base = s.path.clone(); + let src = load_other(&path, p::str(v, "data"), base.as_deref())?; + let report = apply_template_styles(&mut s.doc, &src); + touch_all(s); + Ok(report) +} + +/// What [`update_styles`] does to a document: all of `src`'s styles and its defaults. +pub fn apply_template_styles(doc: &mut Document, src: &Document) -> Value { + doc.styles.default_chr = src.styles.default_chr.clone(); + doc.styles.default_para = src.styles.default_para.clone(); + let ids: Vec = src.styles.styles.iter().map(|st| st.id.clone()).collect(); + copy_styles(doc, src, &ids) +} + +fn organizer(s: &mut Session, v: &Value) -> CmdResult { + let mut out = json!({"document": style_list(&s.doc)}); + if let Some(path) = p::str(v, "path") { + let base = s.path.clone(); + let other = load_other(path, p::str(v, "data"), base.as_deref())?; + if let Some(o) = out.as_object_mut() { + o.insert("file".into(), json!({"path": path, "styles": style_list(&other)})); + } + } + Ok(out) +} + +/// A document's styles for the Organizer, by name: `[{id, name, type, builtIn}]`. +pub fn style_list(doc: &Document) -> Value { + let mut list: Vec<&Style> = doc.styles.styles.iter().collect(); + list.sort_by_cached_key(|st| st.name.to_lowercase()); + Value::Array( + list.iter() + .map(|st| { + let kind = match st.kind { + StyleKind::Paragraph => "paragraph", + StyleKind::Character => "character", + StyleKind::Table => "table", + StyleKind::Numbering => "numbering", + }; + json!({"id": st.id, "name": st.name, "type": kind, "builtIn": st.builtin}) + }) + .collect(), + ) +} + +fn copy_from(s: &mut Session, v: &Value) -> CmdResult { + let path = p::req_str(v, "path")?; + let names: Vec<&str> = match v.get("styles") { + Some(Value::Array(a)) => a.iter().filter_map(Value::as_str).take(MAX_COPY).collect(), + Some(Value::String(one)) => vec![one.as_str()], + _ => return Err(CmdError::Params("`styles` (list of style names) is required".into())), + }; + if names.is_empty() { + return Err(CmdError::Params("`styles` is empty".into())); + } + let base = s.path.clone(); + let src = load_other(path, p::str(v, "data"), base.as_deref())?; + let mut ids = Vec::new(); + for n in names { + let st = src.styles.find(n).ok_or_else(|| CmdError::Params(format!("`{path}` has no style `{n}`")))?; + ids.push(st.id.clone()); + } + let report = copy_styles(&mut s.doc, &src, &ids); + touch_all(s); + Ok(report) +} + +fn rename(s: &mut Session, v: &Value) -> CmdResult { + let which = p::req_str(v, "style")?; + let name = p::req_str(v, "name")?.trim().to_string(); + if name.is_empty() || name.chars().count() > MAX_NAME { + return Err(CmdError::Params(format!("a style name has 1 to {MAX_NAME} characters"))); + } + let st = s.doc.styles.find(which).cloned().ok_or_else(|| CmdError::Params(format!("no style `{which}`")))?; + if st.builtin { + return Err(CmdError::Failed(format!("`{}` is a built-in style and can't be renamed", st.name))); + } + if s.doc.styles.styles.iter().any(|o| o.id != st.id && o.name.eq_ignore_ascii_case(&name)) { + return Err(CmdError::Failed(format!("a style named `{name}` already exists"))); + } + if let Some(x) = s.doc.styles.get_mut(&st.id) { + x.name = name.clone(); + } + touch_all(s); + Ok(json!({"id": st.id, "name": name})) +} + +/// Read another document or template: from `data` (base64 bytes, `name` gives the format) or +/// from the file `name` names (a path or `file:` URL; relative to `base`'s folder). +fn load_other(name: &str, data: Option<&str>, base: Option<&Path>) -> Result { + if name.len() > MAX_PATH { + return Err(CmdError::Params("`path` is too long".into())); + } + let local = local_path(name, base).ok_or_else(|| CmdError::Failed(format!("`{name}` is not a local file")))?; + let ext = local.extension().map(|e| e.to_string_lossy().to_ascii_lowercase()).unwrap_or_default(); + if matches!(ext.as_str(), "" | "txt" | "text") { + return Err(CmdError::Params(format!("`{name}` is not a document or template with styles"))); + } + match data { + Some(d) => { + let bytes = super::insert::base64_decode(d).ok_or_else(|| CmdError::Params("bad base64".into()))?; + crate::io::open_bytes(&local.to_string_lossy(), &bytes).map_err(CmdError::Failed) + } + None => crate::io::open_path(&local).map_err(CmdError::Failed), + } +} + +/// A template reference as a local path: a `file:` URL (percent-encoded) becomes a path, a +/// relative path is taken from `base`'s folder. Other URLs (`http:`…) are `None`. +pub fn local_path(target: &str, base: Option<&Path>) -> Option { + let t = target.trim(); + let lower = t.to_ascii_lowercase(); + let path = if lower.starts_with("file:") { + let rest = percent_decode(t.get(5..)?); + let rest = match rest.strip_prefix("//") { + // `file:///x` and `file://localhost/x` are local; `file://host/share/x` names another machine. + Some(r) if r.starts_with('/') => r.to_string(), + Some(r) if r.len() >= 10 && r.get(..10).is_some_and(|h| h.eq_ignore_ascii_case("localhost/")) => r.get(9..)?.to_string(), + Some(_) => return None, + None => rest, + }; + // `file:///C:/x` → `C:/x`. + let b = rest.as_bytes(); + if b.first() == Some(&b'/') && b.get(1).is_some_and(u8::is_ascii_alphabetic) && b.get(2) == Some(&b':') { + rest.get(1..)?.to_string() + } else { + rest + } + } else if t.contains("://") { + return None; + } else { + t.to_string() + }; + // Never follow a template path to another machine: opening `\\host\share\T.dotx` (or `//host/…`) from a + // hostile document would make Windows authenticate to that host and leak the user's NTLM credentials. + if path.is_empty() || is_network_path(&path) { + return None; + } + let p = PathBuf::from(&path); + if p.is_relative() + && !is_windows_absolute(&path) + && let Some(dir) = base.and_then(Path::parent) + { + return Some(dir.join(p)); + } + Some(p) +} + +fn is_network_path(p: &str) -> bool { + let b = p.as_bytes(); + matches!((b.first(), b.get(1)), (Some(b'\\' | b'/'), Some(b'\\' | b'/'))) +} + +fn is_windows_absolute(p: &str) -> bool { + let b = p.as_bytes(); + p.starts_with("\\\\") || (b.first().is_some_and(u8::is_ascii_alphabetic) && b.get(1) == Some(&b':')) +} + +fn percent_decode(s: &str) -> String { + let hex = |c: u8| (c as char).to_digit(16).and_then(|d| u8::try_from(d).ok()); + let mut out = Vec::with_capacity(s.len()); + let mut it = s.bytes(); + while let Some(c) = it.next() { + if c != b'%' { + out.push(c); + continue; + } + let rest = it.clone(); + match (it.next().and_then(hex), it.next().and_then(hex)) { + (Some(h), Some(l)) => out.push((h << 4) | l), + _ => { + out.push(b'%'); + it = rest; + } + } + } + String::from_utf8_lossy(&out).into_owned() +} + +/// Copy styles `ids` of `src` into `doc`, Organizer-style: a style replaces the document's style +/// of the same name (and kind), or is added (under its own id when that is free). The styles they +/// are based on that the document lacks, and their linked halves, come too; `based on`, `next` +/// and `linked` are mapped to the document's ids, and lists a style numbers with are copied. +/// Returns `{"added": [name], "replaced": [name], "skipped": [name]}`. +pub fn copy_styles(doc: &mut Document, src: &Document, ids: &[String]) -> Value { + let sheet = &src.styles; + let same_name = |doc: &Document, st: &Style| doc.styles.styles.iter().find(|d| d.name.eq_ignore_ascii_case(&st.name)).map(|d| d.id.clone()); + // What to copy: the asked-for styles, their linked halves, and missing bases. + let mut want: Vec = Vec::new(); + let mut seen: HashSet = HashSet::new(); + let mut queue: Vec = ids.iter().rev().cloned().collect(); + while let Some(id) = queue.pop() { + if want.len() >= MAX_COPY { + break; + } + if !seen.insert(id.clone()) { + continue; + } + let Some(st) = sheet.get(&id) else { continue }; + want.push(id.clone()); + if let Some(l) = st.linked.as_deref() + && sheet.get(l).is_some_and(|x| x.linked.as_deref() == Some(st.id.as_str())) + { + queue.push(l.to_string()); + } + if let Some(b) = st.based_on.as_deref() + && let Some(base) = sheet.get(b) + && same_name(doc, base).is_none() + { + queue.push(b.to_string()); + } + } + // Ids in the document, in `src` order. + let mut map: HashMap = HashMap::new(); + let mut order: Vec<(String, String)> = Vec::new(); + let (mut added, mut replaced, mut skipped) = (Vec::new(), Vec::new(), Vec::new()); + let want: HashSet = want.into_iter().collect(); + for st in sheet.styles.iter().filter(|st| want.contains(&st.id)) { + let target = match same_name(doc, st) { + Some(existing) => { + if doc.styles.get(&existing).is_some_and(|d| d.kind != st.kind) { + skipped.push(st.name.clone()); + continue; + } + replaced.push(st.name.clone()); + existing + } + None => { + added.push(st.name.clone()); + if doc.styles.get(&st.id).is_none() { st.id.clone() } else { doc.styles.new_id(&st.name) } + } + }; + let builtin = doc.styles.get(&target).is_some_and(|d| d.builtin) || st.builtin; + doc.styles.upsert(Style { id: target.clone(), builtin, ..st.clone() }); + map.insert(st.id.clone(), target.clone()); + order.push((st.id.clone(), target)); + } + // References to `src` ids → the document's ids (copied styles, or same-named ones). + let resolve = |doc: &Document, map: &HashMap, r: &str| -> Option { + map.get(r).cloned().or_else(|| sheet.get(r).and_then(|st| same_name(doc, st))) + }; + let mut nums: HashMap = HashMap::new(); + for (src_id, target) in order { + let Some(st) = sheet.get(&src_id) else { continue }; + let based_on = st.based_on.as_deref().and_then(|r| resolve(doc, &map, r)).filter(|b| *b != target); + let next = st.next.as_deref().and_then(|r| resolve(doc, &map, r)); + let linked = st.linked.as_deref().and_then(|r| map.get(r).cloned()); + let numbering = st.para.numbering.map(|mut n| { + if n.num != 0 { + n.num = import_num(doc, src, n.num, &map, &mut nums).unwrap_or(0); + } + n + }); + if let Some(d) = doc.styles.get_mut(&target) { + d.based_on = based_on; + d.next = next; + d.linked = linked; + d.para.numbering = numbering.filter(|n| n.num != 0 || st.para.numbering.is_some_and(|o| o.num == 0)); + } + } + json!({"added": added, "replaced": replaced, "skipped": skipped}) +} + +/// Copy list `num` of `src` (and its abstract list) into `doc` under new ids; memoised in `done`. +fn import_num(doc: &mut Document, src: &Document, num: u32, map: &HashMap, done: &mut HashMap) -> Option { + if let Some(n) = done.get(&num) { + return Some(*n); + } + let n = src.numbering.nums.iter().find(|n| n.id == num)?; + let abs = src.numbering.abstracts.iter().find(|a| a.id == n.abstract_id)?; + let new_abs = doc.numbering.abstracts.iter().map(|a| a.id).max().map_or(Some(0), |m| m.checked_add(1))?; + let new_num = doc.numbering.nums.iter().map(|n| n.id).max().map_or(Some(1), |m| m.checked_add(1))?; + let mut levels = abs.levels.clone(); + for l in &mut levels { + l.style = l.style.as_deref().and_then(|s| map.get(s).cloned().or_else(|| doc.styles.get(s).map(|x| x.id.clone()))); + } + doc.numbering.abstracts.push(AbstractNum { id: new_abs, name: abs.name.clone(), levels }); + doc.numbering.nums.push(Num { id: new_num, abstract_id: new_abs, ..n.clone() }); + done.insert(num, new_num); + Some(new_num) +} + +#[cfg(test)] +mod tests { + use super::*; + use wordcraft_doc::numbering::ListKind; + use wordcraft_doc::props::{CharProps, NumRef}; + + /// A template built in code: a changed Heading 1, a custom "Callout" based on a custom + /// "Box" (missing in the document) and a numbered "Steps" style. + fn template() -> Document { + let mut t = Document::new(); + if let Some(h) = t.styles.get_mut("Heading1") { + h.chr.size = Some(31.0); + } + t.styles.upsert(Style { + id: "Box".into(), + name: "Box".into(), + kind: StyleKind::Paragraph, + based_on: Some("Normal".into()), + ..Default::default() + }); + t.styles.upsert(Style { + id: "Callout".into(), + name: "Callout".into(), + kind: StyleKind::Paragraph, + based_on: Some("Box".into()), + chr: CharProps { italic: Some(true), ..Default::default() }, + ..Default::default() + }); + let num = t.numbering.add_list(ListKind::Numbered); + t.styles.upsert(Style { + id: "Steps".into(), + name: "Steps".into(), + kind: StyleKind::Paragraph, + para: wordcraft_doc::props::ParaProps { numbering: Some(NumRef { num, level: 0 }), ..Default::default() }, + ..Default::default() + }); + t + } + + fn save(name: &str, doc: &Document) -> PathBuf { + let dir = std::env::temp_dir().join(format!("wordcraft-templates-{name}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("Report.dotx"); + crate::io::save_path(&path, doc).unwrap(); + path + } + + #[test] + fn update_styles_from_template_is_one_undo_step() { + let path = save("update", &template()); + let mut s = Session::new(Document::new()); + s.run("tools.attachTemplate", &json!({"path": path.to_string_lossy()})).unwrap(); + assert_eq!(s.doc.settings.attached_template.as_deref(), Some(path.to_string_lossy().as_ref())); + let before = s.doc.styles.clone(); + let r = s.run("tools.updateStyles", &json!({})).unwrap(); + assert!(r["replaced"].as_array().unwrap().iter().any(|n| n == "heading 1" || n == "Heading 1"), "{r}"); + assert_eq!(s.doc.styles.get("Heading1").unwrap().chr.size, Some(31.0)); + let callout = s.doc.styles.get("Callout").unwrap(); + assert_eq!(callout.based_on.as_deref(), Some("Box"), "the missing base came along"); + let steps = s.doc.styles.get("Steps").unwrap().para.numbering.unwrap(); + assert!(s.doc.numbering.nums.iter().any(|n| n.id == steps.num), "the style's list was copied"); + s.run("edit.undo", &json!({})).unwrap(); + assert_eq!(s.doc.styles, before); + // A missing template is an error, not a crash, and changes nothing. + let err = s.run("tools.updateStyles", &json!({"path": "/nonexistent/x.dotx"})); + assert!(err.is_err()); + assert_eq!(s.doc.styles, before); + let _ = std::fs::remove_dir_all(path.parent().unwrap()); + } + + #[test] + fn organizer_copies_renames_and_undoes() { + let path = save("organizer", &template()); + let p = path.to_string_lossy().to_string(); + let mut s = Session::new(Document::new()); + let listed = s.run("styles.organizer", &json!({"path": p})).unwrap(); + assert!(listed["file"]["styles"].as_array().unwrap().iter().any(|x| x["name"] == "Callout")); + let r = s.run("styles.copyFrom", &json!({"path": p, "styles": ["Callout"]})).unwrap(); + assert_eq!(r["added"], json!(["Box", "Callout"])); + assert_eq!(s.doc.styles.get("Callout").unwrap().chr.italic, Some(true)); + s.run("styles.rename", &json!({"style": "Callout", "name": "Aside"})).unwrap(); + assert_eq!(s.doc.styles.find("Aside").map(|x| x.id.as_str()), Some("Callout")); + assert!(s.run("styles.rename", &json!({"style": "Normal", "name": "Plain"})).is_err(), "built-in styles keep their names"); + assert!(s.run("styles.rename", &json!({"style": "Aside", "name": "Box"})).is_err(), "names stay unique"); + s.run("edit.undo", &json!({})).unwrap(); + assert!(s.doc.styles.find("Callout").is_some()); + s.run("edit.undo", &json!({})).unwrap(); + assert!(s.doc.styles.get("Callout").is_none() && s.doc.styles.get("Box").is_none()); + let _ = std::fs::remove_dir_all(path.parent().unwrap()); + } + + /// The template path comes from the document, so opening never reads it, even with + /// "Automatically update document styles" on: only an explicit `tools.updateStyles` does. + #[test] + fn opening_a_linked_document_never_reads_its_template() { + let path = save("open", &template()); + let mut d = Document::new(); + d.settings.attached_template = Some("Report.dotx".into()); + d.settings.link_styles = true; + let doc_path = path.with_file_name("Letter.docx"); + crate::io::save_path(&doc_path, &d).unwrap(); + let mut s = Session::new(Document::new()); + s.run("file.open", &json!({"path": doc_path.to_string_lossy()})).unwrap(); + assert_eq!(s.doc.settings.attached_template.as_deref(), Some("Report.dotx"), "the reference is kept"); + assert!(s.doc.settings.link_styles); + assert_ne!(s.doc.styles.get("Heading1").unwrap().chr.size, Some(31.0), "the template's styles were not read"); + assert!(s.doc.styles.get("Callout").is_none()); + assert!(!s.dirty); + // The user's explicit update reads it (relative to the document's folder). + s.run("tools.updateStyles", &json!({})).unwrap(); + assert_eq!(s.doc.styles.get("Heading1").unwrap().chr.size, Some(31.0)); + let _ = std::fs::remove_dir_all(path.parent().unwrap()); + } + + #[test] + fn template_references_become_local_paths() { + assert_eq!(local_path("file:///C:/Users/A%20B/T.dotx", None), Some(PathBuf::from("C:/Users/A B/T.dotx"))); + assert_eq!(local_path("file:///home/a/T.dotx", None), Some(PathBuf::from("/home/a/T.dotx"))); + assert_eq!(local_path("https://example.com/T.dotx", None), None); + assert_eq!(local_path("T.dotx", Some(Path::new("/docs/Letter.docx"))), Some(PathBuf::from("/docs/T.dotx"))); + assert_eq!(local_path("file://localhost/home/a/T.dotx", None), Some(PathBuf::from("/home/a/T.dotx"))); + // A hostile document must not send us to another machine (NTLM credential leak over SMB on Windows). + for remote in [ + "\\\\evil.example\\share\\T.dotx", + "//evil.example/share/T.dotx", + "\\\\?\\UNC\\evil.example\\share\\T.dotx", + "file://evil.example/share/T.dotx", + "file:////evil.example/share/T.dotx", + "FILE://Evil.Example/T.dotx", + ] { + assert_eq!(local_path(remote, Some(Path::new("/docs/Letter.docx"))), None, "{remote}"); + } + assert_eq!(percent_decode("100%"), "100%"); + assert_eq!(percent_decode("%zz%4"), "%zz%4"); + } +} diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index dc743d1..08d2d31 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -331,6 +331,14 @@ pub enum Dialog { NoteOptions { form: Box, }, + /// View › Templates › Templates and Add-ins (#377). + Templates { + form: Box, + }, + /// Templates and Add-ins › Organizer: copy, delete and rename styles (#377). + Organizer { + form: Box, + }, /// Line Numbers, Hyphenation, Language, Envelopes and Labels (#407). Layout(Box), } @@ -756,6 +764,8 @@ impl Dialog { Dialog::Control { .. } => "controlProperties", Dialog::Enclose { .. } => "enclose", Dialog::NoteOptions { .. } => "noteOptions", + Dialog::Templates { .. } => "templates", + Dialog::Organizer { .. } => "organizer", Dialog::Layout(d) => d.name(), } } @@ -987,6 +997,8 @@ impl Dialog { Dialog::CustomizeRibbon { form: Box::new(crate::dialogs_ribbon::RibbonForm::read(app, crate::dialogs_ribbon::Page::QuickAccess)) } } "noteOptions" => Dialog::NoteOptions { form: Box::new(crate::dialogs_notes::NoteForm::read(app)) }, + "templates" => Dialog::Templates { form: Box::new(crate::dialogs_templates::TemplatesForm::read(app)) }, + "organizer" => Dialog::Organizer { form: Box::new(crate::dialogs_templates::OrganizerForm::read(app, None)) }, "controlProperties" => match crate::dialogs_controls::ControlForm::read(app) { Some(form) => Dialog::Control { form: Box::new(form) }, None => { @@ -1162,6 +1174,8 @@ pub fn show(app: &mut WordApp, ctx: &egui::Context) { Dialog::Control { .. } => "Content Control Properties", Dialog::Enclose { .. } => "Enclose Characters", Dialog::NoteOptions { .. } => "Footnote and Endnote", + Dialog::Templates { .. } => "Templates and Add-ins", + Dialog::Organizer { .. } => "Organizer", Dialog::Layout(d) => d.title(), }; let id = egui::Id::new(("dialog", title)); @@ -2267,6 +2281,8 @@ fn body(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bool { cancel } Dialog::NoteOptions { form } => crate::dialogs_notes::note_options(app, ui, form), + Dialog::Templates { form } => crate::dialogs_templates::templates(app, ui, form), + Dialog::Organizer { form } => crate::dialogs_templates::organizer(app, ui, form), Dialog::Layout(d) => crate::dialogs_layout::body(app, ui, d), Dialog::FindRecipient { text, message } => { ui.horizontal(|ui| { diff --git a/crates/ui-egui/src/dialogs_templates.rs b/crates/ui-egui/src/dialogs_templates.rs new file mode 100644 index 0000000..474dc5a --- /dev/null +++ b/crates/ui-egui/src/dialogs_templates.rs @@ -0,0 +1,314 @@ +//! Templates and Add-ins (View › Templates) and the style Organizer (#377). Each button ends in +//! one command (`tools.attachTemplate`, `tools.detachTemplate`, `tools.linkStyles`, +//! `styles.copyFrom`, `styles.delete`, `styles.rename`), so agents get the same result without +//! the dialogs. + +use egui::{Ui, vec2}; +use serde::Serialize; +use serde_json::{Value, json}; + +use crate::WordApp; +use crate::dialogs::{Dialog, buttons}; +use crate::file_dialogs::{AfterPick, FileDialogRequest}; +use crate::theme::{Tokens, semibold}; + +/// The Templates and Add-ins dialog's fields. +#[derive(Clone, Debug, Serialize)] +pub struct TemplatesForm { + pub path: String, + /// "Automatically update document styles" (`w:linkStyles`). + pub link: bool, + was_path: String, + was_link: bool, + pub message: String, +} + +impl TemplatesForm { + pub fn read(app: &mut WordApp) -> TemplatesForm { + // A file picked for a dialog that has since closed doesn't belong to this one. + app.dialog_pick = None; + let s = &app.session.doc.settings; + let path = s.attached_template.clone().unwrap_or_default(); + TemplatesForm { was_path: path.clone(), path, link: s.link_styles, was_link: s.link_styles, message: String::new() } + } + + /// OK: attach (updating the styles now when they follow the template), detach, or just switch + /// the automatic update. + fn apply(&self, app: &mut WordApp) -> Result<(), String> { + let path = self.path.trim(); + if path.is_empty() { + if !self.was_path.trim().is_empty() { + app.run("tools.detachTemplate", json!({}))?; + } else if self.link != self.was_link { + app.run("tools.linkStyles", json!({"value": self.link}))?; + } + return Ok(()); + } + if path != self.was_path || self.link != self.was_link || self.link { + app.run("tools.attachTemplate", json!({"path": path, "linkStyles": self.link, "update": self.link}))?; + } + Ok(()) + } +} + +/// Whether this host shows a file picker whose answer comes back to the dialog (the web's picker +/// opens what it picks instead). +fn can_pick(app: &WordApp) -> bool { + app.services.open_async.is_none() && (app.services.file_dialog.is_some() || app.services.pick_open.is_some()) +} + +/// Ask for a template or document; the answer arrives in `app.dialog_pick`. +fn pick(app: &mut WordApp) { + let _ = app.ask_file(FileDialogRequest::Open { purpose: "template".into() }, AfterPick::ForDialog); +} + +/// Returns true to close. +pub fn templates(app: &mut WordApp, ui: &mut Ui, f: &mut TemplatesForm) -> bool { + if let Some(p) = app.dialog_pick.take() { + f.path = p; + } + ui.label(egui::RichText::new(tl!("Document template:")).font(semibold(12.5))); + ui.horizontal(|ui| { + ui.add(egui::TextEdit::singleline(&mut f.path).desired_width(300.0)); + if ui.add_enabled(can_pick(app), egui::Button::new(tl!("Attach…"))).clicked() { + pick(app); + } + }); + ui.checkbox(&mut f.link, tl!("Automatically update document styles")); + ui.add_space(6.0); + let organizer = ui.button(tl!("Organizer…")).clicked(); + if !f.message.is_empty() { + ui.add(egui::Label::new(egui::RichText::new(f.message.as_str()).small().color(ui.visuals().error_fg_color)).wrap()); + } + let (ok, cancel) = buttons(ui, tl!("OK")); + if organizer { + let back = TemplatesForm { message: String::new(), ..f.clone() }; + app.dialog = Some(Dialog::Organizer { form: Box::new(OrganizerForm::read(app, Some(back))) }); + return true; + } + if ok && let Err(e) = f.apply(app) { + f.message = e; + return false; + } + ok || cancel +} + +/// The Organizer's state: this document's styles on the left (read live), another document's or +/// template's on the right. +#[derive(Clone, Debug, Serialize)] +pub struct OrganizerForm { + /// The other file (empty: none open). + pub file: String, + /// Its styles: (name, type). + pub other: Vec<(String, String)>, + /// Selected style id in this document. + pub left: String, + /// Selected style name in the other file. + pub right: String, + /// The new name being typed (Rename…). + pub rename: Option, + pub message: String, + /// Templates and Add-ins, to go back to on Close. + back: Option>, +} + +impl OrganizerForm { + /// Opens with the attached template (or the template typed in Templates and Add-ins) on the right. + pub fn read(app: &mut WordApp, back: Option) -> OrganizerForm { + app.dialog_pick = None; + let file = + back.as_ref().map(|b| b.path.trim().to_string()).or_else(|| app.session.doc.settings.attached_template.clone()).unwrap_or_default(); + let mut f = OrganizerForm { + file: String::new(), + other: Vec::new(), + left: String::new(), + right: String::new(), + rename: None, + message: String::new(), + back: back.map(Box::new), + }; + if !file.is_empty() { + f.load(app, &file); + } + f + } + + fn load(&mut self, app: &mut WordApp, path: &str) { + match app.session.run("styles.organizer", &json!({"path": path})) { + Ok(v) => { + let list = v.get("file").and_then(|f| f.get("styles")).and_then(Value::as_array).cloned().unwrap_or_default(); + let s = |x: &Value, k: &str| x.get(k).and_then(Value::as_str).unwrap_or("").to_string(); + self.other = list.iter().map(|x| (s(x, "name"), s(x, "type"))).collect(); + self.file = path.to_string(); + self.right.clear(); + self.message.clear(); + } + Err(e) => self.message = e.to_string(), + } + } +} + +fn glyph(ty: &str) -> &'static str { + match ty { + "character" => "a", + "table" => "⊞", + "numbering" => "≡", + _ => "¶", + } +} + +/// Returns true to close. +pub fn organizer(app: &mut WordApp, ui: &mut Ui, f: &mut OrganizerForm) -> bool { + if let Some(p) = app.dialog_pick.take() { + f.load(app, &p); + } + let mine = wordcraft_engine::cmd::templates::style_list(&app.session.doc); + let mine = mine.as_array().cloned().unwrap_or_default(); + let s = |x: &Value, k: &str| x.get(k).and_then(Value::as_str).unwrap_or("").to_string(); + if !mine.iter().any(|x| s(x, "id") == f.left) { + f.left.clear(); + } + let t = Tokens::get(ui.ctx()); + let list_frame = |ui: &mut Ui, salt: &str, add: &mut dyn FnMut(&mut Ui)| { + egui::Frame::NONE.stroke(egui::Stroke::new(1.0, t.border)).inner_margin(4).show(ui, |ui| { + ui.set_width(220.0); + ui.set_height(240.0); + egui::ScrollArea::vertical().id_salt(salt).max_height(240.0).auto_shrink([false, false]).show(ui, |ui| add(ui)); + }); + }; + let mut copy = false; + let mut delete = false; + let mut rename = false; + let mut browse = false; + let left_builtin = mine.iter().find(|x| s(x, "id") == f.left).is_none_or(|x| x.get("builtIn").and_then(Value::as_bool).unwrap_or(true)); + ui.horizontal_top(|ui| { + ui.vertical(|ui| { + ui.label(egui::RichText::new(tl!("Document")).font(semibold(12.5))); + list_frame(ui, "organizer_mine", &mut |ui| { + for st in &mine { + let id = s(st, "id"); + if ui.selectable_label(f.left == id, format!("{} {}", glyph(&s(st, "type")), s(st, "name"))).clicked() { + f.left = id; + f.rename = None; + } + } + }); + }); + ui.vertical(|ui| { + ui.add_space(60.0); + let w = vec2(96.0, 0.0); + copy = ui.add_enabled(!f.right.is_empty(), egui::Button::new(format!("◀ {}", tl!("Copy"))).min_size(w)).clicked(); + delete = ui.add_enabled(!left_builtin, egui::Button::new(tl!("Delete")).min_size(w)).clicked(); + rename = ui.add_enabled(!left_builtin, egui::Button::new(tl!("Rename…")).min_size(w)).clicked(); + }); + ui.vertical(|ui| { + let name = std::path::Path::new(&f.file).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "—".into()); + ui.label(egui::RichText::new(name).font(semibold(12.5))).on_hover_text(f.file.as_str()); + list_frame(ui, "organizer_other", &mut |ui| { + for (n, ty) in &f.other { + if ui.selectable_label(f.right == *n, format!("{} {n}", glyph(ty))).clicked() { + f.right = n.clone(); + } + } + }); + browse = ui.add_enabled(can_pick(app), egui::Button::new(tl!("Browse…"))).clicked(); + }); + }); + if copy { + let r = app.run("styles.copyFrom", json!({"path": f.file, "styles": [f.right]})); + f.message = r.err().unwrap_or_default(); + } + if delete { + let r = app.run("styles.delete", json!({"style": f.left})); + f.message = r.err().unwrap_or_default(); + f.left.clear(); + } + if rename { + f.rename = mine.iter().find(|x| s(x, "id") == f.left).map(|x| s(x, "name")); + } + if browse { + pick(app); + } + let mut renamed = false; + if let Some(new) = f.rename.as_mut() { + ui.add_space(6.0); + ui.horizontal(|ui| { + ui.label(tl!("Name:")); + let r = ui.add(egui::TextEdit::singleline(new).desired_width(220.0)); + renamed = ui.button(tl!("OK")).clicked() || (r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter))); + }); + } + if renamed && let Some(new) = f.rename.take() { + let r = app.run("styles.rename", json!({"style": f.left, "name": new})); + f.message = r.err().unwrap_or_default(); + } + if !f.message.is_empty() { + ui.add(egui::Label::new(egui::RichText::new(f.message.as_str()).small().color(ui.visuals().error_fg_color)).wrap()); + } + // Escape while renaming only stops renaming. + if f.rename.is_some() && ui.input(|i| i.key_pressed(egui::Key::Escape)) { + f.rename = None; + return false; + } + // Close (Escape too), in a row of its own so the dialog stays as tall as its lists. + ui.add_space(8.0); + let mut close = ui.input(|i| i.key_pressed(egui::Key::Escape)); + ui.horizontal(|ui| { + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + close |= ui.add(egui::Button::new(egui::RichText::new(tl!("Close")).color(egui::Color32::WHITE)).fill(crate::theme::APP_COLOR)).clicked(); + }); + }); + if close { + if let Some(back) = f.back.take() { + app.dialog = Some(Dialog::Templates { form: back }); + } + return true; + } + false +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::Services; + use wordcraft_engine::Session; + + /// The ribbon button opens Templates and Add-ins; OK attaches the typed template; the + /// Organizer copies a style from it into the document and Close goes back. + #[test] + fn templates_dialog_attaches_and_organizer_copies() { + let dir = std::env::temp_dir().join(format!("wordcraft-ui-templates-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("House.dotx"); + let mut t = wordcraft_doc::Document::new(); + t.styles.upsert(wordcraft_doc::styles::Style { + id: "Memo".into(), + name: "Memo".into(), + kind: wordcraft_doc::styles::StyleKind::Paragraph, + ..Default::default() + }); + wordcraft_engine::io::save_path(&path, &t).unwrap(); + let p = path.to_string_lossy().to_string(); + + let mut a = WordApp::new(Session::new(wordcraft_doc::Document::new()), Services::default()); + assert_eq!(a.run("tools.templates", json!({})).unwrap(), json!({"pending": "templates"})); + let Some(Dialog::Templates { form }) = &mut a.dialog else { panic!("dialog") }; + form.path = p.clone(); + let form = form.clone(); + form.apply(&mut a).unwrap(); + assert_eq!(a.session.doc.settings.attached_template.as_deref(), Some(p.as_str())); + + let mut org = OrganizerForm::read(&mut a, None); + assert!(org.other.iter().any(|(n, _)| n == "Memo"), "{:?}", org.other); + org.right = "Memo".into(); + let r = a.run("styles.copyFrom", json!({"path": org.file, "styles": [org.right]})); + assert!(r.is_ok(), "{r:?}"); + assert!(a.session.doc.styles.get("Memo").is_some()); + // Agents and scripts get the state, never the dialog. + a.dialog = None; + assert_eq!(a.execute("tools.templates", json!({})).unwrap()["template"], json!(p)); + assert!(a.dialog.is_none()); + let _ = std::fs::remove_dir_all(&dir); + } +} diff --git a/crates/ui-egui/src/file_dialogs.rs b/crates/ui-egui/src/file_dialogs.rs index da4d53a..dcbdac6 100644 --- a/crates/ui-egui/src/file_dialogs.rs +++ b/crates/ui-egui/src/file_dialogs.rs @@ -35,6 +35,9 @@ pub(crate) enum AfterPick { Export { ext: String }, /// Load the picked CSV/TSV/text file or spreadsheet as the mail-merge recipients (#240, #334). Recipients, + /// Hand the picked path to the open dialog (`WordApp::dialog_pick`): Templates and Add-ins, + /// Organizer (#377). + ForDialog, /// Put the picked document into the Compare dialog (original or revised, #412). ComparePath { revised: bool }, } @@ -125,6 +128,12 @@ impl WordApp { }; self.insert_or_change_picture(json!({"path": path})) } + AfterPick::ForDialog => { + let picked = picked.filter(|p| !p.is_empty()); + let r = json!({"picked": picked}); + self.dialog_pick = picked; + Ok(r) + } AfterPick::Recipients => { let Some(path) = picked else { return Ok(json!({"cancelled": true})) }; self.load_recipients(json!({"path": path})) diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 948f229..1742ae3 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -1615,6 +1615,19 @@ Enlarge symbol Agrandar símbolo Enclosure Contorno Circle Círculo + Templates Plantillas + Templates and Add-ins Plantillas y complementos + Attach Template Adjuntar plantilla + Detach Template Quitar plantilla + Automatically Update Document Styles Actualizar automáticamente los estilos del documento + Update Styles from Template Actualizar estilos desde la plantilla + Organizer Organizador + Copy Styles Copiar estilos + Rename Style Cambiar nombre del estilo + Document template: Plantilla del documento: + Attach… Adjuntar… + Automatically update document styles Actualizar automáticamente los estilos del documento + Organizer… Organizador… Change Chart Type Cambiar tipo de gráfico Chart Design Diseño de gráfico Type Tipo diff --git a/crates/ui-egui/src/i18n/et.tsv b/crates/ui-egui/src/i18n/et.tsv index 0c47dec..5fbf242 100644 --- a/crates/ui-egui/src/i18n/et.tsv +++ b/crates/ui-egui/src/i18n/et.tsv @@ -1553,6 +1553,19 @@ Right (vertical) Paremal (vertikaalne) Ruby text: Ruby-tekst: Size (pt): Suurus (pt): + Templates Mallid + Templates and Add-ins Mallid ja lisandmoodulid + Attach Template Manusta mall + Detach Template Eemalda mall + Automatically Update Document Styles Uuenda dokumendi stiile automaatselt + Update Styles from Template Uuenda stiile mallist + Organizer Korraldaja + Copy Styles Kopeeri stiilid + Rename Style Nimeta stiil ümber + Document template: Dokumendi mall: + Attach… Manusta… + Automatically update document styles Uuenda dokumendi stiile automaatselt + Organizer… Korraldaja… Moved from Teisaldatud kohast Moved to Teisaldatud kohta Change Chart Type Muuda diagrammi tüüpi diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index b1309e6..5174f05 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -1599,6 +1599,19 @@ Enlarge symbol 記号を拡大 Enclosure 囲い Circle 円 + Templates テンプレート + Templates and Add-ins テンプレートとアドイン + Attach Template テンプレートの添付 + Detach Template テンプレートの解除 + Automatically Update Document Styles 文書のスタイルを自動的に更新 + Update Styles from Template テンプレートからスタイルを更新 + Organizer オーガナイザー + Copy Styles スタイルのコピー + Rename Style スタイル名の変更 + Document template: 文書テンプレート: + Attach… 添付… + Automatically update document styles 文書のスタイルを自動的に更新する + Organizer… オーガナイザー… Change Chart Type グラフの種類の変更 Chart Design グラフのデザイン Type 種類 diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index f39011e..acb4d66 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -1611,6 +1611,19 @@ Enlarge symbol Ampliar símbolo Enclosure Contorno Circle Círculo + Templates Modelos + Templates and Add-ins Modelos e suplementos + Attach Template Anexar modelo + Detach Template Desanexar modelo + Automatically Update Document Styles Atualizar automaticamente os estilos do documento + Update Styles from Template Atualizar estilos a partir do modelo + Organizer Organizador + Copy Styles Copiar estilos + Rename Style Renomear estilo + Document template: Modelo do documento: + Attach… Anexar… + Automatically update document styles Atualizar automaticamente os estilos do documento + Organizer… Organizador… Change Chart Type Alterar tipo de gráfico Chart Design Design do gráfico Type Tipo diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index b9d841c..a3af8de 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -1975,6 +1975,19 @@ preview Title Пример Search icons Поиск значков No icons match your search. Нет значков, соответствующих поиску. Recolor a one-color picture, such as an icon Перекрасить одноцветный рисунок, например значок + Templates Шаблоны + Templates and Add-ins Шаблоны и надстройки + Attach Template Присоединить шаблон + Detach Template Отсоединить шаблон + Automatically Update Document Styles Автоматически обновлять стили документа + Update Styles from Template Обновить стили из шаблона + Organizer Организатор + Copy Styles Копировать стили + Rename Style Переименовать стиль + Document template: Шаблон документа: + Attach… Присоединить… + Automatically update document styles Автоматически обновлять стили документа + Organizer… Организатор… 1. Formatting restrictions 1. Ограничения форматирования 2. Editing restrictions 2. Ограничения редактирования 3. Start enforcement 3. Включение защиты diff --git a/crates/ui-egui/src/i18n/sr-latn.tsv b/crates/ui-egui/src/i18n/sr-latn.tsv index ff7b312..882e21c 100644 --- a/crates/ui-egui/src/i18n/sr-latn.tsv +++ b/crates/ui-egui/src/i18n/sr-latn.tsv @@ -1609,6 +1609,19 @@ Enlarge symbol Uvećaj simbol Enclosure Okvir Circle Krug + Templates Šabloni + Templates and Add-ins Šabloni i dodaci + Attach Template Priloži šablon + Detach Template Ukloni šablon + Automatically Update Document Styles Automatski ažuriraj stilove dokumenta + Update Styles from Template Ažuriraj stilove iz šablona + Organizer Organizator + Copy Styles Kopiraj stilove + Rename Style Preimenuj stil + Document template: Šablon dokumenta: + Attach… Priloži… + Automatically update document styles Automatski ažuriraj stilove dokumenta + Organizer… Organizator… Change Chart Type Promeni tip grafikona Chart Design Dizajn grafikona Type Tip diff --git a/crates/ui-egui/src/i18n/sr.tsv b/crates/ui-egui/src/i18n/sr.tsv index d38c19d..c10cd21 100644 --- a/crates/ui-egui/src/i18n/sr.tsv +++ b/crates/ui-egui/src/i18n/sr.tsv @@ -1613,6 +1613,19 @@ Enlarge symbol Увећај симбол Enclosure Оквир Circle Круг + Templates Шаблони + Templates and Add-ins Шаблони и додаци + Attach Template Приложи шаблон + Detach Template Уклони шаблон + Automatically Update Document Styles Аутоматски ажурирај стилове документа + Update Styles from Template Ажурирај стилове из шаблона + Organizer Организатор + Copy Styles Копирај стилове + Rename Style Преименуј стил + Document template: Шаблон документа: + Attach… Приложи… + Automatically update document styles Аутоматски ажурирај стилове документа + Organizer… Организатор… Change Chart Type Промени тип графикона Chart Design Дизајн графикона Type Тип diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index f307493..b1f0e6d 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -1597,6 +1597,19 @@ Search icons Simgelerde ara No icons match your search. Aramanızla eşleşen simge yok. Recolor a one-color picture, such as an icon Simge gibi tek renkli bir resmi yeniden renklendir + Templates Şablonlar + Templates and Add-ins Şablonlar ve Eklentiler + Attach Template Şablonu Ekle + Detach Template Şablonu Ayır + Automatically Update Document Styles Belge Stillerini Otomatik Güncelle + Update Styles from Template Stilleri Şablondan Güncelle + Organizer Düzenleyici + Copy Styles Stilleri Kopyala + Rename Style Stili Yeniden Adlandır + Document template: Belge şablonu: + Attach… Ekle… + Automatically update document styles Belge stillerini otomatik güncelle + Organizer… Düzenleyici… 1. Formatting restrictions 1. Biçimlendirme kısıtlamaları 2. Editing restrictions 2. Düzenleme kısıtlamaları 3. Start enforcement 3. Korumayı başlat diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index fe9dd70..9d1b1ef 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -1611,6 +1611,19 @@ Enlarge symbol Збільшити символ Enclosure Обведення Circle Коло + Templates Шаблони + Templates and Add-ins Шаблони й надбудови + Attach Template Приєднати шаблон + Detach Template Від'єднати шаблон + Automatically Update Document Styles Автоматично оновлювати стилі документа + Update Styles from Template Оновити стилі із шаблону + Organizer Організатор + Copy Styles Копіювати стилі + Rename Style Перейменувати стиль + Document template: Шаблон документа: + Attach… Приєднати… + Automatically update document styles Автоматично оновлювати стилі документа + Organizer… Організатор… Change Chart Type Змінити тип діаграми Chart Design Конструктор діаграми Type Тип diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index e92cfae..353dd40 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -1611,6 +1611,19 @@ Enlarge symbol 增大圈号 Enclosure 圈号 Circle 圆形 + Templates 模板 + Templates and Add-ins 模板和加载项 + Attach Template 附加模板 + Detach Template 分离模板 + Automatically Update Document Styles 自动更新文档样式 + Update Styles from Template 从模板更新样式 + Organizer 管理器 + Copy Styles 复制样式 + Rename Style 重命名样式 + Document template: 文档模板: + Attach… 附加… + Automatically update document styles 自动更新文档样式 + Organizer… 管理器… Change Chart Type 更改图表类型 Chart Design 图表设计 Type 类型 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 4a26155..7e5d2a7 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -1600,6 +1600,19 @@ Enlarge symbol 放大符號 Enclosure 圍繞符號 Circle 圓形 + Templates 範本 + Templates and Add-ins 範本與增益集 + Attach Template 附加範本 + Detach Template 中斷範本連結 + Automatically Update Document Styles 自動更新文件樣式 + Update Styles from Template 從範本更新樣式 + Organizer 管理器 + Copy Styles 複製樣式 + Rename Style 重新命名樣式 + Document template: 文件範本: + Attach… 附加… + Automatically update document styles 自動更新文件樣式 + Organizer… 管理器… Change Chart Type 變更圖表類型 Chart Design 圖表設計 Type 類型 diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs index 2534957..3679b54 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -1024,6 +1024,12 @@ pub fn paint(p: &Painter, r: Rect, name: &str, c: Color32, accent: Color32) { pen.rect(3.0, 3.0, 17.0, 17.0, c); pen.fill(&[(8.0, 6.5), (8.0, 13.5), (14.0, 10.0)], a); } + // A page with a filled band at the top and a style mark: the document's template. + "documentTemplate" => { + pen.page(4.0, 2.0, 16.0, 18.0); + pen.frect(6.0, 5.0, 11.5, 7.0, a); + pen.text(10.0, 13.0, 8.0, "A", a, false); + } "properties" | "info" => { pen.circle(10.0, 10.0, 7.0, c); pen.line_c(&[(10.0, 9.0), (10.0, 14.5)], a); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 453f111..818579e 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -33,6 +33,7 @@ pub mod dialogs_para; pub mod dialogs_refs; pub mod dialogs_review; pub mod dialogs_ribbon; +pub mod dialogs_templates; pub mod equation_tab; pub mod file_dialogs; pub mod frame; @@ -245,6 +246,9 @@ pub struct WordApp { /// Web: a recipient list was asked for (Select Recipients › Use an Existing List…), so the /// next text file from the picker loads as recipients instead of opening (#240). pub recipient_list_pending: bool, + /// A file picked for the open dialog (Templates and Add-ins › Attach…, Organizer › + /// Browse…); the dialog takes it on its next frame. + pub(crate) dialog_pick: Option, /// macOS: the window has no title bar; leave room for the traffic lights. pub integrated_titlebar: bool, control_rx: Option>, @@ -351,6 +355,7 @@ impl WordApp { autosave_path: None, change_picture_target: None, recipient_list_pending: false, + dialog_pick: None, file_dialog: None, info_editing: None, } @@ -1315,6 +1320,9 @@ fn input_dialog(id: &str, params: &Value) -> Option<&'static str> { "references.markEntry" if !has("entry") => Some("markEntry"), "references.sources" if params.as_object().is_none_or(|m| m.is_empty()) => Some("sourceManager"), "references.citation" if !has("tag") && !has("source") => Some("createSource"), + // Templates and Add-ins, and the Organizer without a file to list (#377). + "tools.templates" => Some("templates"), + "styles.organizer" if !has("path") => Some("organizer"), // Language, Envelopes and Labels without settings show their dialogs (#407). "review.language" if params.as_object().is_none_or(|m| m.is_empty()) => Some("language"), "mailings.envelopes" if params.as_object().is_none_or(|m| m.is_empty()) => Some("envelopes"), diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index 895c57a..7f7ca50 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -1499,6 +1499,9 @@ fn view(app: &mut WordApp, ui: &mut Ui) { group(ui, "Macros", None, app, |ui, app| { big(ui, app, "macros", "Macros", "tools.macros", json!({}), false); }); + group(ui, "Templates", None, app, |ui, app| { + big(ui, app, "documentTemplate", "Templates", "tools.templates", json!({}), false); + }); } fn help(app: &mut WordApp, ui: &mut Ui) { diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 2279dcb..04cf7b3 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -69,7 +69,7 @@ Measured by grepping `crates/docx/src/read` and `src/write` for the OOXML elemen | OLE objects (`w:object`: embedded or linked files, ActiveX) | 🟡 | ✅ | Shown as their picture (VML or DrawingML); saved back whole with the embedding and picture parts, at the size, rotation and flips set in WordCraft (#319, #332). Can't be opened or edited | | Word ink (`w14:contentPart`, InkML), 3D models | ❌ | ❌ | Dropped on read, not preserved on save | | Equations (OMML `m:oMath`) | ✅ | ✅ | #191; manual breaks (`m:brk`) and the settings' `m:mathPr` wrapping options (#326) | -| Themes, font table, settings, compatibility mode | ✅ | ✅ | Embedded fonts (`w:embedRegular`) not read | +| Themes, font table, settings, compatibility mode | ✅ | ✅ | Embedded fonts (`w:embedRegular`) not read; attached template (`w:attachedTemplate` and its external relationship) and `w:linkStyles` round-trip (#377). The template path comes from the file, so opening never reads it: styles update from it only on an explicit Update Styles | | Custom XML parts, document properties, bibliography sources | 🟡 | 🟡 | Custom properties round-trip; bibliography sources pending (#169) | | Ruby (`w:ruby`: `w:rubyPr`, `w:rt`, `w:rubyBase`) | ✅ | ✅ | #288; base and ruby text with their run formatting, alignment, sizes, raise, language | | Document grid (`w:docGrid`: type, line pitch, `w:charSpace`), paragraph `w:snapToGrid` | ✅ | ✅ | #391; run-level `w:snapToGrid` not read | @@ -90,6 +90,7 @@ Measured by grepping `crates/docx/src/read` and `src/write` for the OOXML elemen | Date | Change | Summary | |---|---|---| +| 2026-10-11 | trivial | Attached template (`w:attachedTemplate`, external relationship of the settings part) and `w:linkStyles` read and written in schema order; opening never reads the template (#377) | | 2026-10-11 | trivial | A bookmark (or comment range) ending after the document's last table keeps its end, in the table's last cell (#464); a field whose cached result is a picture (`INCLUDEPICTURE`) is kept as a range field around the picture, so the picture survives save; the linked file is never read (#465) | | 2026-10-11 | trivial | Restrict Editing: Word's legacy password hash (`w:cryptAlgorithmSid`, `w:hash`, `w:salt`, `w:cryptSpinCount`) verified per ECMA-376 Part 4 §14.8.1; unsupported hash forms refused (#412) | | 2026-10-11 | trivial | Restrict Editing: `w:documentProtection` (edit, formatting, password hash), `w:locked` styles and `w:permStart`/`w:permEnd` exceptions read and written (#412) | diff --git a/docs/gaps.md b/docs/gaps.md index 267f968..c65c8fa 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -17,7 +17,7 @@ first (`gh pr list`), and update this file and the parity doc when a gap closes. | 4 | **Pagination differs from Word**: Aptos has no metric-matched substitute; legacy compatibility options ignored; no page-by-page comparison harness | B | [layout-parity.md](layout-parity.md); [typing-parity.md](typing-parity.md) Known gaps | Page counts and line breaks differ, which matters for forms, legal and academic work | 30–50 + owner (font) | [layout-parity.md](layout-parity.md) | | 5 | **Content controls** landed (#381): `w:sdt` kept, shown and edited (Developer tab, Design Mode, Properties). Move tracking landed (#400): `w:moveFrom`/`w:moveTo` with their named ranges round-trip, a tracked cut and paste of a paragraph or sentence records a move, either end accepts/rejects both, green double strike/underline markup and "Moved from/to" balloons; still missing: drag-and-drop moves (no text drag yet) and move options in Track Changes Options. Formatting revisions landed (#41): `w:rPrChange`/`w:pPrChange`/`w:tblPrChange`/`w:trPrChange`/`w:tcPrChange`/`w:sectPrChange`/`w:numberingChange` round-trip, are recorded while tracking, accepted/rejected and shown in balloons; still missing: tracked style-definition changes and table/section changes made in WordCraft | B | Issues #41, #400; source grep | Legal and editorial review workflows; templates with form controls | 5–8 | [file-format-parity.md](file-format-parity.md) | | 6 | **No native printing** on desktop: File › Print opens the PDF in the system viewer, which prints (#286); no print dialog of our own (printer, copies, ranges); web prints through the browser (#209) | B | `ui.print` → `apps/wordcraft/src/print.rs`; issue #15 | Everyone who prints | 5–8 | [hardware-parity.md](hardware-parity.md) | -| 7 | **Dialog depth**: 52 modal dialogs vs Word's ~100 | B | `Dialog` enum in `crates/ui-egui/src/dialogs.rs` | Power users reach for dialogs the ribbon alone doesn't cover | 11–19 | [ui-parity.md](ui-parity.md) | +| 7 | **Dialog depth**: 54 modal dialogs vs Word's ~100 | B | `Dialog` enum in `crates/ui-egui/src/dialogs.rs` | Power users reach for dialogs the ribbon alone doesn't cover | 11–19 | [ui-parity.md](ui-parity.md) | | 8 | **Proofing is English only**; no language per run, no dictionaries for other languages | | `crates/proof` has one dictionary; Word ships 120 proofing tools; issues #25, #40, #100 | Every non-English writer | 25–45 | [localization-parity.md](localization-parity.md) | | 9 | **Password-protected files** can't be opened or saved | | #55; `docbin` detects and refuses RC4/XOR | Corporate and legal users | 6–10 | [file-format-parity.md](file-format-parity.md) | | 10 | **Objects**: no group (#267 open), text in a freely rotated text box (free rotation and flips landed, #332; text direction, alignment and linked text boxes landed, #369; contour wrap and wrap side landed, #354), shape effects, WordArt 3-D effects and connector routing around shapes (WordArt effects and warps and glued connectors landed, #416), dragging members inside a Drawing Canvas (the canvas landed, #344) | | [parity-checklist.md](parity-checklist.md) (Shape Format 6/6, Layout › Group) | Newsletters, flyers, reports with diagrams | 20–30 | [target-app-parity.md](target-app-parity.md) | diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index ceb446b..0c29940 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -2,7 +2,7 @@ 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. -**458 of 475 catalog features have commands (96.4%).** +**459 of 475 catalog features have commands (96.6%).** | Tab | Live | Total | |---|---|---| @@ -26,7 +26,7 @@ Generated by `cargo xtask parity` (`wordcraft-cli parity --markdown`): the word- | Chart Design | 5 | 5 | | Header & Footer | 5 | 5 | | Editing | 38 | 38 | -| Tools | 5 | 6 | +| Tools | 6 | 6 | ## Missing @@ -64,7 +64,3 @@ Generated by `cargo xtask parity` (`wordcraft-cli parity --markdown`): the word- - Tools › Ink Equation (`insert.inkEquation`) -### Tools - -- Templates › Templates and Add-ins (`tools.templates`) - diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 78ba0a3..1d95896 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -13,7 +13,7 @@ The generated ribbon checklist is [`parity-checklist.md`](parity-checklist.md). | Number | Value | Kind | |---|---|---| -| Ribbon/menu catalog coverage | **458 / 475 (96.4%)** | **measured**: `cargo xtask parity` (re-derived this pass from `crates/engine/src/catalog.rs` against every `CommandSpec::new` id in the source; identical to the checked-in `parity-checklist.md`) | +| Ribbon/menu catalog coverage | **459 / 475 (96.6%)** | **measured**: `cargo xtask parity` (re-derived this pass from `crates/engine/src/catalog.rs` against every `CommandSpec::new` id in the source; identical to the checked-in `parity-checklist.md`) | | Feature breadth (weighted, beyond the ribbon: dialog options, citation styles, proofing languages, chart types, UI languages) | **~80%** | estimated | | Feature depth (weighted by use, table below) | **~68%** | estimated | | **Ready for real work** (full target) | **~60%** (range 55–63%) | estimated: weighted sum over the dimension table ([By dimension](#by-dimension)) | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 8280181..62d71e2 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -16,7 +16,7 @@ own (`AGENTS.md`: no Microsoft iconography, ever). | Quick Access Toolbar | AutoSave, then the user's commands (Save, Undo, Redo by default); add, remove and reorder from its … menu (common commands, More Commands…), right-click on any ribbon button, or Customize Ribbon; saved per user (#379, #376). Not shown below the ribbon | customizable | 90% | 0.5 | | Customize Ribbon / Customize Keyboard | Customize Keyboard (#368): categories, a command's keys, press a combination to see which command uses it, Assign / Remove / Reset All; custom keys override built-ins, removed built-ins do nothing, saved per user; `tools.customizeKeyboard` for agents. Customize Ribbon (#379): show, hide and reorder tabs, custom tabs and custom groups (on any tab) holding any commands, rename, remove, Reset; right-click on the ribbon; saved per user; `tools.customizeRibbon` for agents. Built-in tabs can't be renamed; no import/export file | ✅ | 85% | 1–2 | | Keyboard shortcuts | **125** commands bound in `CommandSpec::key`, 144 key combinations (measured on Linux/Windows); styles, spacing, symbols, fields, views, mail merge (#330); field keys Alt+F9, Shift+F9, Ctrl+Shift+F9, Ctrl+F11, Ctrl+Shift+F11, F11, Shift+F11 (#339); Alt+Shift+arrows (move paragraphs, promote/demote), Shift+F5 Go Back and Alt+X (#343). Missing: en dash on the numeric keypad | ~250 default shortcuts | ~70% | 2–4 | -| Modal dialogs | **52** (`Dialog` enum: Font, Enclose Characters, Paragraph, Tabs, Borders and Shading, Find, Go To, Insert Table, Table Properties, Page Setup, Link, Bookmark, Word Count, Zoom, Watermark, New Style, Modify Style, Manage Styles, Table Style, Commands, Icons, Compare and Combine, AutoCorrect Options, Paste Special, About, Save Changes, Password, Encrypt with Password, Define New Multilevel List, Track Changes Options; Columns, Symbol with Special Characters, Field (#321); Field code (#339); mail merge Recipients, Insert Merge Field, Find Recipient, Rules, Match Fields, Check for Errors; Caption with New Label and Numbering, Index, Mark Index Entry, Custom Table of Contents with Options and TOC styles, Source Manager, Create/Edit Source (#402); Line Numbers, Hyphenation with Manual Hyphenation, Language, Envelopes, Labels (#407)) plus panes and menus for the rest | ~100 (Options panes…) | ~45% | 11–19 | +| Modal dialogs | **54** (`Dialog` enum: Font, Enclose Characters, Paragraph, Tabs, Borders and Shading, Find, Go To, Insert Table, Table Properties, Page Setup, Link, Bookmark, Word Count, Zoom, Watermark, New Style, Modify Style, Manage Styles, Table Style, Commands, Icons, Templates and Add-ins, Organizer, Compare and Combine, AutoCorrect Options, Paste Special, About, Save Changes, Password, Encrypt with Password, Define New Multilevel List, Track Changes Options; Columns, Symbol with Special Characters, Field (#321); Field code (#339); mail merge Recipients, Insert Merge Field, Find Recipient, Rules, Match Fields, Check for Errors; Caption with New Label and Numbering, Index, Mark Index Entry, Custom Table of Contents with Options and TOC styles, Source Manager, Create/Edit Source (#402); Line Numbers, Hyphenation with Manual Hyphenation, Language, Envelopes, Labels (#407)) plus panes and menus for the rest | ~100 (Options panes…) | ~45% | 11–19 | | Task panes (Navigation, Styles, Comments, Reviewing, Format Picture/Shape, Thesaurus, Accessibility, Clipboard, Selection) | Navigation, Styles, Comments, 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); right-click selects a picture, shape or text box and offers Wrap Text (styles, Move with Text / Fix Position on Page, #513); tables partial | rich per context | 60% | 3–5 | | 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) | | 68% | 4–6 | @@ -52,6 +52,7 @@ own (`AGENTS.md`: no Microsoft iconography, ever). | 2026-10-11 | trivial | Dialogs keep their top edge in place when switching tabs (Paragraph, Font, Borders and Shading…): only the bottom edge moves (#507) | | 2026-10-11 | trivial | Interface size setting in File › Options › General (80–200 %), kept between runs and applied on top of the system's display scale; `ui.interfaceSize` for agents (#475) | | 2026-10-11 | trivial | Font box (ribbon and mini toolbar): typing lists the matching fonts (prefix first, then any part of the name); Up/Down, Enter or a click picks one, Escape cancels (#359) | +| 2026-10-11 | trivial | Templates and Add-ins (template path, Attach…, automatically update document styles) and Organizer (copy styles from another file, delete, rename) dialogs (#377); styles update from the template only when the user asks (OK with the box ticked, or Update Styles), never on open; the three Modal dialogs rows merged into one | | 2026-10-11 | trivial | Field codes (#339): View › Field Codes (Alt+F9), Toggle Field Codes (Shift+F9), a field-code popup (double-click, right-click › Edit Field…; Ctrl+F9 opens the Field dialog), unlink/lock/unlock and next/previous field keys; field items in the context menu | | 2026-10-10 | trivial | Tabs dialog (Paragraph › Tabs…, double-click a ruler tab) and Borders and Shading with Borders, Page Border and Shading tabs (#320) | | 2026-10-10 | trivial | Columns (presets, unequal widths, line between, apply to), Symbol (font coverage grid by Unicode subset, recently used, character code, Special Characters) and Field (categories, options, field codes) dialogs (#321) |