Fix up #509: copy media by a typed walk of every reference

slide.reuse indexed src.slides[i] and found media by scanning JSON keys
(media/poster/preview/sound), which missed artistic-effect originals and would
miss any reference stored under another key. New engine module media_refs: one
typed walker over the model (written once, expanded for & and &mut) that visits
every MediaId a presentation, master, layout, slide or shape can hold: pictures
and their artistic originals, picture fills in shapes, lines, runs, table cells
and styles, chart series and points, backgrounds and theme format schemes;
media clips and posters, 3-D models, OLE/SmartArt previews and node pictures,
picture bullets, hyperlink, transition and animation sounds, embedded fonts.
slide.reuse now lists and remaps media through it, uses get() for slides, and
keeps a linked media item's link when copying it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas
2026-10-14 15:35:50 -04:00
co-authored by Claude Opus 5.5
parent a5a3137e80
commit 5ef510e57b
4 changed files with 494 additions and 69 deletions
+101 -66
View File
@@ -12,7 +12,7 @@ use deckcraft_model::{LayoutId, MasterId, MediaId, Presentation, ShapeId, ShapeK
use serde_json::{Value, json};
use super::*;
use crate::{Result, Session};
use crate::{Result, Session, media_refs};
pub fn specs() -> Vec<CommandSpec> {
vec![cmd!(
@@ -26,40 +26,15 @@ pub fn specs() -> Vec<CommandSpec> {
)]
}
/// 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);
}
/// Point every media id under `x` at its copy in the destination (`map`: source → destination).
fn remap(map: &HashMap<MediaId, MediaId>) -> impl FnMut(&mut MediaId) + '_ {
move |id: &mut MediaId| {
if let Some(to) = map.get(id) {
*id = *to;
}
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));
@@ -132,20 +107,22 @@ fn reuse(s: &mut Session, p: &Value) -> Result<Value> {
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();
let mut media_map: HashMap<MediaId, MediaId> = 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 };
let Some((m, _)) = src.slides.get(i).and_then(|sl| src.master_for(sl)) 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()))?;
let mut used = vec![];
media_refs::visit::master(m, &mut |id: &MediaId| used.push(*id));
copy_media(doc, &src, &used, &mut media_map);
let mut m: deckcraft_model::Master = with_media(m, &media_map)?;
let mut m = m.clone();
media_refs::visit_mut::master(&mut m, &mut remap(&media_map));
m.id = MasterId(doc.alloc_id());
renumber(doc, &mut m.shapes);
for l in &mut m.layouts {
@@ -159,10 +136,10 @@ fn reuse(s: &mut Session, p: &Value) -> Result<Value> {
}
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)?;
let Some(from) = src.slides.get(i) else { continue };
copy_media(doc, &src, &media_refs::slide_media(from), &mut media_map);
let mut sl: Slide = from.as_ref().clone();
media_refs::visit_mut::slide(&mut sl, &mut remap(&media_map));
sl.id = SlideId(doc.alloc_id());
let map = renumber(doc, &mut sl.shapes);
for a in &mut sl.animations {
@@ -188,39 +165,97 @@ fn reuse(s: &mut Session, p: &Value) -> Result<Value> {
})
}
/// 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 {
/// Copy the source media `used` into `doc` under fresh ids, once each (identical bytes already in
/// `doc` are shared; a linked item keeps its link).
fn copy_media(doc: &mut Presentation, src: &Presentation, used: &[MediaId], map: &mut HashMap<MediaId, MediaId>) {
for &id in used {
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);
}
let Some(m) = src.media(id) else { continue };
let new = if m.link.is_some() {
let mut item = m.clone();
item.id = MediaId(doc.alloc_id());
let new = item.id;
doc.media.push(item);
new
} else {
doc.add_media(&m.name, &m.content_type, m.data.as_ref().clone())
};
map.insert(id, new);
}
}
/// 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);
}
}
#[cfg(test)]
mod tests {
use deckcraft_model::style::{Artistic, Fill, PictureFill};
use deckcraft_model::text::{Action, Bullet, Hyperlink, TextBody};
use deckcraft_model::{Background, MediaClip, Shape, ShapeKind, defaults};
use serde_json::json;
use super::*;
/// Every kind of media a slide can hold comes along, each pointing at its own bytes: the
/// artistic-effect original, a background picture, poster, sounds and picture bullets too.
#[test]
fn reused_slides_bring_every_media_reference() {
let mut src = defaults::new_presentation(None);
let blobs: Vec<Vec<u8>> = (0..8u8).map(|i| vec![0xD0, i, i, i]).collect();
let ids: Vec<MediaId> =
blobs.iter().enumerate().map(|(i, b)| src.add_media(&format!("m{i}.bin"), "application/octet-stream", b.clone())).collect();
let id = |i: usize| ids.get(i).copied().unwrap_or_default();
let mut text = TextBody::from_text("bullet");
if let Some(p) = text.paragraphs.first_mut() {
p.props.bullet = Some(Bullet::Picture { media: id(6) });
}
Value::Array(a) => a.iter().for_each(|x| collect_media(x, out, depth + 1)),
_ => {}
let slide = Arc::make_mut(src.slides.first_mut().expect("slide"));
slide.shapes = vec![
Shape {
id: ShapeId(9001),
kind: ShapeKind::Picture {
fill: PictureFill {
media: id(0),
artistic: Some(Artistic { effect: "artisticBlur".into(), original: id(1) }),
..Default::default()
},
},
..Default::default()
},
Shape {
id: ShapeId(9002),
kind: ShapeKind::Media(MediaClip { media: id(2), poster: Some(id(3)), ..Default::default() }),
..Default::default()
},
Shape {
id: ShapeId(9003),
click: Some(Hyperlink {
action: Action::NextSlide,
tooltip: String::new(),
highlight_click: false,
sound: Some(id(5)),
stop_sound: false,
}),
text: Some(text),
..Default::default()
},
];
slide.background = Some(Background::Fill { fill: Fill::Picture(PictureFill { media: id(4), ..Default::default() }) });
slide.transition = Some(deckcraft_model::anim::Transition { sound: Some(id(7)), ..Default::default() });
let bytes = deckcraft_format::save(&src).expect("save");
let mut s = Session::with_new();
let own = vec![1u8, 2, 3];
let own_id = s.edit(|doc, _| Ok(doc.add_media("own.bin", "application/octet-stream", own.clone()))).expect("own media");
s.execute("slide.reuse", &json!({"data": super::super::base64_encode(&bytes), "name": "src.deckcraft", "slides": [0]})).expect("reuse");
let doc = &s.doc().expect("doc").doc;
assert_eq!(doc.media(own_id).map(|m| m.data.as_slice() == own.as_slice()), Some(true), "own media untouched");
let reused = doc.slides.get(1).expect("reused slide");
let got: Vec<Vec<u8>> =
media_refs::slide_media(reused).iter().map(|m| doc.media(*m).map(|m| m.data.as_ref().clone()).unwrap_or_default()).collect();
let mut got = got;
got.sort();
let mut want = blobs.clone();
want.sort();
assert_eq!(got, want, "every reference resolves to its own copied bytes");
}
}
+1
View File
@@ -17,6 +17,7 @@ pub mod icons;
pub mod inkshape;
pub mod keys;
pub mod links;
pub mod media_refs;
pub mod recovery;
pub mod sample;
pub mod sounds;
+390
View File
@@ -0,0 +1,390 @@
//! Every [`MediaId`] the model can reference, found by type rather than by scanning JSON keys.
//!
//! One walker, written once ([`walkers!`]) and expanded twice: [`visit`] reads ids, [`visit_mut`]
//! rewrites them in place. It covers pictures (and their artistic-effect originals), picture fills
//! wherever a fill can sit (shapes, lines, text runs, table cells and styles, chart series and
//! points, backgrounds, theme format schemes), media clips and their posters, 3-D models, OLE and
//! SmartArt previews, SmartArt node pictures, picture bullets, hyperlink, transition and
//! animation sounds, and embedded font faces. Adding a `MediaId` field to the model means adding
//! it here; the shape kinds are matched exhaustively so a new kind can't be missed.
use std::collections::HashSet;
use deckcraft_model::{MediaId, Presentation, Shape, Slide};
macro_rules! walkers {
($($m:tt)?) => {
use deckcraft_model::chart::Chart;
use deckcraft_model::style::{Fill, Line, PictureFill};
use deckcraft_model::table::{Table, TableBorder, TablePartStyle, TableStyleDef};
use deckcraft_model::text::{Hyperlink, ListStyle, ParaProps, RunProps, TextBody, Bullet};
use deckcraft_model::theme::Theme;
use deckcraft_model::{Background, EmbeddedFont, Layout, Master, MediaId, Shape, ShapeKind, Slide};
/// Deepest group nesting walked (the model's other walkers stop at 64 too).
const MAX_DEPTH: usize = 64;
pub type F<'a> = dyn FnMut(&$($m)? MediaId) + 'a;
pub fn picture(p: &$($m)? PictureFill, f: &mut F) {
f(&$($m)? p.media);
if let Some(a) = &$($m)? p.artistic {
f(&$($m)? a.original);
}
}
pub fn fill(x: &$($m)? Fill, f: &mut F) {
if let Fill::Picture(p) = x {
picture(p, f);
}
}
fn opt_fill(x: &$($m)? Option<Fill>, f: &mut F) {
if let Some(x) = x {
fill(x, f);
}
}
pub fn line(l: &$($m)? Line, f: &mut F) {
opt_fill(&$($m)? l.fill, f);
}
fn opt_line(l: &$($m)? Option<Line>, f: &mut F) {
if let Some(l) = l {
line(l, f);
}
}
fn link(h: &$($m)? Option<Hyperlink>, f: &mut F) {
if let Some(Hyperlink { sound: Some(id), .. }) = h {
f(id);
}
}
fn run(r: &$($m)? RunProps, f: &mut F) {
opt_fill(&$($m)? r.fill, f);
opt_line(&$($m)? r.outline, f);
link(&$($m)? r.link, f);
link(&$($m)? r.hover, f);
}
fn para(p: &$($m)? ParaProps, f: &mut F) {
if let Some(Bullet::Picture { media }) = &$($m)? p.bullet {
f(media);
}
}
pub fn list(l: &$($m)? ListStyle, f: &mut F) {
for lv in (&$($m)? l.levels).into_iter().flatten() {
para(&$($m)? lv.para, f);
run(&$($m)? lv.run, f);
}
}
pub fn text(t: &$($m)? TextBody, f: &mut F) {
list(&$($m)? t.list_style, f);
for p in &$($m)? t.paragraphs {
para(&$($m)? p.props, f);
run(&$($m)? p.def_props, f);
run(&$($m)? p.end_props, f);
for r in &$($m)? p.runs {
run(&$($m)? r.props, f);
}
}
}
fn table(t: &$($m)? Table, f: &mut F) {
opt_fill(&$($m)? t.background, f);
for row in &$($m)? t.rows {
for c in &$($m)? row.cells {
text(&$($m)? c.text, f);
opt_fill(&$($m)? c.fill, f);
for b in &$($m)? c.borders {
opt_line(b, f);
}
opt_line(&$($m)? c.diag_down, f);
opt_line(&$($m)? c.diag_up, f);
}
}
}
fn table_part(s: &$($m)? TablePartStyle, f: &mut F) {
opt_fill(&$($m)? s.fill, f);
for b in &$($m)? s.borders {
if let Some(TableBorder::Line { line: l }) = b {
line(l, f);
}
}
}
pub fn table_style(t: &$($m)? TableStyleDef, f: &mut F) {
for (_, s) in &$($m)? t.parts {
table_part(s, f);
}
if let Some(s) = &$($m)? t.background {
table_part(s, f);
}
}
fn chart(c: &$($m)? Chart, f: &mut F) {
for s in &$($m)? c.series {
opt_fill(&$($m)? s.fill, f);
opt_line(&$($m)? s.line, f);
for (_, x) in &$($m)? s.point_fills {
fill(x, f);
}
}
opt_fill(&$($m)? c.plot_fill, f);
opt_fill(&$($m)? c.chart_fill, f);
}
fn shape_at(s: &$($m)? Shape, f: &mut F, depth: usize) {
if depth > MAX_DEPTH {
return;
}
match &$($m)? s.kind {
ShapeKind::Shape | ShapeKind::Connector { .. } | ShapeKind::Ink { .. } => {}
ShapeKind::Picture { fill } => picture(fill, f),
ShapeKind::Group { children, .. } => {
for c in children {
shape_at(c, f, depth + 1);
}
}
ShapeKind::Table(t) => table(t, f),
ShapeKind::Chart(c) => chart(c, f),
ShapeKind::Media(m) => {
f(&$($m)? m.media);
if let Some(p) = &$($m)? m.poster {
f(p);
}
}
ShapeKind::Model3d { media, poster } => {
f(media);
if let Some(p) = poster {
f(p);
}
}
ShapeKind::Opaque { preview, .. } => {
if let Some(p) = preview {
f(p);
}
}
}
opt_fill(&$($m)? s.fill, f);
opt_line(&$($m)? s.line, f);
if let Some(t) = &$($m)? s.text {
text(t, f);
}
link(&$($m)? s.click, f);
link(&$($m)? s.hover, f);
if let Some(d) = &$($m)? s.diagram {
for n in &$($m)? d.nodes {
if let Some(p) = &$($m)? n.picture {
f(p);
}
}
}
}
/// A shape and, for a group, everything in it.
pub fn shape(s: &$($m)? Shape, f: &mut F) {
shape_at(s, f, 0);
}
pub fn shapes(v: &$($m)? [Shape], f: &mut F) {
for s in v {
shape_at(s, f, 0);
}
}
fn background(b: &$($m)? Option<Background>, f: &mut F) {
if let Some(Background::Fill { fill: x }) = b {
fill(x, f);
}
}
/// A slide: shapes, background, transition and animation sounds, notes.
pub fn slide(s: &$($m)? Slide, f: &mut F) {
shapes(&$($m)? s.shapes, f);
background(&$($m)? s.background, f);
if let Some(t) = &$($m)? s.transition
&& let Some(id) = &$($m)? t.sound
{
f(id);
}
for a in &$($m)? s.animations {
if let Some(id) = &$($m)? a.sound {
f(id);
}
}
text(&$($m)? s.notes, f);
}
pub fn layout(l: &$($m)? Layout, f: &mut F) {
shapes(&$($m)? l.shapes, f);
background(&$($m)? l.background, f);
}
pub fn theme(t: &$($m)? Theme, f: &mut F) {
for x in &$($m)? t.format.fills {
fill(x, f);
}
for x in &$($m)? t.format.bg_fills {
fill(x, f);
}
for l in &$($m)? t.format.lines {
line(l, f);
}
}
/// A master with its theme, text styles and layouts.
pub fn master(m: &$($m)? Master, f: &mut F) {
theme(&$($m)? m.theme, f);
shapes(&$($m)? m.shapes, f);
background(&$($m)? m.background, f);
list(&$($m)? m.title_style, f);
list(&$($m)? m.body_style, f);
list(&$($m)? m.other_style, f);
for l in &$($m)? m.layouts {
layout(l, f);
}
}
pub fn font(e: &$($m)? EmbeddedFont, f: &mut F) {
for id in [&$($m)? e.regular, &$($m)? e.bold, &$($m)? e.italic, &$($m)? e.bold_italic].into_iter().flatten() {
f(id);
}
}
};
}
/// Read every media id under a part of the model.
pub mod visit {
walkers!();
}
/// Rewrite every media id under a part of the model in place.
pub mod visit_mut {
walkers!(mut);
}
/// Every media reference in a presentation: slides, masters (with their layouts and themes),
/// the notes and handout masters, default text style, table styles and embedded fonts.
pub fn visit_presentation(p: &Presentation, f: &mut visit::F) {
for s in &p.slides {
visit::slide(s, f);
}
for m in p.masters.iter().chain(p.notes_master.iter()).chain(p.handout_master.iter()) {
visit::master(m, f);
}
visit::list(&p.default_text_style, f);
for t in &p.table_styles {
visit::table_style(t, f);
}
for e in &p.embedded_fonts {
visit::font(e, f);
}
}
/// The media ids a presentation references, each once, in the order first met.
pub fn presentation_media(p: &Presentation) -> Vec<MediaId> {
collect(|f| visit_presentation(p, f))
}
/// The media ids a slide references (shapes, background, sounds, notes), each once.
pub fn slide_media(s: &Slide) -> Vec<MediaId> {
collect(|f| visit::slide(s, f))
}
/// The media ids shapes reference (groups included), each once.
pub fn shapes_media(v: &[Shape]) -> Vec<MediaId> {
collect(|f| visit::shapes(v, f))
}
fn collect(walk: impl FnOnce(&mut visit::F)) -> Vec<MediaId> {
let mut seen = HashSet::new();
let mut out = vec![];
walk(&mut |id: &MediaId| {
if seen.insert(*id) {
out.push(*id);
}
});
out
}
#[cfg(test)]
mod tests {
use std::sync::Arc;
use deckcraft_model::style::{Artistic, Fill, PictureFill};
use deckcraft_model::text::{Action, Bullet, Hyperlink};
use deckcraft_model::{Background, MediaClip, ShapeKind, defaults};
use super::*;
fn pf(media: u32) -> PictureFill {
PictureFill { media: MediaId(media), ..Default::default() }
}
/// One reference of each kind, every one a different id.
fn deck() -> Presentation {
let mut p = defaults::new_presentation(None);
let mut s = (*p.slides.first().cloned().unwrap_or_default()).clone();
let pic = Shape {
kind: ShapeKind::Picture {
fill: PictureFill { artistic: Some(Artistic { effect: "artisticBlur".into(), original: MediaId(9002) }), ..pf(9001) },
},
..Default::default()
};
let clip = Shape {
kind: ShapeKind::Media(MediaClip { media: MediaId(9003), poster: Some(MediaId(9004)), ..Default::default() }),
..Default::default()
};
let mut text = deckcraft_model::text::TextBody::from_text("x");
if let Some(para) = text.paragraphs.first_mut() {
para.props.bullet = Some(Bullet::Picture { media: MediaId(9007) });
}
let filled = Shape {
fill: Some(Fill::Picture(pf(9005))),
click: Some(Hyperlink {
action: Action::NextSlide,
tooltip: String::new(),
highlight_click: false,
sound: Some(MediaId(9006)),
stop_sound: false,
}),
text: Some(text),
..Default::default()
};
let group = Shape { kind: ShapeKind::Group { children: vec![pic, clip], child: Default::default() }, ..Default::default() };
s.shapes = vec![group, filled];
s.background = Some(Background::Fill { fill: Fill::Picture(pf(9008)) });
p.slides = vec![Arc::new(s)];
if let Some(m) = p.masters.first_mut() {
Arc::make_mut(m).theme.format.bg_fills.push(Fill::Picture(pf(9009)));
}
p.embedded_fonts.push(deckcraft_model::EmbeddedFont { regular: Some(MediaId(9010)), ..Default::default() });
p
}
#[test]
fn every_kind_of_reference_is_found() {
let p = deck();
let mut got = presentation_media(&p);
got.sort();
assert_eq!(got, (9001..=9010).map(MediaId).collect::<Vec<_>>());
let mut slide = p.slides.first().map(|s| slide_media(s)).unwrap_or_default();
slide.sort();
assert_eq!(slide, (9001..=9008).map(MediaId).collect::<Vec<_>>());
}
#[test]
fn visit_mut_rewrites_every_reference() {
let p = deck();
let mut s = (**p.slides.first().expect("slide")).clone();
visit_mut::slide(&mut s, &mut |id: &mut MediaId| id.0 += 100);
let mut got = slide_media(&s);
got.sort();
assert_eq!(got, (9101..=9108).map(MediaId).collect::<Vec<_>>());
}
}
+2 -3
View File
@@ -349,11 +349,10 @@ selected effect, and most of the effect gallery. Detail in
- **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), 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
Zoom (summary/section/slide, 6–10 h), reuse slides pane with thumbnails (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