mirror of
https://github.com/storytold/wordcraft.git
synced 2026-11-09 20:00:11 -06:00
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:
co-authored by
Claude Opus 5.5
dkhlapov
parent
c21d5af2a6
commit
2fb91eece2
Generated
+1
@@ -6824,6 +6824,7 @@ dependencies = [
|
||||
"wordcraft-engine",
|
||||
"wordcraft-fonts",
|
||||
"wordcraft-ui-egui",
|
||||
"x11rb",
|
||||
"zbus",
|
||||
]
|
||||
|
||||
|
||||
@@ -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) |
|
||||
|
||||
@@ -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
@@ -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) {}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -244,6 +244,7 @@ impl Session {
|
||||
clipboard: _,
|
||||
clipboard_text: _,
|
||||
clipboard_html: _,
|
||||
clipboard_rtf: _,
|
||||
clip_history: _,
|
||||
registry: _,
|
||||
author: _,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -80,6 +80,7 @@ impl Session {
|
||||
pending_move: _,
|
||||
clipboard_text: _,
|
||||
clipboard_html: _,
|
||||
clipboard_rtf: _,
|
||||
clip_history: _,
|
||||
find: _,
|
||||
goal_x: _,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
File diff suppressed because one or more lines are too long
+8
-5
@@ -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 |
|
||||
|
||||
@@ -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) |
|
||||
|
||||
Reference in New Issue
Block a user