mirror of
https://github.com/storytold/wordcraft.git
synced 2026-10-29 21:07:24 -06:00
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>
361 lines
15 KiB
Rust
361 lines
15 KiB
Rust
//! 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 }
|
||
}
|