From 5ef510e57bb307ef34ce866f98bd8902d17bf2b4 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Wed, 14 Oct 2026 15:35:50 -0400 Subject: [PATCH] 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 --- crates/engine/src/cmd/reuse.rs | 167 ++++++++------ crates/engine/src/lib.rs | 1 + crates/engine/src/media_refs.rs | 390 ++++++++++++++++++++++++++++++++ docs/gaps.md | 5 +- 4 files changed, 494 insertions(+), 69 deletions(-) create mode 100644 crates/engine/src/media_refs.rs diff --git a/crates/engine/src/cmd/reuse.rs b/crates/engine/src/cmd/reuse.rs index d5e31b11..1025b01e 100644 --- a/crates/engine/src/cmd/reuse.rs +++ b/crates/engine/src/cmd/reuse.rs @@ -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 { vec![cmd!( @@ -26,40 +26,15 @@ pub fn specs() -> Vec { )] } -/// 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); - } +/// Point every media id under `x` at its copy in the destination (`map`: source → destination). +fn remap(map: &HashMap) -> 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(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)); @@ -132,20 +107,22 @@ fn reuse(s: &mut Session, p: &Value) -> Result { 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(); + 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 }; + 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 { } 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 { }) } -/// 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 { +/// 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) { + 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, 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> = (0..8u8).map(|i| vec![0xD0, i, i, i]).collect(); + let ids: Vec = + 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> = + 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"); } } diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index b6c0bbe7..60eebaf8 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -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; diff --git a/crates/engine/src/media_refs.rs b/crates/engine/src/media_refs.rs new file mode 100644 index 00000000..33bc6a37 --- /dev/null +++ b/crates/engine/src/media_refs.rs @@ -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, 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, f: &mut F) { + if let Some(l) = l { + line(l, f); + } + } + + fn link(h: &$($m)? Option, 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, 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 { + 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 { + collect(|f| visit::slide(s, f)) +} + +/// The media ids shapes reference (groups included), each once. +pub fn shapes_media(v: &[Shape]) -> Vec { + collect(|f| visit::shapes(v, f)) +} + +fn collect(walk: impl FnOnce(&mut visit::F)) -> Vec { + 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::>()); + let mut slide = p.slides.first().map(|s| slide_media(s)).unwrap_or_default(); + slide.sort(); + assert_eq!(slide, (9001..=9008).map(MediaId).collect::>()); + } + + #[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::>()); + } +} diff --git a/docs/gaps.md b/docs/gaps.md index 89485553..5723c8ba 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -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