mirror of
https://github.com/storytold/deckcraft.git
synced 2026-10-24 10:37:03 -06:00
Merge PR #509 from NikitkaRa: Slides: reuse slides from another presentation
# Conflicts: # crates/engine/src/cmd/mod.rs # crates/pptx/tests/roundtrip.rs # crates/ui-egui/src/lib.rs
This commit is contained in:
@@ -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());
|
||||
|
||||
@@ -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<CommandSpec> {
|
||||
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<u32, u32>, 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<T: serde::Serialize + serde::de::DeserializeOwned>(value: &T, map: &HashMap<u32, u32>) -> Result<T> {
|
||||
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<ShapeId> {
|
||||
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<ShapeId, ShapeId>, 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<ShapeId, ShapeId> {
|
||||
let old = shape_ids(shapes);
|
||||
super::edit::reid(doc, shapes);
|
||||
let map: HashMap<ShapeId, ShapeId> = 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<Value> {
|
||||
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<usize> = 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<u32, u32> = HashMap::new();
|
||||
// Keep Source Formatting: bring each source master used, once, with fresh ids.
|
||||
let mut layout_map: HashMap<LayoutId, LayoutId> = HashMap::new();
|
||||
if keep {
|
||||
let mut masters_done: Vec<MasterId> = 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<u32, u32>) {
|
||||
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<u32>, 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)),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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!({}));
|
||||
}
|
||||
|
||||
@@ -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| {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 |
|
||||
|
||||
Reference in New Issue
Block a user