Paste keeps other programs' formatting; clipboard pictures on Wayland (#687, #833) (#847)

* #687, #833: paste keeps other programs' formatting; Wayland clipboard pictures

- Paste (Ctrl+V, Home > Paste) reads the system clipboard's HTML, else RTF,
  and keeps its formatting; our own copy still wins while the clipboard holds it.
  Windows CF_HTML headers are understood; oversized or empty payloads fall back
  to the text.
- Merge Formatting inserts the content in the destination's font and paragraph,
  keeping bold, italic, underline and strikethrough (it did nothing before).
- Paste Special lists what the system clipboard holds (HTML, RTF, picture,
  text); Mod+Alt+V opens it even with only a picture there.
- Services gain clipboard_html, clipboard_rtf and clipboard_formats hooks; the
  host reads RTF on Windows (clipboard-win) and Wayland (wl-clipboard-rs).
- Linux: arboard's wayland-data-control feature, so pictures copied in a
  browser or Spectacle reach a native Wayland window (KWin only syncs to
  Xwayland while an X11 window has focus); other picture types are read
  from Wayland directly. A paste that finds nothing usable names the formats
  on the clipboard, or why its picture couldn't be read, instead of "empty".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Review fixes for #847: translate the Paste Special picture strings; Windows/macOS unused import

- `Picture` and `Pastes the clipboard's picture.` translated in all 15 interface catalogs.
- clipboard.rs: `MAX_FILE_BYTES` is used only by the Linux/Wayland module, so it is named
  there; the top-level import was unused (a warning, and a clippy -D warnings error) on Windows
  and macOS.
- Merged main (ROADMAP.md, docs/gaps.md, docs/file-format-parity.md, main.rs services: keeps
  both the clipboard hooks and the default-app hook from #295). Cargo.lock adds only the
  wl-clipboard-rs set.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* i18n: Hungarian and French strings for this change

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

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-12 08:18:25 -04:00
1 parent 18e25d0883
commit 411ce31de7
35 files changed
+848 -72

No files matched your search

Generated
+87 -4
View File
@@ -241,6 +241,7 @@ dependencies = [
"parking_lot",
"percent-encoding",
"windows-sys 0.60.2",
"wl-clipboard-rs",
"x11rb",
]
@@ -1315,6 +1316,12 @@ version = "0.1.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484"
[[package]]
name = "fixedbitset"
version = "0.5.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99"
[[package]]
name = "flate2"
version = "1.1.10"
@@ -1352,6 +1359,12 @@ version = "1.0.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
[[package]]
name = "foldhash"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
[[package]]
name = "foldhash"
version = "0.2.0"
@@ -1539,7 +1552,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed4a1bb24121291d27230c1b1b44e07d6a9b28cefdb32fe1581dfb84e14f940a"
dependencies = [
"bytemuck",
"foldhash",
"foldhash 0.2.0",
"hashbrown 0.17.1",
"log",
"peniko",
@@ -1555,7 +1568,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "282a26c1e23de04bdab3e34a21b6f877a479b96737792516b9b8b8f69b6661be"
dependencies = [
"bytemuck",
"foldhash",
"foldhash 0.2.0",
"hashbrown 0.17.1",
"log",
"peniko",
@@ -1703,6 +1716,15 @@ dependencies = [
"smallvec",
]
[[package]]
name = "hashbrown"
version = "0.15.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
dependencies = [
"foldhash 0.1.5",
]
[[package]]
name = "hashbrown"
version = "0.16.1"
@@ -1711,7 +1733,7 @@ checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
dependencies = [
"allocator-api2",
"equivalent",
"foldhash",
"foldhash 0.2.0",
]
[[package]]
@@ -1720,7 +1742,7 @@ version = "0.17.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
dependencies = [
"foldhash",
"foldhash 0.2.0",
]
[[package]]
@@ -2399,6 +2421,15 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451"
[[package]]
name = "nom"
version = "8.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405"
dependencies = [
"memchr",
]
[[package]]
name = "num-traits"
version = "0.2.19"
@@ -2796,6 +2827,16 @@ dependencies = [
"pin-project-lite",
]
[[package]]
name = "os_pipe"
version = "1.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967"
dependencies = [
"libc",
"windows-sys 0.52.0",
]
[[package]]
name = "parking"
version = "2.2.1"
@@ -2856,6 +2897,17 @@ version = "2.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
[[package]]
name = "petgraph"
version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455"
dependencies = [
"fixedbitset",
"hashbrown 0.15.5",
"indexmap",
]
[[package]]
name = "phf"
version = "0.13.1"
@@ -3996,6 +4048,17 @@ dependencies = [
"once_cell",
]
[[package]]
name = "tree_magic_mini"
version = "3.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8765b90061cba6c22b5831f675da109ae5561588290f9fa2317adab2714d5a6"
dependencies = [
"memchr",
"nom",
"petgraph",
]
[[package]]
name = "ttf-parser"
version = "0.25.1"
@@ -4996,11 +5059,30 @@ version = "0.57.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
[[package]]
name = "wl-clipboard-rs"
version = "0.9.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4d7888ccd4896447b2d14d3a9350a85df2aeb6f181e2e7a31349d104ac46cac1"
dependencies = [
"libc",
"log",
"os_pipe",
"rustix 1.1.5",
"thiserror 2.0.21",
"tree_magic_mini",
"wayland-backend",
"wayland-client",
"wayland-protocols",
"wayland-protocols-wlr",
]
[[package]]
name = "wordcraft"
version = "0.6.0"
dependencies = [
"arboard",
"clipboard-win",
"dispatch2",
"eframe",
"egui",
@@ -5016,6 +5098,7 @@ dependencies = [
"windows",
"winit",
"winresource",
"wl-clipboard-rs",
"wordcraft-control-key",
"wordcraft-doc",
"wordcraft-engine",
+1 -1
View File
@@ -73,7 +73,7 @@ egui_extras = { version = "0.36", default-features = false }
eframe = { version = "0.36", default-features = false }
egui_kittest = "0.36"
rfd = "0.17"
arboard = { version = "3.6", default-features = false }
arboard = { version = "3.6", default-features = false, features = ["wayland-data-control"] }
[workspace.lints.rust]
unsafe_code = "forbid"
+2 -1
View File
@@ -2,7 +2,7 @@
**Stage: alpha** · next: beta, ~15 points (60% → 75% ready for real work) and ~150–230 h away
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (Portuguese proofing; previously minor: Polish interface and Polish proofing; previously trivial: Turkish interface landed; previously trivial: formatting revisions landed, #41; previously trivial: Draw tab ink, #307, and Draw Table and Eraser, #303, landed: catalog 402/434; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-12 · **Change:** trivial (Turkish interface catalog completed, #774; previously trivial: Navigation heading truncation; previously trivial: Hungarian interface landed; previously trivial: formatted paste from other programs, Wayland clipboard pictures, #687, #833; previously trivial: independent complex-script formatting preserved across DOCX open/save; previously trivial: Turkish interface landed; previously trivial: formatting revisions landed, #41; previously trivial: Draw tab ink, #307, and Draw Table and Eraser, #303, landed: catalog 402/434; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4
WordCraft aims for complete parity with Microsoft Word, then goes further on speed, openness and
agent control. This page is the summary; the assessment is
@@ -132,6 +132,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
## Progress log
| Date | What landed |
| 2026-10-12 | Paste from other programs keeps their formatting (#687): Ctrl+V and Home › Paste read the system clipboard's HTML (and RTF on Windows and Wayland); Merge Formatting takes the destination's font and paragraph and keeps bold, italic, underline and strikethrough; Paste Special lists what the system clipboard holds (HTML, RTF, picture, text). Linux: pictures copied in a browser or Spectacle paste on KDE and other Wayland desktops (data-control clipboard), and a paste that finds nothing usable names what the clipboard holds instead of "empty" (#833) |
| 2026-10-11 | Hide White Space (#645): `view.hideWhiteSpace` (View › Show checkbox, or double-click the gap between pages) collapses margins, headers and footers between pages to a thin line in Print Layout; screen only, page count unchanged |
| 2026-10-11 | Fonts: Palatino Linotype and Book Antiqua fall back to Palatino (macOS) or TeX Gyre Pagella / P052 / URW Palladio (Linux) before generic serif (#670) |
|---|---|
+7
View File
@@ -48,6 +48,10 @@ winit = { version = "0.30", default-features = false, features = ["x11"] }
# The desktop's animation setting on Linux and the BSDs, read from the XDG settings portal over
# D-Bus. Already in the build through AccessKit, at this version; this adds only its blocking API.
zbus = { version = "5", default-features = false, features = ["async-io", "blocking-api"] }
# Wayland's clipboard through the data-control protocol (#687, #833): the RTF and other picture
# types that arboard doesn't read, and the formats offered. Already in the build through arboard's
# `wayland-data-control` feature, at this version.
wl-clipboard-rs = "0.9"
[target.'cfg(target_os = "windows")'.dependencies]
# The system's Animation effects setting (Settings › Accessibility › Visual effects), read through
@@ -55,6 +59,9 @@ zbus = { version = "5", default-features = false, features = ["async-io", "block
# workspace forbids. Already in the build through wgpu and AccessKit, at this version; this adds
# only the `UI_ViewManagement` bindings.
windows = { version = "0.62", features = ["UI_ViewManagement"] }
# The clipboard's "Rich Text Format" and the names of its formats (#687, #833), which arboard
# doesn't read. Already in the build through arboard, at this version.
clipboard-win = { version = "5.4", features = ["std"] }
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
rfd = { workspace = true }
+164
View File
@@ -0,0 +1,164 @@
//! The system clipboard beyond egui's text (#45, #584, #687, #833): text for the ribbon's Paste,
//! another program's formatted copy (HTML, and RTF where the platform offers it), pictures and
//! copied files, and the names of the formats there for a paste that finds nothing it can use.
//!
//! On Linux, arboard talks to Wayland compositors through the data-control protocol (KDE Plasma,
//! wlroots compositors); without it, a native Wayland window only sees what the compositor copies
//! over to Xwayland, which KWin does only while an X11 window has focus — so pictures copied in a
//! browser or Spectacle never arrived (#833). Compositors without the protocol fall back to X11.
//!
//! Everything read here comes from other programs: sizes are capped as it is read.
use wordcraft_engine::cmd::paste::MAX_PASTE_BYTES;
use wordcraft_ui_egui::paste_picture::ClipboardPicture;
fn open() -> Option<arboard::Clipboard> {
arboard::Clipboard::new().map_err(|e| log::warn!("clipboard: {e}")).ok()
}
/// The clipboard's text.
pub fn text() -> Option<String> {
open()?.get_text().ok()
}
/// The clipboard's HTML (Windows: the fragment of its "HTML Format"), if not too large.
pub fn html() -> Option<String> {
open()?.get().html().ok().filter(|h| h.len() <= MAX_PASTE_BYTES)
}
/// The clipboard's RTF: Windows ("Rich Text Format") and Wayland (`text/rtf`); not offered by
/// arboard, so read here.
pub fn rtf() -> Option<String> {
let bytes = platform::rtf()?;
if bytes.len() > MAX_PASTE_BYTES {
return None;
}
// RTF is 7-bit by the spec; stray 8-bit bytes become replacement characters.
let s = String::from_utf8_lossy(&bytes).trim_end_matches('\0').to_string();
s.trim_start().starts_with("{\\rtf").then_some(s)
}
/// The names of the formats on the clipboard, where the platform lists them (Wayland, Windows).
pub fn formats() -> Vec<String> {
platform::formats()
}
/// The picture on the clipboard: copied files, else image pixels, else (Wayland) an encoded
/// picture in a format arboard doesn't decode. A picture that is there but can't be read says
/// why (#833). The UI checks the sizes before inserting anything.
pub fn picture() -> Option<ClipboardPicture> {
let mut clipboard = match arboard::Clipboard::new() {
Ok(c) => c,
Err(e) => {
log::warn!("clipboard: {e}");
return Some(ClipboardPicture::Unreadable(format!("the system clipboard couldn't be opened ({e})")));
}
};
if let Ok(files) = clipboard.get().file_list()
&& !files.is_empty()
{
return Some(ClipboardPicture::Files(files));
}
match clipboard.get_image() {
Ok(image) => Some(ClipboardPicture::Pixels { width: image.width, height: image.height, rgba: image.bytes.into_owned() }),
Err(arboard::Error::ContentNotAvailable) => platform::encoded_picture().map(ClipboardPicture::Encoded),
Err(arboard::Error::ConversionFailure) => Some(
platform::encoded_picture()
.map(ClipboardPicture::Encoded)
.unwrap_or_else(|| ClipboardPicture::Unreadable("WordCraft can't decode its format".into())),
),
Err(e) => {
log::warn!("clipboard picture: {e}");
platform::encoded_picture().map(ClipboardPicture::Encoded).or(Some(ClipboardPicture::Unreadable(e.to_string())))
}
}
}
/// Picture types read straight from Wayland when arboard (PNG only) finds none, best first.
#[cfg(all(unix, not(any(target_os = "macos", target_os = "android", target_os = "emscripten"))))]
const PICTURE_MIMES: [&str; 6] = ["image/png", "image/jpeg", "image/gif", "image/webp", "image/bmp", "image/x-bmp"];
#[cfg(all(unix, not(any(target_os = "macos", target_os = "android", target_os = "emscripten"))))]
mod platform {
use std::io::Read;
use wl_clipboard_rs::paste::{ClipboardType, MimeType, Seat, get_contents, get_mime_types_ordered};
fn wayland() -> bool {
std::env::var_os("WAYLAND_DISPLAY").is_some()
}
/// One MIME type's bytes, at most `cap`; `None` when absent or larger.
fn read(mime: &str, cap: u64) -> Option<Vec<u8>> {
let (pipe, _) = get_contents(ClipboardType::Regular, Seat::Unspecified, MimeType::Specific(mime)).ok()?;
let mut out = Vec::new();
pipe.take(cap.saturating_add(1)).read_to_end(&mut out).ok()?;
(out.len() as u64 <= cap).then_some(out)
}
pub fn formats() -> Vec<String> {
if !wayland() {
return Vec::new();
}
get_mime_types_ordered(ClipboardType::Regular, Seat::Unspecified).unwrap_or_default()
}
pub fn rtf() -> Option<Vec<u8>> {
if !wayland() {
return None;
}
let offered = formats();
let mime = ["text/rtf", "application/rtf", "text/richtext"].into_iter().find(|m| offered.iter().any(|o| o == m))?;
read(mime, super::MAX_PASTE_BYTES as u64)
}
pub fn encoded_picture() -> Option<Vec<u8>> {
if !wayland() {
return None;
}
let offered = formats();
let mime = super::PICTURE_MIMES.into_iter().find(|m| offered.iter().any(|o| o == m))?;
read(mime, wordcraft_ui_egui::paste_picture::MAX_FILE_BYTES)
}
}
#[cfg(target_os = "windows")]
mod platform {
use clipboard_win::{Clipboard, EnumFormats, raw};
pub fn formats() -> Vec<String> {
let Ok(_open) = Clipboard::new_attempts(10) else { return Vec::new() };
// Standard formats are named too (CF_BITMAP, CF_UNICODETEXT…).
EnumFormats::new().take(64).filter_map(raw::format_name_big).collect()
}
pub fn rtf() -> Option<Vec<u8>> {
let format = raw::register_format("Rich Text Format")?;
let _open = Clipboard::new_attempts(10).ok()?;
if raw::size(format.get()).is_none_or(|n| n.get() > super::MAX_PASTE_BYTES) {
return None;
}
let mut out = Vec::new();
raw::get_vec(format.get(), &mut out).ok()?;
Some(out)
}
pub fn encoded_picture() -> Option<Vec<u8>> {
None
}
}
#[cfg(not(any(target_os = "windows", all(unix, not(any(target_os = "macos", target_os = "android", target_os = "emscripten"))))))]
mod platform {
pub fn formats() -> Vec<String> {
Vec::new()
}
pub fn rtf() -> Option<Vec<u8>> {
None
}
pub fn encoded_picture() -> Option<Vec<u8>> {
None
}
}
+9 -19
View File
@@ -12,6 +12,7 @@
mod animations;
#[cfg(target_os = "macos")]
mod apple_events;
mod clipboard;
mod control_server;
mod default_apps;
mod file_dialogs;
@@ -23,7 +24,6 @@ mod print;
use wordcraft_engine::Session;
use wordcraft_ui_egui::{
Services, UiState, WordApp,
paste_picture::ClipboardPicture,
window_geometry::{WindowGeometry, take_rescue},
};
@@ -192,34 +192,24 @@ fn save_prefs(app: &WordApp) {
}
/// Native file dialogs, shown without blocking the window (`file_dialogs`, #94), File › Print
/// through the system's PDF viewer (`print`, #15), pictures on the system clipboard (#45), the
/// system's animation setting (`animations`), and making WordCraft the default app for Word
/// documents (`default_apps`, #295).
/// through the system's PDF viewer (`print`, #15), the system clipboard's text, formatted copies,
/// pictures and format names (`clipboard`, #45, #584, #687, #833), the system's animation setting
/// (`animations`), and making WordCraft the default app for Word documents (`default_apps`, #295).
fn services(file_dialog: file_dialogs::Hook, ctx: &egui::Context) -> Services {
Services {
file_dialog: Some(file_dialog),
print: Some(print::hook()),
clipboard_picture: Some(Box::new(clipboard_picture)),
clipboard_picture: Some(Box::new(clipboard::picture)),
make_default_app: default_apps::hook(),
animations_enabled: Some(animations::hook(ctx)),
clipboard_text: Some(Box::new(|| arboard::Clipboard::new().ok()?.get_text().ok())),
clipboard_text: Some(Box::new(clipboard::text)),
clipboard_html: Some(Box::new(clipboard::html)),
clipboard_rtf: Some(Box::new(clipboard::rtf)),
clipboard_formats: Some(Box::new(clipboard::formats)),
..Default::default()
}
}
/// The picture on the system clipboard (#45): copied files, else image pixels. The UI checks the
/// sizes before inserting anything.
fn clipboard_picture() -> Option<ClipboardPicture> {
let mut clipboard = arboard::Clipboard::new().map_err(|e| log::warn!("clipboard: {e}")).ok()?;
if let Ok(files) = clipboard.get().file_list()
&& !files.is_empty()
{
return Some(ClipboardPicture::Files(files));
}
let image = clipboard.get_image().ok()?;
Some(ClipboardPicture::Pixels { width: image.width, height: image.height, rgba: image.bytes.into_owned() })
}
/// Window, Dock and taskbar icon.
fn app_icon() -> Option<egui::IconData> {
#[cfg(target_os = "macos")]
+17 -9
View File
@@ -33,11 +33,12 @@ pub fn specs() -> Vec<CommandSpec> {
.pure(),
CommandSpec::new("edit.cut", "Cut", "Home › Clipboard", cut).key("Mod+X").when(has_sel),
CommandSpec::new("edit.copy", "Copy", "Home › Clipboard", copy).key("Mod+C").pure(),
CommandSpec::new("edit.paste", "Paste", "Home › Clipboard", paste).key("Mod+V").params(r#"{"text"?: string}"#),
CommandSpec::new("edit.paste", "Paste", "Home › Clipboard", paste).key("Mod+V").params(r#"{"text"?: string, "html"?: string, "rtf"?: string}"#),
CommandSpec::new("edit.pasteText", "Paste: Keep Text Only", "Home › Clipboard › Paste", paste_text)
.params(r#"{"text"?: string}"#)
.key("Mod+Shift+Alt+V"),
CommandSpec::new("edit.pasteMerge", "Paste: Merge Formatting", "Home › Clipboard › Paste", paste_text).params(r#"{"text"?: string}"#),
CommandSpec::new("edit.pasteMerge", "Paste: Merge Formatting", "Home › Clipboard › Paste", paste_merge)
.params(r#"{"text"?: string, "html"?: string, "rtf"?: string}"#),
CommandSpec::new("edit.clipboardPane", "Clipboard Pane", "Home › Clipboard", |s, v| {
let on = p::bool(v, "value").unwrap_or(!s.view.clipboard_pane);
s.view.clipboard_pane = on;
@@ -133,14 +134,18 @@ fn cut(s: &mut Session, v: &Value) -> CmdResult {
Ok(r)
}
/// Paste keeps the source formatting: our own copy while the system clipboard still holds it,
/// else another program's HTML or RTF (#687), else the system clipboard's text.
fn paste(s: &mut Session, v: &Value) -> CmdResult {
// An explicit text param (from the system clipboard) wins unless it matches our own copy.
let frag = match (p::str(v, "text"), &s.clipboard) {
(Some(t), Some(f)) if f.plain_text() == t.replace("\r\n", "\n") => f.clone(),
(Some(t), _) => Fragment::from_text(&t.replace("\r\n", "\n")),
(None, Some(f)) => f.clone(),
(None, None) => return Err(CmdError::Failed("the clipboard is empty".into())),
};
let frag = super::paste::paste_fragment(s, v)?;
super::paste::insert(s, frag)?;
sel_result(s)
}
/// Merge Formatting: the pasted text takes the destination's formatting, keeping its emphasis.
fn paste_merge(s: &mut Session, v: &Value) -> CmdResult {
let mut frag = super::paste::paste_fragment(s, v)?;
super::paste::merge_formatting(s, &mut frag);
super::paste::insert(s, frag)?;
sel_result(s)
}
@@ -284,6 +289,9 @@ fn paste_text(s: &mut Session, v: &Value) -> CmdResult {
Some(t) => t.to_string(),
None => s.clipboard.as_ref().map(Fragment::plain_text).unwrap_or_default(),
};
if text.is_empty() {
return Err(CmdError::Failed("the clipboard is empty".into()));
}
super::paste_text(s, &text.replace("\r\n", "\r").replace('\n', "\r"))?;
sel_result(s)
}
+1 -1
View File
@@ -183,7 +183,7 @@ pub fn base64_encode(b: &[u8]) -> String {
out
}
fn picture(s: &mut Session, v: &Value) -> CmdResult {
pub(crate) fn picture(s: &mut Session, v: &Value) -> CmdResult {
let bytes = if let Some(d) = p::str(v, "data") {
base64_decode(d).ok_or_else(|| CmdError::Params("bad base64 data".into()))?
} else if let Some(path) = p::str(v, "path") {
+124 -9
View File
@@ -1,9 +1,11 @@
//! Paste Special: paste the clipboard in a chosen format (formatted, RTF, HTML, unformatted).
//! Paste Special: paste the clipboard in a chosen format (formatted, RTF, HTML, picture,
//! unformatted), and the formatted content Paste and Merge Formatting take from other programs.
//!
//! 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.
//! with a richer clipboard) may pass `html`, `rtf` or a `picture` too. Text that is itself RTF or
//! HTML source can also be pasted as that format. Paste keeps the source formatting of another
//! program's HTML or RTF; Merge Formatting keeps only its emphasis (#687).
use std::collections::BTreeMap;
use std::sync::Arc;
@@ -17,18 +19,25 @@ use crate::{CmdError, CmdResult, CommandSpec, Selection, Session, p};
/// Largest clipboard payload Paste Special accepts, in bytes.
pub const MAX_PASTE_BYTES: usize = 16 << 20;
/// Largest clipboard picture Paste Special accepts (base64), in bytes.
pub const MAX_PICTURE_BYTES: usize = 96 << 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 const FORMATS: [(&str, &str); 5] = [
("formatted", "Formatted Text"),
("rtf", "Rich Text Format (RTF)"),
("html", "HTML Format"),
("picture", "Picture"),
("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}"#),
.params(r#"{"as"?: "formatted"|"rtf"|"html"|"picture"|"text", "text"?: string, "html"?: string, "rtf"?: string, "picture"?: base64}"#),
]
}
@@ -63,6 +72,7 @@ fn source(s: &Session, v: &Value, fmt: &str) -> Option<String> {
}
"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),
"picture" => p::str(v, "picture").map(str::to_string).and_then(non_empty),
"text" => clip_text(s, v).and_then(non_empty),
_ => None,
}
@@ -83,11 +93,14 @@ fn paste_special(s: &mut Session, v: &Value) -> CmdResult {
return Err(CmdError::Params(format!("`{k}` is larger than {} MB", MAX_PASTE_BYTES >> 20)));
}
}
if p::str(v, "picture").is_some_and(|t| t.len() > MAX_PICTURE_BYTES) {
return Err(CmdError::Params(format!("`picture` is larger than {} MB", MAX_PICTURE_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"] {
for k in ["text", "html", "rtf", "picture"] {
if let Some(t) = p::str(v, k) {
req[k] = json!(t);
}
@@ -99,8 +112,9 @@ fn paste_special(s: &mut Session, v: &Value) -> CmdResult {
"formatted" | "keep" | "wordcraft" => "formatted",
"rtf" => "rtf",
"html" => "html",
"picture" | "png" | "image" => "picture",
"text" | "unformatted" | "plain" => "text",
other => return Err(CmdError::Params(format!("unknown format `{other}` (formatted, rtf, html or text)"))),
other => return Err(CmdError::Params(format!("unknown format `{other}` (formatted, rtf, html, picture or text)"))),
};
let Some(src) = source(s, v, fmt) else {
return Err(CmdError::Failed(if ids.is_empty() {
@@ -116,8 +130,13 @@ fn paste_special(s: &mut Session, v: &Value) -> CmdResult {
r["pastedAs"] = json!(fmt);
return Ok(r);
}
"picture" => {
let mut r = super::insert::picture(s, &json!({"data": src}))?;
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)),
"html" => adopt(s, wordcraft_formats::html::import(html_fragment(&src))),
_ => adopt(s, wordcraft_formats::rtf::import(src.as_bytes()).map_err(|e| CmdError::Failed(format!("bad RTF: {e}")))?),
};
insert(s, frag)?;
@@ -126,6 +145,102 @@ fn paste_special(s: &mut Session, v: &Value) -> CmdResult {
Ok(r)
}
/// The HTML itself from a Windows "HTML Format" (CF_HTML) clipboard payload — a `Version:` header
/// giving byte offsets — or the text unchanged. Hostile offsets fall back to the text after the
/// header.
pub fn html_fragment(html: &str) -> &str {
if !html.starts_with("Version:") {
return html;
}
let offset = |key: &str| -> Option<usize> {
let at = html.find(key)?.checked_add(key.len())?;
let digits: &str = html.get(at..)?.split(['\r', '\n']).next()?;
digits.trim().parse().ok()
};
if let (Some(a), Some(b)) = (offset("StartFragment:"), offset("EndFragment:"))
&& let Some(frag) = html.get(a..b)
{
return frag;
}
html.find('<').and_then(|i| html.get(i..)).unwrap_or("")
}
/// Whether an imported document has anything to paste: text, a table, or a picture it carries.
fn has_content(d: &Document) -> bool {
d.body.iter().any(|b| match &**b {
Block::Para(p) => !p.text.trim().is_empty() || (!p.objects.is_empty() && !d.media.is_empty()),
Block::Table(_) => true,
})
}
/// Another program's formatted copy, from the system clipboard's HTML, else its RTF (#687), as a
/// fragment of this document. `None` when neither is given, either is larger than
/// [`MAX_PASTE_BYTES`], or it holds nothing to paste (the caller pastes the text then).
pub fn rich_fragment(s: &mut Session, v: &Value) -> Option<Fragment> {
let fits = |k: &str| p::str(v, k).filter(|t| !t.trim().is_empty() && t.len() <= MAX_PASTE_BYTES);
let doc = fits("html").map(|html| wordcraft_formats::html::import(html_fragment(html)));
let doc = match doc.filter(has_content) {
Some(d) => d,
None => {
let rtf = fits("rtf").filter(|t| looks_like_rtf(t))?;
wordcraft_formats::rtf::import(rtf.as_bytes()).ok().filter(has_content)?
}
};
Some(adopt(s, doc))
}
/// What Paste inserts: WordCraft's own copy while the system clipboard still holds it, else
/// another program's formatted copy (HTML, RTF), else the system clipboard's text.
pub fn paste_fragment(s: &mut Session, v: &Value) -> Result<Fragment, CmdError> {
let text = p::str(v, "text").map(|t| t.replace("\r\n", "\n"));
if let Some(f) = &s.clipboard
&& text.as_ref().is_none_or(|t| f.plain_text() == *t)
&& (text.is_some() || (p::str(v, "html").is_none() && p::str(v, "rtf").is_none()))
{
return Ok(f.clone());
}
if let Some(f) = rich_fragment(s, v) {
return Ok(f);
}
match text {
Some(t) if !t.is_empty() => Ok(Fragment::from_text(&t)),
_ => Err(CmdError::Failed("the clipboard is empty".into())),
}
}
/// Merge Formatting (#687): the pasted text takes the formatting where it lands — the paragraph's
/// and the caret's — keeping only its emphasis (bold, italic, underline, strikethrough).
pub fn merge_formatting(s: &Session, frag: &mut Fragment) {
let mut base = s.typing_props();
base.ins = None;
base.del = None;
base.fmt_change = None;
let (start, _) = s.sel.ordered();
let para = s.doc.para_at(&start).map(|p| p.props.clone()).unwrap_or_default();
for b in &mut frag.blocks {
each_para(b, 0, &mut |p| {
p.props = para.clone();
for run in &mut p.runs {
let src = std::mem::replace(&mut run.props, base.clone());
if src.bold == Some(true) {
run.props.bold = Some(true);
}
if src.italic == Some(true) {
run.props.italic = Some(true);
}
if src.underline.is_some_and(|u| u != wordcraft_doc::props::Underline::None) {
run.props.underline = src.underline;
}
if src.strike == Some(true) {
run.props.strike = Some(true);
}
run.props.link = src.link;
}
p.touch();
});
}
}
/// Replace the selection with `frag`, marking it as an insertion under Track Changes.
pub fn insert(s: &mut Session, mut frag: Fragment) -> Result<(), CmdError> {
super::controls::prepare_edit(s)?;
+86
View File
@@ -829,6 +829,92 @@ fn paste_special_respects_track_changes_and_brings_lists() {
assert!(text(&s).starts_with("one\ntwo"));
}
/// Character formatting at a body offset of the first paragraph.
fn props_at(s: &Session, off: usize) -> wordcraft_doc::CharProps {
let pos = Pos { story: StoryRef::Body, path: wordcraft_doc::Path::top(0), off };
s.doc.para_at(&pos).map(|p| p.props_of_char(off).clone()).unwrap_or_default()
}
/// Paste of another program's formatted copy keeps its formatting (#687); Merge Formatting keeps
/// only its emphasis, in the destination's font; Keep Text Only drops it all.
#[test]
fn paste_keeps_source_formatting_from_other_programs() {
let mut s = s();
run(&mut s, "document.setText", json!({"text": "x"}));
run(&mut s, "caret.docEnd", json!({}));
let html = "<p><span style=\"font-family:'Courier New';color:#ff0000\">red <b>bold</b></span></p>";
let clip = json!({"text": "red bold", "html": html});
run(&mut s, "edit.paste", clip.clone());
assert_eq!(text(&s), "xred bold");
assert_eq!(props_at(&s, 1).font.as_deref(), Some("Courier New"));
assert!(props_at(&s, 1).color.is_some());
assert!(!bold_at(&s, 0, 1) && bold_at(&s, 0, 5));
run(&mut s, "edit.undo", json!({}));
assert_eq!(text(&s), "x");
run(&mut s, "edit.pasteMerge", clip.clone());
assert_eq!(text(&s), "xred bold");
assert_eq!(props_at(&s, 1).font, None, "Merge Formatting takes the destination's font");
assert_eq!(props_at(&s, 1).color, None);
assert!(bold_at(&s, 0, 5), "and keeps the emphasis");
run(&mut s, "edit.undo", json!({}));
run(&mut s, "edit.pasteText", clip);
assert_eq!(text(&s), "xred bold");
assert!(!bold_at(&s, 0, 5));
// Windows' "HTML Format" carries a header with the fragment's byte offsets.
run(&mut s, "document.setText", json!({"text": ""}));
let body = "<html><body><!--StartFragment--><b>win</b><!--EndFragment--></body></html>";
let head = "Version:0.9\r\nStartHTML:0000000000\r\nEndHTML:0000000000\r\nStartFragment:SSSSSSSSSS\r\nEndFragment:EEEEEEEEEE\r\n";
let start = head.len() + body.find("<b>").unwrap();
let end = head.len() + body.find("<!--EndFragment").unwrap();
let cf = format!("{head}{body}").replace("SSSSSSSSSS", &format!("{start:010}")).replace("EEEEEEEEEE", &format!("{end:010}"));
run(&mut s, "edit.paste", json!({"text": "win", "html": cf}));
assert_eq!(text(&s), "win");
assert!(bold_at(&s, 0, 0));
assert_eq!(crate::cmd::paste::html_fragment("Version:1\r\nStartFragment:99999\r\nEndFragment:5\r\n<i>x</i>"), "<i>x</i>");
// RTF when there is no HTML.
run(&mut s, "document.setText", json!({"text": ""}));
run(&mut s, "edit.paste", json!({"text": "bold then", "rtf": r"{\rtf1\ansi {\b bold}\b0 then}"}));
assert!(text(&s).starts_with("bold"), "{}", text(&s));
assert!(bold_at(&s, 0, 0));
// HTML with nothing to paste (a web picture by address) or too large: the text is pasted.
run(&mut s, "document.setText", json!({"text": ""}));
run(&mut s, "edit.paste", json!({"text": "alt", "html": "<img src='https://example.com/a.png'>"}));
assert_eq!(text(&s), "alt");
let huge = format!("<b>{}</b>", "a".repeat(crate::cmd::paste::MAX_PASTE_BYTES));
run(&mut s, "edit.paste", json!({"text": "!", "html": huge}));
assert_eq!(text(&s), "alt!");
assert!(s.run("edit.paste", &json!({"html": "<p> </p>"})).is_err(), "nothing to paste is an error");
assert!(s.run("edit.pasteMerge", &json!({"rtf": "not rtf"})).is_err());
assert!(s.run("edit.pasteText", &json!({})).is_err());
// Our own copy still wins while the system clipboard holds its text.
run(&mut s, "document.setText", json!({"text": "Own"}));
run(&mut s, "select.all", json!({}));
run(&mut s, "format.italic", json!({}));
run(&mut s, "edit.copy", json!({}));
run(&mut s, "caret.docEnd", json!({}));
run(&mut s, "edit.paste", json!({"text": "Own"}));
assert_eq!(text(&s), "OwnOwn");
assert_eq!(props_at(&s, 4).italic, Some(true));
}
/// Paste Special lists a picture on the system clipboard and pastes it (#687).
#[test]
fn paste_special_lists_and_pastes_a_picture() {
let mut s = s();
let png = crate::cmd::insert::base64_encode(&tiny_png());
let r = run(&mut s, "edit.pasteSpecial", json!({"picture": png, "html": "<img src='x.png'>"}));
let ids: Vec<&str> = r["formats"].as_array().unwrap().iter().map(|f| f["id"].as_str().unwrap()).collect();
assert_eq!(ids, ["html", "picture"]);
run(&mut s, "edit.pasteSpecial", json!({"as": "picture", "picture": png}));
assert_eq!(s.doc.media.len(), 1);
assert!(s.run("edit.pasteSpecial", &json!({"as": "picture", "picture": "%%%"})).is_err());
assert!(s.run("edit.pasteSpecial", &json!({"as": "picture"})).is_err());
}
#[test]
fn clipboard_pane_collects_copies_and_pastes_one_undoably() {
let mut s = s();
+16 -4
View File
@@ -317,6 +317,9 @@ pub enum Dialog {
choice: usize,
#[serde(skip)]
payload: Value,
/// Shown when nothing can be pasted: what the clipboard holds, or that it is empty (#833).
#[serde(skip)]
note: String,
},
/// Tabs: 0 About, 1 Contributors, 2 Models.
About {
@@ -1164,12 +1167,13 @@ impl Dialog {
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"] {
for k in ["text", "html", "rtf", "picture"] {
if let Some(t) = payload.get(k).and_then(Value::as_str) {
kept[k] = json!(t);
}
}
Dialog::PasteSpecial { formats, choice: 0, payload: kept }
let note = if formats.is_empty() { crate::system_clipboard::nothing_to_paste(app) } else { String::new() };
Dialog::PasteSpecial { formats, choice: 0, payload: kept, note }
}
pub fn modify_style(app: &WordApp, id: &str) -> Option<Dialog> {
@@ -2249,10 +2253,17 @@ fn body(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bool {
close || ui.input(|i| i.key_pressed(egui::Key::Escape))
}
Dialog::Icons { query, category, selected } => icons_body(app, ui, query, category, selected),
Dialog::PasteSpecial { formats, choice, payload } => {
Dialog::PasteSpecial { formats, choice, payload, note } => {
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());
// The engine's wording, sentence-cased; the format names in it aren't translated.
let text = if note.is_empty() || note == "the clipboard is empty" {
tl!("The clipboard is empty.").to_string()
} else {
let mut c = note.chars();
c.next().map(|f| f.to_uppercase().chain(c).collect::<String>() + ".").unwrap_or_default()
};
ui.label(egui::RichText::new(text).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());
@@ -2265,6 +2276,7 @@ fn body(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bool {
"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.",
"picture" => "Pastes the clipboard's picture.",
_ => "Drops all formatting and pastes plain text.",
};
ui.label(egui::RichText::new(tl!(hint)).weak());
+2
View File
@@ -2437,3 +2437,5 @@
WordCraft is now the default app for Word documents. WordCraft вече е приложението по подразбиране за документи на Word.
Correct TWo INitial CApitals Коригиране на ДВе НАчални ГЛавни букви
Correct accidental usage of cAPS LOCK key Коригиране на случайно използване на клавиша cAPS LOCK
Picture Картина
Pastes the clipboard's picture. Поставя картината от клипборда.
Can't render this file because it contains an unexpected character in line 7 and column 25.
+2
View File
@@ -867,6 +867,8 @@
Reading Speed Rychlost čtení
Reads the clipboard's rich text and keeps its formatting. Načte formátovaný text (RTF) ze schránky a zachová jeho formátování.
Reads the clipboard's web content and keeps its formatting. Načte webový obsah ze schránky a zachová jeho formátování.
Picture Obrázek
Pastes the clipboard's picture. Vloží obrázek ze schránky.
Recent Nedávné
Recently used symbols: Naposledy použité symboly:
Recipient List Seznam příjemců
Can't render this file because it contains an unexpected character in line 6 and column 25.
+2
View File
@@ -1003,6 +1003,8 @@
Keeps the fonts and formatting of the copied text. Behält die Schriftarten und Formatierung des kopierten Textes bei.
Reads the clipboard's rich text and keeps its formatting. Liest den Rich Text der Zwischenablage und behält dessen Formatierung bei.
Reads the clipboard's web content and keeps its formatting. Liest den Webinhalt der Zwischenablage und behält dessen Formatierung bei.
Picture Bild
Pastes the clipboard's picture. Fügt das Bild aus der Zwischenablage ein.
Drops all formatting and pastes plain text. Verwirft jegliche Formatierung und fügt reinen Text ein.
Advanced Find Erweiterte Suche
Advanced Find… Erweiterte Suche…
Can't render this file because it contains an unexpected character in line 6 and column 25.
+2
View File
@@ -712,6 +712,8 @@
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.
Picture Imagen
Pastes the clipboard's picture. Pega la imagen del portapapeles.
Reading Highlight Resaltado de lectura
Recent Recientes
Record Macro Grabar macro
Can't render this file because it contains an unexpected character in line 6 and column 25.
+2
View File
@@ -595,6 +595,8 @@
Read\nMode Lugemis-\nrežiim
Reads the clipboard's rich text and keeps its formatting. Loeb lõikelaualt vormindatud teksti ja säilitab vorminduse.
Reads the clipboard's web content and keeps its formatting. Loeb lõikelaualt veebisisu ja säilitab vorminduse.
Picture Pilt
Pastes the clipboard's picture. Kleebib lõikelaual oleva pildi.
Reading Highlight Otsingutulemuste esiletõst
Recent Hiljutised
Recent documents to show: Kuvatavad hiljutised dokumendid:
Can't render this file because it contains an unexpected character in line 458 and column 13.
+2
View File
@@ -2063,3 +2063,5 @@
Reading Text Size Taille du texte de lecture
Correct TWo INitial CApitals Corriger DEux MAjuscules EN début de mot
Correct accidental usage of cAPS LOCK key Corriger l’utilisation accidentelle de la touche vERR. mAJ
Picture Image
Pastes the clipboard's picture. Colle l'image du Presse-papiers.
Can't render this file because it contains an unexpected character in line 7 and column 25.
+2
View File
@@ -2453,3 +2453,5 @@
Reading Text Size Olvasási szövegméret
Correct TWo INitial CApitals KÉt KEzdő NAgybetű javítása
Correct accidental usage of cAPS LOCK key A cAPS LOCK billentyű véletlen használatának javítása
Picture Kép
Pastes the clipboard's picture. Beilleszti a vágólapon lévő képet.
Can't render this file because it contains an unexpected character in line 6 and column 25.
+2
View File
@@ -628,6 +628,8 @@
Read\nMode Modalità\nlettura
Reads the clipboard's rich text and keeps its formatting. Legge il testo RTF degli appunti e ne mantiene la formattazione.
Reads the clipboard's web content and keeps its formatting. Legge il contenuto web degli appunti e ne mantiene la formattazione.
Picture Immagine
Pastes the clipboard's picture. Incolla l'immagine degli appunti.
Reading Highlight Evidenziazione lettura
Recent Recenti
Record Macro Registra macro
Can't render this file because it contains an unexpected character in line 6 and column 25.
+2
View File
@@ -1073,6 +1073,8 @@
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 コンテンツを読み込み、書式を保持します。
Picture 画像
Pastes the clipboard's picture. クリップボードの画像を貼り付けます。
Drops all formatting and pastes plain text. 書式をすべて取り除き、プレーン テキストとして貼り付けます。
Advanced Find 高度な検索
Advanced Find… 高度な検索…
Can't render this file because it contains an unexpected character in line 560 and column 13.
+2
View File
@@ -551,6 +551,8 @@
Read\nMode Tryb\nczytania
Reads the clipboard's rich text and keeps its formatting. Odczytuje sformatowany tekst ze schowka i zachowuje jego formatowanie.
Reads the clipboard's web content and keeps its formatting. Odczytuje zawartość internetową ze schowka i zachowuje jej formatowanie.
Picture Obraz
Pastes the clipboard's picture. Wkleja obraz ze schowka.
Reading Highlight Wyróżnienie wyników
Recent Ostatnie
Record Macro Zarejestruj makro
Can't render this file because it contains an unexpected character in line 6 and column 25.
+2
View File
@@ -1085,6 +1085,8 @@
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.
Picture Imagem
Pastes the clipboard's picture. Cola a imagem da área de transferência.
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…
Can't render this file because it contains an unexpected character in line 6 and column 25.
+2
View File
@@ -965,6 +965,8 @@ preview Title Пример
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. Берёт веб-содержимое из буфера обмена и сохраняет его форматирование.
Picture Рисунок
Pastes the clipboard's picture. Вставляет рисунок из буфера обмена.
Drops all formatting and pastes plain text. Убирает всё форматирование и вставляет обычный текст.
Advanced Find Расширенный поиск
Advanced Find… Расширенный поиск…
Can't render this file because it contains an unexpected character in line 7 and column 25.
+2
View File
@@ -1139,6 +1139,8 @@
Keeps the fonts and formatting of the copied text. Zadržava fontove i formatiranje kopiranog teksta.
Reads the clipboard's rich text and keeps its formatting. Čita obogaćeni tekst (RTF) iz ostave i zadržava njegovo formatiranje.
Reads the clipboard's web content and keeps its formatting. Čita veb sadržaj iz ostave i zadržava njegovo formatiranje.
Picture Slika
Pastes the clipboard's picture. Lepi sliku iz ostave.
Drops all formatting and pastes plain text. Uklanja sve formatiranje i lepi običan tekst.
Advanced Find Napredna pretraga
Advanced Find… Napredna pretraga…
Can't render this file because it contains an unexpected character in line 6 and column 25.
+2
View File
@@ -1143,6 +1143,8 @@
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. Чита веб садржај из оставе и задржава његово форматирање.
Picture Слика
Pastes the clipboard's picture. Лепи слику из оставе.
Drops all formatting and pastes plain text. Уклања све форматирање и лепи обичан текст.
Advanced Find Напредна претрага
Advanced Find… Напредна претрага…
Can't render this file because it contains an unexpected character in line 11 and column 25.
+5 -3
View File
@@ -557,6 +557,11 @@
Read Mode Okuma Modu
Read\nAloud Sesli\nOku
Read\nMode Okuma\nModu
Reads the clipboard's rich text and keeps its formatting. Panodaki zengin metni okur ve biçimlendirmesini korur.
Reads the clipboard's web content and keeps its formatting. Panodaki web içeriğini okur ve biçimlendirmesini korur.
Picture Resim
Pastes the clipboard's picture. Panodaki resmi yapıştırır.
Reading Highlight Okuma Vurgusu
Recent Son Kullanılanlar
Record Macro Makro Kaydet
Recover Unsaved Documents Kaydedilmemiş Belgeleri Kurtar
@@ -963,13 +968,10 @@ preview Title Örnek
HTML Format HTML Biçimi
Unformatted Text Biçimlendirilmemiş Metin
Result Sonuç
Reads the clipboard's rich text and keeps its formatting. Panodaki zengin metni okur ve biçimlendirmesini korur.
Reads the clipboard's web content and keeps its formatting. Panodaki web içeriğini okur ve biçimlendirmesini korur.
Drops all formatting and pastes plain text. Tüm biçimlendirmeyi kaldırır ve düz metin yapıştırır.
Advanced Find Gelişmiş Bul
Advanced Find… Gelişmiş Bul…
Go To… Git…
Reading Highlight Okuma Vurgusu
New Table Style Yeni Tablo Stili
Modify Table Style Tablo Stilini Değiştir
New Table Style… Yeni Tablo Stili…
Can't render this file because it contains an unexpected character in line 438 and column 13.
+2
View File
@@ -705,6 +705,8 @@
Read\nMode Режим\nчитання
Reads the clipboard's rich text and keeps its formatting. Читає форматований текст (RTF) із буфера обміну та зберігає його форматування.
Reads the clipboard's web content and keeps its formatting. Читає вебвміст із буфера обміну та зберігає його форматування.
Picture Зображення
Pastes the clipboard's picture. Вставляє зображення з буфера обміну.
Reading Highlight Виділення під час читання
Recent Останні
Record Macro Записати макрос
Can't render this file because it contains an unexpected character in line 6 and column 31.
+2
View File
@@ -1085,6 +1085,8 @@
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. 读取剪贴板中的网页内容并保留其格式。
Picture 图片
Pastes the clipboard's picture. 粘贴剪贴板中的图片。
Drops all formatting and pastes plain text. 去除所有格式,粘贴纯文本。
Advanced Find 高级查找
Advanced Find… 高级查找…
Can't render this file because it contains an unexpected character in line 7 and column 25.
+2
View File
@@ -1074,6 +1074,8 @@
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. 讀取剪貼簿中的網頁內容並保留其格式。
Picture 圖片
Pastes the clipboard's picture. 貼上剪貼簿中的圖片。
Drops all formatting and pastes plain text. 移除所有格式,貼上純文字。
Advanced Find 進階尋找
Advanced Find… 進階尋找…
Can't render this file because it contains an unexpected character in line 561 and column 13.
+12
View File
@@ -170,6 +170,11 @@ fn is_paste_release(key: Key, modifiers: egui::Modifiers) -> bool {
(modifiers.command || key == Key::Paste) && !modifiers.alt && !modifiers.shift
}
/// Whether releasing `key` with `modifiers` is Mod+Alt+V (Paste Special).
fn is_paste_special_release(key: Key, modifiers: egui::Modifiers) -> bool {
key == Key::V && modifiers.command && modifiers.alt && !modifiers.shift
}
/// Events for the focused canvas: text, editing keys, clipboard, IME. While keytips are showing
/// every key belongs to them (letters pick badges, Escape cancels), so nothing here runs.
pub fn canvas_events(app: &mut WordApp, ctx: &egui::Context) {
@@ -226,6 +231,11 @@ pub fn canvas_events(app: &mut WordApp, ctx: &egui::Context) {
if !pasted && paste_keys && app.session.math.is_none() && app.services.clipboard_picture.is_some() {
let _ = app.run("edit.paste", json!({}));
}
// Mod+Alt+V with no text on the clipboard: Paste Special still opens, listing what
// is there (a picture) or saying what it holds (#687, #833).
if !pasted && is_paste_special_release(*key, *modifiers) && app.session.math.is_none() && app.services.clipboard_picture.is_some() {
let _ = app.run("edit.pasteSpecial", json!({}));
}
continue;
}
// Editing an equation: text and editing keys go into it.
@@ -375,5 +385,7 @@ mod tests {
assert!(!is_paste_release(Key::V, m(true, true, false)), "Mod+Shift+V is Paste Formatting");
assert!(!is_paste_release(Key::V, m(true, false, true)), "Mod+Alt+V is Paste Special");
assert!(!is_paste_release(Key::V, m(false, false, false)), "a plain V");
assert!(is_paste_special_release(Key::V, m(true, false, true)), "Mod+Alt+V opens Paste Special");
assert!(!is_paste_special_release(Key::V, m(true, true, true)) && !is_paste_special_release(Key::V, m(true, false, false)));
}
}
+15 -16
View File
@@ -66,6 +66,7 @@ pub mod print_page;
pub mod read_aloud;
pub mod ribbon;
pub mod scroll;
pub mod system_clipboard;
pub mod table_pen;
pub mod table_resize;
pub mod theme;
@@ -120,6 +121,15 @@ pub struct Services {
/// Desktop: the text on the system clipboard, if any. Ctrl+V gets it from egui's paste event;
/// the ribbon and context-menu Paste read it here (#584).
pub clipboard_text: Option<Box<dyn Fn() -> Option<String>>>,
/// Desktop: the HTML on the system clipboard — another program's formatted copy (a Word
/// document, a web page) — if any. Paste keeps its formatting (#687); see [`system_clipboard`].
pub clipboard_html: Option<Box<dyn Fn() -> Option<String>>>,
/// Desktop: the RTF on the system clipboard, where the platform offers it (#687).
pub clipboard_rtf: Option<Box<dyn Fn() -> Option<String>>>,
/// Desktop: the names of the formats on the system clipboard (MIME types, Windows format
/// names), where the platform lists them, so a paste that finds nothing it can use says what
/// is there instead of "empty" (#833).
pub clipboard_formats: Option<Box<dyn Fn() -> Vec<String>>>,
/// Web: keep a font the user added (stored file name, original bytes) for the next visit
/// (#582). Desktop fonts are stored by the font database itself, in the app's font folder.
pub store_font: Option<Box<dyn Fn(&str, &[u8])>>,
@@ -549,23 +559,11 @@ impl WordApp {
return Ok(json!({"pending": "saveChanges"}));
}
// The ribbon and context-menu Paste commands send no text: read the system clipboard's, as
// Ctrl+V's paste event carries it (#584).
// Ctrl+V's paste event carries it (#584), and its HTML and RTF, which egui's paste event
// doesn't carry (#687).
let mut params = params;
if matches!(id, "edit.paste" | "edit.pasteText" | "edit.pasteMerge" | "edit.pasteSpecial")
&& params.get("text").is_none()
&& let Some(text) = self.services.clipboard_text.as_ref().and_then(|f| f()).filter(|t| !t.is_empty())
&& let Some(o) = params.as_object_mut()
{
// Picture files copied in a file manager reach us as their paths: paste the pictures,
// as Ctrl+V does.
if id == "edit.paste"
&& self.session.math.is_none()
&& paste_picture::names_picture_files(&text)
&& let Some(r) = paste_picture::paste(self)
{
return r;
}
o.insert("text".into(), Value::String(text));
if let Some(r) = system_clipboard::fill(self, id, &mut params) {
return r;
}
// Paste without system clipboard text: a picture there is newer than our own copy (copying
// here replaces it with text), so it wins (#45).
@@ -576,6 +574,7 @@ impl WordApp {
return r;
}
let r = self.execute_user(id, params);
let r = system_clipboard::explain(self, id, r);
// Check Accessibility shows its findings in the Accessibility pane, kept up to date there.
if id == "file.accessibility" && r.is_ok() {
self.session.view.accessibility_pane = true;
+35
View File
@@ -16,8 +16,13 @@ use crate::WordApp;
pub enum ClipboardPicture {
/// Straight (not premultiplied) RGBA8 pixels, row by row.
Pixels { width: usize, height: usize, rgba: Vec<u8> },
/// An encoded picture (PNG, JPEG, GIF, WebP, BMP) as another program offered it.
Encoded(Vec<u8>),
/// Copied files (a file manager's Copy); the pictures among them are inserted.
Files(Vec<PathBuf>),
/// A picture the host couldn't read, and why (its format isn't supported, the clipboard
/// couldn't be reached) — said instead of "the clipboard is empty" (#833).
Unreadable(String),
}
/// Longest side of a pasted picture, in pixels.
@@ -82,6 +87,18 @@ pub(crate) fn paste(app: &mut WordApp) -> Option<Result<Value, String>> {
Err(e)
}
},
ClipboardPicture::Encoded(bytes) if bytes.len() as u64 > MAX_FILE_BYTES => {
let e = "the clipboard picture is larger than 200 MB".to_string();
app.status(e.clone());
Err(e)
}
ClipboardPicture::Encoded(bytes) => app.execute("insert.picture", json!({"data": wordcraft_engine::cmd::insert::base64_encode(&bytes)})),
ClipboardPicture::Unreadable(why) => {
let e = format!("the picture on the clipboard couldn't be read: {why}");
log::warn!("clipboard picture: {e}");
app.status(e.clone());
Err(e)
}
ClipboardPicture::Files(files) => {
let files: Vec<PathBuf> = files.into_iter().filter(|p| is_picture_file(p)).take(MAX_FILES).collect();
if files.is_empty() {
@@ -107,6 +124,24 @@ pub(crate) fn paste(app: &mut WordApp) -> Option<Result<Value, String>> {
Some(r)
}
/// The picture on the system clipboard as PNG (or the first copied picture file's bytes), for
/// Paste Special to offer. `None` when there is none, or it is too large or unreadable.
pub(crate) fn clipboard_png(app: &WordApp) -> Option<Vec<u8>> {
match app.services.clipboard_picture.as_ref()?()? {
ClipboardPicture::Pixels { width, height, rgba } => png_from_rgba(width, height, rgba).ok(),
ClipboardPicture::Encoded(bytes) => Some(bytes),
ClipboardPicture::Files(files) => {
let path = files.into_iter().take(MAX_FILES).find(|p| is_picture_file(p))?;
let size = std::fs::metadata(&path).ok()?.len();
if size > MAX_FILE_BYTES.min(wordcraft_engine::cmd::paste::MAX_PICTURE_BYTES as u64 / 4 * 3) {
return None;
}
std::fs::read(path).ok()
}
ClipboardPicture::Unreadable(_) => None,
}
}
/// Paste text naming copied picture files: insert the pictures when the clipboard holds those
/// files. False leaves the text to the usual paste.
pub(crate) fn paste_named_files(app: &mut WordApp, text: &str) -> bool {
+224
View File
@@ -0,0 +1,224 @@
//! The system clipboard for the Paste commands (#584, #687, #833).
//!
//! egui's paste event only carries text, so the host reads the rest through [`crate::Services`]:
//! another program's formatted copy as HTML or RTF (Paste keeps its formatting, Merge Formatting
//! its emphasis), a picture for Paste Special, and the names of the formats there so a paste that
//! finds nothing usable says what the clipboard holds instead of "empty". Everything read here
//! comes from other programs: sizes are capped before it reaches the engine.
use serde_json::Value;
use wordcraft_engine::cmd::paste::{MAX_PASTE_BYTES, MAX_PICTURE_BYTES};
use crate::{WordApp, paste_picture};
/// The commands that paste the clipboard.
pub(crate) fn is_paste(id: &str) -> bool {
matches!(id, "edit.paste" | "edit.pasteText" | "edit.pasteMerge" | "edit.pasteSpecial")
}
/// Most format names an error lists.
const MAX_NAMES: usize = 6;
/// Longest format name shown.
const MAX_NAME_CHARS: usize = 60;
/// Add what the system clipboard holds to a paste command's params: its text (the ribbon and
/// context-menu Paste send none), its HTML and RTF, and for Paste Special its picture. Params a
/// caller already gave are kept. `Some` when the paste was done here instead (copied picture
/// files, which reach us as their paths).
pub(crate) fn fill(app: &mut WordApp, id: &str, params: &mut Value) -> Option<Result<Value, String>> {
if !is_paste(id) {
return None;
}
let o = params.as_object_mut()?;
if !o.contains_key("text")
&& let Some(text) = app.services.clipboard_text.as_ref().and_then(|f| f()).filter(|t| !t.is_empty())
{
// Picture files copied in a file manager reach us as their paths: paste the pictures,
// as Ctrl+V does.
if id == "edit.paste"
&& app.session.math.is_none()
&& paste_picture::names_picture_files(&text)
&& let Some(r) = paste_picture::paste(app)
{
return Some(r);
}
o.insert("text".into(), Value::String(text));
}
// Keep Text Only needs nothing more; a caller that passed formats of its own (an agent, Paste
// Special's OK) has them already.
if id == "edit.pasteText" || ["html", "rtf", "picture"].iter().any(|k| o.contains_key(*k)) {
return None;
}
let fits = |t: &String| !t.trim().is_empty() && t.len() <= MAX_PASTE_BYTES;
if let Some(html) = app.services.clipboard_html.as_ref().and_then(|f| f()).filter(fits) {
o.insert("html".into(), Value::String(html));
}
if let Some(rtf) = app.services.clipboard_rtf.as_ref().and_then(|f| f()).filter(fits) {
o.insert("rtf".into(), Value::String(rtf));
}
if id == "edit.pasteSpecial"
&& let Some(png) = paste_picture::clipboard_png(app)
{
let data = wordcraft_engine::cmd::insert::base64_encode(&png);
if data.len() <= MAX_PICTURE_BYTES {
o.insert("picture".into(), Value::String(data));
}
}
None
}
/// Format names worth showing: not the clipboard protocol's own bookkeeping targets.
fn shown(name: &str) -> bool {
let n = name.trim();
!n.is_empty()
&& !matches!(n, "TARGETS" | "TIMESTAMP" | "MULTIPLE" | "SAVE_TARGETS" | "DELETE" | "INCR")
&& !n.starts_with("chromium/")
&& !n.starts_with("application/x-qt-")
}
/// What to say when a paste found nothing it can use: the formats on the clipboard when the
/// host lists them (#833), else that it is empty.
pub(crate) fn nothing_to_paste(app: &WordApp) -> String {
let names: Vec<String> = app.services.clipboard_formats.as_ref().map(|f| f()).unwrap_or_default();
let mut shown_names: Vec<String> = Vec::new();
for n in names.iter().filter(|n| shown(n)) {
let n: String = n.trim().chars().take(MAX_NAME_CHARS).collect();
if !shown_names.contains(&n) {
shown_names.push(n);
}
}
if shown_names.is_empty() {
return "the clipboard is empty".into();
}
let more = shown_names.len().saturating_sub(MAX_NAMES);
shown_names.truncate(MAX_NAMES);
let mut list = shown_names.join(", ");
if more > 0 {
list.push_str(&format!(" and {more} more"));
}
format!("nothing on the clipboard can be pasted here (it holds {list})")
}
/// A paste that found the clipboard empty, explained (#833): the status bar says what is there.
pub(crate) fn explain(app: &mut WordApp, id: &str, r: Result<Value, String>) -> Result<Value, String> {
match r {
Err(e) if is_paste(id) && e == "the clipboard is empty" => {
let e = nothing_to_paste(app);
app.status(e.clone());
Err(e)
}
r => r,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::Services;
use crate::paste_picture::ClipboardPicture;
use serde_json::json;
use wordcraft_engine::Session;
fn app(services: Services) -> WordApp {
WordApp::new(Session::new(wordcraft_doc::Document::new()), services)
}
fn first_para(a: &WordApp) -> String {
let focus = a.session.sel.focus.clone();
a.session.doc.para_at(&focus).map(|p| p.text.clone()).unwrap_or_default()
}
fn bold_at(a: &WordApp, off: usize) -> bool {
let focus = a.session.sel.focus.clone();
a.session.doc.para_at(&focus).and_then(|p| p.props_of_char(off).bold).unwrap_or(false)
}
/// Ctrl+V (a paste event with the text) and the ribbon's Paste keep another program's
/// formatting from the clipboard's HTML (#687); Keep Text Only doesn't.
#[test]
fn paste_keeps_formatting_from_the_system_clipboard_html() {
let services = Services {
clipboard_text: Some(Box::new(|| Some("plain strong".into()))),
clipboard_html: Some(Box::new(|| Some("<p>plain <b>strong</b></p>".into()))),
..Default::default()
};
let mut a = app(services);
// Ctrl+V: the paste event carries the text.
a.run("edit.paste", json!({"text": "plain strong"})).unwrap();
assert_eq!(first_para(&a), "plain strong");
assert!(!bold_at(&a, 0) && bold_at(&a, 7));
a.run("edit.undo", json!({})).unwrap();
// The ribbon's Paste sends nothing.
a.run("edit.paste", json!({})).unwrap();
assert!(bold_at(&a, 7));
a.run("edit.undo", json!({})).unwrap();
// Merge Formatting inserts it, keeping the bold.
a.run("edit.pasteMerge", json!({})).unwrap();
assert_eq!(first_para(&a), "plain strong");
assert!(bold_at(&a, 7));
a.run("edit.undo", json!({})).unwrap();
a.run("edit.pasteText", json!({})).unwrap();
assert!(!bold_at(&a, 7));
// Paste Special lists what is on the system clipboard.
let r = a.run("edit.pasteSpecial", json!({})).unwrap();
let ids: Vec<&str> = r["formats"].as_array().unwrap().iter().filter_map(|f| f["id"].as_str()).collect();
assert_eq!(ids, ["html", "text"]);
}
/// RTF is used where the clipboard has no HTML; oversized payloads are dropped, not pasted.
#[test]
fn rtf_and_oversized_clipboard_payloads() {
let services = Services {
clipboard_text: Some(Box::new(|| Some("bold".into()))),
clipboard_html: Some(Box::new(|| Some(format!("<b>{}</b>", "x".repeat(MAX_PASTE_BYTES))))),
clipboard_rtf: Some(Box::new(|| Some(r"{\rtf1\ansi {\b bold}}".into()))),
..Default::default()
};
let mut a = app(services);
a.run("edit.paste", json!({})).unwrap();
assert_eq!(first_para(&a), "bold");
assert!(bold_at(&a, 0), "the RTF's bold, not the oversized HTML");
}
/// Paste Special lists a picture on the system clipboard and pastes it.
#[test]
fn paste_special_offers_the_clipboard_picture() {
let services = Services {
clipboard_picture: Some(Box::new(|| Some(ClipboardPicture::Pixels { width: 4, height: 4, rgba: vec![200; 64] }))),
..Default::default()
};
let mut a = app(services);
let r = a.run("edit.pasteSpecial", json!({})).unwrap();
let ids: Vec<&str> = r["formats"].as_array().unwrap().iter().filter_map(|f| f["id"].as_str()).collect();
assert_eq!(ids, ["picture"]);
// Choosing it in the dialog (or naming it) pastes the picture.
let r = a.run("edit.pasteSpecial", json!({"as": "picture"})).unwrap();
assert_eq!(r["pastedAs"], "picture");
assert_eq!(a.session.doc.media.len(), 1);
}
/// A clipboard holding nothing WordCraft can paste says what it holds (#833); one that can't
/// be read says why; a truly empty one says it is empty.
#[test]
fn nothing_to_paste_says_what_the_clipboard_holds() {
let services = Services {
clipboard_formats: Some(Box::new(|| vec!["TARGETS".into(), "image/x-unknown".into(), "text/x-moz-url".into(), "image/x-unknown".into()])),
clipboard_picture: Some(Box::new(|| None)),
..Default::default()
};
let mut a = app(services);
let e = a.run("edit.paste", json!({})).unwrap_err();
assert_eq!(e, "nothing on the clipboard can be pasted here (it holds image/x-unknown, text/x-moz-url)");
assert_eq!(a.run("edit.pasteMerge", json!({})).unwrap_err(), e);
let many = Services { clipboard_formats: Some(Box::new(|| (0..20).map(|i| format!("f{i}")).collect())), ..Default::default() };
assert!(nothing_to_paste(&app(many)).ends_with("f5 and 14 more)"));
let mut a = app(Services::default());
assert_eq!(a.run("edit.paste", json!({})).unwrap_err(), "the clipboard is empty");
let unreadable = Services {
clipboard_picture: Some(Box::new(|| Some(ClipboardPicture::Unreadable("its format isn't supported".into())))),
..Default::default()
};
let e = app(unreadable).run("edit.paste", json!({})).unwrap_err();
assert!(e.contains("couldn't be read") && e.contains("isn't supported"), "{e}");
}
}
+4 -3
View File
@@ -1,6 +1,6 @@
# File-format parity with Microsoft Word
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** trivial (ODT page size, orientation and margins read on open, #810; previously trivial: native save temporary-file ownership and failure protection; previously trivial: Print Markup: comments in PDF/print/copies, #589; previously trivial: SmartArt written from WordCraft's model, #497; previously trivial: import/export fixes for TXT, RTF, ODT, HTML and LaTeX, #433–#440; previously major: first version; every format Word reads or writes, measured from the readers and writers on origin/main) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4, plus Word for Windows' extra formats
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-11 · **Change:** trivial (formatted paste from the system clipboard's HTML and RTF, #687; previously trivial: independent complex-script formatting inheritance and round-trip regression tests; previously trivial: import/export fixes for TXT, RTF, ODT, HTML and LaTeX, #433–#440; previously major: first version; every format Word reads or writes, measured from the readers and writers on origin/main) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4, plus Word for Windows' extra formats
Every format Word opens or saves, what WordCraft does with it, and how it's tested. Fidelity is the
share of a typical real-world file's content and formatting that survives (read: shows correctly;
@@ -25,10 +25,10 @@ Weights are the share of a Word user's file traffic (estimate). Read/write: ✅
| `.doc` Word 97-2003 (and `.dot`) | 8% | R/W | 🟡 | ❌ | ~60% | 25–40 | `crates/docbin/src/tests.rs`, `tests/drawing.rs` (56) | Spec-based reader ([MS-DOC]): text, formatting, styles, sections, headers/footers, tables, lists, notes, fields, bookmarks; inline and floating pictures (PNG, JPEG, DIB, TIFF, EMF, WMF; Macintosh PICT as a sized placeholder) with crop, alt text, position and wrap; basic shapes, lines, groups and text boxes ([MS-ODRAW]). Freeform shapes, z-order and Word 6/95 or encrypted files are dropped or refused. No writer |
| PDF export | 8% | W | — | ✅ | ~80% | 6–10 | `crates/pdf/src/tests.rs` | krilla: embedded subsets, selectable text incl. field results (#133), links, outline, tagging. Fonts it can't subset become outlines (#132). Print Markup (#589): comments in balloons beside each page (scaled to its paper) and tracked changes; HTML/Markdown/text copies end with a comment list. No PDF/A option, no "best for printing/online" choice |
| PDF open (PDF Reflow, Word for Windows) | 1% | R | ❌ | — | 0% | 30–50 | — | Converting a PDF into an editable document. A sibling app (PdfCraft) may supply the parser |
| `.rtf` Rich Text Format | 3% | R/W | 🟡 | 🟡 | ~50% | 10–15 | `crates/formats/src/tests.rs` | ~1,500 lines: text, character/paragraph formatting, styles, lists, tables, pictures (`\shppict`, #154), fields, bookmarks. Thin: comments, notes, revisions, sections, RTL |
| `.rtf` Rich Text Format | 3% | R/W | 🟡 | 🟡 | ~50% | 10–15 | `crates/formats/src/tests.rs` | ~1,500 lines: text, character/paragraph formatting, styles, lists, tables, pictures (`\shppict`, #154), fields, bookmarks. Thin: comments, notes, revisions, sections, RTL. Pasted from the system clipboard where the platform offers it (Windows, Wayland) when there is no HTML (#687) |
| `.odt` OpenDocument Text | 2% | R/W | 🟡 | 🟡 | ~50% | 8–12 | `crates/formats/src/tests.rs` | Styles, lists (#87), tables, pictures, headers/footers, page breaks (#86), page size, orientation and margins (#810). No footnotes, comments, tracked changes, fields or RTL |
| `.txt` Plain text | 2% | R/W | ✅ | ✅ | ~95% | 1–2 | unit tests | Encoding detection. Files with other extensions (`.m3u`/`.m3u8` playlists, logs, config files, no extension) open as text when they are text, Word and RTF files by their signature; binary files are refused (#569). Word's encoding choice dialog and line-ending options missing; such files save only through Save As |
| `.htm/.html` Web Page and Web Page, Filtered | 1% | R/W | 🟡 | 🟡 | ~60% | 6–10 | `crates/formats/src/tests.rs` | Tables (#102), relative pictures embedded (#113), missing `</head>` tolerated (#154). No Word "Web Page" (with `_files` folder) round-trip |
| `.htm/.html` Web Page and Web Page, Filtered | 1% | R/W | 🟡 | 🟡 | ~60% | 6–10 | `crates/formats/src/tests.rs` | Tables (#102), relative pictures embedded (#113), missing `</head>` tolerated (#154). No Word "Web Page" (with `_files` folder) round-trip. The same reader takes HTML from the system clipboard: Paste keeps another program's formatting (a Word document, a web page; Windows' "HTML Format" header understood), Merge Formatting keeps its emphasis (#687) |
| `.mht/.mhtml` Single File Web Page | <1% | R/W | ❌ | ❌ | 0% | 3–5 | — | |
| `.xml` Word XML Document (Flat OPC) | <1% | R/W | ✅ | ✅ | ~65% | — | `crates/docx/tests/word_xml.rs`, `roundtrip.rs` (`flat_opc_round_trip_equals_docx`) | The DOCX package in one XML file, read and written by the DOCX code (#548); File › Export › Word XML Document. `.xml` files are told apart by their root element |
| `.xml` Word 2003 XML (WordprocessingML 2003) | <1% | R/W | 🟡 | ❌ | ~55% | 2–3 | `crates/docx/tests/word_xml.rs` | Opened by rewriting it as a package for the DOCX reader (#548): styles, default fonts, lists, tables, sections, headers/footers, footnotes/endnotes, comments, bookmarks, tracked insertions/deletions, hyperlinks, `w:binData` pictures, document properties. Not read: formatting changes, `w:docPr` settings, smart tags. Saving as 2003 XML is not planned (legacy) |
@@ -162,6 +162,7 @@ raise readiness estimates. No Word-derived files are fixtures.
| Date | Change | Summary |
|---|---|---|
| 2026-10-12 | trivial | Paste keeps the formatting of HTML and RTF on the system clipboard; Paste Special lists them and pictures (#687, #833) |
| 2026-10-12 | trivial | ODT page size, orientation and margins read back from `styles.xml` on open, so they survive a save and reopen (#810) |
| 2026-10-11 | trivial | Independent complex-script size, bold and italic inherit correctly through defaults and styles; explicit equal values survive; six synthetic multi-generation round-trip tests, including tracked formatting, added |
| 2026-10-11 | minor | Opening files from the system: file associations and the default-app button (#295) |
+5 -2
View File
@@ -1,6 +1,6 @@
# Where WordCraft falls short of Word
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-11 · **Change:** trivial (DOCX complex-script formatting inheritance fixed; broader RTL work remains; previously trivial: #24 narrowed: Customize Keyboard and Customize Ribbon landed; previously trivial: formatting revisions landed, #41; previously trivial: Draw Table and Eraser landed, #303; previously minor: beta distance restated: ~15 points, ~150–230 h; previously: first version; every known shortfall from the 2026-10-10 re-measure, ranked) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-11 · **Change:** trivial (formatted paste from other programs and Wayland clipboard pictures, #687, #833; previously trivial: DOCX complex-script formatting inheritance fixed; broader RTL work remains; previously trivial: #24 narrowed: Customize Keyboard and Customize Ribbon landed; previously trivial: formatting revisions landed, #41; previously trivial: Draw Table and Eraser landed, #303; previously minor: beta distance restated: ~15 points, ~150–230 h; previously: first version; every known shortfall from the 2026-10-10 re-measure, ranked) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4
The work list. One entry per shortfall, ranked by how much it stops a Word user from switching
(**B** = blocks beta). Each says what's missing, the evidence, who it hurts, an Opus 5.5 agent
@@ -54,7 +54,10 @@ The same gaps grouped the way the parity documents are, for agents working in on
- **Feature gaps:** #2 (preserve), #5, #10, #12, #13, #15, #17, #21, #25, #27, #28, #29; Clipboard
pane, Manage Styles, Group (open PRs exist for the first three — review and merge before
re-implementing). Paste Special, Advanced Find, Column Selection, Style Inspector, custom table
styles, and Draw Table and Eraser (#303) landed on 2026-10-10.
styles, and Draw Table and Eraser (#303) landed on 2026-10-10. Paste from other programs keeps
their formatting (system clipboard HTML/RTF), Merge Formatting keeps emphasis, Paste Special lists
what the system clipboard holds, and Linux/Wayland clipboard pictures paste (#687, #833);
still missing: the Paste Options button after a paste, and RTF on macOS and X11.
- **UI/UX gaps:** #7, #18, #24; crop handles on the canvas (rotation handle landed, #332); table column markers on the
ruler (#49; dragging borders and the resize handle on the page landed, #217); context menus for objects and tables; Linux title bar theming (#78); File ›
Options › Animation effects › Use system setting reads macOS (Reduce motion) and the Linux/BSD