Files
wordcraft/crates/engine/src/cmd/tools.rs
T

2374 lines
113 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Tools and the long tail: Repeat, macros, AutoCorrect, Compare, accessibility checker,
//! document inspector, restrict editing, versions, templates, Quick Parts, and more.
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use std::sync::Arc;
use wordcraft_doc::para::InlineObject;
use wordcraft_doc::props::{CharProps, NumRef};
use wordcraft_doc::{Block, Document, Paragraph, Pos, RevisionKind, StoryRef, para_block};
use super::{delete_selection, sel_result};
use crate::{CmdError, CmdResult, CommandSpec, Selection, Session, p};
/// Smileys replaced even when typed straight after a word ("ok:)"), as Word does.
const SMILEYS: &[&str] = &[":-)", ":-(", ":)", ":(", ":|"];
/// Built-in AutoCorrect replacements (our own list of common typos and symbols).
pub const AUTOCORRECT: &[(&str, &str)] = &[
("teh", "the"),
("adn", "and"),
("recieve", "receive"),
("recieved", "received"),
("seperate", "separate"),
("definately", "definitely"),
("occured", "occurred"),
("untill", "until"),
("wich", "which"),
("becuase", "because"),
("thier", "their"),
("alot", "a lot"),
("accomodate", "accommodate"),
("acheive", "achieve"),
("beleive", "believe"),
("calender", "calendar"),
("goverment", "government"),
("tommorow", "tomorrow"),
("wierd", "weird"),
("i", "I"),
("dont", "don't"),
("cant", "can't"),
("wont", "won't"),
("didnt", "didn't"),
("doesnt", "doesn't"),
("isnt", "isn't"),
("(c)", "©"),
("(r)", "®"),
("(tm)", "™"),
("...", "…"),
("->", "→"),
("-->", "→"),
("<-", "←"),
("=>", "⇒"),
(":)", "☺"),
(":-)", "☺"),
(":(", "☹"),
(":-(", "☹"),
(":|", "\u{1F610}"),
("1/2", "½"),
("1/4", "¼"),
("3/4", "¾"),
];
/// A built-in entry that is an English word (letters and apostrophes only), not a symbol.
fn is_english_entry(from: &str) -> bool {
from.chars().any(char::is_alphabetic) && from.chars().all(|c| c.is_alphabetic() || c == '\'')
}
/// Compare and Combine take the same settings.
const COMPARE_PARAMS: &str = r#"{"path"?: string (revised document), "text"?: string (revised text), "original"?: string (original document; default: this one), "author"?: string (label changes with), "caseChanges"?: bool, "whiteSpace"?: bool, "formatting"?: bool (default true: differences count), "level"?: "word|character", "showIn"?: "original|revised|new" (revised/new: an untitled document), "moves"|"comments"|"tables"|"headersFooters"|"footnotes"|"textBoxes"|"fields"?: bool (accepted; not honoured yet except tables, always compared)}"#;
/// AutoCorrect Options (Word keeps them per user; the front end saves them with its preferences).
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct AutoCorrectPrefs {
/// All of AutoCorrect and AutoFormat As You Type.
pub enabled: bool,
/// Replace text as you type (the replacement list).
pub replace_text: bool,
/// Correct TWo INitial CApitals: "WOrd" → "Word" (not the exceptions, e.g. "IDs").
pub two_initial_caps: bool,
/// Capitalize the first letter of sentences.
pub cap_sentences: bool,
/// Capitalize the first letter of table cells.
pub cap_cells: bool,
/// Capitalize names of days.
pub cap_days: bool,
/// Correct accidental usage of cAPS LOCK: "wORD" → "Word".
pub caps_lock: bool,
/// AutoFormat As You Type: "straight quotes" with “smart quotes”.
pub smart_quotes: bool,
/// Fractions (1/2) with fraction characters (½).
pub fractions: bool,
/// Ordinals (1st) with superscript.
pub ordinals: bool,
/// *Bold* and _italic_ with real formatting: the marks are removed and the text between them
/// is made bold or italic. Off unless the user turns it on.
pub bold_italic: bool,
/// Hyphens (--) with dashes.
pub dashes: bool,
/// Internet addresses with hyperlinks.
pub links: bool,
/// Automatic bulleted lists.
pub bullets: bool,
/// Automatic numbered lists.
pub numbering: bool,
/// Border lines.
pub border_lines: bool,
/// Set left- and first-indent with tabs and backspaces: Tab at the start of a line of a
/// paragraph with text indents it instead of typing a tab, and Backspace at its start takes
/// the indent back. Off by default.
pub tab_indent: bool,
/// Built-in Heading styles: a second Enter after a line of five or fewer words that ends in a
/// letter or digit makes it Heading 1 (Heading 2 or 3 after one or two leading tabs).
/// Off by default.
pub heading_styles: bool,
/// The user's entries: added, or replacing a built-in one.
pub entries: Vec<(String, String)>,
/// Built-in entries the user deleted.
pub removed: Vec<String>,
/// Abbreviations the user added after which a sentence doesn't end ("approx.").
pub exceptions: Vec<String>,
/// Built-in abbreviations the user deleted from the exceptions.
pub exceptions_removed: Vec<String>,
/// The user's words that keep two initial capitals (besides the built-in ones).
pub initial_caps_exceptions: Vec<String>,
/// Undoing (or backspacing and retyping) an automatic sentence capital adds the word before
/// it to the exceptions (First Letter's "Automatically add words to list").
pub auto_add_first_letter: bool,
/// The same for undoing a TWo INitial CApitals fix (INitial CAps's switch).
pub auto_add_initial_caps: bool,
}
impl Default for AutoCorrectPrefs {
fn default() -> Self {
AutoCorrectPrefs {
enabled: true,
replace_text: true,
two_initial_caps: true,
cap_sentences: true,
cap_cells: true,
cap_days: true,
caps_lock: true,
smart_quotes: true,
fractions: true,
ordinals: true,
bold_italic: false,
dashes: true,
links: true,
bullets: true,
numbering: true,
border_lines: true,
tab_indent: false,
heading_styles: false,
entries: Vec::new(),
removed: Vec::new(),
exceptions: Vec::new(),
exceptions_removed: Vec::new(),
initial_caps_exceptions: Vec::new(),
auto_add_first_letter: true,
auto_add_initial_caps: true,
}
}
}
/// Longest AutoCorrect "replace" text, in characters.
const MAX_FROM: usize = 255;
/// Longest "with" text, in characters.
const MAX_TO: usize = 2000;
/// Most entries (and exceptions) kept.
const MAX_ENTRIES: usize = 10_000;
/// Built-in fractions: AutoFormat's (Fractions), not the replacement list's.
const FRACTIONS: [&str; 3] = ["1/2", "1/4", "3/4"];
impl AutoCorrectPrefs {
/// Drop what a damaged or hostile saved value could hold: empty or overlong entries,
/// duplicates, too many of them.
pub fn cleaned(mut self) -> Self {
let ok = |t: &str, max: usize| !t.trim().is_empty() && t.chars().count() <= max && !t.contains(['\n', '\r']);
let mut seen = std::collections::HashSet::new();
self.entries.retain(|(a, b)| ok(a, MAX_FROM) && b.chars().count() <= MAX_TO && !b.contains(['\n', '\r']) && seen.insert(a.clone()));
self.entries.truncate(MAX_ENTRIES);
for list in [&mut self.removed, &mut self.exceptions, &mut self.exceptions_removed, &mut self.initial_caps_exceptions] {
let mut seen = std::collections::HashSet::new();
list.retain(|a| ok(a, MAX_FROM) && seen.insert(a.clone()));
list.truncate(MAX_ENTRIES);
}
self
}
/// The replacement for `word` in English text, if any: the user's entry, else a built-in
/// one not deleted. Built-in lowercase entries also match a capitalised word ("Teh" → "The").
pub fn replacement(&self, word: &str) -> Option<String> {
self.replacement_in(word, true)
}
/// The replacement for `word` in text that is (`english`) or isn't English: the built-in
/// English words ("teh", "i", "dont") apply only to English text, as Word keeps a list per
/// language (#835: Polish "i" means "and"); symbols, fractions and the user's entries apply
/// whatever the language.
pub fn replacement_in(&self, word: &str, english: bool) -> Option<String> {
if let Some((_, b)) = self.entries.iter().find(|(a, _)| a == word) {
return Some(b.clone());
}
AUTOCORRECT
.iter()
.filter(|(a, _)| english || !is_english_entry(a))
.filter(|(a, _)| !self.removed.iter().any(|r| r == a) && !self.entries.iter().any(|(e, _)| e == a))
.filter(|(a, _)| self.fractions || !FRACTIONS.contains(a))
.find(|(a, _)| {
*a == word || (a.chars().all(char::is_lowercase) && word.to_lowercase() == *a && word.chars().next().is_some_and(char::is_uppercase))
})
.map(|(a, b)| {
if *a != word && b.chars().next().is_some_and(char::is_lowercase) {
let mut c = b.chars();
c.next().map(|x| x.to_uppercase().chain(c).collect::<String>()).unwrap_or_default()
} else {
b.to_string()
}
})
}
/// The replacement list as the dialog shows it, sorted: `(from, to, built in)`.
pub fn list(&self) -> Vec<(String, String, bool)> {
let mut v: Vec<(String, String, bool)> = AUTOCORRECT
.iter()
.filter(|(a, _)| !self.removed.iter().any(|r| r == a) && !self.entries.iter().any(|(e, _)| e == a))
.map(|(a, b)| (a.to_string(), b.to_string(), true))
.chain(self.entries.iter().map(|(a, b)| (a.clone(), b.clone(), false)))
.collect();
v.sort_by(|x, y| x.0.to_lowercase().cmp(&y.0.to_lowercase()).then(x.0.cmp(&y.0)));
v
}
/// Add a First Letter exception (lowercase; one the built-in list has just stops being deleted).
fn add_exception(&mut self, e: String) {
if !plain_word(&e) {
return;
}
self.exceptions_removed.retain(|r| *r != e);
if !NOT_SENTENCE_END.contains(&e.as_str()) && !self.exceptions.contains(&e) && self.exceptions.len() < MAX_ENTRIES {
self.exceptions.push(e);
}
}
/// Add a word that keeps its two initial capitals.
fn add_initial_caps_exception(&mut self, w: &str) {
if !plain_word(w) {
return;
}
if !INITIAL_CAPS_EXCEPTIONS.contains(&w)
&& !self.initial_caps_exceptions.iter().any(|e| e == w)
&& self.initial_caps_exceptions.len() < MAX_ENTRIES
{
self.initial_caps_exceptions.push(w.to_string());
}
}
/// Abbreviations after which a sentence doesn't end, lowercase.
pub fn exception_list(&self) -> Vec<String> {
let mut v: Vec<String> = NOT_SENTENCE_END
.iter()
.filter(|a| !self.exceptions_removed.iter().any(|r| r.eq_ignore_ascii_case(a)))
.map(ToString::to_string)
.chain(self.exceptions.iter().map(|a| a.to_lowercase()))
.collect();
v.sort();
v.dedup();
v
}
}
/// Is `w` a word an exception list may hold: one short line, no spaces or object characters?
fn plain_word(w: &str) -> bool {
!w.trim().is_empty() && w.chars().count() <= MAX_FROM && !w.contains(|c: char| c.is_whitespace() || c == '\u{FFFC}')
}
/// The newest automatic capitalisation (a sentence capital, a TWo INitial CApitals fix), kept so
/// that undoing it, or backspacing it and typing the word again, makes an exception (#1857).
#[derive(Clone, Debug)]
pub struct AutoCap {
/// Undo steps pushed (ever) right after the change (`u64::MAX` until the caller has pushed its step).
pub(crate) depth: u64,
story: wordcraft_doc::StoryRef,
path: wordcraft_doc::Path,
/// Byte offset of the word in its paragraph.
start: usize,
/// The token before a sentence capital's word, lowercase ("there."); empty when none or when
/// the capital wasn't a sentence one.
prev: String,
/// The TWo INitial CApitals word as typed ("TWo").
two_caps: Option<String>,
/// The user backspaced into the word after the change.
backspaced: bool,
}
impl AutoCap {
pub(crate) fn add_exceptions(self, ac: &mut AutoCorrectPrefs) {
if ac.auto_add_first_letter && !self.prev.is_empty() {
ac.add_exception(self.prev);
}
if ac.auto_add_initial_caps
&& let Some(w) = self.two_caps
{
ac.add_initial_caps_exception(&w);
}
}
/// A Backspace deleted `a..f`; it reached back over the capitalised word's first letter.
pub(crate) fn note_backspace(&mut self, a: &Pos, f: &Pos) {
if f.story == self.story && f.path == self.path && a.off <= self.start && f.off > self.start {
self.backspaced = true;
}
}
}
/// Saved AutoCorrect options: anything that doesn't read as them reads as the defaults (a
/// damaged `ui.json` never loses the other preferences).
pub fn lenient_autocorrect<'de, D: serde::Deserializer<'de>>(d: D) -> Result<AutoCorrectPrefs, D::Error> {
let v = Value::deserialize(d)?;
Ok(serde_json::from_value::<AutoCorrectPrefs>(v).map(AutoCorrectPrefs::cleaned).unwrap_or_default())
}
/// `tools.autocorrect`: change AutoCorrect Options and report them.
fn autocorrect_options(s: &mut Session, v: &Value) -> CmdResult {
let ac = &mut s.prefs.autocorrect;
for (k, f) in [
("enabled", &mut ac.enabled as &mut bool),
("replaceText", &mut ac.replace_text),
("twoInitialCaps", &mut ac.two_initial_caps),
("capSentences", &mut ac.cap_sentences),
("capCells", &mut ac.cap_cells),
("capDays", &mut ac.cap_days),
("capsLock", &mut ac.caps_lock),
("smartQuotes", &mut ac.smart_quotes),
("fractions", &mut ac.fractions),
("ordinals", &mut ac.ordinals),
("boldItalic", &mut ac.bold_italic),
("dashes", &mut ac.dashes),
("links", &mut ac.links),
("bullets", &mut ac.bullets),
("numbering", &mut ac.numbering),
("borderLines", &mut ac.border_lines),
("tabIndent", &mut ac.tab_indent),
("headingStyles", &mut ac.heading_styles),
("autoAddFirstLetter", &mut ac.auto_add_first_letter),
("autoAddInitialCaps", &mut ac.auto_add_initial_caps),
] {
if let Some(b) = p::bool(v, k) {
*f = b;
}
}
let bad = |t: &str, max: usize| t.trim().is_empty() || t.chars().count() > max || t.contains(['\n', '\r']);
if let Some(add) = v.get("add") {
let (Some(f), Some(t)) = (add.get("from").and_then(Value::as_str), add.get("to").and_then(Value::as_str)) else {
return Err(CmdError::Params("`add` is {\"from\": string, \"to\": string}".into()));
};
if bad(f, MAX_FROM) || t.chars().count() > MAX_TO || t.contains(['\n', '\r']) {
return Err(CmdError::Params(format!("an entry replaces 1–{MAX_FROM} characters (one line) with at most {MAX_TO}")));
}
ac.entries.retain(|(a, _)| a != f);
if ac.entries.len() >= MAX_ENTRIES {
return Err(CmdError::Params("too many AutoCorrect entries".into()));
}
ac.entries.push((f.to_string(), t.to_string()));
ac.removed.retain(|r| r != f);
}
if let Some(f) = p::str(v, "delete") {
let n = ac.entries.len();
ac.entries.retain(|(a, _)| a != f);
if AUTOCORRECT.iter().any(|(a, _)| *a == f) && !ac.removed.iter().any(|r| r == f) {
ac.removed.push(f.to_string());
} else if n == ac.entries.len() {
return Err(CmdError::Params(format!("no AutoCorrect entry {f:?}")));
}
}
if let Some(e) = p::str(v, "addException") {
let e = e.trim().to_lowercase();
if bad(&e, MAX_FROM) || e.contains(char::is_whitespace) {
return Err(CmdError::Params("an exception is one abbreviation, like \"approx.\"".into()));
}
ac.add_exception(e);
}
if let Some(e) = p::str(v, "deleteException") {
let e = e.trim().to_lowercase();
ac.exceptions.retain(|r| *r != e);
if NOT_SENTENCE_END.contains(&e.as_str()) && !ac.exceptions_removed.contains(&e) {
ac.exceptions_removed.push(e);
}
}
if let Some(w) = p::str(v, "addInitialCapsException") {
let w = w.trim();
if bad(w, MAX_FROM) || w.contains(char::is_whitespace) {
return Err(CmdError::Params("an INitial CAps exception is one word, like \"IDs\"".into()));
}
ac.add_initial_caps_exception(w);
}
if let Some(w) = p::str(v, "deleteInitialCapsException") {
let w = w.trim();
ac.initial_caps_exceptions.retain(|e| e != w);
}
let ac = &s.prefs.autocorrect;
Ok(json!({
"enabled": ac.enabled,
"replaceText": ac.replace_text,
"twoInitialCaps": ac.two_initial_caps,
"capSentences": ac.cap_sentences,
"capCells": ac.cap_cells,
"capDays": ac.cap_days,
"capsLock": ac.caps_lock,
"smartQuotes": ac.smart_quotes,
"fractions": ac.fractions,
"ordinals": ac.ordinals,
"boldItalic": ac.bold_italic,
"dashes": ac.dashes,
"links": ac.links,
"bullets": ac.bullets,
"numbering": ac.numbering,
"borderLines": ac.border_lines,
"tabIndent": ac.tab_indent,
"headingStyles": ac.heading_styles,
"autoAddFirstLetter": ac.auto_add_first_letter,
"autoAddInitialCaps": ac.auto_add_initial_caps,
"entries": ac.list().into_iter().map(|(a, b, builtin)| json!({"from": a, "to": b, "builtIn": builtin})).collect::<Vec<_>>(),
"user": ac.entries,
"exceptions": ac.exception_list(),
"initialCapsExceptions": INITIAL_CAPS_EXCEPTIONS.iter().map(|e| e.to_string()).chain(ac.initial_caps_exceptions.iter().cloned()).collect::<Vec<_>>(),
}))
}
pub fn specs() -> Vec<CommandSpec> {
vec![
CommandSpec::new("edit.repeat", "Repeat", "Quick Access Toolbar", |s, _| {
let Some((id, params)) = s.last_command.clone() else { return Err(CmdError::Failed("nothing to repeat".into())) };
s.run(&id, &params)
})
.key("F4")
.pure(),
CommandSpec::new("tools.recordMacro", "Record Macro", "View › Macros", |s, v| match s.recording.take() {
Some((name, steps)) => {
let n = steps.len();
s.macros.insert(name.clone(), steps);
Ok(json!({"recording": false, "saved": name, "steps": n}))
}
None => {
let name = p::str(v, "name").unwrap_or("Macro1").to_string();
s.recording = Some((name.clone(), Vec::new()));
Ok(json!({"recording": true, "name": name}))
}
})
.params(r#"{"name"?: string} (call again to stop)"#)
.pure(),
CommandSpec::new("tools.macros", "Macros", "View › Macros", macros)
.key("Alt+F8")
.params(r#"{"run"?: name, "define"?: {"name": string, "steps": [{"command", "params"}]}, "delete"?: name}"#)
.pure(),
CommandSpec::new("tools.autocorrect", "AutoCorrect Options", "File › Options › Proofing", autocorrect_options)
.params(
r#"{"enabled"?: bool (all of AutoCorrect), "replaceText"|"twoInitialCaps"|"capSentences"|"capCells"|"capDays"|"capsLock"|"smartQuotes"|"fractions"|"ordinals"|"dashes"|"links"|"bullets"|"numbering"|"borderLines"?: bool, "boldItalic"?: bool (*bold* and _italic_ with real formatting; default false), "tabIndent"?: bool (Set left- and first-indent with tabs and backspaces; default false), "headingStyles"?: bool (Built-in Heading styles; default false), "autoAddFirstLetter"|"autoAddInitialCaps"?: bool (Automatically add words to the exception lists when an automatic capital is undone; default true), "add"?: {"from": string, "to": string} (adds or replaces an entry), "delete"?: string (an entry's "from", built in or added), "addException"?: string, "deleteException"?: string, "addInitialCapsException"?: string (keeps TWo INitial CApitals, like "IDs"), "deleteInitialCapsException"?: string} → options, entries and exceptions"#,
)
.pure(),
CommandSpec::new("tools.customizeKeyboard", "Customize Keyboard", "File › Options", customize_keyboard)
.params(
r#"{"command"?: id (its keys), "key"?: "Mod+Shift+K" (the command it runs), "assign"?: {"command": id, "key": string}, "remove"?: {"command": id, "key": string}, "reset"?: true (built-in keys only)} → {custom: {assigned: {key: id}, removed: [key]}, …}; no params opens the dialog"#,
)
.pure(),
CommandSpec::new("tools.customizeRibbon", "Customize Ribbon", "File › Options", customize_ribbon)
.params(
r#"{"hide"?: tab, "show"?: tab, "newTab"?: {"name"?, "after"?: tab}, "newGroup"?: {"tab", "name"?}, "add"?: {"tab", "group", "command", "after"?: id}, "remove"?: {"tab", "group"?, "command"?}, "rename"?: {"tab", "group"?, "to"}, "move"?: {"tab", "group"?, "command"?, "by": int}, "qatAdd"?: id, "qatRemove"?: id, "qatMove"?: {"command", "by": int}, "reset"?: true | "ribbon" | "qat", "layout"?: object} → {tabs: [{name, custom?, hidden?, groups?: [{name, commands}]}], qat: [id]}; only custom tabs and groups can be renamed or removed (built-in tabs can be hidden and moved); no params opens the dialog"#,
)
.pure(),
// Word's Search box in the title bar ("Tell me what you want to do"), Alt+Q (#819).
CommandSpec::new("tools.search", "Search", "Title Bar", search_commands)
.key(super::non_mac("Alt+Q"))
.params(r#"{"query"?: string} → {matches: [{id, label, location, shortcut}]} (every word of the query in the label, location or id; at most 50); no query opens the Search box"#)
.pure(),
CommandSpec::new("review.compare", "Compare", "Review › Compare", compare).params(COMPARE_PARAMS),
CommandSpec::new("review.combine", "Combine", "Review › Compare", compare).params(COMPARE_PARAMS),
CommandSpec::new("file.accessibility", "Check Accessibility", "Review › Accessibility", accessibility).pure(),
CommandSpec::new("file.inspect", "Inspect Document", "File › Info", inspect_doc)
.params(r#"{"remove"?: ["comments", "revisions", "properties", "hidden", "headers"]} → {found: {comments, revisions, properties, hiddenText, hiddenCount, hidden: [{story: "body|header|footer|footnote|endnote|comment|textBox", part?, text}] (at most 1000), truncated, unsupported: [{story, object: "table|opaque", ..}] (hidden text not inspected: table styles, verbatim objects), headers}, removed, residual: [..] (hidden text a removal couldn't reach), removedNotes: [{kind, part, text}] (notes whose hidden reference was removed)}; whole hidden paragraphs go with their marks; the removals are refused while the document tracks changes"#),
CommandSpec::new("file.protect", "Protect Document", "File › Info", |s, v| {
let mode = p::str(v, "mode").unwrap_or("readOnly");
s.run("review.restrict", &json!({"mode": mode}))
})
.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| {
// Validate a restore before anything is saved: a rejected command must leave the
// version list as it was. A `restore` that is there but not a non-negative integer
// is refused, not read as absent.
let restore = if v.get("restore").is_some_and(|x| !x.is_null()) {
let i = p::u64(v, "restore").ok_or_else(|| CmdError::Params("restore (non-negative integer) is required".into()))?;
// A `u64` that doesn't fit in `usize` names no version: `as usize` would wrap it
// to a small index on a 32-bit target and restore the wrong document.
let i = usize::try_from(i).map_err(|_| CmdError::Params("no such version".into()))?;
let version = s.versions.get(i).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)?;
Some(version)
} else {
None
};
if let Some(label) = p::str(v, "save") {
s.versions.push((label.to_string(), super::now_iso(), s.doc.clone()));
if s.versions.len() > 50 {
s.versions.remove(0);
}
}
if let Some((_, _, d)) = restore {
s.checkpoint("Restore Version");
// A snapshot's own protection is stale: it was taken at some earlier moment, and
// restoring it must not bring a protection back that the live document doesn't
// have (#1327 follow-up).
let protection = s.doc.settings.protection.clone();
let protect_hash = s.doc.settings.protect_hash.clone();
let protect_formatting = s.doc.settings.protect_formatting;
s.doc = d;
s.doc.settings.protection = protection;
s.doc.settings.protect_hash = protect_hash;
s.doc.settings.protect_formatting = protect_formatting;
// The restored document reuses the open document's id; the body and any equation
// editing session are replaced.
s.note_body_replaced();
s.math = None;
s.touch();
s.clamp_selection();
}
Ok(Value::Array(s.versions.iter().enumerate().map(|(i, (l, d, _))| json!({"index": i, "label": l, "date": d})).collect()))
})
.params(r#"{"save"?: label, "restore"?: index}"#)
.pure(),
CommandSpec::new("file.recover", "Recover Unsaved Documents", "File › Info", |s, v| s.recover(v))
.params(r#"{"restore"?: recoveryId, "discard"?: recoveryId, "refresh"?: true}; desktop only; returns {enabled,busy,documents,error}; restore loads asynchronously without a file path"#)
.pure(),
CommandSpec::new("file.newFromTemplate", "New from Template", "File › New", |s, v| {
let path = p::req_str(v, "path")?;
let mut doc = crate::io::open_path(std::path::Path::new(path)).map_err(CmdError::Failed)?;
// As in Word, the new document is attached to the template it came from (#2852).
doc.settings.attached_template = Some(path.to_string());
s.set_document(doc);
s.path = None;
s.dirty = true;
sel_result(s)
})
.params(r#"{"path": string} → a new, untitled document from the template, attached to it"#)
.pure(),
CommandSpec::new("file.saveTemplate", "Save as Template", "File › Save As", |s, v| {
let path = p::req_str(v, "path")?;
// A .dotm keeps its name (#2852); anything else becomes a .dotx.
let path = if super::user_templates::is_template_name(path) { path.to_string() } else { format!("{path}.dotx") };
crate::io::save_path(std::path::Path::new(&path), &s.doc).map_err(CmdError::Failed)?;
Ok(json!({"path": path}))
})
.params(r#"{"path": string}"#)
.pure(),
CommandSpec::new("file.autosave", "AutoSave", "Quick Access Toolbar", |s, v| {
s.autosave = p::bool(v, "value").unwrap_or(!s.autosave);
Ok(json!({"value": s.autosave}))
})
.pure(),
CommandSpec::new("file.compatibility", "Check Compatibility", "File › Info", |s, _| {
let mut issues = Vec::new();
if s.doc
.para_paths(StoryRef::Body)
.iter()
.any(|p| s.doc.para(StoryRef::Body, p).is_some_and(|x| x.objects.iter().any(|o| matches!(o, InlineObject::Opaque { .. }))))
{
issues.push("Some embedded objects from the original file are shown as text only.");
}
Ok(json!({"issues": issues}))
})
.pure(),
CommandSpec::new("file.share", "Share", "File", |s, _| {
s.push_ui_request(json!({"open": "saveAs"}));
Ok(json!({"hint": "Save or export a copy (PDF, Word document) to share it."}))
})
.pure(),
CommandSpec::new("insert.quickParts", "Quick Parts", "Insert › Text", |s, v| quick_parts(s, v, wordcraft_doc::QUICK_PARTS_GALLERY)).params(
r#"{"save"?: name (from the selection), "in"?: "document" (save into the document's own building blocks, kept in its file; else this session's), "insert"?: name, "delete"?: name} → {"parts": names (this session's, then the document's Quick Parts), "document": the document's Quick Parts}"#,
),
CommandSpec::new("insert.autoText", "AutoText", "Insert › Text › Quick Parts", |s, v| quick_parts(s, v, wordcraft_doc::AUTO_TEXT_GALLERY))
.key(super::non_mac("Alt+F3"))
.params(r#"{"save"?: name, "in"?: "document", "insert"?: name, "delete"?: name} → {"parts": names, "document": the document's AutoText}"#),
CommandSpec::new("insert.docProperty", "Document Property", "Insert › Text › Quick Parts", |s, v| {
let name = p::str(v, "name").unwrap_or("Title").to_string();
let c = &s.doc.core;
let val = match name.to_lowercase().as_str() {
"title" => c.title.clone(),
"author" | "creator" => c.creator.clone(),
"subject" => c.subject.clone(),
"keywords" => c.keywords.clone(),
"comments" | "description" => c.description.clone(),
"category" => c.category.clone(),
_ => return Err(CmdError::Params("unknown property".into())),
};
let instr = match name.to_lowercase().as_str() {
"author" | "creator" => "AUTHOR".to_string(),
"title" => "TITLE".to_string(),
other => format!("DOCPROPERTY {other}"),
};
let props = s.typing_props();
let at = delete_selection(s)?;
let end = s.doc.insert_object(&at, InlineObject::Field { instr, result: val, locked: false, dirty: false, code: false, form: String::new() }, &props)?;
s.sel = Selection::caret(end);
sel_result(s)
})
.params(r#"{"name": "Title|Author|Subject|Keywords|Comments|Category"}"#),
CommandSpec::new("insert.signatureLine", "Signature Line", "Insert › Text", |s, v| {
let signer = p::str(v, "signer").unwrap_or("").to_string();
let title = p::str(v, "title").unwrap_or("").to_string();
let at = delete_selection(s)?;
let mut lines = vec!["X ______________________________".to_string()];
if !signer.is_empty() {
lines.push(signer);
}
if !title.is_empty() {
lines.push(title);
}
let frag = wordcraft_doc::edit::Fragment {
blocks: lines.iter().map(|l| Block::Para(Paragraph::with_text(l, CharProps::default()).styled("NoSpacing"))).collect(),
..Default::default()
};
let end = s.doc.insert_fragment(&at, &frag)?;
s.sel = Selection::caret(end);
sel_result(s)
})
.params(r#"{"signer"?: string, "title"?: string}"#),
CommandSpec::new("insert.object", "Object", "Insert › Text", |s, v| {
let path = p::req_str(v, "path")?;
s.run("insert.textFromFile", &json!({"path": path}))
})
.params(r#"{"path": string}"#),
CommandSpec::new("insert.spreadsheet", "Spreadsheet Table", "Insert › Tables", |s, v| {
let csv = p::str(v, "csv").unwrap_or("A,B,C\n1,2,3\n4,5,6");
let rows = super::mailings::parse_csv(csv);
let cols = rows.iter().map(Vec::len).max().unwrap_or(1).clamp(1, 63);
s.run("insert.table", &json!({"rows": rows.len().max(1), "cols": cols, "style": "GridTable1Light"}))?;
let Some((tp, _, _)) = s.sel.focus.path.cell() else { return sel_result(s) };
let t = s.doc.table_mut(s.sel.focus.story, &tp)?;
for (r, row) in rows.iter().enumerate() {
for (c, val) in row.iter().enumerate() {
if let Some(cell) = t.rows.get_mut(r).and_then(|x| x.cells.get_mut(c)) {
cell.blocks = vec![para_block(Paragraph::with_text(val, CharProps::default()))];
}
}
}
sel_result(s)
})
.params(r#"{"csv"?: string}"#),
CommandSpec::new("layout.linkToPrevious", "Link to Previous", "Header & Footer › Navigation", |s, v| {
let header = p::bool(v, "footer").map(|f| !f).unwrap_or(true);
let block = s.sel.focus.path.0.first().copied().unwrap_or(0) as usize;
let sect = s.doc.section_mut(block);
if header {
sect.headers = Default::default();
} else {
sect.footers = Default::default();
}
Ok(json!({"linked": true}))
})
.key(super::non_mac("Alt+Shift+R"))
.params(r#"{"footer"?: bool}"#),
CommandSpec::new("hf.next", "Next Section", "Header & Footer › Navigation", |s, _| hf_nav(s, 1)).pure(),
CommandSpec::new("hf.previous", "Previous Section", "Header & Footer › Navigation", |s, _| hf_nav(s, -1)).pure(),
CommandSpec::new("hf.position", "Header/Footer Position", "Header & Footer › Position", |s, v| {
let (h, f) = (p::f32(v, "header"), p::f32(v, "footer"));
let block = s.sel.focus.path.0.first().copied().unwrap_or(0) as usize;
let sect = s.doc.section_mut(block);
if let Some(h) = h {
sect.header = h.clamp(0.0, 300.0);
}
if let Some(f) = f {
sect.footer = f.clamp(0.0, 300.0);
}
Ok(json!({"header": sect.header, "footer": sect.footer}))
})
.params(r#"{"header"?: pt, "footer"?: pt}"#),
CommandSpec::new("table.cellMargins", "Cell Margins", "Table Layout › Alignment", |s, v| {
let Some((tp, _, _)) = s.sel.focus.path.cell() else { return Err(CmdError::Disabled("not in a table".into())) };
let story = s.sel.focus.story;
let current = {
let table = s.doc.table(story, &tp).ok_or_else(|| CmdError::Failed("no table".into()))?;
let style = table.props.style.as_deref().and_then(|id| s.doc.styles.table_style(id));
table.props.cell_margins.or(style.and_then(|st| st.parts.cell_margins)).unwrap_or([0.0, 5.4, 0.0, 5.4])
};
let m = [
p::f32(v, "top").unwrap_or(current[0]),
p::f32(v, "left").unwrap_or(current[1]),
p::f32(v, "bottom").unwrap_or(current[2]),
p::f32(v, "right").unwrap_or(current[3]),
]
.map(|x| x.clamp(0.0, 144.0));
s.doc.table_mut(story, &tp)?.props.cell_margins = Some(m);
for path in s.doc.para_paths(story).into_iter().filter(|p| p.0.starts_with(&tp.0) && p.0.len() > tp.0.len()) {
s.doc.para_mut(story, &path)?.touch();
}
sel_result(s)
})
.params(r#"{"top"?, "left"?, "bottom"?, "right"? (pt)}"#),
CommandSpec::new("table.borderPainter", "Border Painter", "Table Design › Borders", |s, v| {
s.run(
"table.borders",
&json!({"kind": p::str(v, "kind").unwrap_or("all"), "width": p::f32(v, "width").unwrap_or(0.5), "color": p::str(v, "color")}),
)
}),
CommandSpec::new("para.setNumberingValue", "Set Numbering Value", "Home › Paragraph › Numbering", |s, v| {
let value = p::u64(v, "value").unwrap_or(1).clamp(0, 100_000) as u32;
let f = s.sel.focus.clone();
let n = s
.doc
.para_at(&f)
.and_then(|x| x.props.numbering)
.filter(|n| n.num != 0)
.ok_or_else(|| CmdError::Failed("not in a numbered list".into()))?;
let new = s.doc.numbering.restart(n.num).ok_or_else(|| CmdError::Failed("bad list".into()))?;
if let Some(num) = s.doc.numbering.nums.iter_mut().find(|x| x.id == new) {
num.start_overrides = vec![(n.level, value)];
}
let paths: Vec<_> = s.doc.para_paths(f.story).into_iter().filter(|q| *q >= f.path).collect();
for path in paths {
let para = s.doc.para_mut(f.story, &path)?;
match para.props.numbering {
Some(x) if x.num == n.num => para.props.numbering = Some(NumRef { num: new, level: x.level }),
_ => break,
}
para.touch();
}
sel_result(s)
})
.params(r#"{"value": n}"#),
CommandSpec::new("para.defineBullet", "Define New Bullet", "Home › Paragraph › Bullets", |s, v| {
s.run("para.bullets", &json!({"kind": p::str(v, "char").unwrap_or("★")}))
})
.params(r#"{"char": string}"#),
CommandSpec::new("para.defineNumber", "Define New Number Format", "Home › Paragraph › Numbering", |s, v| {
let fmt = wordcraft_doc::section::NumFormat::from_ooxml(p::str(v, "format").unwrap_or("decimal"));
let text = p::str(v, "text").unwrap_or("%1.").to_string();
let start = p::u64(v, "start").unwrap_or(1).min(100_000) as u32;
let id = s.doc.numbering.try_add_list(wordcraft_doc::ListKind::Numbered)?;
if let Some(a) = s.doc.numbering.abstract_of(id).map(|a| a.id)
&& let Some(abs) = s.doc.numbering.abstracts.iter_mut().find(|x| x.id == a)
&& let Some(l) = abs.levels.first_mut()
{
l.format = fmt;
l.text = text;
l.start = start;
}
super::para::fmt(s, &|pp| {
pp.numbering = Some(NumRef { num: id, level: 0 });
pp.style = Some("ListParagraph".into());
})
})
.params(r#"{"format": "decimal|upperRoman|lowerLetter…", "text"?: "%1.", "start"?: n}"#),
CommandSpec::new("review.language", "Language", "Review › Language", language).params(
r#"{"lang"?: "en-US|en-GB|pl-PL|fr-FR|…" (BCP 47; default en-US), "noProof"?: bool, "detect"?: bool (detect language automatically, a preference), "default"?: bool (also the document's default and the saved language for future blank documents)}"#,
),
CommandSpec::new("review.showMarkup", "Show Markup", "Review › Tracking", |s, v| {
s.view.show_markup = p::bool(v, "value").unwrap_or(!s.view.show_markup);
s.relayout();
Ok(json!({"value": s.view.show_markup}))
})
.pure(),
CommandSpec::new("review.editor", "Editor", "Home › Editor", |s, v| s.run("review.spelling", v)).pure(),
CommandSpec::new("select.similar", "Select Text with Similar Formatting", "Home › Editing › Select", select_similar).pure(),
CommandSpec::new("select.extend", "Extend Selection", "Editing › Selection", |s, _| {
// F8 grows the selection: word → sentence → paragraph → document.
let (a, b) = s.sel.ordered();
let para_len = s.doc.para_at(&a).map(|x| x.len()).unwrap_or(0);
let id = if a == b {
"select.word"
} else if a.path == b.path && (a.off > 0 || b.off < para_len) {
if s.doc.para_at(&a).map(|x| x.sentence_at(a.off)) == Some((a.off, b.off)) { "select.paragraph" } else { "select.sentence" }
} else {
"select.all"
};
let params = if id == "select.all" { json!({"whole": true}) } else { json!({}) };
s.run(id, &params)
})
.key("F8")
.pure(),
CommandSpec::new("design.effects", "Effects", "Design › Document Formatting", |s, v| {
s.doc.settings.theme_name =
format!("{} ({})", s.doc.settings.theme_name.split(" (").next().unwrap_or("Craft"), p::str(v, "name").unwrap_or("Subtle"));
sel_result(s)
}),
CommandSpec::new("view.immersive", "Immersive Reader", "View › Immersive", |s, _| {
s.view.read_mode = !s.view.read_mode;
Ok(json!({"value": s.view.read_mode}))
})
.pure(),
CommandSpec::new("view.vertical", "Vertical", "View › Page Movement", |s, _| {
s.view.multi_page = false;
s.view.fit.clear();
Ok(json!({"pageMovement": "vertical"}))
})
.pure(),
CommandSpec::new("view.sideToSide", "Side to Side", "View › Page Movement", |s, _| {
s.view.multi_page = true;
s.view.single_column = false;
s.view.fit = "multiplePages".into();
Ok(json!({"pageMovement": "sideToSide"}))
})
.pure(),
]
}
/// Set Proofing Language: the selection's language and "do not check spelling or grammar",
/// or (`default`) the document's default and the saved language for future blank documents;
/// `detect` is the per-user detection preference.
/// Nothing at all sets US English, as before.
fn language(s: &mut Session, v: &Value) -> CmdResult {
let lang = match p::str(v, "lang") {
Some(t) => Some(
wordcraft_proof::lang::normalize_tag(t)
.ok_or_else(|| CmdError::Params(format!("`lang` must be a language tag such as pl-PL or en-US, not {t:?}")))?,
),
None => None,
};
let no_proof = p::bool(v, "noProof");
let detect = p::bool(v, "detect");
if let Some(d) = detect {
s.prefs.detect_language = d;
}
if p::bool(v, "default") == Some(true) {
let l = lang.clone().ok_or_else(|| CmdError::Params("`default` needs `lang`".into()))?;
s.doc.styles.default_chr.lang = Some(l.clone());
// A Normal style that names its own language follows the new default.
if let Some(n) = s.doc.styles.styles.iter_mut().find(|x| x.id == "Normal")
&& n.chr.lang.is_some()
{
n.chr.lang = Some(l);
}
}
let lang = match (lang, no_proof, detect) {
(None, None, None) => Some("en-US".to_string()),
(l, ..) => l,
};
if lang.is_none() && no_proof.is_none() {
return Ok(json!({"detect": s.prefs.detect_language}));
}
let result = super::format::apply(s, &|c| {
if let Some(l) = &lang {
c.lang = Some(l.clone());
}
if let Some(np) = no_proof {
c.no_proof = Some(np);
}
})?;
if p::bool(v, "default") == Some(true) {
s.prefs.default_language = lang;
}
Ok(result)
}
fn macros(s: &mut Session, v: &Value) -> CmdResult {
if let Some(d) = v.get("define") {
let name = d.get("name").and_then(Value::as_str).unwrap_or("Macro").to_string();
let steps: Vec<(String, Value)> = d
.get("steps")
.and_then(Value::as_array)
.map(|a| {
a.iter().filter_map(|st| Some((st.get("command")?.as_str()?.to_string(), st.get("params").cloned().unwrap_or(json!({}))))).collect()
})
.unwrap_or_default();
s.macros.insert(name, steps);
}
if let Some(n) = p::str(v, "delete") {
s.macros.remove(n);
}
if let Some(n) = p::str(v, "run") {
let steps = s.macros.get(n).cloned().ok_or_else(|| CmdError::Params(format!("no macro `{n}`")))?;
for (id, params) in steps.iter().take(10_000) {
if id == "tools.macros" {
continue;
}
s.run(id, params)?;
}
return Ok(json!({"ran": n, "steps": steps.len()}));
}
Ok(Value::Object(
s.macros.iter().map(|(k, v)| (k.clone(), json!(v.iter().map(|(c, p)| json!({"command": c, "params": p})).collect::<Vec<_>>()))).collect(),
))
}
/// Apply AutoCorrect to the word just before the caret (called after typing a space/punctuation):
/// replacements, then ordinal superscripts, web addresses as links and sentence capitals.
pub fn autocorrect(s: &mut Session) -> Result<(), CmdError> {
autocorrect_word(s, false)
}
/// AutoCorrect the word ending at the caret; `on_enter` means Enter finished it (no trigger
/// character was typed).
pub fn autocorrect_word(s: &mut Session, on_enter: bool) -> Result<(), CmdError> {
if !s.prefs.autocorrect.enabled {
return Ok(());
}
// The word without its marks goes on through the steps below ("*teh* " → "The ").
let outside = emphasis_autoformat(s, on_enter)?;
let ac = s.prefs.autocorrect.clone();
let mut f = s.sel.focus.clone();
let Some(text) = s.doc.para_at(&f).map(|p| p.text.clone()) else { return Ok(()) };
let Some(before) = text.get(..f.off) else { return Ok(()) };
let Some(last) = before.chars().next_back() else { return Ok(()) };
if !(on_enter || last == ' ' || ",.;:!?".contains(last)) {
return Ok(());
}
let trigger = if on_enter { 0 } else { last.len_utf8() };
let Some(body) = before.get(..before.len() - trigger) else { return Ok(()) };
// The word starts after the last white-space character, whatever its width (a no-break
// space is two bytes and an ideographic space is three).
let mut start = body.char_indices().rev().find(|(_, c)| c.is_whitespace()).map(|(i, c)| i + c.len_utf8()).unwrap_or(0);
let Some(mut word) = body.get(start..).map(str::to_string) else { return Ok(()) };
if word.is_empty() {
return Ok(());
}
// The opening mark of a `*bold*` or `_italic_` span is not part of the word, so the first
// word of a span of several words is corrected as it is typed ("*teh end*" → "*the end*").
let mark = word.chars().next().filter(|c| ac.bold_italic && (*c == '*' || *c == '_'));
let mut opened = false;
if let Some(rest) = mark.and_then(|m| word.get(1..).filter(|r| !r.is_empty() && !r.contains(m))) {
start += 1;
word = rest.to_string();
opened = true;
}
// The word's language (run, paragraph style, document default) picks the replacement list
// (#835) and the case mapping (#1923). Text with no language at all is in the editing
// language (a Polish interface writes Polish), else English.
let lang: Option<String> = s.doc.para_at(&f).and_then(|p| {
let rc = s.doc.styles.resolve_char(p.props.style.as_deref(), p.props_of_char(start));
rc.lang.clone()
});
let lang = lang.or_else(|| s.editing_lang.clone());
let turkic = is_turkic(lang.as_deref());
// TWo INitial CApitals and cAPS LOCK come first, so "TEh" is still a replacement's typo.
if let Some((at, reps)) = case_fix(&ac, &word, turkic) {
let para = s.doc.para_mut(f.story, &f.path)?;
let letters = word.get(at..).unwrap_or("").chars().take(reps.len()).collect::<String>();
let mut end = start + at + letters.len();
for (c, rep) in letters.chars().rev().zip(reps.iter().rev()) {
let from = end.saturating_sub(c.len_utf8());
if rep.chars().ne(std::iter::once(c)) {
let props = para.props_of_char(from).clone().without_symbol();
para.delete(from, end)?;
para.insert_text(from, rep, &props)?;
}
end = from;
}
s.autocap = Some(AutoCap {
depth: u64::MAX,
story: f.story,
path: f.path.clone(),
start,
prev: String::new(),
two_caps: (ac.two_initial_caps
&& letters.chars().nth(1).is_some_and(char::is_uppercase)
&& letters.chars().next().is_some_and(char::is_uppercase))
.then(|| letters.clone()),
backspaced: false,
});
let fixed: String = reps.concat();
f.off = (f.off + fixed.len()).saturating_sub(letters.len());
s.sel = Selection::caret(f.clone());
word = format!("{}{fixed}{}", word.get(..at).unwrap_or(""), word.get(at + letters.len()..).unwrap_or(""));
}
// A whole-word replacement, else "wait..." → "wait…". Proofing decides what is English the
// same way (#835); every English (en-GB, en-CA…) counts (#1281).
let english = s.doc.para_at(&f).is_none_or(|_| wordcraft_proof::ProofLang::from_tag(lang.as_deref()).is_english());
let found = if ac.replace_text { ac.replacement_in(&word, english) } else { None };
let (from, rep) = match found {
Some(r) => (start, Some(r)),
None if ac.replace_text && word.len() > 3 && word.ends_with("...") => (start + word.len() - 3, Some("…".to_string())),
None if ac.replace_text => match SMILEYS.iter().find(|m| word.len() > m.len() && word.ends_with(**m)) {
// "ok:)" → "ok☺": only the smiley at the end of the word is replaced.
Some(m) => (start + word.len() - m.len(), AUTOCORRECT.iter().find(|(a, _)| a == m).map(|(_, b)| b.to_string())),
None => (start, None),
},
None => (start, None),
};
let replaced = rep.is_some();
let mut caret = f.off;
if let Some(rep) = rep {
let end = start + word.len();
let para = s.doc.para_mut(f.story, &f.path)?;
// The replacement is text: a symbol it replaces doesn't give it its symbol font (#2036).
let props = para.props_of_char(from).clone().without_symbol();
para.delete(from, end)?;
para.insert_text(from, &rep, &props)?;
caret = (f.off + rep.len()).saturating_sub(end - from);
s.sel = Selection::caret(Pos { off: caret, ..f.clone() });
}
let end = caret.saturating_sub(trigger);
let Some(word) = s.doc.para_at(&f).and_then(|p| p.text.get(start..end)).map(str::to_string) else { return Ok(()) };
let at = |off: usize| Pos { off, ..f.clone() };
// 1st, 22nd, 103rd → superscript suffix.
if let Some(n) = ordinal_suffix(&word).filter(|_| ac.ordinals) {
s.doc.format_range(&at(end - n), &at(end), &|c| c.vert_align = Some(wordcraft_doc::props::VertAlign::Superscript))?;
return Ok(());
}
// Web addresses become links (trailing punctuation stays outside).
let lower = word.to_ascii_lowercase();
if ac.links && (lower.starts_with("http://") || lower.starts_with("https://") || lower.starts_with("www.")) && word.len() > 8 {
let url = word.trim_end_matches(|c: char| ".,;:!?)]\"'”’".contains(c));
let href = if lower.starts_with("www.") { format!("http://{url}") } else { url.to_string() };
let url_end = start + url.len();
s.doc.format_range(&at(start), &at(url_end), &|c| {
c.link = Some(href.clone());
c.style = Some("Hyperlink".into());
})?;
// Typing after the link isn't part of it (but keeps the text's other formatting, or
// the formatting outside a `*bold*` or `_italic_` span).
let mut after = outside.unwrap_or_else(|| s.doc.para_at(&f).map(|p| p.props_of_char(start).clone()).unwrap_or_default());
after.link = None;
after.style = None;
s.pending = Some(after);
return Ok(());
}
// A sentence starts before the opening mark, as for the word without it.
let before_word = text.get(..start - usize::from(opened)).unwrap_or("");
// The first word in a table cell follows its own option; other sentences the sentence one.
let sentence_on = if before_word.trim().is_empty() && f.path.cell().is_some() { ac.cap_cells } else { ac.cap_sentences };
let exceptions = ac.exception_list();
// A replacement of several words ("brb" → "be right back") is capitalised by its first.
let first = word.split_whitespace().next().unwrap_or("");
let next = if first.len() < word.len() {
' '
} else if on_enter {
'\n'
} else {
last
};
let mut sentence = sentence_on && starts_sentence(before_word, &exceptions) && should_capitalize(first, next);
let prev = if sentence { prev_token(before_word) } else { String::new() };
// Typing the word again after backspacing an automatic capital: the user doesn't want it
// (Word's "Automatically add words to list"); an exception, so no capital this time either.
if sentence
&& !prev.is_empty()
&& s.autocap.take_if(|c| c.backspaced && c.story == f.story && c.path == f.path && c.start == start && c.prev == prev).is_some()
&& ac.auto_add_first_letter
{
s.prefs.autocorrect.add_exception(prev.clone());
sentence = false;
}
let day = ac.cap_days && DAYS.contains(&first);
if sentence || day {
let para = s.doc.para_mut(f.story, &f.path)?;
if let Some(c) = word.chars().next() {
let up = upper_char(c, turkic);
let props = para.props_of_char(start).clone().without_symbol();
para.delete(start, start + c.len_utf8())?;
para.insert_text(start, &up, &props)?;
let off = (caret + up.len()).saturating_sub(c.len_utf8());
s.sel = Selection::caret(Pos { off, ..f.clone() });
if sentence && !prev.is_empty() && !replaced {
match s.autocap.as_mut().filter(|c| c.depth == u64::MAX) {
Some(c) => c.prev = prev,
None => {
s.autocap = Some(AutoCap { depth: u64::MAX, story: f.story, path: f.path, start, prev, two_caps: None, backspaced: false })
}
}
}
}
}
Ok(())
}
/// The lowercase token before a word that starts a sentence ("there." in "there. world"), as
/// [`starts_sentence`] reads it; empty at the start of a paragraph.
fn prev_token(before: &str) -> String {
let t = before.trim_end().trim_end_matches(|c: char| "\"'”’)]".contains(c));
// Only full stops: Word's exception was observed with "there." (`there?` isn't added).
if !t.ends_with('.') {
return String::new();
}
t.rsplit(char::is_whitespace).next().unwrap_or("").trim_start_matches(|c: char| "\"'“‘([".contains(c)).to_lowercase()
}
/// Turkish, Azerbaijani (and Crimean Tatar) map `i`/`İ` and `ı`/`I` as pairs (#1923).
pub(crate) fn is_turkic(lang: Option<&str>) -> bool {
if wordcraft_proof::ProofLang::from_tag(lang) == wordcraft_proof::ProofLang::Tr {
return true; // tr, tur, any region, same normalisation as proofing
}
let Some(tag) = lang.map(str::trim) else { return false };
let primary = tag.split(['-', '_']).next().unwrap_or("");
["az", "aze", "crh"].iter().any(|c| primary.eq_ignore_ascii_case(c))
}
/// A letter in capitals: `i` → `İ` in Turkic languages, otherwise the Unicode default.
pub(crate) fn upper_char(c: char, turkic: bool) -> String {
if turkic && c == 'i' {
return "\u{130}".to_string();
}
c.to_uppercase().collect()
}
/// A letter in lowercase: `I` → `ı` in Turkic languages. `İ` is always the single letter `i`,
/// never `i` plus a combining dot.
pub(crate) fn lower_char(c: char, turkic: bool) -> String {
match c {
'I' if turkic => "\u{131}".to_string(),
'\u{130}' => "i".to_string(),
_ => c.to_lowercase().collect(),
}
}
/// Built-in words that keep their two initial capitals (plurals of two-letter abbreviations).
pub const INITIAL_CAPS_EXCEPTIONS: &[&str] = &["CDs", "IDs", "PCs", "TVs", "DJs", "GBs", "KBs", "MBs", "TBs", "VMs"];
/// Words with a lowercase first letter and the rest in capitals that aren't cAPS LOCK slips.
const CAPS_LOCK_EXCEPTIONS: &[&str] = &["iOS", "mRNA", "tRNA", "rRNA", "cDNA", "eSIM", "eSATA", "iSCSI", "vCPU"];
/// The capitalisation fix for a finished word: TWo INitial CApitals ("WOrd" → "Word") or cAPS
/// LOCK ("wORD" → "Word"). Returns the byte offset of the word's letters and each letter's
/// replacement. Only words of three or more letters, optionally in quotes or brackets.
fn case_fix(caps: &AutoCorrectPrefs, word: &str, turkic: bool) -> Option<(usize, Vec<String>)> {
if !caps.two_initial_caps && !caps.caps_lock {
return None;
}
let at = word.find(char::is_alphabetic)?;
let rest = word.get(at..)?;
let letters = rest.get(..rest.find(|c: char| !c.is_alphabetic()).unwrap_or(rest.len()))?;
let tail = rest.get(letters.len()..)?;
if !word.get(..at)?.chars().all(|c| "\"'“‘([".contains(c)) || !tail.chars().all(|c| "\"'”’)]".contains(c)) {
return None;
}
let cs: Vec<char> = letters.chars().collect();
let (&a, &b, more) = match cs.as_slice() {
[a, b, more @ ..] if !more.is_empty() => (a, b, more),
_ => return None,
};
let one = |c: char| c.to_string();
let lower = |c: char| lower_char(c, turkic);
if caps.two_initial_caps
&& a.is_uppercase()
&& b.is_uppercase()
&& more.iter().all(|c| c.is_lowercase())
&& !INITIAL_CAPS_EXCEPTIONS.contains(&letters)
&& !caps.initial_caps_exceptions.iter().any(|e| e == letters)
{
return Some((at, std::iter::once(one(a)).chain(std::iter::once(lower(b))).chain(more.iter().map(|&c| one(c))).collect()));
}
if caps.caps_lock && a.is_lowercase() && b.is_uppercase() && more.iter().all(|c| c.is_uppercase()) && !CAPS_LOCK_EXCEPTIONS.contains(&letters) {
let up = upper_char(a, turkic);
return Some((at, std::iter::once(up).chain(std::iter::once(lower(b))).chain(more.iter().map(|&c| lower(c))).collect()));
}
None
}
/// *Bold* and _italic_ with real formatting, for the word ending at the caret (`on_enter` as for
/// [`autocorrect_word`]): the two marks are removed and the text between them is made bold or
/// italic, for plain and complex-script text. Returns the closing mark's formatting, which is
/// the formatting outside the span, when anything changed.
pub fn emphasis_autoformat(s: &mut Session, on_enter: bool) -> Result<Option<wordcraft_doc::props::CharProps>, CmdError> {
let Some((open, close, bold)) = emphasis_at_caret(s, on_enter) else { return Ok(None) };
let f = s.sel.focus.clone();
let para = s.doc.para_mut(f.story, &f.path)?;
let outside = para.props_of_char(close).clone();
para.delete(close, close + 1)?;
para.delete(open, open + 1)?;
para.format(open, close - 1, &|c| {
if bold {
c.bold = Some(true);
c.bold_cs = Some(true);
} else {
c.italic = Some(true);
c.italic_cs = Some(true);
}
})?;
s.sel = Selection::caret(Pos { off: f.off.saturating_sub(2), ..f });
Ok(Some(outside))
}
/// Would [`emphasis_autoformat`] change the word ending at the caret?
pub fn has_emphasis(s: &Session, on_enter: bool) -> bool {
emphasis_at_caret(s, on_enter).is_some()
}
/// The `*bold*` or `_italic_` span ending at the caret (see [`emphasis_span`]) when the option is
/// on. With Track Changes on, only the current author's tracked insertions are converted, since
/// removing the marks from other text would not be tracked. Nothing is converted while the
/// document limits formatting to its styles.
fn emphasis_at_caret(s: &Session, on_enter: bool) -> Option<(usize, usize, bool)> {
// Formatting limited to the document's styles allows no direct bold or italic.
let styles_only = super::protect::enforced(&s.doc) && s.doc.settings.protect_formatting;
if !(s.prefs.autocorrect.enabled && s.prefs.autocorrect.bold_italic) || styles_only {
return None;
}
let f = &s.sel.focus;
let para = s.doc.para_at(f)?;
let before = para.text.get(..f.off)?;
let trigger = match before.chars().next_back() {
_ if on_enter => 0,
Some(c) if c == ' ' || ",.;:!?".contains(c) => c.len_utf8(),
_ => return None,
};
let span = before.get(..before.len() - trigger).and_then(emphasis_span)?;
let tracked = |(open, close, _): &(usize, usize, bool)| {
!s.doc.settings.track_changes
|| para
.run_ranges()
.filter(|(r, _)| r.start <= *close && r.end > *open)
.all(|(_, p)| p.del.is_none() && p.ins.and_then(|i| s.doc.revisions.get(i as usize)).is_some_and(|rv| rv.author == s.author))
};
Some(span).filter(tracked)
}
/// The marks of a `*bold*` or `_italic_` span that `text` ends with: the byte offsets of the
/// opening and closing mark, and whether it is bold. The opening mark starts a word (it follows
/// the paragraph start, white space or an opening bracket or quote), the text between the marks
/// holds a letter or digit and neither starts nor ends with white space, so `2*3*`, `file_name_`
/// and `*?!*` stay as typed. Text holding an object (a picture, field or content control) is left
/// alone.
fn emphasis_span(text: &str) -> Option<(usize, usize, bool)> {
let mark = text.chars().next_back().filter(|c| *c == '*' || *c == '_')?;
let close = text.len() - 1;
let open = text.get(..close)?.rfind(mark)?;
let inner = text.get(open + 1..close)?;
let edge_ok = |c: Option<char>| c.is_some_and(|c| !c.is_whitespace());
if !edge_ok(inner.chars().next())
|| !edge_ok(inner.chars().next_back())
|| !inner.chars().any(char::is_alphanumeric)
|| inner.contains(wordcraft_doc::para::OBJ)
{
return None;
}
let starts_word = text.get(..open)?.chars().next_back().is_none_or(|c| c.is_whitespace() || "([{\"'“‘".contains(c));
starts_word.then_some((open, close, mark == '*'))
}
/// Byte length of an ordinal suffix to superscript ("1st" → 2), if `word` is a correct ordinal.
fn ordinal_suffix(word: &str) -> Option<usize> {
let digits = word.find(|c: char| !c.is_ascii_digit())?;
let (num, suffix) = word.split_at(digits);
if num.is_empty() || num.len() > 9 {
return None;
}
let n: u64 = num.parse().ok()?;
let want = match (n % 100, n % 10) {
(11..=13, _) => "th",
(_, 1) => "st",
(_, 2) => "nd",
(_, 3) => "rd",
_ => "th",
};
(suffix == want).then_some(2)
}
/// Abbreviations whose full stop doesn't end a sentence.
const NOT_SENTENCE_END: &[&str] = &[
"e.g.", "i.e.", "etc.", "vs.", "cf.", "mr.", "mrs.", "ms.", "dr.", "st.", "no.", "approx.", "fig.", "p.", "pp.", "jr.", "sr.", "ca.", "inc.",
"ltd.", "co.", "vol.", "a.m.", "p.m.",
];
/// Names of days, capitalised as you type (Capitalize names of days).
const DAYS: [&str; 7] = ["monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday"];
/// Does a word typed after `before` (the paragraph text up to the word) begin a sentence?
/// `exceptions` are the lowercase abbreviations whose full stop doesn't end one.
fn starts_sentence(before: &str, exceptions: &[String]) -> bool {
let t = before.trim_end();
if t.is_empty() {
return true;
}
if t.len() == before.len() {
return false; // no space between the previous text and the word
}
let t = t.trim_end_matches(|c: char| "\"'”’)]".contains(c));
if !t.ends_with(['.', '!', '?']) || t.ends_with("..") || t.ends_with('…') {
return false;
}
let prev = t.rsplit(char::is_whitespace).next().unwrap_or(t).trim_start_matches(|c: char| "\"'“‘([".contains(c));
let lower = prev.to_lowercase();
// "J." (an initial) or a known abbreviation.
let initial = prev.chars().count() == 2 && prev.chars().next().is_some_and(char::is_uppercase);
!initial && !exceptions.contains(&lower)
}
/// Only plain lowercase words are capitalised (not "iPhone", "x2", file names or addresses).
fn should_capitalize(word: &str, next: char) -> bool {
// A single letter before a full stop is a list label or an initial ("a.", "j.").
if (next == '.' || word.ends_with('.')) && word.trim_end_matches('.').chars().count() < 2 {
return false;
}
if word == "e.g." || word == "i.e." {
return true;
}
word.chars().next().is_some_and(char::is_lowercase)
&& word.chars().all(|c| c.is_alphabetic() || "'’-.".contains(c))
&& !word.chars().any(char::is_uppercase)
&& !word.trim_end_matches('.').contains('.')
}
/// What Compare / Combine compares (the More >> settings of Word's dialog).
struct CompareOptions {
/// Differences only in letter case count as changes.
case: bool,
/// Differences only in white space count as changes.
white_space: bool,
/// Formatting differences become tracked formatting changes.
formatting: bool,
/// Changed paragraphs are compared character by character (else word by word).
characters: bool,
}
impl CompareOptions {
/// The text a comparison sees: case folded and white space collapsed as the options say.
fn key(&self, t: &str) -> String {
let t = if self.white_space { t.to_string() } else { t.split_whitespace().collect::<Vec<_>>().join(" ") };
if self.case { t } else { t.to_lowercase() }
}
}
/// Changed paragraphs longer than this (in characters) are compared word by word even at
/// character level, so the comparison table stays small.
const MAX_CHAR_DIFF: usize = 2000;
/// Compare the current document (original, or `original` from a file) with a revised one: the
/// result shows the differences as tracked insertions, deletions and formatting changes, labelled
/// with `author`. Options Word offers that the comparison can't honour yet (moves, comments,
/// headers and footers, footnotes, text boxes, fields) are accepted and listed in the result.
fn compare(s: &mut Session, v: &Value) -> CmdResult {
let revised = if let Some(t) = p::str(v, "text") {
Document::from_text(t)
} else if let Some(path) = p::str(v, "path") {
crate::io::open_path(std::path::Path::new(path)).map_err(CmdError::Failed)?
} else {
return Err(CmdError::Params("`path` or `text` of the revised document is required".into()));
};
let show_in = p::str(v, "showIn").unwrap_or("original");
if !matches!(show_in, "original" | "revised" | "new") {
return Err(CmdError::Params("`showIn` is \"original\", \"revised\" or \"new\"".into()));
}
if let Some(path) = p::str(v, "original") {
let doc = crate::io::open_path(std::path::Path::new(path)).map_err(CmdError::Failed)?;
s.doc = doc;
s.doc.ensure_nonempty();
s.note_body_replaced();
s.math = None;
}
let level = p::str(v, "level").unwrap_or("word");
let opts = CompareOptions {
case: p::bool(v, "caseChanges").unwrap_or(true),
white_space: p::bool(v, "whiteSpace").unwrap_or(true),
formatting: p::bool(v, "formatting").unwrap_or(true),
characters: matches!(level, "character" | "char"),
};
let not_honoured: Vec<&str> =
["moves", "comments", "headersFooters", "footnotes", "textBoxes", "fields"].into_iter().filter(|k| v.get(*k).is_some()).collect();
let author = p::str(v, "author").map(str::trim).filter(|a| !a.is_empty()).map(str::to_string).unwrap_or_else(|| s.author.clone());
let date = super::now_iso();
let old: Vec<Arc<Block>> = s.doc.body.clone();
let new: Vec<Arc<Block>> = revised.body.clone();
s.doc.revisions.push(wordcraft_doc::Revision { kind: RevisionKind::Insert, author: author.clone(), date: date.clone(), move_name: None });
let ins = (s.doc.revisions.len() - 1) as u32;
s.doc.revisions.push(wordcraft_doc::Revision { kind: RevisionKind::Delete, author: author.clone(), date: date.clone(), move_name: None });
let del = (s.doc.revisions.len() - 1) as u32;
let key = |b: &Arc<Block>| match &**b {
Block::Para(p) => opts.key(&p.plain_text()),
// Tables match as a whole, by their text.
Block::Table(_) => format!("\u{0}table\u{0}{}", opts.key(&block_text(b, 0))),
};
let ops = lcs_ops(&old.iter().map(key).collect::<Vec<_>>(), &new.iter().map(key).collect::<Vec<_>>());
let mut out: Vec<Arc<Block>> = Vec::new();
let (mut i, mut j) = (0usize, 0usize);
let mut changes = 0usize;
let mut k = 0;
while k < ops.len() {
match ops.get(k) {
Some(Op::Same) => {
let (a, b) = (old.get(i), new.get(j));
// The revised block, with its formatting changes tracked.
if let (Some(Block::Para(pa)), Some(Block::Para(pb))) = (a.map(|x| &**x), b.map(|x| &**x)) {
let mut p = pb.clone();
if opts.formatting && pa.text == pb.text {
let before = s.doc.revisions.len();
super::fmt_revisions::compare_formatting(pa, &mut p, &mut s.doc.revisions, &author, &date);
if s.doc.revisions.len() > before || p != *pb {
changes += 1;
}
}
out.push(para_block(p));
} else if let Some(x) = b.or(a) {
out.push(x.clone());
}
i += 1;
j += 1;
k += 1;
}
Some(_) => {
// A run of deletions and insertions: changed paragraphs pair up and are diffed
// word by word (or by character); the rest are deleted or inserted whole.
let run = ops.get(k..).unwrap_or_default().iter().take_while(|o| **o != Op::Same).count();
let dels = ops.get(k..k + run).unwrap_or_default().iter().filter(|o| **o == Op::Del).count();
let inss = run - dels;
for x in 0..dels.max(inss) {
let (a, b) = (if x < dels { old.get(i + x) } else { None }, if x < inss { new.get(j + x) } else { None });
match (a.map(|x| &**x), b.map(|x| &**x)) {
(Some(Block::Para(pa)), Some(Block::Para(pb))) => out.push(para_block(word_diff(pa, pb, ins, del, &opts))),
_ => {
if let Some(x) = a {
out.push(Arc::new(mark_block(x, &|c| c.del = Some(del), 0)));
}
if let Some(x) = b {
out.push(Arc::new(mark_block(x, &|c| c.ins = Some(ins), 0)));
}
}
}
changes += 1;
}
i += dels;
j += inss;
k += run.max(1);
}
None => break,
}
}
s.doc.body = out;
s.doc.ensure_nonempty();
s.note_body_replaced();
s.math = None;
if show_in != "original" || p::str(v, "original").is_some() {
// The result is a new, untitled document: saving it never overwrites either file.
s.path = None;
s.file_name = None;
// Version snapshots belong to the document they were taken from, including when
// Compare creates an untitled result from the current document.
s.clear_versions();
}
s.sel = Selection::caret(s.doc.start_of(StoryRef::Body));
Ok(json!({"changes": changes, "author": author, "notHonoured": not_honoured}))
}
/// A block's text (a table's cells in order), for matching blocks.
fn block_text(b: &Block, depth: usize) -> String {
match b {
Block::Para(p) => p.plain_text(),
Block::Table(t) if depth < 16 => {
t.rows.iter().flat_map(|r| r.cells.iter()).flat_map(|c| c.blocks.iter()).map(|x| block_text(x, depth + 1)).collect::<Vec<_>>().join("\t")
}
Block::Table(_) => String::new(),
}
}
/// A copy of the block with every run (in tables, every cell's) changed by `f`.
fn mark_block(b: &Block, f: &dyn Fn(&mut CharProps), depth: usize) -> Block {
match b {
Block::Para(p) => {
let mut p = p.clone();
let len = p.len();
let _ = p.format(0, len, f);
Block::Para(p)
}
Block::Table(t) => {
let mut t = t.clone();
if depth < 16 {
for c in t.rows.iter_mut().flat_map(|r| r.cells.iter_mut()) {
c.blocks = c.blocks.iter().map(|x| Arc::new(mark_block(x, f, depth + 1))).collect();
}
}
Block::Table(t)
}
}
}
#[derive(Clone, Copy, Debug, PartialEq)]
enum Op {
Same,
Del,
Ins,
}
/// Longest sequence the comparison table is built for; the rest of a longer sequence is
/// compared as deleted and inserted, never dropped.
const MAX_LCS: usize = 4000;
/// LCS edit script between two sequences: the common start and end are matched first, then the
/// middle (bounded size).
fn lcs_ops<T: PartialEq>(a: &[T], b: &[T]) -> Vec<Op> {
let pre = a.iter().zip(b).take_while(|(x, y)| x == y).count();
let (ra, rb) = (a.get(pre..).unwrap_or_default(), b.get(pre..).unwrap_or_default());
let suf = ra.iter().rev().zip(rb.iter().rev()).take_while(|(x, y)| x == y).count();
let (a, b) = (ra.get(..ra.len() - suf).unwrap_or_default(), rb.get(..rb.len() - suf).unwrap_or_default());
let mut ops = vec![Op::Same; pre];
let (n, m) = (a.len().min(MAX_LCS), b.len().min(MAX_LCS));
let mut t = vec![vec![0u32; m + 1]; n + 1];
for i in (0..n).rev() {
for j in (0..m).rev() {
let v = if a.get(i) == b.get(j) {
t.get(i + 1).and_then(|r| r.get(j + 1)).copied().unwrap_or(0) + 1
} else {
t.get(i + 1).and_then(|r| r.get(j)).copied().unwrap_or(0).max(t.get(i).and_then(|r| r.get(j + 1)).copied().unwrap_or(0))
};
if let Some(c) = t.get_mut(i).and_then(|r| r.get_mut(j)) {
*c = v;
}
}
}
let (mut i, mut j) = (0, 0);
while i < n && j < m {
if a.get(i) == b.get(j) {
ops.push(Op::Same);
i += 1;
j += 1;
} else if t.get(i + 1).and_then(|r| r.get(j)) >= t.get(i).and_then(|r| r.get(j + 1)) {
ops.push(Op::Del);
i += 1;
} else {
ops.push(Op::Ins);
j += 1;
}
}
ops.extend(std::iter::repeat_n(Op::Del, a.len() - i));
ops.extend(std::iter::repeat_n(Op::Ins, b.len() - j));
ops.extend(std::iter::repeat_n(Op::Same, suf));
ops
}
/// How short an interior match can be, inside a run of edits, before it's folded into the
/// surrounding delete/insert instead of staying its own little island of "same" text. Two short
/// strings that happen to share a character in a different order (common with digits in dates,
/// IDs, phone numbers) otherwise come out of `lcs_ops` as a true shortest edit script, but render
/// as a "ransom note" of alternating single-character deletions and insertions where a reader
/// (and Word) would expect one clean replacement.
const NOISE_MATCH_LEN: usize = 1;
/// Folds single-token matches that sit strictly inside a run of edits into the surrounding
/// delete/insert, so a character-level diff doesn't fragment a replaced run. A matched run that
/// opens or closes the whole sequence is the genuine common prefix/suffix `lcs_ops` already
/// stripped before running its table, so it's left untouched regardless of length; only a short
/// match produced by the table's own backtracking, which can only ever be interior, is a
/// candidate.
fn collapse_noise_matches(ops: &[Op]) -> Vec<Op> {
#[derive(Clone, Copy, PartialEq)]
enum Kind {
Keep,
Fold,
}
let mut runs: Vec<(Op, usize, Kind)> = Vec::new();
let mut k = 0;
while k < ops.len() {
let op = ops[k];
let len = ops[k..].iter().take_while(|o| **o == op).count();
let kind = if op == Op::Same && k != 0 && k + len != ops.len() && len <= NOISE_MATCH_LEN { Kind::Fold } else { Kind::Keep };
runs.push((op, len, kind));
k += len;
}
if !runs.iter().any(|(_, _, kind)| *kind == Kind::Fold) {
return ops.to_vec();
}
let mut out = Vec::with_capacity(ops.len());
let mut i = 0;
while i < runs.len() {
if runs[i].0 == Op::Same && runs[i].2 == Kind::Keep {
out.extend(std::iter::repeat_n(Op::Same, runs[i].1));
i += 1;
continue;
}
let start = i;
while i < runs.len() && !(runs[i].0 == Op::Same && runs[i].2 == Kind::Keep) {
i += 1;
}
let region = &runs[start..i];
if region.iter().any(|(_, _, kind)| *kind == Kind::Fold) {
// Flatten the region: every original character first (in its own order), then every
// revised character (in its own order) — one clean delete, one clean insert.
let dels: usize = region.iter().map(|(op, len, kind)| if *kind == Kind::Fold || *op == Op::Del { *len } else { 0 }).sum();
let inss: usize = region.iter().map(|(op, len, kind)| if *kind == Kind::Fold || *op == Op::Ins { *len } else { 0 }).sum();
out.extend(std::iter::repeat_n(Op::Del, dels));
out.extend(std::iter::repeat_n(Op::Ins, inss));
} else {
for (op, len, _) in region {
out.extend(std::iter::repeat_n(*op, *len));
}
}
}
out
}
/// Words with the spaces after them ("The ", "cat "), or single characters.
fn tokens(s: &str, characters: bool) -> Vec<String> {
if characters {
return s.chars().map(String::from).collect();
}
let mut v = Vec::new();
let mut cur = String::new();
for c in s.chars() {
cur.push(c);
if c == ' ' {
v.push(std::mem::take(&mut cur));
}
}
if !cur.is_empty() {
v.push(cur);
}
v
}
fn word_diff(a: &Paragraph, b: &Paragraph, ins: u32, del: u32, opts: &CompareOptions) -> Paragraph {
let (at, bt) = (a.plain_text(), b.plain_text());
let chars = opts.characters && at.chars().count().max(bt.chars().count()) <= MAX_CHAR_DIFF;
let (ta, tb) = (tokens(&at, chars), tokens(&bt, chars));
let ops = lcs_ops(&ta.iter().map(|t| opts.key(t)).collect::<Vec<_>>(), &tb.iter().map(|t| opts.key(t)).collect::<Vec<_>>());
// Only at character granularity: a matched whole word almost never happens by coincidence,
// but a matched single character very often does (digits, punctuation), so only there does
// a short interior match need folding back into its surrounding edit.
let ops = if chars { collapse_noise_matches(&ops) } else { ops };
let mut p = Paragraph::new();
p.props = b.props.clone();
let base = a.runs.first().map(|r| r.props.clone()).unwrap_or_default();
let (mut i, mut j) = (0, 0);
for op in ops {
let at = p.len();
match op {
Op::Same => {
// Equal as compared (perhaps up to case or spacing): the revised text.
let _ = p.insert_text(at, tb.get(j).map(String::as_str).unwrap_or(""), &base);
i += 1;
j += 1;
}
Op::Del => {
let _ = p.insert_text(at, ta.get(i).map(String::as_str).unwrap_or(""), &CharProps { del: Some(del), ..base.clone() });
i += 1;
}
Op::Ins => {
let _ = p.insert_text(at, tb.get(j).map(String::as_str).unwrap_or(""), &CharProps { ins: Some(ins), ..base.clone() });
j += 1;
}
}
}
p
}
fn accessibility(s: &mut Session, _: &Value) -> CmdResult {
let issues = super::accessibility::check(&s.doc);
Ok(json!({"ok": issues.is_empty(), "issues": issues}))
}
fn inspect_doc(s: &mut Session, v: &Value) -> CmdResult {
let remove: Vec<String> =
v.get("remove").and_then(Value::as_array).map(|a| a.iter().filter_map(|x| x.as_str().map(str::to_string)).collect()).unwrap_or_default();
// The removals aren't tracked: they delete straight from the document, so on a document that
// tracks changes the text would go with no revision to show for it, and the revision trail
// would be broken behind the user's back. Refuse the whole removal instead of quietly
// untracking it. Reading the document (no `remove`) is always allowed.
if !remove.is_empty() && s.doc.settings.track_changes {
return Err(CmdError::Disabled("the document tracks changes: stop tracking before removing content".into()));
}
let hidden = hidden_text(&s.doc);
let report = json!({
"comments": s.doc.comments.len(),
"revisions": s.doc.para_paths(StoryRef::Body).iter().filter(|p| s.doc.para(StoryRef::Body, p).is_some_and(|x| x.runs.iter().any(|r| r.props.ins.is_some() || r.props.del.is_some()))).count(),
"properties": !s.doc.core.creator.is_empty() || !s.doc.core.last_modified_by.is_empty(),
"hiddenText": !hidden.is_empty(),
"hiddenCount": hidden.len(),
"hidden": hidden.iter().take(MAX_HIDDEN_LISTED).map(|h| h.report(&s.doc)).collect::<Vec<_>>(),
"truncated": hidden.len() > MAX_HIDDEN_LISTED,
"unsupported": unsupported_hidden(&s.doc),
"headers": s.doc.parts.values().filter(|p| matches!(p.kind, wordcraft_doc::PartKind::Header | wordcraft_doc::PartKind::Footer)).count(),
});
let mut removed_notes = Vec::new();
for r in &remove {
match r.as_str() {
"comments" => {
s.run("review.deleteComment", &json!({"all": true}))?;
}
"revisions" => {
s.run("review.acceptAll", &json!({}))?;
}
"properties" => {
let title = s.doc.core.title.clone();
s.doc.core = Default::default();
s.doc.core.title = title;
}
"hidden" => removed_notes = remove_hidden(&mut s.doc)?,
"headers" => {
s.run("insert.removeHeader", &json!({}))?;
s.run("insert.removeFooter", &json!({}))?;
}
_ => {}
}
}
s.clamp_selection();
// What a removal couldn't reach: hidden text still there, or kept in objects we don't model.
let residual: Vec<Value> = if remove.iter().any(|r| r == "hidden") {
let mut v: Vec<Value> = hidden_text(&s.doc).iter().take(MAX_HIDDEN_LISTED).map(|h| h.report(&s.doc)).collect();
v.extend(unsupported_hidden(&s.doc));
v
} else {
Vec::new()
};
Ok(json!({"found": report, "removed": remove, "residual": residual, "removedNotes": removed_notes}))
}
/// Most hidden pieces (and removed notes) `file.inspect` lists (the count is always exact).
const MAX_HIDDEN_LISTED: usize = 1000;
/// Most characters of a removed note's text `file.inspect` echoes.
const MAX_NOTE_TEXT: usize = 1000;
/// A piece of hidden text: a byte range of a paragraph in a story. `whole`: the paragraph's mark
/// is hidden too and so is all its text, so the paragraph itself goes.
struct Hidden {
story: StoryRef,
path: wordcraft_doc::Path,
range: std::ops::Range<usize>,
whole: bool,
}
/// Remove All for hidden text (#1344): every hidden piece, last first so earlier offsets and
/// paths stay valid; whole hidden paragraphs with their marks; and the notes whose references
/// were hidden, as deleting a reference deletes its note. Returns the notes removed.
fn remove_hidden(doc: &mut Document) -> Result<Vec<Value>, CmdError> {
let mut refs: Vec<u32> = Vec::new();
for h in hidden_text(doc).into_iter().rev() {
if let Some(p) = doc.para(h.story, &h.path) {
for off in p.object_offsets() {
if h.range.contains(&off)
&& let Some(InlineObject::NoteRef { id, .. }) = p.object_at(off)
{
refs.push(*id);
}
}
}
remove_piece(doc, &h)?;
}
if refs.is_empty() {
return Ok(Vec::new());
}
// Notes still referenced from elsewhere (a note's own mark doesn't count) stay.
let mut kept = std::collections::BTreeSet::new();
for story in all_stories(doc) {
for path in doc.para_paths(story) {
let Some(p) = doc.para(story, &path) else { continue };
for off in p.object_offsets() {
if let Some(InlineObject::NoteRef { id, .. }) = p.object_at(off)
&& story != StoryRef::Part(*id)
{
kept.insert(*id);
}
}
}
}
refs.sort_unstable();
refs.dedup();
let mut out = Vec::new();
for id in refs {
if kept.contains(&id)
|| !doc.parts.get(&id).is_some_and(|p| matches!(p.kind, wordcraft_doc::PartKind::Footnote | wordcraft_doc::PartKind::Endnote))
{
continue;
}
// Only the echo is capped; every such note goes.
if out.len() < MAX_HIDDEN_LISTED {
let text: String = doc.plain_text(StoryRef::Part(id)).replace('\u{FFFC}', "").trim().chars().take(MAX_NOTE_TEXT).collect();
out.push(json!({"kind": story_kind(doc, StoryRef::Part(id)), "part": id, "text": text}));
}
doc.parts.remove(&id);
}
Ok(out)
}
/// Whether a whole hidden paragraph can go, mark and all. It stays when it is the last block of
/// its story or cell (they end with a paragraph), carries a section break, holds content control
/// markers (their partners may be elsewhere), or sits between two tables (they'd join on reopen).
fn removable(doc: &Document, story: StoryRef, path: &wordcraft_doc::Path, p: &Paragraph) -> bool {
let i = path.last();
let Some(c) = doc.container(story, path) else { return false };
let is_table = |j: Option<usize>| j.and_then(|j| c.get(j)).is_some_and(|b| matches!(**b, Block::Table(_)));
i + 1 < c.len()
&& p.section.is_none()
&& !p.object_offsets().into_iter().any(|o| matches!(p.object_at(o), Some(InlineObject::ControlStart { .. } | InlineObject::ControlEnd)))
&& !(is_table(i.checked_sub(1)) && is_table(i.checked_add(1)))
}
/// Whether `range` of `p` holds text or an object, not only zero-width markers.
fn has_content(p: &Paragraph, range: &std::ops::Range<usize>) -> bool {
p.text
.get(range.clone())
.is_some_and(|t| t.char_indices().any(|(i, ch)| ch != '\u{FFFC}' || p.object_at(range.start + i).is_some_and(|o| !o.is_marker())))
}
/// Delete one hidden piece. Within a paragraph: straight in the paragraph, unless a content
/// control marker is inside (then through `delete_range`, which keeps markers whose partner is
/// outside). A whole hidden paragraph goes with its mark when [`removable`]; otherwise its text
/// goes and it stays, empty, its mark still hidden: invisible and holding no text, so it isn't
/// reported again.
fn remove_piece(doc: &mut Document, h: &Hidden) -> Result<(), CmdError> {
let Some(p) = doc.para(h.story, &h.path) else { return Ok(()) };
let has_control = p
.object_offsets()
.into_iter()
.any(|o| h.range.contains(&o) && matches!(p.object_at(o), Some(InlineObject::ControlStart { .. } | InlineObject::ControlEnd)));
if h.whole && removable(doc, h.story, &h.path, p) {
let i = h.path.last();
let blocks = doc.container_mut(h.story, &h.path)?;
if i < blocks.len() {
blocks.remove(i);
}
return Ok(());
}
if !h.range.is_empty() {
if has_control {
let at = |off| Pos { story: h.story, path: h.path.clone(), off };
doc.delete_range(&at(h.range.start), &at(h.range.end))?;
} else {
doc.para_mut(h.story, &h.path)?.delete(h.range.start, h.range.end)?;
}
}
Ok(())
}
impl Hidden {
fn report(&self, doc: &Document) -> Value {
let text = doc.para(self.story, &self.path).and_then(|p| p.text.get(self.range.clone())).unwrap_or("");
let mut v = json!({"story": story_kind(doc, self.story), "text": text});
if let (StoryRef::Part(id), Value::Object(m)) = (self.story, &mut v) {
m.insert("part".into(), json!(id));
}
v
}
}
/// Every story of the document: the body, then headers, footers, notes, comments and text boxes.
fn all_stories(doc: &Document) -> Vec<StoryRef> {
std::iter::once(StoryRef::Body).chain(doc.parts.keys().map(|k| StoryRef::Part(*k))).collect()
}
/// What the inspector calls a story.
fn story_kind(doc: &Document, st: StoryRef) -> &'static str {
use wordcraft_doc::PartKind;
match st {
StoryRef::Body => "body",
StoryRef::Part(id) => match doc.parts.get(&id).map(|p| p.kind) {
Some(PartKind::Header) => "header",
Some(PartKind::Footer) => "footer",
Some(PartKind::Footnote) => "footnote",
Some(PartKind::Endnote) => "endnote",
Some(PartKind::Comment) => "comment",
Some(PartKind::TextBox) => "textBox",
None => "unknown",
},
}
}
/// The hidden text of the document (Inspect Document, #1344): runs whose effective formatting
/// (document defaults, paragraph style chain, character style chain, direct) is hidden, in every
/// story, adjacent runs merged. Ranges holding only zero-width markers (bookmarks, comment
/// ranges, content control and field markers) aren't text and are left out.
fn hidden_text(doc: &Document) -> Vec<Hidden> {
let mut out = Vec::new();
for story in all_stories(doc) {
for path in doc.para_paths(story) {
let Some(p) = doc.para(story, &path) else { continue };
let style = p.props.style.as_deref();
let mut ranges: Vec<std::ops::Range<usize>> = Vec::new();
let mut all_hidden = true;
for (r, c) in p.run_ranges() {
if r.is_empty() {
continue;
}
if !doc.styles.resolve_char(style, c).hidden {
all_hidden = false;
continue;
}
match ranges.last_mut() {
Some(last) if last.end == r.start => last.end = r.end,
_ => ranges.push(r),
}
}
if all_hidden && doc.styles.resolve_char(style, &p.mark).hidden {
// An empty hidden paragraph that has to stay is invisible and holds no text.
if removable(doc, story, &path, p) || has_content(p, &(0..p.len())) {
out.push(Hidden { story, path: path.clone(), range: 0..p.len(), whole: true });
}
continue;
}
for range in ranges {
if has_content(p, &range) {
out.push(Hidden { story, path: path.clone(), range, whole: false });
}
}
}
}
out
}
/// Hidden text the inspector can't reach: objects kept verbatim from the source file
/// (`Opaque`) whose markup hides text (`w:vanish`), outside hidden runs (those go with the run).
fn unsupported_hidden(doc: &Document) -> Vec<Value> {
let mut out = Vec::new();
for story in all_stories(doc) {
for path in doc.para_paths(story) {
let Some(p) = doc.para(story, &path) else { continue };
let style = p.props.style.as_deref();
for off in p.object_offsets() {
if let Some(InlineObject::Opaque { format, xml, .. }) = p.object_at(off)
&& xml.contains("vanish")
&& !doc.styles.resolve_char(style, p.props_of_char(off)).hidden
{
out.push(json!({"story": story_kind(doc, story), "object": "opaque", "format": format, "reason": "hidden text inside an object kept from the source file"}));
}
}
}
if let Some(blocks) = doc.story(story) {
tables_hiding_text(doc, story, blocks, 0, &mut out);
}
}
out
}
/// Tables whose table style (or one of its conditional parts: header row, bands, first or last
/// column, total row) hides text. The inspector doesn't resolve table styles, so it lists them.
fn tables_hiding_text(doc: &Document, story: StoryRef, blocks: &wordcraft_doc::Blocks, depth: usize, out: &mut Vec<Value>) {
if depth > 32 {
return;
}
for b in blocks {
let Block::Table(t) = &**b else { continue };
if let Some(id) = t.props.style.as_deref()
&& let Some(ts) = doc.styles.table_style(id)
{
let q = &ts.parts;
if [&ts.chr, &q.header_chr, &q.band_chr, &q.col_band_chr, &q.first_col_chr, &q.last_col_chr, &q.total_chr]
.iter()
.any(|c| c.hidden == Some(true))
{
out.push(json!({"story": story_kind(doc, story), "object": "table", "style": id, "reason": "text hidden by a table style isn't inspected or removed"}));
}
}
for row in &t.rows {
for cell in &row.cells {
tables_hiding_text(doc, story, &cell.blocks, depth + 1, out);
}
}
}
}
/// Insert › Quick Parts and AutoText: this session's building blocks, and the document's own
/// (`Document::glossary`, kept in its file) in `gallery`. A name is looked up in the session's
/// first, then in the document's (`gallery` first).
fn quick_parts(s: &mut Session, v: &Value, gallery: &str) -> CmdResult {
let in_document = p::str(v, "in") == Some("document");
if let Some(name) = p::str(v, "save") {
let (a, b) = s.sel.ordered();
if a == b {
return Err(CmdError::Params("select the content to save".into()));
}
let frag = s.doc.copy_range(&a, &b);
if in_document {
if name.trim().is_empty() {
return Err(CmdError::Params("a building block needs a name".into()));
}
let blocks: wordcraft_doc::Blocks = frag.blocks.into_iter().map(Arc::new).collect();
match s.doc.glossary.iter_mut().find(|e| e.name == name && e.gallery == gallery) {
Some(e) => e.blocks = blocks,
None => s.doc.glossary.push(wordcraft_doc::BuildingBlock {
name: name.to_string(),
gallery: gallery.to_string(),
category: "General".into(),
blocks,
..Default::default()
}),
}
} else {
s.building_blocks.insert(name.to_string(), frag);
}
}
if let Some(name) = p::str(v, "delete")
&& (in_document || s.building_blocks.remove(name).is_none())
&& let Some(i) = document_entry(&s.doc, name, gallery)
{
s.doc.glossary.remove(i);
}
if let Some(name) = p::str(v, "insert") {
let frag = match s.building_blocks.get(name) {
Some(f) => f.clone(),
None => {
let e = document_entry(&s.doc, name, gallery).and_then(|i| s.doc.glossary.get(i));
let e = e.ok_or_else(|| CmdError::Params(format!("no Quick Part `{name}`")))?;
wordcraft_doc::edit::Fragment { blocks: e.blocks.iter().map(|b| (**b).clone()).collect(), parts: Default::default() }
}
};
let at = delete_selection(s)?;
let end = s.doc.insert_fragment(&at, &frag)?;
s.sel = Selection::caret(end);
}
let document: Vec<&str> = s.doc.glossary.iter().filter(|e| e.gallery == gallery).map(|e| e.name.as_str()).collect();
let mut parts: Vec<&str> = s.building_blocks.keys().map(String::as_str).collect();
let mut seen: std::collections::HashSet<&str> = parts.iter().copied().collect();
parts.extend(document.iter().copied().filter(|n| seen.insert(n)));
Ok(json!({"parts": parts, "document": document}))
}
/// The index of the document's building block `name`, in `gallery` if there is one there.
fn document_entry(doc: &Document, name: &str, gallery: &str) -> Option<usize> {
let g = &doc.glossary;
g.iter().position(|e| e.name == name && e.gallery == gallery).or_else(|| g.iter().position(|e| e.name == name))
}
fn hf_nav(s: &mut Session, dir: i32) -> CmdResult {
let StoryRef::Part(cur) = s.sel.focus.story else { return Err(CmdError::Disabled("edit a header or footer first".into())) };
let mut ids: Vec<u32> = Vec::new();
for (_, sect) in s.doc.sections() {
for id in [sect.headers.default, sect.headers.first, sect.headers.even, sect.footers.default, sect.footers.first, sect.footers.even]
.into_iter()
.flatten()
{
if !ids.contains(&id) {
ids.push(id);
}
}
}
let i = ids.iter().position(|x| *x == cur).unwrap_or(0) as i32;
let n = ids.len().max(1) as i32;
let next = ids.get(((i + dir).rem_euclid(n)) as usize).copied().unwrap_or(cur);
s.sel = Selection::caret(s.doc.start_of(StoryRef::Part(next)));
sel_result(s)
}
fn select_similar(s: &mut Session, _: &Value) -> CmdResult {
let f = s.sel.ordered().0;
let target = s.doc.para_at(&f).map(|p| p.props_of_char(f.off).clone()).unwrap_or_default();
let mut first: Option<Pos> = None;
let mut last: Option<Pos> = None;
let mut count = 0;
for path in s.doc.para_paths(f.story) {
let Some(p) = s.doc.para(f.story, &path) else { continue };
for (r, c) in p.run_ranges() {
if *c == target {
count += 1;
if first.is_none() {
first = Some(Pos { story: f.story, path: path.clone(), off: r.start });
}
last = Some(Pos { story: f.story, path: path.clone(), off: r.end });
}
}
}
if let (Some(a), Some(b)) = (first, last) {
s.sel = Selection { anchor: a, focus: b };
}
Ok(json!({"runs": count}))
}
/// Tools › Customize Keyboard: read and change the custom keyboard shortcuts ([`crate::KeyMap`]).
/// Without params it opens the dialog; with any, it only does what they ask (no dialog).
fn customize_keyboard(s: &mut Session, v: &Value) -> CmdResult {
let reg = s.registry.clone();
let pair = |x: &Value| -> Result<(String, String), CmdError> { Ok((p::req_str(x, "command")?.to_string(), p::req_str(x, "key")?.to_string())) };
let mut out = serde_json::Map::new();
let mut asked = false;
if p::bool(v, "reset") == Some(true) {
asked = true;
s.keymap.reset();
}
if let Some(a) = v.get("assign") {
asked = true;
let (id, key) = pair(a)?;
let previous = s.keymap.assign(&reg, &id, &key).map_err(CmdError::Params)?;
out.insert("replaced".into(), json!(previous));
}
if let Some(r) = v.get("remove") {
asked = true;
let (id, key) = pair(r)?;
out.insert("removed".into(), json!(s.keymap.remove(&reg, &id, &key).map_err(CmdError::Params)?));
}
if let Some(id) = p::str(v, "command") {
asked = true;
if reg.get(id).is_none() {
return Err(CmdError::Params(format!("unknown command `{id}`")));
}
out.insert("keys".into(), json!(s.keymap.keys_for(&reg, id)));
}
if let Some(key) = p::str(v, "key") {
asked = true;
let key = crate::keymap::normalize(key).ok_or_else(|| CmdError::Params(format!("`{key}` isn't a key (e.g. Mod+Shift+K, Alt+F7, F2)")))?;
out.insert("assignedTo".into(), json!(s.keymap.resolve(&reg, &key).map(|c| c.id)));
out.insert("key".into(), json!(key));
}
out.insert("custom".into(), serde_json::to_value(&s.keymap).unwrap_or(Value::Null));
if !asked {
s.push_ui_request(json!({"open": "customizeKeyboard"}));
}
Ok(Value::Object(out))
}
/// `tools.search`: the commands whose label, location or id hold every word of `query`, or,
/// with no query, the Search box (`ui_requests` → the UI's command search).
fn search_commands(s: &mut Session, v: &Value) -> CmdResult {
let Some(query) = p::str(v, "query") else {
s.push_ui_request(json!({"open": "commands"}));
return Ok(json!({"matches": []}));
};
let words: Vec<String> = query.split_whitespace().map(str::to_lowercase).collect();
let matches: Vec<Value> = if words.is_empty() {
Vec::new()
} else {
s.registry
.all()
.iter()
.filter(|c| {
let hay = format!("{} {} {}", c.label, c.location, c.id).to_lowercase();
words.iter().all(|w| hay.contains(w.as_str()))
})
.take(50)
.map(|c| json!({"id": c.id, "label": c.label, "location": c.location, "shortcut": c.shortcut}))
.collect()
};
Ok(json!({"matches": matches}))
}
/// Tools › Customize Ribbon: read and change the ribbon's tabs, custom groups and the Quick Access
/// Toolbar ([`crate::RibbonLayout`]). Without params it opens the dialog; with any, it only does
/// what they ask (no dialog).
fn customize_ribbon(s: &mut Session, v: &Value) -> CmdResult {
let reg = s.registry.clone();
let l = &mut s.ribbon;
let e = CmdError::Params;
let str_of = |x: &Value, k: &str| x.get(k).and_then(Value::as_str).map(str::to_string);
let req = |x: &Value, k: &str| p::req_str(x, k).map(str::to_string);
let by = |x: &Value| x.get("by").and_then(Value::as_i64).ok_or_else(|| e("`by` (integer, e.g. -1 or 1) is required".into()));
let mut out = serde_json::Map::new();
let mut asked = false;
if let Some(x) = v.get("layout") {
asked = true;
*l = crate::RibbonLayout::from_value(x);
l.retain_known(&reg);
}
if let Some(r) = v.get("reset") {
asked = true;
match (r.as_bool(), r.as_str()) {
(Some(true), _) => *l = crate::RibbonLayout::default(),
(_, Some("ribbon")) => l.reset_ribbon(),
(_, Some("qat")) => l.reset_qat(),
_ => return Err(e("`reset` is true, \"ribbon\" or \"qat\"".into())),
}
}
for (key, hidden) in [("hide", true), ("show", false)] {
if let Some(tab) = p::str(v, key) {
asked = true;
l.set_hidden(tab, hidden).map_err(e)?;
}
}
if let Some(x) = v.get("newTab") {
asked = true;
let name = l.new_tab(str_of(x, "name").as_deref(), str_of(x, "after").as_deref()).map_err(e)?;
out.insert("tab".into(), json!(name));
}
if let Some(x) = v.get("newGroup") {
asked = true;
let name = l.new_group(&req(x, "tab")?, str_of(x, "name").as_deref()).map_err(e)?;
out.insert("group".into(), json!(name));
}
if let Some(x) = v.get("add") {
asked = true;
let added = l.add(&reg, &req(x, "tab")?, &req(x, "group")?, &req(x, "command")?, str_of(x, "after").as_deref()).map_err(e)?;
out.insert("added".into(), json!(added));
}
if let Some(x) = v.get("remove") {
asked = true;
l.remove(&req(x, "tab")?, str_of(x, "group").as_deref(), str_of(x, "command").as_deref()).map_err(e)?;
}
if let Some(x) = v.get("rename") {
asked = true;
let name = l.rename(&req(x, "tab")?, str_of(x, "group").as_deref(), &req(x, "to")?).map_err(e)?;
out.insert("name".into(), json!(name));
}
if let Some(x) = v.get("move") {
asked = true;
l.move_by(&req(x, "tab")?, str_of(x, "group").as_deref(), str_of(x, "command").as_deref(), by(x)?).map_err(e)?;
}
if let Some(id) = p::str(v, "qatAdd") {
asked = true;
out.insert("added".into(), json!(l.qat_add(&reg, id, None).map_err(e)?));
}
if let Some(id) = p::str(v, "qatRemove") {
asked = true;
out.insert("removed".into(), json!(l.qat_remove(id)));
}
if let Some(x) = v.get("qatMove") {
asked = true;
l.qat_move(&req(x, "command")?, by(x)?).map_err(e)?;
}
out.insert("tabs".into(), serde_json::to_value(l.tabs().as_ref()).unwrap_or(Value::Null));
out.insert("qat".into(), json!(l.qat()));
if !asked {
s.push_ui_request(json!({"open": "customizeRibbon"}));
}
Ok(Value::Object(out))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn compare_marks_changes() {
let mut s = Session::new(Document::from_text("The cat sat.\nKeep this.\nOld line."));
let r = s.run("review.compare", &json!({"text": "The dog sat.\nKeep this.\nNew line here."})).unwrap();
assert!(r["changes"].as_u64().unwrap() >= 2);
s.run("review.acceptAll", &json!({})).unwrap();
assert_eq!(s.doc.plain_text(StoryRef::Body), "The dog sat.\nKeep this.\nNew line here.");
}
#[test]
fn compare_options_ignore_case_and_white_space() {
let original = "The Cat sat.\nTwo spaces here.";
let revised = "The cat sat.\nTwo spaces here.";
// By default both differences are changes.
let mut s = Session::new(Document::from_text(original));
let r = s.run("review.compare", &json!({"text": revised, "author": "Reviewer"})).unwrap();
assert_eq!(r["changes"], 2);
assert!(s.doc.revisions.iter().any(|x| x.author == "Reviewer"));
// Ignoring case and white space: no changes, and the result reads as the revised text.
let mut s = Session::new(Document::from_text(original));
let r = s.run("review.compare", &json!({"text": revised, "caseChanges": false, "whiteSpace": false})).unwrap();
assert_eq!(r["changes"], 0);
assert_eq!(s.doc.plain_text(StoryRef::Body), revised);
// Character level marks only the changed letters.
let mut s = Session::new(Document::from_text("colour"));
s.run("review.compare", &json!({"text": "color", "level": "character"})).unwrap();
let p = s.doc.body[0].as_para().unwrap();
let deleted: String = p.run_ranges().filter(|(_, c)| c.del.is_some()).map(|(r, _)| p.text[r].to_string()).collect();
assert_eq!(deleted, "u");
// Two dates that happen to share digits in a different order ("01.09.2026" vs
// "19.10.2026") must come out as one clean replacement, not a scrambled chain of
// single-character deletions and insertions wherever a digit coincidentally recurs.
let mut s = Session::new(Document::from_text("zawarta w dniu 01.09.2026 pomiędzy:"));
s.run("review.compare", &json!({"text": "zawarta w dniu 19.10.2026 pomiędzy:", "level": "character"})).unwrap();
let p = s.doc.body[0].as_para().unwrap();
let deleted: String = p.run_ranges().filter(|(_, c)| c.del.is_some()).map(|(r, _)| p.text[r].to_string()).collect();
let inserted: String = p.run_ranges().filter(|(_, c)| c.ins.is_some()).map(|(r, _)| p.text[r].to_string()).collect();
assert_eq!(deleted, "01.09");
assert_eq!(inserted, "19.10");
assert_eq!(p.run_ranges().filter(|(_, c)| c.del.is_some()).count(), 1, "one clean deleted run, not several scattered ones");
assert_eq!(p.run_ranges().filter(|(_, c)| c.ins.is_some()).count(), 1, "one clean inserted run, not several scattered ones");
// Formatting differences become formatting changes.
let mut s = Session::new(Document::from_text("Same words."));
let mut rev = Document::from_text("Same words.");
rev.format_range(&rev.start_of(StoryRef::Body), &rev.end_of(StoryRef::Body), &|c| c.bold = Some(true)).unwrap();
let path = std::env::temp_dir().join(format!("wc-compare-{}.docx", std::process::id()));
crate::io::save_path(&path, &rev).unwrap();
let r = s.run("review.compare", &json!({"path": path.to_string_lossy()})).unwrap();
let _ = std::fs::remove_file(&path);
assert_eq!(r["changes"], 1);
assert!(s.doc.revisions.iter().any(|x| x.kind == RevisionKind::Format));
}
#[test]
fn collapse_noise_matches_merges_short_interior_runs_but_keeps_prefix_suffix() {
// "01.09" vs "19.10": a pure LCS finds a scrambled chain of single-character matches
// ('1', '.', '0') threaded through what a reader expects to be one clean replacement.
let ops = lcs_ops(&['0', '1', '.', '0', '9'], &['1', '9', '.', '1', '0']);
assert!(ops.contains(&Op::Same), "the raw LCS does find spurious matches here, or this test proves nothing");
assert_eq!(collapse_noise_matches(&ops), vec![Op::Del, Op::Del, Op::Del, Op::Del, Op::Del, Op::Ins, Op::Ins, Op::Ins, Op::Ins, Op::Ins]);
// A plain single-character replacement flanked by a real common prefix and suffix has no
// interior match to begin with (the DP's middle is pure delete/insert), so it's untouched.
let ops = lcs_ops(&['a', 'b', 'c', 'x', 'd', 'e', 'f'], &['a', 'b', 'c', 'y', 'd', 'e', 'f']);
assert_eq!(collapse_noise_matches(&ops), ops);
}
#[test]
fn compare_at_word_level_still_matches_a_whole_unchanged_word() {
// `collapse_noise_matches` only runs for character-level compares (wired up in
// `word_diff`): a single matched *word* between two edits is a real, meaningful match
// (unlike a single matched character), and must never be folded into a replacement.
let mut s = Session::new(Document::from_text("the cat sat"));
s.run("review.compare", &json!({"text": "a cat ran"})).unwrap();
let p = s.doc.body[0].as_para().unwrap();
assert!(
p.run_ranges().any(|(r, c)| c.del.is_none() && c.ins.is_none() && p.text[r].trim() == "cat"),
"the unchanged word must survive as plain text"
);
}
#[test]
fn macros_record_and_run() {
let mut s = Session::new(Document::new());
s.run("tools.recordMacro", &json!({"name": "hi"})).unwrap();
s.run("text.insert", &json!({"text": "Hi "})).unwrap();
s.run("format.bold", &json!({})).unwrap();
s.run("tools.recordMacro", &json!({})).unwrap();
s.run("tools.macros", &json!({"run": "hi"})).unwrap();
assert_eq!(s.doc.plain_text(StoryRef::Body), "Hi Hi ");
s.run("edit.repeat", &json!({})).unwrap();
}
#[test]
fn autocorrect_on_space() {
let mut s = Session::new(Document::new());
for t in ["teh", " ", "Teh", " ", "(c)", " "] {
s.run("text.insert", &json!({"text": t})).unwrap();
}
// The first word of a sentence is capitalised too.
assert_eq!(s.doc.plain_text(StoryRef::Body), "The The © ");
}
#[test]
fn protection_blocks_edits() {
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());
// 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());
}
#[test]
fn accessibility_and_quick_parts() {
let mut s = Session::new(crate::sample::sample_document());
let r = s.run("file.accessibility", &json!({})).unwrap();
assert!(r["issues"].is_array());
s.run("select.text", &json!({"text": "Membership"})).unwrap();
s.run("insert.quickParts", &json!({"save": "m"})).unwrap();
s.run("caret.docEnd", &json!({})).unwrap();
s.run("insert.quickParts", &json!({"insert": "m"})).unwrap();
assert!(s.doc.plain_text(StoryRef::Body).ends_with("Membership"));
}
/// #407 Language: the selection's language and "do not check", the default language and
/// the detect preference; a bad tag is refused.
#[test]
fn proofing_language_options() {
let mut s = Session::new(Document::from_text("Bonjour"));
s.run("select.all", &json!({})).unwrap();
s.run("review.language", &json!({"lang": "fr-FR", "noProof": true, "detect": false})).unwrap();
let rc = s.doc.para_at(&Pos::body(0, 1)).map(|p| p.props_at(1).clone()).unwrap();
assert_eq!((rc.lang.as_deref(), rc.no_proof), (Some("fr-FR"), Some(true)));
assert!(!s.prefs.detect_language);
s.run("review.language", &json!({"lang": "de-DE", "default": true})).unwrap();
assert_eq!(s.doc.styles.default_chr.lang.as_deref(), Some("de-DE"));
assert!(s.run("review.language", &json!({"lang": "<script>"})).is_err());
}
/// The inspector's removals delete straight from the document, so they aren't tracked: on a
/// document that tracks changes the text would go with no revision to show for it, and the
/// revision trail would be broken behind the user's back. The removal is refused instead,
/// and the document is left as it was.
#[test]
fn inspect_removals_are_refused_while_the_document_tracks_changes() {
let mut s = Session::new(Document::from_text("Visible text.\nHidden text."));
s.run("select.text", &json!({"text": "Hidden"})).unwrap();
s.run("format.hidden", &json!({"value": true})).unwrap();
// Tracking without Restrict Editing isolates the Inspector guard from the protected
// document check added separately in #3285.
s.run("review.trackChanges", &json!({"value": true})).unwrap();
assert!(s.doc.settings.track_changes, "the document doesn't track changes");
let before = s.doc.clone();
let err = s.run("file.inspect", &json!({"remove": ["hidden"]})).unwrap_err();
assert!(matches!(err, CmdError::Disabled(_)), "{err}");
assert_eq!(s.doc, before, "the document changed despite the refusal");
// Reading the document stays allowed.
let found = s.run("file.inspect", &json!({})).unwrap();
assert_eq!(found["found"]["hiddenText"], true, "{found}");
}
/// A document that doesn't track changes is inspected as before: the hidden text goes.
#[test]
fn inspect_removals_work_when_the_document_does_not_track_changes() {
let mut s = Session::new(Document::from_text("Visible text.\nHidden text."));
s.run("select.text", &json!({"text": "Hidden"})).unwrap();
s.run("format.hidden", &json!({"value": true})).unwrap();
assert!(!s.doc.settings.track_changes);
let out = s.run("file.inspect", &json!({"remove": ["hidden"]})).unwrap();
assert_eq!(out["residual"], json!([]), "{out}");
let text = s.doc.plain_text(StoryRef::Body);
assert!(!text.contains("Hidden"), "the hidden text is still there: {text}");
assert!(text.contains("Visible text."), "{text}");
}
}