//! Paste Special: paste the clipboard in a chosen format (formatted, RTF, HTML, picture, //! unformatted), and the formatted content Paste and Merge Formatting take from other programs. //! //! The clipboard can hold several representations of the same content. WordCraft's own copy keeps //! a rich fragment; the system clipboard gives text, and callers (agents, the CLI, a front end //! with a richer clipboard) may pass `html`, `rtf` or a `picture` too. Text that is itself RTF or //! HTML source can also be pasted as that format. Paste keeps the source formatting of another //! program's HTML or RTF; Merge Formatting keeps only its emphasis (#687). //! Plain text has no formatting to keep, so Paste inserts it as Keep Text Only does. use std::collections::BTreeMap; use std::sync::Arc; use serde_json::{Value, json}; use wordcraft_doc::edit::Fragment; use wordcraft_doc::{Block, Document, Paragraph}; use super::{delete_selection, sel_result}; use crate::{CmdError, CmdResult, CommandSpec, Selection, Session, p}; /// Largest clipboard payload Paste Special accepts, in bytes. pub const MAX_PASTE_BYTES: usize = 16 << 20; /// Largest clipboard picture Paste Special accepts (base64), in bytes. pub const MAX_PICTURE_BYTES: usize = 96 << 20; /// Nesting depth we follow when adjusting pasted tables. const MAX_DEPTH: usize = 64; /// The formats, in the order the dialog lists them: (id, label). pub const FORMATS: [(&str, &str); 5] = [ ("formatted", "Formatted Text"), ("rtf", "Rich Text Format (RTF)"), ("html", "HTML Format"), ("picture", "Picture"), ("text", "Unformatted Text"), ]; pub fn specs() -> Vec { vec![ CommandSpec::new("edit.pasteSpecial", "Paste Special", "Home › Clipboard › Paste", paste_special) .key("Mod+Alt+V") .params(r#"{"as"?: "formatted"|"rtf"|"html"|"picture"|"text", "text"?: string, "html"?: string, "rtf"?: string, "picture"?: base64}"#), ] } /// Clipboard text from the params (system clipboard), else WordCraft's own copy. fn clip_text(s: &Session, v: &Value) -> Option { match p::str(v, "text") { Some(t) => Some(t.replace("\r\n", "\n")), None => s.clipboard.as_ref().map(Fragment::plain_text), } } fn looks_like_rtf(t: &str) -> bool { t.trim_start().starts_with("{\\rtf") } fn looks_like_html(t: &str) -> bool { let t = t.trim_start(); t.starts_with('<') && (t.contains("")) } /// The source for a format, if the clipboard has it. fn source(s: &Session, v: &Value, fmt: &str) -> Option { let non_empty = |t: String| if t.is_empty() { None } else { Some(t) }; match fmt { "formatted" => { let f = s.clipboard.as_ref()?; // System clipboard text that isn't our own copy means someone else copied since. match p::str(v, "text") { Some(t) if f.clipboard_text() != t.replace("\r\n", "\n") => None, _ => Some(String::new()), } } "rtf" => p::str(v, "rtf").map(str::to_string).or_else(|| clip_text(s, v).filter(|t| looks_like_rtf(t))).and_then(non_empty), "html" => p::str(v, "html").map(str::to_string).or_else(|| clip_text(s, v).filter(|t| looks_like_html(t))).and_then(non_empty), "picture" => p::str(v, "picture").map(str::to_string).and_then(non_empty), "text" => clip_text(s, v).and_then(non_empty), _ => None, } } /// Formats the clipboard can be pasted as, best first. pub fn available(s: &Session, v: &Value) -> Vec<&'static str> { FORMATS.iter().map(|(id, _)| *id).filter(|id| source(s, v, id).is_some()).collect() } fn formats_json(ids: &[&str]) -> Value { json!(ids.iter().map(|id| json!({"id": id, "label": FORMATS.iter().find(|(f, _)| f == id).map(|(_, l)| *l).unwrap_or(*id)})).collect::>()) } fn paste_special(s: &mut Session, v: &Value) -> CmdResult { for k in ["text", "html", "rtf"] { if p::str(v, k).is_some_and(|t| t.len() > MAX_PASTE_BYTES) { return Err(CmdError::Params(format!("`{k}` is larger than {} MB", MAX_PASTE_BYTES >> 20))); } } if p::str(v, "picture").is_some_and(|t| t.len() > MAX_PICTURE_BYTES) { return Err(CmdError::Params(format!("`picture` is larger than {} MB", MAX_PICTURE_BYTES >> 20))); } let ids = available(s, v); let Some(fmt) = p::str(v, "as") else { // No format chosen: list them and ask a front end (if any) to show the dialog. let mut req = json!({"open": "pasteSpecial", "formats": ids}); for k in ["text", "html", "rtf", "picture"] { if let Some(t) = p::str(v, k) { req[k] = json!(t); } } // Only the newest dialog request matters; older ones would pin their payloads. s.ui_requests.retain(|r| r["open"] != "pasteSpecial"); s.push_ui_request(req); return Ok(json!({"formats": formats_json(&ids)})); }; let fmt = match fmt.to_ascii_lowercase().as_str() { "formatted" | "keep" | "wordcraft" => "formatted", "rtf" => "rtf", "html" => "html", "picture" | "png" | "image" => "picture", "text" | "unformatted" | "plain" => "text", other => return Err(CmdError::Params(format!("unknown format `{other}` (formatted, rtf, html, picture or text)"))), }; let Some(src) = source(s, v, fmt) else { return Err(CmdError::Failed(if ids.is_empty() { "the clipboard is empty".into() } else { format!("the clipboard has no {fmt} content (available: {})", ids.join(", ")) })); }; let frag = match fmt { "text" => { super::paste_text(s, &src.replace("\r\n", "\r").replace('\n', "\r"))?; let mut r = sel_result(s)?; r["pastedAs"] = json!(fmt); return Ok(r); } "picture" => { let mut r = super::insert::picture(s, &json!({"data": src}))?; r["pastedAs"] = json!(fmt); return Ok(r); } "formatted" => s.clipboard.clone().ok_or_else(|| CmdError::Failed("the clipboard is empty".into()))?, "html" => adopt(s, import_html(&src)), _ => adopt(s, wordcraft_formats::rtf::import(src.as_bytes()).map_err(|e| CmdError::Failed(format!("bad RTF: {e}")))?), }; insert(s, frag)?; let mut r = sel_result(s)?; r["pastedAs"] = json!(fmt); Ok(r) } /// Indent each side of a pasted `
` whose page gives it no margins, points. const BLOCKQUOTE_INDENT: f32 = 36.0; /// Another program's HTML clipboard payload as a document. A `
` there is how /// LibreOffice (and many web pages) write a paragraph indented on both sides, not a quotation /// (#1270): it pastes as a Normal paragraph indented by the page's `blockquote` margins, else half /// an inch each side, rather than in the (centred, italic) Quote style. Opening an .html file keeps /// `
` as Quote, so HTML round-trips. fn import_html(html: &str) -> Document { let mut doc = wordcraft_formats::html::import(html_fragment(html)); let (left, right) = blockquote_margins(html); for b in &mut doc.body { each_para(Arc::make_mut(b), 0, &mut |p| { if p.props.style.as_deref() == Some("Quote") { p.props.style = None; p.props.indent_left.get_or_insert(left); p.props.indent_right.get_or_insert(right); p.touch(); } }); } doc } /// The left and right margins (points) the payload's `