diff --git a/Cargo.lock b/Cargo.lock index 45ddd60ad3..e46eb7356f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6824,6 +6824,7 @@ dependencies = [ "wordcraft-engine", "wordcraft-fonts", "wordcraft-ui-egui", + "x11rb", "zbus", ] diff --git a/ROADMAP.md b/ROADMAP.md index 5436d56c0b..f9593f2c79 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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) | diff --git a/apps/wordcraft/Cargo.toml b/apps/wordcraft/Cargo.toml index f60cf295ef..f44ae42a57 100644 --- a/apps/wordcraft/Cargo.toml +++ b/apps/wordcraft/Cargo.toml @@ -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. diff --git a/apps/wordcraft/src/clipboard.rs b/apps/wordcraft/src/clipboard.rs index e22f89bae3..95ec1a35c0 100644 --- a/apps/wordcraft/src/clipboard.rs +++ b/apps/wordcraft/src/clipboard.rs @@ -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> = 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 { 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 { 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> { 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 { + conn.intern_atom(false, name.as_bytes()).ok()?.reply().ok().map(|r| r.atom) + } + + impl Reader { + fn new() -> Option { + 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 { + 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> { + 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 = + 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::>(); + 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 { Vec::new() } pub fn rtf() -> Option> { - 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> { @@ -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) {} } diff --git a/apps/wordcraft/src/main.rs b/apps/wordcraft/src/main.rs index ab4e5c57e7..4795bc8f08 100644 --- a/apps/wordcraft/src/main.rs +++ b/apps/wordcraft/src/main.rs @@ -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() diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index a8a26d14a0..277df7860c 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -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 diff --git a/crates/engine/src/session.rs b/crates/engine/src/session.rs index 86898641d4..e38ec91de6 100644 --- a/crates/engine/src/session.rs +++ b/crates/engine/src/session.rs @@ -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, diff --git a/crates/engine/src/session/documents.rs b/crates/engine/src/session/documents.rs index 4f7ce2f11e..7a6203056a 100644 --- a/crates/engine/src/session/documents.rs +++ b/crates/engine/src/session/documents.rs @@ -244,6 +244,7 @@ impl Session { clipboard: _, clipboard_text: _, clipboard_html: _, + clipboard_rtf: _, clip_history: _, registry: _, author: _, diff --git a/crates/engine/src/session/lifecycle.rs b/crates/engine/src/session/lifecycle.rs index ac94b45ff2..c1f55876a3 100644 --- a/crates/engine/src/session/lifecycle.rs +++ b/crates/engine/src/session/lifecycle.rs @@ -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, diff --git a/crates/engine/src/session/snapshot.rs b/crates/engine/src/session/snapshot.rs index ef08e94576..2d6531ad9e 100644 --- a/crates/engine/src/session/snapshot.rs +++ b/crates/engine/src/session/snapshot.rs @@ -80,6 +80,7 @@ impl Session { pending_move: _, clipboard_text: _, clipboard_html: _, + clipboard_rtf: _, clip_history: _, find: _, goal_x: _, diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 3111f1aaae..e13cf934b7 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -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 = + 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(); diff --git a/crates/ui-egui/src/services.rs b/crates/ui-egui/src/services.rs index b591fbc427..3ac6cb3806 100644 --- a/crates/ui-egui/src/services.rs +++ b/crates/ui-egui/src/services.rs @@ -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 Option>>, - /// 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 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 bool>>, /// Desktop: the RTF on the system clipboard, where the platform offers it (#687). pub clipboard_rtf: Option Option>>, /// Desktop: the names of the formats on the system clipboard (MIME types, Windows format diff --git a/crates/ui-egui/src/system_clipboard.rs b/crates/ui-egui/src/system_clipboard.rs index 893e1e017e..bbb9525f53 100644 --- a/crates/ui-egui/src/system_clipboard.rs +++ b/crates/ui-egui/src/system_clipboard.rs @@ -67,11 +67,11 @@ pub(crate) fn fill(app: &mut WordApp, id: &str, params: &mut Value) -> Option::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("