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("")) +} + +/// 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": "
  • one
  • two
"})); + 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 { let st = app.session.doc.styles.get(id)?; let rc = app.session.doc.styles.resolve_char( @@ -570,6 +592,7 @@ pub fn show(app: &mut WordApp, ctx: &egui::Context) { Dialog::TableStyle { id: None, .. } => "New Table Style", Dialog::TableStyle { .. } => "Modify Table Style", Dialog::Commands { .. } => "Search Commands", + Dialog::PasteSpecial { .. } => "Paste Special", Dialog::About { .. } => "About WordCraft", Dialog::SaveChanges { .. } => "WordCraft", Dialog::RecipientList { .. } => "Recipient List", @@ -1130,6 +1153,34 @@ fn body(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bool { } close || ui.input(|i| i.key_pressed(egui::Key::Escape)) } + Dialog::PasteSpecial { formats, choice, payload } => { + ui.label(egui::RichText::new(tl!("Paste as:")).font(semibold(12.5))); + if formats.is_empty() { + ui.label(egui::RichText::new(tl!("The clipboard is empty.")).weak()); + } + for (i, f) in formats.iter().enumerate() { + let label = wordcraft_engine::cmd::paste::FORMATS.iter().find(|(id, _)| id == f).map(|(_, l)| *l).unwrap_or(f.as_str()); + ui.radio_value(choice, i, tl!(label)); + } + if let Some(f) = formats.get(*choice) { + ui.add_space(6.0); + ui.label(egui::RichText::new(tl!("Result")).font(semibold(12.5))); + let hint = match f.as_str() { + "formatted" => "Keeps the fonts and formatting of the copied text.", + "rtf" => "Reads the clipboard's rich text and keeps its formatting.", + "html" => "Reads the clipboard's web content and keeps its formatting.", + _ => "Drops all formatting and pastes plain text.", + }; + ui.label(egui::RichText::new(tl!(hint)).weak()); + } + let (ok, cancel) = buttons(ui, tl!("OK")); + if ok && let Some(f) = formats.get(*choice) { + let mut params = payload.clone(); + params["as"] = json!(f); + let _ = app.run("edit.pasteSpecial", params); + } + ok || cancel + } Dialog::About { tab } => { ui.set_width(660.0); ui.horizontal(|ui| { diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 88f3a57..55cd1f7 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -216,6 +216,7 @@ Drawing Tools Herramientas de dibujo Drawing\nCanvas Lienzo de\ndibujo Drop Cap Letra capital + Drops all formatting and pastes plain text. Quita todo el formato y pega texto sin formato. Edit Footer Editar pie de página Edit Header Editar encabezado Edit Recipient List Editar lista de destinatarios @@ -271,6 +272,7 @@ Format Page Numbers Formato de números de página Format Page Numbers… Formato de números de página… Format Painter Pincel de formato + Formatted Text Texto con formato Formatting at Selection Formato en la selección Formula Fórmula General General @@ -284,6 +286,7 @@ Gridlines Líneas de cuadrícula Group Agrupar Grow Font 1 Point Aumentar fuente 1 punto + HTML Format Formato HTML Hanging Indent Sangría francesa Header Encabezado Header & Footer Encabezado y pie de página @@ -353,6 +356,7 @@ Keep lines together Mantener líneas juntas Keep with Next Mantener con el siguiente Keep with next Mantener con el siguiente + Keeps the fonts and formatting of the copied text. Conserva las fuentes y el formato del texto copiado. Labels Etiquetas Landscape Horizontal Language Idioma @@ -497,6 +501,9 @@ Paste Pegar Paste (⌘V) Pegar (⌘V) Paste Formatting Pegar formato + Paste Special Pegado especial + Paste Special… Pegado especial… + Paste as: Pegar como: Paste: Keep Text Only Pegar: mantener solo el texto Paste: Merge Formatting Pegar: combinar formato Pen Pluma @@ -530,6 +537,8 @@ Read Mode Modo lectura Read\nAloud Leer en\nvoz alta Read\nMode Modo\nlectura + Reads the clipboard's rich text and keeps its formatting. Lee el texto enriquecido del portapapeles y conserva su formato. + Reads the clipboard's web content and keeps its formatting. Lee el contenido web del portapapeles y conserva su formato. Reading Highlight Resaltado de lectura Recent Recientes Record Macro Grabar macro @@ -568,12 +577,14 @@ Restart at 1 Reiniciar en 1 Restrict Editing Restringir edición Restrict\nEditing Restringir\nedición + Result Resultado Results Resultados Reverse the order Invertir el orden Reviewing Revisión Reviewing Pane Panel de revisión Reviewing — edits become suggestions Revisión — las ediciones pasan a ser sugerencias Rich Text Format (*.rtf) Formato de texto enriquecido (*.rtf) + Rich Text Format (RTF) Formato de texto enriquecido (RTF) Right Derecha Right Border Borde derecho Right-to-Left Text Direction Dirección de texto de derecha a izquierda @@ -712,6 +723,7 @@ Text: Texto: Text\nBox Cuadro de\ntexto Text\nDirection Dirección del\ntexto + The clipboard is empty. El portapapeles está vacío. The new style takes the formatting of the current paragraph. El nuevo estilo toma el formato del párrafo actual. Theme Colors Colores del tema Theme Fonts Fuentes del tema @@ -739,6 +751,7 @@ Underline Words Only Subrayar solo las palabras Undo Deshacer Undo {action} Deshacer {action} + Unformatted Text Texto sin formato Up Arriba Update Field Actualizar campo Update Index Actualizar índice diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index a1ed376..c985bc8 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -931,6 +931,19 @@ Dark ダーク Use system setting システム設定を使用 A file dialog is already open. ファイル ダイアログが既に開いています。 + Paste Special 形式を選択して貼り付け + Paste Special… 形式を選択して貼り付け… + Paste as: 貼り付ける形式: + The clipboard is empty. クリップボードは空です。 + Formatted Text 書式付きテキスト + Rich Text Format (RTF) リッチ テキスト形式 (RTF) + HTML Format HTML 形式 + Unformatted Text 書式なしテキスト + Result 結果 + Keeps the fonts and formatting of the copied text. コピーしたテキストのフォントと書式を保持します。 + Reads the clipboard's rich text and keeps its formatting. クリップボードの RTF を読み込み、書式を保持します。 + Reads the clipboard's web content and keeps its formatting. クリップボードの Web コンテンツを読み込み、書式を保持します。 + Drops all formatting and pastes plain text. 書式をすべて取り除き、プレーン テキストとして貼り付けます。 Advanced Find 高度な検索 Advanced Find… 高度な検索… Go To… ジャンプ… diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 318240f..410d82b 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -943,6 +943,19 @@ Dark Escuro Use system setting Usar a configuração do sistema A file dialog is already open. Já há uma caixa de diálogo de arquivo aberta. + Paste Special Colar especial + Paste Special… Colar especial… + Paste as: Colar como: + The clipboard is empty. A área de transferência está vazia. + Formatted Text Texto formatado + Rich Text Format (RTF) Rich Text Format (RTF) + HTML Format Formato HTML + Unformatted Text Texto sem formatação + Result Resultado + Keeps the fonts and formatting of the copied text. Mantém as fontes e a formatação do texto copiado. + Reads the clipboard's rich text and keeps its formatting. Lê o rich text da área de transferência e mantém a formatação. + Reads the clipboard's web content and keeps its formatting. Lê o conteúdo web da área de transferência e mantém a formatação. + Drops all formatting and pastes plain text. Remove toda a formatação e cola texto simples. Advanced Find Localização avançada Advanced Find… Localização avançada… Go To… Ir para… diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index b59e8ba..f100054 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -217,6 +217,7 @@ Drawing Tools Знаряддя малювання Drawing\nCanvas Полотно\nмалювання Drop Cap Буквиця + Drops all formatting and pastes plain text. Відкидає все форматування й вставляє звичайний текст. Edit Footer Редагувати нижній колонтитул Edit Header Редагувати верхній колонтитул Edit Recipient List Редагувати список одержувачів @@ -273,6 +274,7 @@ Format Page Numbers Формат номерів сторінок Format Page Numbers… Формат номерів сторінок… Format Painter Пензель форматування + Formatted Text Форматований текст Formatting at Selection Форматування виділення Formula Формула General Загальні @@ -304,6 +306,7 @@ Home Основне Horizontal Горизонтальна Horizontal Line Горизонтальна лінія + HTML Format Формат HTML Hyphenation Перенесення слів Icons Піктограми Ignore All Ігнорувати всі @@ -355,6 +358,7 @@ Keep Text Only Зберегти лише текст Keep with Next Не відривати від наступного Keep with next Не відривати від наступного + Keeps the fonts and formatting of the copied text. Зберігає шрифти й форматування скопійованого тексту. Labels Етикетки Landscape Альбомна Language Мова @@ -496,7 +500,10 @@ Paragraphs Абзаци Paste Вставити Paste (⌘V) Вставити (⌘V) + Paste as: Вставити як: Paste Formatting Вставити форматування + Paste Special Спеціальна вставка + Paste Special… Спеціальна вставка… Paste: Keep Text Only Вставлення: зберегти лише текст Paste: Merge Formatting Вставлення: об'єднати форматування PDF document (*.pdf) Документ PDF (*.pdf) @@ -531,6 +538,8 @@ Read Mode Режим читання Read\nAloud Читати\nвголос Read\nMode Режим\nчитання + Reads the clipboard's rich text and keeps its formatting. Читає форматований текст (RTF) із буфера обміну та зберігає його форматування. + Reads the clipboard's web content and keeps its formatting. Читає вебвміст із буфера обміну та зберігає його форматування. Reading Highlight Виділення під час читання Recent Останні Record Macro Записати макрос @@ -569,6 +578,7 @@ Restart Each Section Починати в кожному розділі Restrict Editing Обмежити редагування Restrict\nEditing Обмежити\nредагування + Result Результат Results Результати Reverse the order Зворотний порядок Review Рецензування @@ -576,6 +586,7 @@ Reviewing Pane Панель рецензування Reviewing — edits become suggestions Рецензування — зміни стають пропозиціями Rich Text Format (*.rtf) Форматований текст (*.rtf) + Rich Text Format (RTF) Формат RTF (Rich Text Format) Right Праворуч Right Border Права межа Right-to-Left Text Direction Напрямок тексту справа наліво @@ -713,6 +724,7 @@ Text: Текст: Text\nBox Текстове\nполе Text\nDirection Напрямок\nтексту + The clipboard is empty. Буфер обміну порожній. The new style takes the formatting of the current paragraph. Новий стиль використовує форматування поточного абзацу. Theme Colors Кольори теми Theme Fonts Шрифти теми @@ -739,6 +751,7 @@ Underline Words Only Підкреслити лише слова Undo Скасувати Undo {action} Скасувати {action} + Unformatted Text Неформатований текст Up Угору Update Field Оновити поле Update Index Оновити покажчик diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 7faf9d4..4faefa1 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -942,6 +942,19 @@ Dark 深色 Use system setting 使用系统设置 A file dialog is already open. 已有一个文件对话框处于打开状态。 + Paste Special 选择性粘贴 + Paste Special… 选择性粘贴… + Paste as: 粘贴为: + The clipboard is empty. 剪贴板为空。 + Formatted Text 带格式文本 + Rich Text Format (RTF) RTF 格式 + HTML Format HTML 格式 + Unformatted Text 无格式文本 + Result 结果 + Keeps the fonts and formatting of the copied text. 保留所复制文本的字体和格式。 + Reads the clipboard's rich text and keeps its formatting. 读取剪贴板中的 RTF 内容并保留其格式。 + Reads the clipboard's web content and keeps its formatting. 读取剪贴板中的网页内容并保留其格式。 + Drops all formatting and pastes plain text. 去除所有格式,粘贴纯文本。 Advanced Find 高级查找 Advanced Find… 高级查找… Go To… 定位… diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index da88b5f..e36b590 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -932,6 +932,19 @@ Dark 深色 Use system setting 使用系統設定 A file dialog is already open. 已有一個檔案對話方塊開啟中。 + Paste Special 選擇性貼上 + Paste Special… 選擇性貼上… + Paste as: 貼上為: + The clipboard is empty. 剪貼簿是空的。 + Formatted Text 格式化文字 + Rich Text Format (RTF) RTF 格式 + HTML Format HTML 格式 + Unformatted Text 純文字 + Result 結果 + Keeps the fonts and formatting of the copied text. 保留所複製文字的字型和格式。 + Reads the clipboard's rich text and keeps its formatting. 讀取剪貼簿中的 RTF 內容並保留其格式。 + Reads the clipboard's web content and keeps its formatting. 讀取剪貼簿中的網頁內容並保留其格式。 + Drops all formatting and pastes plain text. 移除所有格式,貼上純文字。 Advanced Find 進階尋找 Advanced Find… 進階尋找… Go To… 移至… diff --git a/crates/ui-egui/src/keys.rs b/crates/ui-egui/src/keys.rs index 476801f..13e2454 100644 --- a/crates/ui-egui/src/keys.rs +++ b/crates/ui-egui/src/keys.rs @@ -185,7 +185,13 @@ pub fn canvas_events(app: &mut WordApp, ctx: &egui::Context) { } } egui::Event::Paste(t) => { - let id = if ctx.input(|i| i.modifiers.shift && i.modifiers.alt) { "edit.pasteText" } else { "edit.paste" }; + // Mod+Alt+V arrives as a paste too (the shell reads the clipboard): Paste Special. + let m = ctx.input(|i| i.modifiers); + let id = match (m.alt, m.shift) { + (true, true) => "edit.pasteText", + (true, false) => "edit.pasteSpecial", + _ => "edit.paste", + }; let _ = app.run(id, json!({"text": t})); } egui::Event::Copy | egui::Event::Cut => { diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 4fef649..b17245d 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -476,6 +476,7 @@ impl WordApp { self.ui.backstage = true; self.ui.backstage_page = "options".into(); } + "pasteSpecial" => self.dialog = Some(dialogs::Dialog::paste_special(self, req)), other => self.dialog = dialogs::Dialog::open(other, self), } } diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index c99b9a4..73c4909 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -246,6 +246,7 @@ fn home(app: &mut WordApp, ui: &mut Ui) { mi(ui, app, "Paste", "edit.paste", json!({})); mi(ui, app, "Keep Text Only", "edit.pasteText", json!({})); mi(ui, app, "Merge Formatting", "edit.pasteMerge", json!({})); + mi(ui, app, "Paste Special…", "edit.pasteSpecial", json!({})); }); stack(ui, |ui| { small(ui, app, "cut", Some("Cut"), "Cut", "edit.cut", json!({}), false); diff --git a/docs/parity.md b/docs/parity.md index 279962e..a227f82 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -2,13 +2,13 @@ Generated by `cargo xtask parity` (`wordcraft-cli parity --markdown`): the word-processor feature catalog (`crates/engine/src/catalog.rs`) compared with the live command registry. -**381 of 430 catalog features have commands (88.6%).** +**382 of 430 catalog features have commands (88.8%).** | Tab | Live | Total | |---|---|---| | File | 20 | 20 | | Quick Access | 4 | 4 | -| Home | 93 | 101 | +| Home | 94 | 101 | | Insert | 42 | 53 | | Draw | 0 | 11 | | Design | 10 | 10 | @@ -30,7 +30,6 @@ Generated by `cargo xtask parity` (`wordcraft-cli parity --markdown`): the word- ### Home -- Clipboard › Paste Special (`edit.pasteSpecial`) - Clipboard › Clipboard Pane (`edit.clipboardPane`) - Font › Phonetic Guide (`format.phonetic`) - Font › Enclose Characters (`format.enclose`)