diff --git a/crates/engine/src/cmd/arrange.rs b/crates/engine/src/cmd/arrange.rs index 06e21e5a..a7109665 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 6a21e00a..bc5501cd 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -16,6 +16,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), @@ -114,7 +123,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) } @@ -323,6 +333,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 9c903f30..d2a63ee9 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -327,6 +327,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 af962760..7058d9e7 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -1566,3 +1566,55 @@ fn numbering_takes_color_and_size() { s.execute("format.bullets", &json!({"on": true, "char": "★", "size": 9000})).unwrap(); assert_eq!(para(&s).bullet_size, Some(4.0)); } + +#[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 e21d64d4..3c7fd5f4 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", @@ -622,6 +623,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 ae8b4268..132ec2c3 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -185,6 +185,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, "{}"), ]; @@ -578,6 +579,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 b053892d..7e0a1ee0 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", @@ -177,6 +181,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 8ce5552b..edfe0206 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -295,6 +295,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 f8bd19e3..58e4f775 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 |