Clipboard: copy and paste RTF (#2630)

Lands #2630.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: dkhlapov <19991457+dkhlapov@users.noreply.github.com>
This commit is contained in:
authored and GitHub committed 2026-11-05 23:36:19 +01:00
1 parent c21d5af2a6
commit 2fb91eece2
16 files changed
+352 -49

No files matched your search

Generated
+1
View File
@@ -6824,6 +6824,7 @@ dependencies = [
"wordcraft-engine",
"wordcraft-fonts",
"wordcraft-ui-egui",
"x11rb",
"zbus",
]
+3
View File
@@ -300,6 +300,8 @@ Imported Word 97–2003 `.doc`/`.dot` files now ask for a supported DOCX target
| Date | What landed |
| 2026-11-04 | Pictures keep the brightness, contrast and transparency their file gives them (#1711, #1766): a "Washout" picture watermark (VML `gain`/`blacklevel`) and a washed-out letterhead picture behind the text (`a:lum`) are drawn faded on screen and in PDF instead of at full colour, with the pixel curve measured on Word's own PDF export; `a:alphaModFix` transparency too. Saved back as DrawingML (and as VML `gain`/`blacklevel` in Word 2003 XML, without the transparency). Picture Format › Corrections, Transparency and Color › Washout replace the file's values (other Color modes leave them alone), and Reset Picture discards them all, as in Word; the PDF embeds the corrected pixels once per picture |
|---|---|
| 2026-10-17 | Copies keep their formatting in programs that read RTF rather than HTML: Copy and Cut put RTF beside the text and HTML on the system clipboard on Windows, macOS and Wayland (an X11 copy stays text and HTML, since arboard owns the selection there; a copy of only pictures or shapes carries no HTML or RTF, so pasting it back keeps every detail), and Paste reads another program's RTF on macOS and X11 too, not only on Windows and Wayland |
| 2026-11-03 | **Arabic interface (#1195):** `ar` selectable (and every `ar-*` system locale); craft-i18n catalog; mirrored ribbon, panes, File page, dialogs and menus; Arabic UI font fallback; document direction stays independent. No kashida or Arabic proofing yet; Hebrew UI still open. |
| 2026-11-01 | **English spelling reads SCOWL 2026 (CP2, #40, #520, #1281, #1045):** en-US, en-GB (also en-AU, en-NZ, en-IE, en-IN…) and en-CA each check against their own SCOWL `rel-2026.02.25` Hunspell dictionary from craft-i18n, embedded and read by craft-proof's affix engine (which now understands `COMPOUNDRULE`, so ordinals like `21st` are words). Suggestions come from the dialect's own dictionary; our American/British policy (`en-variants.txt`) still decides the other dialect's spellings, because Word accepts more than SCOWL's one spelling per dialect (`organize` and `organise` in en-GB). Rare words the 1990s Moby list accepted (`aposiopesis`) are now flagged. The Moby list stays for hyphenation and as a fallback. The build carries 1.5 MB more Language data. |
| 2026-11-01 | Proofing moved to the shared libraries, with nothing users see changing: the code is `craft-proof` in storytold/craft-libs (with its history) and the spelling and hyphenation data (the Moby list, our English patterns and word lists, the German, Polish, Portuguese and Italian patterns, `ru-extra`) is craft-i18n's `language-data/`, embedded from `CRAFT_I18N_DIR` at the commit in `craft-i18n.rev`. Before/after `wordcraft-cli` runs on a proofing document give the same issues, suggestions and hyphenated PDF (CP1). |
@@ -800,6 +802,7 @@ MATH glyphs that reference the same construction now share its immutable variant
| 2026-11-03 | trivial | Progress log: the missing-pack bar on a fresh install, the caret's typing language, and the spelling menu heading in the interface language (CP4.3) |
| 2026-11-03 | trivial | Progress log: Options › Language and Proofing redesign (CP4.3) |
| 2026-10-30 | trivial | Progress log: layout resource caps — repeated table headers are bounded by item counts, the wrap cache by decoded pixels and linked text box layout by its lines and items; PDF export, hit testing, tracked changes, clipped tables and long endnote selections cost linear time (#3867, #3866) |
| 2026-10-17 | trivial | Progress log: RTF on the system clipboard |
| 2026-11-03 | trivial | Progress log: craft-proof pin, French elision suggestions and Valencian (CP4.3) |
| 2026-11-03 | trivial | Progress log: a long comment's card and balloon stop at ten lines (#3382) |
| 2026-11-03 | trivial | Progress log: Replace and Remove Table of Contents with Word's title above a section break (#4041) |
+7 -3
View File
@@ -38,13 +38,14 @@ fmv-macos-events = "=0.1.0"
# The system's Reduce motion setting (System Settings › Accessibility › Display), read through
# NSWorkspace: a safe call in these bindings. Already in the build through eframe, rfd and arboard,
# at this version; this adds the `NSAccessibility` bindings and, to fit the title bar around the
# traffic lights, the window, button and view ones.
# traffic lights, the window, button and view ones; and `NSPasteboard` for the clipboard's RTF.
objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "NSWorkspace", "NSAccessibility", "NSApplication", "NSWindow", "NSButton", "NSControl", "NSView", "NSResponder", "NSPasteboard"] }
objc2 = "0.6"
# A copied equation's MathML goes on the pasteboard as `public.mathml.presentation` beside
# arboard's HTML and text (#1297), through NSPasteboard's safe `setString:forType:`. Already in
# the build through arboard, at this version; this adds only the NSString bindings.
objc2-foundation = { version = "0.3", default-features = false, features = ["std", "NSString"] }
# the build through arboard, at this version; this adds only the NSString bindings, and NSData
# for the clipboard's RTF (`public.rtf`).
objc2-foundation = { version = "0.3", default-features = false, features = ["std", "NSString", "NSData"] }
# Closing the window must quit through NSApplication rather than by dropping the NSWindow
# inside the AppKit run loop (see `schedule_macos_terminate`).
dispatch2 = "0.3"
@@ -58,6 +59,9 @@ zbus = { version = "5", default-features = false, features = ["async-io", "block
# 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"
# Another program's RTF on X11 (`text/rtf`), which arboard doesn't read. Already in the build
# through arboard, at this version.
x11rb = "0.13"
[target.'cfg(target_os = "windows")'.dependencies]
# The upstream default-apps button opens Windows Settings; printing uses craft-print instead.
+222 -15
View File
@@ -18,6 +18,11 @@
//! they paste math (learn.microsoft.com/office/math/mathml, "Clipboard support"). Pasting into
//! Word and LibreOffice is not yet verified. Linux and the BSDs have no Word, and arboard can't
//! add MIME types of other names, so there the HTML carries the MathML alone.
//!
//! RTF goes beside them where we can add it: Windows ("Rich Text Format") and macOS
//! (`public.rtf`) after arboard's HTML, Wayland (`text/rtf`) in one offer with the text and HTML.
//! On X11 arboard owns the selection and serves only its own targets, so a copy there carries text
//! and HTML only; reading another program's RTF works on all four.
use wordcraft_engine::cmd::paste::MAX_PASTE_BYTES;
use wordcraft_ui_egui::paste_picture::ClipboardPicture;
@@ -31,17 +36,24 @@ thread_local! {
static WRITER: std::cell::RefCell<Option<arboard::Clipboard>> = const { std::cell::RefCell::new(None) };
}
/// Put `html` on the clipboard with `text` as its plain-text alternative, and `mathml` (a copy of
/// one equation alone; empty for any other copy) in the platform's MathML formats. `false` when
/// the HTML and text couldn't be set (the caller copies the text alone), including a Wayland
/// session without the data-control protocol, where arboard would fall back to X11 and native
/// Wayland programs might not see it. MathML that can't be added is logged: the HTML still
/// carries it.
pub fn set_html(html: &str, text: &str, mathml: &str) -> bool {
/// Put a copy on the clipboard: `text`, with `html` and `rtf` beside it where the platform takes
/// them (an empty one isn't offered), and `mathml` (a copy of one equation alone; empty for any
/// other copy) in the platform's MathML formats. `false` when the text and HTML couldn't be set
/// (the caller copies the text alone), including a Wayland session without the data-control
/// protocol, where arboard would fall back to X11 and native Wayland programs might not see it.
/// RTF or MathML that can't be added is logged: the HTML still carries the copy.
pub fn set_rich(text: &str, html: &str, rtf: &str, mathml: &str) -> bool {
// Wayland: one offer with the text, HTML and RTF (no MathML formats there; see above).
if platform::set_rich(text, html, rtf) {
return true;
}
if !platform::can_set() {
return false;
}
let set = set_html_text(html, text);
if set && !rtf.is_empty() {
platform::add_rtf(rtf);
}
if set && !mathml.is_empty() && mathml.len() <= MAX_PASTE_BYTES {
platform::add_mathml(mathml);
}
@@ -54,7 +66,8 @@ fn set_html_text(html: &str, text: &str) -> bool {
if slot.is_none() {
*slot = open();
}
match slot.as_mut().map(|c| c.set().html(html, Some(text))) {
let set = slot.as_mut().map(|c| if html.is_empty() { c.set_text(text) } else { c.set().html(html, Some(text)) });
match set {
Some(Ok(())) => true,
Some(Err(e)) => {
log::warn!("clipboard: {e}");
@@ -76,8 +89,8 @@ 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.
/// The clipboard's RTF: Windows ("Rich Text Format"), macOS (`public.rtf`), Wayland and X11
/// (`text/rtf`); not offered by arboard, so read here.
pub fn rtf() -> Option<String> {
let bytes = platform::rtf()?;
if bytes.len() > MAX_PASTE_BYTES {
@@ -176,15 +189,51 @@ mod platform {
)
}
/// Put the copy on a Wayland clipboard in one offer: the text (with the usual plain-text
/// aliases), HTML and RTF, served from a background thread until something else is copied.
/// `false` outside Wayland (X11 goes through arboard) or without the data-control protocol.
pub fn set_rich(text: &str, html: &str, rtf: &str) -> bool {
use wl_clipboard_rs::copy::{MimeSource, MimeType, Options, Source};
if !wayland() {
return false;
}
let offer = |mime: &str, data: &str| MimeSource { source: Source::Bytes(data.as_bytes().into()), mime_type: MimeType::Specific(mime.into()) };
// The text first: the plain-text aliases serve the first text type offered.
let mut sources = vec![offer("text/plain;charset=utf-8", text)];
for (mime, data) in [("text/html", html), ("text/rtf", rtf)] {
if !data.is_empty() {
sources.push(offer(mime, data));
}
}
match Options::new().copy_multi(sources) {
Ok(()) => true,
Err(e) => {
log::warn!("clipboard: {e}");
false
}
}
}
/// Wayland copies carry their RTF already; X11 ones can't (see the module docs).
pub fn add_rtf(_rtf: &str) {}
/// RTF types a clipboard may offer, best first.
const RTF_MIMES: [&str; 3] = ["text/rtf", "application/rtf", "text/richtext"];
/// The RTF type to ask for among those `offered`.
fn rtf_target(offered: &[String]) -> Option<&'static str> {
RTF_MIMES.into_iter().find(|m| offered.iter().any(|o| o == m))
}
/// arboard serves its own MIME types only; Word, which reads the MathML formats, isn't here.
pub fn add_mathml(_mathml: &str) {}
pub fn rtf() -> Option<Vec<u8>> {
if !wayland() {
return None;
return std::env::var_os("DISPLAY").is_some().then(|| x11::rtf(super::MAX_PASTE_BYTES)).flatten();
}
let offered = formats();
let mime = ["text/rtf", "application/rtf", "text/richtext"].into_iter().find(|m| offered.iter().any(|o| o == m))?;
let mime = rtf_target(&formats())?;
read(mime, super::MAX_PASTE_BYTES as u64)
}
@@ -196,6 +245,125 @@ mod platform {
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)
}
/// Whether a property read of `len` bytes, with `bytes_after` left unread, is the whole value
/// and at most `cap` bytes.
fn complete(len: usize, bytes_after: u32, cap: usize) -> bool {
bytes_after == 0 && len <= cap
}
/// Reading another program's RTF on X11, which arboard doesn't offer: ask the CLIPBOARD
/// selection's owner for its targets, then for its RTF, into a property of a hidden window.
mod x11 {
use std::time::{Duration, Instant};
use x11rb::connection::Connection;
use x11rb::protocol::Event;
use x11rb::protocol::xproto::{Atom, AtomEnum, ConnectionExt as _, CreateWindowAux, GetPropertyReply, Window, WindowClass};
use x11rb::rust_connection::RustConnection;
/// How long a paste waits for the selection's owner, in all.
const TIMEOUT: Duration = Duration::from_secs(1);
/// Most targets looked at.
const MAX_TARGETS: usize = 128;
struct Reader {
conn: RustConnection,
win: Window,
clipboard: Atom,
prop: Atom,
deadline: Instant,
}
fn atom(conn: &RustConnection, name: &str) -> Option<Atom> {
conn.intern_atom(false, name.as_bytes()).ok()?.reply().ok().map(|r| r.atom)
}
impl Reader {
fn new() -> Option<Self> {
let (conn, screen) = x11rb::connect(None).ok()?;
let root = conn.setup().roots.get(screen)?.root;
let win = conn.generate_id().ok()?;
conn.create_window(0, win, root, 0, 0, 1, 1, 0, WindowClass::INPUT_ONLY, x11rb::COPY_FROM_PARENT, &CreateWindowAux::new()).ok()?;
let clipboard = atom(&conn, "CLIPBOARD")?;
let prop = atom(&conn, "WORDCRAFT_PASTE")?;
Some(Self { conn, win, clipboard, prop, deadline: Instant::now() + TIMEOUT })
}
/// The selection converted to `target`, at most `cap` bytes; `None` when the owner
/// refuses or doesn't answer in time. A longer value is `None` when `whole`, else
/// its first `cap` bytes.
fn convert(&self, target: Atom, cap: usize, whole: bool) -> Option<GetPropertyReply> {
self.conn.convert_selection(self.win, self.clipboard, target, self.prop, x11rb::CURRENT_TIME).ok()?;
self.conn.flush().ok()?;
loop {
// Checked every time round, so a stream of other events can't keep us here.
if Instant::now() >= self.deadline {
return None;
}
match self.conn.poll_for_event().ok()? {
Some(Event::SelectionNotify(e)) if e.requestor == self.win => {
if e.property == x11rb::NONE {
return None;
}
break;
}
Some(_) => {}
None => std::thread::sleep(Duration::from_millis(5)),
}
}
// In 32-bit units; for a whole value one more than the cap, so a larger one shows
// as `bytes_after`.
let units = u32::try_from(cap / 4 + usize::from(whole)).unwrap_or(u32::MAX);
let r = self.conn.get_property(true, self.win, self.prop, AtomEnum::ANY, 0, units).ok()?.reply().ok()?;
(!whole || super::complete(r.value.len(), r.bytes_after, cap)).then_some(r)
}
}
impl Drop for Reader {
fn drop(&mut self) {
let _ = self.conn.destroy_window(self.win);
let _ = self.conn.flush();
}
}
/// The CLIPBOARD selection's RTF, at most `cap` bytes. Copies too large to send at once
/// arrive in pieces (INCR), which aren't read: `None` then.
pub fn rtf(cap: usize) -> Option<Vec<u8>> {
let r = Reader::new()?;
// The first targets only: a longer list is cut short, not refused.
let targets = r.convert(atom(&r.conn, "TARGETS")?, MAX_TARGETS.saturating_mul(4), false)?;
let cookies: Vec<_> = targets.value32()?.take(MAX_TARGETS).filter_map(|a| r.conn.get_atom_name(a).ok()).collect();
let names: Vec<String> =
cookies.into_iter().filter_map(|c| c.reply().ok()).map(|n| String::from_utf8_lossy(&n.name).into_owned()).collect();
let target = atom(&r.conn, super::rtf_target(&names)?)?;
let incr = atom(&r.conn, "INCR")?;
let reply = r.convert(target, cap, true)?;
(reply.type_ != incr).then_some(reply.value)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn rtf_target_prefers_text_rtf() {
let names = |n: &[&str]| n.iter().map(|s| s.to_string()).collect::<Vec<_>>();
assert_eq!(rtf_target(&names(&["TARGETS", "text/richtext", "application/rtf", "text/rtf"])), Some("text/rtf"));
assert_eq!(rtf_target(&names(&["text/richtext", "UTF8_STRING"])), Some("text/richtext"));
assert_eq!(rtf_target(&names(&["text/html", "text/rtf;charset=x"])), None);
assert_eq!(rtf_target(&[]), None);
}
#[test]
fn property_reads_must_be_whole_and_capped() {
assert!(complete(10, 0, 10));
assert!(!complete(11, 0, 10), "over the cap");
assert!(!complete(8, 4, 10), "more left to read");
assert!(complete(0, 0, 0));
}
}
}
#[cfg(target_os = "windows")]
@@ -251,6 +419,23 @@ mod platform {
true
}
/// arboard sets the text and HTML.
pub fn set_rich(_text: &str, _html: &str, _rtf: &str) -> bool {
false
}
/// Add "Rich Text Format" to what arboard just set, NUL-terminated as is usual.
pub fn add_rtf(rtf: &str) {
let Some(format) = raw::register_format("Rich Text Format") else { return };
let Ok(_open) = Clipboard::new_attempts(10) else { return };
let mut bytes = Vec::with_capacity(rtf.len().saturating_add(1));
bytes.extend_from_slice(rtf.as_bytes());
bytes.push(0);
if let Err(e) = raw::set_without_clear(format.get(), &bytes) {
log::warn!("clipboard RTF: {e}");
}
}
/// Add the MathML to what arboard just set, as "MathML" and "MathML Presentation": UTF-16LE
/// text (with its terminating NUL, as Windows text formats have).
pub fn add_mathml(mathml: &str) {
@@ -276,14 +461,18 @@ const MATHML_FORMATS: [&str; 2] = ["MathML", "MathML Presentation"];
#[cfg(target_os = "macos")]
mod platform {
use objc2_app_kit::NSPasteboard;
use objc2_foundation::NSString;
use objc2_foundation::{NSData, NSString};
/// RTF's pasteboard type (`NSPasteboardTypeRTF`, whose extern static needs unsafe to read).
const RTF: &str = "public.rtf";
pub fn formats() -> Vec<String> {
Vec::new()
}
pub fn rtf() -> Option<Vec<u8>> {
None
let data = NSPasteboard::generalPasteboard().dataForType(&NSString::from_str(RTF))?;
(data.len() <= super::MAX_PASTE_BYTES).then(|| data.to_vec())
}
pub fn encoded_picture() -> Option<Vec<u8>> {
@@ -294,6 +483,18 @@ mod platform {
true
}
/// arboard sets the text and HTML.
pub fn set_rich(_text: &str, _html: &str, _rtf: &str) -> bool {
false
}
/// Add RTF to the pasteboard item arboard just wrote (it cleared the pasteboard first).
pub fn add_rtf(rtf: &str) {
if !NSPasteboard::generalPasteboard().setData_forType(Some(&NSData::with_bytes(rtf.as_bytes())), &NSString::from_str(RTF)) {
log::warn!("clipboard: the RTF couldn't be added");
}
}
/// Add the MathML to the pasteboard item arboard just wrote, as the MathML types (W3C MathML 3
/// §6.2.4): presentation markup, and the generic type for readers that only know that one.
pub fn add_mathml(mathml: &str) {
@@ -329,5 +530,11 @@ mod platform {
true
}
pub fn set_rich(_text: &str, _html: &str, _rtf: &str) -> bool {
false
}
pub fn add_rtf(_rtf: &str) {}
pub fn add_mathml(_mathml: &str) {}
}
+1 -1
View File
@@ -312,7 +312,7 @@ fn services(file_dialog: file_dialogs::Hook, ctx: &egui::Context) -> Services {
animations_enabled: Some(animations::hook(ctx)),
clipboard_text: Some(Box::new(clipboard::text)),
clipboard_html: Some(Box::new(clipboard::html)),
set_clipboard_html: Some(Box::new(clipboard::set_html)),
set_clipboard_rich: Some(Box::new(clipboard::set_rich)),
clipboard_rtf: Some(Box::new(clipboard::rtf)),
clipboard_formats: Some(Box::new(clipboard::formats)),
..Default::default()
+12 -7
View File
@@ -199,16 +199,21 @@ fn copy_with(s: &mut Session, whole: Option<&Path>) -> CmdResult {
}
/// What Copy and Cut return: the clipboard's text, and the copy as HTML (equations as MathML,
/// #816) for the host to put on the system clipboard too, so other programs keep the formatting;
/// and for a copy of one equation alone, its MathML (#1297).
/// #816) and as RTF for the host to put on the system clipboard too, so other programs keep the
/// formatting; and for a copy of one equation alone, its MathML (#1297).
pub(crate) fn copy_result(s: &mut Session) -> Value {
s.clipboard_html = match &s.clipboard {
Some(f) => wordcraft_formats::html::export_fragment(&f.to_document()),
None => String::new(),
(s.clipboard_html, s.clipboard_rtf) = match &s.clipboard {
Some(f) => {
let d = f.to_document();
(wordcraft_formats::html::export_fragment(&d), wordcraft_formats::rtf::export(&d))
}
None => (String::new(), String::new()),
};
let mut r = json!({"text": s.clipboard_text});
if !s.clipboard_html.is_empty() {
r["html"] = json!(s.clipboard_html);
for (k, v) in [("html", &s.clipboard_html), ("rtf", &s.clipboard_rtf)] {
if !v.is_empty() {
r[k] = json!(v);
}
}
// A copy of one equation alone, as MathML for the clipboard formats equation editors read
// ("MathML" on Windows, `public.mathml.presentation` on macOS; #1297). Word looks for them
+3
View File
@@ -71,6 +71,9 @@ pub struct Session {
/// HTML for the system clipboard beside [`Self::clipboard_text`]: the copy's formatting for
/// programs that read HTML, equations as MathML (#816); empty when nothing was copied.
pub clipboard_html: String,
/// RTF for the system clipboard beside [`Self::clipboard_html`], for programs that read RTF
/// rather than HTML; empty when nothing was copied.
pub clipboard_rtf: String,
/// Items collected by Copy and Cut this session, for the Clipboard pane.
pub clip_history: crate::cmd::edit::ClipHistory,
pub find: FindState,
+1
View File
@@ -244,6 +244,7 @@ impl Session {
clipboard: _,
clipboard_text: _,
clipboard_html: _,
clipboard_rtf: _,
clip_history: _,
registry: _,
author: _,
+1
View File
@@ -98,6 +98,7 @@ impl Session {
pending_move,
clipboard_text: String::new(),
clipboard_html: String::new(),
clipboard_rtf: String::new(),
clip_history: Default::default(),
find,
goal_x,
+1
View File
@@ -80,6 +80,7 @@ impl Session {
pending_move: _,
clipboard_text: _,
clipboard_html: _,
clipboard_rtf: _,
clip_history: _,
find: _,
goal_x: _,
+68
View File
@@ -1346,6 +1346,74 @@ fn copying_bold_text_puts_html_on_the_clipboard() {
assert_eq!(s.clipboard_html, html);
}
/// Bold and italic of a paragraph's character, in a document.
fn emphasis_at(d: &wordcraft_doc::Document, block: usize, off: usize) -> (bool, bool) {
match d.body.get(block).map(|b| &**b) {
Some(wordcraft_doc::Block::Para(p)) => {
let c = p.props_of_char(off);
(c.bold.unwrap_or(false), c.italic.unwrap_or(false))
}
_ => (false, false),
}
}
#[test]
fn copying_puts_rtf_on_the_clipboard() {
// gap-clipboard-rtf: a copy carries RTF beside its text and HTML, for programs that read RTF.
let mut s = s();
run(&mut s, "document.setText", json!({"text": "plain bold\nsome italic"}));
s.doc.format_range(&Pos::body(0, 6), &Pos::body(0, 10), &|c| c.bold = Some(true)).unwrap();
s.doc.format_range(&Pos::body(1, 5), &Pos::body(1, 11), &|c| c.italic = Some(true)).unwrap();
run(&mut s, "select.all", json!({}));
let r = run(&mut s, "edit.copy", json!({}));
let rtf = r["rtf"].as_str().unwrap_or_default().to_string();
assert!(rtf.starts_with("{\\rtf1"), "{r}");
assert_eq!(s.clipboard_rtf, rtf);
let d = wordcraft_formats::rtf::import(rtf.as_bytes()).unwrap();
let paras: Vec<String> =
d.body.iter().filter_map(|b| if let wordcraft_doc::Block::Para(p) = &**b { Some(p.text.clone()) } else { None }).collect();
assert_eq!(paras.get(..2), Some(&["plain bold".to_string(), "some italic".to_string()][..]), "{paras:?}");
assert_eq!(emphasis_at(&d, 0, 0), (false, false));
assert_eq!(emphasis_at(&d, 0, 7), (true, false));
assert_eq!(emphasis_at(&d, 1, 7), (false, true));
// Pasting it elsewhere keeps the formatting.
let mut t = Session::new(wordcraft_doc::Document::new());
run(&mut t, "edit.paste", json!({"rtf": rtf}));
assert!(text(&t).starts_with("plain bold\nsome italic"), "{}", text(&t));
assert_eq!(emphasis_at(&t.doc, 0, 7), (true, false));
assert_eq!(emphasis_at(&t.doc, 1, 7), (false, true));
// Cut carries it too.
run(&mut s, "select.range", json!({"anchor": {"block": 0, "off": 6}, "focus": {"block": 0, "off": 10}}));
let r = run(&mut s, "edit.cut", json!({}));
assert!(r["rtf"].as_str().is_some_and(|t| t.starts_with("{\\rtf1") && t.contains("bold")), "{r}");
assert_eq!(text(&s).lines().next(), Some("plain "));
// Nothing selected: nothing copied, no RTF.
run(&mut s, "caret.docEnd", json!({}));
let r = run(&mut s, "edit.copy", json!({}));
assert!(r.get("rtf").is_none(), "{r}");
}
#[test]
fn pasting_hostile_rtf_never_panics() {
let deep = format!("{{\\rtf1 {}x{}", "{\\b ".repeat(5000), "}".repeat(5000));
let junk = ["{\\rtf1", "{\\rtf1 {\\b bo", "{\\rtf1\\ansi\\u-99999999999? \\bin999999999 x}", "{\\rtf1 \\'zz\\fonttbl{", "{\\rtf\u{0}\u{FFFF}}"];
for rtf in junk.iter().map(|j| j.to_string()).chain([deep]) {
for id in ["edit.paste", "edit.pasteMerge", "edit.pasteSpecial"] {
let mut t = Session::new(wordcraft_doc::Document::new());
// `Session::run` turns a panic into an error: look for it.
for v in [json!({"rtf": rtf, "as": "rtf"}), json!({"rtf": rtf, "text": "fallback"})] {
if let Err(e) = t.run(id, &v) {
assert!(!e.to_string().contains("panicked"), "{id}: {e}");
}
let _ = t.layout();
}
}
}
}
#[test]
fn copying_symbol_font_text_puts_the_unicode_symbols_on_the_clipboard() {
let mut s = s();
+7 -6
View File
@@ -63,12 +63,13 @@ pub struct Services {
/// 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: put HTML on the system clipboard with its plain-text alternative (html, text, and
/// for a copy of one equation alone its MathML, else empty), so other programs keep the
/// formatting (#1523) and equations (#816). The MathML goes in the platform's MathML formats
/// as well, which Word reads a pasted equation from (#1297). `true` when the HTML and text are
/// there; otherwise the copy's text goes through egui as usual.
pub set_clipboard_html: Option<Box<dyn Fn(&str, &str, &str) -> bool>>,
/// Desktop: put a copy on the system clipboard as (text, html, rtf, mathml): its text, with
/// its HTML (equations as MathML, #816) and RTF beside it where the platform takes them, so
/// other programs keep the formatting (#1523); an empty one isn't offered. `mathml` is a copy
/// of one equation alone (else empty), for the platform's MathML formats, which Word reads a
/// pasted equation from (#1297). `true` when the text and HTML are there; otherwise the copy's
/// text goes through egui as usual.
pub set_clipboard_rich: Option<Box<dyn Fn(&str, &str, &str, &str) -> bool>>,
/// 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
+14 -12
View File
@@ -67,11 +67,11 @@ pub(crate) fn fill(app: &mut WordApp, id: &str, params: &mut Value) -> Option<Re
None
}
/// Put a copy on the system clipboard: its HTML beside the text (the formatting, #1523, and
/// equations as MathML, #816; a lone equation in the MathML formats too, #1297) when the host can
/// set it, else the text through egui. egui sets its text at the end of the frame, which would
/// replace the HTML, so only one of them runs.
pub(crate) fn put(app: &WordApp, ctx: &egui::Context, text: &str, html: &str, mathml: &str) {
/// Put a copy on the system clipboard: its HTML and RTF beside the text (the formatting, #1523,
/// and equations as MathML, #816; a lone equation in the MathML formats too, #1297) when the host
/// can set them, else the text through egui. egui sets its text at the end of the frame, which
/// would replace the rest, so only one of them runs.
pub(crate) fn put(app: &WordApp, ctx: &egui::Context, text: &str, html: &str, rtf: &str, mathml: &str) {
if text.is_empty() {
// A copied picture or shape has no text, only HTML (#2276): empty the clipboard's text,
// else Paste would find the text copied before it and paste that instead of our copy.
@@ -80,7 +80,7 @@ pub(crate) fn put(app: &WordApp, ctx: &egui::Context, text: &str, html: &str, ma
}
return;
}
if !html.is_empty() && app.services.set_clipboard_html.as_ref().is_some_and(|f| f(html, text, mathml)) {
if !(html.is_empty() && rtf.is_empty()) && app.services.set_clipboard_rich.as_ref().is_some_and(|f| f(text, html, rtf, mathml)) {
return;
}
ctx.copy_text(text.to_string());
@@ -89,7 +89,7 @@ pub(crate) fn put(app: &WordApp, ctx: &egui::Context, text: &str, html: &str, ma
/// Put what Copy or Cut returned on the system clipboard (see [`put`]).
pub(crate) fn put_result(app: &WordApp, ctx: &egui::Context, r: &Value) {
let s = |k: &str| r.get(k).and_then(Value::as_str).unwrap_or("");
put(app, ctx, s("text"), s("html"), s("mathml"));
put(app, ctx, s("text"), s("html"), s("rtf"), s("mathml"));
}
/// Format names worth showing: not the clipboard protocol's own bookkeeping targets.
@@ -249,12 +249,12 @@ mod tests {
#[test]
fn copying_an_equation_puts_its_html_on_the_system_clipboard() {
// #816: the host gets HTML with the equation as MathML beside the text.
let got = std::rc::Rc::new(std::cell::RefCell::new(Vec::<(String, String, String)>::new()));
// #816: the host gets HTML with the equation as MathML beside the text, and RTF.
let got = std::rc::Rc::new(std::cell::RefCell::new(Vec::<(String, String, String, String)>::new()));
let sink = got.clone();
let mut a = app(Services {
set_clipboard_html: Some(Box::new(move |h, t, m| {
sink.borrow_mut().push((h.to_string(), t.to_string(), m.to_string()));
set_clipboard_rich: Some(Box::new(move |t, h, r, m| {
sink.borrow_mut().push((h.to_string(), t.to_string(), m.to_string(), r.to_string()));
true
})),
..Default::default()
@@ -271,7 +271,8 @@ mod tests {
assert!(got_now[0].0.contains("<math") && got_now[0].1.contains("y=2x_s"), "{got_now:?}");
// With text around it, no MathML formats: Word would paste the equation alone (#1297).
assert!(got_now[0].2.is_empty(), "{got_now:?}");
// Text without an equation gets HTML too, so other programs keep its formatting (#1523).
// Text without an equation gets HTML too, so other programs keep its formatting (#1523),
// and RTF for the programs that read that (gap-clipboard-rtf).
a.run("select.range", json!({"anchor": {"block": 0, "off": 0}, "focus": {"block": 0, "off": 5}})).unwrap();
let r = a.run("edit.copy", json!({})).unwrap();
put_result(&a, &ctx, &r);
@@ -279,6 +280,7 @@ mod tests {
assert_eq!(got_now.len(), 2);
assert!(got_now[1].0.contains("Speed") && !got_now[1].0.contains("<math") && got_now[1].1 == "Speed", "{got_now:?}");
assert!(got_now[1].2.is_empty(), "{got_now:?}");
assert!(got_now[1].3.starts_with("{\\rtf1") && got_now[1].3.contains("Speed"), "{got_now:?}");
// The equation alone: its MathML for the platform's MathML formats too (#1297).
a.run("select.range", json!({"anchor": {"block": 0, "off": 6}, "focus": {"block": 0, "off": 9}})).unwrap();
let r = a.run("edit.copy", json!({})).unwrap();
+2
View File
@@ -66,6 +66,7 @@ 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` | 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]). Table cell shading from every SHD record (`sprmTDefTableShd`, `sprmTDefTableShdRaw` and their 2nd/3rd ranges, with the Word 97 `Shd80` as fallback; a clear pattern shows its background colour); the row-end mark is not a cell, and each table has its own grid and properties (#1380). Horizontally merged rows stored as fewer, wider cells come with #1123. Freeform shapes, z-order and Word 6/95 files are dropped or refused. Password-protected files (XOR obfuscation, RC4, RC4 CryptoAPI) open after a password prompt, from the public specs; not yet tried on a file Word wrote (AES-encrypted ones are refused). No writer. DIB conversion caps BMP bytes (header included) and encoded PNG at the supplied BLIP byte limit, and uses it as the decoder's allocation limit; a BMP fallback is kept if it fits (related #1334 and #1554). Hostile files are bounded: a sector table longer than the file is refused before parsing (#3642); formatting and link runs are read once per change, not per character (#3692, #3708); copies of alt text, link targets and drawn objects share a 64 MB budget (#3709, #3710), and what it leaves out is logged once a kind per document; picture decoding shares a 512 MB budget and aliased store entries decode once (#3711, #3712). The default fonts are the stylesheet's standard fonts (`rgftcStandardChpStsh` and `ftcBi` in the `Stshi`, [MS-DOC] §2.9.273): Aptos in a Word 365 file, whose font table still starts with Times New Roman; a header too short for them, or an index past the font table, keeps the table's first font (#5066). Runs' East Asian and complex-script fonts (`sprmCRgFtc1`, `sprmCFtcBi`) are read. |
| PDF export | 8% | W | — | ✅ | ~80% | 6–10 | `crates/pdf/src/tests.rs` | krilla: embedded subsets, selectable text incl. field results (#133); Hebrew and Arabic copy and search as whole words (right-to-left glyphs written in visual order, #1383, also with fonts that draw lam-alef as two glyphs at one x, #2310); All Caps and Small Caps text extracts as the capitals shown, as from Word's PDFs, and never changes the case of other text (#1473), links, outline, tagging. Outline destinations follow leading page breaks to the heading text. 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. Horizontal and vertical lines inside groups are drawn (#1474). 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. 8-bit text in the document's code page (`\ansicpg`) or the current font's (`\fcharset`, `\cpg`): Cyrillic, Central European, Greek, Turkish, Hebrew, Arabic, Baltic, Vietnamese, Thai (was Windows-1252 only), Mac OS Roman (`\mac`, `\fcharset77`) and DOS code pages (`\pc` 437, `\pca` 850, `\cpg` 437/737/850/852/857/866, `\fcharset254`) (#1653). East Asian multibyte code pages decoded as runs: 932 Shift-JIS (`\fcharset128`, Japanese), 936 GBK (134, Simplified Chinese), 949 UHC/windows-949 (129, Korean) and 950 Big5 (136, Traditional Chinese) through `\ansicpg`, `\cpg` or the font's charset, escaped or raw, a lead byte and its trail byte together; invalid sequences read as U+FFFD, and a run that starts `EF BB BF` is text, not a BOM (#1653). Pasted from the system clipboard where the platform offers it (Windows, macOS, Wayland, X11) when there is no HTML (#687). Tracked insertions and deletions read and written (`\revtbl`, `\revised`/`\deleted`, authors, `\revdttm` dates), so Accept/Reject All work after opening or a round trip; tracked paragraph marks (`\par` inside `\revised`/`\deleted` groups, including a `\sect` inside a deletion) and character and paragraph formatting revisions (`\crauth`/`\crdate` with `\oldcprops`, `\prauth`/`\prdate` with `\oldpprops`) read and written; section and table formatting revisions (`\oldsprops`, `\oldtprops`) not yet. Sections on import (#1378): each `\sect` ends a section with its own page size and orientation, margins, header/footer distance, columns, break type, page-number restart, start and format, title page and headers/footers (those it leaves out link to the previous); export writes every section too (break type, page size and orientation, margins, header/footer distance, columns, page numbering, title page; not headers/footers). On the system clipboard: Copy and Cut write RTF (Windows, macOS, Wayland; not X11), except for picture- and shape-only copies, which keep to WordCraft's own copy, and Paste reads it on all four. Thin: comments, notes, 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; Windows and Enhanced Metafiles, `\wmetafile` and `\emfblip`, read and written, #4259), fields, bookmarks. 8-bit text in the document's code page (`\ansicpg`) or the current font's (`\fcharset`, `\cpg`): Cyrillic, Central European, Greek, Turkish, Hebrew, Arabic, Baltic, Vietnamese, Thai (was Windows-1252 only), Mac OS Roman (`\mac`, `\fcharset77`) and DOS code pages (`\pc` 437, `\pca` 850, `\cpg` 437/737/850/852/857/866, `\fcharset254`) (#1653). East Asian multibyte code pages decoded as runs: 932 Shift-JIS (`\fcharset128`, Japanese), 936 GBK (134, Simplified Chinese), 949 UHC/windows-949 (129, Korean) and 950 Big5 (136, Traditional Chinese) through `\ansicpg`, `\cpg` or the font's charset, escaped or raw, a lead byte and its trail byte together; invalid sequences read as U+FFFD, and a run that starts `EF BB BF` is text, not a BOM (#1653). Pasted from the system clipboard where the platform offers it (Windows, Wayland) when there is no HTML (#687). Tracked insertions and deletions read and written (`\revtbl`, `\revised`/`\deleted`, authors, `\revdttm` dates), so Accept/Reject All work after opening or a round trip; tracked paragraph marks (`\par` inside `\revised`/`\deleted` groups, including a `\sect` inside a deletion) and character and paragraph formatting revisions (`\crauth`/`\crdate` with `\oldcprops`, `\prauth`/`\prdate` with `\oldpprops`) read and written; section and table formatting revisions (`\oldsprops`, `\oldtprops`) not yet. Sections on import (#1378): each `\sect` ends a section with its own page size and orientation, margins, header/footer distance, columns, break type, page-number restart, start and format, title page and headers/footers (those it leaves out link to the previous); export writes every section too (break type, page size and orientation, margins, header/footer distance, columns, page numbering, title page; not headers/footers). Thin: comments, notes, RTL |
| `.odt` OpenDocument Text (and flat `.fodt`, templates `.ott`) | 2% | R/W | 🟡 | 🟡 | ~90% | 20–35 | `crates/formats/src/tests.rs`, `crates/formats/src/odt/tests.rs` and one file per feature in `crates/formats/src/odt/tests_more/`, `crates/engine/tests/it/odt_sections.rs`, `odt_toc.rs`, `odt_fields.rs`, `odt_charts.rs`, `odt_objects.rs`, `odt_password.rs`, `odt_forms.rs`; checked against LibreOffice's own documents (round trips compared page by page) and the ODF 1.3 schema; what the standard leaves open is WordCraft's one choice for each case ([odf-interpretation.md](odf-interpretation.md)) | Ruby (`text:ruby`, §6.4: a base text with ruby text over it) is read as WordCraft's Phonetic Guide and written back as ODF ruby above its base, centred (one inside a tracked change is written as its base text, which the change marks); its own position and alignment aren't kept. A border around text (LibreOffice's `loext:border` or its sides, with `loext:padding`: ODF 1.3 has no attribute for it) is Word's text border, one line all round, and is written back so (a document with one is then extended ODF, not strict). Emphasis marks (`style:text-emphasize`) and rotated text (`style:text-rotation-angle`) aren't read. How text flows: a page break after a paragraph (`fo:break-after`, a page break before the next paragraph as Word has it), lines kept together (`fo:keep-together`), widow and orphan control (`fo:widows`/`fo:orphans`: on with any line count, off with none; the counts themselves aren't kept) and hyphenation (`fo:hyphenate`, a paragraph's in ODF and the document's in WordCraft: a document with a hyphenated paragraph hyphenates, its other paragraphs opting out) are read and written; a table's alignment and width (`table:align`, `style:rel-width`, or `style:width` for an aligned table) and its rows' heights (`style:row-height` exact, `style:min-row-height` at least) are read and written. Form controls (`office:forms` controls placed by `draw:control`, ODF 1.3 §13) open as content controls — text fields and text areas as plain text controls, check boxes as check boxes, radio buttons as check boxes with round symbols, list boxes as drop-down lists, combo boxes as combo boxes, date fields as date controls, each with its value, entries and selection, its name as the control's tag and a label as its title — and content controls of those kinds in the body are written back as form controls LibreOffice shows with their values (in headers and footers, as their text); a control keeps its size (`draw:control` `svg:width`/`svg:height`); buttons, image buttons, labels and the other controls WordCraft has no kind for show their label, locked, and are kept verbatim and written back as they were (one naming a namespace WordCraft doesn't declare is left out); hidden controls (`form:hidden`) are kept and written back with their form; a control's look (colours, font) isn't kept. Placeholders (`text:placeholder`, §7.7.2, as LibreOffice's templates use: "<Recipient's name>") open as plain text content controls showing them, so a click selects one and typing replaces it, and are written back as placeholders of their type (their description as the control's title) while they show it. A master page followed by another (`style:next-style-name`: LibreOffice's "First Page" before "Default") is a section with a different first page, its own headers and footers on the first page and the next one's after it. The default paragraph style's font size (`fo:font-size` in `style:default-style`, or the Standard style's: the document's defaults) is the document's default size (it stayed 12 pt), a style with no parent keeping the default style's, and a date style ordered by the locale (`number:automatic-order`) shows in US English order ("November 2, 2026", as LibreOffice shows it here). A page background picture (`style:background-image` in the page layout, §17.3) is WordCraft's page picture (Design › Page Color › Fill Effects): repeated as tiled, stretched as stretched, not repeated as centred at its own size (a fill WordCraft draws so; DOCX writes it stretched), and is written back with its picture. Tables are fixed layout (Word's `w:tblLayout`), as LibreOffice's columns never fit their content, and a table starting a page (`fo:break-before` on the table) starts it: its first paragraph has a page break before it, which moves the table, and is written back on the table. The columns of a table filling the margins are written with their share of the table (`style:rel-column-width`) too, so on a narrower page they keep their proportions in LibreOffice (an aligned table's aren't: LibreOffice lays one out oddly with shares). A fixed date or time with neither a value nor text (LibreOffice's templates) is a date field showing the day; `text:author-name` is `USERNAME`, which WordCraft now fills with the user's name. A page layout that states no size is the default page's (the user's region's, as LibreOffice uses its default paper), and a maLine truncated
| `.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). A Word or RTF extension that contradicts the content also opens by signature, as in Word: RTF saved as `.doc` (hh.ru résumés, accounting exports), a `.docx` renamed `.doc`, a Word 97-2003 file renamed `.docx`; a Word XML Document (Word 2003 XML or Flat OPC) or a web page (`<html>`, `<!DOCTYPE html>`) or a Single File Web Page (MHT) under a `.doc`, `.docx` or `.rtf` name opens as what it is (#2853, `tests::word_xml_and_web_pages_saved_as_doc_open_by_content`, `tests::a_single_file_web_page_saved_as_doc_opens_by_content`). Word's encoding choice dialog and line-ending options missing; such files save only through Save As |
@@ -426,6 +427,7 @@ Table rows and cells inside `mc:AlternateContent` use the same selected-branch p
| 2026-10-31 | trivial | ODT: headers and footers are written to each section's master page (every, left and first page), with Word's header and footer distances converted to ODF's header and footer bands and back on open; text fields (`text:page-number`, `text:page-count`, `text:date`, `text:time`, document information, `text:bookmark-ref`) open as WordCraft fields and are written back as ODF fields. `formats::odt::tests_more::{headers, fields}` |
| 2026-10-31 | trivial | ODT: paragraph spacing, indents and line spacing (`fo:margin-*`, `fo:text-indent`, `fo:line-height`, `style:line-height-at-least`; `style:line-spacing` leading as a proportional approximation) read from automatic and common styles and written back; the default and Standard paragraph styles give the document defaults, so an ODT paragraph without spacing has none instead of Word's 8 pt after and 1.15 lines. `formats::odt::tests_more::spacing` |
| 2026-10-28 | trivial | Print Markup text-copy comment discovery walks paragraph objects once (#3614); quotes, order, limits and readiness estimates unchanged |
| 2026-10-17 | trivial | RTF on the system clipboard: Copy and Cut write the copy as RTF beside its text and HTML (Windows, macOS, Wayland; not X11), and Paste reads RTF on macOS and X11 too |
| 2026-11-03 | trivial | Replace Table of Contents keeps Word's title above the section break over entries in columns, and Remove takes the title, the blank paragraph and its break with the table, as observed in Word for Mac (#4041) |
| 2026-11-02 | minor | `w:altChunk` content (MHT, HTML, RTF, text, nested documents) brought in on open, where it stands; `.mht`/`.mhtml` open; HTML reads `style` element rules, paragraph margins, indents and line height, cell widths and borders, and an opened page takes Word's web-page defaults. Insert › Text from File keeps the file's pictures and lists |
| 2026-11-02 | trivial | DOCX: a VML horizontal rule (`v:rect o:hr`, a web page's `<hr>` pasted into Word) is drawn across its line as Word draws it — the whole width inside the paragraph's indents, or `o:hrpct` of it, or its own width, placed by `o:hralign` — instead of as a 1 pt dot; in a table cell it follows the column; it saves back as the same VML rule |
+8 -5
View File
@@ -116,11 +116,13 @@ The same gaps grouped the way the parity documents are, for agents working in on
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);
- **UI/UX gaps:** #7, #18, #24; context menus for objects (the table's landed; it still lacks Border Styles); Linux title bar theming (#78); File ›
still missing: the Paste Options button after a paste, and RTF on macOS and X11. Copy puts
HTML beside the text on the clipboard, so other programs keep the formatting (#1523), with
equations as MathML (#816), and a lone equation also in the MathML clipboard formats on
Windows and macOS (#1297; pasting into Word/LibreOffice not yet verified); RTF on the
clipboard is not written yet.
still missing: the Paste Options button after a paste. Copy puts HTML and RTF beside the text
on the clipboard, so other programs keep the formatting (#1523), with equations as MathML
(#816), and a lone equation also in the MathML clipboard formats on Windows and macOS (#1297;
pasting into Word/LibreOffice not yet verified); RTF is written on Windows, macOS and Wayland,
while an X11 copy still carries text and HTML only (arboard owns the selection there); a
copy of only pictures or shapes carries neither, so WordCraft pastes its own copy. Paste
reads RTF on all four.
Options › Animation effects › Use system setting reads macOS (Reduce motion) and the Linux/BSD
desktops (`apps/wordcraft/src/animations.rs`) but hasn't been tried on a Mac or in a real
GNOME or KDE session yet.
@@ -177,6 +179,7 @@ Table rows and cells inside `mc:AlternateContent` use the same selected-branch p
| 2026-10-29 | trivial | Print Markup balloon anchor discovery no longer repeats paragraph-prefix scans (#3614); readiness estimates unchanged |
| 2026-10-28 | trivial | Print Markup text-copy comment discovery walks paragraph objects once (#3614); quotes, order, limits and readiness estimates unchanged |
| 2026-10-30 | trivial | #20 narrowed: layout resource caps and linear-time passes (#3867, #3866) |
| 2026-10-17 | trivial | UI/UX gaps: copies put RTF on the clipboard (Windows, macOS, Wayland; not X11), and paste reads RTF on macOS and X11 too |
| 2026-11-03 | minor | #11: Arabic interface with mirrored shell (#1195); key languages missing are now hi and id (plus pt-PT); Hebrew UI still open |
| 2026-11-03 | trivial | #8 (CP4.3): one language list: Set Proofing Language, Review › Language and the status bar offer the Language pack languages (Bulgarian was in none); every published pack named in English; Valencian is `ca-ES-valencia`; gap 8 names built-in proofing |
| 2026-11-03 | trivial | #8 (CP4.3): the missing-pack bar offers a language before the list of packs is fetched (a fresh install, or a list cached for an older pin) and gets it on Install; with downloading on, a missing or stale list is fetched once in the background; the caret's typing language counts; the spelling context menu's heading is in the interface language |
+1
View File
@@ -97,6 +97,7 @@ Right-click and Shift+F10 keep the compact suggestions menu. The pane is shared
| Date | Change | Summary |
|---|---|---|
| 2026-10-31 | trivial | Dialogs are modal (#3859): while one is open, clicks on the ribbon, File or the page, typing and shortcuts do nothing, as in Word. Find and Replace, Symbol, Mark Index Entry, Cross-reference and Insert Merge Field stay modeless, as in Word for Mac. A press beside a modal dialog shakes it a little, or flashes its border with animation effects off, so it is clear why nothing behind it reacts |
| 2026-10-17 | trivial | Copy and Cut put RTF beside the text and HTML on the system clipboard, for programs that read RTF rather than HTML (Windows "Rich Text Format", macOS `public.rtf`, Wayland `text/rtf`; an X11 copy stays text and HTML, since arboard owns the selection there; a copy of only pictures or shapes carries no HTML or RTF, so pasting it back keeps every detail); Paste reads another program's RTF on macOS and X11 too, as on Windows and Wayland |
| 2026-11-03 | trivial | Comments: a long comment's card, balloon and fields stop at ten lines; the fields scroll, and a balloon that isn't selected shows the first ten lines of the comment and of each reply (#3382) |
| 2026-11-03 | minor | Arabic mirrored interface: title bar, ribbon, panes, File page, dialogs and menus run from the right; document canvas stays independent (#1195); Hebrew UI still open |
| 2026-11-03 | minor | File › Options › Language: Your languages (English variants in use, installed Language packs, the document's languages) with each one's proofing status, Install, Remove and Set as default; Add a language… opens a searchable picker (dutch, nederlands, nl) sorted by name; the Dictionaries folder and the Dictionary files list moved here from Proofing, which has no Dictionaries section any more (CP4.3) |