Merge PR #558 from NikitkaRa: Edit: Paste Special (destination theme, keep source, picture, text only)

# Conflicts:
#	crates/engine/src/tests.rs
This commit is contained in:
Brandon Thomas
2026-10-14 13:08:54 -04:00
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
@@ -16,6 +16,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),
@@ -114,7 +123,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)
}
@@ -323,6 +333,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
@@ -327,6 +327,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
@@ -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());
}
+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",
@@ -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();
+2
View File
@@ -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));
+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",
@@ -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"),
+3
View File
@@ -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!({}));
}
+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 |