Edit: Paste Special (destination theme, keep source, picture, text only)

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 <cursoragent@cursor.com>
This commit is contained in:
Nikita Rakitin
2026-10-13 23:55:45 +03:00
co-authored by Cursor
parent 457406a7a5
commit f0be1aa767
9 changed files with 210 additions and 6 deletions
+1 -1
View File
@@ -153,7 +153,7 @@ fn reorder(s: &mut Session, p: &Value) -> Result<Value> {
}
/// Bounds of shapes (rotation included).
fn union_bounds(xs: &[Xfrm]) -> Option<Rect> {
pub(crate) fn union_bounds(xs: &[Xfrm]) -> Option<Rect> {
let mut it = xs.iter().map(Xfrm::bounds);
let first = it.next()?;
Some(it.fold(first, |a, b| a.union(b)))
+118 -1
View File
@@ -15,6 +15,15 @@ pub fn specs() -> Vec<CommandSpec> {
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<String> {
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::<Vec<_>>().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<Value> {
s.execute("insert.textBox", &json!({"rect": rect, "text": text}))
}
fn paste_special(s: &mut Session, p: &Value) -> Result<Value> {
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::<deckcraft_color::SchemeSlot>(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::<Shape>(v) {
*sh = baked;
}
}
}
/// Paste Special › Picture: the copied shapes drawn into one PNG picture.
fn paste_picture(s: &mut Session) -> Result<Value> {
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<deckcraft_geom::Xfrm> = 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<Value> {
let t = str_param(p, "text").map(String::from).unwrap_or_else(|| s.clipboard.plain.clone());
paste_plain(s, &t)
+3
View File
@@ -319,6 +319,9 @@ pub struct Clipboard {
pub media: Vec<deckcraft_model::MediaItem>,
pub text: Option<deckcraft_model::TextBody>,
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,
}
+52
View File
@@ -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());
}
+22
View File
@@ -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();
+2
View File
@@ -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));
+5
View File
@@ -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"),
+3
View File
@@ -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!({}));
}
+4 -4
View File
@@ -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 `<!-- SUMMARY -->
**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 `<!-- SUMMARY -->
| 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 |