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

907 lines
40 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_json::{Value, json};
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};
/// 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)", "™"),
("...", "…"),
("->", "→"),
("<-", "←"),
("=>", "⇒"),
(":)", "☺"),
];
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)
.params(r#"{"run"?: name, "define"?: {"name": string, "steps": [{"command", "params"}]}, "delete"?: name}"#)
.pure(),
CommandSpec::new("tools.autocorrect", "AutoCorrect Options", "File › Options › Proofing", |s, v| {
if let Some(b) = p::bool(v, "enabled") {
s.autocorrect_on = b;
}
if let (Some(f), Some(t)) =
(v.get("add").and_then(|a| a.get("from")).and_then(Value::as_str), v.get("add").and_then(|a| a.get("to")).and_then(Value::as_str))
{
s.autocorrect_user.retain(|(a, _)| a != f);
s.autocorrect_user.push((f.to_string(), t.to_string()));
}
Ok(json!({"enabled": s.autocorrect_on, "entries": AUTOCORRECT.len() + s.autocorrect_user.len(), "user": s.autocorrect_user}))
})
.params(r#"{"enabled"?: bool, "add"?: {"from": string, "to": string}}"#)
.pure(),
CommandSpec::new("review.compare", "Compare", "Review › Compare", compare).params(r#"{"path"?: string, "text"?: string (revised version)}"#),
CommandSpec::new("review.combine", "Combine", "Review › Compare", compare).params(r#"{"path"?: string}"#),
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"]}"#),
CommandSpec::new("review.restrict", "Restrict Editing", "Review › Protect", |s, v| {
let mode = p::str(v, "mode").unwrap_or("readOnly").to_string();
s.doc.settings.protection = if mode == "none" { None } else { Some(mode.clone()) };
if mode == "trackedChanges" {
s.doc.settings.track_changes = true;
}
Ok(json!({"protection": s.doc.settings.protection}))
})
.params(r#"{"mode": "none|readOnly|comments|trackedChanges|forms"}"#),
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"}"#),
CommandSpec::new("file.versions", "Version History", "File › Info", |s, v| {
if let Some(label) = p::str(v, "save") {
s.versions.push((label.to_string(), super::now_iso(), s.doc.clone()));
if s.versions.len() > 50 {
s.versions.remove(0);
}
}
if let Some(i) = p::u64(v, "restore") {
let (_, _, d) = s.versions.get(i as usize).cloned().ok_or_else(|| CmdError::Params("no such version".into()))?;
s.checkpoint("Restore Version");
s.doc = d;
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, _| {
Ok(Value::Array(s.versions.iter().enumerate().map(|(i, (l, d, _))| json!({"index": i, "label": l, "date": d})).collect()))
})
.pure(),
CommandSpec::new("file.newFromTemplate", "New from Template", "File › New", |s, v| {
let path = p::req_str(v, "path")?;
let doc = crate::io::open_path(std::path::Path::new(path)).map_err(CmdError::Failed)?;
s.set_document(doc);
s.path = None;
s.dirty = true;
sel_result(s)
})
.params(r#"{"path": string}"#)
.pure(),
CommandSpec::new("file.saveTemplate", "Save as Template", "File › Save As", |s, v| {
let path = p::req_str(v, "path")?;
let path = if path.ends_with(".dotx") { path.to_string() } else { format!("{path}.dotx") };
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.settings.watermark.is_some() {
issues.push("Watermarks are kept in WordCraft documents but not yet written to .docx.");
}
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.ui_requests.push(json!({"open": "saveAs"}));
Ok(json!({"hint": "Save or export a copy (PDF, Word document) to share it."}))
})
.pure(),
CommandSpec::new("file.encrypt", "Encrypt with Password", "File › Info › Protect Document", |s, _| {
s.status = "Password encryption isn't available yet; use Restrict Editing to prevent changes.".into();
Err(CmdError::Failed("password encryption isn't available yet".into()))
})
.pure(),
CommandSpec::new("insert.quickParts", "Quick Parts", "Insert › Text", quick_parts)
.params(r#"{"save"?: name (from the selection), "insert"?: name, "delete"?: name} → list"#),
CommandSpec::new("insert.autoText", "AutoText", "Insert › Text › Quick Parts", quick_parts).params(r#"{"save"?: name, "insert"?: name}"#),
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 }, &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(),
};
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}))
})
.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.textDirection", "Text Direction", "Table Layout › Alignment", |s, _| {
let Some((tp, r, c)) = s.sel.focus.path.cell() else { return Err(CmdError::Disabled("not in a table".into())) };
let t = s.doc.table_mut(s.sel.focus.story, &tp)?;
if let Some(cell) = t.rows.get_mut(r).and_then(|x| x.cells.get_mut(c)) {
cell.props.vertical_text = !cell.props.vertical_text;
}
sel_result(s)
}),
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 m = [
p::f32(v, "top").unwrap_or(0.0),
p::f32(v, "left").unwrap_or(5.4),
p::f32(v, "bottom").unwrap_or(0.0),
p::f32(v, "right").unwrap_or(5.4),
]
.map(|x| x.clamp(0.0, 144.0));
let story = s.sel.focus.story;
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.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("para.rtl", "Right-to-Left Text Direction", "Home › Paragraph", |s, v| {
let on = p::bool(v, "value").unwrap_or_else(|| !s.doc.para_at(&s.sel.focus).and_then(|x| x.props.bidi).unwrap_or(false));
super::para::fmt(s, &|pp| {
pp.bidi = Some(on);
pp.align = Some(if on { wordcraft_doc::Align::Right } else { wordcraft_doc::Align::Left });
})
}),
CommandSpec::new("review.language", "Language", "Review › Language", |s, v| {
let lang = p::str(v, "lang").unwrap_or("en-US").to_string();
let no_proof = p::bool(v, "noProof");
super::format::apply(s, &|c| {
c.lang = Some(lang.clone());
if let Some(np) = no_proof {
c.no_proof = Some(np);
}
})
})
.params(r#"{"lang": "en-US|en-GB|fr-FR|…", "noProof"?: bool}"#),
CommandSpec::new("review.showMarkup", "Show Markup", "Review › Tracking", |s, v| {
s.view.show_markup = p::bool(v, "value").unwrap_or(!s.view.show_markup);
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("review.readAloud", "Read Aloud", "Review › Speech", read_aloud).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"
};
s.run(id, &json!({}))
})
.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;
s.view.focus_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.fit = "multiplePages".into();
Ok(json!({"pageMovement": "sideToSide"}))
})
.pure(),
]
}
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).
pub fn autocorrect(s: &mut Session) -> Result<(), CmdError> {
if !s.autocorrect_on {
return Ok(());
}
let 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 !(last == ' ' || ",.;:!?".contains(last)) {
return Ok(());
}
let body = &before[..before.len() - last.len_utf8()];
let start = body.rfind(char::is_whitespace).map(|i| i + 1).unwrap_or(0);
let Some(word) = body.get(start..).map(str::to_string) else { return Ok(()) };
let word = word.as_str();
if word.is_empty() {
return Ok(());
}
let user = s.autocorrect_user.iter().find(|(a, _)| a == word).map(|(_, b)| b.clone());
let builtin = AUTOCORRECT
.iter()
.find(|(a, _)| {
*a == word || (a.chars().all(|c| c.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()
}
});
let Some(rep) = user.or(builtin) else { return Ok(()) };
let para = s.doc.para_mut(f.story, &f.path)?;
let props = para.props_of_char(start).clone();
let end = start + word.len();
para.delete(start, end)?;
para.insert_text(start, &rep, &props)?;
let off = (f.off + rep.len()).saturating_sub(word.len());
s.sel = Selection::caret(Pos { off, ..f });
Ok(())
}
/// Compare the current document (original) with a revised one: the result shows the
/// differences as tracked insertions and deletions.
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 old: Vec<Paragraph> = s.doc.body.iter().filter_map(|b| b.as_para().cloned()).collect();
let new: Vec<Paragraph> = revised.body.iter().filter_map(|b| b.as_para().cloned()).collect();
let author = "Compare".to_string();
let date = super::now_iso();
s.doc.revisions.push(wordcraft_doc::Revision { kind: RevisionKind::Insert, author: author.clone(), date: date.clone() });
let ins = (s.doc.revisions.len() - 1) as u32;
s.doc.revisions.push(wordcraft_doc::Revision { kind: RevisionKind::Delete, author, date });
let del = (s.doc.revisions.len() - 1) as u32;
let ops = lcs_ops(&old.iter().map(|p| p.plain_text()).collect::<Vec<_>>(), &new.iter().map(|p| p.plain_text()).collect::<Vec<_>>());
let mut out: Vec<Paragraph> = 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) => {
if let Some(p) = old.get(i) {
out.push(p.clone());
}
i += 1;
j += 1;
k += 1;
}
Some(Op::Del) if matches!(ops.get(k + 1), Some(Op::Ins)) => {
// A changed paragraph: diff its words.
let (a, b) = (old.get(i).cloned().unwrap_or_default(), new.get(j).cloned().unwrap_or_default());
out.push(word_diff(&a, &b, ins, del));
changes += 1;
i += 1;
j += 1;
k += 2;
}
Some(Op::Del) => {
let mut p = old.get(i).cloned().unwrap_or_default();
let len = p.len();
let _ = p.format(0, len, &|c| c.del = Some(del));
out.push(p);
changes += 1;
i += 1;
k += 1;
}
Some(Op::Ins) => {
let mut p = new.get(j).cloned().unwrap_or_default();
let len = p.len();
let _ = p.format(0, len, &|c| c.ins = Some(ins));
out.push(p);
changes += 1;
j += 1;
k += 1;
}
None => break,
}
}
s.doc.body = out.into_iter().map(para_block).collect();
s.doc.ensure_nonempty();
s.sel = Selection::caret(s.doc.start_of(StoryRef::Body));
Ok(json!({"changes": changes}))
}
#[derive(Clone, Copy, Debug, PartialEq)]
enum Op {
Same,
Del,
Ins,
}
/// LCS edit script between two sequences (bounded size).
fn lcs_ops<T: PartialEq>(a: &[T], b: &[T]) -> Vec<Op> {
let (n, m) = (a.len().min(4000), b.len().min(4000));
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);
let mut ops = Vec::new();
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, n - i));
ops.extend(std::iter::repeat_n(Op::Ins, m - j));
ops
}
fn tokens(s: &str) -> Vec<String> {
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) -> Paragraph {
let (ta, tb) = (tokens(&a.plain_text()), tokens(&b.plain_text()));
let ops = lcs_ops(&ta, &tb);
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 => {
let _ = p.insert_text(at, ta.get(i).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 mut issues = Vec::new();
let mut last_level: Option<u8> = None;
for path in s.doc.para_paths(StoryRef::Body) {
let Some(p) = s.doc.para(StoryRef::Body, &path) else { continue };
for o in &p.objects {
if let InlineObject::Image { alt, .. } = o
&& alt.trim().is_empty()
{
issues.push(json!({"kind": "error", "issue": "Missing alternative text", "where": path.0, "fix": "Select the picture and run picture.altText"}));
}
}
if let Some(l) = s.doc.styles.resolve_para(&p.props).outline_level {
if let Some(prev) = last_level
&& l > prev + 1
{
issues.push(json!({"kind": "warning", "issue": format!("Heading level skipped (Heading {} after Heading {})", l + 1, prev + 1), "where": path.0}));
}
last_level = Some(l);
}
if p.runs.iter().any(|r| r.props.link.is_some()) {
let txt = p.plain_text().to_lowercase();
if txt.contains("click here") {
issues.push(json!({"kind": "tip", "issue": "Link text \"click here\" isn't descriptive", "where": path.0}));
}
}
}
for (i, b) in s.doc.body.iter().enumerate() {
if let Block::Table(t) = &**b
&& !t.rows.first().is_some_and(|r| r.props.header)
{
issues.push(json!({"kind": "warning", "issue": "Table has no header row", "where": [i], "fix": "table.repeatHeader on the first row"}));
}
}
if s.doc.core.title.is_empty() {
issues.push(json!({"kind": "tip", "issue": "Document has no title", "fix": "file.properties {title}"}));
}
Ok(json!({"issues": issues, "ok": issues.is_empty()}))
}
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();
let has_hidden = s
.doc
.para_paths(StoryRef::Body)
.iter()
.any(|p| s.doc.para(StoryRef::Body, p).is_some_and(|x| x.runs.iter().any(|r| r.props.hidden == Some(true))));
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": has_hidden,
"headers": s.doc.parts.values().filter(|p| matches!(p.kind, wordcraft_doc::PartKind::Header | wordcraft_doc::PartKind::Footer)).count(),
});
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" => {
for path in s.doc.para_paths(StoryRef::Body) {
let ranges: Vec<(usize, usize)> = s
.doc
.para(StoryRef::Body, &path)
.map(|p| p.run_ranges().filter(|(_, c)| c.hidden == Some(true)).map(|(r, _)| (r.start, r.end)).collect())
.unwrap_or_default();
let para = s.doc.para_mut(StoryRef::Body, &path)?;
for (a, b) in ranges.into_iter().rev() {
para.delete(a, b)?;
}
}
}
"headers" => {
s.run("insert.removeHeader", &json!({}))?;
s.run("insert.removeFooter", &json!({}))?;
}
_ => {}
}
}
s.clamp_selection();
Ok(json!({"found": report, "removed": remove}))
}
fn quick_parts(s: &mut Session, v: &Value) -> CmdResult {
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);
s.building_blocks.insert(name.to_string(), frag);
}
if let Some(name) = p::str(v, "delete") {
s.building_blocks.remove(name);
}
if let Some(name) = p::str(v, "insert") {
let frag = s.building_blocks.get(name).cloned().ok_or_else(|| CmdError::Params(format!("no Quick Part `{name}`")))?;
let at = delete_selection(s)?;
let end = s.doc.insert_fragment(&at, &frag)?;
s.sel = Selection::caret(end);
}
Ok(json!({"parts": s.building_blocks.keys().collect::<Vec<_>>()}))
}
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 read_aloud(s: &mut Session, _: &Value) -> CmdResult {
let text = if s.sel.is_collapsed() {
let f = s.sel.focus.clone();
s.doc
.para_paths(f.story)
.into_iter()
.filter(|p| *p >= f.path)
.filter_map(|p| s.doc.para(f.story, &p).map(|x| x.plain_text()))
.collect::<Vec<_>>()
.join("\n")
} else {
s.selected_text()
};
let text: String = text.chars().take(20_000).collect();
#[cfg(target_arch = "wasm32")]
let _ = &text;
#[cfg(target_os = "macos")]
{
let _ = std::process::Command::new("say").arg(&text).spawn().map_err(|e| CmdError::Failed(e.to_string()))?;
return Ok(json!({"speaking": text.len()}));
}
#[cfg(target_os = "windows")]
{
let script =
format!("Add-Type -AssemblyName System.Speech; (New-Object System.Speech.Synthesis.SpeechSynthesizer).Speak([Console]::In.ReadToEnd())");
if let Ok(mut c) =
std::process::Command::new("powershell").args(["-NoProfile", "-Command", &script]).stdin(std::process::Stdio::piped()).spawn()
&& let Some(mut i) = c.stdin.take()
{
use std::io::Write;
let _ = i.write_all(text.as_bytes());
}
return Ok(json!({"speaking": text.len()}));
}
#[cfg(all(unix, not(target_os = "macos")))]
{
let _ = std::process::Command::new("spd-say")
.arg(&text)
.spawn()
.or_else(|_| std::process::Command::new("espeak").arg(&text).spawn())
.map_err(|e| CmdError::Failed(format!("no speech engine found: {e}")))?;
return Ok(json!({"speaking": text.len()}));
}
#[allow(unreachable_code)]
Err(CmdError::Failed("Read Aloud isn't available on this platform".into()))
}
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}))
}
#[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 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();
}
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());
s.run("review.restrict", &json!({"mode": "none"})).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"));
}
}