diff --git a/examples/set_html.rs b/examples/set_html.rs new file mode 100644 index 0000000..2662d31 --- /dev/null +++ b/examples/set_html.rs @@ -0,0 +1,19 @@ +use arboard::Clipboard; +use simple_logger::SimpleLogger; +use std::{thread, time::Duration}; + +fn main() { + SimpleLogger::new().init().unwrap(); + let mut ctx = Clipboard::new().unwrap(); + + let html = r#"

Hello, World!

+Lorem ipsum dolor sit amet,
+consectetur adipiscing elit."#; + + let alt_text = r#"Hello, World! +Lorem ipsum dolor sit amet, +consectetur adipiscing elit."#; + + ctx.set_html(html, Some(alt_text)).unwrap(); + thread::sleep(Duration::from_secs(5)); +} diff --git a/src/lib.rs b/src/lib.rs index 447e4f4..25c5864 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -60,6 +60,17 @@ impl Clipboard { self.set().text(text) } + /// Places the HTML as well as a plain-text alternative onto the clipboard. + /// + /// Any valid utf-8 string is accepted. + pub fn set_html<'a, T: Into>>( + &mut self, + html: T, + alt_text: Option, + ) -> Result<(), Error> { + self.set().html(html, alt_text) + } + /// Fetches image data from the clipboard, and returns the decoded pixels. /// /// Any image data placed on the clipboard with `set_image` will be possible read back, using @@ -142,6 +153,20 @@ impl Set<'_> { self.platform.text(text) } + /// Completes the "set" operation by placing HTML as well as a plain-text alternative onto the + /// clipboard. + /// + /// Any valid UTF-8 string is accepted. + pub fn html<'a, T: Into>>( + self, + html: T, + alt_text: Option, + ) -> Result<(), Error> { + let html = html.into(); + let alt_text = alt_text.map(|e| e.into()); + self.platform.html(html, alt_text) + } + /// Completes the "set" operation by placing an image onto the clipboard. /// /// The chosen output format, depending on the platform is the following: @@ -219,6 +244,27 @@ fn all_tests() { // confirm it is OK to clear when already empty. ctx.clear().unwrap(); } + { + let mut ctx = Clipboard::new().unwrap(); + let html = "hello world!"; + + ctx.set_html(html, None).unwrap(); + + match ctx.get_text() { + Ok(text) => assert!(text.is_empty()), + Err(Error::ContentNotAvailable) => {} + Err(e) => panic!("unexpected error: {}", e), + }; + } + { + let mut ctx = Clipboard::new().unwrap(); + + let html = "hello world!"; + let alt_text = "hello world!"; + + ctx.set_html(html, Some(alt_text)).unwrap(); + assert_eq!(ctx.get_text().unwrap(), alt_text); + } #[cfg(feature = "image-data")] { let mut ctx = Clipboard::new().unwrap(); diff --git a/src/platform/linux/mod.rs b/src/platform/linux/mod.rs index 47519fc..14dc9fe 100644 --- a/src/platform/linux/mod.rs +++ b/src/platform/linux/mod.rs @@ -159,6 +159,14 @@ impl<'clipboard> Set<'clipboard> { } } + pub(crate) fn html(self, html: Cow<'_, str>, alt: Option>) -> Result<(), Error> { + match self.clipboard { + Clipboard::X11(clipboard) => clipboard.set_html(html, alt, self.selection, self.wait), + #[cfg(feature = "wayland-data-control")] + Clipboard::WlDataControl(clipboard) => clipboard.set_html(html, alt, self.selection, self.wait), + } + } + #[cfg(feature = "image-data")] pub(crate) fn image(self, image: ImageData<'_>) -> Result<(), Error> { match self.clipboard { diff --git a/src/platform/linux/wayland.rs b/src/platform/linux/wayland.rs index 51051f0..d88a0fa 100644 --- a/src/platform/linux/wayland.rs +++ b/src/platform/linux/wayland.rs @@ -3,7 +3,7 @@ use std::convert::TryInto; use std::io::Read; use wl_clipboard_rs::{ - copy::{self, Error as CopyError, Options, Source}, + copy::{self, Error as CopyError, MimeSource, MimeType, Options, Source}, paste::{self, get_contents, Error as PasteError, Seat}, utils::is_primary_selection_supported, }; @@ -81,7 +81,6 @@ impl Clipboard { selection: LinuxClipboardKind, wait: bool, ) -> Result<(), Error> { - use wl_clipboard_rs::copy::MimeType; let mut opts = Options::new(); opts.foreground(wait); opts.clipboard(selection.try_into()?); @@ -93,6 +92,36 @@ impl Clipboard { Ok(()) } + pub(crate) fn set_html( + &self, + html: Cow<'_, str>, + alt: Option>, + selection: LinuxClipboardKind, + wait: bool, + ) -> Result<(), Error> { + let html_mime = MimeType::Specific(String::from("text/html")); + let mut opts = Options::new(); + opts.foreground(wait); + opts.clipboard(selection.try_into()?); + let html_source = Source::Bytes(html.into_owned().into_bytes().into_boxed_slice()); + match alt { + Some(alt_text) => { + let alt_source = + Source::Bytes(alt_text.into_owned().into_bytes().into_boxed_slice()); + opts.copy_multi(vec![ + MimeSource { source: alt_source, mime_type: MimeType::Text }, + MimeSource { source: html_source, mime_type: html_mime }, + ]) + } + None => opts.copy(html_source, html_mime), + } + .map_err(|e| match e { + CopyError::PrimarySelectionUnsupported => Error::ClipboardNotSupported, + other => into_unknown(other), + })?; + Ok(()) + } + #[cfg(feature = "image-data")] pub(crate) fn get_image( &mut self, @@ -140,8 +169,6 @@ impl Clipboard { selection: LinuxClipboardKind, wait: bool, ) -> Result<(), Error> { - use wl_clipboard_rs::copy::MimeType; - let image = encode_as_png(&image)?; let mut opts = Options::new(); opts.foreground(wait); diff --git a/src/platform/linux/x11.rs b/src/platform/linux/x11.rs index 59408c5..14c7865 100644 --- a/src/platform/linux/x11.rs +++ b/src/platform/linux/x11.rs @@ -77,6 +77,8 @@ x11rb::atom_manager! { TEXT, TEXT_MIME_UNKNOWN: b"text/plain", + HTML: b"text/html", + PNG_MIME: b"image/png", // This is just some random name for the property on our window, into which @@ -172,7 +174,7 @@ impl XContext { #[derive(Default)] struct Selection { - data: RwLock>, + data: RwLock>>, /// Mutex around nothing to use with the below condvar. mutex: Mutex<()>, /// A condvar that is notified when the contents of this clipboard are changed. @@ -213,7 +215,12 @@ impl Inner { }) } - fn write(&self, data: ClipboardData, selection: LinuxClipboardKind, wait: bool) -> Result<()> { + fn write( + &self, + data: Vec, + selection: LinuxClipboardKind, + wait: bool, + ) -> Result<()> { if self.serve_stopped.load(Ordering::Relaxed) { return Err(Error::Unknown { description: "The clipboard handler thread seems to have stopped. Logging messages may reveal the cause. (See the `log` crate.)".into() @@ -262,10 +269,12 @@ impl Inner { // if we are the current owner, we can get the current clipboard ourselves if self.is_owner(selection)? { let data = self.selection_of(selection).data.read(); - if let Some(data) = &*data { - for format in formats { - if *format == data.format { - return Ok(data.clone()); + if let Some(data_list) = &*data { + for data in data_list { + for format in formats { + if *format == data.format { + return Ok(data.clone()); + } } } } @@ -571,13 +580,15 @@ impl Inner { targets.push(self.atoms.TARGETS); targets.push(self.atoms.SAVE_TARGETS); let data = self.selection_of(selection).data.read(); - if let Some(data) = &*data { - targets.push(data.format); - if data.format == self.atoms.UTF8_STRING { - // When we are storing a UTF8 string, - // add all equivalent formats to the supported targets - targets.push(self.atoms.UTF8_MIME_0); - targets.push(self.atoms.UTF8_MIME_1); + if let Some(data_list) = &*data { + for data in data_list { + targets.push(data.format); + if data.format == self.atoms.UTF8_STRING { + // When we are storing a UTF8 string, + // add all equivalent formats to the supported targets + targets.push(self.atoms.UTF8_MIME_0); + targets.push(self.atoms.UTF8_MIME_1); + } } } self.server @@ -596,23 +607,24 @@ impl Inner { } else { trace!("Handling request for (probably) the clipboard contents."); let data = self.selection_of(selection).data.read(); - if let Some(data) = &*data { - if data.format == event.target { - self.server - .conn - .change_property8( - PropMode::REPLACE, - event.requestor, - event.property, - event.target, - &data.bytes, - ) - .map_err(into_unknown)?; - self.server.conn.flush().map_err(into_unknown)?; - success = true; - } else { - success = false - } + if let Some(data_list) = &*data { + success = match data_list.iter().find(|d| d.format == event.target) { + Some(data) => { + self.server + .conn + .change_property8( + PropMode::REPLACE, + event.requestor, + event.property, + event.target, + &data.bytes, + ) + .map_err(into_unknown)?; + self.server.conn.flush().map_err(into_unknown)?; + true + } + None => false, + }; } else { // This must mean that we lost ownership of the data // since the other side requested the selection. @@ -857,10 +869,31 @@ impl Clipboard { selection: LinuxClipboardKind, wait: bool, ) -> Result<()> { - let data = ClipboardData { + let data = vec![ClipboardData { bytes: message.into_owned().into_bytes(), format: self.inner.atoms.UTF8_STRING, - }; + }]; + self.inner.write(data, selection, wait) + } + + pub(crate) fn set_html( + &self, + html: Cow<'_, str>, + alt: Option>, + selection: LinuxClipboardKind, + wait: bool, + ) -> Result<()> { + let mut data = vec![]; + if let Some(alt_text) = alt { + data.push(ClipboardData { + bytes: alt_text.into_owned().into_bytes(), + format: self.inner.atoms.UTF8_STRING, + }); + } + data.push(ClipboardData { + bytes: html.into_owned().into_bytes(), + format: self.inner.atoms.HTML, + }); self.inner.write(data, selection, wait) } @@ -890,7 +923,7 @@ impl Clipboard { wait: bool, ) -> Result<()> { let encoded = encode_as_png(&image)?; - let data = ClipboardData { bytes: encoded, format: self.inner.atoms.PNG_MIME }; + let data = vec![ClipboardData { bytes: encoded, format: self.inner.atoms.PNG_MIME }]; self.inner.write(data, selection, wait) } } diff --git a/src/platform/osx.rs b/src/platform/osx.rs index d54bfa6..3ef0421 100644 --- a/src/platform/osx.rs +++ b/src/platform/osx.rs @@ -30,7 +30,10 @@ use std::borrow::Cow; // Required to bring NSPasteboard into the path of the class-resolver #[link(name = "AppKit", kind = "framework")] -extern "C" {} +extern "C" { + static NSPasteboardTypeHTML: *const Object; + static NSPasteboardTypeString: *const Object; +} static NSSTRING_CLASS: Lazy<&Class> = Lazy::new(|| Class::get("NSString").unwrap()); #[cfg(feature = "image-data")] @@ -268,6 +271,42 @@ impl<'clipboard> Set<'clipboard> { } } + pub(crate) fn html(self, html: Cow<'_, str>, alt: Option>) -> Result<(), Error> { + self.clipboard.clear(); + // Text goes to the clipboard as UTF-8 but may be interpreted as Windows Latin 1. + // This wrapping forces it to be interpreted as UTF-8. + // + // See: + // https://bugzilla.mozilla.org/show_bug.cgi?id=466599 + // https://bugs.chromium.org/p/chromium/issues/detail?id=11957 + let html = format!( + r#" + + + + {} + "#, + html + ); + let html_nss = NSString::from_str(&html); + let mut success: bool = unsafe { + msg_send![self.clipboard.pasteboard, setString: html_nss forType:NSPasteboardTypeHTML] + }; + if success { + if let Some(alt_text) = alt { + let alt_nss = NSString::from_str(&alt_text); + success = unsafe { + msg_send![self.clipboard.pasteboard, setString: alt_nss forType:NSPasteboardTypeString] + }; + } + } + if success { + Ok(()) + } else { + Err(Error::Unknown { description: "NSPasteboard#writeObjects: returned false".into() }) + } + } + #[cfg(feature = "image-data")] pub(crate) fn image(self, data: ImageData) -> Result<(), Error> { let pixels = data.bytes.into(); diff --git a/src/platform/windows.rs b/src/platform/windows.rs index 70901b3..a85ff98 100644 --- a/src/platform/windows.rs +++ b/src/platform/windows.rs @@ -465,7 +465,39 @@ impl<'clipboard> Set<'clipboard> { clipboard_win::raw::set_string(&data).map_err(|_| Error::Unknown { description: "Could not place the specified text to the clipboard".into(), })?; + self.add_clipboard_exclusions()?; + Ok(()) + } + + pub(crate) fn html(self, html: Cow<'_, str>, alt: Option>) -> Result<(), Error> { + let alt = match alt { + Some(s) => s.into(), + None => String::new(), + }; + clipboard_win::raw::set_string(&alt).map_err(|_| Error::Unknown { + description: "Could not place the specified text to the clipboard".into(), + })?; + if let Some(format) = clipboard_win::register_format("HTML Format") { + let html = wrap_html(&html); + clipboard_win::raw::set_without_clear(format.get(), html.as_bytes()) + .map_err(|e| Error::Unknown { description: e.to_string() })?; + } + self.add_clipboard_exclusions()?; + Ok(()) + } + + #[cfg(feature = "image-data")] + pub(crate) fn image(self, image: ImageData) -> Result<(), Error> { + if let Err(e) = clipboard_win::raw::empty() { + return Err(Error::Unknown { + description: format!("Failed to empty the clipboard. Got error code: {}", e), + }); + }; + add_cf_dibv5(image) + } + + fn add_clipboard_exclusions(&self) -> Result<(), Error> { // Clipboard exclusions are applied retroactively to the item that is currently in the clipboard. // See the MS docs on `CLIPBOARD_EXCLUSION_DATA` for specifics. Once the item is added to the clipboard, // tell Windows to remove it from cloud syncing and history. @@ -494,17 +526,6 @@ impl<'clipboard> Set<'clipboard> { Ok(()) } - - #[cfg(feature = "image-data")] - pub(crate) fn image(self, image: ImageData) -> Result<(), Error> { - if let Err(e) = clipboard_win::raw::empty() { - return Err(Error::Unknown { - description: format!("Failed to empty the clipboard. Got error code: {}", e), - }); - }; - - add_cf_dibv5(image) - } } /// Windows-specific extensions to the [`Set`](super::Set) builder. @@ -549,6 +570,41 @@ impl<'clipboard> Clear<'clipboard> { } } +fn wrap_html(ctn: &str) -> String { + let h_version = "Version:0.9"; + let h_start_html = "\r\nStartHTML:"; + let h_end_html = "\r\nEndHTML:"; + let h_start_frag = "\r\nStartFragment:"; + let h_end_frag = "\r\nEndFragment:"; + let c_start_frag = "\r\n\r\n\r\n\r\n"; + let c_end_frag = "\r\n\r\n\r\n"; + let h_len = h_version.len() + + h_start_html.len() + + 10 + h_end_html.len() + + 10 + h_start_frag.len() + + 10 + h_end_frag.len() + + 10; + let n_start_html = h_len + 2; + let n_start_frag = h_len + c_start_frag.len(); + let n_end_frag = n_start_frag + ctn.len(); + let n_end_html = n_end_frag + c_end_frag.len(); + format!( + "{}{}{:010}{}{:010}{}{:010}{}{:010}{}{}{}", + h_version, + h_start_html, + n_start_html, + h_end_html, + n_end_html, + h_start_frag, + n_start_frag, + h_end_frag, + n_end_frag, + c_start_frag, + ctn, + c_end_frag, + ) +} + #[cfg(all(test, feature = "image-data"))] mod tests { use super::{rgba_to_win, win_to_rgba};