mirror of
https://github.com/aphrody-labs/wordcraft.git
synced 2026-10-13 19:26:53 +00:00
* #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:
35 files changed
+848
-72
No files matched your search
Generated
+87
-4
@@ -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
@@ -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
@@ -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) |
|
||||
|---|---|
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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")]
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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)?;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in new issue
Block a user