Files
wordcraft/crates/engine/src/cmd/paste.rs
T
0f9dc5c6ae #776: draw Wingdings, Wingdings 2/3, Webdings and Symbol text as Unicode symbols (#876)
Word's symbol fonts are proprietary and never bundled, so text set in them
showed the substitute's letters. New wordcraft_doc::symbol_fonts maps every
code (stored as U+F020-U+F0FF, e.g. w:sym, or as a plain 8-bit code) to its
Unicode equivalent: Symbol from Unicode's Adobe symbol.txt, the Wingdings
and Webdings sets from Unicode L2/12-368 (WG2 N4384), checked against UCD.
Layout shapes the mapped characters (fallback faces draw them); PDF text,
copy as text and TXT export read as the symbols; the stored text and docx
round trips are unchanged.

Co-authored-by: Dmitrii Khlapov <19991457+dkhlapov@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-12 11:26:21 -04:00

361 lines
15 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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<CommandSpec> {
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<String> {
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("</") || t.contains("/>"))
}
/// The source for a format, if the clipboard has it.
fn source(s: &Session, v: &Value, fmt: &str) -> Option<String> {
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::<Vec<_>>())
}
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<usize> {
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<Fragment> {
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<Fragment, CmdError> {
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<Block> = 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 }
}