From 7432c61665ca72f002b5b4b9db844ba6c8ebf46c Mon Sep 17 00:00:00 2001 From: Nikita Rakitin Date: Tue, 13 Oct 2026 22:14:46 +0300 Subject: [PATCH] Slides: reuse slides from another presentation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Home › New Slide gains Reuse Slides… and Reuse Slides (Keep Source Formatting)…, backed by a new slide.reuse command that inserts the slides of a .pptx or .deckcraft file (all, a list, or a range) after the current slide in one undo step. By default each slide moves to the destination layout with the same name or kind, so it takes this deck's theme; keepFormatting brings the source master and its layouts along. Pictures, media and sounds are copied under fresh media ids, so ids that clash between the two decks no longer mix up pictures, and shape ids are renumbered with animations, triggers and connector ends following them. Co-authored-by: Cursor --- crates/engine/src/cmd/mod.rs | 2 + crates/engine/src/cmd/reuse.rs | 226 +++++++++++++++++++++++++++++++++ crates/pptx/tests/roundtrip.rs | 63 +++++++++ crates/ui-egui/src/lib.rs | 16 +++ crates/ui-egui/src/ribbon.rs | 8 ++ docs/gaps.md | 2 +- docs/parity-checklist.md | 2 +- 7 files changed, 317 insertions(+), 2 deletions(-) create mode 100644 crates/engine/src/cmd/reuse.rs diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index ce909b02..7cd60555 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -10,6 +10,7 @@ pub mod insert; pub mod inspect; pub mod media; pub mod merge; +pub mod reuse; pub mod review; pub mod selection; pub mod shape; @@ -128,6 +129,7 @@ pub fn command_specs() -> &'static [CommandSpec] { v.extend(file::specs()); v.extend(edit::specs()); v.extend(slide::specs()); + v.extend(reuse::specs()); v.extend(insert::specs()); v.extend(shape::specs()); v.extend(arrange::specs()); diff --git a/crates/engine/src/cmd/reuse.rs b/crates/engine/src/cmd/reuse.rs new file mode 100644 index 00000000..d5e31b11 --- /dev/null +++ b/crates/engine/src/cmd/reuse.rs @@ -0,0 +1,226 @@ +//! Home ▸ New Slide ▸ Reuse Slides: insert slides from another presentation. +//! +//! The slides take the destination's look by default (each one moves to the destination layout +//! with the same name, else the same kind); with `keepFormatting` the source master and its +//! layouts come along. Pictures, media and sounds are copied under fresh ids, and shape ids are +//! renumbered with animations, triggers and connector ends following them. + +use std::collections::HashMap; +use std::sync::Arc; + +use deckcraft_model::{LayoutId, MasterId, MediaId, Presentation, ShapeId, ShapeKind, Slide, SlideId}; +use serde_json::{Value, json}; + +use super::*; +use crate::{Result, Session}; + +pub fn specs() -> Vec { + vec![cmd!( + "slide.reuse", + "Reuse Slides…", + ["Home", "Slides", "New Slide"], + None, + "{path? | data: base64, name?; slides?: [index] (0-based, default all); keepFormatting?: bool (default false: use the destination theme); at?: index (default after the current slide)}", + has_doc, + reuse + )] +} + +/// Keys under which the model stores a [`MediaId`] (pictures, media, posters, previews, sounds). +const MEDIA_KEYS: [&str; 4] = ["media", "poster", "preview", "sound"]; + +/// Rewrite every media id in `v` through `map`. +fn remap_media(v: &mut Value, map: &HashMap, depth: usize) { + if depth > 256 { + return; + } + match v { + Value::Object(o) => { + for (k, x) in o.iter_mut() { + if MEDIA_KEYS.contains(&k.as_str()) + && let Some(n) = x.as_u64().and_then(|n| u32::try_from(n).ok()) + { + if let Some(to) = map.get(&n) { + *x = json!(to); + } + continue; + } + remap_media(x, map, depth + 1); + } + } + Value::Array(a) => a.iter_mut().for_each(|x| remap_media(x, map, depth + 1)), + _ => {} + } +} + +/// `value` with its media ids rewritten, via its JSON form. +fn with_media(value: &T, map: &HashMap) -> Result { + let mut v = serde_json::to_value(value).map_err(|e| EngineError::Other(e.to_string()))?; + remap_media(&mut v, map, 0); + serde_json::from_value(v).map_err(|e| EngineError::Other(e.to_string())) +} + +fn shape_ids(shapes: &[deckcraft_model::Shape]) -> Vec { + let mut v = vec![]; + deckcraft_model::walk(shapes, &mut |sh, _| v.push(sh.id)); + v +} + +/// Point connector ends at the renumbered shapes. +fn remap_connectors(shapes: &mut [deckcraft_model::Shape], map: &HashMap, depth: usize) { + if depth > 64 { + return; + } + for sh in shapes { + if let ShapeKind::Connector { start, end } = &mut sh.kind { + for c in [start, end] { + if let Some((id, _)) = c.as_mut() { + *id = map.get(id).copied().unwrap_or(*id); + } + } + } + if let Some(ch) = sh.children_mut() { + remap_connectors(ch, map, depth + 1); + } + } +} + +/// Fresh shape ids for `shapes`, and the old → new map. +fn renumber(doc: &mut Presentation, shapes: &mut [deckcraft_model::Shape]) -> HashMap { + let old = shape_ids(shapes); + super::edit::reid(doc, shapes); + let map: HashMap = old.into_iter().zip(shape_ids(shapes)).collect(); + remap_connectors(shapes, &map, 0); + map +} + +/// The destination layout a reused slide moves to: same name, else same kind, else the first. +fn matching_layout(doc: &Presentation, name: &str, kind: deckcraft_model::LayoutType) -> LayoutId { + let all = || doc.masters.iter().flat_map(|m| m.layouts.iter()); + all() + .find(|l| !name.is_empty() && l.name.eq_ignore_ascii_case(name)) + .or_else(|| all().find(|l| l.kind == kind)) + .or_else(|| all().next()) + .map(|l| l.id) + .unwrap_or_default() +} + +fn reuse(s: &mut Session, p: &Value) -> Result { + let (name, bytes) = super::insert::media_bytes(p, "slide.reuse")?; + let src = super::file::open_presentation(&name, &bytes)?; + let n = src.slides.len(); + let picked: Vec = match p.get("slides") { + Some(Value::Array(a)) => { + let mut v = vec![]; + for x in a { + let i = x.as_u64().map(|i| i as usize).filter(|i| *i < n); + let i = i.ok_or_else(|| bad("slide.reuse", format!("{name} has {n} slides; {x} isn't one of them (indices count from 0)")))?; + if !v.contains(&i) { + v.push(i); + } + } + v + } + Some(Value::String(t)) if !t.trim().is_empty() => super::file::parse_slide_range(t, n)?, + _ => (0..n).collect(), + }; + if picked.is_empty() { + return Err(bad("slide.reuse", format!("{name} has no slides to reuse"))); + } + let keep = bool_or(p, "keepFormatting", false); + let at = usize_param(p, "at"); + s.edit(|doc, sel| { + let at = at.unwrap_or(sel.slide + 1).min(doc.slides.len()); + // Media: what the slides use is copied under fresh ids. + let mut media_map: HashMap = HashMap::new(); + // Keep Source Formatting: bring each source master used, once, with fresh ids. + let mut layout_map: HashMap = HashMap::new(); + if keep { + let mut masters_done: Vec = vec![]; + for &i in &picked { + let Some((m, _)) = src.master_for(&src.slides[i]) else { continue }; + if masters_done.contains(&m.id) { + continue; + } + masters_done.push(m.id); + let used = serde_json::to_value(m).map_err(|e| EngineError::Other(e.to_string()))?; + copy_media(doc, &src, &used, &mut media_map); + let mut m: deckcraft_model::Master = with_media(m, &media_map)?; + m.id = MasterId(doc.alloc_id()); + renumber(doc, &mut m.shapes); + for l in &mut m.layouts { + let new = LayoutId(doc.alloc_id()); + layout_map.insert(l.id, new); + l.id = new; + renumber(doc, &mut l.shapes); + } + doc.masters.push(Arc::new(m)); + } + } + let mut new_ids = vec![]; + for (k, &i) in picked.iter().enumerate() { + let from = &src.slides[i]; + let used = serde_json::to_value(from.as_ref()).map_err(|e| EngineError::Other(e.to_string()))?; + copy_media(doc, &src, &used, &mut media_map); + let mut sl: Slide = with_media(from.as_ref(), &media_map)?; + sl.id = SlideId(doc.alloc_id()); + let map = renumber(doc, &mut sl.shapes); + for a in &mut sl.animations { + a.shape = map.get(&a.shape).copied().unwrap_or(a.shape); + a.trigger = a.trigger.map(|t| map.get(&t).copied().unwrap_or(t)); + } + sl.layout = match layout_map.get(&sl.layout) { + Some(l) => *l, + None => { + let (lname, kind) = + src.layout(from.layout).map(|(_, l)| (l.name.clone(), l.kind)).unwrap_or_else(|| (String::new(), Default::default())); + matching_layout(doc, &lname, kind) + } + }; + new_ids.push(sl.id); + doc.slides.insert((at + k).min(doc.slides.len()), Arc::new(sl)); + } + sel.slide = (at + new_ids.len()).saturating_sub(1).min(doc.slides.len().saturating_sub(1)); + sel.slides = new_ids.clone(); + sel.shapes.clear(); + sel.text = None; + Ok(json!({"slides": new_ids.len(), "ids": new_ids, "from": name, "keepFormatting": keep})) + }) +} + +/// Copy the source media `used` refers to into `doc` under fresh ids, once each. +fn copy_media(doc: &mut Presentation, src: &Presentation, used: &Value, map: &mut HashMap) { + let mut ids = vec![]; + collect_media(used, &mut ids, 0); + for id in ids { + if map.contains_key(&id) { + continue; + } + if let Some(m) = src.media(MediaId(id)) { + let new = doc.add_media(&m.name, &m.content_type, m.data.as_ref().clone()); + map.insert(id, new.0); + } + } +} + +/// The media ids `v` refers to. +fn collect_media(v: &Value, out: &mut Vec, depth: usize) { + if depth > 256 { + return; + } + match v { + Value::Object(o) => { + for (k, x) in o { + if MEDIA_KEYS.contains(&k.as_str()) + && let Some(n) = x.as_u64().and_then(|n| u32::try_from(n).ok()) + { + out.push(n); + } else { + collect_media(x, out, depth + 1); + } + } + } + Value::Array(a) => a.iter().for_each(|x| collect_media(x, out, depth + 1)), + _ => {} + } +} diff --git a/crates/pptx/tests/roundtrip.rs b/crates/pptx/tests/roundtrip.rs index db6d0beb..ef7f1ab9 100644 --- a/crates/pptx/tests/roundtrip.rs +++ b/crates/pptx/tests/roundtrip.rs @@ -991,3 +991,66 @@ fn transition_defaults_read_like_powerpoint() { assert_eq!((t.kind.as_str(), t.option.as_str(), t.duration_ms), (*kind, *option, 2000), "{el}"); } } + +/// Home › New Slide › Reuse Slides: slides from another .pptx land after the current slide with +/// their pictures (fresh media ids, same bytes) and animations on the renumbered shapes. By +/// default they take a destination layout; Keep Source Formatting brings the source master. +#[test] +fn reuse_slides_from_another_presentation() { + use deckcraft_engine::Session; + use serde_json::json; + let b64 = deckcraft_engine::cmd::base64_encode; + // Source: three slides, a picture with an animation on the second. + let mut src = Session::with_new(); + src.execute("slide.new", &json!({})).unwrap(); + let pic = png(8, 8, [10, 200, 30]); + src.execute("insert.picture", &json!({"data": b64(&pic), "name": "green.png"})).unwrap(); + src.execute("animation.add", &json!({"effect": "fade"})).unwrap(); + src.execute("slide.new", &json!({})).unwrap(); + let src_bytes = deckcraft_pptx::export(&src.doc().unwrap().doc).unwrap(); + + let mut s = Session::with_new(); + // Media ids in the two decks overlap; the destination's own picture must stay its own. + let red = png(4, 4, [200, 0, 0]); + s.execute("insert.picture", &json!({"data": b64(&red), "name": "red.png"})).unwrap(); + let r = s.execute("slide.reuse", &json!({"data": b64(&src_bytes), "name": "src.pptx", "slides": [1]})).unwrap(); + assert_eq!(r["slides"], 1, "{r}"); + let doc = s.doc().unwrap().doc.clone(); + assert_eq!(doc.slides.len(), 2); + assert_eq!(s.doc().unwrap().selection.slide, 1, "the reused slide is selected"); + let pictures = |sl: &deckcraft_model::Slide| { + let mut v = vec![]; + deckcraft_model::walk(&sl.shapes, &mut |sh, _| { + if let deckcraft_model::ShapeKind::Picture { fill } = &sh.kind { + v.push(fill.media); + } + }); + v + }; + let mine = pictures(&doc.slides[0]); + let reused = pictures(&doc.slides[1]); + assert_eq!(doc.media(mine[0]).map(|m| m.data.as_slice() == red.as_slice()), Some(true), "own picture untouched"); + assert_eq!(doc.media(reused[0]).map(|m| m.data.as_slice() == pic.as_slice()), Some(true), "reused picture copied"); + let anim = &doc.slides[1].animations[0]; + assert!(doc.slides[1].shapes.iter().any(|sh| sh.id == anim.shape), "animation follows its shape"); + assert!(doc.layout(doc.slides[1].layout).is_some(), "uses a destination layout"); + assert_eq!(doc.masters.len(), 1); + // The result saves and reopens. + let back = deckcraft_pptx::import(&deckcraft_pptx::export(&doc).unwrap()).unwrap(); + assert_eq!(back.slides.len(), 2); + + // Keep Source Formatting: every slide, and the source master comes along. + let r = s.execute("slide.reuse", &json!({"data": b64(&src_bytes), "name": "src.pptx", "keepFormatting": true, "at": 0})).unwrap(); + assert_eq!(r["slides"], 3); + let doc = s.doc().unwrap().doc.clone(); + assert_eq!(doc.slides.len(), 5); + assert_eq!(doc.masters.len(), 2); + for sl in &doc.slides[..3] { + assert_eq!(doc.layout(sl.layout).map(|(m, _)| m.id), Some(doc.masters[1].id), "source master"); + } + // One undo removes the whole reuse. + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().doc.slides.len(), 2); + assert!(s.execute("slide.reuse", &json!({"data": b64(&src_bytes), "name": "src.pptx", "slides": [7]})).is_err()); + assert!(s.execute("slide.reuse", &json!({"data": b64(&[0u8, 159, 146, 150]), "name": "x.bin"})).is_err()); +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index ec97e6e6..4d3b314e 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -164,6 +164,7 @@ pub const UI_COMMANDS: &[(&str, &str, Option<&str>, &str)] = &[ ("app.openDialog", "Open…", None, "{purpose?}"), ("app.insertPictureDialog", "Picture from File…", None, "{}"), ("app.insertAudioDialog", "Audio from File…", None, "{}"), + ("app.reuseSlidesDialog", "Reuse Slides…", None, "{keepFormatting?: bool}"), ("app.insertVideoDialog", "Video from File…", None, "{}"), ("app.saveAsDialog", "Save As…", None, "{}"), ("app.exportDialog", "Export…", None, "{}"), @@ -538,6 +539,7 @@ impl SlideApp { "app.openDialog" => self.pick_and_open(), "app.insertPictureDialog" => self.pick_and_insert("picture", "insert.picture"), "app.insertAudioDialog" => self.pick_and_insert("audio", "insert.audio"), + "app.reuseSlidesDialog" => self.pick_and_reuse(p.get("keepFormatting").and_then(Value::as_bool).unwrap_or(false)), "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")), @@ -580,6 +582,20 @@ impl SlideApp { Ok(()) } + /// Home › New Slide › Reuse Slides…: pick a presentation and insert all its slides after the + /// current one (desktop only: the browser picker opens what it picks as a document). + fn pick_and_reuse(&mut self, keep: bool) { + let Some(pick) = self.services.pick_open.as_mut() else { + self.set_status("Reuse Slides needs the desktop app".to_string()); + return; + }; + let Some(path) = pick("open") else { return }; + match self.session.execute("slide.reuse", &json!({"path": path, "keepFormatting": keep})) { + Ok(r) => self.set_status(format!("Inserted {} slides from {path}", r["slides"])), + Err(e) => self.set_status(e.to_string()), + } + } + fn pick_and_insert(&mut self, purpose: &str, cmd: &str) { if let Some(pick) = self.services.pick_open.as_mut() { if let Some(path) = pick(purpose) { diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index 428eff0b..0e4a0e0d 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -380,6 +380,14 @@ fn slides_group(app: &mut SlideApp, ui: &mut Ui) { if ui.button("Slides from Outline…").clicked() { app.dialog = Some(crate::dialogs::Dialog::new("outline")); } + let desktop = app.services.pick_open.is_some(); + for (label, keep) in [("Reuse Slides…", false), ("Reuse Slides (Keep Source Formatting)…", true)] { + let r = ui.add_enabled(desktop, egui::Button::new(label)); + if r.clicked() { + run(app, "app.reuseSlidesDialog", json!({"keepFormatting": keep})); + } + r.on_disabled_hover_text("Choosing a presentation to reuse needs the desktop app"); + } }); } rows(ui, |ui| { diff --git a/docs/gaps.md b/docs/gaps.md index a2894b42..ecc3d27e 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -184,7 +184,7 @@ Detail in [animation-parity.md](animation-parity.md). 10–16 h; the pane is a * - **Estimate:** 3–6 h to review, land and round-trip. ### Smaller missing rows -Zoom (summary/section/slide, 6–10 h), reuse slides (2–4 h), laser pointer (1–2 h), transition +Zoom (summary/section/slide, 6–10 h), reuse slides pane with thumbnails (1–2 h; `slide.reuse` inserts from a file), laser pointer (1–2 h), transition sounds (1–2 h), artistic effects (6–10 h), remove background (6–10 h; needs a model or a classical matte), compress pictures (2–3 h), insert screenshot (2–3 h), record audio/screen and record slide show (8–12 h), compare presentations (6–10 h), password protection (4–6 h, ECMA-376 agile diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 2a3d450c..2237d6db 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -68,7 +68,7 @@ Open P0 items: Contextual tabs (Shape Format, Picture Format, Table Design/Layou | Files | Export video (MP4) / animated GIF | P2 | M | | Files | Print (slides, handouts, notes, outline) | P1 | M | | Files | Password protection | P2 | M | -| Files | Reuse slides from another file | P1 | M | +| Files | Reuse slides from another file | P1 | P | | Files | Drag and drop files onto the window | P1 | D | | Files | OpenDocument .odp open/save | P2 | M | | Slides | New slide by layout | P0 | D |