//! 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). 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); } } s.ui_requests.push(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, wordcraft_formats::html::import(html_fragment(&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) } /// The HTML itself from a Windows "HTML Format" (CF_HTML) clipboard payload — a `Version:` header /// giving byte offsets — or the text unchanged. Hostile offsets fall back to the text after the /// header. pub fn html_fragment(html: &str) -> &str { if !html.starts_with("Version:") { return html; } let offset = |key: &str| -> Option { let at = html.find(key)?.checked_add(key.len())?; let digits: &str = html.get(at..)?.split(['\r', '\n']).next()?; digits.trim().parse().ok() }; if let (Some(a), Some(b)) = (offset("StartFragment:"), offset("EndFragment:")) && let Some(frag) = html.get(a..b) { return frag; } html.find('<').and_then(|i| html.get(i..)).unwrap_or("") } /// Whether an imported document has anything to paste: text, a table, or a picture it carries. fn has_content(d: &Document) -> bool { d.body.iter().any(|b| match &**b { Block::Para(p) => !p.text.trim().is_empty() || (!p.objects.is_empty() && !d.media.is_empty()), Block::Table(_) => true, }) } /// Another program's formatted copy, from the system clipboard's HTML, else its RTF (#687), as a /// fragment of this document. `None` when neither is given, either is larger than /// [`MAX_PASTE_BYTES`], or it holds nothing to paste (the caller pastes the text then). pub fn rich_fragment(s: &mut Session, v: &Value) -> Option { let fits = |k: &str| p::str(v, k).filter(|t| !t.trim().is_empty() && t.len() <= MAX_PASTE_BYTES); let doc = fits("html").map(|html| wordcraft_formats::html::import(html_fragment(html))); let doc = match doc.filter(has_content) { Some(d) => d, None => { let rtf = fits("rtf").filter(|t| looks_like_rtf(t))?; wordcraft_formats::rtf::import(rtf.as_bytes()).ok().filter(has_content)? } }; Some(adopt(s, doc)) } /// What Paste inserts: WordCraft's own copy while the system clipboard still holds it, else /// another program's formatted copy (HTML, RTF), else the system clipboard's text. pub fn paste_fragment(s: &mut Session, v: &Value) -> Result { let text = p::str(v, "text").map(|t| t.replace("\r\n", "\n")); if let Some(f) = &s.clipboard && text.as_ref().is_none_or(|t| f.clipboard_text() == *t) && (text.is_some() || (p::str(v, "html").is_none() && p::str(v, "rtf").is_none())) { return Ok(f.clone()); } if let Some(f) = rich_fragment(s, v) { return Ok(f); } match text { Some(t) if !t.is_empty() => Ok(Fragment::from_text(&t)), _ => Err(CmdError::Failed("the clipboard is empty".into())), } } /// Merge Formatting (#687): the pasted text takes the formatting where it lands — the paragraph's /// and the caret's — keeping only its emphasis (bold, italic, underline, strikethrough). pub fn merge_formatting(s: &Session, frag: &mut Fragment) { let mut base = s.typing_props(); base.ins = None; base.del = None; base.fmt_change = None; let (start, _) = s.sel.ordered(); let para = s.doc.para_at(&start).map(|p| p.props.clone()).unwrap_or_default(); for b in &mut frag.blocks { each_para(b, 0, &mut |p| { p.props = para.clone(); for run in &mut p.runs { let src = std::mem::replace(&mut run.props, base.clone()); if src.bold == Some(true) { run.props.bold = Some(true); } if src.italic == Some(true) { run.props.italic = Some(true); } if src.underline.is_some_and(|u| u != wordcraft_doc::props::Underline::None) { run.props.underline = src.underline; } if src.strike == Some(true) { run.props.strike = Some(true); } run.props.link = src.link; } p.touch(); }); } } /// Replace the selection with `frag`, marking it as an insertion under Track Changes. pub fn insert(s: &mut Session, mut frag: Fragment) -> Result<(), CmdError> { super::controls::prepare_edit(s)?; let at = delete_selection(s)?; let track = s.doc.settings.track_changes; let mut rid = None; if track { // What the last command cut, pasted again: the two ends of a move. let r = match super::moves::take_paste(s, &frag) { Some(r) => r, None => super::new_revision(s, wordcraft_doc::RevisionKind::Insert), }; rid = Some(r); for b in &mut frag.blocks { each_para(b, 0, &mut |p| { for run in &mut p.runs { run.props.ins = Some(r); run.props.del = None; run.props.fmt_change = None; } p.mark.ins = None; p.mark.del = None; }); } } let end = s.doc.insert_fragment(&at, &frag)?; // The paragraph marks the paste added are inserted too, so rejecting it joins the // paragraphs again. The last paragraph ends with the original mark and keeps it. if let Some(r) = rid && at.story == end.story && at.path.parent() == end.path.parent() { for i in at.path.last()..end.path.last() { if let Ok(p) = s.doc.para_mut(at.story, &at.path.with_last(i)) { p.mark.ins = Some(r); p.mark.del = None; p.touch(); } } } s.sel = Selection::caret(end); Ok(()) } /// Every paragraph in a block, tables included (bounded depth). pub(crate) fn each_para(b: &mut Block, depth: usize, f: &mut dyn FnMut(&mut Paragraph)) { if depth > MAX_DEPTH { return; } match b { Block::Para(p) => f(p), Block::Table(t) => { for row in &mut t.rows { for cell in &mut row.cells { for cb in &mut cell.blocks { each_para(Arc::make_mut(cb), depth + 1, f); } } } } } } /// Turn an imported document into a fragment of the session's document: bring over the media, /// lists and styles it refers to (renumbered so they can't collide with ours). fn adopt(s: &mut Session, src: Document) -> Fragment { let mut media = BTreeMap::new(); for (key, data) in &src.media { let ext = key.rsplit_once('.').map(|(_, e)| e).unwrap_or("png"); media.insert(key.clone(), s.doc.add_media(data.to_vec(), ext)); } let next_abs = s.doc.numbering.abstracts.iter().map(|a| a.id.saturating_add(1)).max().unwrap_or(0); let mut abs_map = BTreeMap::new(); for (i, a) in src.numbering.abstracts.iter().enumerate() { let id = next_abs.saturating_add(i as u32); abs_map.insert(a.id, id); s.doc.numbering.abstracts.push(wordcraft_doc::numbering::AbstractNum { id, ..a.clone() }); } let next_num = s.doc.numbering.nums.iter().map(|n| n.id.saturating_add(1)).max().unwrap_or(1).max(1); let mut num_map = BTreeMap::new(); for (i, n) in src.numbering.nums.iter().enumerate() { let id = next_num.saturating_add(i as u32); num_map.insert(n.id, id); let abstract_id = abs_map.get(&n.abstract_id).copied().unwrap_or(n.abstract_id); s.doc.numbering.nums.push(wordcraft_doc::numbering::Num { id, abstract_id, ..n.clone() }); } for st in &src.styles.styles { if s.doc.styles.get(&st.id).is_none() { s.doc.styles.upsert(st.clone()); } } let mut blocks: Vec = src.body.iter().map(|b| (**b).clone()).collect(); // Pasted pictures fit our text width, like Insert › Pictures (layout keeps their size). let max_w = crate::io::text_width(&s.doc); for b in &mut blocks { crate::io::fit_block_pictures(b, max_w); } for b in &mut blocks { each_para(b, 0, &mut |p| { if let Some(n) = p.props.numbering.as_mut() && n.num != 0 { n.num = num_map.get(&n.num).copied().unwrap_or(0); } for o in &mut p.objects { if let wordcraft_doc::InlineObject::Image { media: key, .. } = o && let Some(k) = media.get(key.as_str()) { *key = k.clone(); } } }); } // Text box stories the imported shapes show; pasting gives each a new story of its own. let parts = src.parts.into_iter().filter(|(_, p)| p.kind == wordcraft_doc::PartKind::TextBox).collect(); Fragment { blocks, parts } }