diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 86300d58..66551b2e 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -17,6 +17,7 @@ pub mod merge; pub mod picture_style; pub mod points; pub mod recolor; +pub mod reuse; pub mod review; mod richclip; pub mod selection; @@ -137,6 +138,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(smartart::specs()); v.extend(shape::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 145e693d..1ca7b62f 100644 --- a/crates/pptx/tests/roundtrip.rs +++ b/crates/pptx/tests/roundtrip.rs @@ -1681,3 +1681,66 @@ fn radar_stock_and_combo_round_trip() { } } } + +/// 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 cb8f12ed..2d83074c 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -390,6 +390,7 @@ pub const UI_COMMANDS: &[(&str, &str, Option<&str>, &str)] = &[ ("app.insertPictureDialog", "Picture from File…", None, "{}"), ("app.smartArtPictureDialog", "Insert Picture in SmartArt…", None, "{id?, index}"), ("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, "{}"), @@ -933,6 +934,7 @@ impl SlideApp { self.pick_and_run("picture", "smartArt.picture", p); } "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")), @@ -1118,6 +1120,20 @@ impl SlideApp { } } + /// 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) { self.pick_and_run(purpose, cmd, &json!({})); } diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index 2ab6dfcf..b4f2a922 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -435,6 +435,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 e7454e85..89485553 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -353,6 +353,7 @@ Zoom (summary/section/slide, 6–10 h), reuse slides (2–4 h), laser pointer (1 sounds (1–2 h), artistic effects (6–10 h), compress pictures (2–3 h), insert screenshot (2–3 h), record audio/screen and record slide sounds (1–2 h), artistic effects (3–6 h for the other 15 of 22; 7 approximated and round-tripped), remove background (6–10 h; needs a model or a classical Zoom (summary/section/slide, 6–10 h), reuse slides (2–4 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 06ff6985..c778c013 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -88,7 +88,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 |