From f0be1aa767f9e89ab8ae16b6237490ac1641ee20 Mon Sep 17 00:00:00 2001 From: Nikita Rakitin Date: Tue, 13 Oct 2026 23:55:45 +0300 Subject: [PATCH] Edit: Paste Special (destination theme, keep source, picture, text only) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds edit.pasteSpecial. Keep Source Formatting bakes the copied shapes' theme colours and theme fonts into fixed values (captured at copy time), Picture draws the copied shapes into one PNG picture, and Keep Text Only pastes plain text. Ctrl+Alt+V, Edit › Paste Special… and the Paste menu open a Paste Special dialog. Co-authored-by: Cursor --- crates/engine/src/cmd/arrange.rs | 2 +- crates/engine/src/cmd/edit.rs | 119 ++++++++++++++++++++++++++++++- crates/engine/src/lib.rs | 3 + crates/engine/src/tests.rs | 52 ++++++++++++++ crates/ui-egui/src/dialogs.rs | 22 ++++++ crates/ui-egui/src/lib.rs | 2 + crates/ui-egui/src/menus.rs | 5 ++ crates/ui-egui/src/ribbon.rs | 3 + docs/parity-checklist.md | 8 +-- 9 files changed, 210 insertions(+), 6 deletions(-) diff --git a/crates/engine/src/cmd/arrange.rs b/crates/engine/src/cmd/arrange.rs index 0c91bbaa..480c6d63 100644 --- a/crates/engine/src/cmd/arrange.rs +++ b/crates/engine/src/cmd/arrange.rs @@ -153,7 +153,7 @@ fn reorder(s: &mut Session, p: &Value) -> Result { } /// Bounds of shapes (rotation included). -fn union_bounds(xs: &[Xfrm]) -> Option { +pub(crate) fn union_bounds(xs: &[Xfrm]) -> Option { let mut it = xs.iter().map(Xfrm::bounds); let first = it.next()?; Some(it.fold(first, |a, b| a.union(b))) diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 30437b15..0ca7c864 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -15,6 +15,15 @@ pub fn specs() -> Vec { cmd!("edit.cut", "Cut", ["Home", "Clipboard"], Some("Cmd+X"), "{scope?: slides}", has_doc, cut), cmd!(noundo "edit.copy", "Copy", ["Home", "Clipboard"], Some("Cmd+C"), "{scope?: slides} → {text}", has_doc, copy), cmd!("edit.paste", "Paste", ["Home", "Clipboard"], Some("Cmd+V"), "{text?: plain text to paste, scope?: slides}", has_doc, paste), + cmd!( + "edit.pasteSpecial", + "Paste Special", + ["Home", "Clipboard"], + None, + "{as: destinationTheme|keepSource|picture|text} — Use Destination Theme, Keep Source Formatting, Picture (PNG), Keep Text Only", + has_doc, + paste_special + ), cmd!("edit.pasteText", "Paste and Match Formatting", ["Edit"], Some("Cmd+Shift+V"), "{text?}", has_doc, paste_text), cmd!("edit.duplicate", "Duplicate", ["Edit"], Some("Cmd+D"), "{ids?}", has_selection, duplicate), cmd!("edit.delete", "Delete", ["Edit"], Some("Delete"), "{ids?, scope?: slides}", has_doc, delete), @@ -110,7 +119,8 @@ fn copy_inner(s: &mut Session, p: &Value) -> Result { media_of_shapes(&shapes, &mut mids); let media = st.doc.media.iter().filter(|m| mids.contains(&m.id)).cloned().collect(); let plain = shapes.iter().filter_map(|x| x.text.as_ref().map(|t| t.text())).collect::>().join("\n"); - s.clipboard = crate::Clipboard { shapes, media, plain: plain.clone(), ..Default::default() }; + let source_theme = st.current_slide().and_then(|sl| st.doc.master_for(sl)).map(|(m, _)| (m.scheme(), m.theme.fonts.clone())); + s.clipboard = crate::Clipboard { shapes, media, plain: plain.clone(), source_theme, ..Default::default() }; Ok(plain) } @@ -239,6 +249,113 @@ fn paste_plain(s: &mut Session, text: &str) -> Result { s.execute("insert.textBox", &json!({"rect": rect, "text": text})) } +fn paste_special(s: &mut Session, p: &Value) -> Result { + match str_param(p, "as").unwrap_or("destinationTheme") { + "destinationTheme" | "useDestinationTheme" => paste(s, &json!({})), + "text" | "keepTextOnly" => paste_text(s, &json!({})), + "keepSource" | "keepSourceFormatting" => { + let saved = s.clipboard.clone(); + if let Some((scheme, fonts)) = &saved.source_theme { + bake_theme(&mut s.clipboard.shapes, scheme, fonts); + } + let r = paste(s, &json!({})); + let pastes = s.clipboard.pastes; + s.clipboard = saved; + s.clipboard.pastes = pastes; + r + } + "picture" => paste_picture(s), + other => Err(bad("edit.pasteSpecial", format!("unknown `as`: {other} (destinationTheme, keepSource, picture, text)"))), + } +} + +/// Replace theme colours and theme fonts with the values they have in `scheme` / `fonts`, so the +/// shapes keep their look under another theme. +pub(crate) fn bake_theme(shapes: &mut [Shape], scheme: &deckcraft_color::ColorScheme, fonts: &deckcraft_model::theme::FontScheme) { + fn rec(v: &mut Value, scheme: &deckcraft_color::ColorScheme, fonts: &deckcraft_model::theme::FontScheme, depth: usize) { + if depth > 256 { + return; + } + match v { + Value::Object(o) => { + if o.get("kind").and_then(Value::as_str) == Some("scheme") + && let Some(slot) = o.get("slot").and_then(|x| serde_json::from_value::(x.clone()).ok()) + && slot != deckcraft_color::SchemeSlot::PhClr + && let Ok(rgb) = serde_json::to_value(scheme.get(slot)) + { + o.clear(); + o.insert("kind".into(), json!("rgb")); + o.insert("rgb".into(), rgb); + return; + } + for x in o.values_mut() { + rec(x, scheme, fonts, depth + 1); + } + } + Value::Array(a) => a.iter_mut().for_each(|x| rec(x, scheme, fonts, depth + 1)), + Value::String(t) if t.starts_with('+') => { + let set = match t.get(1..3) { + Some("mj") => &fonts.major, + Some("mn") => &fonts.minor, + _ => return, + }; + let face = match t.get(3..) { + Some("-lt") => &set.latin, + Some("-ea") => &set.ea, + Some("-cs") => &set.cs, + _ => return, + }; + if !face.is_empty() { + *t = face.clone(); + } + } + _ => {} + } + } + for sh in shapes.iter_mut() { + let Ok(mut v) = serde_json::to_value(&*sh) else { continue }; + rec(&mut v, scheme, fonts, 0); + if let Ok(baked) = serde_json::from_value::(v) { + *sh = baked; + } + } +} + +/// Paste Special › Picture: the copied shapes drawn into one PNG picture. +fn paste_picture(s: &mut Session) -> Result { + let mut clip = s.clipboard.clone(); + if clip.shapes.is_empty() { + return Err(bad("edit.pasteSpecial", "copy shapes first to paste them as a picture")); + } + if let Some((scheme, fonts)) = &clip.source_theme { + bake_theme(&mut clip.shapes, scheme, fonts); + } + let st = s.doc()?; + let mut doc = (*st.doc).clone(); + bring_media(&mut doc, &clip.media); + let mut slide = st.current_slide().cloned().ok_or_else(|| bad("edit.pasteSpecial", "no slide"))?; + let xs: Vec = clip.shapes.iter().filter_map(|x| x.xfrm).collect(); + let b = super::arrange::union_bounds(&xs).ok_or_else(|| bad("edit.pasteSpecial", "nothing to draw"))?; + let gx = deckcraft_geom::Xfrm::new(b.x0, b.y0, b.width(), b.height()); + let gid = ShapeId(u32::MAX - 1); + let one = if clip.shapes.len() == 1 { + let mut sh = clip.shapes[0].clone(); + sh.id = gid; + sh + } else { + Shape { id: gid, xfrm: Some(gx), kind: deckcraft_model::ShapeKind::Group { children: clip.shapes.clone(), child: gx }, ..Default::default() } + }; + slide.shapes = vec![one]; + const SCALE: f64 = 2.0; + let img = deckcraft_render::render_shape(&doc, &slide, gid, SCALE).ok_or_else(|| bad("edit.pasteSpecial", "the shapes could not be drawn"))?; + // render_shape draws the bounds plus an 8 pt margin (room for shadows and outlines). + let (w, h) = (img.width as f64 / SCALE, img.height as f64 / SCALE); + let same_slide = clip.shapes.iter().any(|c| st.shape(c.id).is_some()); + let off = if same_slide { s.prefs.duplicate_offset } else { 0.0 }; + let png = img.to_png(); + s.execute("insert.picture", &json!({"data": base64_encode(&png), "name": "Picture.png", "rect": [b.x0 - 8.0 + off, b.y0 - 8.0 + off, w, h]})) +} + fn paste_text(s: &mut Session, p: &Value) -> Result { let t = str_param(p, "text").map(String::from).unwrap_or_else(|| s.clipboard.plain.clone()); paste_plain(s, &t) diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index c2b56c7f..eafe54f1 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -319,6 +319,9 @@ pub struct Clipboard { pub media: Vec, pub text: Option, pub plain: String, + /// The copied shapes' theme colours (colour map applied) and fonts, for Paste Special › + /// Keep Source Formatting. + pub source_theme: Option<(deckcraft_color::ColorScheme, deckcraft_model::theme::FontScheme)>, /// How many times the shapes were pasted (each paste offsets further). pub pastes: u32, } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index e83f72ef..1563b874 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -835,3 +835,55 @@ fn slide_ranges_parse_like_powerpoints_print_box() { let opts = cmd::file::pdf_options(&json!({"slides": [0, 7, 1]}), 2).unwrap(); assert_eq!(opts.slides, Some(vec![0, 1]), "indices past the end are dropped"); } + +#[test] +fn paste_special_keeps_source_look_picture_or_text() { + use deckcraft_model::style::{ColorBase, Fill}; + let mut s = session(); + s.execute("shape.insert", &json!({"preset": "rect", "rect": [100, 100, 200, 100], "text": "Q3 plan"})).unwrap(); + s.execute("shape.fill", &json!({"color": "accent1"})).unwrap(); + let old_accent = { + let d = s.doc().unwrap(); + let sl = d.current_slide().unwrap(); + d.doc.master_for(sl).unwrap().0.scheme().get(deckcraft_color::SchemeSlot::Accent1) + }; + s.execute("edit.copy", &json!({})).unwrap(); + s.execute("design.theme", &json!({"name": "Ember"})).unwrap(); + { + let d = s.doc().unwrap(); + let sl = d.current_slide().unwrap(); + assert_ne!(d.doc.master_for(sl).unwrap().0.scheme().get(deckcraft_color::SchemeSlot::Accent1), old_accent); + } + let fill_of = |s: &Session, id: &Value| { + let sid = deckcraft_model::ShapeId(id.as_u64().unwrap() as u32); + s.doc().unwrap().shape(sid).unwrap().fill.clone() + }; + + let dest = s.execute("edit.pasteSpecial", &json!({"as": "destinationTheme"})).unwrap(); + assert!(matches!(fill_of(&s, &dest["ids"][0]), Some(Fill::Solid { color }) if matches!(color.base, ColorBase::Scheme { .. }))); + + let keep = s.execute("edit.pasteSpecial", &json!({"as": "keepSource"})).unwrap(); + match fill_of(&s, &keep["ids"][0]) { + Some(Fill::Solid { color }) => assert_eq!(color.base, ColorBase::Rgb { rgb: old_accent }), + f => panic!("{f:?}"), + } + // The clipboard itself still holds the theme-following shape. + assert!(matches!(&s.clipboard.shapes[0].fill, Some(Fill::Solid { color }) if matches!(color.base, ColorBase::Scheme { .. }))); + + let pic = s.execute("edit.pasteSpecial", &json!({"as": "picture"})).unwrap(); + let pid = deckcraft_model::ShapeId(pic["id"].as_u64().or_else(|| pic["ids"][0].as_u64()).unwrap() as u32); + let d = s.doc().unwrap(); + let sh = d.shape(pid).unwrap(); + let deckcraft_model::ShapeKind::Picture { fill } = &sh.kind else { panic!("{:?}", sh.kind) }; + assert_eq!(d.doc.media(fill.media).unwrap().content_type, "image/png"); + let x = sh.xfrm.unwrap(); + assert!((x.w - 216.0).abs() < 1.0 && (x.h - 116.0).abs() < 1.0, "{x:?}"); + + let n = s.doc().unwrap().current_slide().unwrap().shapes.len(); + s.execute("edit.pasteSpecial", &json!({"as": "text"})).unwrap(); + let d = s.doc().unwrap(); + let last = d.current_slide().unwrap().shapes.last().unwrap(); + assert_eq!(d.current_slide().unwrap().shapes.len(), n + 1); + assert_eq!(last.text.as_ref().unwrap().text(), "Q3 plan"); + assert!(s.execute("edit.pasteSpecial", &json!({"as": "bogus"})).is_err()); +} diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index d1934be9..822d32e3 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -49,6 +49,7 @@ fn title(id: &str) -> &'static str { "symbol" => "Symbol", "equation" => "Equation", "spelling" => "Spelling", + "pasteSpecial" => "Paste Special", "accessibility" => "Accessibility Checker", "language" => "Language", "customShow" => "Custom Shows", @@ -509,6 +510,27 @@ fn body(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { } ok || cancel } + "pasteSpecial" => { + const OPTS: [(&str, &str); 4] = [ + ("destinationTheme", "Use Destination Theme"), + ("keepSource", "Keep Source Formatting"), + ("picture", "Picture (PNG)"), + ("text", "Keep Text Only"), + ]; + let has_shapes = !app.session.clipboard.shapes.is_empty(); + let mut cur = d.get("as", "destinationTheme"); + ui.label("As:"); + for (id, label) in OPTS { + let ok = has_shapes || matches!(id, "destinationTheme" | "text"); + ui.add_enabled_ui(ok, |ui| ui.radio_value(&mut cur, id.to_string(), label)); + } + d.fields.insert("as".into(), cur.clone()); + let (ok, cancel) = buttons(ui, "OK"); + if ok { + run(app, "edit.pasteSpecial", json!({"as": cur})); + } + ok || cancel + } "spelling" => { let v = app.session.execute("review.spelling", &json!({})).unwrap_or_default(); let list = v.as_array().cloned().unwrap_or_default(); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index ec97e6e6..4f6e0fb8 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -168,6 +168,7 @@ pub const UI_COMMANDS: &[(&str, &str, Option<&str>, &str)] = &[ ("app.saveAsDialog", "Save As…", None, "{}"), ("app.exportDialog", "Export…", None, "{}"), ("app.dialog", "Open Dialog", None, "{id}"), + ("app.pasteSpecial", "Paste Special…", Some("Cmd+Alt+V"), "{}"), ("app.links", "Community Links", None, "{}"), ]; @@ -541,6 +542,7 @@ impl SlideApp { "app.insertVideoDialog" => self.pick_and_insert("video", "insert.video"), "app.saveAsDialog" => self.save_as_dialog("deckcraft"), "app.exportDialog" => self.dialog = Some(dialogs::Dialog::new("export")), + "app.pasteSpecial" => self.dialog = Some(dialogs::Dialog::new("pasteSpecial")), "app.dialog" => { let d = p.get("id").and_then(Value::as_str).ok_or("missing `id`")?; self.dialog = Some(dialogs::Dialog::new(d)); diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 25398b45..f6805862 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -49,6 +49,10 @@ pub fn ui_shortcut(app: &mut SlideApp, key: egui::Key, m: Mods) -> bool { return true; } (O, true, false, false) => "app.openDialog", + (V, true, false, true) if app.session.active().is_some() => { + app.dialog = Some(crate::dialogs::Dialog::new("pasteSpecial")); + return true; + } (P, true, true, false) => "app.palette", (Comma, true, false, false) => "app.preferences", (Equals | Plus, true, false, false) => "view.zoomIn", @@ -171,6 +175,7 @@ pub fn menu_tree() -> Vec<(&'static str, Vec<(&'static str, &'static str)>)> { ("Copy", "edit.copy"), ("Paste", "edit.paste"), ("Paste and Match Formatting", "edit.pasteText"), + ("Paste Special…", "app.pasteSpecial"), ("-", ""), ("Select All", "edit.selectAll"), ("Duplicate", "edit.duplicate"), diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index 428eff0b..a8106666 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -292,6 +292,9 @@ fn clipboard_group(app: &mut SlideApp, ui: &mut Ui) { if ui.button("Paste and Match Formatting").clicked() { run(app, "edit.pasteText", json!({})); } + if ui.button("Paste Special…").clicked() { + run(app, "app.pasteSpecial", json!({})); + } if ui.button("Paste Formatting").clicked() { run(app, "format.painterApply", json!({})); } diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 2a3d450c..4fa924e8 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -3,12 +3,12 @@ > **Generated:** 2026-10-10 (summary by `cargo xtask parity`; rows re-scored 2026-10-10 against PowerPoint for Mac 16.113.4) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) The summary between ` -**Weighted breadth parity: 79%** over 191 features. +**Weighted breadth parity: 80%** over 191 features. | Priority | Features | Done | Partial | Missing | Parity | |---|---|---|---|---|---| | P0 | 72 | 61 | 11 | 0 | 92% | -| P1 | 65 | 38 | 21 | 6 | 75% | +| P1 | 65 | 39 | 20 | 6 | 75% | | P2 | 54 | 13 | 15 | 26 | 38% | | Area | Parity | @@ -32,7 +32,7 @@ The summary between ` | Slide show | 66% | | Review | 60% | | Draw | 33% | -| Undo & clipboard | 88% | +| Undo & clipboard | 100% | | Automation (✱) | 100% | | Platforms (✱) | 67% | @@ -227,7 +227,7 @@ Open P0 items: Contextual tabs (Shape Format, Picture Format, Table Design/Layou | Draw | Ink to shape / text | P2 | M | | Undo & clipboard | Unlimited undo/redo | P0 | D | | Undo & clipboard | Cut, copy, paste shapes and slides | P0 | D | -| Undo & clipboard | Paste special (keep formatting, picture, text only) | P1 | P | +| Undo & clipboard | Paste special (keep formatting, picture, text only) | P1 | D | | Automation (✱) | Every command scriptable (CLI, control channel, MCP) | P0 | D | | Automation (✱) | Headless rendering and conversion | P0 | D | | Platforms (✱) | macOS, Windows, Linux builds | P0 | P |