From bcd751a58808f3697116e04d6900f296ec6d9317 Mon Sep 17 00:00:00 2001 From: Mat Strange Date: Tue, 20 Oct 2026 13:00:21 +0100 Subject: [PATCH 001/407] Restrict Editing: a file command that changes the document is checked too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `always_allowed` waved through every `file.*` id, so on a protected document the file commands that do change it went unchecked: the privacy options and the properties wrote document settings, and the inspector's removals deleted content. The privacy options' own comment already said they were "refused on a protected document"; that wasn't true. The allow-list now names the file commands that don't change the open document, and the ones that do are checked like any other command. Reading the document stays allowed: the inspector without removals, the privacy settings, the info, and saving a version snapshot. Restoring a stored version replaces the whole document, and a snapshot's own protection is stale — restoring one lifted the protection and dropped its password. The live document's protection, password and formatting limit now survive a restore. Co-Authored-By: opencode --- ROADMAP.md | 2 + crates/engine/src/cmd/protect.rs | 74 ++++++++++++++++++++++++++++++-- crates/engine/src/cmd/tools.rs | 9 ++++ docs/file-format-parity.md | 3 +- docs/gaps.md | 1 + 5 files changed, 84 insertions(+), 5 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 5a9fbf09fe..4d99711433 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -141,6 +141,7 @@ Imported Word 97–2003 `.doc`/`.dot` files now ask for a supported DOCX target - 2026-10-15: Find opens Navigation on Results; editing the query restores Results after manual Headings/Pages navigation. Typed and selected-text searches retain document contents (#2096). | Date | What landed | +| 2026-10-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 | Save As, Export and Open no longer do nothing on Linux desktops whose file chooser portal fails to show its dialog (#2999): a "cancel" quicker than anyone could make one asks again without the parent window, then opens the built-in file browser. | | 2026-10-18 | Long navigation headings wrap onto further lines inside the resizable pane instead of ending in an ellipsis: the row grows with the text and the pane keeps its width (#759, #3015). | | 2026-10-18 | Clearing a font query with no matches keeps Recently Used Fonts visible at the top of a scrollable list. The initial scroll position no longer skips its heading; measured keyboard scrolling is unchanged (#2068). | @@ -506,6 +507,7 @@ Footer page-table height and body wrapping are corrected. Other pagination gaps | Date | Change | Summary | |---|---|---| +| 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-19 | trivial | Progress log: forms protection keeps its content controls: no inserting, Properties or Remove Content Control | | 2026-10-19 | trivial | Progress log: text typed into a content control takes the control's formatting; localized placeholder styles recognised | | 2026-10-18 | trivial | Progress log: long navigation headings wrap within the pane instead of ending in an ellipsis (#3015) | diff --git a/crates/engine/src/cmd/protect.rs b/crates/engine/src/cmd/protect.rs index b77a6f9d6d..57541444ea 100644 --- a/crates/engine/src/cmd/protect.rs +++ b/crates/engine/src/cmd/protect.rs @@ -202,9 +202,23 @@ 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 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, + } } /// What a protected document lets command `id` do before it runs: `Err` refuses it; `Ok(true)` @@ -212,7 +226,7 @@ fn always_allowed(id: &str) -> bool { /// [`outside_unchanged`]). pub(crate) fn check_before(s: &Session, id: &str, params: &Value) -> Result { 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); } // Word blocks content-control property edits until Stop Protection: changing a control's @@ -687,6 +701,58 @@ mod tests { assert_eq!(s.doc, before); } + /// Restoring a stored version replaces the whole document, and a snapshot's own protection + /// is stale: the live document's protection, and its password, still hold after a restore + /// (#1327 follow-up). + #[test] + fn a_version_restore_keeps_the_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(); + assert_eq!(s.doc.settings.protection.as_deref(), Some("readOnly")); + let hash = s.doc.settings.protect_hash.clone(); + assert!(!hash.is_empty(), "the password is set"); + + s.run("file.versions", &json!({"restore": 0})).unwrap(); + assert_eq!(s.doc.settings.protection.as_deref(), Some("readOnly"), "the restore lifted the protection"); + assert_eq!(s.doc.settings.protect_hash, hash, "the restore dropped the password"); + // The document is still protected: an edit outside the exceptions is refused. + let before = s.doc.clone(); + assert!(s.run("text.insert", &json!({"text": "edited "})).is_err()); + assert_eq!(s.doc, before); + } + + /// A file command that changes the document is checked like any other: the privacy options + /// and the properties are document settings, and the inspector removes content (#1327 + /// follow-up). + #[test] + fn file_commands_that_change_the_document_are_protected() { + 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": "readOnly"})).unwrap(); + let before = s.doc.clone(); + assert!(s.run(id, ¶ms).is_err(), "{id}: a protected document took the change"); + assert_eq!(s.doc, before, "{id}"); + } + } + + /// 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, ¶ms).is_ok(), "{id}: a protected document refused a read"); + } + } + /// 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). diff --git a/crates/engine/src/cmd/tools.rs b/crates/engine/src/cmd/tools.rs index b02408dd21..c09a9c5349 100644 --- a/crates/engine/src/cmd/tools.rs +++ b/crates/engine/src/cmd/tools.rs @@ -476,7 +476,16 @@ pub fn specs() -> Vec { 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()))?; s.checkpoint("Restore Version"); + // A snapshot's own protection is stale: it was taken at some earlier moment, and + // restoring it must not lift the protection the live document is under, nor + // its password (#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(); } diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index d68488edf5..d5ad1cdd8f 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -83,7 +83,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) | | 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. 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 | | 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 | ❌ | ❌ | | @@ -233,6 +233,7 @@ Imported text fields use the first nonempty cached result run's formatting on th | Date | Change | Summary | |---|---|---| +| 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-19 | trivial | Restrict Editing: with Filling in forms enforced, inserting content controls, their Properties and Remove Content Control are refused, as in Word (it greys them out), so a form's fields stay as they are and only their content is filled in | | 2026-10-19 | trivial | Content controls: text typed over a placeholder takes the control's own run properties (`w:sdtPr/w:rPr`, ECMA-376 §17.5.2.27), as in Word, and the placeholder style is recognised by its built-in name "Placeholder Text" whatever its localized id (German Word: `Platzhaltertext`), so entries in forms from localized Word no longer stay grey | | 2026-10-18 | trivial | A Word 2003 XML or Flat OPC document, or a web page, saved under a `.doc`, `.docx` or `.rtf` name opens by what it holds, as in Word, instead of going to the Word 97-2003 reader and showing a blank page (#2853) | diff --git a/docs/gaps.md b/docs/gaps.md index a65a6d80cc..61958969df 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -106,6 +106,7 @@ Footer page-table height and body wrapping are corrected. Other pagination gaps | Date | Change | Summary | |---|---|---| +| 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 | Restrict Editing: with Filling in forms enforced, inserting content controls, their Properties and Remove Content Control are refused, as in Word (it greys them out), so a form's fields stay as they are and only their content is filled in | | 2026-10-19 | trivial | Content controls: text typed over a placeholder takes the control's own run properties (`w:sdtPr/w:rPr`, ECMA-376 §17.5.2.27), as in Word, and the placeholder style is recognised by its built-in name "Placeholder Text" whatever its localized id (German Word: `Platzhaltertext`), so entries in forms from localized Word no longer stay grey | | 2026-10-18 | trivial | Font list starts at its first row after clearing a no-match query; recent heading remains visible (#2068) | From b58348e738f9771411e6072a1b14f498d01b7b84 Mon Sep 17 00:00:00 2001 From: Mat Strange Date: Tue, 20 Oct 2026 13:26:50 +0100 Subject: [PATCH 002/407] Restrict Editing: the user name can't change under protection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `file.setAuthor` sets the name a protected document's named exception ranges are checked against, and the author new revisions and comments are recorded under. `always_allowed` waved through every `file.*` id, so on a protected document the name could be changed: an agent could take the identity of a named editor and unlock their exception range, and forge the authorship of the revisions it leaves behind. A protected document refuses the change now, with the rule Word uses for a content control's properties: stop protection first. The check is in the command itself — `file.setAuthor` is a pure command, so `check_before` never sees it — and in `check_before` for the day it isn't. Word lets the user name change under protection: it is a preference there. This project doesn't, because that identity gates what may be edited. Co-Authored-By: opencode --- crates/engine/src/cmd/file.rs | 2 ++ crates/engine/src/cmd/protect.rs | 38 ++++++++++++++++++++++++++++++++ 2 files changed, 40 insertions(+) diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index 118ed41b5c..187587c47e 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -17,7 +17,9 @@ 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. +/// 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)?; let name: String = p::req_str(v, "name")?.trim().chars().filter(|c| !c.is_control()).take(MAX_AUTHOR_CHARS).collect(); let name = name.trim_end(); if name.is_empty() { diff --git a/crates/engine/src/cmd/protect.rs b/crates/engine/src/cmd/protect.rs index 57541444ea..e07aaa9028 100644 --- a/crates/engine/src/cmd/protect.rs +++ b/crates/engine/src/cmd/protect.rs @@ -215,12 +215,26 @@ fn always_allowed(id: &str, params: &Value) -> bool { } 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(()) +} + /// What a protected document lets command `id` do before it runs: `Err` refuses it; `Ok(true)` /// lets it run as long as it only changes the exceptions (checked afterwards with /// [`outside_unchanged`]). @@ -229,6 +243,13 @@ pub(crate) fn check_before(s: &Session, id: &str, params: &Value) -> Result Date: Tue, 20 Oct 2026 13:28:20 +0100 Subject: [PATCH 003/407] Restrict Editing: a protected document's protection needs an explicit stop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The guard in `restrict` refused a protected document only when its protection had a password, so a password-less one could have its protection changed — and `file.protect {"mode": "none"}` stopped it outright. An agent could strip a user's protection silently and then edit the document freely. Two rules now, as Word has them (Stop Protection first): - The protection of a protected document doesn't change, whatever the mode. The formatting-only protection doesn't count: it stores the mode "none", and it limits the styles, not the editing. - Stopping the protection is explicit. The "none" mode is the shorthand for it, but on a protected document only `{"stop": true}` stops it — the way the pane's Stop Protection button does it — so a command that merely names a mode can't strip it. The password still guards the stop, and the formatting-only path (the mode "none" with the formatting restriction) still works. `cmd::tools::tests::protection_blocks_edits` stops the protection with `{"mode": "none"}`; it has to stop it explicitly now. Co-Authored-By: opencode --- crates/engine/src/cmd/protect.rs | 65 ++++++++++++++++++++++++++++++-- 1 file changed, 62 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/cmd/protect.rs b/crates/engine/src/cmd/protect.rs index e07aaa9028..71972079af 100644 --- a/crates/engine/src/cmd/protect.rs +++ b/crates/engine/src/cmd/protect.rs @@ -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,15 @@ 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.) + if editing_restricted(&s.doc) { + 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"); @@ -778,6 +799,42 @@ mod tests { 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); + } + /// Reading the document stays allowed under protection: the inspector without removals, the /// privacy settings, the info, and saving a version snapshot. #[test] @@ -1079,6 +1136,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); } From 9aa9eca9e827b9b00f74b9908fe4ec69b60bc3fd Mon Sep 17 00:00:00 2001 From: Mat Strange Date: Tue, 20 Oct 2026 13:35:55 +0100 Subject: [PATCH 004/407] Restrict Editing: the post-check compares the settings and the metadata MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `outside_unchanged` is the check that rolls a command back when it changed anything outside the editable regions. It compared the text, the formatting, the page setup, the document defaults and the styles — but not the document's settings, its metadata, its custom properties, the resolved flag of its comments, its list definitions or its bibliography sources. A command that changed the metadata of a protected document was accepted and never rolled back: `file.properties {"title": "Forged title"}` on a protected document with an exception changed the title and stayed. The settings, the core properties, the custom properties, the comments' resolved flags, the list definitions and the sources are compared now, so a command that changes them is rolled back like any other change outside the editable regions. The revisions and the media store stay out of it on purpose: an edit inside an exception legitimately leaves a revision, and a picture inserted there adds to the media store. The list definitions and the sources are compared like the styles: the ones the document had must be unchanged, but a new one may be added — a list or a citation inserted inside an editable region adds its definition, like applying a style adds one. Saving is unaffected: it writes the metadata, but `file.save` is a pure command and isn't post-checked, and the protection itself is changed by `review.restrict`, which is always allowed. Co-Authored-By: opencode --- crates/engine/src/cmd/protect.rs | 56 +++++++++++++++++++++++++++++++- 1 file changed, 55 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/cmd/protect.rs b/crates/engine/src/cmd/protect.rs index 71972079af..b05fc193c3 100644 --- a/crates/engine/src/cmd/protect.rs +++ b/crates/engine/src/cmd/protect.rs @@ -370,7 +370,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()) }; @@ -380,9 +380,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() @@ -835,6 +861,34 @@ mod tests { assert!(t.doc.settings.protect_formatting); } + /// The settings and the metadata of a protected document are part of what stays as it was: a + /// command that changes them is 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 rolled back, and the command says so. + let e = s.run("file.properties", &json!({"title": "Forged title"})).unwrap_err().to_string(); + assert!(e.contains("only the editable regions"), "{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] From 823684807b0cc5c04a87eb27a681f208609e9980 Mon Sep 17 00:00:00 2001 From: Mat Strange Date: Tue, 20 Oct 2026 13:41:58 +0100 Subject: [PATCH 005/407] The protection is stopped explicitly, and the docs say so The test that stopped the protection used the "none" shorthand, which a protected document now refuses; it stops it the explicit way instead, and pins that the shorthand doesn't stop it. `file.protect` says the same: it starts enforcement, and "none" doesn't stop a protected document. Co-Authored-By: opencode --- crates/engine/src/cmd/tools.rs | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/cmd/tools.rs b/crates/engine/src/cmd/tools.rs index c09a9c5349..7536ec0ef6 100644 --- a/crates/engine/src/cmd/tools.rs +++ b/crates/engine/src/cmd/tools.rs @@ -465,7 +465,7 @@ pub fn specs() -> Vec { 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())); @@ -2034,7 +2034,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()); } From 376a517cb0a4f3034b4677c37ddc08dc3141cd2f Mon Sep 17 00:00:00 2001 From: Mat Strange Date: Tue, 20 Oct 2026 13:44:43 +0100 Subject: [PATCH 006/407] Docs: the Restrict Editing gaps that are closed Co-Authored-By: opencode --- ROADMAP.md | 2 ++ docs/file-format-parity.md | 2 +- docs/gaps.md | 1 + 3 files changed, 4 insertions(+), 1 deletion(-) diff --git a/ROADMAP.md b/ROADMAP.md index 4d99711433..5269b39feb 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -141,6 +141,7 @@ Imported Word 97–2003 `.doc`/`.dot` files now ask for a supported DOCX target - 2026-10-15: Find opens Navigation on Results; editing the query restores Results after manual Headings/Pages navigation. Typed and selected-text searches retain document contents (#2096). | Date | What landed | +| 2026-10-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 user name can't change while the document is protected, 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 | Save As, Export and Open no longer do nothing on Linux desktops whose file chooser portal fails to show its dialog (#2999): a "cancel" quicker than anyone could make one asks again without the parent window, then opens the built-in file browser. | | 2026-10-18 | Long navigation headings wrap onto further lines inside the resizable pane instead of ending in an ellipsis: the row grows with the text and the pane keeps its width (#759, #3015). | @@ -507,6 +508,7 @@ Footer page-table height and body wrapping are corrected. Other pagination gaps | Date | Change | Summary | |---|---|---| +| 2026-10-20 | minor | Restrict Editing, three more gaps closed: the protection of a protected document only changes through the explicit stop, the user name can't change 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-19 | trivial | Progress log: forms protection keeps its content controls: no inserting, Properties or Remove Content Control | | 2026-10-19 | trivial | Progress log: text typed into a content control takes the control's formatting; localized placeholder styles recognised | diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index d5ad1cdd8f..fa33f12950 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -83,7 +83,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) | | 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. 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; 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 | +| 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 user name can't change while the document is protected, 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 | | 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 | ❌ | ❌ | | diff --git a/docs/gaps.md b/docs/gaps.md index 61958969df..afbfc204dd 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -106,6 +106,7 @@ Footer page-table height and body wrapping are corrected. Other pagination gaps | Date | Change | Summary | |---|---|---| +| 2026-10-20 | trivial | Restrict Editing, three more gaps closed: the protection of a protected document only changes through the explicit stop, the user name can't change under protection, and the post-check compares the settings and the metadata. No gap row (enforcement fix); 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 | Restrict Editing: with Filling in forms enforced, inserting content controls, their Properties and Remove Content Control are refused, as in Word (it greys them out), so a form's fields stay as they are and only their content is filled in | | 2026-10-19 | trivial | Content controls: text typed over a placeholder takes the control's own run properties (`w:sdtPr/w:rPr`, ECMA-376 §17.5.2.27), as in Word, and the placeholder style is recognised by its built-in name "Placeholder Text" whatever its localized id (German Word: `Platzhaltertext`), so entries in forms from localized Word no longer stay grey | From f36339ab4b617386d59c477e344d28e43ffaecd9 Mon Sep 17 00:00:00 2001 From: Mat Strange Date: Tue, 20 Oct 2026 17:20:53 +0100 Subject: [PATCH 007/407] A password guards the protection whatever its mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The guard that stops the protection of a protected document from changing asked whether the editing was restricted, so a formatting-only protection — the mode "none", which limits the styles rather than the editing — was exempted. A formatting-only protection can carry a password, and that password guarded it: it was exempt from the guard, so `{"mode": "readOnly", "password": "other"}` replaced the protection and the password with ones of the caller's choosing, and the protection could then be stopped with them. A password guards the protection whatever its mode, as it did before. Co-Authored-By: opencode --- crates/engine/src/cmd/protect.rs | 26 ++++++++++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/cmd/protect.rs b/crates/engine/src/cmd/protect.rs index b05fc193c3..bbbaae8247 100644 --- a/crates/engine/src/cmd/protect.rs +++ b/crates/engine/src/cmd/protect.rs @@ -105,8 +105,9 @@ fn restrict(s: &mut Session, v: &Value) -> CmdResult { return Ok(state(s)); } // 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.) - if editing_restricted(&s.doc) { + // (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 { @@ -861,6 +862,27 @@ mod tests { 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 rolled back, like any other change outside the editable /// regions (#1327 follow-up). From d809c838c4b10bdbabeb75ba8593589adf4fef39 Mon Sep 17 00:00:00 2001 From: Mat Strange Date: Tue, 20 Oct 2026 17:20:53 +0100 Subject: [PATCH 008/407] The CLI's --author names the session, it doesn't change the document MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `--author` dispatched `file.setAuthor`, which a protected document refuses now that the name is checked as the identity its exceptions are matched against — so a script that named itself on a protected file failed before it ran a single command. The flag names the session, not the open document, so it sets the name directly, with the same normalisation and blank-name check the command uses. `file.setAuthor` keeps its protection check for the callers that mean to change the document's identity. Co-Authored-By: opencode --- apps/wordcraft-cli/src/main.rs | 13 ++++++++++++- crates/engine/src/cmd/file.rs | 22 ++++++++++++++-------- 2 files changed, 26 insertions(+), 9 deletions(-) diff --git a/apps/wordcraft-cli/src/main.rs b/apps/wordcraft-cli/src/main.rs index 446b902fac..1a273dda42 100644 --- a/apps/wordcraft-cli/src/main.rs +++ b/apps/wordcraft-cli/src/main.rs @@ -180,8 +180,12 @@ fn listed_text(doc: &wordcraft_doc::Document) -> Result { } /// `--author NAME`: the author recorded on tracked changes and comments (`file.setAuthor`). +/// The name is set on the session directly: the caller is naming itself, not changing the open +/// document, so the protection that refuses `file.setAuthor` doesn't apply here. fn set_author(s: &mut Session, name: &str) -> Result<(), String> { - s.run("file.setAuthor", &json!({"name": name})).map(|_| ()).map_err(|e| format!("--author: {e}")) + let name = wordcraft_engine::cmd::file::normalize_author(name).map_err(|e| format!("--author: {e}"))?; + s.author = name; + Ok(()) } fn arg_value(args: &[String], name: &str) -> Option { @@ -804,6 +808,13 @@ 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 names the session, so a protected document doesn't refuse it — changing the + // document's identity is a different thing, and that is still refused. + let mut s = Session::new(wordcraft_doc::Document::new()); + s.run("review.restrict", &json!({"mode": "readOnly"})).unwrap(); + assert!(set_author(&mut s, "Claude").is_ok()); + assert_eq!(s.author, "Claude"); + assert!(s.run("file.setAuthor", &json!({"name": "Claude"})).is_err()); } #[test] diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index 187587c47e..167d0dbbc0 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -14,18 +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. -/// 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)?; - 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 { + 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)); From 3a15b19e7f2103c65bd2d10df1e31becd117f19b Mon Sep 17 00:00:00 2001 From: Mat Strange Date: Tue, 20 Oct 2026 17:20:53 +0100 Subject: [PATCH 009/407] Docs: the progress docs' status lines carry today's date Co-Authored-By: opencode --- docs/file-format-parity.md | 2 +- docs/gaps.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index fa33f12950..57cf77eb34 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File-format parity with Microsoft Word -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-19 · **Change:** 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 `
` 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-20 · **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 user name can't change under protection, and the post-check compares the settings and the metadata; 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 `
` 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; diff --git a/docs/gaps.md b/docs/gaps.md index afbfc204dd..45b44955f3 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Where WordCraft falls short of Word -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-19 · **Change:** 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-20 · **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 user name can't change under protection, and the post-check compares the settings and the metadata; previously trivial: text typed into a content control takes the control's formatting; localized placeholder styles recognised; previously trivial: forms protection keeps its content controls: no inserting, Properties or Remove Content Control; previously trivial: clearing a no-match font query keeps the recent heading visible, #2068; previously trivial: line breaking: a slash opening a word travels whole to the next line, #2963; previously trivial: recipient list search and merged-only view, #1576; previously trivial: #2 impact restated: only ink rendering and OLE editing remain; previously trivial: #2 narrowed: native ink nested in groups and drawing canvases kept on save; previously trivial: opening a DOCX no longer copies its pictures; previously trivial: RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: automatic Asian/Latin and Asian/number spacing laid out, #2358; previously trivial: hint-reliant CJK outlines; previously trivial: numbered headings, #1793; previously trivial: legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: #8 narrowed: Indonesian proofing from the installed Hunspell dictionary, #498; previously trivial: glossary/building blocks closed; previously trivial: Split Cells and Convert Text to Table follow the Word check, #2592; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717); previously trivial: #29: a copied equation also goes in the MathML clipboard formats, #1297; previously trivial: #24: the Spike, Ctrl+F3 and Ctrl+Shift+F3; previously trivial: VML freeforms read and kept on save; previously trivial: consistent preferred cell widths; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: floating-table overlap ignores empty line-break lines; previously trivial: copies keep their formatting on the clipboard as HTML, #1523; previously trivial: sample-text formulas on Enter, #1067; previously trivial: #29: copied equations as MathML, OMML open as #1297, #816; previously trivial: the Font list no longer keeps every previewed font in memory, #848; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw Table and Eraser landed, #303; previously minor: beta distance restated: ~15 points, ~150–230 h; previously: first version; every known shortfall from the 2026-10-10 re-measure, ranked) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 > **Printing update:** 2026-10-12 · direct desktop printing through craft-print; details below. From f7f78cadd6bc03d370d91534f10d1965b3f553fa Mon Sep 17 00:00:00 2001 From: Mat Strange Date: Tue, 20 Oct 2026 21:59:54 +0100 Subject: [PATCH 010/407] The document's properties and a version restore need the protection stopped Two gaps the review found, both of them a change to a protected document that went through without the protection seeing it. The properties (`file.properties`) and the privacy options (`file.privacyOptions`) are document-wide state, and they aren't tracked: under tracked changes they changed the document with no revision to show for it, and under the other modes they were only rolled back by the post-check, after the command had run. They are refused now, before the command runs, as the content control's properties are. A version restore (`file.versions`) replaces the whole document, including the content the protection kept read-only, and it did that without the password: `file.versions` is a pure command, so the protection never saw it. Keeping the live protection and its password stopped them being dropped, but the content underneath was still replaced. A restore needs the protection stopped first, and the password with it when the document has one. The live protection still wins over the snapshot's stale one, so a restore can't lock the user out of their own document either. Co-Authored-By: opencode --- crates/engine/src/cmd/protect.rs | 111 +++++++++++++++++++++++++------ crates/engine/src/cmd/tools.rs | 8 ++- 2 files changed, 98 insertions(+), 21 deletions(-) diff --git a/crates/engine/src/cmd/protect.rs b/crates/engine/src/cmd/protect.rs index bbbaae8247..610d1cccc7 100644 --- a/crates/engine/src/cmd/protect.rs +++ b/crates/engine/src/cmd/protect.rs @@ -257,6 +257,17 @@ pub(crate) fn check_user_name(s: &Session) -> Result<(), CmdError> { 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(()) +} + /// What a protected document lets command `id` do before it runs: `Err` refuses it; `Ok(true)` /// lets it run as long as it only changes the exceptions (checked afterwards with /// [`outside_unchanged`]). @@ -280,6 +291,17 @@ pub(crate) fn check_before(s: &Session, id: &str, params: &Value) -> Result 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"))), + _ => {} + } + } // Observed in Word for Mac: with Filling in forms enforced, the Developer tab's control // buttons and Properties are greyed out and a field's shortcut menu offers no Remove Content // Control. The form's fields stay as they are; only their content is filled in. @@ -770,25 +792,61 @@ mod tests { assert_eq!(s.doc, before); } - /// Restoring a stored version replaces the whole document, and a snapshot's own protection - /// is stale: the live document's protection, and its password, still hold after a restore - /// (#1327 follow-up). + /// 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_keeps_the_protection() { + 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(); - assert_eq!(s.doc.settings.protection.as_deref(), Some("readOnly")); - let hash = s.doc.settings.protect_hash.clone(); - assert!(!hash.is_empty(), "the password is set"); - + // 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_eq!(s.doc.settings.protection.as_deref(), Some("readOnly"), "the restore lifted the protection"); - assert_eq!(s.doc.settings.protect_hash, hash, "the restore dropped the password"); - // The document is still protected: an edit outside the exceptions is refused. - let before = s.doc.clone(); - assert!(s.run("text.insert", &json!({"text": "edited "})).is_err()); - assert_eq!(s.doc, before); + 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, ¶ms).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 @@ -883,9 +941,9 @@ mod tests { 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 rolled back, like any other change outside the editable - /// regions (#1327 follow-up). + /// 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.")); @@ -893,9 +951,9 @@ mod tests { 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 rolled back, and the command says so. + // 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("only the editable regions"), "{e}"); + assert!(e.contains("stop protection"), "{e}"); assert_eq!(s.doc, before); assert_ne!(s.doc.core.title, "Forged title"); // A custom property too. @@ -924,6 +982,21 @@ mod tests { } } + /// 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). diff --git a/crates/engine/src/cmd/tools.rs b/crates/engine/src/cmd/tools.rs index 7536ec0ef6..142cbc6ace 100644 --- a/crates/engine/src/cmd/tools.rs +++ b/crates/engine/src/cmd/tools.rs @@ -475,10 +475,14 @@ pub fn specs() -> Vec { } 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 lift the protection the live document is under, nor - // its password (#1327 follow-up). + // 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; From 40494a5ca7ccbcf5793609d70af5e1c2a9213327 Mon Sep 17 00:00:00 2001 From: Federico Olivares Date: Thu, 22 Oct 2026 01:53:37 -0300 Subject: [PATCH 011/407] #3360, #3553: Spanish proofing (es-ES, es-MX, es-419, es-UY) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Spanish text was never checked: ProofLang had no Spanish variant, so every es-* run fell through to Other (no marks). And the RLA-ES dictionaries LibreOffice ships were refused by the affix engine for their `☎️` flag (☎ + variation selector U+FE0F), which Hunspell reads as ☎. - ProofLang::Es (Spain, plain es, spa), EsMx (es-MX), Es419 (es-419 and the other countries of the Americas) and EsUy (es-UY), chosen by region like the English dialects - each variety reads its own installed Hunspell dictionary with fallbacks (es_ES; es_MX; es_419/es_US/es_MX; es_UY/es_AR); nothing bundled - variation selectors in affix flag fields belong to the flag before them - Uruguayan vos forms accepted when the dictionary has the infinitive: imperatives (vení, andá), with pronouns (decime, decímelo), present (tenés); a vos form typed without its accent gets it back first - suggestions use the dictionary-driven Czech ranking (prueva → prueba, corector → corrector, tambien → también) - grammar: repeated word, spacing, missing opening ¿/¡, space after an opening mark, with Spanish messages - Review › Language, status bar, Language dialog and File › Options › Proofing list the four varieties; the two new names translated in every catalog; a Spanish interface writes new documents in the region's Spanish - the dictionaries-folder engine test holds the Italian and Spanish fixtures, so parallel proofing tests no longer lose their dictionaries - tests use hand-written synthetic dictionaries; engine end-to-end tests --- ROADMAP.md | 6 +- crates/engine/src/session.rs | 12 + crates/engine/src/tests.rs | 94 +++++++- crates/proof/src/affix.rs | 41 +++- crates/proof/src/es/grammar.rs | 188 +++++++++++++++ crates/proof/src/es/mod.rs | 327 +++++++++++++++++++++++++++ crates/proof/src/es/suggest.rs | 99 ++++++++ crates/proof/src/es/test_dict.rs | 111 +++++++++ crates/proof/src/es/voseo.rs | 155 +++++++++++++ crates/proof/src/hyphen.rs | 2 +- crates/proof/src/lang.rs | 111 +++++++-- crates/proof/src/lib.rs | 69 +++++- crates/ui-egui/src/dialogs_layout.rs | 4 +- crates/ui-egui/src/i18n/bg.tsv | 2 + crates/ui-egui/src/i18n/cs.tsv | 2 + crates/ui-egui/src/i18n/de.tsv | 2 + crates/ui-egui/src/i18n/es.tsv | 2 + crates/ui-egui/src/i18n/et.tsv | 2 + crates/ui-egui/src/i18n/fr.tsv | 2 + crates/ui-egui/src/i18n/hr.tsv | 2 + crates/ui-egui/src/i18n/hu.tsv | 2 + crates/ui-egui/src/i18n/it.tsv | 2 + crates/ui-egui/src/i18n/ja.tsv | 2 + crates/ui-egui/src/i18n/ko.tsv | 2 + crates/ui-egui/src/i18n/mod.rs | 2 + crates/ui-egui/src/i18n/pl.tsv | 2 + crates/ui-egui/src/i18n/pt-br.tsv | 2 + crates/ui-egui/src/i18n/ru.tsv | 2 + crates/ui-egui/src/i18n/sr-latn.tsv | 2 + crates/ui-egui/src/i18n/sr.tsv | 2 + crates/ui-egui/src/i18n/sv.tsv | 2 + crates/ui-egui/src/i18n/tr.tsv | 2 + crates/ui-egui/src/i18n/uk.tsv | 2 + crates/ui-egui/src/i18n/vi.tsv | 2 + crates/ui-egui/src/i18n/zh-hans.tsv | 2 + crates/ui-egui/src/i18n/zh-hant.tsv | 2 + crates/ui-egui/src/lib.rs | 4 +- crates/ui-egui/src/options.rs | 4 + crates/ui-egui/src/ribbon.rs | 28 ++- docs/gaps.md | 5 +- docs/localization-parity.md | 8 +- docs/spelling-dictionaries.md | 20 +- 42 files changed, 1284 insertions(+), 50 deletions(-) create mode 100644 crates/proof/src/es/grammar.rs create mode 100644 crates/proof/src/es/mod.rs create mode 100644 crates/proof/src/es/suggest.rs create mode 100644 crates/proof/src/es/test_dict.rs create mode 100644 crates/proof/src/es/voseo.rs diff --git a/ROADMAP.md b/ROADMAP.md index 9cb35f9e94..33ac14e70c 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~15 points (60% → 75% ready for real work) and ~150–230 h away -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-21 · **Change:** trivial (personal templates in File › New and Save as Word Template, #2852; previously trivial: progress log: legacy menu keys, #1697; previously trivial: progress log: shape gradients and transparency, #1500; previously trivial: progress log: Focus fills the screen, and Full Screen View shows the pages alone, #1678; previously trivial: progress log: text above a float's anchor wraps around it, #2048; previously trivial: progress log: File › Recover follows the app theme, #3243; previously trivial: progress log: caret inside ligatures, #1974; previously trivial: text typed into a content control takes the control's formatting; localized placeholder styles recognised; previously trivial: forms protection keeps its content controls: no inserting, Properties or Remove Content Control; previously trivial: long navigation headings wrap onto further lines within the pane, #3015; previously trivial: clearing a no-match font query keeps the recent heading visible, #2068; previously trivial: a word after a slash wraps whole, like Word, #2963; previously trivial: progress log: a header-cell picture watermark sits behind the text, #1775; previously trivial: symbols keep their font when the text font changes, #2036; previously trivial: opening a DOCX no longer copies its pictures; previously trivial: Add Font imports every selected font file, #1789); previously trivial (RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: Read Aloud speaks each sentence in its own language, #1843; previously trivial: hint-reliant CJK outlines; previously trivial: Ctrl+wheel zoom no longer scrolls the document, #1743; previously trivial: progress log: Turkish case mapping in AutoCorrect, #1923; previously trivial: a mouse drag on a table leaves the view where it is, #1738; previously trivial: progress log: Hebrew field results and smart quotes, #1670; previously trivial: Backspace removes a selected table, rows or columns, #1844; previously trivial: progress log: mixed Hebrew/Latin text keeps its order in Word, #1543; previously trivial: progress log: web typing in non-Latin text no longer repeats, #1480; previously trivial: progress log: font family caches bounded and math font lookups follow font changes, #1354; previously trivial: `-->` AutoCorrect replacement, #2223; previously trivial: numbered headings, #1793; previously trivial: Navigation Find exposes Results, #2096; previously trivial: progress log: Columns on selected text, #2024; previously trivial: progress log: legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: progress log: Indonesian proofing, #498; previously trivial: progress log: DOCX glossary documents (building blocks); previously trivial: progress log: Read Mode keeps the keyboard and shows whole, unobscured columns, #1772); previously trivial: progress log: font list entries of tall-ascent fonts no longer cut off, #2570); previously trivial: progress log: Split Cells and Convert Text to Table dialogs follow the Word check, #2592; previously trivial: a new source's Tag is made like Word's, three letters of the author and two digits of the year with a number added when taken, and an edit keeps the Tag, #2094; previously trivial: Web Layout fills the window width, #1876; previously trivial: progress log: Different First Page headers reachable from page 1, #2826; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717); previously trivial: progress log: web preferences kept between visits, #2132; previously trivial: progress log: undrawable private-use list bullets show the level's default bullet, #1683; previously trivial: progress log: `w:eastAsianLayout` kept on save; previously trivial: table style editor formats every part, #714); previously trivial: VML freeforms read and kept on save; previously trivial: progress log: consistent preferred cell widths; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: progress log: grouped `flowChartOr` shapes draw their circle and crossing spokes; previously trivial: progress log: copies keep their formatting in other programs, #1523; previously trivial: progress log: privacy options on save, #1062; previously trivial: progress log: equation pictures in theme colours on Windows, #1002; previously trivial: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw tab ink, #307, and Draw Table and Eraser, #303, landed: catalog 402/434; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-22 · **Change:** minor (progress log: Spanish proofing, #3360, #3553; previously trivial: personal templates in File › New and Save as Word Template, #2852; previously trivial: progress log: legacy menu keys, #1697; previously trivial: progress log: shape gradients and transparency, #1500; previously trivial: progress log: Focus fills the screen, and Full Screen View shows the pages alone, #1678; previously trivial: progress log: text above a float's anchor wraps around it, #2048; previously trivial: progress log: File › Recover follows the app theme, #3243; previously trivial: progress log: caret inside ligatures, #1974; previously trivial: text typed into a content control takes the control's formatting; localized placeholder styles recognised; previously trivial: forms protection keeps its content controls: no inserting, Properties or Remove Content Control; previously trivial: long navigation headings wrap onto further lines within the pane, #3015; previously trivial: clearing a no-match font query keeps the recent heading visible, #2068; previously trivial: a word after a slash wraps whole, like Word, #2963; previously trivial: progress log: a header-cell picture watermark sits behind the text, #1775; previously trivial: symbols keep their font when the text font changes, #2036; previously trivial: opening a DOCX no longer copies its pictures; previously trivial: Add Font imports every selected font file, #1789); previously trivial (RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: Read Aloud speaks each sentence in its own language, #1843; previously trivial: hint-reliant CJK outlines; previously trivial: Ctrl+wheel zoom no longer scrolls the document, #1743; previously trivial: progress log: Turkish case mapping in AutoCorrect, #1923; previously trivial: a mouse drag on a table leaves the view where it is, #1738; previously trivial: progress log: Hebrew field results and smart quotes, #1670; previously trivial: Backspace removes a selected table, rows or columns, #1844; previously trivial: progress log: mixed Hebrew/Latin text keeps its order in Word, #1543; previously trivial: progress log: web typing in non-Latin text no longer repeats, #1480; previously trivial: progress log: font family caches bounded and math font lookups follow font changes, #1354; previously trivial: `-->` AutoCorrect replacement, #2223; previously trivial: numbered headings, #1793; previously trivial: Navigation Find exposes Results, #2096; previously trivial: progress log: Columns on selected text, #2024; previously trivial: progress log: legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: progress log: Indonesian proofing, #498; previously trivial: progress log: DOCX glossary documents (building blocks); previously trivial: progress log: Read Mode keeps the keyboard and shows whole, unobscured columns, #1772); previously trivial: progress log: font list entries of tall-ascent fonts no longer cut off, #2570); previously trivial: progress log: Split Cells and Convert Text to Table dialogs follow the Word check, #2592; previously trivial: a new source's Tag is made like Word's, three letters of the author and two digits of the year with a number added when taken, and an edit keeps the Tag, #2094; previously trivial: Web Layout fills the window width, #1876; previously trivial: progress log: Different First Page headers reachable from page 1, #2826; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717); previously trivial: progress log: web preferences kept between visits, #2132; previously trivial: progress log: undrawable private-use list bullets show the level's default bullet, #1683; previously trivial: progress log: `w:eastAsianLayout` kept on save; previously trivial: table style editor formats every part, #714); previously trivial: VML freeforms read and kept on save; previously trivial: progress log: consistent preferred cell widths; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: progress log: grouped `flowChartOr` shapes draw their circle and crossing spokes; previously trivial: progress log: copies keep their formatting in other programs, #1523; previously trivial: progress log: privacy options on save, #1062; previously trivial: progress log: equation pictures in theme colours on Windows, #1002; previously trivial: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw tab ink, #307, and Draw Table and Eraser, #303, landed: catalog 402/434; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 > **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-18 · **Change:** trivial (English dialects in proofing, #1281; previously trivial: triple borders draw three lines, #1740; previously trivial: progress log: double-clicking a later page's footer no longer scrolls up, #2103; previously trivial: long navigation headings wrap onto further lines within the pane, #3015; previously trivial: clearing a no-match font query keeps the recent heading visible, #2068; previously trivial: a word after a slash wraps whole, like Word, #2963; previously trivial: RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: progress log: pasted blockquotes are indented paragraphs, #1270; previously trivial: progress log: print-area strings translated in every catalog; previously trivial: progress log: comments readable in the dark theme, #2045; previously trivial: progress log: ODT sections, #1379; previously trivial: progress log: table deletes, Change Case and Unlink Fields respect Track Changes and locked content controls, #1329; previously trivial: progress log: rounded rectangle corners, #1476; previously trivial: progress log: Save As type list, #1399; previously trivial: progress log: privacy options on save, #1062; previously trivial: progress log: equation pictures in theme colours on Windows, #1002; previously trivial: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw tab ink, #307, and Draw Table and Eraser, #303, landed: catalog 402/434; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 > **Printing update:** 2026-10-12 · direct desktop printing through craft-print; details below. @@ -116,7 +116,7 @@ Also shipped: Traditional Chinese (`zh-hant`), Ukrainian (`uk`), Serbian (`sr`, system-locale selection and a saved language choice (Italian also has 51 strings none of the others had yet); Estonian, Turkish and Italian proofing are not included. Hungarian (`hu`) has a complete 2,419-entry catalog, also without proofing. Vietnamese (`vi`, #1434) has a complete 2,448-entry catalog, also without proofing. -Proofing: English, Polish, Portuguese and Russian (spelling, grammar, hyphenation), plus Czech and Indonesian (system Hunspell spelling, generic checks), chosen per run from its language. Word ships +Proofing: English, Polish, Portuguese and Russian (spelling, grammar, hyphenation), plus Czech, Indonesian and Spanish (system Hunspell spelling, generic checks; Spanish per variety: Spain, Mexico, Latin America, Uruguay), chosen per run from its language. Word ships 30 interface languages and proofing for ~50. Detail: [docs/localization-parity.md](docs/localization-parity.md). ## Upcoming @@ -136,6 +136,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm Imported Word 97–2003 `.doc`/`.dot` files now ask for a supported DOCX target on interactive Save (#2445). Save before Close waits for that write; cancel or failure keeps the draft and original file. This does not add binary Word output. Late Save As answers are rejected before writing if automation has replaced the document. +- 2026-10-22: Spanish proofing (#3360, #3553): text in Spanish was never checked (it fell through to the languages without tools), and the RLA-ES dictionaries LibreOffice ships were refused for their `☎️` flag. Now Spain (`es-ES`), Mexico (`es-MX`), Latin America (`es-419` and the other countries of the Americas) and Uruguay (`es-UY`) each read their own installed dictionary, with fallbacks; suggestions restore accents first (`prueva` → `prueba`, `tambien` → `también`); Uruguayan Spanish accepts the `vos` forms, imperatives included (`vení`, `decime`, `decímelo`); repeated-word, spacing and missing `¿`/`¡` checks explain in Spanish; Review › Language, the status bar and File › Options › Proofing list the four varieties, and a Spanish interface writes new documents in the region's Spanish. No dictionary data bundled; parity estimates unchanged - 2026-10-21: File › Recover follows the app theme (#3243, part of #2348): no black page or forced white text on the light theme - 2026-10-19: Restrict Editing: with Filling in forms enforced, inserting content controls, their Properties and Remove Content Control are refused, as in Word (it greys them out), so a form's fields stay as they are and only their content is filled in. - 2026-10-19: Content controls: text typed over a placeholder takes the control's own run properties (`w:sdtPr/w:rPr`, ECMA-376 §17.5.2.27), as in Word, and the placeholder style is recognised by its built-in name "Placeholder Text" whatever its localized id (German Word: `Platzhaltertext`), so entries in forms from localized Word no longer stay grey. @@ -517,6 +518,7 @@ Footer page-table height and body wrapping are corrected. Other pagination gaps | Date | Change | Summary | |---|---|---| +| 2026-10-22 | minor | Progress log: Spanish proofing (#3360, #3553) | | 2026-10-21 | trivial | Progress log: File › Recover follows the app theme (#3243) | | 2026-10-19 | trivial | Progress log: forms protection keeps its content controls: no inserting, Properties or Remove Content Control | | 2026-10-19 | trivial | Progress log: text typed into a content control takes the control's formatting; localized placeholder styles recognised | diff --git a/crates/engine/src/session.rs b/crates/engine/src/session.rs index 864ec0b729..b918867654 100644 --- a/crates/engine/src/session.rs +++ b/crates/engine/src/session.rs @@ -1121,6 +1121,18 @@ impl Session { .and_then(|t| wordcraft_proof::lang::normalize_tag(&t)) } + /// The Spanish of the region in effect (`es-MX` on a Mexican system, `es-UY` on a Uruguayan + /// one), or Spain's (`es-ES`) when the region's language isn't Spanish or it names no + /// territory. A Spanish interface writes new documents in it, so they are proofed in the + /// writer's Spanish from the first word (#3360). + pub fn region_spanish(&self) -> String { + let r = self.region(); + (r.language == "es" && !r.territory.is_empty()) + .then(|| format!("es-{}", r.territory)) + .and_then(|t| wordcraft_proof::lang::normalize_tag(&t)) + .unwrap_or_else(|| "es-ES".into()) + } + /// The unit rulers and dialogs show lengths in: the user's choice, else the region's. pub fn units(&self) -> wordcraft_geom::Unit { self.prefs.units.unwrap_or_else(|| self.region().units()) diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index afc7eaad6b..71f944fd3a 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -6441,6 +6441,10 @@ fn dictionaries_folder_option_is_saved_searched_and_reported() { ("pl_PL", POLISH_TEST_AFF, POLISH_TEST_DIC), ("pt_BR", PORTUGUESE_TEST_AFF, PORTUGUESE_TEST_DIC), ("id_ID", INDONESIAN_TEST_AFF, INDONESIAN_TEST_DIC), + // The others the tests install, so reloading from the folder keeps their words. + ("it_IT", ITALIAN_TEST_AFF, ITALIAN_TEST_DIC), + ("es_ES", SPANISH_TEST_AFF, SPANISH_TEST_DIC), + ("es_UY", SPANISH_TEST_AFF, URUGUAYAN_TEST_DIC), ] { std::fs::write(dir.join(format!("{name}.aff")), aff).unwrap(); std::fs::write(dir.join(format!("{name}.dic")), dic).unwrap(); @@ -6459,6 +6463,11 @@ fn dictionaries_folder_option_is_saved_searched_and_reported() { assert_eq!(file("pl"), Some(json!(dir.join("pl_PL.dic").to_string_lossy()))); assert_eq!(file("pt"), Some(json!(dir.join("pt_BR.dic").to_string_lossy()))); assert_eq!(file("id"), Some(json!(dir.join("id_ID.dic").to_string_lossy()))); + // Each Spanish variety reports its own file (Spain, Mexico, Latin America, Uruguay, in order). + let spanish: Vec = dicts.iter().filter(|d| d["language"] == "es").map(|d| d["file"].clone()).collect(); + assert_eq!(spanish.len(), 4); + assert_eq!(spanish.first(), Some(&json!(dir.join("es_ES.dic").to_string_lossy()))); + assert_eq!(spanish.last(), Some(&json!(dir.join("es_UY.dic").to_string_lossy()))); // Polish text is checked against the folder's dictionary. run(&mut s, "document.setText", json!({"text": "rzeka płynie żeka"})); run(&mut s, "select.all", json!({})); @@ -6651,6 +6660,84 @@ fn new_documents_follow_the_regions_english() { assert_eq!(s.region_english(), None); } +/// A Spanish interface writes new documents in the region's Spanish (#3360): Uruguay's on a +/// Uruguayan system, Mexico's on a Mexican one, Spain's elsewhere. +#[test] +fn new_documents_follow_the_regions_spanish() { + let mut s = s(); + s.system_region = wordcraft_geom::Region::from_tag("es_UY.UTF-8"); + assert_eq!(s.region_spanish(), "es-UY"); + s.set_editing_language(Some(&s.region_spanish())); + assert_eq!(s.doc.styles.default_chr.lang.as_deref(), Some("es-UY"), "the untouched document follows"); + for (tag, spanish) in [("es-MX", "es-MX"), ("es-419", "es-419"), ("es-ES", "es-ES"), ("en-US", "es-ES"), ("fr-FR", "es-ES"), ("auto", "es-UY")] { + run(&mut s, "file.options", json!({"region": tag})); + assert_eq!(s.region_spanish(), spanish, "{tag}"); + } +} + +// Hand-written synthetic fixtures (no system dictionary data): Spain's has `vosotros` forms, +// Uruguay's has `vos` ones' verbs only (the forms come from the voseo rules). +const SPANISH_TEST_AFF: &str = + "SET UTF-8\nFLAG UTF-8\nTRY aeosrnidlctumpbgvyqhfzjñáéíóú\nMAP 2\nMAP eé\nMAP ií\nREP 2\nREP v b\nREP b v\nSFX S Y 1\nSFX S 0 s [aeiou]\n"; +const SPANISH_TEST_DIC: &str = "10\nesto\nes\nuna\nprueba/S\ndel\ncorrector\ntambién\nhabéis\nvenido\ndecir\n"; +const URUGUAYAN_TEST_DIC: &str = "10\nesto\nes\nuna\nprueba/S\ndel\ncorrector\ntambién\ntener\nvenir\ndecir\n"; + +fn spanish_test_dictionaries() { + use wordcraft_proof::ProofLang; + for (lang, dic) in [(ProofLang::Es, SPANISH_TEST_DIC), (ProofLang::EsUy, URUGUAYAN_TEST_DIC)] { + wordcraft_proof::es::install_dictionary(lang, SPANISH_TEST_AFF, dic).unwrap(); + } +} + +/// #3553: Spanish proofing through the same commands as Review, F7, Change and Add to +/// Dictionary: `prueva` and `corector` are flagged with `prueba` and `corrector` offered. +#[test] +fn spanish_proofing_through_the_review_commands() { + spanish_test_dictionaries(); + let mut s = s(); + run(&mut s, "document.setText", json!({"text": "esto es una prueva del corector. Habéis venido?"})); + run(&mut s, "select.all", json!({})); + run(&mut s, "review.language", json!({"lang": "es_es", "default": true})); + assert_eq!(s.doc.styles.default_chr.lang.as_deref(), Some("es-ES")); + let issues = run(&mut s, "review.issues", json!({})); + let arr = issues.as_array().unwrap(); + assert!(arr.iter().all(|i| i["lang"] == "es"), "{issues}"); + let has = |text: &str, kind: &str, sugg: &str| { + arr.iter().any(|i| i["text"] == text && i["kind"] == kind && i["suggestions"].as_array().unwrap().iter().any(|x| x == sugg)) + }; + assert!(has("prueva", "spelling", "prueba"), "{issues}"); + assert!(has("corector", "spelling", "corrector"), "{issues}"); + assert!(has("Habéis venido?", "grammar", "¿Habéis venido?"), "{issues}"); + assert_eq!(arr.len(), 3, "{issues}"); + assert!(arr.iter().any(|i| i["message"] == "Posible error ortográfico"), "{issues}"); + run(&mut s, "caret.docStart", json!({})); + assert_eq!(run(&mut s, "review.spelling", json!({}))["text"], "prueva"); + run(&mut s, "review.applySuggestion", json!({"text": "prueba"})); + assert!(text(&s).starts_with("esto es una prueba del"), "{}", text(&s)); + run(&mut s, "review.addToDictionary", json!({"word": "corector"})); + let left = run(&mut s, "review.issues", json!({})); + assert_eq!(left.as_array().unwrap().len(), 1, "{left}"); +} + +/// Uruguayan Spanish accepts the `vos` forms, imperatives included, that Spain's flags. +#[test] +fn uruguayan_spanish_accepts_vos_forms() { + spanish_test_dictionaries(); + let mut s = s(); + run(&mut s, "document.setText", json!({"text": "Vení, decime qué tenés."})); + run(&mut s, "select.all", json!({})); + run(&mut s, "review.language", json!({"lang": "es-UY"})); + assert_eq!(run(&mut s, "format.state", json!({}))["lang"], "es-UY"); + let spelling = |s: &mut Session| -> Vec { + let issues = run(s, "review.issues", json!({})); + issues.as_array().unwrap().iter().filter(|i| i["kind"] == "spelling").filter_map(|i| i["text"].as_str().map(String::from)).collect() + }; + assert_eq!(spelling(&mut s), ["qué"], "only the word the fixture lacks"); + run(&mut s, "select.all", json!({})); + run(&mut s, "review.language", json!({"lang": "es-ES"})); + assert_eq!(spelling(&mut s), ["Vení", "decime", "qué", "tenés"]); +} + /// Czech proofing through the same commands as Review, F7, Change and Add to Dictionary. #[test] fn czech_proofing_through_the_review_commands() { @@ -6690,10 +6777,11 @@ fn czech_proofing_through_the_review_commands() { /// Review › Language and the proofing commands work in Polish end to end: the language makes /// the document's words Polish, F7 and the issue list use the Polish dictionary and grammar, /// suggestions come from it, and a suggestion fixes the text. +const ITALIAN_TEST_AFF: &str = "SET UTF-8\nTRY aeiortnsclcaffèéè\nMAP 2\nMAP aà\nMAP eéè\nREP 1\nREP caffe caffè\n"; +const ITALIAN_TEST_DIC: &str = "6\ncasa\ncaffè\nroma\ndisse\ne\nper\n"; + fn italian_test_dictionary() { - let aff = "SET UTF-8\nTRY aeiortnsclcaffèéè\nMAP 2\nMAP aà\nMAP eéè\nREP 1\nREP caffe caffè\n"; - let dic = "6\ncasa\ncaffè\nroma\ndisse\ne\nper\n"; - wordcraft_proof::it::install_dictionary(aff, dic).unwrap(); + wordcraft_proof::it::install_dictionary(ITALIAN_TEST_AFF, ITALIAN_TEST_DIC).unwrap(); } /// Review › Language and the proofing commands work in Italian end to end: the language makes diff --git a/crates/proof/src/affix.rs b/crates/proof/src/affix.rs index 1cbf3d1df6..9bd6c1f8aa 100644 --- a/crates/proof/src/affix.rs +++ b/crates/proof/src/affix.rs @@ -11,7 +11,8 @@ //! also be stored in WordCraft's compact format ([`AffixDict::to_bytes`]): the rules as text //! lines and the stems front-coded, raw-deflated together (the Polish dictionary is under 1 MB). //! -//! Supported: one-character flags (at most 128 distinct, any Unicode character — `FLAG UTF-8`), +//! Supported: one-character flags (at most 128 distinct, any Unicode character — `FLAG UTF-8`; +//! a variation selector after one, as in the Spanish dictionaries' `☎️`, belongs to it), //! `PFX`/`SFX` with strip, append and condition (`.`, letters, `[abc]`, `[^abc]`), cross //! products, continuation classes on suffixes (at most two suffixes in a row, as in Hunspell), //! `FORBIDDENWORD`, `NOSUGGEST`, and the suggestion tables `TRY`, `KEY`, `MAP` and `REP`. Rejected: compounding, @@ -261,6 +262,18 @@ struct Rules { /// A rule as parsed from text, before flags become bits: `(flag, cross, strip, add, cont, cond)`. type RawRule = (char, bool, String, String, String, String); +/// Is `c` a Unicode variation selector (U+FE00–U+FE0F)? It only picks how the character before +/// it is drawn, so in a flag field it belongs to that flag: the RLA-ES Spanish dictionaries write +/// a flag as `☎️` (`☎` and U+FE0F), which Hunspell reads as `☎` (#3360). +fn is_variation_selector(c: char) -> bool { + ('\u{FE00}'..='\u{FE0F}').contains(&c) +} + +/// The flag characters of a flag field, without [variation selectors](is_variation_selector). +fn flag_chars(field: &str) -> impl Iterator + '_ { + field.chars().filter(|&c| !is_variation_selector(c)) +} + /// `0` means empty in Hunspell's strip and append fields. fn zero_empty(s: &str) -> String { if s == "0" { String::new() } else { s.to_string() } @@ -290,7 +303,7 @@ fn parse_aff(aff: &str) -> Result { } } directive @ ("FORBIDDENWORD" | "NOSUGGEST") => { - let mut it = at(1).chars(); + let mut it = flag_chars(at(1)); match (it.next(), it.next()) { (Some(c), None) if !c.is_whitespace() => { if directive == "FORBIDDENWORD" { @@ -320,8 +333,8 @@ fn parse_aff(aff: &str) -> Result { } "PFX" | "SFX" => { let suffix = dir == "SFX"; - let mut flag_chars = at(1).chars(); - let (Some(flag), None) = (flag_chars.next(), flag_chars.next()) else { + let mut flag_field = flag_chars(at(1)); + let (Some(flag), None) = (flag_field.next(), flag_field.next()) else { return Err(format!("line {}: flag `{}` is not one character", n + 1, at(1))); }; // Only a header ignores trailing fields; a rule condition beginning with # is literal. @@ -341,7 +354,7 @@ fn parse_aff(aff: &str) -> Result { let cond = if f.len() >= 5 { at(4) } else { "." }; parse_cond(cond).map_err(|e| format!("line {}: {e}", n + 1))?; let c = cross.get(&(suffix, flag)).copied().unwrap_or(false); - let rule: RawRule = (flag, c, zero_empty(at(2)), zero_empty(add), cont.to_string(), cond.to_string()); + let rule: RawRule = (flag, c, zero_empty(at(2)), zero_empty(add), flag_chars(cont).collect(), cond.to_string()); if suffix { r.suffixes.push(rule); } else { @@ -377,7 +390,9 @@ impl AffixDict { let mut in_flags = false; while let Some(c) = chars.next() { if in_flags { - flags.push(c); + if !is_variation_selector(c) { + flags.push(c); + } } else if c == '\\' && chars.peek() == Some(&'/') { word.push('/'); chars.next(); @@ -1079,6 +1094,20 @@ pub(crate) mod tests { assert!(AffixDict::from_hunspell("FLAG UTF-8\n", "0\n", &[]).is_ok(), "FLAG alone is harmless"); } + /// #3360: the RLA-ES Spanish dictionaries write one flag as `☎️` (`☎` and the variation + /// selector U+FE0F) in the `.aff` and in the `.dic`; it is the flag `☎`, as Hunspell reads it, + /// not a two-character flag that refuses the whole dictionary. + #[test] + fn a_variation_selector_belongs_to_its_flag() { + let aff = "SET UTF-8\nFLAG UTF-8\nFORBIDDENWORD ✖\u{FE0F}\nSFX \u{260E}\u{FE0F} Y 1\nSFX \u{260E}\u{FE0F} 0 s/A\u{FE0F} .\nSFX A Y 1\nSFX A 0 x .\n"; + let d = AffixDict::from_hunspell(aff, "3\ncasa/\u{260E}\u{FE0F}\nmesa/\u{260E}\nzq/✖\u{FE0F}\n", &[]).unwrap(); + assert!(d.contains("casa") && d.contains("casas") && d.contains("casasx")); + assert!(d.contains("mesas"), "the bare flag is the same flag"); + assert!(!d.contains("zq"), "FORBIDDENWORD with a selector still forbids"); + assert_eq!(d.flag_chars.len(), 3, "{:?}", d.flag_chars); + assert!(AffixDict::from_hunspell("SFX ab Y 1\nSFX ab 0 s .\n", "0\n", &[]).is_err(), "two flag characters are still refused"); + } + #[test] fn more_than_64_distinct_flags_load() { // VERO pt_BR uses 106 distinct flags (FLAG UTF-8). Build one with every basic Latin diff --git a/crates/proof/src/es/grammar.rs b/crates/proof/src/es/grammar.rs new file mode 100644 index 0000000000..a22a6ff879 --- /dev/null +++ b/crates/proof/src/es/grammar.rs @@ -0,0 +1,188 @@ +//! Spanish grammar checks with Spanish messages: the generic repeated-word and spacing checks, +//! and the opening question and exclamation marks Spanish writes before a question or an +//! exclamation (`¿Vienes?`, `¡Qué bien!`): a closing `?` or `!` whose sentence has no opening mark +//! is flagged, with the opening mark offered at the sentence's start, and so is a space after an +//! opening mark (`¿ Vienes?`). Either opening mark may pair with either closing one (`¡Qué +//! dices?`), and `(?)` and `(!)`, which mark doubt or surprise, stand alone. No other +//! Spanish-specific grammar, comma or sentence-capital rules are inferred. + +use crate::{Issue, IssueKind, ProofLang, words}; + +fn issue(start: usize, end: usize, message: String, suggestions: Vec, lang: ProofLang) -> Issue { + Issue { start, end, kind: IssueKind::Grammar, message, suggestions, lang } +} + +/// Grammar issues in a paragraph of Spanish text; `lang` is the variety (they explain the same in +/// every one). +pub fn check(text: &str, lang: ProofLang) -> Vec { + let mut issues = Vec::new(); + repeated_words(text, &words(text), lang, &mut issues); + spaces(text, lang, &mut issues); + opening_marks(text, lang, &mut issues); + issues.sort_by_key(|i| (i.start, i.end)); + issues +} + +fn repeated_words(text: &str, ws: &[(usize, usize)], lang: ProofLang, v: &mut Vec) { + for pair in ws.windows(2) { + let [(a0, b0), (a1, b1)] = pair else { continue }; + let (Some(w0), Some(w1), Some(gap)) = (text.get(*a0..*b0), text.get(*a1..*b1), text.get(*b0..*a1)) else { continue }; + if !gap.is_empty() && gap.chars().all(char::is_whitespace) && w0.chars().all(char::is_alphabetic) && w0.to_lowercase() == w1.to_lowercase() { + v.push(issue(*a0, *b1, format!("Palabra repetida: «{w1}»"), vec![w0.to_string()], lang)); + } + } +} + +fn spaces(text: &str, lang: ProofLang, v: &mut Vec) { + for (i, c) in text.char_indices() { + // A space after an opening mark: `¿ Vienes?`. + if matches!(c, '¿' | '¡') + && text.get(i + c.len_utf8()..).is_some_and(|r| r.starts_with(' ') && r.trim_start().chars().next().is_some_and(char::is_alphanumeric)) + { + v.push(issue(i, i + c.len_utf8() + 1, "Espacio después de un signo de apertura".into(), vec![c.to_string()], lang)); + continue; + } + if c != ' ' { + continue; + } + let rest = text.get(i + 1..).unwrap_or(""); + let mut it = rest.chars(); + let Some(p) = it.next() else { continue }; + let after = it.next(); + // A ratio/score keeps the spaces in digit-space-colon-space-digit. + let ratio = p == ':' + && text.get(..i).and_then(|before| before.chars().next_back()).is_some_and(|c| c.is_ascii_digit()) + && after == Some(' ') + && it.next().is_some_and(|c| c.is_ascii_digit()); + let flag = !ratio + && match p { + ',' => true, + // `;)` and `;-)` are emoticons. + ';' => !matches!(after, Some(')' | '(' | '-' | 'P' | 'D')), + // ` ...` is an ellipsis, ` .5` a number. + '.' => !matches!(after, Some('.')) && !after.is_some_and(|a| a.is_ascii_digit()), + // ` :)`, ` :-)`, ` :D` and similar are emoticons; ` ?!` is still a stray space. + ':' | '!' | '?' => after.is_none_or(|a| a.is_whitespace() || matches!(a, '"' | '”' | '’' | '»' | '!' | '?')), + _ => false, + }; + if flag { + v.push(issue(i, i + 1 + p.len_utf8(), "Espacio antes de un signo de puntuación".into(), vec![p.to_string()], lang)); + } + } + for (i, _) in text.match_indices(" ") { + if i > 0 && text.get(..i).is_some_and(|t| t.ends_with(' ')) { + continue; + } + v.push(issue(i, i + 2, "Espacio doble".into(), vec![" ".into()], lang)); + } +} + +/// Characters a question or exclamation can't reach back past: sentence ends, clause breaks, +/// line breaks, and the start of a quotation or parenthesis. +fn is_boundary(c: char) -> bool { + matches!(c, '.' | '?' | '!' | '…' | ':' | ';' | '\n' | '\r' | '\t' | '«' | '“' | '"' | '(' | '[' | '—') +} + +/// A closing `?` or `!` whose sentence has no opening `¿` or `¡`. +fn opening_marks(text: &str, lang: ProofLang, v: &mut Vec) { + for (i, c) in text.char_indices() { + let (opener, message) = match c { + '?' => ('¿', "Falta el signo de interrogación de apertura (¿)"), + '!' => ('¡', "Falta el signo de exclamación de apertura (¡)"), + _ => continue, + }; + let before = text.get(..i).unwrap_or(""); + let prev = before.chars().next_back(); + let next = text.get(i + 1..).and_then(|r| r.chars().next()); + // `?!` and `!!!` are handled at their first mark; `(?)` stands alone; `?q=` and `a!b` + // aren't sentence ends. + if matches!(prev, Some('?' | '!')) || (prev == Some('(') && next == Some(')')) || next.is_some_and(char::is_alphanumeric) { + continue; + } + let from = before.char_indices().rev().find(|&(_, b)| is_boundary(b) || matches!(b, '¿' | '¡')); + if from.is_some_and(|(_, b)| matches!(b, '¿' | '¡')) { + continue; + } + let start = from.map_or(0, |(j, b)| j + b.len_utf8()); + let Some(sentence) = text.get(start..i) else { continue }; + let lead = sentence.len() - sentence.trim_start().len(); + let start = start + lead; + let Some(sentence) = text.get(start..i + 1) else { continue }; + if !sentence.chars().any(char::is_alphabetic) { + continue; + } + v.push(issue(start, i + 1, message.into(), vec![format!("{opener}{sentence}")], lang)); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn messages(text: &str) -> Vec<(String, String)> { + check(text, ProofLang::Es).into_iter().map(|i| (text[i.start..i.end].to_string(), i.message)).collect() + } + + #[test] + fn repeated_words_and_spaces_have_spanish_messages() { + let text = "Es es la casa , dijo."; + let issues = check(text, ProofLang::EsMx); + assert_eq!(issues.len(), 3, "{issues:?}"); + assert_eq!(issues[0].message, "Palabra repetida: «es»"); + assert_eq!(issues[0].suggestions, ["Es"]); + assert!(issues.iter().any(|i| i.message == "Espacio antes de un signo de puntuación" && &text[i.start..i.end] == " ,")); + assert!(issues.iter().any(|i| i.message == "Espacio doble")); + assert!(issues.iter().all(|i| i.lang == ProofLang::EsMx && i.kind == IssueKind::Grammar)); + for text in ["La casa. Es grande", "Sr. García", "No, no lo sé", "Hola :) número .5 y puntos ...", "3 : 1", "teórico-práctico"] { + assert!(check(text, ProofLang::Es).is_empty(), "{text}: {:?}", check(text, ProofLang::Es)); + } + } + + #[test] + fn questions_and_exclamations_need_their_opening_mark() { + assert_eq!(messages("Vienes mañana?"), [("Vienes mañana?".into(), "Falta el signo de interrogación de apertura (¿)".into())]); + assert_eq!(check("Vienes mañana?", ProofLang::Es)[0].suggestions, ["¿Vienes mañana?"]); + assert_eq!(messages("Llegó. Qué bien!"), [("Qué bien!".into(), "Falta el signo de exclamación de apertura (¡)".into())]); + assert_eq!(messages("Le preguntó: vienes?")[0].0, "vienes?"); + assert_eq!(messages("Dijo «vienes?»")[0].0, "vienes?"); + assert_eq!(messages("Hola!!!").len(), 1); + assert_eq!(messages("Qué?!").len(), 1); + for text in [ + "¿Vienes mañana?", + "¡Qué bien!", + "Si vienes, ¿me llamas?", + "¡Qué dices?", + "¿Qué?! ¡No!", + "Nació en 1950 (?) en Montevideo.", + "Lo dijo (!) en público.", + "Ver https://example.com/?q=1 y a!b", + "¿Qué? ¿Cómo?", + "2 + 2 = ?", + "", + "?", + ] { + assert!(messages(text).iter().all(|(_, m)| !m.starts_with("Falta")), "{text}: {:?}", messages(text)); + } + } + + #[test] + fn a_space_after_an_opening_mark_is_flagged() { + let text = "¿ Vienes? ¡ Hola!"; + let issues = check(text, ProofLang::EsUy); + let spaced: Vec<&str> = + issues.iter().filter(|i| i.message == "Espacio después de un signo de apertura").map(|i| &text[i.start..i.end]).collect(); + assert_eq!(spaced, ["¿ ", "¡ "], "{issues:?}"); + assert!(issues.iter().all(|i| !i.message.starts_with("Falta")), "{issues:?}"); + assert!(check("¿ ", ProofLang::Es).is_empty()); + } + + proptest::proptest! { + #[test] + fn never_panics_and_ranges_are_valid(s in "\\PC{0,80}|[¿¡?!a-z .,(«»):]{0,40}") { + for issue in check(&s, ProofLang::Es) { + proptest::prop_assert!(issue.start <= issue.end && issue.end <= s.len()); + proptest::prop_assert!(s.is_char_boundary(issue.start) && s.is_char_boundary(issue.end)); + } + } + } +} diff --git a/crates/proof/src/es/mod.rs b/crates/proof/src/es/mod.rs new file mode 100644 index 0000000000..2a0f98dc50 --- /dev/null +++ b/crates/proof/src/es/mod.rs @@ -0,0 +1,327 @@ +//! Spanish proofing (#3360, #3553) using the installed Hunspell dictionaries (never bundled; +//! LibreOffice ships them as its `dict-es` extension, Debian and Ubuntu as `hunspell-es`). Each +//! variety reads its own dictionary: Spain ([`ProofLang::Es`]) `es_ES`, Mexico +//! ([`ProofLang::EsMx`]) `es_MX`, Latin America ([`ProofLang::Es419`]) `es_419` and Uruguay +//! ([`ProofLang::EsUy`]) `es_UY`, each falling back to a nearby or generic one. Uruguayan Spanish +//! also accepts the `vos` forms of every verb in the dictionary ([`voseo`]: `tenés`, `vení`, +//! `decime`). Suggestions use the dictionary's own MAP/REP/TRY/KEY tables (the regional +//! dictionaries' REP tables carry their differences, such as `vosotros` forms in Mexico). +//! Grammar is limited to generic repeated-word and spacing checks and the Spanish opening +//! question and exclamation marks, with Spanish messages; hyphenation keeps the English fallback +//! (no Spanish patterns are bundled). + +pub mod grammar; +mod suggest; +#[cfg(test)] +pub(crate) mod test_dict; +pub mod voseo; + +use std::sync::{Arc, RwLock}; + +use crate::ProofLang; +use crate::affix::AffixDict; + +pub use suggest::suggest; + +/// The dictionary of each variety (Spain, Mexico, Latin America, Uruguay): `None` until first +/// use, then the installed one (or an empty one). +static DICTS: [RwLock>>; 4] = [const { RwLock::new(None) }; 4]; + +/// File names of a Spanish Hunspell dictionary for Spain, most specific first (`es_ANY` is the +/// generic one RLA-ES publishes). +const SPAIN: &[&str] = &["es_ES", "es-ES", "es_ANY", "es"]; +/// For Mexico: its own, then a Latin American or generic one. +const MEXICO: &[&str] = &["es_MX", "es-MX", "es_419", "es-419", "es_ANY", "es"]; +/// For Latin America: its own or a generic one, then the United States' and Mexico's, which +/// LibreOffice ships (it has no `es_419`) and which use Latin American spelling. +const LATIN_AMERICA: &[&str] = &["es_419", "es-419", "es_ANY", "es", "es_US", "es_MX"]; +/// For Uruguay: its own, then Argentina's (the same Rioplatense Spanish), then a Latin American +/// or generic one. +const URUGUAY: &[&str] = &["es_UY", "es-UY", "es_AR", "es-AR", "es_419", "es-419", "es_ANY", "es"]; + +/// The slot of `lang`'s dictionary in [`DICTS`] and its file names; Spain for any other language. +fn variety(lang: ProofLang) -> (usize, &'static [&'static str]) { + match lang { + ProofLang::EsMx => (1, MEXICO), + ProofLang::Es419 => (2, LATIN_AMERICA), + ProofLang::EsUy => (3, URUGUAY), + _ => (0, SPAIN), + } +} + +/// The variety's English name, for the log. +fn variety_name(lang: ProofLang) -> &'static str { + match lang { + ProofLang::EsMx => "Spanish (Mexico)", + ProofLang::Es419 => "Spanish (Latin America)", + ProofLang::EsUy => "Spanish (Uruguay)", + _ => "Spanish (Spain)", + } +} + +/// The Hunspell name each variety's dictionary is known by (`es_ES`, `es_MX`, `es_419`, +/// `es_UY`): the first of its file names, used for the status-bar hint and load errors. +pub fn dictionary_name(lang: ProofLang) -> &'static str { + variety(lang).1.first().copied().unwrap_or("es_ES") +} + +/// The Spanish dictionary of `lang`'s variety: the installed one (read on first use), one given +/// with [`install_dictionary`], or an empty one when none is available (spelling is off then; +/// see [`has_dictionary`]). +pub fn dictionary(lang: ProofLang) -> Arc { + let Some(slot) = DICTS.get(variety(lang).0) else { return Arc::new(AffixDict::default()) }; + if let Some(d) = slot.read().unwrap_or_else(|e| e.into_inner()).as_ref() { + return d.clone(); + } + let loaded = Arc::new(load_installed(lang)); + let mut slot = slot.write().unwrap_or_else(|e| e.into_inner()); + // Another thread (or an install) may have filled it meanwhile: that one wins. + slot.get_or_insert(loaded).clone() +} + +fn load_installed(lang: ProofLang) -> AffixDict { + let key = dictionary_name(lang); + let Some((aff, dic)) = crate::hunspell::find(variety(lang).1) else { + crate::hunspell::clear_load_error(key); + log::info!("{} spelling: no Hunspell {key} dictionary installed", variety_name(lang)); + return AffixDict::default(); + }; + crate::hunspell::note_load(key, &dic, crate::hunspell::load(&aff, &dic)).unwrap_or_else(|e| { + log::warn!("{} spelling: {e}", variety_name(lang)); + AffixDict::default() + }) +} + +/// Use this dictionary for `lang`'s variety from now on (Hunspell `.aff` and `.dic` text, +/// decoded to UTF-8), for example one the user picked. On error the current dictionary stays. +pub fn install_dictionary(lang: ProofLang, aff: &str, dic: &str) -> Result<(), String> { + let d = AffixDict::from_hunspell(aff, dic, &[])?; + if let Some(slot) = DICTS.get(variety(lang).0) { + *slot.write().unwrap_or_else(|e| e.into_inner()) = Some(Arc::new(d)); + } + Ok(()) +} + +/// Forget every variety's loaded dictionary, so the next check looks for an installed one again +/// (after [`crate::hunspell::set_dictionary_dir`] or a dictionary was installed). +pub fn reload_dictionary() { + for slot in &DICTS { + *slot.write().unwrap_or_else(|e| e.into_inner()) = None; + } +} + +/// The `.dic` file of the Hunspell dictionary `lang`'s variety finds in the dictionaries folder +/// or the system locations now (File › Options › Proofing shows it); `None` when there is none. +/// A dictionary given with [`install_dictionary`] has no file and isn't reported. +pub fn installed_path(lang: ProofLang) -> Option { + crate::hunspell::find(variety(lang).1).map(|(_, dic)| dic) +} + +/// Is a dictionary available for `lang`'s variety (spelling is checked)? +pub fn has_dictionary(lang: ProofLang) -> bool { + !dictionary(lang).is_empty() +} + +/// Is `c` a letter of the Latin script (the only one the Spanish dictionaries cover)? +fn is_latin_letter(c: char) -> bool { + c.is_alphabetic() && (c < '\u{0250}' || ('\u{1E00}'..='\u{1EFF}').contains(&c)) +} + +/// Is `word` spelled correctly in `lang`'s Spanish? Numbers, single letters, short words in +/// capitals (acronyms: `ONU`, `CDMX`), words with digits and words in other scripts are accepted, +/// and so is every word when no dictionary is installed for the variety. Hyphenated compounds +/// are checked part by part (`teórico-práctico`, `franco-mexicano`). In Uruguayan Spanish the +/// `vos` forms of the dictionary's verbs are correct too ([`voseo`]). +pub fn is_correct(word: &str, lang: ProofLang) -> bool { + is_correct_with(&dictionary(lang), word, lang) +} + +/// [`is_correct`] against a given dictionary (an empty one accepts everything). +pub fn is_correct_with(d: &AffixDict, word: &str, lang: ProofLang) -> bool { + if d.is_empty() { + return true; // no Spanish dictionary installed: no spelling marks + } + let w = word.trim_matches(|c: char| !c.is_alphanumeric() && c != '\'' && c != '’'); + let w = w.trim_matches(['\'', '’']); + let n = w.chars().count(); + if n <= 1 || w.chars().any(|c| c.is_ascii_digit()) || !w.chars().any(char::is_alphabetic) { + return true; + } + if w.chars().all(|c| !c.is_lowercase()) && n <= 6 { + return true; + } + if !w.chars().any(is_latin_letter) { + return true; // other scripts: no dictionary + } + if w.contains('-') { + return known(d, w, lang) || w.split('-').all(|p| p.chars().count() <= 1 || known(d, p, lang)); + } + known(d, w, lang) +} + +/// Is one word in the dictionary or the user dictionary, or (in Uruguayan Spanish) the `vos` +/// form of one of the dictionary's verbs? +fn known(d: &AffixDict, w: &str, lang: ProofLang) -> bool { + d.check(w) || crate::in_user_dictionary(&w.to_lowercase()) || (lang == ProofLang::EsUy && voseo::is_vos_form(w, |inf| d.check(inf))) +} + +#[cfg(test)] +mod tests { + use super::*; + + const VARIETIES: [ProofLang; 4] = [ProofLang::Es, ProofLang::EsMx, ProofLang::Es419, ProofLang::EsUy]; + + #[test] + fn each_variety_looks_for_its_own_dictionary_first() { + let dir = std::env::temp_dir().join(format!("wordcraft-es-aliases-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let (es, mx, la, uy) = (ProofLang::Es, ProofLang::EsMx, ProofLang::Es419, ProofLang::EsUy); + // Alone in the folder, each file name is found by the varieties that list it. + for (name, found_by) in [ + ("es_ES", &[es][..]), + ("es-ES", &[es]), + ("es_MX", &[mx, la]), + ("es-MX", &[mx]), + ("es_419", &[mx, la, uy]), + ("es-419", &[mx, la, uy]), + ("es_US", &[la]), + ("es_UY", &[uy]), + ("es-UY", &[uy]), + ("es_AR", &[uy]), + ("es-AR", &[uy]), + ("es_ANY", &VARIETIES), + ("es", &VARIETIES), + ] { + std::fs::write(dir.join(format!("{name}.aff")), test_dict::AFF).unwrap(); + std::fs::write(dir.join(format!("{name}.dic")), test_dict::DIC).unwrap(); + for lang in VARIETIES { + let found = crate::hunspell::find_in(&dir, variety(lang).1); + assert_eq!(found.is_some(), found_by.contains(&lang), "{name}: {lang:?}"); + if let Some((aff, dic)) = found { + assert!(is_correct_with(&crate::hunspell::load(&aff, &dic).unwrap(), "canción", lang)); + } + } + std::fs::remove_file(dir.join(format!("{name}.aff"))).unwrap(); + std::fs::remove_file(dir.join(format!("{name}.dic"))).unwrap(); + } + // With them all, each variety reads its own. + for name in ["es_ES", "es_MX", "es_419", "es_UY", "es_AR", "es_ANY"] { + std::fs::write(dir.join(format!("{name}.aff")), test_dict::AFF).unwrap(); + std::fs::write(dir.join(format!("{name}.dic")), test_dict::DIC).unwrap(); + } + for (lang, name) in [(es, "es_ES"), (mx, "es_MX"), (la, "es_419"), (uy, "es_UY")] { + assert_eq!(crate::hunspell::find_in(&dir, variety(lang).1).map(|(_, dic)| dic), Some(dir.join(format!("{name}.dic")))); + assert_eq!(dictionary_name(lang), name); + } + std::fs::remove_dir_all(dir).unwrap(); + } + + #[test] + fn spanish_words_affixes_case_and_hyphens() { + test_dict::install(); + let d = dictionary(ProofLang::Es); + assert!(has_dictionary(ProofLang::Es)); + assert!(d.rule_count() > 0); + for w in [ + "canción", + "canciones", + "prueba", + "pruebas", + "corrector", + "correctores", + "también", + "México", + "Canción", + "CANCIÓN", + "teórico-práctico", + "franco-mexicano", + "ONU", + "Año", + "niño", + "pingüino", + ] { + assert!(is_correct(w, ProofLang::Es), "{w}"); + } + for w in ["prueva", "corector", "tambien", "mexico", "cancion", "teórico-prático", "cAnción", "nino"] { + assert!(!is_correct(w, ProofLang::Es), "{w}"); + } + } + + #[test] + fn varieties_keep_their_own_dictionaries() { + test_dict::install(); + // The Americas' fixture lacks `vosotros` forms and has American words; Spain's is the other way round. + assert!(is_correct("habéis", ProofLang::Es) && !is_correct("habéis", ProofLang::EsMx)); + assert!(is_correct("platicar", ProofLang::EsMx) && !is_correct("platicar", ProofLang::Es)); + assert!(is_correct("platicar", ProofLang::Es419) && is_correct("computadora", ProofLang::Es419)); + assert!(!is_correct("habéis", ProofLang::Es419) && !is_correct("habéis", ProofLang::EsUy)); + assert!(is_correct("ordenador", ProofLang::Es) && !is_correct("ordenador", ProofLang::EsMx)); + } + + /// Uruguayan Spanish accepts the `vos` forms of the dictionary's verbs, which the other + /// varieties flag. + #[test] + fn uruguayan_spanish_accepts_vos_forms() { + test_dict::install(); + for w in ["tenés", "vení", "Vení", "decime", "decímelo", "andate", "mirá", "hablás", "querés", "venís", "fijate"] { + assert!(is_correct(w, ProofLang::EsUy), "{w}"); + assert!(!is_correct(w, ProofLang::Es419) && !is_correct(w, ProofLang::Es), "{w}"); + } + for w in ["tenéz", "venì", "decimelo", "decíme", "zqbailá"] { + assert!(!is_correct(w, ProofLang::EsUy), "{w}"); + } + } + + #[test] + fn forbidden_words_are_rejected_in_every_case() { + test_dict::install(); + for w in ["zqprohibida", "Zqprohibida", "ZQPROHIBIDA"] { + assert!(!is_correct(w, ProofLang::Es), "{w}"); + } + } + + #[test] + fn missing_dictionary_and_ignored_inputs_are_quiet() { + let empty = AffixDict::default(); + assert!(is_correct_with(&empty, "prueva", ProofLang::Es)); + assert!(install_dictionary(ProofLang::Es, "FLAG long\n", "1\ncasa\n").is_err()); + let d = AffixDict::from_hunspell(test_dict::AFF, test_dict::DIC, &[]).unwrap(); + for w in ["", "a", "2026", "EE", "1.º", "日本語", "-", "--"] { + assert!(is_correct_with(&d, w, ProofLang::EsUy), "{w}"); + } + } + + #[test] + fn user_dictionary_applies() { + test_dict::install(); + assert!(!is_correct("zqpalabrapropia", ProofLang::EsMx)); + crate::add_word("Zqpalabrapropia"); + assert!(is_correct("zqpalabrapropia", ProofLang::EsMx)); + } + + #[test] + fn spanish_dispatch_messages_options_and_mixed_spans() { + test_dict::install(); + for tag in ["es-ES", "es-MX", "es-419", "es-AR", "es-UY", "spa"] { + assert!(crate::has_dictionary(Some(tag)), "{tag}"); + } + assert!(crate::is_correct_for("canción", Some("es-MX"))); + assert!(crate::is_correct_for("tenés", Some("es-UY")) && !crate::is_correct_for("tenés", Some("es-MX"))); + // The sentence from #3553. + let text = "esto es una prueva del corector"; + for lang in VARIETIES { + let issues = crate::check_text(text, &[(0..text.len(), lang)]); + let flagged: Vec<&str> = issues.iter().map(|i| &text[i.start..i.end]).collect(); + assert_eq!(flagged, ["prueva", "corector"], "{lang:?}: {issues:?}"); + assert!(issues.iter().all(|i| i.message == "Posible error ortográfico" && i.lang == lang), "{issues:?}"); + assert!(crate::suggest_in("prueva", 8, lang).contains(&"prueba".into()), "{lang:?}"); + assert!(crate::suggest_in("corector", 8, lang).contains(&"corrector".into()), "{lang:?}"); + } + let text = "Canción this is is wrong"; + let issues = crate::check_text(text, &[(0..8, ProofLang::Es419), (9..text.len(), ProofLang::En)]); + assert!(issues.iter().any(|i| i.lang == ProofLang::En && i.message.starts_with("Repeated word"))); + assert!(issues.iter().all(|i| i.start >= 9)); + let opts = crate::Options { ignore_uppercase: false, ..Default::default() }; + assert!(!crate::is_correct_in_with("ZQPROHIBIDA", ProofLang::Es, &opts)); + } +} diff --git a/crates/proof/src/es/suggest.rs b/crates/proof/src/es/suggest.rs new file mode 100644 index 0000000000..01c8e4a558 --- /dev/null +++ b/crates/proof/src/es/suggest.rs @@ -0,0 +1,99 @@ +//! Spanish suggestions. The ranking is the dictionary-driven one Czech uses +//! ([`crate::cs::suggest::suggest_in`]: case, MAP — the Spanish dictionaries map each vowel to +//! its accented forms and `n` to `ñ`, so `tambien` → `también` comes first —, REP, missing spaces, +//! KEY, TRY edits and a stem scan); every table comes from the variety's installed dictionary. +//! In Uruguayan Spanish a `vos` form typed without its accent is offered with it first +//! (`veni` → `vení`, `tenes` → `tenés`; [`super::voseo`]). + +use crate::ProofLang; +use crate::affix::{AffixDict, Casing, capitalize, casing}; + +/// Longest word suggestions are looked for, in letters (as in the shared ranking). +const MAX_LETTERS: usize = 40; + +/// Spelling suggestions for a word in `lang`'s Spanish, best first, at most `max`, in the word's +/// case. +pub fn suggest(word: &str, max: usize, lang: ProofLang) -> Vec { + suggest_in(&super::dictionary(lang), word, max, lang) +} + +/// [`suggest`] against a given dictionary. +pub fn suggest_in(d: &AffixDict, word: &str, max: usize, lang: ProofLang) -> Vec { + let mut out = Vec::new(); + let word = word.trim(); + if lang == ProofLang::EsUy + && !d.is_empty() + && word.chars().count() <= MAX_LETTERS + && let Some(v) = super::voseo::accented(&word.to_lowercase(), |inf| d.check(inf)) + { + out.push(match casing(word) { + Casing::Upper => v.to_uppercase(), + Casing::Initial => capitalize(&v), + _ => v, + }); + } + for s in crate::cs::suggest::suggest_in(d, word, max) { + if !out.contains(&s) { + out.push(s); + } + } + out.truncate(max); + out +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn spanish_suggestions_use_runtime_tables_and_case() { + crate::es::test_dict::install(); + for lang in [ProofLang::Es, ProofLang::EsMx, ProofLang::Es419, ProofLang::EsUy] { + for (word, want) in [ + ("tambien", "también"), + ("cancion", "canción"), + ("Cancion", "Canción"), + ("mexico", "México"), + ("nino", "niño"), + ("pinguino", "pingüino"), + ("prueva", "prueba"), + ("corector", "corrector"), + ("estoes", "esto es"), + ] { + assert!(suggest(word, 8, lang).contains(&want.into()), "{lang:?} {word}: {:?}", suggest(word, 8, lang)); + } + } + // The first suggestion for a missing accent is the accented word. + assert_eq!(suggest("tambien", 4, ProofLang::Es).first().map(String::as_str), Some("también")); + } + + #[test] + fn uruguayan_vos_forms_get_their_accent_back_first() { + crate::es::test_dict::install(); + for (word, want) in [("veni", "vení"), ("tenes", "tenés"), ("Veni", "Vení"), ("TENES", "TENÉS"), ("queres", "querés")] { + assert_eq!(suggest(word, 4, ProofLang::EsUy).first().map(String::as_str), Some(want), "{word}"); + assert!(!suggest(word, 8, ProofLang::Es419).contains(&want.into()), "{word}: no vos forms outside Uruguay"); + } + } + + #[test] + fn suggestions_stay_bounded_and_skip_forbidden_words() { + crate::es::test_dict::install(); + for lang in [ProofLang::Es, ProofLang::EsUy] { + for w in ["", "dos palabras", &"a".repeat(41)] { + assert!(suggest(w, 4, lang).is_empty(), "{w}"); + } + assert!(suggest("prueva", 0, lang).is_empty()); + assert!(suggest("veni", 0, lang).is_empty()); + assert!(suggest_in(&AffixDict::default(), "veni", 4, lang).is_empty()); + assert!(suggest("prueba", 4, lang).len() <= 4); + assert!(!suggest("prueba", 4, lang).contains(&"prueba".into())); + assert!(!suggest("zqprohibda", 8, lang).contains(&"zqprohibida".into())); + } + } + + #[test] + fn nosuggest_stems_and_forms_are_never_offered() { + crate::affix::tests::assert_nosuggest_is_honoured(|d, w, max| suggest_in(d, w, max, ProofLang::Es)); + } +} diff --git a/crates/proof/src/es/test_dict.rs b/crates/proof/src/es/test_dict.rs new file mode 100644 index 0000000000..6b3e6b1f50 --- /dev/null +++ b/crates/proof/src/es/test_dict.rs @@ -0,0 +1,111 @@ +//! Hand-written synthetic Spanish test data. No words, rules or suggestion tables were copied +//! from a system dictionary. This is a small fixture, not a redistributable Spanish dictionary. +//! Spain's word list has `vosotros` forms and Spain's words; the Americas' list (Mexico, Latin +//! America and Uruguay) has theirs instead, so the tests can tell the varieties' dictionaries apart. + +use crate::ProofLang; + +pub const AFF: &str = "SET UTF-8 +FLAG UTF-8 +FORBIDDENWORD F +TRY aeosrnidlctumpbgvyqhfzjñáéíóúüxkw +KEY qwertyuiop|asdfghjklñ|zxcvbnm +MAP 6 +MAP aá +MAP eé +MAP ií +MAP oó +MAP uúü +MAP nñ +REP 2 +REP v b +REP b v +SFX S Y 2 # synthetic plurals +SFX S 0 s [aeiou] +SFX S ón ones ón +SFX E Y 1 +SFX E 0 es [^aeiou] +"; + +/// Spain. +pub const DIC: &str = "32 +hablar +tener +venir +decir +andar +mirar +querer +fijar +poner +canción/S +prueba/S +corrector/E +también +México +esto +es +una +del +de +la +el +y +niño/S +pingüino/S +año/S +teórico +práctico +mexicano +franco +habéis +ordenador/E +zqprohibida/F +"; + +/// Mexico, Latin America and Uruguay. +pub const DIC_AMERICAS: &str = "33 +hablar +tener +venir +decir +andar +mirar +querer +fijar +poner +canción/S +prueba/S +corrector/E +también +México +esto +es +una +del +de +la +el +y +niño/S +pingüino/S +año/S +teórico +práctico +mexicano +franco +platicar +computadora/S +han +zqprohibida/F +"; + +/// Install the fixture for every variety: [`DIC`] for Spain, [`DIC_AMERICAS`] for Mexico, Latin +/// America and Uruguay. +pub fn install() { + for (lang, dic) in [(ProofLang::Es, DIC), (ProofLang::EsMx, DIC_AMERICAS), (ProofLang::Es419, DIC_AMERICAS), (ProofLang::EsUy, DIC_AMERICAS)] { + if let Err(e) = super::install_dictionary(lang, AFF, dic) { + panic!("test dictionary: {e}"); + } + } +} diff --git a/crates/proof/src/es/voseo.rs b/crates/proof/src/es/voseo.rs new file mode 100644 index 0000000000..d9753e1290 --- /dev/null +++ b/crates/proof/src/es/voseo.rs @@ -0,0 +1,155 @@ +//! The `vos` forms of verbs in Uruguayan Spanish ([`crate::ProofLang::EsUy`]), which dictionaries +//! built around `tú` may lack (#3360). Rioplatense `voseo` builds them regularly from the +//! infinitive, stress on the ending, without the stem changes `tú` has: +//! +//! - the imperative drops the infinitive's `r` and accents the vowel: `hablar` → `hablá`, +//! `tener` → `tené`, `venir` → `vení`, `decir` → `decí` (`ir` uses `andá`, from `andar`); +//! - with one pronoun after it the accent goes (`decime`, `fijate`, `andate`, `poneme`), with two +//! it comes back (`decímelo`, `mandámela`); `-ír` verbs keep theirs (`sonreíte`); +//! - the present indicative ends in `-ás`, `-és`, `-ís`: `hablás`, `tenés`, `venís`. +//! +//! A form is accepted only when the dictionary has its verb's infinitive, so nothing but these +//! endings is hard-coded. Other `vos` forms (`sos`) are ordinary dictionary words. + +/// Pronouns that come first when two follow an imperative (`decímelo`, `dáselo`). +const FIRST_PRONOUNS: [&str; 4] = ["me", "te", "se", "nos"]; +/// Pronouns that come second (or alone). +const SECOND_PRONOUNS: [&str; 6] = ["lo", "la", "le", "los", "las", "les"]; + +/// Shortest infinitive considered (`dar` and `ir` have no `vos` imperative of their own here, and +/// short stems would let nouns such as `bar` pass for verbs). +const MIN_INFINITIVE: usize = 4; + +/// Is `word` (one word, any case) a `vos` form of a verb whose infinitive `is_word` accepts? +pub fn is_vos_form(word: &str, is_word: impl Fn(&str) -> bool) -> bool { + let w = word.to_lowercase(); + if w.chars().count() > crate::affix::MAX_WORD_BYTES { + return false; + } + infinitives(&w).iter().any(|inf| inf.chars().count() >= MIN_INFINITIVE && is_word(inf)) +} + +/// The infinitives `w` (lower case) could be a `vos` form of, by its ending. +fn infinitives(w: &str) -> Vec { + let mut out = Vec::new(); + // Present indicative: `hablás`, `tenés`, `venís`. + if let Some(stem) = w.strip_suffix('s') { + imperative(stem, &mut out); + } + // Imperative: `hablá`, `tené`, `vení`. + imperative(w, &mut out); + // With one pronoun the accent goes: `decime`, `fijate`; `-ír` verbs keep it: `sonreíte`. + for p in FIRST_PRONOUNS.iter().chain(SECOND_PRONOUNS.iter()) { + if let Some(base) = w.strip_suffix(p) + && base.ends_with(['a', 'e', 'i', 'í']) + { + out.push(format!("{base}r")); + } + } + // With two the accent comes back: `decímelo`, `mandámela`. + for second in SECOND_PRONOUNS { + let Some(rest) = w.strip_suffix(second) else { continue }; + for first in FIRST_PRONOUNS { + if let Some(base) = rest.strip_suffix(first) { + imperative(base, &mut out); + } + } + } + out +} + +/// The infinitives of an imperative ending in an accented vowel: `hablá` → `hablar`, and for `í` +/// both `vení` → `venir` and `sonreí` → `sonreír`. +fn imperative(form: &str, out: &mut Vec) { + let mut chars = form.chars(); + let Some(last) = chars.next_back() else { return }; + let stem = chars.as_str(); + match last { + 'á' => out.push(format!("{stem}ar")), + 'é' => out.push(format!("{stem}er")), + 'í' => { + out.push(format!("{stem}ir")); + out.push(format!("{stem}ír")); + } + _ => {} + } +} + +/// The `vos` form `word` (lower case, without its accent) was most likely meant as, when the +/// writer left the accent out: `veni` → `vení`, `tenes` → `tenés`. `None` when it isn't one. +pub fn accented(word: &str, is_word: impl Fn(&str) -> bool) -> Option { + let (body, s) = match word.strip_suffix('s') { + Some(b) => (b, "s"), + None => (word, ""), + }; + let mut chars = body.chars(); + let accent = match chars.next_back()? { + 'a' => 'á', + 'e' => 'é', + 'i' => 'í', + _ => return None, + }; + let form = format!("{}{accent}{s}", chars.as_str()); + is_vos_form(&form, is_word).then_some(form) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn verbs(w: &str) -> bool { + ["hablar", "tener", "venir", "decir", "andar", "mirar", "sonreír", "fijar", "mandar", "poner", "bar", "querer"].contains(&w) + } + + #[test] + fn imperatives_present_and_pronouns_come_from_the_infinitive() { + for w in [ + "hablá", + "tené", + "vení", + "decí", + "andá", + "mirá", + "poné", + "hablás", + "tenés", + "venís", + "querés", + "decime", + "fijate", + "andate", + "poneme", + "sonreíte", + "sonreí", + "decímelo", + "mandámela", + "decinos", + "Vení", + "TENÉS", + ] { + assert!(is_vos_form(w, verbs), "{w}"); + } + // Wrong accents, verbs the dictionary lacks, and short stems (`bame` isn't `bar`). + for w in ["habla", "decíme", "decínos", "decimelo", "tenéz", "cantá", "bá", "bame", "", "s", "á", "més", "lo", "melo"] { + assert!(!is_vos_form(w, verbs), "{w}"); + } + } + + #[test] + fn missing_accents_are_restored() { + assert_eq!(accented("veni", verbs).as_deref(), Some("vení")); + assert_eq!(accented("tenes", verbs).as_deref(), Some("tenés")); + assert_eq!(accented("habla", verbs).as_deref(), Some("hablá")); + for w in ["canta", "casa", "", "s", "pan", "tenés"] { + assert_eq!(accented(w, verbs), None, "{w}"); + } + } + + proptest::proptest! { + #[test] + fn never_panics(s in "\\PC{0,40}") { + let _ = is_vos_form(&s, |_| true); + let _ = accented(&s, |_| true); + } + } +} diff --git a/crates/proof/src/hyphen.rs b/crates/proof/src/hyphen.rs index 9fdae033f6..a2adaba02b 100644 --- a/crates/proof/src/hyphen.rs +++ b/crates/proof/src/hyphen.rs @@ -856,7 +856,7 @@ mod tests { #[test] fn languages_without_patterns_keep_the_english_ones() { for w in ["hyphenation", "typography", "internationalization"] { - for lang in [ProofLang::Other, ProofLang::Cs, ProofLang::Id] { + for lang in [ProofLang::Other, ProofLang::Cs, ProofLang::Id, ProofLang::Es, ProofLang::EsMx, ProofLang::Es419, ProofLang::EsUy] { assert_eq!(hyphen_points_in(w, &Limits::default(), lang), hyphen_points(w, &Limits::default()), "{w}"); } } diff --git a/crates/proof/src/lang.rs b/crates/proof/src/lang.rs index f94ec95f48..95587f698f 100644 --- a/crates/proof/src/lang.rs +++ b/crates/proof/src/lang.rs @@ -49,6 +49,22 @@ pub enum ProofLang { /// `id_ID` dictionary, dictionary-driven suggestions and generic checks with Indonesian /// messages. Hyphenation keeps the fallback English patterns. Id, + /// Spanish of Spain (`es`, `es-ES`, and every region not listed for [`ProofLang::EsMx`] or + /// [`ProofLang::Es419`]; also ISO 639-2 `spa`): the installed Hunspell `es_ES` dictionary + /// (or a generic Spanish one), dictionary-driven suggestions and generic checks with Spanish + /// messages ([`crate::es`]). Hyphenation keeps the fallback English patterns. + Es, + /// Mexican Spanish (`es-MX`): [`ProofLang::Es`]'s tools with the installed `es_MX` + /// dictionary, else a Latin American or generic Spanish one. + EsMx, + /// Latin American Spanish (`es-419`, and the Spanish of the Americas other than Mexico and + /// Uruguay: `es-AR`, `es-CO`, `es-US`…): [`ProofLang::Es`]'s tools with the installed `es_419` + /// dictionary, else a generic or another Latin American Spanish one. + Es419, + /// Uruguayan Spanish (`es-UY`): [`ProofLang::Es`]'s tools with the installed `es_UY` + /// dictionary (else the neighbouring Rioplatense `es_AR`, a Latin American or a generic one), + /// and the `vos` forms of verbs (`tenés`, `vení`, `decime`) accepted ([`crate::es::voseo`]). + EsUy, /// A language without proofing tools here (`fr-FR`, `ja-JP`, `x-none`…). Like Word without /// the language's proofing tools: no spelling or grammar marks, so its words are never /// flagged as English misspellings. Hyphenation keeps using the English patterns, as before @@ -58,7 +74,7 @@ pub enum ProofLang { impl ProofLang { /// Every language with a spelling dictionary, in display order. - pub const PROOFED: [ProofLang; 11] = [ + pub const PROOFED: [ProofLang; 15] = [ ProofLang::En, ProofLang::EnGb, ProofLang::EnCa, @@ -70,11 +86,16 @@ impl ProofLang { ProofLang::Ru, ProofLang::Cs, ProofLang::Id, + ProofLang::Es, + ProofLang::EsMx, + ProofLang::Es419, + ProofLang::EsUy, ]; /// The proofing language of a BCP 47 tag (`pl-PL`, `en_GB`, `PL`…). The primary subtag - /// counts, and for English the region too ([`ProofLang::english`]). No tag (or an empty one) - /// is English, WordCraft's default editing language. + /// counts, and for English and Spanish the region too ([`ProofLang::english`], + /// [`ProofLang::spanish`]). No tag (or an empty one) is English, WordCraft's default editing + /// language. pub fn from_tag(tag: Option<&str>) -> ProofLang { let Some(tag) = tag.map(str::trim).filter(|t| !t.is_empty()) else { return ProofLang::En }; let primary = tag.split(['-', '_']).next().unwrap_or(""); @@ -98,36 +119,48 @@ impl ProofLang { ProofLang::Cs } else if primary.eq_ignore_ascii_case("id") || primary.eq_ignore_ascii_case("ind") || primary.eq_ignore_ascii_case("in") { ProofLang::Id + } else if primary.eq_ignore_ascii_case("es") || primary.eq_ignore_ascii_case("spa") { + ProofLang::spanish(tag) } else { ProofLang::Other } } - /// The English of an English tag, by its region (the first two-letter or three-digit subtag - /// after the language and script, before any singleton; a POSIX `.UTF-8` or `@…` tail is ignored): Canada is - /// [`ProofLang::EnCa`], regions that spell the British way are [`ProofLang::EnGb`], and the - /// United States, any other region and no region are [`ProofLang::En`]. + /// The English of an English tag, by its [`region`]: Canada is [`ProofLang::EnCa`], regions + /// that spell the British way are [`ProofLang::EnGb`], and the United States, any other + /// region and no region are [`ProofLang::En`]. fn english(tag: &str) -> ProofLang { - let tag = tag.split(['.', '@']).next().unwrap_or(""); - // Subtags after a singleton (`x-…` private use, `u-…` extensions) aren't regions. - let region = tag - .split(['-', '_']) - .skip(1) - .take_while(|p| p.len() != 1) - .find(|p| (p.len() == 2 && p.chars().all(|c| c.is_ascii_alphabetic())) || (p.len() == 3 && p.chars().all(|c| c.is_ascii_digit()))); - match region { + match region(tag) { Some(r) if r.eq_ignore_ascii_case("CA") => ProofLang::EnCa, Some(r) if BRITISH_SPELLING.iter().any(|b| b.eq_ignore_ascii_case(r)) => ProofLang::EnGb, _ => ProofLang::En, } } + /// The Spanish of a Spanish tag, by its [`region`]: Mexico is [`ProofLang::EsMx`], Uruguay + /// [`ProofLang::EsUy`], Latin America (`419`) and the other countries of the Americas are + /// [`ProofLang::Es419`], and Spain, any other region and no region are [`ProofLang::Es`]. + fn spanish(tag: &str) -> ProofLang { + match region(tag) { + Some(r) if r.eq_ignore_ascii_case("MX") => ProofLang::EsMx, + Some(r) if r.eq_ignore_ascii_case("UY") => ProofLang::EsUy, + Some(r) if LATIN_AMERICA.iter().any(|l| l.eq_ignore_ascii_case(r)) => ProofLang::Es419, + _ => ProofLang::Es, + } + } + /// Is this one of the Englishes ([`ProofLang::En`], [`ProofLang::EnGb`], [`ProofLang::EnCa`])? pub fn is_english(self) -> bool { matches!(self, ProofLang::En | ProofLang::EnGb | ProofLang::EnCa) } - /// The primary language subtag (`en`, `pl`, `bg`, `tr`, `ru`, `cs`, `id`); empty for [`ProofLang::Other`]. + /// Is this one of the Spanishes ([`ProofLang::Es`], [`ProofLang::EsMx`], [`ProofLang::Es419`], + /// [`ProofLang::EsUy`])? + pub fn is_spanish(self) -> bool { + matches!(self, ProofLang::Es | ProofLang::EsMx | ProofLang::Es419 | ProofLang::EsUy) + } + + /// The primary language subtag (`en`, `pl`, `bg`, `tr`, `ru`, `cs`, `id`, `es`); empty for [`ProofLang::Other`]. pub fn code(self) -> &'static str { match self { ProofLang::En | ProofLang::EnGb | ProofLang::EnCa => "en", @@ -140,6 +173,7 @@ impl ProofLang { ProofLang::Ru => "ru", ProofLang::Cs => "cs", ProofLang::Id => "id", + ProofLang::Es | ProofLang::EsMx | ProofLang::Es419 | ProofLang::EsUy => "es", ProofLang::Other => "", } } @@ -150,6 +184,24 @@ impl ProofLang { } } +/// The region of a tag: the first two-letter or three-digit subtag after the language and +/// script, before any singleton; a POSIX `.UTF-8` or `@…` tail is ignored. +fn region(tag: &str) -> Option<&str> { + let tag = tag.split(['.', '@']).next().unwrap_or(""); + // Subtags after a singleton (`x-…` private use, `u-…` extensions) aren't regions. + tag.split(['-', '_']) + .skip(1) + .take_while(|p| p.len() != 1) + .find(|p| (p.len() == 2 && p.chars().all(|c| c.is_ascii_alphabetic())) || (p.len() == 3 && p.chars().all(|c| c.is_ascii_digit()))) +} + +/// Regions whose Spanish is Latin American ([`ProofLang::Es419`]): Latin America and the +/// Caribbean (`419`) and its parts (South America `005`, Central America `013`, the Caribbean +/// `029`), the Spanish-speaking countries of the Americas other than Mexico and Uruguay, and the +/// United States. +const LATIN_AMERICA: [&str; 22] = + ["419", "005", "013", "029", "AR", "BO", "CL", "CO", "CR", "CU", "DO", "EC", "GT", "HN", "NI", "PA", "PE", "PR", "PY", "SV", "US", "VE"]; + /// Regions whose English uses British spelling ([`ProofLang::EnGb`]): the United Kingdom (and /// the non-standard `UK`), Ireland, Australia, New Zealand, South Africa, India and other /// Commonwealth countries, the Crown dependencies and Europe (`150`). @@ -275,7 +327,7 @@ mod tests { for t in ["tr", "tr-TR", "tr-CY", "TR_tr", "tur"] { assert_eq!(ProofLang::from_tag(Some(t)), ProofLang::Tr, "{t}"); } - for t in ["fr-FR", "de", "x-none", "ja-JP", "plx", "bgx", "trx", "csx", "idx", "inx", "e", "-"] { + for t in ["fr-FR", "de", "x-none", "ja-JP", "plx", "bgx", "trx", "csx", "idx", "inx", "esx", "spax", "e", "-"] { assert_eq!(ProofLang::from_tag(Some(t)), ProofLang::Other, "{t}"); } assert_eq!(ProofLang::from_tag(None), ProofLang::En); @@ -302,4 +354,29 @@ mod tests { assert_eq!(ProofLang::Tr.code(), "tr"); assert!(ProofLang::PROOFED.contains(&ProofLang::Pt), "every proofed language is listed"); } + + /// #3360, #3553: Spanish is proofed, by region: Spain (and no region), Mexico, Uruguay, and + /// Latin America with the other countries of the Americas. + #[test] + fn spanish_tags_resolve_by_region() { + for t in ["es", "es-ES", "ES_es", "es_ES.UTF-8", " es-ES ", "spa", "es-Latn-ES", "es-GQ", "es-PH", "es-x-mx", "es_ES@euro"] { + assert_eq!(ProofLang::from_tag(Some(t)), ProofLang::Es, "{t}"); + } + for t in ["es-MX", "es_MX", "ES-mx", "es_MX.UTF-8", "spa-MX", "es-Latn-MX"] { + assert_eq!(ProofLang::from_tag(Some(t)), ProofLang::EsMx, "{t}"); + } + for t in ["es-419", "es_419", "es-AR", "es-CO", "es_CL.UTF-8", "es-US", "ES-ve", "es-PR", "es-Latn-419", "es-005", "spa-PE"] { + assert_eq!(ProofLang::from_tag(Some(t)), ProofLang::Es419, "{t}"); + } + for t in ["es-UY", "es_UY", "ES-uy", "es_UY.UTF-8", "spa-UY", "es-Latn-UY"] { + assert_eq!(ProofLang::from_tag(Some(t)), ProofLang::EsUy, "{t}"); + } + for l in [ProofLang::Es, ProofLang::EsMx, ProofLang::Es419, ProofLang::EsUy] { + assert!(l.is_spanish() && l.is_proofed() && ProofLang::PROOFED.contains(&l), "{l:?}"); + assert!(!l.is_english()); + assert_eq!(l.code(), "es"); + } + assert!(!ProofLang::En.is_spanish() && !ProofLang::Pt.is_spanish() && !ProofLang::Other.is_spanish()); + assert_eq!(normalize_tag("ES_mx").as_deref(), Some("es-MX")); + } } diff --git a/crates/proof/src/lib.rs b/crates/proof/src/lib.rs index 9347fba774..e79b3e5dd8 100644 --- a/crates/proof/src/lib.rs +++ b/crates/proof/src/lib.rs @@ -34,6 +34,11 @@ //! - **Indonesian** ([`ProofLang::Id`], module [`id`]): the installed Hunspell `id_ID` //! dictionary (not bundled), dictionary-driven suggestions and generic checks with Indonesian //! messages. Hyphenation uses the existing English fallback. +//! - **Spanish** ([`ProofLang::Es`], [`ProofLang::EsMx`], [`ProofLang::Es419`], [`ProofLang::EsUy`], +//! module [`es`]): each variety's installed Hunspell dictionary (`es_ES`, `es_MX`, `es_419`, +//! `es_UY`, with fallbacks; not bundled), dictionary-driven suggestions, Uruguayan `vos` forms, +//! and generic checks plus the opening `¿`/`¡` with Spanish messages. Hyphenation uses the +//! existing English fallback. //! - **Other languages** ([`ProofLang::Other`]): no spelling or grammar marks, like Word without //! the language's proofing tools. //! - **Hyphenation** ([`hyphen`]): dictionary, Liang patterns (English, Polish, Portuguese), @@ -47,6 +52,7 @@ pub mod bg; pub mod cs; pub mod dict; pub mod en_variants; +pub mod es; pub mod hr; pub mod hunspell; pub mod hyphen; @@ -152,6 +158,7 @@ pub fn set_dictionaries_folder(dir: Option) { hr::reload_dictionary(); tr::reload_dictionary(); id::reload_dictionary(); + es::reload_dictionary(); DICTIONARY_GENERATION.fetch_add(1, Ordering::Relaxed); } @@ -165,6 +172,10 @@ pub fn installed_dictionaries() -> Vec<(ProofLang, Option)> (ProofLang::Hr, hr::installed_path()), (ProofLang::Tr, tr::installed_path()), (ProofLang::Id, id::installed_path()), + (ProofLang::Es, es::installed_path(ProofLang::Es)), + (ProofLang::EsMx, es::installed_path(ProofLang::EsMx)), + (ProofLang::Es419, es::installed_path(ProofLang::Es419)), + (ProofLang::EsUy, es::installed_path(ProofLang::EsUy)), ] } @@ -179,6 +190,7 @@ pub fn dictionary_load_error(lang: ProofLang) -> Option { ProofLang::Hr => "hr_HR", ProofLang::Tr => "tr_TR", ProofLang::Id => "id_ID", + ProofLang::Es | ProofLang::EsMx | ProofLang::Es419 | ProofLang::EsUy => es::dictionary_name(lang), _ => return None, }; hunspell::load_error(key).map(|(_, reason)| reason) @@ -397,6 +409,7 @@ pub fn missing_dictionary(lang: Option<&str>) -> Option<&'static str> { match ProofLang::from_tag(lang) { ProofLang::Pl => dictionary_needed(ProofLang::Pl, pl::has_dictionary()), ProofLang::Pt => dictionary_needed(ProofLang::Pt, pt::has_dictionary()), + l if l.is_spanish() => dictionary_needed(l, es::has_dictionary(l)), _ => None, } } @@ -409,6 +422,7 @@ pub fn unreadable_dictionary(lang: Option<&str>) -> Option<(&'static str, std::p let (proof_lang, loaded) = match ProofLang::from_tag(lang) { ProofLang::Pl => (ProofLang::Pl, pl::has_dictionary()), ProofLang::Pt => (ProofLang::Pt, pt::has_dictionary()), + l if l.is_spanish() => (l, es::has_dictionary(l)), _ => return None, }; let name = dictionary_needed(proof_lang, false)?; @@ -418,12 +432,14 @@ pub fn unreadable_dictionary(lang: Option<&str>) -> Option<(&'static str, std::p hunspell::load_error(name).map(|(file, reason)| (name, file, reason)) } -/// The Hunspell name `lang` needs when `installed` is false; only Polish and Portuguese use one. +/// The Hunspell name `lang` needs when `installed` is false; Polish, Portuguese and Spanish (per +/// variety: `es_ES`, `es_MX`, `es_419`, `es_UY`) name one. fn dictionary_needed(lang: ProofLang, installed: bool) -> Option<&'static str> { match lang { _ if installed => None, ProofLang::Pl => Some("pl_PL"), ProofLang::Pt => Some("pt_BR"), + l if l.is_spanish() => Some(es::dictionary_name(l)), _ => None, } } @@ -698,6 +714,7 @@ fn spelling_issue(text: &str, a: usize, b: usize, lang: ProofLang, opts: &Option ProofLang::Ru => "Возможна орфографическая ошибка", ProofLang::Cs => "Možná pravopisná chyba", ProofLang::Id => "Kemungkinan salah eja", + ProofLang::Es | ProofLang::EsMx | ProofLang::Es419 | ProofLang::EsUy => "Posible error ortográfico", _ => "Possible spelling mistake", }; Some(Issue { start: a, end: b, kind: IssueKind::Spelling, message: message.into(), suggestions: Vec::new(), lang }) @@ -720,6 +737,7 @@ pub fn is_correct_in_with(word: &str, lang: ProofLang, opts: &Options) -> bool { ProofLang::Ru => accepted_by_options(word, opts) || ru::is_correct(word), ProofLang::Cs => accepted_by_options(word, opts) || cs::is_correct(word), ProofLang::Id => accepted_by_options(word, opts) || id::is_correct(word), + ProofLang::Es | ProofLang::EsMx | ProofLang::Es419 | ProofLang::EsUy => accepted_by_options(word, opts) || es::is_correct(word, lang), ProofLang::Bg | ProofLang::Other => true, } } @@ -736,13 +754,15 @@ pub fn suggest_in(word: &str, max: usize, lang: ProofLang) -> Vec { ProofLang::Ru => ru::suggest(word, max), ProofLang::Cs => cs::suggest(word, max), ProofLang::Id => id::suggest(word, max), + ProofLang::Es | ProofLang::EsMx | ProofLang::Es419 | ProofLang::EsUy => es::suggest(word, max, lang), ProofLang::Bg | ProofLang::Other => Vec::new(), } } /// Grammar issues in a paragraph of text in `lang`: the English rules, the Polish ones /// ([`pl::grammar`]), the Portuguese ones ([`pt::grammar`]), the Italian ones ([`it::grammar`]), -/// the Turkish ones ([`tr::grammar`]), Czech and Indonesian generic checks ([`cs::grammar`], [`id::grammar`]), or none for a language WordCraft doesn't proof. +/// the Turkish ones ([`tr::grammar`]), Czech and Indonesian generic checks ([`cs::grammar`], [`id::grammar`]), the Spanish ones +/// ([`es::grammar`]), or none for a language WordCraft doesn't proof. pub fn check_grammar_in(text: &str, lang: ProofLang) -> Vec { grammar_in(text, lang, true) } @@ -762,6 +782,8 @@ fn grammar_in(text: &str, lang: ProofLang, capital_first: bool) -> Vec { // Czech has no sentence-capital rule, so a list item needs no exception. ProofLang::Cs => cs::grammar::check(text), ProofLang::Id => id::grammar::check(text), + // Spanish has no sentence-capital rule either. + ProofLang::Es | ProofLang::EsMx | ProofLang::Es419 | ProofLang::EsUy => es::grammar::check(text, lang), ProofLang::Bg | ProofLang::Other => Vec::new(), } } @@ -951,13 +973,19 @@ mod tests { assert_eq!(dictionary_needed(ProofLang::Pt, false), Some("pt_BR")); assert_eq!(dictionary_needed(ProofLang::Pl, true), None); assert_eq!(dictionary_needed(ProofLang::Pt, true), None); + // #3360: each Spanish variety names its own. + for (l, name) in [(ProofLang::Es, "es_ES"), (ProofLang::EsMx, "es_MX"), (ProofLang::Es419, "es_419"), (ProofLang::EsUy, "es_UY")] { + assert_eq!(dictionary_needed(l, false), Some(name)); + assert_eq!(dictionary_needed(l, true), None); + } for l in [ProofLang::En, ProofLang::Bg, ProofLang::Other] { assert_eq!(dictionary_needed(l, false), None); } // Installed dictionaries and the built-in English list: no hint. pl::test_dict::install(); pt::test_dict::install(); - for tag in [Some("pl-PL"), Some("pt-BR"), Some("en-US"), Some("fr-FR"), None] { + es::test_dict::install(); + for tag in [Some("pl-PL"), Some("pt-BR"), Some("es-ES"), Some("es-MX"), Some("es-419"), Some("es-UY"), Some("en-US"), Some("fr-FR"), None] { assert_eq!(missing_dictionary(tag), None, "{tag:?}"); } } @@ -978,6 +1006,10 @@ mod tests { ("it_IT", it::test_dict::AFF, it::test_dict::DIC), ("hr_HR", hr::test_dict::AFF, hr::test_dict::DIC), ("id_ID", id::test_dict::AFF, id::test_dict::DIC), + ("es_ES", es::test_dict::AFF, es::test_dict::DIC), + ("es_MX", es::test_dict::AFF, es::test_dict::DIC_AMERICAS), + ("es_419", es::test_dict::AFF, es::test_dict::DIC_AMERICAS), + ("es_UY", es::test_dict::AFF, es::test_dict::DIC_AMERICAS), ] { std::fs::write(dir.join(format!("{name}.aff")), aff).unwrap(); std::fs::write(dir.join(format!("{name}.dic")), dic).unwrap(); @@ -985,10 +1017,19 @@ mod tests { let before = dictionary_generation(); set_dictionaries_folder(Some(dir.clone())); assert!(dictionary_generation() > before, "cached checks are dropped"); - // Turkish has no test dictionary to put in the folder; Indonesian is listed after it. + // Turkish has no test dictionary to put in the folder; Indonesian and the Spanishes are listed after it. let found = installed_dictionaries(); assert_eq!(found.get(4).map(|(lang, _)| *lang), Some(ProofLang::Tr)); - assert_eq!(found.last(), Some(&(ProofLang::Id, Some(dir.join("id_ID.dic"))))); + assert_eq!( + found[5..], + [ + (ProofLang::Id, Some(dir.join("id_ID.dic"))), + (ProofLang::Es, Some(dir.join("es_ES.dic"))), + (ProofLang::EsMx, Some(dir.join("es_MX.dic"))), + (ProofLang::Es419, Some(dir.join("es_419.dic"))), + (ProofLang::EsUy, Some(dir.join("es_UY.dic"))), + ] + ); assert_eq!( found[..4], [ @@ -999,6 +1040,7 @@ mod tests { ] ); assert!(is_correct_in("chrząszcz", ProofLang::Pl) && !is_correct_in("chrzonszcz", ProofLang::Pl)); + assert!(is_correct_in("tenés", ProofLang::EsUy) && !is_correct_in("prueva", ProofLang::EsUy)); } /// #1741: a list item may be a fragment, so its first word needs no capital in any language @@ -1541,7 +1583,7 @@ mod tests { for l in [None, Some(""), Some(" "), Some("en"), Some("en-US"), Some("en-GB"), Some("EN-us"), Some("en_US")] { assert!(has_dictionary(l), "{l:?}"); } - for l in [Some("lt-LT"), Some("fr-FR"), Some("de"), Some("uk"), Some("es"), Some("-US"), Some("e")] { + for l in [Some("lt-LT"), Some("fr-FR"), Some("de"), Some("uk"), Some("-US"), Some("e")] { assert!(!has_dictionary(l), "{l:?}"); } // The gate follows the dictionaries, not English: every proofed language is proofed. @@ -1662,7 +1704,7 @@ mod tests { let _ = has_dictionary(Some(&s)); let _ = is_correct_for(&s, Some(&s)); let _ = hyphen::hyphen_points(&s, &hyphen::Limits::default()); - for lang in [ProofLang::En, ProofLang::EnGb, ProofLang::EnCa, ProofLang::Pl, ProofLang::Pt, ProofLang::It, ProofLang::Hr, ProofLang::Tr, ProofLang::Ru, ProofLang::Cs, ProofLang::Id, ProofLang::Other] { + for lang in [ProofLang::En, ProofLang::EnGb, ProofLang::EnCa, ProofLang::Pl, ProofLang::Pt, ProofLang::It, ProofLang::Hr, ProofLang::Tr, ProofLang::Ru, ProofLang::Cs, ProofLang::Id, ProofLang::Es, ProofLang::EsMx, ProofLang::Es419, ProofLang::EsUy, ProofLang::Other] { let _ = check_spelling_in(&s, lang); let _ = check_grammar_in(&s, lang); let _ = is_correct_in(&s, lang); @@ -1706,8 +1748,17 @@ mod tests { } #[test] - fn mixed_spans_never_panic(s in "\\PC{0,60}", cuts in proptest::collection::vec((0usize..80, 0usize..80, 0u8..11), 0..6)) { - let langs = [ProofLang::En, ProofLang::EnGb, ProofLang::EnCa, ProofLang::Pl, ProofLang::Pt, ProofLang::It, ProofLang::Tr, ProofLang::Ru, ProofLang::Cs, ProofLang::Id, ProofLang::Other]; + fn spanish_suggestions_never_panic(s in "[a-zA-ZáéíóúüñÁÉÍÓÚÜÑ¿¡'’ -]{0,14}") { + es::test_dict::install(); + for lang in [ProofLang::Es, ProofLang::EsMx, ProofLang::Es419, ProofLang::EsUy] { + let _ = suggest_in(&s, 4, lang); + let _ = is_correct_in(&s, lang); + } + } + + #[test] + fn mixed_spans_never_panic(s in "\\PC{0,60}", cuts in proptest::collection::vec((0usize..80, 0usize..80, 0u8..15), 0..6)) { + let langs = [ProofLang::En, ProofLang::EnGb, ProofLang::EnCa, ProofLang::Pl, ProofLang::Pt, ProofLang::It, ProofLang::Tr, ProofLang::Ru, ProofLang::Cs, ProofLang::Id, ProofLang::Es, ProofLang::EsMx, ProofLang::Es419, ProofLang::EsUy, ProofLang::Other]; let spans: Vec<(Range, ProofLang)> = cuts.iter().map(|&(a, b, l)| (a.min(b)..a.max(b), langs[l as usize % langs.len()])).collect(); for i in check_text(&s, &spans) { proptest::prop_assert!(i.start <= i.end && i.end <= s.len()); diff --git a/crates/ui-egui/src/dialogs_layout.rs b/crates/ui-egui/src/dialogs_layout.rs index 2936c9a03b..ab952f58ce 100644 --- a/crates/ui-egui/src/dialogs_layout.rs +++ b/crates/ui-egui/src/dialogs_layout.rs @@ -401,7 +401,7 @@ fn manual_ui(app: &mut WordApp, ui: &mut Ui, f: &mut ManualForm) -> bool { // Language /// Proofing languages offered, by BCP 47 tag, with their English names (translated for display). -pub const LANGUAGES: [(&str, &str); 36] = [ +pub const LANGUAGES: [(&str, &str); 38] = [ ("en-US", "English (United States)"), ("en-GB", "English (United Kingdom)"), ("en-AU", "English (Australia)"), @@ -414,6 +414,8 @@ pub const LANGUAGES: [(&str, &str); 36] = [ ("fr-FR", "French (France)"), ("es-ES", "Spanish (Spain)"), ("es-MX", "Spanish (Mexico)"), + ("es-419", "Spanish (Latin America)"), + ("es-UY", "Spanish (Uruguay)"), ("it-IT", "Italian"), ("pt-BR", "Portuguese (Brazil)"), ("pt-PT", "Portuguese (Portugal)"), diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index 032467cf6f..1c1046592d 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -1771,8 +1771,10 @@ Set As Default Задаване по подразбиране Shipping labels Етикети за пратки Single label Единичен етикет + Spanish (Latin America) Испански (Латинска Америка) Spanish (Mexico) Испански (Мексико) Spanish (Spain) Испански (Испания) + Spanish (Uruguay) Испански (Уругвай) Swedish Шведски Turkish Турски Ukrainian Украински diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 3a210cc850..2ceffa4880 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -2070,8 +2070,10 @@ preview Title Ukázka Set As Default Nastavit jako výchozí Shipping labels Přepravní štítky Single label Jeden štítek + Spanish (Latin America) Španělština (Latinská Amerika) Spanish (Mexico) Španělština (Mexiko) Spanish (Spain) Španělština (Španělsko) + Spanish (Uruguay) Španělština (Uruguay) Swedish Švédština Turkish Turečtina Ukrainian Ukrajinština diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 3fe150cca1..75591fff4d 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -2073,8 +2073,10 @@ Set As Default Als Standard festlegen Shipping labels Versandetiketten Single label Einzelnes Etikett + Spanish (Latin America) Spanisch (Lateinamerika) Spanish (Mexico) Spanisch (Mexiko) Spanish (Spain) Spanisch (Spanien) + Spanish (Uruguay) Spanisch (Uruguay) Swedish Schwedisch Turkish Türkisch Ukrainian Ukrainisch diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 3431633a95..42b2240afe 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -2131,8 +2131,10 @@ Set As Default Establecer como predeterminado Shipping labels Etiquetas de envío Single label Una sola etiqueta + Spanish (Latin America) Español (Latinoamérica) Spanish (Mexico) Español (México) Spanish (Spain) Español (España) + Spanish (Uruguay) Español (Uruguay) Swedish Sueco Turkish Turco Ukrainian Ucraniano diff --git a/crates/ui-egui/src/i18n/et.tsv b/crates/ui-egui/src/i18n/et.tsv index 263f5dace0..65fdf77c9a 100644 --- a/crates/ui-egui/src/i18n/et.tsv +++ b/crates/ui-egui/src/i18n/et.tsv @@ -2125,8 +2125,10 @@ Set As Default Määra vaikeväärtuseks Shipping labels Saatmissildid Single label Üks silt + Spanish (Latin America) Hispaania (Ladina-Ameerika) Spanish (Mexico) Hispaania (Mehhiko) Spanish (Spain) Hispaania (Hispaania) + Spanish (Uruguay) Hispaania (Uruguay) Swedish Rootsi Turkish Türgi Ukrainian Ukraina diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 6ebff61ee6..8eb3aa06e2 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -1884,8 +1884,10 @@ Set As Default Définir par défaut Shipping labels Étiquettes d’expédition Single label Étiquette unique + Spanish (Latin America) Espagnol (Amérique latine) Spanish (Mexico) Espagnol (Mexique) Spanish (Spain) Espagnol (Espagne) + Spanish (Uruguay) Espagnol (Uruguay) Swedish Suédois Turkish Turc Ukrainian Ukrainien diff --git a/crates/ui-egui/src/i18n/hr.tsv b/crates/ui-egui/src/i18n/hr.tsv index 404dcae7be..639eafe257 100644 --- a/crates/ui-egui/src/i18n/hr.tsv +++ b/crates/ui-egui/src/i18n/hr.tsv @@ -2082,8 +2082,10 @@ template Letter Pismo Kerning for fonts: Kerning fontova: Points and above pt i više Spacing: Razmak: + Spanish (Latin America) Španjolski (Latinska Amerika) Spanish (Mexico) Španjolski (Meksiko) Spanish (Spain) Španjolski (Španjolska) + Spanish (Uruguay) Španjolski (Urugvaj) Sparse Matrices Rijetke matrice Special Posebno Special Characters Posebni znakovi diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 0e2b13a52b..c2cec63e89 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -2249,8 +2249,10 @@ Set As Default Beállítás alapértelmezettként Shipping labels Szállítási címkék Single label Egyetlen címke + Spanish (Latin America) Spanyol (Latin-Amerika) Spanish (Mexico) Spanyol (Mexikó) Spanish (Spain) Spanyol (Spanyolország) + Spanish (Uruguay) Spanyol (Uruguay) Swedish Svéd Turkish Török Ukrainian Ukrán diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index e2c47a0a4b..b7038c7bb5 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -2108,8 +2108,10 @@ Set As Default Imposta come predefinito Shipping labels Etichette di spedizione Single label Etichetta singola + Spanish (Latin America) Spagnolo (America Latina) Spanish (Mexico) Spagnolo (Messico) Spanish (Spain) Spagnolo (Spagna) + Spanish (Uruguay) Spagnolo (Uruguay) Swedish Svedese Turkish Turco Ukrainian Ucraino diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 50a6a038ce..9af16dc53e 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -2091,8 +2091,10 @@ Set As Default 既定に設定 Shipping labels 配送ラベル Single label 1 枚のラベル + Spanish (Latin America) スペイン語(ラテンアメリカ) Spanish (Mexico) スペイン語(メキシコ) Spanish (Spain) スペイン語(スペイン) + Spanish (Uruguay) スペイン語(ウルグアイ) Swedish スウェーデン語 Turkish トルコ語 Ukrainian ウクライナ語 diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 0ef015a5ea..523405bbd6 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -2071,8 +2071,10 @@ Set As Default 기본값으로 설정 Shipping labels 배송 레이블 Single label 레이블 하나 + Spanish (Latin America) 스페인어(라틴 아메리카) Spanish (Mexico) 스페인어(멕시코) Spanish (Spain) 스페인어(스페인) + Spanish (Uruguay) 스페인어(우루과이) Swedish 스웨덴어 Turkish 터키어 Ukrainian 우크라이나어 diff --git a/crates/ui-egui/src/i18n/mod.rs b/crates/ui-egui/src/i18n/mod.rs index 092e376844..c8edc4f16e 100644 --- a/crates/ui-egui/src/i18n/mod.rs +++ b/crates/ui-egui/src/i18n/mod.rs @@ -85,6 +85,8 @@ pub static LANGUAGES: [LangInfo; 23] = [ code: "uk", name: "Українська", source: include_str!("uk.tsv"), prefer_hans: false, editing_lang: None, catalog: OnceLock::new() }, // Spanish, neutral across Spain and Latin America; `es-ES`, `es-MX`, `es-419` … resolve here. + // No fixed editing language: new documents are written in the region's Spanish + // (`Session::region_spanish`), like English. LangInfo { code: "es", name: "Español", source: include_str!("es.tsv"), prefer_hans: false, editing_lang: None, catalog: OnceLock::new() }, // Brazilian Portuguese; `pt-BR` and `pt-BR-*` resolve here. Plain `pt` and `pt-PT` have no // catalog yet (the European vocabulary differs), so they stay in English. diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 9d29c2a55a..84ac1adec6 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -1670,8 +1670,10 @@ Source Manager Menedżer źródeł Source type: Typ źródła: Sources in this document: Źródła w tym dokumencie: + Spanish (Latin America) Hiszpański (Ameryka Łacińska) Spanish (Mexico) Hiszpański (Meksyk) Spanish (Spain) Hiszpański (Hiszpania) + Spanish (Uruguay) Hiszpański (Urugwaj) Stroke {n} of {total} Pociągnięcie {n} z {total} Styles: Style: Subentry: Pozycja podrzędna: diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 2f099becf6..008c929d8b 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -2103,8 +2103,10 @@ Set As Default Definir como padrão Shipping labels Etiquetas de envio Single label Etiqueta única + Spanish (Latin America) Espanhol (América Latina) Spanish (Mexico) Espanhol (México) Spanish (Spain) Espanhol (Espanha) + Spanish (Uruguay) Espanhol (Uruguai) Swedish Sueco Turkish Turco Ukrainian Ucraniano diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index b9c89ce611..791c1b171c 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -2427,8 +2427,10 @@ preview Title Пример Set As Default Использовать по умолчанию Shipping labels Транспортные наклейки Single label Одна наклейка + Spanish (Latin America) Испанский (Латинская Америка) Spanish (Mexico) Испанский (Мексика) Spanish (Spain) Испанский (Испания) + Spanish (Uruguay) Испанский (Уругвай) Swedish Шведский Turkish Турецкий Ukrainian Украинский diff --git a/crates/ui-egui/src/i18n/sr-latn.tsv b/crates/ui-egui/src/i18n/sr-latn.tsv index b492b07d32..c96a2e637d 100644 --- a/crates/ui-egui/src/i18n/sr-latn.tsv +++ b/crates/ui-egui/src/i18n/sr-latn.tsv @@ -2101,8 +2101,10 @@ Set As Default Postavi kao podrazumevano Shipping labels Nalepnice za pošiljke Single label Jedna nalepnica + Spanish (Latin America) Španski (Latinska Amerika) Spanish (Mexico) Španski (Meksiko) Spanish (Spain) Španski (Španija) + Spanish (Uruguay) Španski (Urugvaj) Swedish Švedski Turkish Turski Ukrainian Ukrajinski diff --git a/crates/ui-egui/src/i18n/sr.tsv b/crates/ui-egui/src/i18n/sr.tsv index 8a0cff04ee..7c5ebb16ea 100644 --- a/crates/ui-egui/src/i18n/sr.tsv +++ b/crates/ui-egui/src/i18n/sr.tsv @@ -2105,8 +2105,10 @@ Set As Default Постави као подразумевано Shipping labels Налепнице за пошиљке Single label Једна налепница + Spanish (Latin America) Шпански (Латинска Америка) Spanish (Mexico) Шпански (Мексико) Spanish (Spain) Шпански (Шпанија) + Spanish (Uruguay) Шпански (Уругвај) Swedish Шведски Turkish Турски Ukrainian Украјински diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index ea569c1896..f0c582bdc9 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -2110,8 +2110,10 @@ Set As Default Ange som standard Shipping labels Fraktetiketter Single label En etikett + Spanish (Latin America) Spanska (Latinamerika) Spanish (Mexico) Spanska (Mexiko) Spanish (Spain) Spanska (Spanien) + Spanish (Uruguay) Spanska (Uruguay) Swedish Svenska Turkish Turkiska Ukrainian Ukrainska diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 17859beae2..402ed9c2b4 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -2426,8 +2426,10 @@ preview Title Örnek Set As Default Varsayılan Olarak Ayarla Shipping labels Kargo etiketleri Single label Tek etiket + Spanish (Latin America) İspanyolca (Latin Amerika) Spanish (Mexico) İspanyolca (Meksika) Spanish (Spain) İspanyolca (İspanya) + Spanish (Uruguay) İspanyolca (Uruguay) Swedish İsveççe Turkish Türkçe Ukrainian Ukraynaca diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 859b28e753..7655aac563 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -2103,8 +2103,10 @@ Set As Default Зробити типовою Shipping labels Етикетки для відправлень Single label Одна етикетка + Spanish (Latin America) Іспанська (Латинська Америка) Spanish (Mexico) Іспанська (Мексика) Spanish (Spain) Іспанська (Іспанія) + Spanish (Uruguay) Іспанська (Уругвай) Swedish Шведська Turkish Турецька Ukrainian Українська diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index a1af28ce89..86ea0abb29 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -1758,8 +1758,10 @@ Set As Default Đặt làm mặc định Shipping labels Nhãn vận chuyển Single label Nhãn đơn + Spanish (Latin America) Tiếng Tây Ban Nha (Mỹ Latinh) Spanish (Mexico) Tiếng Tây Ban Nha (Mexico) Spanish (Spain) Tiếng Tây Ban Nha (Tây Ban Nha) + Spanish (Uruguay) Tiếng Tây Ban Nha (Uruguay) Swedish Tiếng Thụy Điển Turkish Tiếng Thổ Nhĩ Kỳ Ukrainian Tiếng Ukraina diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index c081e34bb7..2e6ccc3c0a 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -2103,8 +2103,10 @@ Set As Default 设为默认值 Shipping labels 运输标签 Single label 单个标签 + Spanish (Latin America) 西班牙语(拉丁美洲) Spanish (Mexico) 西班牙语(墨西哥) Spanish (Spain) 西班牙语(西班牙) + Spanish (Uruguay) 西班牙语(乌拉圭) Swedish 瑞典语 Turkish 土耳其语 Ukrainian 乌克兰语 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 0780f108fa..a96e966c3f 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -2092,8 +2092,10 @@ Set As Default 設為預設值 Shipping labels 寄送標籤 Single label 單一標籤 + Spanish (Latin America) 西班牙文(拉丁美洲) Spanish (Mexico) 西班牙文(墨西哥) Spanish (Spain) 西班牙文(西班牙) + Spanish (Uruguay) 西班牙文(烏拉圭) Swedish 瑞典文 Turkish 土耳其文 Ukrainian 烏克蘭文 diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 6c9cde7b4c..d98b50bd68 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1978,10 +1978,12 @@ impl WordApp { // New documents are written in the interface's language when WordCraft can proof it // (a Polish interface: Polish spelling, grammar and hyphenation from the first word). // An English interface writes the region's English (English (UK) on a British system, - // #1045). + // #1045), a Spanish one the region's Spanish (Spanish (Uruguay) on a Uruguayan system, + // Spain's elsewhere, #3360). let editing = match lang.editing_lang() { Some(t) => Some(t.to_string()), None if lang.code() == "en" => self.session.region_english(), + None if lang.code() == "es" => Some(self.session.region_spanish()), None => None, }; if self.applied_editing.as_ref().is_none_or(|(l, e)| *l != lang || *e != editing) { diff --git a/crates/ui-egui/src/options.rs b/crates/ui-egui/src/options.rs index 2962187d66..941c6af136 100644 --- a/crates/ui-egui/src/options.rs +++ b/crates/ui-egui/src/options.rs @@ -352,6 +352,10 @@ fn language_name(lang: wordcraft_engine::proof::ProofLang) -> &'static str { ProofLang::Ru => tl!("Russian"), ProofLang::Cs => tl!("Czech"), ProofLang::Id => tl!("Indonesian"), + ProofLang::Es => tl!("Spanish (Spain)"), + ProofLang::EsMx => tl!("Spanish (Mexico)"), + ProofLang::Es419 => tl!("Spanish (Latin America)"), + ProofLang::EsUy => tl!("Spanish (Uruguay)"), // English uses its bundled word list; Bulgarian has no dictionary yet. ProofLang::En | ProofLang::EnGb | ProofLang::EnCa | ProofLang::Bg | ProofLang::Other => "", } diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index c4f299584b..f942f2d387 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -676,7 +676,7 @@ fn mi_text(ui: &mut Ui, app: &mut WordApp, text: &str, id: &str, params: Value) } /// The proofing languages the Language menu offers: tag and English name (translated when drawn). -pub(crate) const PROOFING_LANGUAGES: [(&str, &str); 12] = [ +pub(crate) const PROOFING_LANGUAGES: [(&str, &str); 16] = [ ("en-US", "English (United States)"), ("en-GB", "English (United Kingdom)"), ("en-AU", "English (Australia)"), @@ -689,6 +689,10 @@ pub(crate) const PROOFING_LANGUAGES: [(&str, &str); 12] = [ ("ru-RU", "Russian"), ("cs-CZ", "Czech"), ("id-ID", "Indonesian"), + ("es-ES", "Spanish (Spain)"), + ("es-MX", "Spanish (Mexico)"), + ("es-419", "Spanish (Latin America)"), + ("es-UY", "Spanish (Uruguay)"), ]; /// The English name of a proofing language tag, as the status bar shows it (no tag: WordCraft's @@ -729,6 +733,7 @@ pub(crate) fn language_name(tag: Option<&str>) -> String { wordcraft_engine::proof::ProofLang::Ru => "Russian".into(), wordcraft_engine::proof::ProofLang::Cs => "Czech".into(), wordcraft_engine::proof::ProofLang::Id => "Indonesian".into(), + wordcraft_engine::proof::ProofLang::Es => "Spanish (Spain)".into(), wordcraft_engine::proof::ProofLang::En if tag.eq_ignore_ascii_case("en") => "English (United States)".into(), _ => tag.to_string(), }, @@ -851,6 +856,7 @@ pub(crate) fn language_menu(ui: &mut Ui, app: &mut WordApp) { || (tag == "tr-TR" && current.eq_ignore_ascii_case("tr")) || (tag == "cs-CZ" && wordcraft_engine::proof::ProofLang::from_tag(Some(¤t)) == wordcraft_engine::proof::ProofLang::Cs) || (tag == "id-ID" && wordcraft_engine::proof::ProofLang::from_tag(Some(¤t)) == wordcraft_engine::proof::ProofLang::Id) + || (tag == "es-ES" && current.eq_ignore_ascii_case("es")) || (tag == "en-US" && current.eq_ignore_ascii_case("en")) }; let checks = language_checks(¤t, listed); @@ -3117,6 +3123,26 @@ mod tests { } } + /// #3360, #3553: the Spanishes WordCraft proofs are in the menu, checked by their tags; other + /// Spanish regions show by name, and a bare `es` is Spain's. + #[test] + fn spanish_varieties_are_in_the_language_menu() { + for (tag, name) in + [("es-ES", "Spanish (Spain)"), ("es-MX", "Spanish (Mexico)"), ("es-419", "Spanish (Latin America)"), ("es-UY", "Spanish (Uruguay)")] + { + assert!(PROOFING_LANGUAGES.contains(&(tag, name)), "{tag}"); + assert!(crate::dialogs_layout::LANGUAGES.contains(&(tag, name)), "{tag}"); + assert_eq!(language_name(Some(tag)), name); + let c = language_checks(&tag.to_ascii_lowercase(), |t| t.eq_ignore_ascii_case(tag)); + assert_eq!((c.extra, c.listed), (None, PROOFING_LANGUAGES.map(|(t, _)| t == tag)), "{tag}"); + } + assert_eq!(language_name(Some("es")), "Spanish (Spain)"); + assert_eq!(language_name(Some("es-AR")), "Spanish (Argentina)"); + let c = language_checks("es-AR", |t| t.eq_ignore_ascii_case("es-AR")); + assert_eq!(c.extra.as_deref(), Some("Spanish (Argentina)")); + assert!(!c.listed.contains(&true)); + } + #[test] fn czech_has_a_name_in_the_language_menu() { assert!(PROOFING_LANGUAGES.contains(&("cs-CZ", "Czech"))); diff --git a/docs/gaps.md b/docs/gaps.md index 768a94e9c4..1320072589 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Where WordCraft falls short of Word -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-19 · **Change:** trivial (personal templates in File › New and Save as Word Template, #2852; previously trivial: #8: English dialects — en-GB, en-CA, en-AU… flag each other's spellings, #1281; previously trivial: #1: shape gradients and transparency, #1500; previously trivial: text typed into a content control takes the control's formatting; localized placeholder styles recognised; previously trivial: forms protection keeps its content controls: no inserting, Properties or Remove Content Control; previously trivial: clearing a no-match font query keeps the recent heading visible, #2068; previously trivial: line breaking: a slash opening a word travels whole to the next line, #2963; previously trivial: recipient list search and merged-only view, #1576; previously trivial: #2 impact restated: only ink rendering and OLE editing remain; previously trivial: #2 narrowed: native ink nested in groups and drawing canvases kept on save; previously trivial: opening a DOCX no longer copies its pictures; previously trivial: RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: automatic Asian/Latin and Asian/number spacing laid out, #2358; previously trivial: hint-reliant CJK outlines; previously trivial: numbered headings, #1793; previously trivial: legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: #8 narrowed: Indonesian proofing from the installed Hunspell dictionary, #498; previously trivial: glossary/building blocks closed; previously trivial: Split Cells and Convert Text to Table follow the Word check, #2592; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717); previously trivial: #29: a copied equation also goes in the MathML clipboard formats, #1297; previously trivial: #24: the Spike, Ctrl+F3 and Ctrl+Shift+F3; previously trivial: VML freeforms read and kept on save; previously trivial: consistent preferred cell widths; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: floating-table overlap ignores empty line-break lines; previously trivial: copies keep their formatting on the clipboard as HTML, #1523; previously trivial: sample-text formulas on Enter, #1067; previously trivial: #29: copied equations as MathML, OMML open as #1297, #816; previously trivial: the Font list no longer keeps every previewed font in memory, #848; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw Table and Eraser landed, #303; previously minor: beta distance restated: ~15 points, ~150–230 h; previously: first version; every known shortfall from the 2026-10-10 re-measure, ranked) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-22 · **Change:** minor (#8 narrowed: Spanish proofing (Spain, Mexico, Latin America, Uruguay) from the installed Hunspell dictionaries, #3360, #3553; previously trivial: personal templates in File › New and Save as Word Template, #2852; previously trivial: #8: English dialects — en-GB, en-CA, en-AU… flag each other's spellings, #1281; previously trivial: #1: shape gradients and transparency, #1500; previously trivial: text typed into a content control takes the control's formatting; localized placeholder styles recognised; previously trivial: forms protection keeps its content controls: no inserting, Properties or Remove Content Control; previously trivial: clearing a no-match font query keeps the recent heading visible, #2068; previously trivial: line breaking: a slash opening a word travels whole to the next line, #2963; previously trivial: recipient list search and merged-only view, #1576; previously trivial: #2 impact restated: only ink rendering and OLE editing remain; previously trivial: #2 narrowed: native ink nested in groups and drawing canvases kept on save; previously trivial: opening a DOCX no longer copies its pictures; previously trivial: RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: automatic Asian/Latin and Asian/number spacing laid out, #2358; previously trivial: hint-reliant CJK outlines; previously trivial: numbered headings, #1793; previously trivial: legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: #8 narrowed: Indonesian proofing from the installed Hunspell dictionary, #498; previously trivial: glossary/building blocks closed; previously trivial: Split Cells and Convert Text to Table follow the Word check, #2592; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717); previously trivial: #29: a copied equation also goes in the MathML clipboard formats, #1297; previously trivial: #24: the Spike, Ctrl+F3 and Ctrl+Shift+F3; previously trivial: VML freeforms read and kept on save; previously trivial: consistent preferred cell widths; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: floating-table overlap ignores empty line-break lines; previously trivial: copies keep their formatting on the clipboard as HTML, #1523; previously trivial: sample-text formulas on Enter, #1067; previously trivial: #29: copied equations as MathML, OMML open as #1297, #816; previously trivial: the Font list no longer keeps every previewed font in memory, #848; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw Table and Eraser landed, #303; previously minor: beta distance restated: ~15 points, ~150–230 h; previously: first version; every known shortfall from the 2026-10-10 re-measure, ranked) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 > **Printing update:** 2026-10-12 · direct desktop printing through craft-print; details below. @@ -43,7 +43,7 @@ Active footnote and endnote text stays readable while editing (#2545). This fixe | 6 | **Print dialog depth**: desktop printer choice, copies, collation, duplex and grayscale submit through craft-print; File › Print retains preview, page ranges and odd/even (#641) and scales pages to another paper size (#2108); driver tray/options and pages per sheet remain; 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**: 59 modal dialogs vs Word's ~100 (Drop Cap Options matches Word's menu and dialog; Split Cells and Convert Text to Table match Word's observed limits and defaults) | B | `Dialog` enum in `crates/ui-egui/src/dialogs.rs`; Cell Margins (#1960) now opens with current defaults and applies only confirmed edits | Power users reach for dialogs the ribbon alone doesn't cover | 11–19 | [ui-parity.md](ui-parity.md) | | 9 | **Password-protected `.doc` files** (AES) can't be opened; password-protected `.docx` opens and saves (agile encryption, #55 closed by #281) | | `crates/docx/src/crypt.rs`; `crates/docbin/src/lib.rs` opens XOR/RC4/RC4 CryptoAPI `.doc` with a password (#1079, untested on Word-written files; AES `.doc` refused) | Corporate and legal users with old files | 3–5 | [file-format-parity.md](file-format-parity.md) | -| 8 | **Proofing covers English, Polish, Portuguese, Italian, Croatian, Turkish, Russian, Czech and Indonesian only**; no dictionaries, grammar or hyphenation for the other ~42 languages Word proofs (text in them is left unmarked) | | `crates/proof` `ProofLang::PROOFED`: English (American, British-spelling and Canadian, each flagging the other's spellings, #1281), Polish, Portuguese (pt-BR via the installed VERO dictionary, #797), Italian (it-IT via the installed Hunspell dictionary), Croatian (hr-HR, 2026-10-13), Turkish (tr_TR spelling, grammar, TDK hyphenation, 2026-10-13) Russian (the installed `ru_RU` dictionary, 2026-10-12) and Indonesian (the installed `id_ID` dictionary, #498, 2026-10-16), a language per run; Bulgarian has hyphenation only (#826); open PRs add Danish (#723), Russian (#894) and Czech (#1048); Word ships 120 proofing tools; issues #25, #40, #100 open | Every writer outside English, Polish, Portuguese, Italian, Croatian, Turkish, Russian, Czech and Indonesian | 20–40 | [localization-parity.md](localization-parity.md) | +| 8 | **Proofing covers English, Polish, Portuguese, Italian, Croatian, Turkish, Russian, Czech, Indonesian and Spanish only**; no dictionaries, grammar or hyphenation for the other ~41 languages Word proofs (text in them is left unmarked) | | `crates/proof` `ProofLang::PROOFED`: English (American, British-spelling and Canadian, each flagging the other's spellings, #1281), Polish, Portuguese (pt-BR via the installed VERO dictionary, #797), Italian (it-IT via the installed Hunspell dictionary), Croatian (hr-HR, 2026-10-13), Turkish (tr_TR spelling, grammar, TDK hyphenation, 2026-10-13) Russian (the installed `ru_RU` dictionary, 2026-10-12) Indonesian (the installed `id_ID` dictionary, #498, 2026-10-16) and Spanish (es-ES, es-MX, es-419 and es-UY via the installed RLA-ES dictionaries, Uruguayan `vos` forms, #3360, #3553, 2026-10-22), a language per run; Bulgarian has hyphenation only (#826); open PRs add Danish (#723), Russian (#894) and Czech (#1048); Word ships 120 proofing tools; issues #25, #40, #100 open | Every writer outside English, Polish, Portuguese, Italian, Croatian, Turkish, Russian, Czech, Indonesian and Spanish | 20–40 | [localization-parity.md](localization-parity.md) | | 10 | **Objects**: 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 11/11, Layout 22/22); group and ungroup landed (#267) | Newsletters, flyers, reports with diagrams | 20–30 | [target-app-parity.md](target-app-parity.md) | | 11 | **Interface languages**: 3 of the 12 key languages missing (hi, ar, id) and pt-PT (French landed, #729; Vietnamese, #1434; Korean, #1216; 21 interface languages in `crates/ui-egui/src/i18n`); open PRs for Arabic (#737, #1042, #1032), Indonesian (#572) and pt-PT (#1028); no mirrored RTL interface yet; ~5% of strings still English in the translated catalogs | | [localization-parity.md](localization-parity.md) | Most of the world's writers | 50–80 | [localization-parity.md](localization-parity.md) | | 12 | **Draw tab / ink**: pens, pencil, highlighter, eraser, Select and Hide Ink landed (#307); Lasso Select, Add Pen, Ink to Shape and Ink Replay landed (#395); Ink to Shape also recognises single-stroke arrows (a line plus a V head drawn in one stroke becomes a straight connector with an arrowhead); two-stroke arrows (a line and a separate V or closed-triangle head) and one-stroke arrows with a closed triangle head landed (#1778); missing: Ink to Math, pressure, rendering/editing Word's own ink (drawing-level and direct run-level InkML content parts now survive DOCX save) | | [parity-checklist.md](parity-checklist.md) (Draw 10/11) | Pen and tablet users | 4–8 | [hardware-parity.md](hardware-parity.md) | @@ -106,6 +106,7 @@ Footer page-table height and body wrapping are corrected. Other pagination gaps | Date | Change | Summary | |---|---|---| +| 2026-10-22 | minor | #8 narrowed: Spanish proofing (es-ES, es-MX, es-419, es-UY spelling from the installed Hunspell dictionaries, Uruguayan `vos` forms, generic and opening-mark checks, #3360, #3553) | | 2026-10-19 | trivial | Restrict Editing: with Filling in forms enforced, inserting content controls, their Properties and Remove Content Control are refused, as in Word (it greys them out), so a form's fields stay as they are and only their content is filled in | | 2026-10-19 | trivial | Content controls: text typed over a placeholder takes the control's own run properties (`w:sdtPr/w:rPr`, ECMA-376 §17.5.2.27), as in Word, and the placeholder style is recognised by its built-in name "Placeholder Text" whatever its localized id (German Word: `Platzhaltertext`), so entries in forms from localized Word no longer stay grey | | 2026-10-18 | trivial | Font list starts at its first row after clearing a no-match query; recent heading remains visible (#2068) | diff --git a/docs/localization-parity.md b/docs/localization-parity.md index c2c849ac38..e847c4b0c0 100644 --- a/docs/localization-parity.md +++ b/docs/localization-parity.md @@ -1,6 +1,6 @@ # Localization parity with Microsoft Word -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-16 · **Change:** minor (English dialects: en-GB, en-CA, en-AU and the other British-spelling Englishes proof their own spellings and flag the other dialect's, #1281, #1045; previously minor: how to install the Hunspell dictionaries on Windows, macOS and Linux: `docs/spelling-dictionaries.md`; previously minor: Read Aloud speaks each sentence in its own language, #1843; previously minor: Indonesian proofing: id-ID spelling from the installed Hunspell dictionary, dictionary-driven suggestions, generic checks with Indonesian messages, #498; previously trivial: print-area strings translated in every catalog; previously trivial: Italian: the interface audit checks the Italian catalog, `Privacy Settings` wording; previously trivial: Italian grammar: `po'`, `qual è`, `È`, `un`/`un'` rules; more elisions and truncations in spelling; previously trivial: Czech interface catalog completed, 2,447 entries; previously trivial: interface audit runs in CI again: size labels translated, 20 strings in every catalog, Read Aloud error race fixed, #1128; previously trivial: Review › Language reaches every language again — Set Proofing Language… and Set As Default, #860; previously minor: Italian proofing: it-IT spelling from the installed Hunspell dictionary, accent-first suggestions, conservative grammar with Italian messages; previously minor: Portuguese proofing; previously trivial: Turkish interface catalog completed, 2,325 entries, #774; previously minor: Hungarian interface added; previously trivial: sorting and table numbers follow the text's language, #836, #837; previously minor: Polish interface; Polish proofing — spelling, grammar, hyphenation — and a proofing language per run; previously minor: Turkish interface added; previously major: first version; catalogs measured from `crates/ui-egui/src/i18n`, Word's languages from its bundle listing) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-22 · **Change:** minor (Spanish proofing: es-ES, es-MX, es-419 and es-UY from the installed Hunspell dictionaries, Uruguayan `vos` forms, opening `¿`/`¡` checks; the RLA-ES dictionaries' `☎️` flag is read, #3360, #3553; previously minor: English dialects: en-GB, en-CA, en-AU and the other British-spelling Englishes proof their own spellings and flag the other dialect's, #1281, #1045; previously minor: how to install the Hunspell dictionaries on Windows, macOS and Linux: `docs/spelling-dictionaries.md`; previously minor: Read Aloud speaks each sentence in its own language, #1843; previously minor: Indonesian proofing: id-ID spelling from the installed Hunspell dictionary, dictionary-driven suggestions, generic checks with Indonesian messages, #498; previously trivial: print-area strings translated in every catalog; previously trivial: Italian: the interface audit checks the Italian catalog, `Privacy Settings` wording; previously trivial: Italian grammar: `po'`, `qual è`, `È`, `un`/`un'` rules; more elisions and truncations in spelling; previously trivial: Czech interface catalog completed, 2,447 entries; previously trivial: interface audit runs in CI again: size labels translated, 20 strings in every catalog, Read Aloud error race fixed, #1128; previously trivial: Review › Language reaches every language again — Set Proofing Language… and Set As Default, #860; previously minor: Italian proofing: it-IT spelling from the installed Hunspell dictionary, accent-first suggestions, conservative grammar with Italian messages; previously minor: Portuguese proofing; previously trivial: Turkish interface catalog completed, 2,325 entries, #774; previously minor: Hungarian interface added; previously trivial: sorting and table numbers follow the text's language, #836, #837; previously minor: Polish interface; Polish proofing — spelling, grammar, hyphenation — and a proofing language per run; previously minor: Turkish interface added; previously major: first version; catalogs measured from `crates/ui-egui/src/i18n`, Word's languages from its bundle listing) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 Interface languages, script support and proofing per language. How translation works (catalog format, adding a language, clean-room rule): `crates/ui-egui/src/i18n/mod.rs`. @@ -29,9 +29,9 @@ native-speaker review (human). Read Aloud, Backstage greetings) aren't wrapped in `tl!` yet, so they show in English in every language: the true share of visible strings is **~93–95%** (estimated). - Pending PRs: Norwegian Bokmål (#208) and Nynorsk (#210), Hebrew with RTL (#20). -- **Proofing: English, Polish, Portuguese, Italian, Russian, Czech and Indonesian**, chosen per run from its language (`w:lang`, resolved through +- **Proofing: English, Polish, Portuguese, Italian, Russian, Czech, Indonesian and Spanish**, chosen per run from its language (`w:lang`, resolved through the styles; Review › Language and the status bar set it: a short list, plus Set Proofing - Language… for any of 30 languages and Set As Default for the document). Languages without proofing tools get + Language… for any of 38 languages and Set As Default for the document). Languages without proofing tools get the styles; Review › Language and the status bar set it). Languages without proofing tools get no spelling or grammar marks (as in Word without the language's tools) instead of being flagged as English. Detail in [Proofing per language](#proofing-per-language). @@ -111,6 +111,7 @@ other than envelopes (Letter, Legal, A4, B5 (JIS)) stay as they are (#1128). | Italian (`it`, any region) | The Hunspell `it_IT` dictionary installed on the system (same places as Polish and Portuguese; not bundled — the upstream file is GPL-licensed), read at runtime by the same affix engine; elided forms check the word after the apostrophe (`l'amico`, `un'idea`, `c'è`, `dov'è`, `senz'altro`), truncations keep their apostrophe (`po'`, `mo'`), hyphenated compounds part by part | missing accents first (`caffe` → `caffè`, `perche` → `perché`), the dictionary's confusion table, missing space (`perme` → `per me`), one typo, then an accent-folding stem scan | Italian messages: repeated word (emphatic doubles like `piano piano` are allowed), space before punctuation, double space, sentence capital (knows `ecc.`, `dott.`, `prof.`, initials, ordinals), doubled punctuation, `un pò` → `un po'`, `qual'è` → `qual è`, `E'` → `È`, `un'altro` → `un altro` and `un altra` → `un'altra` (gender from the ending, masculine `-ista`/`-ma`/`-cida`… left alone); these work without a dictionary; no comma rule | TeX Italian patterns (Beccari; MIT option), no exception words | | Turkish (`tr`, any region) | The Hunspell `tr_TR` dictionary installed on the system (same places as Polish; not bundled), read at runtime; proper nouns capitalized, apostrophe suffixes (`Ankara'da`, `WordCraft'ta`) and hyphenated compounds | missing diacritics / ASCII restoration first (`turkce` → `Türkçe`, `isik` → `ışık`), common confusion table (`yalnış` → `yanlış`, `herşey` → `her şey`), detached suffixes (`bende` → `ben de`, `geldinmi` → `geldi mi`), one typo | Turkish messages: repeated word (idiomatic reduplications allowed: `yavaş yavaş`), space before punctuation, double space, sentence capital (knows `vb.`, `vs.`, `prof.`, `dr.`), no comma before/after conjunctions (`ve`, `veya`, `de`), doubled punctuation | Turkish syllable rules (TDK rules); no external pattern file or word list needed | | Indonesian (`id`, `id-ID`, `ind`, legacy `in`) | The system Hunspell `id_ID` dictionary, read at runtime (Debian/Ubuntu: `hunspell-id`; LibreOffice's `dict-id`; the same system, LibreOffice, Dictionaries folder and `WORDCRAFT_DICTIONARIES` search paths as Polish). No dictionary data is bundled; without an installed dictionary spelling stays off. Hyphenated reduplication is accepted whole (`kupu-kupu`) or by parts (`anak-anak`); hyphenated clitics after capitals and abbreviations (`KTP-nya`, `se-Indonesia`) are accepted | the same dictionary-driven ranking as Czech: MAP/REP/TRY/KEY tables of the loaded dictionary, missing spaces and a stem scan; nothing Indonesian hard-coded | Indonesian messages for repeated words (offering the hyphenated reduplication `anak-anak` first, then the single word), space before punctuation and double spaces only; no Indonesian-specific grammar rules | existing English fallback, as for `Other`; no Indonesian patterns bundled | +| Spanish (`es`, `spa`; by region: Spain and no region, `es-MX`, `es-UY`, and `es-419` with the other countries of the Americas) | The system Hunspell dictionary of each variety, read at runtime: `es_ES`, `es_MX`, `es_419` (else `es_US` or `es_MX`, which LibreOffice ships) and `es_UY` (else the Rioplatense `es_AR`), each falling back to the generic `es_ANY`; LibreOffice's `dict-es` and Debian/Ubuntu's `hunspell-es` have them (RLA-ES). No dictionary data is bundled; without one the status bar says the variety has no dictionary. The RLA-ES files' `☎️` flag (a variation selector after the flag) is read as Hunspell reads it, which refused the whole dictionary before. Hyphenated compounds are checked part by part (`teórico-práctico`). Uruguayan Spanish also accepts the `vos` forms of every verb in the dictionary: imperatives (`vení`, `tené`, `andá`), with pronouns (`decime`, `fijate`, `decímelo`) and the present (`tenés`, `querés`) | the same dictionary-driven ranking as Czech, from the variety's dictionary (its MAP restores accents and `ñ` first: `tambien` → `también`; the regional REP tables carry `vosotros` → `ustedes`); in Uruguay a `vos` form typed without its accent gets it back first (`veni` → `vení`) | Spanish messages for repeated words, space before punctuation and double spaces; a closing `?` or `!` without its opening `¿` or `¡` (the sentence with the mark is offered; `¡…?` pairs, `(?)` and `(!)` are allowed) and a space after an opening mark | existing English fallback, as for `Other`; no Spanish patterns bundled | | Any other | — (no marks) | — | — | the English patterns, as before | The dictionary file names each language looks for, the folders searched on each system and how to @@ -198,6 +199,7 @@ WordCraft keeps 2,54 cm). | Date | Change | Summary | |---|---|---| +| 2026-10-22 | minor | Spanish proofing (#3360, #3553): es-ES, es-MX, es-419 and es-UY each read their installed Hunspell dictionary (RLA-ES, not bundled; its `☎️` flag no longer refuses the file), Czech's dictionary-driven suggestions, Uruguayan `vos` forms (imperatives included), repeated-word, spacing and opening-mark checks with Spanish messages; Review › Language, the status bar, Set Proofing Language and File › Options › Proofing list the four varieties, and a Spanish interface writes new documents in the region's Spanish; estimates unchanged | | 2026-10-14 | trivial | The sentence-capital rule (English, Polish, Croatian, Italian, Turkish, Portuguese) skips the first word of a list item (#1741) | | 2026-10-16 | minor | Indonesian proofing (#498): id-ID spelling from the installed Hunspell `id_ID` dictionary (not bundled), Czech's dictionary-driven suggestions, repeated-word (hyphenated reduplication offered) and spacing checks with Indonesian messages; Review › Language, the status bar, Set Proofing Language and File › Options › Proofing list Indonesian; estimates unchanged | | 2026-10-16 | trivial | Read Aloud picks a voice for the sentence's language (run tag, else script) when the default voice doesn't speak it, reads Windows text as UTF-8 | diff --git a/docs/spelling-dictionaries.md b/docs/spelling-dictionaries.md index 9e90e91e0f..29902cceb3 100644 --- a/docs/spelling-dictionaries.md +++ b/docs/spelling-dictionaries.md @@ -1,9 +1,9 @@ # Installing spelling dictionaries -> **Last reviewed:** 2026-10-16 · **Last updated:** 2026-10-18 · **Change:** trivial (ISO 8859-1 dictionaries are read, #2824; previously major: first version) +> **Last reviewed:** 2026-10-16 · **Last updated:** 2026-10-22 · **Change:** minor (Spanish: `es_ES`, `es_MX`, `es_419` and `es_UY` with their fallbacks, #3360, #3553; previously trivial: ISO 8859-1 dictionaries are read, #2824; previously major: first version) -English spelling works out of the box. Polish, Russian, Portuguese, Czech, Italian, Croatian and -Turkish spelling use a standard **Hunspell** dictionary (an `.aff` and a `.dic` file) that +English spelling works out of the box. Polish, Russian, Portuguese, Czech, Italian, Croatian, +Turkish, Indonesian and Spanish spelling use a standard **Hunspell** dictionary (an `.aff` and a `.dic` file) that WordCraft reads at runtime. These dictionaries are not bundled: they are large, and several are GPL or LGPL licensed. Without one, the language isn't checked. The status bar then shows the language with **(no dictionary)**, for example *Português (Brasil) (no dictionary)*. @@ -29,10 +29,20 @@ WordCraft looks for the first of these file names (each name means a `.aff | Italian | `it_IT`, `it_CH`, `it-IT`, `it` | LibreOffice `dict-it` | | Croatian | `hr_HR`, `hr-HR`, `hr` | LibreOffice `dict-hr` | | Turkish | `tr_TR`, `tr-TR`, `tr` | LibreOffice `dict-tr` | +| Spanish (Spain) | `es_ES`, `es-ES`, `es_ANY`, `es` | LibreOffice `dict-es` (RLA-ES) | +| Spanish (Mexico) | `es_MX`, `es-MX`, `es_419`, `es-419`, `es_ANY`, `es` | LibreOffice `dict-es` (RLA-ES) | +| Spanish (Latin America, and the other Spanish regions of the Americas) | `es_419`, `es-419`, `es_ANY`, `es`, `es_US`, `es_MX` | LibreOffice `dict-es` (RLA-ES) | +| Spanish (Uruguay) | `es_UY`, `es-UY`, `es_AR`, `es-AR`, `es_419`, `es-419`, `es_ANY`, `es` | LibreOffice `dict-es` (RLA-ES) | Every Portuguese region uses the same `pt_BR` dictionary, so text marked *Português (Portugal)* is checked with it too. +Spanish has a dictionary per variety: text marked *Español (España)* uses `es_ES`, *Español +(México)* `es_MX`, *Español (Uruguay)* `es_UY`, and *Español (Latinoamérica)* and the other +Spanish regions of the Americas (Argentina, Colombia, the United States…) the first Latin +American one found. LibreOffice's `dict-es` has them all. Uruguayan Spanish also accepts the +`vos` forms of the dictionary's verbs (`tenés`, `vení`, `decime`, `decímelo`). + The files may be UTF-8 or the encoding named on the `.aff`'s `SET` line (ISO 8859-1, ISO 8859-2, KOI8-R and Windows-1251 are read as well; the original VERO `pt_BR` is ISO 8859-1). @@ -104,7 +114,8 @@ curl -fLO https://raw.githubusercontent.com/LibreOffice/dictionaries/master/pt_B ``` Other languages work the same way: replace `pt_BR` with the language's folder and file names -from the LibreOffice repository (for example `it_IT/it_IT.aff`). +from the LibreOffice repository (for example `it_IT/it_IT.aff`; the Spanish ones are all in +`es/`, for example `es/es_MX.aff`). ## Checking that it works @@ -127,6 +138,7 @@ Never commit dictionary files to this repository: they stay on the user's system | Date | Change | Summary | |---|---|---| +| 2026-10-22 | minor | Spanish (#3360, #3553): the file names for Spain, Mexico, Latin America and Uruguay and their fallbacks; the RLA-ES dictionaries load (their `☎️` flag was refused before) | | 2026-10-18 | trivial | ISO 8859-1 (`SET ISO8859-1`) dictionaries are read, so the original VERO `pt_BR` works without converting it to UTF-8 (#2824) | | 2026-10-18 | minor | An installed dictionary that can't be read shows **(dictionary unreadable)** in the status bar, with the file and reason in its tooltip and in File › Options › Proofing (#2830) | | 2026-10-16 | major | First version: the file names per language, the search order and folders on Windows, macOS and Linux, installing `pt_BR` on each, and how to check it works | From caf5ea509cb79ea354de2e0cb3bb56b897b59a87 Mon Sep 17 00:00:00 2001 From: Rob Date: Tue, 20 Oct 2026 11:23:18 +0100 Subject: [PATCH 012/407] Web: a ?host=parent bridge for pages that keep WordCraft's files A same-origin page that frames wordcraft-web (a Nextcloud app, a personal file server) can now hand it a document and store what it saves, with the same postMessage protocol as GridCraft's and PdfCraft's bridges: wordcraft:ready / open / save {name, saveAs, bytes} / dirty / failed. In host mode Save, Save As and Export go to the parent instead of the save picker or a download (Services::host_save, with whether it was Save As). Save writes the opened file under its own name and format, as the desktop app does (an import-only .doc still becomes a .docx), and a Save or Save As that keeps everything makes that name the document's file, so the next Save goes to it. The parent guards leaving the page; ?author= names the user for comments and tracked changes. Co-Authored-By: Claude Opus 5.5 --- apps/wordcraft-web/Cargo.toml | 2 +- apps/wordcraft-web/src/host.rs | 158 +++++++++++++++++++++++++++++++++ apps/wordcraft-web/src/main.rs | 5 ++ apps/wordcraft-web/src/web.rs | 30 ++++++- crates/ui-egui/src/lib.rs | 113 ++++++++++++++++++++++- 5 files changed, 300 insertions(+), 8 deletions(-) create mode 100644 apps/wordcraft-web/src/host.rs diff --git a/apps/wordcraft-web/Cargo.toml b/apps/wordcraft-web/Cargo.toml index 9152db3334..4a2a9d014e 100644 --- a/apps/wordcraft-web/Cargo.toml +++ b/apps/wordcraft-web/Cargo.toml @@ -29,4 +29,4 @@ rfd = { workspace = true } wasm-bindgen = "0.2" wasm-bindgen-futures = "0.4" js-sys = "0.3" -web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlAnchorElement", "HtmlIFrameElement", "Blob", "BlobPropertyBag", "Url", "Location", "CssStyleDeclaration", "Event", "EventTarget", "BeforeUnloadEvent", "ClipboardEvent", "DataTransfer", "FileList", "File", "DomStringList", "IdbFactory", "IdbOpenDbRequest", "IdbRequest", "IdbDatabase", "IdbObjectStore", "IdbTransaction", "IdbTransactionMode", "MediaQueryList", "Storage"] } +web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlAnchorElement", "HtmlIFrameElement", "Blob", "BlobPropertyBag", "Url", "Location", "CssStyleDeclaration", "Event", "EventTarget", "BeforeUnloadEvent", "ClipboardEvent", "DataTransfer", "FileList", "File", "DomStringList", "IdbFactory", "IdbOpenDbRequest", "IdbRequest", "IdbDatabase", "IdbObjectStore", "IdbTransaction", "IdbTransactionMode", "MediaQueryList", "Storage", "MessageEvent", "UrlSearchParams"] } diff --git a/apps/wordcraft-web/src/host.rs b/apps/wordcraft-web/src/host.rs new file mode 100644 index 0000000000..6a0bd8859b --- /dev/null +++ b/apps/wordcraft-web/src/host.rs @@ -0,0 +1,158 @@ +//! The `postMessage` bridge to a same-origin parent page (`?host=parent`), for pages that embed +//! WordCraft in an `