Paste Special: paste the clipboard as formatted text, RTF, HTML or unformatted text (edit.pasteSpecial) (#235)

New edit.pasteSpecial command ({"as"?, "text"?, "html"?, "rtf"?}). Without "as" it
lists the clipboard's formats and asks the front end for the dialog; with "as" it pastes in
that format. HTML/RTF go through the wordcraft-formats importers, with their lists, styles and
images merged into the document under fresh ids. Respects Track Changes (now also inside pasted
tables) and caps payloads at 16 MB. UI: Paste Special dialog (radio list + result hint),
Mod+Alt+V and Paste menu entry; strings in all four catalogs. Parity 355/405, floor 87.5%.

Co-authored-by: Dmitrii Khlapov <19991457+dkhlapov@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
This commit is contained in:
authored and GitHub committed 2026-10-10 13:46:21 -04:00
1 parent 9c3ad84d33
commit 8bc4e6c999
17 files changed
+444 -29

No files matched your search

+2 -2
View File
@@ -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) |
+1 -1
View File
@@ -502,6 +502,6 @@ mod tests {
let p = parity(&reg);
let pct = p["percent"].as_f64().unwrap();
// The floor only ever rises.
assert!(pct >= 60.0, "parity {pct}%");
assert!(pct >= 87.5, "parity {pct}%");
}
}
+2 -18
View File
@@ -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)
}
+2
View File
@@ -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());
+215
View File
@@ -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<CommandSpec> {
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<String> {
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<String> {
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::<Vec<_>>())
}
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<Block> = 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 }
}
+78
View File
@@ -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 = "<p>plain <b>strong</b></p>";
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 <b> 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": "<b>hi</b> there</p>"}));
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": "<ul><li>one</li><li>two</li></ul>"}));
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();
+55 -4
View File
@@ -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<String>,
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<String> = 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<Dialog> {
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| {
+13
View File
@@ -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
Can't render this file because it contains an unexpected character in line 6 and column 25.
+13
View File
@@ -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… ジャンプ…
Can't render this file because it contains an unexpected character in line 411 and column 13.
+13
View File
@@ -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…
Can't render this file because it contains an unexpected character in line 6 and column 25.
+13
View File
@@ -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 Оновити покажчик
Can't render this file because it contains an unexpected character in line 6 and column 31.
+13
View File
@@ -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… 定位…
Can't render this file because it contains an unexpected character in line 6 and column 25.
+13
View File
@@ -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… 移至…
Can't render this file because it contains an unexpected character in line 412 and column 13.
+7 -1
View File
@@ -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 => {
+1
View File
@@ -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),
}
}
+1
View File
@@ -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);
+2 -3
View File
@@ -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`)