mirror of
https://github.com/storytold/wordcraft.git
synced 2026-11-09 03:39:51 -06:00
Word brings a document's alternative format chunks (w:altChunk) in where they stand when it opens the file; WordCraft left them out, so a document made of one (as generators and mail-merge tools write them) opened empty. - docx: read_with/ReadOptions with a ChunkImporter for formats other than WordprocessingML; nested Word documents are read here (at most 4 deep, 256 chunks and 512 MB of chunk bytes a file, XML depth counted on). - doc: Document::adopt (moved from paste) brings another document's media, lists, styles, notes, comments and revisions along under new ids; pictures in groups and charts are relinked. Insert > Text from File uses it and now keeps the file's pictures and lists. - engine: chunks in MHT, HTML (in their charset), RTF and plain text go through the format importers. - formats: MHT web archives open (MIME, quoted-printable/base64, charsets, pictures by Content-Location or cid). HTML reads style element rules (simple selectors, cascade, budgets), paragraph margins, indents, line height, cell widths and borders; an opened page takes Word's web-page defaults (Times New Roman 12 pt, single lines, black headings, automatic spacing). html::decode knows the single-byte Windows code pages. docxide-pdf suite: croatian_regulations_altchunk goes from one empty page to Word's 8 pages, SSIM 2.7% -> 78.7%; no other document changes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
430 lines
18 KiB
Rust
430 lines
18 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).
|
||
//! Plain text has no formatting to keep, so Paste inserts it as Keep Text Only does.
|
||
|
||
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);
|
||
}
|
||
}
|
||
// 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 `<blockquote>` whose page gives it no margins, points.
|
||
const BLOCKQUOTE_INDENT: f32 = 36.0;
|
||
|
||
/// Another program's HTML clipboard payload as a document. A `<blockquote>` 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
|
||
/// `<blockquote>` 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 `<style>` sheets give `blockquote` — LibreOffice
|
||
/// writes `blockquote { margin-left: 1cm; margin-right: 1cm }` — else [`BLOCKQUOTE_INDENT`].
|
||
fn blockquote_margins(html: &str) -> (f32, f32) {
|
||
let len = |v: &str| wordcraft_formats::html::css_len_pt(v).map(|x| x.clamp(0.0, 1584.0));
|
||
let (mut left, mut right) = (BLOCKQUOTE_INDENT, BLOCKQUOTE_INDENT);
|
||
let lower = html.to_ascii_lowercase();
|
||
let mut rest = lower.as_str();
|
||
while let Some(at) = rest.find("<style") {
|
||
let tag = rest.get(at..).unwrap_or("");
|
||
let sheet = tag.find('>').and_then(|j| tag.get(j + 1..)).unwrap_or("");
|
||
let end = sheet.find("</style").unwrap_or(sheet.len());
|
||
for rule in sheet.get(..end).unwrap_or("").split('}') {
|
||
let Some((sel, decls)) = rule.split_once('{') else { continue };
|
||
if !sel.split(',').any(|s| s.trim().trim_start_matches("<!--").trim() == "blockquote") {
|
||
continue;
|
||
}
|
||
for d in decls.split(';') {
|
||
let Some((name, v)) = d.split_once(':') else { continue };
|
||
match name.trim() {
|
||
"margin-left" => left = len(v).unwrap_or(left),
|
||
"margin-right" => right = len(v).unwrap_or(right),
|
||
"margin" => {
|
||
let vals: Vec<&str> = v.split_whitespace().take(4).collect();
|
||
let (r, l) = match vals.as_slice() {
|
||
[a] => (*a, *a),
|
||
[_, h] | [_, h, _] => (*h, *h),
|
||
[_, r, _, l] => (*r, *l),
|
||
_ => continue,
|
||
};
|
||
right = len(r).unwrap_or(right);
|
||
left = len(l).unwrap_or(left);
|
||
}
|
||
_ => {}
|
||
}
|
||
}
|
||
}
|
||
rest = sheet.get(end..).unwrap_or("");
|
||
}
|
||
(left, right)
|
||
}
|
||
|
||
/// 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(import_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 a Paste brings in.
|
||
pub enum Pasted {
|
||
/// WordCraft's own copy, or another program's HTML or RTF: it keeps the formatting it carries.
|
||
Fragment(Fragment),
|
||
/// The system clipboard's text, lines separated by `\n`.
|
||
Text(String),
|
||
}
|
||
|
||
/// 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_content(s: &mut Session, v: &Value) -> Result<Pasted, 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(Pasted::Fragment(f.clone()));
|
||
}
|
||
if let Some(f) = rich_fragment(s, v) {
|
||
return Ok(Pasted::Fragment(f));
|
||
}
|
||
match text {
|
||
Some(t) if !t.is_empty() => Ok(Pasted::Text(t)),
|
||
_ => Err(CmdError::Failed("the clipboard is empty".into())),
|
||
}
|
||
}
|
||
|
||
/// [`paste_content`] as a fragment. Text becomes paragraphs with no formatting of their own.
|
||
pub fn paste_fragment(s: &mut Session, v: &Value) -> Result<Fragment, CmdError> {
|
||
Ok(match paste_content(s, v)? {
|
||
Pasted::Fragment(f) => f,
|
||
Pasted::Text(t) => Fragment::from_text(&t),
|
||
})
|
||
}
|
||
|
||
/// Paste plain text the way Keep Text Only does, which is what Word does with text that carries
|
||
/// no formatting: the text takes the formatting before the caret, and each line after the first
|
||
/// starts a paragraph formatted like the one the text lands in (direction, style, alignment,
|
||
/// indents, list). Empty lines become empty paragraphs.
|
||
pub fn insert_text(s: &mut Session, text: &str) -> Result<(), CmdError> {
|
||
// Only our own cut, pasted straight after it, becomes a move (see `insert`).
|
||
s.pending_move = None;
|
||
super::paste_text(s, &text.replace("\r\n", "\r").replace('\n', "\r"))
|
||
}
|
||
|
||
/// 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;
|
||
// Tracked changes are not formatting: text deleted in the source stays deleted.
|
||
run.props.ins = src.ins;
|
||
run.props.del = src.del;
|
||
}
|
||
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 {
|
||
// Text deleted in the source stays deleted: inserted, then deleted.
|
||
run.props.ins = Some(r);
|
||
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);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Section breaks brought in from another document keep their page, but their header and
|
||
/// footer ids are that document's: clear them (and the different first page that went with
|
||
/// them) so the sections link to the previous section's headers and footers, as in Word.
|
||
pub(crate) fn unlink_sections(blocks: &mut [Block]) {
|
||
for b in blocks {
|
||
each_para(b, 0, &mut |p| {
|
||
if let Some(sec) = p.section.as_deref_mut() {
|
||
sec.headers = Default::default();
|
||
sec.footers = Default::default();
|
||
sec.title_page = false;
|
||
}
|
||
});
|
||
}
|
||
}
|
||
|
||
/// 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 frag = s.doc.adopt(src);
|
||
// 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 frag.blocks {
|
||
crate::io::fit_block_pictures(b, max_w);
|
||
}
|
||
unlink_sections(&mut frag.blocks);
|
||
frag
|
||
}
|