Merge pull request #3285 from matstrange/fix/protection-file-commands

Restrict Editing: the file commands, the user name and the post-check
This commit is contained in:
skylar taylor-barrick
2026-10-22 21:19:40 -06:00
committed by GitHub
9 changed files with 420 additions and 21 deletions
+6
View File
@@ -158,6 +158,9 @@ 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-22 | Restrict Editing: the CLI's `--author` uses the same protection check as `file.setAuthor`, and File › Options disables the User name field while the document is protected. |
| 2026-10-20 | Restrict Editing, three more gaps closed: the protection of a protected document only changes through the explicit stop (`review.restrict {"stop": true}`; the "none" mode is the shorthand and doesn't stop it), the session author used for new tracked changes and comments can't change through `file.setAuthor` while the document is protected (the CLI's `--author` uses the same protection check), and the post-check compares the document's settings, metadata, custom properties, list definitions, bibliography sources and the comments' resolved flags as well as its text and formatting |
| 2026-10-20 | Restrict Editing holds for the file commands too: the privacy options, the properties and the inspector's removals are refused on a protected document, and restoring a stored version keeps the protection and its password instead of the snapshot's stale ones |
| 2026-10-19 | Insert Table of Figures opens its dialog (#3013): pick the caption label (Figure, Table, Equation or your own), with or without label and number, page numbers and leader, so a document can have a list of figures and a list of tables. The list is a `TOC \c`/`\a` field (ECMA-376 §17.16.5.68); a Word table of figures with its switches in any order updates too, and Update Table keeps each list's label and leader. |
| 2026-10-19 | Right-clicking text or pressing Shift+F10 offers the existing two-row formatting mini toolbar above the context-menu commands. Formatting keeps the selection and popup; menu commands, Escape and outside clicks dismiss it. This is the mini-toolbar portion of #2959; paste choices are in PR #2599. |
| 2026-10-20 | The move and resize handles of a table show while the caret is in it, not only under the pointer, as in Word (#2428) |
@@ -572,6 +575,9 @@ Footer page-table height and body wrapping are corrected. Other pagination gaps
| Date | Change | Summary |
|---|---|---|
| 2026-10-22 | minor | The CLI author flag and the Options user name field cannot change the session name after protection starts (#3279 follow-up). |
| 2026-10-20 | minor | Restrict Editing, three more gaps closed: the protection of a protected document only changes through the explicit stop, the session author can't change through `file.setAuthor` under protection, and the post-check compares the settings and the metadata as well as the text and the formatting |
| 2026-10-20 | minor | Restrict Editing: a file command that changes the open document is checked like any other — the privacy options, the properties and the inspector's removals are refused on a protected document, and restoring a stored version no longer lifts the protection or drops its password |
| 2026-10-22 | trivial | Progress log: a click in the footer while editing the header moves there |
| 2026-10-18 | trivial | Explicit New/Open is not interrupted by startup recovery discovery; older copies stay available and Recover follows the interface theme. |
| 2026-10-17 | trivial | Progress log: Home › Underline drop-down (#2738) |
+27
View File
@@ -180,6 +180,8 @@ fn listed_text(doc: &wordcraft_doc::Document) -> Result<String, String> {
}
/// `--author NAME`: the author recorded on tracked changes and comments (`file.setAuthor`).
/// The command also checks whether the open document is protected: this name determines which
/// named editing exceptions the session can use, so the flag must not bypass that check.
fn set_author(s: &mut Session, name: &str) -> Result<(), String> {
s.run("file.setAuthor", &json!({"name": name})).map(|_| ()).map_err(|e| format!("--author: {e}"))
}
@@ -805,6 +807,31 @@ mod tests {
assert!(run(&args(&["run", "--author", "Claude", "--cmd", "review.trackChanges={\"value\":true}"])).is_ok());
// The app's user name is the person's own; a bridge flag must not rewrite it.
assert!(run(&args(&["mcp", "--connect", "127.0.0.1:9", "--author", "Claude"])).is_err_and(|e| e.to_string().contains("--author")));
// The flag goes through the same protection check as the command.
let mut s = Session::new(wordcraft_doc::Document::new());
s.run("review.restrict", &json!({"mode": "readOnly"})).unwrap();
let before = s.author.clone();
assert!(set_author(&mut s, "Claude").is_err());
assert_eq!(s.author, before);
assert!(s.run("file.setAuthor", &json!({"name": "Claude"})).is_err());
}
#[test]
fn author_flag_cannot_claim_a_named_exception_after_protection() {
use wordcraft_doc::{CharProps, InlineObject, StoryRef};
let mut s = Session::new(wordcraft_doc::Document::from_text("Named exception."));
let end = s.doc.end_of(StoryRef::Body);
s.doc.insert_object(&end, InlineObject::PermEnd { id: 1 }, &CharProps::default()).unwrap();
let start = s.doc.start_of(StoryRef::Body);
s.doc.insert_object(&start, InlineObject::PermStart { id: 1, group: String::new(), editor: "Alice".into() }, &CharProps::default()).unwrap();
assert_eq!(s.doc.perm_ranges().len(), 1);
s.run("review.restrict", &json!({"mode": "readOnly"})).unwrap();
s.run("select.text", &json!({"text": "Named"})).unwrap();
assert!(s.run("text.insert", &json!({"text": "Changed"})).is_err());
assert!(set_author(&mut s, "Alice").is_err(), "--author changed the identity used for a protected exception");
assert!(s.run("text.insert", &json!({"text": "Changed"})).is_err());
}
#[test]
+14 -6
View File
@@ -14,16 +14,24 @@ pub const FORMAT_FLAT_OPC: &str = "flatopc";
pub const MAX_AUTHOR_CHARS: usize = 255;
/// Sets the user name that new tracked changes and comments are recorded under. The name is
/// trimmed, stripped of control characters (they can't go into the saved XML) and cut to
/// [`MAX_AUTHOR_CHARS`]; a blank name is refused so revisions always carry an author.
pub(crate) fn set_author(s: &mut Session, v: &Value) -> CmdResult {
let name: String = p::req_str(v, "name")?.trim().chars().filter(|c| !c.is_control()).take(MAX_AUTHOR_CHARS).collect();
/// The user name as it is stored: trimmed, stripped of control characters (they can't go into
/// the saved XML) and cut to [`MAX_AUTHOR_CHARS`]; a blank name is refused so revisions always
/// carry an author.
pub fn normalize_author(name: &str) -> Result<String, CmdError> {
let name: String = name.trim().chars().filter(|c| !c.is_control()).take(MAX_AUTHOR_CHARS).collect();
let name = name.trim_end();
if name.is_empty() {
return Err(CmdError::Params("`name` must not be blank".into()));
}
s.author = name.to_string();
Ok(name.to_string())
}
/// Sets the user name that new tracked changes and comments are recorded under. The name is
/// normalised by [`normalize_author`].
/// A protected document refuses it: the name is the identity its exceptions are checked against.
pub(crate) fn set_author(s: &mut Session, v: &Value) -> CmdResult {
super::protect::check_user_name(s)?;
s.author = normalize_author(p::req_str(v, "name")?)?;
let mut r = sel_result(s)?;
if let Some(o) = r.as_object_mut() {
o.insert("name".into(), json!(s.author));
+331 -8
View File
@@ -47,6 +47,13 @@ pub fn enforced(doc: &Document) -> bool {
doc.settings.protection.is_some()
}
/// Is the document's editing restricted? The formatting-only protection stores the mode "none"
/// (`w:documentProtection w:edit="none"` with `w:formatting="1"`), so it doesn't count here: it
/// limits the styles, not the editing.
pub fn editing_restricted(doc: &Document) -> bool {
doc.settings.protection.as_deref().is_some_and(|m| m != "none")
}
/// May `author` edit inside the range?
fn editable_by(r: &PermRange, author: &str) -> bool {
r.everyone() || (!r.editor.is_empty() && r.editor.eq_ignore_ascii_case(author.trim()))
@@ -82,7 +89,14 @@ fn restrict(s: &mut Session, v: &Value) -> CmdResult {
return Err(CmdError::Params(format!("unknown `mode` {m:?}: none, readOnly, comments, trackedChanges or forms")));
}
let formatting = p::bool(v, "formatting");
let stop = p::bool(v, "stop") == Some(true) || (mode == Some("none") && formatting != Some(true));
let explicit_stop = p::bool(v, "stop") == Some(true);
// Stopping the protection is explicit: the "none" mode is the shorthand for it, but on a
// protected document only `{"stop": true}` stops it — a command that merely names a mode
// can't strip a user's protection (Word asks for Stop Protection first).
if mode == Some("none") && formatting != Some(true) && !explicit_stop && enforced(&s.doc) {
return Err(CmdError::Disabled("the document is protected: stop protection first (\"stop\": true)".into()));
}
let stop = explicit_stop || (mode == Some("none") && formatting != Some(true));
if stop {
return stop_protection(s, p::str(v, "password").unwrap_or(""));
}
@@ -90,8 +104,16 @@ fn restrict(s: &mut Session, v: &Value) -> CmdResult {
// Only the locked styles changed (or nothing): report the state.
return Ok(state(s));
}
if enforced(&s.doc) && !s.doc.settings.protect_hash.is_empty() {
return Err(CmdError::Disabled("the document is protected with a password: stop protection first".into()));
// The protection of a protected document doesn't change: Word asks for Stop Protection first.
// (The formatting-only protection isn't one: it limits the styles, not the editing — but a
// password on it still guards it, so it can't be replaced either.)
if editing_restricted(&s.doc) || !s.doc.settings.protect_hash.is_empty() {
let why = if s.doc.settings.protect_hash.is_empty() {
"the document is protected: stop protection first"
} else {
"the document is protected with a password: stop protection first"
};
return Err(CmdError::Disabled(why.into()));
}
let formatting = formatting.unwrap_or(false);
let mode = mode.unwrap_or("none");
@@ -202,9 +224,48 @@ const CONTROL_INSERTS: [&str; 9] = [
"developer.repeatingSection",
];
/// Commands always allowed in a protected document: protection itself, file commands, undo.
fn always_allowed(id: &str) -> bool {
id == "review.restrict" || id.starts_with("file.") || id == "edit.undo" || id == "edit.redo"
/// Commands always allowed in a protected document: protection itself, undo, and the file
/// commands that don't change the open document. A `file.*` command that does change it is
/// checked like any other, so the protection holds for it too: the privacy options and the
/// properties are document settings, and the inspector removes content (#1327 follow-up).
fn always_allowed(id: &str, params: &Value) -> bool {
if id == "review.restrict" || id == "edit.undo" || id == "edit.redo" {
return true;
}
if !id.starts_with("file.") {
return false;
}
match id {
"file.privacyOptions" | "file.properties" => false,
// The user name says who may edit the document and who the revisions belong to: it is
// checked like any other change, not waved through.
"file.setAuthor" => false,
// The inspector reads the document; its removals change it.
"file.inspect" => params.get("remove").and_then(Value::as_array).is_none_or(|a| a.is_empty()),
_ => true,
}
}
/// The user name (`file.setAuthor`) is the identity a named exception range is checked against,
/// and the author recorded on new revisions and comments: it can't change while the document is
/// protected. Word lets the user name change under protection (it is a preference there); this
/// project doesn't, because the name says who may edit what (#1327 follow-up).
pub(crate) fn check_user_name(s: &Session) -> Result<(), CmdError> {
if enforced(&s.doc) {
return Err(CmdError::Disabled("file.setAuthor: stop protection before changing the user name".into()));
}
Ok(())
}
/// A version restore replaces the whole document, including the content the protection kept
/// read-only, so it needs the protection stopped first — and the password with it, when the
/// document has one. (`file.versions` is a pure command, so `check_before` doesn't see it; the
/// command checks this itself — see [`check_version_restore`].)
pub(crate) fn check_version_restore(s: &Session) -> Result<(), CmdError> {
if enforced(&s.doc) {
return Err(CmdError::Disabled("file.versions: stop protection before restoring a version".into()));
}
Ok(())
}
/// Why protection refuses command `id` whatever its params and the selection, so the ribbon greys
@@ -229,9 +290,16 @@ pub(crate) fn greyed_out(s: &Session, id: &str) -> Option<&'static str> {
/// [`outside_unchanged`]).
pub(crate) fn check_before(s: &Session, id: &str, params: &Value) -> Result<bool, CmdError> {
let Some(mode) = s.doc.settings.protection.as_deref() else { return Ok(false) };
if always_allowed(id) {
if always_allowed(id, params) {
return Ok(false);
}
// The user name says who may edit the document, and who the new revisions and comments
// belong to: it can't change while the document is protected. (`file.setAuthor` is a pure
// command, so `check_before` doesn't see it; the command checks this itself — see
// [`check_user_name`].)
if id == "file.setAuthor" {
check_user_name(s)?;
}
// Word blocks content-control property edits until Stop Protection: changing a control's
// lock flags would reshape what forms mode lets through. The param's presence is the
// trigger — absent, the command keeps the current lock (as `check_formatting` inspects
@@ -240,6 +308,23 @@ pub(crate) fn check_before(s: &Session, id: &str, params: &Value) -> Result<bool
if id == "developer.properties" && (params.get("lockEdit").is_some() || params.get("lockDelete").is_some()) {
return Err(CmdError::Disabled(format!("{id}: stop protection before changing a content control's locks")));
}
// The document's properties and its privacy options are document-wide state, and they aren't
// tracked: a document that tracks changes would take them with no revision to show for it, and
// the revision trail would say nothing about them. They're refused while the document is
// protected, as the content control's properties are (#3278 follow-up).
// The inspector's removals take content out of the document — the comments, the revisions,
// the properties, the hidden text, the headers — so they are refused in every mode too: under
// tracked changes they would change the document with no revision to show for it, and under
// the other modes the document may not change at all. (An inspection without removals only
// reads the document, and is let through by `always_allowed`.)
if enforced(&s.doc) {
match id {
"file.properties" => return Err(CmdError::Disabled(format!("{id}: stop protection before changing the document's properties"))),
"file.privacyOptions" => return Err(CmdError::Disabled(format!("{id}: stop protection before changing the privacy options"))),
"file.inspect" => return Err(CmdError::Disabled(format!("{id}: stop protection before removing content with the inspector"))),
_ => {}
}
}
if let Some(why) = greyed_out(s, id) {
return Err(CmdError::Disabled(format!("{id}: {why}")));
}
@@ -320,7 +405,7 @@ enum HoleMode {
/// run formatting and paragraph formatting outside the ranges `author` may edit, in every story,
/// and the formatting of every table, row and cell that holds any of that protected content, and what
/// reaches the whole document: page setup, the document defaults and the existing styles
/// (applying a style may add one).
/// (applying a style may add one), the document's settings and its metadata.
pub(crate) fn outside_unchanged(before: &Document, after: &Document, author: &str) -> bool {
// Protection can't change mid-command (`protection_lifted` guards undo and redo).
let mode = if Some("forms") == before.settings.protection.as_deref() { HoleMode::Controls } else { HoleMode::Perms(author.into()) };
@@ -330,9 +415,35 @@ pub(crate) fn outside_unchanged(before: &Document, after: &Document, author: &st
&& b.default_chr == a.default_chr
&& b.default_para == a.default_para
&& b.styles.iter().all(|st| a.get(&st.id) == Some(st))
&& before.settings == after.settings
&& before.core == after.core
&& before.custom_props == after.custom_props
&& comments_unchanged(before, after)
&& lists_unchanged(before, after)
&& sources_unchanged(before, after)
&& outside(before, &mode) == outside(after, &mode)
}
/// The comments' resolved flags: a comment's text is a part, and the parts are compared with the
/// rest of the document; the flag is the comment's own state.
fn comments_unchanged(before: &Document, after: &Document) -> bool {
before.comments.iter().all(|(id, c)| after.comments.get(id).is_some_and(|a| a.resolved == c.resolved))
}
/// The list definitions: the ones the document had must be unchanged — a list's format reaches
/// every paragraph that uses it — but a new one may be added, like a new style: applying a list
/// inside an editable region adds its definition.
fn lists_unchanged(before: &Document, after: &Document) -> bool {
before.numbering.abstracts.iter().all(|x| after.numbering.abstracts.contains(x))
&& before.numbering.nums.iter().all(|x| after.numbering.nums.contains(x))
}
/// The bibliography sources: the ones the document had must be unchanged; a new one may be added
/// (a citation inserted inside an editable region).
fn sources_unchanged(before: &Document, after: &Document) -> bool {
before.sources.iter().all(|x| after.sources.contains(x))
}
/// The section properties paragraphs end, in order.
fn sections(doc: &Document) -> Vec<&wordcraft_doc::SectionProps> {
doc.para_paths(StoryRef::Body).iter().filter_map(|path| doc.para(StoryRef::Body, path)?.section.as_deref()).collect()
@@ -693,6 +804,216 @@ mod tests {
assert_eq!(s.doc, before);
}
/// A version restore replaces the whole document, including the content the protection kept
/// read-only, so it needs the protection stopped first — and the password with it.
#[test]
fn a_version_restore_needs_the_protection_stopped_first() {
for mode in ["readOnly", "forms", "comments", "trackedChanges"] {
let mut s = Session::new(Document::from_text("Original text."));
s.run("file.versions", &json!({"save": "before"})).unwrap();
s.run("review.restrict", &json!({"mode": mode, "password": "pw"})).unwrap();
let before = s.doc.clone();
let e = s.run("file.versions", &json!({"restore": 0})).unwrap_err().to_string();
assert!(e.contains("stop protection"), "{mode}: {e}");
assert_eq!(s.doc, before, "{mode}: the restore changed the document");
assert_eq!(s.versions.len(), 1, "{mode}: the snapshot is still there");
}
}
/// A restore never changes the protection: the live document's choice stands, and a
/// snapshot taken while the document was protected doesn't bring that protection back.
#[test]
fn a_version_restore_keeps_the_live_protection() {
let mut s = Session::new(Document::from_text("Original text."));
s.run("file.versions", &json!({"save": "before"})).unwrap();
s.run("review.restrict", &json!({"mode": "readOnly", "password": "pw"})).unwrap();
// The protection is stopped, with its password, and the version restored after that.
s.run("review.restrict", &json!({"stop": true, "password": "pw"})).unwrap();
assert!(s.doc.settings.protection.is_none());
s.run("file.versions", &json!({"restore": 0})).unwrap();
assert!(s.doc.settings.protection.is_none(), "the restore brought the protection back");
assert!(s.doc.settings.protect_hash.is_empty(), "the restore brought the password back");
assert_eq!(s.doc.plain_text(StoryRef::Body), "Original text.");
}
/// A document-wide change that isn't tracked — the properties, the privacy options — is
/// refused while the document is protected: under tracked changes it would change the
/// document with no revision to show for it, and under the other modes the document may not
/// change at all.
#[test]
fn a_document_wide_change_that_is_not_tracked_is_refused_under_protection() {
for mode in ["readOnly", "forms", "comments", "trackedChanges"] {
for (id, params) in [("file.properties", json!({"title": "Forged title"})), ("file.privacyOptions", json!({"removePersonalInfo": true}))]
{
let mut s = Session::new(Document::from_text("Some text."));
s.run("review.restrict", &json!({"mode": mode})).unwrap();
let before = s.doc.clone();
let e = s.run(id, &params).unwrap_err().to_string();
assert!(e.contains("stop protection"), "{mode} {id}: {e}");
assert_eq!(s.doc, before, "{mode} {id}: the document changed");
}
}
// Without protection they work.
let mut s = Session::new(Document::from_text("Some text."));
s.run("file.properties", &json!({"title": "A title"})).unwrap();
assert_eq!(s.doc.core.title, "A title");
s.run("file.privacyOptions", &json!({"removePersonalInfo": true})).unwrap();
assert!(s.doc.settings.remove_personal_info);
}
/// A file command that changes the document is checked like any other: the privacy options,
/// the properties and the inspector's removals are refused in every mode a protected document
/// can be in (#1327 follow-up).
#[test]
fn file_commands_that_change_the_document_are_protected() {
for mode in ["readOnly", "forms", "comments", "trackedChanges"] {
for (id, params) in [
("file.privacyOptions", json!({"removePersonalInfo": true})),
("file.properties", json!({"title": "New title"})),
("file.inspect", json!({"remove": ["comments"]})),
] {
let mut s = Session::new(Document::from_text("Some text."));
s.run("review.restrict", &json!({"mode": mode})).unwrap();
let before = s.doc.clone();
assert!(s.run(id, &params).is_err(), "{mode} {id}: a protected document took the change");
assert_eq!(s.doc, before, "{mode} {id}");
}
}
// Without protection they work.
let mut s = Session::new(Document::from_text("Some text."));
s.run("file.inspect", &json!({"remove": ["comments"]})).unwrap();
}
/// The user name is the identity a named exception range is checked against, and the author
/// the new revisions and comments are recorded under: a protected document refuses to change
/// it (#1327 follow-up).
#[test]
fn the_user_name_cannot_change_under_protection() {
let mut s = Session::new(Document::from_text("Some text."));
s.run("review.restrict", &json!({"mode": "readOnly"})).unwrap();
let author = s.author.clone();
let e = s.run("file.setAuthor", &json!({"name": "Alice"})).unwrap_err().to_string();
assert!(e.contains("stop protection"), "{e}");
assert_eq!(s.author, author, "the user name did not change");
// Without protection it works again.
s.run("review.restrict", &json!({"stop": true})).unwrap();
s.run("file.setAuthor", &json!({"name": "Alice"})).unwrap();
assert_eq!(s.author, "Alice");
}
/// The protection of a protected document doesn't change: Word asks for Stop Protection first,
/// and only the explicit way stops it — a command that merely names a mode can't strip it
/// (#1327 follow-up).
#[test]
fn a_protected_documents_protection_needs_an_explicit_stop() {
let mut s = Session::new(Document::from_text("Some text."));
s.run("review.restrict", &json!({"mode": "readOnly"})).unwrap();
// The "none" shorthand stops the protection: refused, and the protection stays.
let e = s.run("file.protect", &json!({"mode": "none"})).unwrap_err().to_string();
assert!(e.contains("stop protection"), "{e}");
assert_eq!(s.doc.settings.protection.as_deref(), Some("readOnly"));
// Changing the mode of a protected document is refused too.
let e = s.run("review.restrict", &json!({"mode": "comments"})).unwrap_err().to_string();
assert!(e.contains("stop protection"), "{e}");
assert_eq!(s.doc.settings.protection.as_deref(), Some("readOnly"));
// Stopping it explicitly works.
s.run("review.restrict", &json!({"stop": true})).unwrap();
assert!(s.doc.settings.protection.is_none());
}
/// A password-protected document still needs its password to stop, and the formatting-only
/// protection (the mode "none") is still how the styles are limited.
#[test]
fn a_password_guards_the_protection_and_the_formatting_only_path_still_works() {
let mut s = Session::new(Document::from_text("Some text."));
s.run("review.restrict", &json!({"mode": "readOnly", "password": "pw"})).unwrap();
assert!(s.run("review.restrict", &json!({"stop": true, "password": "wrong"})).is_err());
assert!(s.run("review.restrict", &json!({"stop": true})).is_err());
assert_eq!(s.doc.settings.protection.as_deref(), Some("readOnly"));
// The formatting-only protection: the mode "none" with the formatting restriction.
let mut t = Session::new(Document::from_text("Some text."));
t.run("review.restrict", &json!({"mode": "none", "formatting": true})).unwrap();
assert_eq!(t.doc.settings.protection.as_deref(), Some("none"));
assert!(t.doc.settings.protect_formatting);
}
/// A password guards the protection whatever its mode: a formatting-only protection with a
/// password can't have that password replaced (and then removed) without knowing it.
#[test]
fn a_password_guards_the_formatting_only_protection_too() {
let mut s = Session::new(Document::from_text("Some text."));
s.run("review.restrict", &json!({"mode": "none", "formatting": true, "password": "pw"})).unwrap();
assert_eq!(s.doc.settings.protection.as_deref(), Some("none"));
let hash = s.doc.settings.protect_hash.clone();
assert!(!hash.is_empty(), "the password is set");
// Replacing the protection would replace the password with one of the caller's choosing.
assert!(s.run("review.restrict", &json!({"mode": "readOnly", "password": "other"})).is_err());
assert_eq!(s.doc.settings.protect_hash, hash, "the password was replaced");
assert_eq!(s.doc.settings.protection.as_deref(), Some("none"), "the protection was replaced");
// And stopping it needs the password.
assert!(s.run("review.restrict", &json!({"stop": true})).is_err());
assert!(s.run("review.restrict", &json!({"stop": true, "password": "wrong"})).is_err());
assert_eq!(s.doc.settings.protection.as_deref(), Some("none"), "the protection was stopped");
s.run("review.restrict", &json!({"stop": true, "password": "pw"})).unwrap();
assert!(s.doc.settings.protection.is_none());
}
/// The settings and the metadata of a protected document are part of what stays as it was:
/// a command that changes them is refused, or rolled back, like any other change outside
/// the editable regions (#1327 follow-up).
#[test]
fn a_protected_document_keeps_its_settings_and_metadata() {
let mut s = Session::new(Document::from_text("Locked intro.\nFill in here."));
select(&mut s, "Fill in here.");
s.run("review.restrict", &json!({"exception": "add"})).unwrap();
s.run("review.restrict", &json!({"mode": "readOnly"})).unwrap();
let before = s.doc.clone();
// The properties: the change is refused, and the command says why.
let e = s.run("file.properties", &json!({"title": "Forged title"})).unwrap_err().to_string();
assert!(e.contains("stop protection"), "{e}");
assert_eq!(s.doc, before);
assert_ne!(s.doc.core.title, "Forged title");
// A custom property too.
assert!(s.run("file.properties", &json!({"custom": {"Owner": "Alice"}})).is_err());
assert_eq!(s.doc, before);
// An edit inside the exception still works, and isn't rolled back.
select(&mut s, "here");
s.run("text.insert", &json!({"text": "there"})).unwrap();
assert!(s.doc.plain_text(StoryRef::Body).contains("Fill in there."));
// Without protection the same command works.
let mut t = Session::new(Document::from_text("Some text."));
t.run("file.properties", &json!({"title": "A title"})).unwrap();
assert_eq!(t.doc.core.title, "A title");
}
/// Reading the document stays allowed under protection: the inspector without removals, the
/// privacy settings, the info, and saving a version snapshot.
#[test]
fn file_commands_that_only_read_stay_allowed() {
let mut s = Session::new(Document::from_text("Some text."));
s.run("review.restrict", &json!({"mode": "readOnly"})).unwrap();
for (id, params) in
[("file.inspect", json!({})), ("file.privacy", json!({})), ("file.info", json!({})), ("file.versions", json!({"save": "snapshot"}))]
{
assert!(s.run(id, &params).is_ok(), "{id}: a protected document refused a read");
}
}
/// The post-check compares the document's settings and its metadata, so a command that
/// changed either is rolled back even where the command itself isn't refused — the
/// backstop behind the refusal the properties and the privacy options get.
#[test]
fn the_post_check_compares_the_settings_and_the_metadata() {
let before = Document::from_text("Some text.");
let mut after = before.clone();
after.core.title = "Forged".into();
assert!(!super::outside_unchanged(&before, &after, "someone"), "the metadata isn't compared");
let mut after = before.clone();
after.settings.remove_personal_info = true;
assert!(!super::outside_unchanged(&before, &after, "someone"), "the settings aren't compared");
assert!(super::outside_unchanged(&before, &before.clone(), "someone"), "an unchanged document isn't unchanged");
}
/// Read only, no exceptions: going to a header the document doesn't have doesn't create
/// one, and loading or filtering recipients doesn't rewrite the merge fields' shown results
/// (both used to change the document from a non-mutating command, #1327).
@@ -1002,6 +1323,8 @@ mod tests {
assert_eq!(start, first.content_start(), "wraps round to the first control");
assert_eq!(end, first.end);
// Without forms mode, Find Next Region is about perm ranges: none here, nothing to find.
// (The protection is stopped first: the mode of a protected document doesn't change.)
s.run("review.restrict", &json!({"stop": true})).unwrap();
s.run("review.restrict", &json!({"mode": "readOnly"})).unwrap();
assert_eq!(next_region(&s, &s.doc.start_of(StoryRef::Body)), None);
}
+19 -2
View File
@@ -465,7 +465,7 @@ pub fn specs() -> Vec<CommandSpec> {
let mode = p::str(v, "mode").unwrap_or("readOnly");
s.run("review.restrict", &json!({"mode": mode}))
})
.params(r#"{"mode": "none|readOnly|comments|trackedChanges"}"#),
.params(r#"{"mode": "none|readOnly|comments|trackedChanges"} (starting enforcement; "none" doesn't stop a protected document — use review.restrict {"stop": true})"#),
CommandSpec::new("file.versions", "Version History", "File › Info", |s, v| {
if let Some(label) = p::str(v, "save") {
s.versions.push((label.to_string(), super::now_iso(), s.doc.clone()));
@@ -475,8 +475,21 @@ pub fn specs() -> Vec<CommandSpec> {
}
if let Some(i) = p::u64(v, "restore") {
let (_, _, d) = s.versions.get(i as usize).cloned().ok_or_else(|| CmdError::Params("no such version".into()))?;
// The restore replaces the whole document, including the content the protection
// kept read-only, so the protection is stopped first (with its password, when it
// has one) — a restore must not go around it.
super::protect::check_version_restore(s)?;
s.checkpoint("Restore Version");
// A snapshot's own protection is stale: it was taken at some earlier moment, and
// restoring it must not bring a protection back that the live document doesn't
// have (#1327 follow-up).
let protection = s.doc.settings.protection.clone();
let protect_hash = s.doc.settings.protect_hash.clone();
let protect_formatting = s.doc.settings.protect_formatting;
s.doc = d;
s.doc.settings.protection = protection;
s.doc.settings.protect_hash = protect_hash;
s.doc.settings.protect_formatting = protect_formatting;
s.touch();
s.clamp_selection();
}
@@ -2038,7 +2051,11 @@ mod tests {
let mut s = Session::new(Document::from_text("locked"));
s.run("review.restrict", &json!({"mode": "readOnly"})).unwrap();
assert!(s.run("text.insert", &json!({"text": "x"})).is_err());
s.run("review.restrict", &json!({"mode": "none"})).unwrap();
// The "none" mode is the shorthand for stopping the protection, and it doesn't stop a
// protected document: only the explicit stop does (#1327 follow-up).
assert!(s.run("review.restrict", &json!({"mode": "none"})).is_err());
assert!(s.run("text.insert", &json!({"text": "x"})).is_err());
s.run("review.restrict", &json!({"stop": true})).unwrap();
assert!(s.run("text.insert", &json!({"text": "x"})).is_ok());
}
+13 -1
View File
@@ -238,7 +238,8 @@ fn general(app: &mut WordApp, ui: &mut Ui) {
egui::Grid::new("options_user").num_columns(2).spacing(vec2(8.0, 6.0)).show(ui, |ui| {
ui.label(tl!("User name:"));
let mut n = app.session.author.clone();
if ui.add(egui::TextEdit::singleline(&mut n).desired_width(260.0)).changed() {
let protected = wordcraft_engine::cmd::protect::enforced(&app.session.doc);
if ui.add_enabled(!protected, egui::TextEdit::singleline(&mut n).desired_width(260.0)).changed() && !protected {
app.session.author = n;
}
ui.end_row();
@@ -826,6 +827,17 @@ mod tests {
h
}
/// File › Options must not offer a way around the protected document's author check.
#[test]
fn user_name_field_disables_when_protection_starts() {
let mut h = nav_harness("en", 100);
let user_name = || egui_kittest::kittest::By::new().role(egui::accesskit::Role::TextInput).value("WordCraft User");
assert!(!h.get(user_name()).accesskit_node().is_disabled());
h.state_mut().run("review.restrict", json!({"mode": "readOnly"})).unwrap();
h.run_steps(4);
assert!(h.get(user_name()).accesskit_node().is_disabled());
}
/// #1266: check the actual painted text and renderer clip, including the bold selected
/// label. AccessKit must still expose the full name and the row must keep its fixed size.
#[test]
+4 -2
View File
@@ -1,6 +1,6 @@
# File-format parity with Microsoft Word
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-21 · **Change:** trivial (a document with many content controls saves in the time the controls take, #13; previously trivial: `majorBidi`/`minorBidi` theme fonts follow the settings' theme font language, as measured in Word; previously trivial: Word signature lines are kept on save, still signable; previously trivial: Times New Roman keeps Liberation Serif under the roman-font rule; previously trivial: shape gradients and transparency, #1500; previously trivial: a folder that keeps no Unix modes, such as a NAS share mounted by GVFS, still takes a save, #3003; previously trivial: a text frame in the paragraph defaults frames nothing, #2360; previously trivial: table style band sizes written, a style without them unbanded as in Word, #3415; previously trivial: HTML export writes ruby (furigana, phonetic guides) and HTML import reads it back, #1681; previously trivial: RTF import decodes the East Asian multibyte code pages, #1653; previously trivial: PDF export: lam-alef drawn as two glyphs at one x extracts in reading order, #2310; previously trivial: native .json stores pictures and kept parts as base64, #586 follow-up; previously trivial: ODT headers and footers read from each master page, #863; previously trivial: RTF import compares heading and code formatting with the file's stylesheet; 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: Word XML and web pages saved as `.doc` open by what they hold, #2853; previously trivial: a VML shape in a header cell is always placed on the page, user-checked in Word for Mac, #1775; previously trivial: `w:sym` characters keep their symbol font when the text's font changes and are written back as `w:sym`, #2036; previously trivial: RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: two lines in one and horizontal in vertical (`w:eastAsianLayout`) kept on save; previously trivial: PDF outline targets follow leading page breaks; previously trivial: positional tabs `w:ptab` read and kept on save, #2367; previously trivial: DOCX: mixed right-to-left runs split and marked `w:rtl` with their language, #1543; previously trivial: `w:doNotTrackMoves` round-trips and turns move tracking off; previously trivial: legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: DOCX glossary documents (building blocks) read, kept and written; previously trivial: cached field results keep their visible run formatting; previously trivial: cached REF results retain hyperlink destinations; previously trivial: CLI conversions can explicitly refresh fields; previously trivial: a copied equation also goes in the clipboard's MathML formats, #1297; previously trivial: a pasted HTML `<blockquote>` is an indented paragraph, not the Quote style, #1270); previously trivial: every table style part round-trips, #714; previously trivial: VML freeforms read and kept on save; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: `flowChartOr` draws its circle and cross spokes, as in Word; previously trivial: RTF: Mac OS Roman and DOS text, #1653; previously trivial: VML text boxes keep their internal margins, fit to their text and vertical alignment; hidden drawings not shown; previously trivial: PDF export draws horizontal and vertical lines inside groups, #1474; previously trivial: symbol-font text exported as Unicode, #776; previously trivial: text-box margins, Resize shape to fit text and freeforms in groups, #743; previously trivial: formatted paste from the system clipboard's HTML and RTF, #687; previously trivial: independent complex-script formatting inheritance and round-trip regression tests; previously trivial: import/export fixes for TXT, RTF, ODT, HTML and LaTeX, #433–#440; previously major: first version; every format Word reads or writes, measured from the readers and writers on origin/main) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4, plus Word for Windows' extra formats
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-22 · **Change:** trivial (the file commands that change the document are checked on a protected document, the protection of a protected document needs an explicit stop, the session author can't change through `file.setAuthor` under protection (the CLI's `--author` uses the same protection check), and the post-check compares the settings and the metadata; previously trivial: a document with many content controls saves in the time the controls take, #13; previously trivial: `majorBidi`/`minorBidi` theme fonts follow the settings' theme font language, as measured in Word; previously trivial: Word signature lines are kept on save, still signable; previously trivial: Times New Roman keeps Liberation Serif under the roman-font rule; previously trivial: shape gradients and transparency, #1500; previously trivial: a folder that keeps no Unix modes, such as a NAS share mounted by GVFS, still takes a save, #3003; previously trivial: a text frame in the paragraph defaults frames nothing, #2360; previously trivial: table style band sizes written, a style without them unbanded as in Word, #3415; previously trivial: HTML export writes ruby (furigana, phonetic guides) and HTML import reads it back, #1681; previously trivial: RTF import decodes the East Asian multibyte code pages, #1653; previously trivial: PDF export: lam-alef drawn as two glyphs at one x extracts in reading order, #2310; previously trivial: native .json stores pictures and kept parts as base64, #586 follow-up; previously trivial: ODT headers and footers read from each master page, #863; previously trivial: RTF import compares heading and code formatting with the file's stylesheet; 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: Word XML and web pages saved as `.doc` open by what they hold, #2853; previously trivial: a VML shape in a header cell is always placed on the page, user-checked in Word for Mac, #1775; previously trivial: `w:sym` characters keep their symbol font when the text's font changes and are written back as `w:sym`, #2036; previously trivial: RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: two lines in one and horizontal in vertical (`w:eastAsianLayout`) kept on save; previously trivial: PDF outline targets follow leading page breaks; previously trivial: positional tabs `w:ptab` read and kept on save, #2367; previously trivial: DOCX: mixed right-to-left runs split and marked `w:rtl` with their language, #1543; previously trivial: `w:doNotTrackMoves` round-trips and turns move tracking off; previously trivial: legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: DOCX glossary documents (building blocks) read, kept and written; previously trivial: cached field results keep their visible run formatting; previously trivial: cached REF results retain hyperlink destinations; previously trivial: CLI conversions can explicitly refresh fields; previously trivial: a copied equation also goes in the clipboard's MathML formats, #1297; previously trivial: a pasted HTML `<blockquote>` is an indented paragraph, not the Quote style, #1270); previously trivial: every table style part round-trips, #714; previously trivial: VML freeforms read and kept on save; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: `flowChartOr` draws its circle and cross spokes, as in Word; previously trivial: RTF: Mac OS Roman and DOS text, #1653; previously trivial: VML text boxes keep their internal margins, fit to their text and vertical alignment; hidden drawings not shown; previously trivial: PDF export draws horizontal and vertical lines inside groups, #1474; previously trivial: symbol-font text exported as Unicode, #776; previously trivial: text-box margins, Resize shape to fit text and freeforms in groups, #743; previously trivial: formatted paste from the system clipboard's HTML and RTF, #687; previously trivial: independent complex-script formatting inheritance and round-trip regression tests; previously trivial: import/export fixes for TXT, RTF, ODT, HTML and LaTeX, #433–#440; previously major: first version; every format Word reads or writes, measured from the readers and writers on origin/main) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4, plus Word for Windows' extra formats
Every format Word opens or saves, what WordCraft does with it, and how it's tested. Fidelity is the
share of a typical real-world file's content and formatting that survives (read: shows correctly;
@@ -93,7 +93,7 @@ Measured by grepping `crates/docx/src/read` and `src/write` for the OOXML elemen
| Fields (`w:fldChar`, `w:fldSimple`), TOC, cross-references, `ADDIN` citations | ✅ | ✅ | Field codes Word supports but we don't evaluate keep their cached result. Textual `REF` fields with `\h` export internal PDF links without refreshing cached text; existing enclosing links take precedence. `wordcraft-cli convert --update-fields` explicitly runs field updates before saving; ordinary conversion preserves caches and locked fields stay unchanged. Instruction-only paragraphs carry hidden marks instead of blank lines; an empty result paragraph keeps its line. Imported TOCs preserve actual result boundaries, formatted entries and nested fields without relying on TOC styles. `EQ \o` (overstrike, enclosed characters) is drawn; enclosed characters are written as Word stores them, their style in the code's run sizes (#297); a cached picture (`INCLUDEPICTURE`) stays as the content of a range field (#465). A `HYPERLINK` field reads as a link, its result ordinary text (tabs, nested `PAGEREF`), and is written back as `w:hyperlink`; its tooltip (`\o`) and target frame (`\t`) are dropped (#2174) |
| Move tracking (`w:moveFrom`/`w:moveTo`, `w:moveFromRangeStart/End`, `w:moveToRangeStart/End`), moved paragraph marks | ✅ | ✅ | #400: both ends linked by the range name; ranges rewritten around each moved stretch (ids renumbered); a move missing an end or a range end opens as a plain deletion/insertion. ODT/PDF export the result (moved-away text hidden like deletions); RTF writes a move as a plain tracked deletion and insertion |
| Content controls (`w:sdt`) | ✅ | ✅ | Block, inline, row and cell level, nested; title, tag, id, lock, placeholder, temporary, rich/plain text, `w14:checkbox`, combo box, drop-down list, date, picture, building block gallery, `w15` repeating sections; other `w:sdtPr` children (data binding, appearance) kept verbatim (#381). A TOC's control is rebuilt by the TOC when the TOC has the heading-and-entries form; around any other TOC it is kept as read (#2427). Replace All respects content-edit and control-deletion locks and refuses a batch before making any edit |
| Restrict Editing (`w:documentProtection` edit/formatting/enforcement and password hash, `w:locked` styles, `w:permStart`/`w:permEnd` exceptions) | ✅ | ✅ | #412: SHA-512 hash with salt and spin count written; Word's legacy `w:cryptAlgorithmSid`/`w:hash` form (SHA-1/-256/-384/-512, ECMA-376 Part 4 §14.8.1) is kept and verified; MD2/MD4/MD5, `w:algIdExt` and other unsupported forms are refused, never unlocked; `w:colFirst`/`w:colLast` table-column ranges read as plain ranges; forms mode (`w:edit="forms"`) lets edits inside fillable content controls (`w:lock` content-editable, innermost wins) run, everything else rolls back; row-wrapped controls and control deletion remain refused; `review.restrict` reports `formFields`; Find Next Region covers form fields (the pane button shows for forms documents); under active protection, `developer.properties` refuses `lockEdit`/`lockDelete` changes, as Word blocks property edits until Stop Protection; under forms protection it refuses every property, and inserting or removing content controls is refused too (Word greys out the Developer tab's control buttons and Properties and offers no Remove Content Control), while under the other modes the title, tag and placeholder stay editable |
| Restrict Editing (`w:documentProtection` edit/formatting/enforcement and password hash, `w:locked` styles, `w:permStart`/`w:permEnd` exceptions) | ✅ | ✅ | #412: SHA-512 hash with salt and spin count written; Word's legacy `w:cryptAlgorithmSid`/`w:hash` form (SHA-1/-256/-384/-512, ECMA-376 Part 4 §14.8.1) is kept and verified; MD2/MD4/MD5, `w:algIdExt` and other unsupported forms are refused, never unlocked; `w:colFirst`/`w:colLast` table-column ranges read as plain ranges; forms mode (`w:edit="forms"`) lets edits inside fillable content controls (`w:lock` content-editable, innermost wins) run, everything else rolls back; row-wrapped controls and control deletion remain refused; `review.restrict` reports `formFields`; Find Next Region covers form fields (the pane button shows for forms documents); under active protection, `developer.properties` refuses `lockEdit`/`lockDelete` changes, as Word blocks property edits until Stop Protection; under forms protection it refuses every property, and inserting or removing content controls is refused too (Word greys out the Developer tab's control buttons and Properties and offers no Remove Content Control), while under the other modes the title, tag and placeholder stay editable; a file command that changes the open document is checked like any other, so the privacy options, the properties and the inspector's removals are refused on a protected document, and restoring a stored version keeps the protection and its password rather than the snapshot's stale ones; the protection of a protected document only changes through the explicit stop (`review.restrict {"stop": true}` — the "none" mode is the shorthand for it and doesn't stop a protected document), the session author used for new tracked changes and comments can't change through `file.setAuthor` while the document is protected (the CLI's `--author` uses the same protection check), and the post-check compares the document's settings, metadata, custom properties, list definitions, bibliography sources and the comments' resolved flags as well as its text and formatting; named exceptions match a self-declared user name, not an authenticated identity |
| Legacy form fields (`w:ffData`), check boxes, drop-downs | 🟡 | ✅ | The field definition is kept raw and saved back. Check boxes (`FORMCHECKBOX`) are drawn as Word draws them: a square as tall as the text's line, inset 1.16 pt, 0.72 pt border, an X when checked (`w:checked`, else `w:default`), `w:size` honoured; drop-downs (`FORMDROPDOWN`) show their selected entry; text fields (`FORMTEXT`) show their result. Not yet: filling them in, Protect Document's forms mode for legacy fields |
| DrawingML pictures inline and anchored (`wp:anchor`), wrap square/tight/through/top-bottom | ✅ | ✅ | Tight/through read and written with their `wp:wrapPolygon` (and `edited`); without one, a shape's outline is written (a picture's rectangle). `wrapText` (both sides, left, right, largest) read and written for square, tight and through |
| Legacy form fields (`w:ffData`), check boxes, drop-downs | ❌ | ❌ | |
@@ -258,6 +258,8 @@ Imported text fields use the first nonempty cached result run's formatting on th
| 2026-10-21 | trivial | ODT: `table:table-column` styles (`style:column-width`, `table:number-columns-repeated`) are read as the table's grid, so column widths set in LibreOffice or changed in WordCraft survive a save; before, every opened table got equal columns and the written widths were dropped on the next open |
| 2026-10-21 | trivial | A document with many content controls saves in the time the controls take: 32,000 controls took four seconds to save and take a tenth of one now (#13) |
| 2026-10-20 | trivial | Nested-picture textboxes retain their frames; WPS picture fills keep their existing image fallback. |
| 2026-10-20 | trivial | Restrict Editing: a file command that changes the open document is checked like any other — the privacy options, the properties and the inspector's removals are refused on a protected document — and restoring a stored version keeps the protection and its password instead of the snapshot's stale ones |
| 2026-10-22 | trivial | The CLI author flag and the Options user name field cannot change the session name after protection starts (#3279 follow-up). |
| 2026-10-19 | trivial | An equation from LibreOffice in an `.odt` file no longer shows blank: it's embedded as a `draw:object` formula sub-document (MathML), with a `draw:image` replacement preview in LibreOffice's own metafile format WordCraft can't decode as a picture; the formula's own `content.xml` is read instead, through the same MathML reader as HTML (#3099) |
| 2026-10-19 | trivial | DOCX: a Word signature line (`w:pict` whose VML picture has an `o:signatureline`) is kept whole like an OLE object and written back as it was, at its new size, so it stays signable in Word; it used to be saved as a plain DrawingML picture |
| 2026-10-19 | trivial | DOCX: a `majorBidi`/`minorBidi` theme font is the theme's font for the script of `w:themeFontLang/@w:bidi` (its Latin font for a Latin language), else the theme's `a:cs`, else Times New Roman; with no bidi language in the settings, Times New Roman. Measured in Word for Mac with synthetic documents; the run's `w:lang` and inherited fonts play no part. Manuscripts set in `majorBidi` with Office's usual theme came out in Calibri Light instead of Times New Roman. Corrects the 2026-10-14 entry |
+4 -1
View File
@@ -1,6 +1,6 @@
# Where WordCraft falls short of Word
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-21 · **Change:** trivial (explicit documents keep the editor when recovery discovery finishes; readable recovery controls; previously trivial: text mini toolbar available with the right-click menu, #2959; previously trivial: personal templates in File › New and Save as Word Template, #2852; previously trivial: tracked table/row/cell/section property changes record in WordCraft, narrowing gap 5; previously trivial: a document with many content controls saves in the time the controls take, #13; previously trivial: Format Page Numbers dialog carries Word's number formats and Continue from previous section / Start at, #1113; previously trivial: centred list labels after leading page breaks, #2964; previously trivial: fixed font inputs in four layout controls; previously trivial: a right, centre or decimal tab whose text doesn't fit, or a left tab past the right indent, goes to the next line with its text, #1840; previously trivial: Bullets and Numbering toggle off style-inherited lists at their effective level, including basedOn chains and numbered ListParagraph styles; other paragraphs and list definitions stay unchanged (related to #3459); previously trivial: Russian sorting keeps ё with е and recognises decomposed ё/й; 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
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-22 · **Change:** trivial (the file commands that change the document are checked on a protected document, the protection of a protected document needs an explicit stop, the session author can't change through `file.setAuthor` under protection (the CLI's `--author` uses the same protection check), and the post-check compares the settings and the metadata; previously trivial: explicit documents keep the editor when recovery discovery finishes; readable recovery controls; previously trivial: text mini toolbar available with the right-click menu, #2959; previously trivial: personal templates in File › New and Save as Word Template, #2852; previously trivial: tracked table/row/cell/section property changes record in WordCraft, narrowing gap 5; previously trivial: a document with many content controls saves in the time the controls take, #13; previously trivial: Format Page Numbers dialog carries Word's number formats and Continue from previous section / Start at, #1113; previously trivial: centred list labels after leading page breaks, #2964; previously trivial: fixed font inputs in four layout controls; previously trivial: a right, centre or decimal tab whose text doesn't fit, or a left tab past the right indent, goes to the next line with its text, #1840; previously trivial: Bullets and Numbering toggle off style-inherited lists at their effective level, including basedOn chains and numbered ListParagraph styles; other paragraphs and list definitions stay unchanged (related to #3459); previously trivial: Russian sorting keeps ё with е and recognises decomposed ё/й; 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
Recovery discovery no longer interrupts an explicitly created or opened document. Older copies remain in File › Recover, whose buttons use the interface palette. See [local recovery](recovery.md).
@@ -135,6 +135,9 @@ Footer page-table height and body wrapping are corrected. Other pagination gaps
| 2026-10-20 | trivial | Nested-picture textboxes retain their frames; WPS picture fills keep their existing image fallback. |
| 2026-10-20 | trivial | Corrected Cuban and Peruvian Spanish table-number parsing and formula results to use decimal points; broader localization estimates unchanged |
| 2026-10-20 | trivial | Set As Default persists the proofing language across restarts; command and dialog regressions cover new documents, interface changes, existing document languages and rejection rollback (#2882); readiness estimates unchanged |
| 2026-10-20 | trivial | Restrict Editing, three more gaps closed: the protection of a protected document only changes through the explicit stop, the session author can't change through `file.setAuthor` under protection, and the post-check compares the settings and the metadata. No gap row (enforcement fix); estimates unchanged |
| 2026-10-22 | trivial | The CLI author flag and the Options user name field now respect that protected-document refusal (#3279 follow-up). No gap row; estimates unchanged. |
| 2026-10-20 | trivial | Restrict Editing: a file command that changes the open document is checked like any other (the privacy options, the properties, the inspector's removals), and restoring a stored version keeps the protection and its password. No gap row (enforcement fix); estimates unchanged |
| 2026-10-19 | trivial | #4: a right, centre or decimal tab whose text doesn't fit, or a left tab past the right indent, goes to the next line with its text, so a TOC entry's page number keeps its leader (#1840) |
| 2026-10-19 | trivial | #7: Format Page Numbers dialog (#1113), 60 modal dialogs |
| 2026-10-19 | trivial | The text context menu includes the existing two-row formatting mini toolbar, at a caret or over selected text (#2959); formatting and its dropdowns preserve the selection, regular menu commands and dismissal still close the popup. Paste options remain in separate PR #2599. |
+2 -1
View File
File diff suppressed because one or more lines are too long