diff --git a/ROADMAP.md b/ROADMAP.md
index b9a31ea..61b18c7 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -8,8 +8,8 @@ and agent control. This file tracks where we are honestly. Generated numbers com
| Measure | Value |
|---|---|
-| Commands (every action, scriptable by CLI/MCP/control channel) | **422** |
-| Feature catalog coverage (Word ribbon/menu features with a command) | **381 / 430 (89%)** |
+| Commands (every action, scriptable by CLI/MCP/control channel) | **427** |
+| Feature catalog coverage (Word ribbon/menu features with a command) | **382 / 430 (89%)** |
| **Estimated real feature parity** (depth and fidelity, not just a command) | **~62%** |
| **Distance to alpha** | **~85% of the way; ≈20–25 h of Claude wall-clock work** |
| **Distance to 100% parity** | **≈120–150 h of Claude Opus 5.5 wall-clock work** (with parallel agents) |
diff --git a/crates/engine/src/catalog.rs b/crates/engine/src/catalog.rs
index 7dcc945..687790a 100644
--- a/crates/engine/src/catalog.rs
+++ b/crates/engine/src/catalog.rs
@@ -502,6 +502,6 @@ mod tests {
let p = parity(®);
let pct = p["percent"].as_f64().unwrap();
// The floor only ever rises.
- assert!(pct >= 60.0, "parity {pct}%");
+ assert!(pct >= 87.5, "parity {pct}%");
}
}
diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs
index b24616b..fc17212 100644
--- a/crates/engine/src/cmd/edit.rs
+++ b/crates/engine/src/cmd/edit.rs
@@ -2,7 +2,7 @@
use serde_json::{Value, json};
use wordcraft_doc::edit::Fragment;
-use wordcraft_doc::{Block, Pos, StoryRef};
+use wordcraft_doc::{Pos, StoryRef};
use super::{delete_selection, pos_json, sel_result};
use crate::{CmdError, CmdResult, CommandSpec, Selection, Session, p};
@@ -84,23 +84,7 @@ fn paste(s: &mut Session, v: &Value) -> CmdResult {
(None, Some(f)) => f.clone(),
(None, None) => return Err(CmdError::Failed("the clipboard is empty".into())),
};
- if let Some(images) = v.get("image").and_then(Value::as_str) {
- let _ = images;
- }
- let at = delete_selection(s)?;
- let mut frag = frag;
- if s.doc.settings.track_changes {
- let rid = super::new_revision(s, wordcraft_doc::RevisionKind::Insert);
- for b in &mut frag.blocks {
- if let Block::Para(p) = b {
- for r in &mut p.runs {
- r.props.ins = Some(rid);
- }
- }
- }
- }
- let end = s.doc.insert_fragment(&at, &frag)?;
- s.sel = Selection::caret(end);
+ super::paste::insert(s, frag)?;
sel_result(s)
}
diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs
index 42aaf7b..6eefb9b 100644
--- a/crates/engine/src/cmd/mod.rs
+++ b/crates/engine/src/cmd/mod.rs
@@ -12,6 +12,7 @@ pub mod mailings;
pub mod objects;
pub mod page;
pub mod para;
+pub mod paste;
pub mod references;
pub mod review;
pub mod speech;
@@ -33,6 +34,7 @@ pub fn registry() -> Registry {
v.extend(text::specs());
v.extend(caret::specs());
v.extend(edit::specs());
+ v.extend(paste::specs());
v.extend(format::specs());
v.extend(para::specs());
v.extend(view::specs());
diff --git a/crates/engine/src/cmd/paste.rs b/crates/engine/src/cmd/paste.rs
new file mode 100644
index 0000000..ad1ba09
--- /dev/null
+++ b/crates/engine/src/cmd/paste.rs
@@ -0,0 +1,215 @@
+//! Paste Special: paste the clipboard in a chosen format (formatted, RTF, HTML, unformatted).
+//!
+//! The clipboard can hold several representations of the same content. WordCraft's own copy keeps
+//! a rich fragment; the system clipboard gives text, and callers (agents, the CLI, a front end
+//! with a richer clipboard) may pass `html` or `rtf` too. Text that is itself RTF or HTML source
+//! can also be pasted as that format.
+
+use std::collections::BTreeMap;
+use std::sync::Arc;
+
+use serde_json::{Value, json};
+use wordcraft_doc::edit::Fragment;
+use wordcraft_doc::{Block, Document, Paragraph};
+
+use super::{delete_selection, sel_result};
+use crate::{CmdError, CmdResult, CommandSpec, Selection, Session, p};
+
+/// Largest clipboard payload Paste Special accepts, in bytes.
+pub const MAX_PASTE_BYTES: usize = 16 << 20;
+/// Nesting depth we follow when adjusting pasted tables.
+const MAX_DEPTH: usize = 64;
+
+/// The formats, in the order the dialog lists them: (id, label).
+pub const FORMATS: [(&str, &str); 4] =
+ [("formatted", "Formatted Text"), ("rtf", "Rich Text Format (RTF)"), ("html", "HTML Format"), ("text", "Unformatted Text")];
+
+pub fn specs() -> Vec {
+ vec![
+ CommandSpec::new("edit.pasteSpecial", "Paste Special", "Home › Clipboard › Paste", paste_special)
+ .key("Mod+Alt+V")
+ .params(r#"{"as"?: "formatted"|"rtf"|"html"|"text", "text"?: string, "html"?: string, "rtf"?: string}"#),
+ ]
+}
+
+/// Clipboard text from the params (system clipboard), else WordCraft's own copy.
+fn clip_text(s: &Session, v: &Value) -> Option {
+ match p::str(v, "text") {
+ Some(t) => Some(t.replace("\r\n", "\n")),
+ None => s.clipboard.as_ref().map(Fragment::plain_text),
+ }
+}
+
+fn looks_like_rtf(t: &str) -> bool {
+ t.trim_start().starts_with("{\\rtf")
+}
+
+fn looks_like_html(t: &str) -> bool {
+ let t = t.trim_start();
+ t.starts_with('<') && (t.contains("") || t.contains("/>"))
+}
+
+/// The source for a format, if the clipboard has it.
+fn source(s: &Session, v: &Value, fmt: &str) -> Option {
+ let non_empty = |t: String| if t.is_empty() { None } else { Some(t) };
+ match fmt {
+ "formatted" => {
+ let f = s.clipboard.as_ref()?;
+ // System clipboard text that isn't our own copy means someone else copied since.
+ match p::str(v, "text") {
+ Some(t) if f.plain_text() != t.replace("\r\n", "\n") => None,
+ _ => Some(String::new()),
+ }
+ }
+ "rtf" => p::str(v, "rtf").map(str::to_string).or_else(|| clip_text(s, v).filter(|t| looks_like_rtf(t))).and_then(non_empty),
+ "html" => p::str(v, "html").map(str::to_string).or_else(|| clip_text(s, v).filter(|t| looks_like_html(t))).and_then(non_empty),
+ "text" => clip_text(s, v).and_then(non_empty),
+ _ => None,
+ }
+}
+
+/// Formats the clipboard can be pasted as, best first.
+pub fn available(s: &Session, v: &Value) -> Vec<&'static str> {
+ FORMATS.iter().map(|(id, _)| *id).filter(|id| source(s, v, id).is_some()).collect()
+}
+
+fn formats_json(ids: &[&str]) -> Value {
+ json!(ids.iter().map(|id| json!({"id": id, "label": FORMATS.iter().find(|(f, _)| f == id).map(|(_, l)| *l).unwrap_or(*id)})).collect::>())
+}
+
+fn paste_special(s: &mut Session, v: &Value) -> CmdResult {
+ for k in ["text", "html", "rtf"] {
+ if p::str(v, k).is_some_and(|t| t.len() > MAX_PASTE_BYTES) {
+ return Err(CmdError::Params(format!("`{k}` is larger than {} MB", MAX_PASTE_BYTES >> 20)));
+ }
+ }
+ let ids = available(s, v);
+ let Some(fmt) = p::str(v, "as") else {
+ // No format chosen: list them and ask a front end (if any) to show the dialog.
+ let mut req = json!({"open": "pasteSpecial", "formats": ids});
+ for k in ["text", "html", "rtf"] {
+ if let Some(t) = p::str(v, k) {
+ req[k] = json!(t);
+ }
+ }
+ s.ui_requests.push(req);
+ return Ok(json!({"formats": formats_json(&ids)}));
+ };
+ let fmt = match fmt.to_ascii_lowercase().as_str() {
+ "formatted" | "keep" | "wordcraft" => "formatted",
+ "rtf" => "rtf",
+ "html" => "html",
+ "text" | "unformatted" | "plain" => "text",
+ other => return Err(CmdError::Params(format!("unknown format `{other}` (formatted, rtf, html or text)"))),
+ };
+ let Some(src) = source(s, v, fmt) else {
+ return Err(CmdError::Failed(if ids.is_empty() {
+ "the clipboard is empty".into()
+ } else {
+ format!("the clipboard has no {fmt} content (available: {})", ids.join(", "))
+ }));
+ };
+ let frag = match fmt {
+ "text" => {
+ super::type_text(s, &src.replace('\n', "\r"))?;
+ let mut r = sel_result(s)?;
+ r["pastedAs"] = json!(fmt);
+ return Ok(r);
+ }
+ "formatted" => s.clipboard.clone().ok_or_else(|| CmdError::Failed("the clipboard is empty".into()))?,
+ "html" => adopt(s, wordcraft_formats::html::import(&src)),
+ _ => adopt(s, wordcraft_formats::rtf::import(src.as_bytes()).map_err(|e| CmdError::Failed(format!("bad RTF: {e}")))?),
+ };
+ insert(s, frag)?;
+ let mut r = sel_result(s)?;
+ r["pastedAs"] = json!(fmt);
+ Ok(r)
+}
+
+/// Replace the selection with `frag`, marking it as an insertion under Track Changes.
+pub fn insert(s: &mut Session, mut frag: Fragment) -> Result<(), CmdError> {
+ let at = delete_selection(s)?;
+ if s.doc.settings.track_changes {
+ let rid = super::new_revision(s, wordcraft_doc::RevisionKind::Insert);
+ for b in &mut frag.blocks {
+ each_para(b, 0, &mut |p| {
+ for r in &mut p.runs {
+ r.props.ins = Some(rid);
+ r.props.del = None;
+ }
+ });
+ }
+ }
+ let end = s.doc.insert_fragment(&at, &frag)?;
+ s.sel = Selection::caret(end);
+ Ok(())
+}
+
+/// Every paragraph in a block, tables included (bounded depth).
+fn each_para(b: &mut Block, depth: usize, f: &mut dyn FnMut(&mut Paragraph)) {
+ if depth > MAX_DEPTH {
+ return;
+ }
+ match b {
+ Block::Para(p) => f(p),
+ Block::Table(t) => {
+ for row in &mut t.rows {
+ for cell in &mut row.cells {
+ for cb in &mut cell.blocks {
+ each_para(Arc::make_mut(cb), depth + 1, f);
+ }
+ }
+ }
+ }
+ }
+}
+
+/// Turn an imported document into a fragment of the session's document: bring over the media,
+/// lists and styles it refers to (renumbered so they can't collide with ours).
+fn adopt(s: &mut Session, src: Document) -> Fragment {
+ let mut media = BTreeMap::new();
+ for (key, data) in &src.media {
+ let ext = key.rsplit_once('.').map(|(_, e)| e).unwrap_or("png");
+ media.insert(key.clone(), s.doc.add_media(data.to_vec(), ext));
+ }
+ let next_abs = s.doc.numbering.abstracts.iter().map(|a| a.id.saturating_add(1)).max().unwrap_or(0);
+ let mut abs_map = BTreeMap::new();
+ for (i, a) in src.numbering.abstracts.iter().enumerate() {
+ let id = next_abs.saturating_add(i as u32);
+ abs_map.insert(a.id, id);
+ s.doc.numbering.abstracts.push(wordcraft_doc::numbering::AbstractNum { id, ..a.clone() });
+ }
+ let next_num = s.doc.numbering.nums.iter().map(|n| n.id.saturating_add(1)).max().unwrap_or(1).max(1);
+ let mut num_map = BTreeMap::new();
+ for (i, n) in src.numbering.nums.iter().enumerate() {
+ let id = next_num.saturating_add(i as u32);
+ num_map.insert(n.id, id);
+ let abstract_id = abs_map.get(&n.abstract_id).copied().unwrap_or(n.abstract_id);
+ s.doc.numbering.nums.push(wordcraft_doc::numbering::Num { id, abstract_id, ..n.clone() });
+ }
+ for st in &src.styles.styles {
+ if s.doc.styles.get(&st.id).is_none() {
+ s.doc.styles.upsert(st.clone());
+ }
+ }
+ let mut blocks: Vec = src.body.iter().map(|b| (**b).clone()).collect();
+ for b in &mut blocks {
+ each_para(b, 0, &mut |p| {
+ if let Some(n) = p.props.numbering.as_mut()
+ && n.num != 0
+ {
+ n.num = num_map.get(&n.num).copied().unwrap_or(0);
+ }
+ for o in &mut p.objects {
+ if let wordcraft_doc::InlineObject::Image { media: key, .. } = o
+ && let Some(k) = media.get(key.as_str())
+ {
+ *key = k.clone();
+ }
+ }
+ });
+ }
+ // Text box stories the imported shapes show; pasting gives each a new story of its own.
+ let parts = src.parts.into_iter().filter(|(_, p)| p.kind == wordcraft_doc::PartKind::TextBox).collect();
+ Fragment { blocks, parts }
+}
diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs
index 0e713df..fb50dab 100644
--- a/crates/engine/src/tests.rs
+++ b/crates/engine/src/tests.rs
@@ -523,6 +523,84 @@ fn clipboard_round_trip() {
assert_eq!(text(&s), "plain\ntext");
}
+fn bold_at(s: &Session, block: usize, off: usize) -> bool {
+ let pos = Pos { story: StoryRef::Body, path: wordcraft_doc::Path::top(block), off };
+ s.doc.para_at(&pos).and_then(|p| p.props_of_char(off).bold).unwrap_or(false)
+}
+
+#[test]
+fn paste_special_lists_formats_and_pastes_text_without_formatting() {
+ let mut s = s();
+ run(&mut s, "document.setText", json!({"text": "Bold words"}));
+ run(&mut s, "select.text", json!({"text": "Bold"}));
+ run(&mut s, "format.bold", json!({}));
+ run(&mut s, "edit.copy", json!({}));
+ // No format: the formats are listed (and a front end is asked to show the dialog).
+ let r = run(&mut s, "edit.pasteSpecial", json!({}));
+ let ids: Vec<&str> = r["formats"].as_array().unwrap().iter().map(|f| f["id"].as_str().unwrap()).collect();
+ assert_eq!(ids, ["formatted", "text"]);
+ assert_eq!(s.ui_requests.last().unwrap()["open"], "pasteSpecial");
+ run(&mut s, "caret.docEnd", json!({}));
+ let r = run(&mut s, "edit.pasteSpecial", json!({"as": "text"}));
+ assert_eq!(r["pastedAs"], "text");
+ assert_eq!(text(&s), "Bold wordsBold");
+ assert!(bold_at(&s, 0, 0));
+ assert!(!bold_at(&s, 0, 11), "unformatted text drops the bold");
+ // Formatted keeps it.
+ run(&mut s, "edit.pasteSpecial", json!({"as": "formatted"}));
+ assert_eq!(text(&s), "Bold wordsBoldBold");
+ assert!(bold_at(&s, 0, 15));
+ // Text that someone else copied since: our rich copy is no longer offered.
+ let r = run(&mut s, "edit.pasteSpecial", json!({"text": "elsewhere"}));
+ assert_eq!(r["formats"].as_array().unwrap().len(), 1);
+ assert!(s.run("edit.pasteSpecial", &json!({"as": "formatted", "text": "elsewhere"})).is_err());
+}
+
+#[test]
+fn paste_special_html_and_rtf_keep_bold() {
+ let mut s = s();
+ run(&mut s, "document.setText", json!({"text": "x"}));
+ run(&mut s, "caret.docEnd", json!({}));
+ let html = "plain strong
";
+ let r = run(&mut s, "edit.pasteSpecial", json!({"html": html, "text": "plain strong"}));
+ let ids: Vec<&str> = r["formats"].as_array().unwrap().iter().map(|f| f["id"].as_str().unwrap()).collect();
+ assert_eq!(ids, ["html", "text"]);
+ run(&mut s, "edit.pasteSpecial", json!({"as": "html", "html": html}));
+ assert_eq!(text(&s), "xplain strong");
+ assert!(!bold_at(&s, 0, 2));
+ assert!(bold_at(&s, 0, 8), "the run stays bold");
+ // HTML source copied as text can be pasted as HTML too.
+ run(&mut s, "document.setText", json!({"text": ""}));
+ run(&mut s, "edit.pasteSpecial", json!({"as": "html", "text": "hi there
"}));
+ assert_eq!(text(&s), "hi there");
+ assert!(bold_at(&s, 0, 0) && !bold_at(&s, 0, 4));
+ // RTF.
+ run(&mut s, "document.setText", json!({"text": ""}));
+ run(&mut s, "edit.pasteSpecial", json!({"as": "rtf", "rtf": r"{\rtf1\ansi {\b bold}\b0 then}"}));
+ assert!(text(&s).starts_with("bold"), "{}", text(&s));
+ assert!(bold_at(&s, 0, 0));
+ // Asking for a format the clipboard lacks is an error, not a panic.
+ assert!(s.run("edit.pasteSpecial", &json!({"as": "rtf", "text": "plain"})).is_err());
+ assert!(s.run("edit.pasteSpecial", &json!({"as": "bogus", "text": "plain"})).is_err());
+ let huge = "a".repeat(crate::cmd::paste::MAX_PASTE_BYTES + 1);
+ assert!(s.run("edit.pasteSpecial", &json!({"as": "text", "text": huge})).is_err());
+}
+
+#[test]
+fn paste_special_respects_track_changes_and_brings_lists() {
+ let mut s = s();
+ run(&mut s, "review.trackChanges", json!({"value": true}));
+ run(&mut s, "edit.pasteSpecial", json!({"as": "html", "html": ""}));
+ assert!(text(&s).starts_with("one\ntwo"), "{}", text(&s));
+ let ch = run(&mut s, "review.changes", json!({}));
+ assert!(!ch.as_array().unwrap().is_empty(), "the paste is a tracked insertion");
+ let first = s.doc.body.first().and_then(|b| b.as_para()).unwrap();
+ let n = first.props.numbering.expect("list kept");
+ assert!(s.doc.numbering.nums.iter().any(|x| x.id == n.num), "the list definition came along");
+ run(&mut s, "review.acceptAll", json!({}));
+ assert!(text(&s).starts_with("one\ntwo"));
+}
+
#[test]
fn tables_commands() {
let mut s = s();
diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs
index 16b7255..7268b30 100644
--- a/crates/ui-egui/src/dialogs.rs
+++ b/crates/ui-egui/src/dialogs.rs
@@ -1,8 +1,8 @@
//! Dialogs: Font, Paragraph, Find & Replace, Go To, Insert Table, Page Setup, Link, Bookmark,
-//! Word Count, Zoom, Watermark, New/Modify Style, New/Modify Table Style, Command search, About,
-//! Save Changes, and the mail-merge Recipient List, Insert Merge Field, Find Recipient, merge rules,
-//! Match Fields and Check for Errors. Every dialog ends by running a command (or shows one's
-//! result), so agents get the same result without the dialog.
+//! Word Count, Zoom, Watermark, New/Modify Style, New/Modify Table Style, Command search, Paste
+//! Special, About, Save Changes, and the mail-merge Recipient List, Insert Merge Field, Find
+//! Recipient, merge rules, Match Fields and Check for Errors. Every dialog ends by running a command
+//! (or shows one's result), so agents get the same result without the dialog.
use egui::{Sense, Ui, vec2};
use serde::Serialize;
@@ -119,6 +119,14 @@ pub enum Dialog {
Commands {
query: String,
},
+ /// Paste Special: the clipboard's formats and the chosen one. The clipboard payload stays out
+ /// of the serialized dialog state (it can be megabytes).
+ PasteSpecial {
+ formats: Vec,
+ choice: usize,
+ #[serde(skip)]
+ payload: Value,
+ },
/// Tabs: 0 About, 1 Contributors, 2 Models.
About {
tab: u8,
@@ -333,6 +341,7 @@ impl Dialog {
Dialog::TableStyle { id: None, .. } => "newTableStyle",
Dialog::TableStyle { .. } => "modifyTableStyle",
Dialog::Commands { .. } => "commands",
+ Dialog::PasteSpecial { .. } => "pasteSpecial",
Dialog::About { .. } => "about",
Dialog::SaveChanges { .. } => "saveChanges",
Dialog::RecipientList { .. } => "recipientList",
@@ -446,6 +455,7 @@ impl Dialog {
Dialog::TableStyle { id: Some(id), name, based_on, region: 0, basis: regions.clone(), regions }
}
"commands" => Dialog::Commands { query: String::new() },
+ "pasteSpecial" => Dialog::paste_special(app, &json!({})),
"about" => Dialog::About { tab: 0 },
"contributors" => Dialog::About { tab: 1 },
"models" => Dialog::About { tab: 2 },
@@ -484,6 +494,18 @@ impl Dialog {
})
}
+ /// Paste Special for a clipboard payload (`text`, `html`, `rtf`; empty = WordCraft's own copy).
+ pub fn paste_special(app: &WordApp, payload: &Value) -> Dialog {
+ let formats: Vec = wordcraft_engine::cmd::paste::available(&app.session, payload).into_iter().map(str::to_string).collect();
+ let mut kept = json!({});
+ for k in ["text", "html", "rtf"] {
+ if let Some(t) = payload.get(k).and_then(Value::as_str) {
+ kept[k] = json!(t);
+ }
+ }
+ Dialog::PasteSpecial { formats, choice: 0, payload: kept }
+ }
+
pub fn modify_style(app: &WordApp, id: &str) -> Option