diff --git a/crates/pptx/README.md b/crates/pptx/README.md index 01de066bb..b71778d4a 100644 --- a/crates/pptx/README.md +++ b/crates/pptx/README.md @@ -43,4 +43,8 @@ tags, kept parts and document properties. A slide whose layout is missing goes o a presentation with no master gets the caller's. `Defaults` also gives the text styles written where a master or the presentation has none, and the program name for `docProps/app.xml`. -Not read or written yet (later changes): transitions and animations, speaker notes, comments. +Transitions (`p:transition`, with the PowerPoint 2010 and later effects) and animations +(`p:timing`, flattened into `anim::Animation` on reading and built back into the timing tree on +writing, with media volume and playback settings) are read and written. + +Not read or written yet (later changes): speaker notes, comments. diff --git a/crates/pptx/src/lib.rs b/crates/pptx/src/lib.rs index bd47542ca..4c8b708b1 100644 --- a/crates/pptx/src/lib.rs +++ b/crates/pptx/src/lib.rs @@ -12,6 +12,7 @@ #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] pub mod anim; +mod maps; mod package; pub mod placeholder; mod read; diff --git a/crates/pptx/src/maps.rs b/crates/pptx/src/maps.rs new file mode 100644 index 000000000..4596e0e40 --- /dev/null +++ b/crates/pptx/src/maps.rs @@ -0,0 +1,453 @@ +//! Mappings between our transition / animation ids and PresentationML elements and preset +//! numbers, shared by the reader and the writer. + +use crate::anim::{ANIMATIONS, AnimClass}; + +use craft_xml::{El, Writer, value}; + +/// (our kind, namespace prefix, element) for transitions that map one-to-one. +const SIMPLE: &[(&str, &str, &str)] = + &[("dissolve", "p", "dissolve"), ("random", "p", "random"), ("honeycomb", "p14", "honeycomb"), ("flash", "p14", "flash")]; + +/// Read a transition effect element → (kind, option). `None` for effects we don't model. +pub fn transition_from_xml(e: &El) -> Option<(String, String)> { + let (k, o) = transition_from_xml_raw(e)?; + // Kinds without options keep none. + let has_opts = crate::anim::TRANSITIONS.iter().find(|t| t.0 == k).is_none_or(|t| !t.4.is_empty()); + Some((k, if has_opts { o } else { String::new() })) +} + +/// Preset transitions (`p15:prstTrans`) whose "Left" option PowerPoint writes with `invX="1"` +/// (the others write it for "Right"), as observed on PowerPoint's own files. +const INVX_LEFT: &[&str] = &["wind", "airplane", "origami"]; + +fn transition_from_xml_raw(e: &El) -> Option<(String, String)> { + // Defaults are what PowerPoint leaves out: e.g. it writes Push Left as ``. + let dir = |d: &str| e.attr_local("dir").unwrap_or(d).to_string(); + let k = |s: &str| s.to_string(); + Some(match e.local() { + "fade" => (k("fade"), if e.attr_local("thruBlk").and_then(value::to_bool) == Some(true) { k("throughBlack") } else { k("smoothly") }), + "cut" => (k("cut"), if e.attr_local("thruBlk").and_then(value::to_bool) == Some(true) { k("throughBlack") } else { String::new() }), + "push" => (k("push"), dir("l")), + "wipe" => (k("wipe"), dir("l")), + // Diagonal wipes. + "strips" => (k("wipe"), dir("lu")), + "split" => { + let o = e.attr_local("orient").unwrap_or("horz"); + let d = e.attr_local("dir").unwrap_or("out"); + (k("split"), format!("{o}{}", if d == "in" { "In" } else { "Out" })) + } + "randomBar" => (k("randomBar"), dir("horz")), + "circle" => (k("shape"), k("circle")), + "diamond" => (k("shape"), k("diamond")), + "plus" => (k("shape"), k("plus")), + // Shape In / Out (the slide zooming in a box). + "zoom" => (k("shape"), if e.attr_local("dir") == Some("in") { k("in") } else { k("out") }), + // The Zoom transition, and its Zoom and Rotate option. + "warp" => (k("zoom"), if e.attr_local("dir") == Some("in") { k("in") } else { k("out") }), + "newsflash" => (k("zoom"), k("rotate")), + "pull" => (k("uncover"), dir("l")), + "cover" => (k("cover"), dir("l")), + "checker" => (k("checker"), dir("horz")), + "blinds" => (k("blinds"), dir("horz")), + "comb" => (k("comb"), dir("horz")), + "wheel" => (k("clock"), k("clockwise")), + "wheelReverse" => (k("clock"), k("counterClockwise")), + "wedge" => (k("clock"), k("wedge")), + "vortex" => (k("vortex"), dir("l")), + "switch" => (k("switch"), dir("r")), + "flip" => (k("flip"), dir("r")), + "ripple" => (k("ripple"), dir("center")), + "doors" => (k("doors"), dir("horz")), + "window" => (k("window"), dir("horz")), + "ferris" => (k("ferris"), dir("l")), + "gallery" => (k("gallery"), dir("l")), + "conveyor" => (k("conveyor"), dir("l")), + "pan" => (k("pan"), dir("l")), + "glitter" => { + let d = dir("l"); + if e.attr_local("pattern") == Some("hexagon") { (k("glitter"), format!("hex{}", d.to_ascii_uppercase())) } else { (k("glitter"), d) } + } + "shred" => { + // `crush` is what DeckCraft wrote before it knew PowerPoint's `rectangle`. + let particles = matches!(e.attr_local("pattern"), Some("rectangle" | "crush")); + let out = e.attr_local("dir") == Some("out"); + (k("shred"), format!("{}{}", if particles { "particles" } else { "strips" }, if out { "Out" } else { "In" })) + } + "reveal" => { + let black = e.attr_local("thruBlk").and_then(value::to_bool) == Some(true); + let right = e.attr_local("dir") == Some("r"); + (k("reveal"), format!("{}{}", if black { "black" } else { "smoothly" }, if right { "Right" } else { "Left" })) + } + "flythrough" => { + let out = e.attr_local("dir") == Some("out"); + let b = e.attr_local("hasBounce").and_then(value::to_bool) == Some(true); + (k("flythrough"), format!("{}{}", if out { "out" } else { "in" }, if b { "Bounce" } else { "" })) + } + "prism" => { + let content = e.attr_local("isContent").and_then(value::to_bool) == Some(true); + let inv = e.attr_local("isInverted").and_then(value::to_bool) == Some(true); + let kind = match (content, inv) { + (false, false) => "cube", + (false, true) => "box", + (true, false) => "rotate", + (true, true) => "orbit", + }; + (k(kind), dir("l")) + } + "prstTrans" => { + let prst = e.attr_local("prst").unwrap_or("fallOver"); + let inv = e.attr_local("invX").and_then(value::to_bool) == Some(true); + if prst == "pageCurlSingle" { + return Some((k("pageCurlDouble"), if inv { k("singleR") } else { k("singleL") })); + } + let known = crate::anim::TRANSITIONS.iter().any(|t| t.0 == prst); + if !known { + return None; + } + let left = if INVX_LEFT.contains(&prst) { inv } else { !inv }; + (k(prst), if left { k("l") } else { k("r") }) + } + "morph" => ( + k("morph"), + match e.attr_local("option") { + Some("byWord") => k("words"), + Some("byChar") => k("characters"), + _ => k("objects"), + }, + ), + other => (k(SIMPLE.iter().find(|s| s.2 == other)?.0), String::new()), + }) +} + +/// Write the effect element for a transition. Returns the namespace prefix it needs +/// (`p`, `p14`, `p15`, `p159`), or `None` when the kind is unknown. +pub fn transition_to_xml(w: &mut Writer, kind: &str, option: &str) -> Option<&'static str> { + let lr = |d: &'static str| match option { + "l" | "r" => option.to_string(), + _ => d.to_string(), + }; + let lrud = |d: &'static str| match option { + "l" | "r" | "u" | "d" => option.to_string(), + _ => d.to_string(), + }; + let hv = |d: &'static str| match option { + "horz" | "vert" => option.to_string(), + _ => d.to_string(), + }; + let eight = |d: &'static str| match option { + "l" | "r" | "u" | "d" | "lu" | "ru" | "ld" | "rd" => option.to_string(), + _ => d.to_string(), + }; + match kind { + "fade" => w.tag("p:fade").attr_opt("thruBlk", (option == "throughBlack").then_some(1)).empty(), + "cut" => w.tag("p:cut").attr_opt("thruBlk", (option == "throughBlack").then_some(1)).empty(), + "push" => w.tag("p:push").attr("dir", lrud("u")).empty(), + "wipe" => match option { + "lu" | "ru" | "ld" | "rd" => w.tag("p:strips").attr("dir", option).empty(), + _ => w.tag("p:wipe").attr("dir", lrud("r")).empty(), + }, + "split" => { + let (o, d) = match option { + "vertIn" => ("vert", "in"), + "vertOut" => ("vert", "out"), + "horzIn" => ("horz", "in"), + _ => ("horz", "out"), + }; + w.tag("p:split").attr("orient", o).attr("dir", d).empty(); + } + "randomBar" => w.tag("p:randomBar").attr("dir", hv("vert")).empty(), + "shape" => match option { + "diamond" => w.empty("p:diamond", &[]), + "plus" => w.empty("p:plus", &[]), + "in" | "out" => w.tag("p:zoom").attr("dir", option).empty(), + _ => w.empty("p:circle", &[]), + }, + "zoom" => match option { + "rotate" => w.empty("p:newsflash", &[]), + _ => { + w.tag("p14:warp").attr("dir", if option == "out" { "out" } else { "in" }).empty(); + return Some("p14"); + } + }, + "uncover" => w.tag("p:pull").attr("dir", eight("l")).empty(), + "cover" => w.tag("p:cover").attr("dir", eight("l")).empty(), + "checker" => w.tag("p:checker").attr("dir", hv("horz")).empty(), + "blinds" => w.tag("p:blinds").attr("dir", hv("horz")).empty(), + "comb" => w.tag("p:comb").attr("dir", hv("horz")).empty(), + "clock" => match option { + "counterClockwise" => { + w.tag("p14:wheelReverse").attr("spokes", 1).empty(); + return Some("p14"); + } + "wedge" => w.empty("p:wedge", &[]), + _ => w.tag("p:wheel").attr("spokes", 1).empty(), + }, + "dissolve" => w.empty("p:dissolve", &[]), + "random" => w.empty("p:random", &[]), + "honeycomb" => { + w.empty("p14:honeycomb", &[]); + return Some("p14"); + } + "flash" => { + w.empty("p14:flash", &[]); + return Some("p14"); + } + "vortex" => { + w.tag("p14:vortex").attr("dir", lrud("l")).empty(); + return Some("p14"); + } + "glitter" => { + let hex = option.starts_with("hex"); + let d = option.trim_start_matches("hex").to_ascii_lowercase(); + let d = if matches!(d.as_str(), "l" | "r" | "u" | "d") { d } else { "l".to_string() }; + w.tag("p14:glitter").attr("dir", d).attr_opt("pattern", hex.then_some("hexagon")).empty(); + return Some("p14"); + } + "switch" | "flip" | "gallery" | "conveyor" | "ferris" => { + let name = match kind { + "switch" => "p14:switch", + "flip" => "p14:flip", + "gallery" => "p14:gallery", + "conveyor" => "p14:conveyor", + _ => "p14:ferris", + }; + w.tag(name).attr("dir", lr("l")).empty(); + return Some("p14"); + } + "ripple" => { + let d = match option { + "lu" | "ru" | "ld" | "rd" => option, + _ => "center", + }; + w.tag("p14:ripple").attr("dir", d).empty(); + return Some("p14"); + } + "doors" | "window" => { + let name = if kind == "doors" { "p14:doors" } else { "p14:window" }; + w.tag(name).attr("dir", hv("vert")).empty(); + return Some("p14"); + } + "pan" => { + w.tag("p14:pan").attr("dir", lrud("d")).empty(); + return Some("p14"); + } + "shred" => { + let particles = option.starts_with("particles"); + w.tag("p14:shred") + .attr("pattern", if particles { "rectangle" } else { "strip" }) + .attr("dir", if option.ends_with("Out") { "out" } else { "in" }) + .empty(); + return Some("p14"); + } + "reveal" => { + w.tag("p14:reveal") + .attr_opt("thruBlk", (option.starts_with("black")).then_some(1)) + .attr("dir", if option.ends_with("Right") { "r" } else { "l" }) + .empty(); + return Some("p14"); + } + "flythrough" => { + w.tag("p14:flythrough") + .attr("dir", if option.starts_with("out") { "out" } else { "in" }) + .attr_opt("hasBounce", (option.ends_with("Bounce")).then_some(1)) + .empty(); + return Some("p14"); + } + "cube" | "box" | "rotate" | "orbit" => { + w.tag("p14:prism") + .attr("dir", lrud("l")) + .attr_opt("isContent", (matches!(kind, "rotate" | "orbit")).then_some(1)) + .attr_opt("isInverted", (matches!(kind, "box" | "orbit")).then_some(1)) + .empty(); + return Some("p14"); + } + "pageCurlDouble" if option.starts_with("single") => { + w.tag("p15:prstTrans").attr("prst", "pageCurlSingle").attr_opt("invX", (option == "singleR").then_some(1)).empty(); + return Some("p15"); + } + "fallOver" | "drape" | "curtains" | "wind" | "prestige" | "fracture" | "crush" | "peelOff" | "pageCurlDouble" | "airplane" | "origami" => { + let inv = if INVX_LEFT.contains(&kind) { option == "l" } else { option == "r" }; + w.tag("p15:prstTrans").attr("prst", kind).attr_opt("invX", (inv).then_some(1)).empty(); + return Some("p15"); + } + "morph" => { + let o = match option { + "words" => "byWord", + "characters" => "byChar", + _ => "byObject", + }; + w.tag("p159:morph").attr("option", o).empty(); + return Some("p159"); + } + _ => return None, + } + Some("p") +} + +// --------------------------------------------------------------------------------------------- +// Animations + +/// Effect options ↔ preset subtypes. +const SUBTYPES: &[(&str, &[(&str, u32)])] = &[ + ("fly", DIR8), + ("flyOut", DIR8), + ("peek", DIR4), + ("wipe", DIR4), + ("wipeOut", DIR4), + ("strips", &[("lu", 9), ("ru", 3), ("ld", 12), ("rd", 6)]), + ("split", SPLIT), + ("splitOut", SPLIT), + ("zoom", INOUT), + ("zoomOut", INOUT), + ("box", INOUT), + ("circle", INOUT), + ("diamond", INOUT), + ("randomBars", HV), + ("randomBarsOut", HV), + ("blinds", HV), + ("swivel", HV), + ("swivelOut", HV), + ("blindsOut", HV), + ("boxOut", INOUT), + ("checkerboardOut", &[("across", 10), ("down", 5)]), + ("diamondOut", INOUT), + ("peekOut", DIR4), + ("stripsOut", &[("lu", 9), ("ru", 3), ("ld", 12), ("rd", 6)]), + ("checkerboard", &[("across", 10), ("down", 5)]), + ("wheel", WHEEL), + ("wheelOut", WHEEL), + ("float", &[("u", 0), ("d", 4)]), + ("floatOut", &[("u", 0), ("d", 4)]), + ( + "shape", + &[("circleIn", 16), ("circleOut", 32), ("boxIn", 16), ("boxOut", 32), ("diamondIn", 16), ("diamondOut", 32), ("plusIn", 16), ("plusOut", 32)], + ), + ("shapeOut", INOUT_SHAPE), + ("lines", &[("down", 4), ("up", 1), ("right", 2), ("left", 8)]), + ("arcs", &[("down", 4), ("up", 1), ("right", 2), ("left", 8)]), + ("turns", &[("down", 4), ("up", 1), ("right", 2), ("left", 8)]), +]; +const DIR8: &[(&str, u32)] = &[("b", 4), ("lb", 12), ("l", 8), ("lt", 9), ("t", 1), ("rt", 3), ("r", 2), ("rb", 6)]; +const DIR4: &[(&str, u32)] = &[("b", 4), ("l", 8), ("t", 1), ("r", 2)]; +const SPLIT: &[(&str, u32)] = &[("horzIn", 21), ("horzOut", 42), ("vertIn", 26), ("vertOut", 37)]; +const INOUT: &[(&str, u32)] = &[("in", 16), ("out", 32)]; +const INOUT_SHAPE: &[(&str, u32)] = &[("circleIn", 16), ("circleOut", 32)]; +const HV: &[(&str, u32)] = &[("horz", 10), ("vert", 5)]; +const WHEEL: &[(&str, u32)] = &[("1", 1), ("2", 2), ("3", 3), ("4", 4), ("8", 8)]; + +pub fn option_from_subtype(effect: &str, sub: u32) -> String { + SUBTYPES.iter().find(|(e, _)| *e == effect).and_then(|(_, m)| m.iter().find(|(_, s)| *s == sub)).map(|(o, _)| o.to_string()).unwrap_or_default() +} + +pub fn subtype_from_option(effect: &str, option: &str) -> Option { + SUBTYPES.iter().find(|(e, _)| *e == effect).and_then(|(_, m)| m.iter().find(|(o, _)| *o == option)).map(|(_, s)| *s) +} + +/// Our effect id for a preset (first match in [`ANIMATIONS`]). +pub fn effect_for_preset(class: AnimClass, preset: u32) -> Option<&'static str> { + ANIMATIONS.iter().find(|a| a.2 == class && a.3 == preset && a.0 != "customPath").map(|a| a.0) +} + +/// Effect used for presets we don't know. +pub fn fallback_effect(class: AnimClass) -> &'static str { + match class { + AnimClass::Entrance => "fade", + AnimClass::Exit => "fadeOut", + AnimClass::Emphasis => "pulse", + AnimClass::Path => "customPath", + AnimClass::Media => "play", + } +} + +/// The preset id to write for an animation. +pub fn preset_for(a: &crate::Animation) -> u32 { + // Float Down is PowerPoint's preset 47 (Float Up is 42). + if matches!(a.effect.as_str(), "float" | "floatOut") && a.option == "d" { + return 47; + } + if let Some(p) = a.preset_id { + let consistent = match effect_for_preset(a.class, p) { + Some(e) => e == a.effect, + None => a.effect == fallback_effect(a.class), + }; + if consistent { + return p; + } + } + match crate::anim::animation_info(&a.effect, a.class) { + Some(info) if info.2 == a.class => info.3, + _ => match a.class { + AnimClass::Entrance | AnimClass::Exit => 10, + AnimClass::Emphasis => 26, + AnimClass::Path => 0, + AnimClass::Media => 1, + }, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::package::parse; + + /// These exits, with their directions, fell back to a plain fade (PowerPoint's ids). + #[test] + fn exit_presets_import_as_their_own_effects() { + for (preset, sub, effect, option) in [ + (3, 10, "blindsOut", "horz"), + (4, 32, "boxOut", "out"), + (5, 5, "checkerboardOut", "down"), + (8, 16, "diamondOut", "in"), + (12, 4, "peekOut", "b"), + (18, 6, "stripsOut", "rd"), + (37, 0, "sinkDown", ""), + (49, 0, "spinnerOut", ""), + (55, 0, "contract", ""), + ] { + let e = effect_for_preset(AnimClass::Exit, preset).expect("known exit"); + assert_eq!((e, option_from_subtype(e, sub).as_str()), (effect, option), "preset {preset}"); + } + } + + #[test] + fn every_transition_kind_round_trips() { + for (kind, _, _, _, opts) in crate::anim::TRANSITIONS { + if *kind == "none" { + continue; + } + let opts: Vec<&str> = if opts.is_empty() { vec![""] } else { opts.to_vec() }; + for o in opts { + let mut w = Writer::new(); + let ns = transition_to_xml(&mut w, kind, o).unwrap_or_else(|| panic!("no xml for {kind}")); + assert!(!ns.is_empty()); + let d = parse(w.as_str().as_bytes()).unwrap(); + let (k, opt) = transition_from_xml(&d.root).unwrap_or_else(|| panic!("no read for {kind}")); + assert_eq!(k, *kind, "kind for {kind}/{o}"); + if !o.is_empty() { + assert_eq!(opt, o, "option for {kind}"); + } + } + } + } + + #[test] + fn subtypes() { + assert_eq!(subtype_from_option("fly", "b"), Some(4)); + assert_eq!(option_from_subtype("fly", 8), "l"); + assert_eq!(option_from_subtype("fade", 0), ""); + assert_eq!(effect_for_preset(AnimClass::Entrance, 2), Some("fly")); + assert_eq!(effect_for_preset(AnimClass::Emphasis, 8), Some("spin")); + } + + /// PowerPoint's own ids: Darken is 24 and Desaturate 25 (read back from its saved file). + #[test] + fn darken_and_desaturate_use_powerpoints_ids() { + assert_eq!(effect_for_preset(AnimClass::Emphasis, 24), Some("darken")); + assert_eq!(effect_for_preset(AnimClass::Emphasis, 25), Some("desaturate")); + for (effect, id) in [("darken", 24), ("desaturate", 25)] { + let a = crate::Animation { class: AnimClass::Emphasis, effect: effect.into(), ..Default::default() }; + assert_eq!(preset_for(&a), id, "{effect}"); + } + } +} diff --git a/crates/pptx/src/read.rs b/crates/pptx/src/read.rs index bb94916f9..5e6529619 100644 --- a/crates/pptx/src/read.rs +++ b/crates/pptx/src/read.rs @@ -2,6 +2,7 @@ pub(crate) mod chart; mod shapes; +mod timing; use std::cell::Cell; use std::collections::{HashMap, HashSet}; @@ -1127,6 +1128,13 @@ fn read_slide(imp: &mut Imp, part: &Part, doc: &Doc, id: SlideId, layout: Layout s.shapes.extend(pics); } } + if let Some(t) = root.child(Local("transition")) { + s.transition = timing::transition(imp, part, t); + } + if let Some(t) = root.child(Local("timing")) { + s.animations = timing::animations(imp, part, &ctx, t, &s.shapes); + timing::media_settings(t, &ctx, &mut s.shapes); + } if let Some(ext) = root.child(Local("extLst")).filter(|e| !has_rel_refs(e)) { s.raw_ext = Some(ext.elements().map(|e| part.keep(e)).collect()); } diff --git a/crates/pptx/src/read/timing.rs b/crates/pptx/src/read/timing.rs new file mode 100644 index 000000000..73f3074a8 --- /dev/null +++ b/crates/pptx/src/read/timing.rs @@ -0,0 +1,378 @@ +//! Slide transitions (`p:transition`) and animations (`p:timing`). + +use crate::anim::{AnimClass, AnimStart, Animation, BuildRole, TextBuild, Transition, build_filled, paragraph_build_roles}; +use crate::{Shape, ShapeKind, TextBody, find_shape, find_shape_mut}; + +use super::shapes::IdCtx; +use super::{Imp, Part, Src, dml}; +use crate::maps; +use craft_xml::{El, Local, value}; + +fn ms_attr(e: &El, name: &str) -> Option { + let v = e.attr_local(name)?.trim(); + if v == "indefinite" { + return None; + } + v.parse::().ok().filter(|v| v.is_finite()).map(|v| v.clamp(0.0, 86_400_000.0) as u32) +} + +pub fn transition(imp: &mut Imp, part: &Part, t: &El) -> Option { + let effect = t.elements().find(|e| !matches!(e.local(), "sndAc" | "extLst")); + let mut tr = Transition { + advance_on_click: t.attr_local("advClick").and_then(value::to_bool).unwrap_or(true), + advance_after_ms: ms_attr(t, "advTm"), + ..Default::default() + }; + match effect.and_then(maps::transition_from_xml) { + Some((k, o)) => { + tr.kind = k; + tr.option = o; + } + None => match effect { + Some(e) => { + tr.kind = e.local().to_string(); + tr.raw = Some(part.keep(t)); + } + None => tr.kind = "none".into(), + }, + } + let default_dur = crate::anim::TRANSITIONS.iter().find(|x| x.0 == tr.kind).map(|x| x.3).unwrap_or(1000); + // `spd` defaults to "fast" in the schema; PowerPoint omits both attributes for 0.5 s. + tr.duration_ms = ms_attr(t, "dur").unwrap_or(match t.attr_local("spd") { + Some("med") => 750, + Some("slow") => default_dur.max(1000), + _ => 500, + }); + if let Some(snd) = t.path(&[Local("sndAc"), Local("stSnd"), Local("snd")]) { + tr.sound = dml::media_ref(&mut Src(imp, part), snd); + tr.sound_loop = t.path(&[Local("sndAc"), Local("stSnd")]).and_then(|s| s.attr_local("loop")).is_some_and(|v| v == "1" || v == "true"); + } + tr.stop_sound = t.path(&[Local("sndAc"), Local("endSnd")]).is_some(); + if tr.kind == "none" && tr.advance_on_click && tr.advance_after_ms.is_none() && tr.sound.is_none() && !tr.stop_sound { + return None; + } + Some(tr) +} + +struct Found<'a> { + ctn: &'a El, + trigger: Option, + /// Click group the effect sits in: a new one starts at every time node whose start is `indefinite`. + group: usize, + /// Sum of the start delays of the time nodes between the click group and the effect, in ms. + offset: u32, +} + +/// Where `collect` is in the time tree: the click group and the delay accumulated inside it. +#[derive(Clone, Copy)] +struct At { + trigger: Option, + group: usize, + offset: u32, +} + +fn collect<'a>(e: &'a El, at: At, groups: &mut usize, out: &mut Vec>, depth: usize) { + if depth > 64 || out.len() > 10_000 { + return; + } + for c in e.elements() { + if c.is(Local("cTn")) && c.attr_local("presetClass").is_some() { + out.push(Found { ctn: c, trigger: at.trigger, group: at.group, offset: at.offset }); + continue; + } + let mut at = at; + if c.is(Local("seq")) + && let Some(ctn) = c.child(Local("cTn")) + && ctn.attr_local("nodeType") == Some("interactiveSeq") + { + at.trigger = ctn + .path(&[Local("stCondLst"), Local("cond"), Local("tgtEl"), Local("spTgt")]) + .and_then(|s| s.attr_local("spid").and_then(value::to_u32_lossy)); + } + if c.is(Local("cTn")) + && let Some(cond) = c.path(&[Local("stCondLst"), Local("cond")]) + { + if cond.attr_local("delay").map(str::trim) == Some("indefinite") { + *groups += 1; + at.group = *groups; + at.offset = 0; + } else { + at.offset = at.offset.saturating_add(ms_attr(cond, "delay").unwrap_or(0)); + } + } + collect(c, at, groups, out, depth + 1); + } +} + +/// How long an effect holds back an After Previous one, as the show timeline counts it. +fn span_ms(a: &Animation) -> u32 { + let iterations = if a.repeat == u32::MAX || a.repeat <= 1 { 1 } else { a.repeat.min(1_000) }; + a.duration_ms.saturating_mul(if a.auto_reverse { 2 } else { 1 }).saturating_mul(iterations) +} + +/// Start times (ms after the click) that the flat effect list plays at, effect after effect. +fn flat_starts(anims: &[&Animation]) -> Vec { + let (mut begin, mut end) = (0u32, 0u32); + anims + .iter() + .map(|a| { + if a.start == AnimStart::AfterPrevious { + begin = end; + } + let start = begin.saturating_add(a.delay_ms); + end = end.max(start.saturating_add(span_ms(a))); + start + }) + .collect() +} + +/// PowerPoint places each effect at the delays of all its enclosing time nodes, so a file may list +/// effects out of time order (a countdown written first, then effects at 5 s placed only by their +/// parent's delay). The flat list only knows "after/with previous" plus the effect's own delay, so +/// a click group listed out of time order, whose flat reading disagrees with the file, is sorted by +/// start time and its starts and delays rewritten to land every effect where PowerPoint plays it. +/// Every other group is left untouched. +fn place_by_parent_delays(out: &mut [(Animation, u32, String, bool)], at: &[(Option, usize, u32)]) { + let mut groups: Vec> = vec![]; + let mut slot: std::collections::HashMap<(Option, usize), usize> = std::collections::HashMap::new(); + for (i, &(t, g, _)) in at.iter().enumerate() { + let n = *slot.entry((t, g)).or_insert_with(|| { + groups.push(vec![]); + groups.len() - 1 + }); + groups[n].push(i); + } + for idx in groups { + // More than one click in a group means the tree isn't one click per group: leave it. + if idx.iter().skip(1).any(|&i| out[i].0.start == AnimStart::OnClick) { + continue; + } + let abs: Vec = idx.iter().map(|&i| at[i].2.saturating_add(out[i].0.delay_ms)).collect(); + // Only a file that lists effects out of time order needs this: files PowerPoint or DeckCraft + // save are in order, and their reading stays exactly as before. + if abs.is_sorted() { + continue; + } + let flat = flat_starts(&idx.iter().map(|&i| &out[i].0).collect::>()); + if flat == abs { + continue; + } + // The group's click stays on whichever effect now plays first. + let had_click = out[idx[0]].0.start == AnimStart::OnClick; + let mut order: Vec = (0..idx.len()).collect(); + order.sort_by_key(|&k| abs[k]); + let mut sorted: Vec<(Animation, u32, String, bool)> = order.iter().map(|&k| out[idx[k]].clone()).collect(); + let (mut begin, mut end) = (0u32, 0u32); + for (n, (&k, item)) in order.iter().zip(sorted.iter_mut()).enumerate() { + let a = &mut item.0; + let t = abs[k]; + if n == 0 && had_click { + a.start = AnimStart::OnClick; + a.delay_ms = t; + } else if a.start == AnimStart::AfterPrevious && t >= end { + begin = end; + a.delay_ms = t - end; + } else { + a.start = AnimStart::WithPrevious; + a.delay_ms = t.saturating_sub(begin); + } + end = end.max(t.saturating_add(span_ms(a))); + } + for (&i, item) in idx.iter().zip(sorted) { + out[i] = item; + } + } +} + +fn max_dur(e: &El, depth: usize) -> Option { + if depth > 64 { + return None; + } + let mut best: Option = None; + for c in e.elements() { + if c.is(Local("cTn")) + && let Some(d) = ms_attr(c, "dur") + && d > 1 + { + best = Some(best.map_or(d, |b: u32| b.max(d))); + } + if let Some(d) = max_dur(c, depth + 1) { + best = Some(best.map_or(d, |b| b.max(d))); + } + } + best +} + +/// Does every behaviour time node (`p:cBhvr/p:cTn`) under the effect node `c` carry `autoRev`? +fn behaviour_auto_reverses(c: &El) -> bool { + let behaviours: Vec<&El> = c.find_all(Local("cBhvr")).collect(); + !behaviours.is_empty() + && behaviours.iter().all(|b| b.child(Local("cTn")).and_then(|t| t.attr_local("autoRev").and_then(value::to_bool)).unwrap_or(false)) +} + +/// Volume, mute, Loop until Stopped, Hide While Not Playing and Play Full Screen live in the timing tree +/// (`p:video`/`p:audio` > `p:cMediaNode`), not on the picture: copy them onto the target's `MediaClip`. +pub fn media_settings(timing: &El, ctx: &IdCtx, shapes: &mut [Shape]) { + let nodes: Vec<&El> = ["video", "audio"].into_iter().flat_map(|tag| timing.find_all(Local(tag))).collect(); + for node in nodes.into_iter().take(10_000) { + let Some(cm) = node.child(Local("cMediaNode")) else { continue }; + let Some(id) = + cm.path(&[Local("tgtEl"), Local("spTgt")]).and_then(|t| t.attr_local("spid").and_then(value::to_u32_lossy)).and_then(|f| ctx.lookup(f)) + else { + continue; + }; + let Some(ShapeKind::Media(clip)) = find_shape_mut(shapes, id).map(|s| &mut s.kind) else { continue }; + if let Some(v) = cm.attr_local("vol").and_then(|v| v.trim().parse::().ok()).filter(|v| v.is_finite()) { + clip.volume = (v / 100_000.0).clamp(0.0, 1.0); + } + if cm.attr_local("mute").and_then(value::to_bool) == Some(true) { + clip.volume = 0.0; + } + if cm.attr_local("showWhenStopped").and_then(value::to_bool) == Some(false) { + clip.hide_while_not_playing = true; + } + if cm.child(Local("cTn")).and_then(|c| c.attr_local("repeatCount")) == Some("indefinite") { + clip.loop_play = true; + } + if node.attr_local("fullScrn").and_then(value::to_bool) == Some(true) { + clip.full_screen = true; + } + } +} + +/// Flatten the timing tree into the Animation Pane list. +pub fn animations(imp: &mut Imp, part: &Part, ctx: &IdCtx, timing: &El, shapes: &[Shape]) -> Vec { + let _ = (imp, part); + let Some(tn) = timing.child(Local("tnLst")) else { return vec![] }; + let mut found = vec![]; + collect(tn, At { trigger: None, group: 0, offset: 0 }, &mut 0, &mut found, 0); + // Paragraph builds (`bldP build="p"`) by (spid, grpId). + let by_para: Vec<(u32, String)> = timing + .child(Local("bldLst")) + .map(|b| { + b.children(Local("bldP")) + .filter(|p| p.attr_local("build") == Some("p")) + .filter_map(|p| Some((p.attr_local("spid").and_then(value::to_u32_lossy)?, p.attr_local("grpId").unwrap_or("0").to_string()))) + .collect() + }) + .unwrap_or_default(); + let mut out: Vec<(Animation, u32, String, bool)> = vec![]; + let mut at: Vec<(Option, usize, u32)> = vec![]; + for f in found { + let c = f.ctn; + let class = AnimClass::from_xml(c.attr_local("presetClass").unwrap_or("entr")); + let preset = c.attr_local("presetID").and_then(value::to_u32_lossy).unwrap_or(0); + let sub = c.attr_local("presetSubtype").and_then(value::to_u32_lossy).unwrap_or(0); + let Some(tgt) = c.find(Local("spTgt")) else { continue }; + let Some(file_id) = tgt.attr_local("spid").and_then(value::to_u32_lossy) else { continue }; + let Some(shape) = ctx.lookup(file_id) else { continue }; + let paragraph = tgt.path(&[Local("txEl"), Local("pRg")]).and_then(|p| p.attr_local("st").and_then(value::to_u32_lossy)); + let effect = maps::effect_for_preset(class, preset).unwrap_or_else(|| maps::fallback_effect(class)); + // PowerPoint writes Float Down as its own preset (47), not as Float with a direction. + let (effect, option) = match (class, preset) { + (AnimClass::Entrance, 47) => ("float", "d".to_string()), + (AnimClass::Exit, 47) => ("floatOut", "d".to_string()), + _ => (effect, maps::option_from_subtype(effect, sub)), + }; + let start = match c.attr_local("nodeType") { + Some("clickEffect") => AnimStart::OnClick, + Some("afterEffect") => AnimStart::AfterPrevious, + _ => AnimStart::WithPrevious, + }; + let mut a = Animation { + shape, + class, + effect: effect.to_string(), + option, + start, + duration_ms: max_dur(c, 0).unwrap_or(0), + delay_ms: c.path(&[Local("stCondLst"), Local("cond")]).and_then(|d| ms_attr(d, "delay")).unwrap_or(0), + paragraph, + trigger: f.trigger.and_then(|t| ctx.lookup(t)), + preset_id: Some(preset), + preset_subtype: Some(sub), + // PowerPoint's "Auto-reverse" is `autoRev` on the behaviour nodes (`p:cBhvr/p:cTn`); Color Pulse + // always writes it there and plays back and forth on its own, so it isn't read as the option. + auto_reverse: c.attr_local("autoRev").and_then(value::to_bool).unwrap_or(false) || (effect != "colorPulse" && behaviour_auto_reverses(c)), + // PowerPoint's "Rewind when done playing" is `fill="remove"` on the effect node. + rewind: c.attr_local("fill") == Some("remove"), + smooth_start: c.attr_local("accel").and_then(value::to_f64).map(|v| v / 100_000.0).unwrap_or(0.0), + smooth_end: c.attr_local("decel").and_then(value::to_f64).map(|v| v / 100_000.0).unwrap_or(0.0), + bounce_end: c.attr_local("bounceEnd").and_then(value::to_f64).map(|v| (v / 100_000.0).clamp(0.0, 1.0)).unwrap_or(0.0), + ..Default::default() + }; + a.repeat = match c.attr_local("repeatCount") { + Some("indefinite") => u32::MAX, + Some(v) => v.parse::().ok().filter(|v| v.is_finite()).map(|v| (v / 1000.0).round().clamp(1.0, 10_000.0) as u32).unwrap_or(1), + None => 1, + }; + if let Some(m) = c.find(Local("animMotion")) { + a.path = m.attr_local("path").map(String::from); + } + if let Some(r) = c.find(Local("animRot")) { + a.amount = r.attr_local("by").and_then(value::to_f64).map(|v| v / 60_000.0); + if a.effect == "spin" && a.amount.is_some_and(|v| v < 0.0) { + a.option = "counterClockwise".into(); + } + } else if let Some(s) = c.find(Local("animScale")).and_then(|s| s.child(Local("by"))) { + a.amount = s.attr_local("x").and_then(value::to_f64).map(|v| v / 100_000.0); + } + if let Some(clr) = c.find(Local("animClr")).and_then(|x| x.child(Local("to"))) { + a.color = dml::color(clr); + } + let grp = c.attr_local("grpId").unwrap_or("0").to_string(); + let build = paragraph.is_some() && by_para.iter().any(|(s, g)| *s == file_id && *g == grp); + out.push((a, file_id, grp, build)); + at.push((f.trigger, f.group, f.offset)); + } + place_by_parent_delays(&mut out, &at); + // A paragraph build's effects, one per paragraph, become one by-paragraph animation only when + // the timeline plays that back exactly: PowerPoint lets each paragraph keep its own start and + // effect, and other shapes' effects can sit between them. + let same_build = + |x: &(Animation, u32, String, bool), y: &(Animation, u32, String, bool)| x.3 && y.3 && x.1 == y.1 && x.2 == y.2 && x.0.trigger == y.0.trigger; + let mut anims = Vec::with_capacity(out.len()); + for run in out.chunk_by(same_build) { + let effects: Vec<&Animation> = run.iter().map(|(a, ..)| a).collect(); + let build = run.first().is_some_and(|r| r.3); + let text = effects.first().and_then(|a| find_shape(shapes, a.shape)).and_then(|s| s.text.as_ref()); + match (effects.first(), text) { + (Some(first), Some(text)) if build && plays_as_build(&effects, text) => { + anims.push(Animation { text_build: TextBuild::ByParagraph, paragraph: None, ..(*first).clone() }); + } + _ => anims.extend(effects.into_iter().cloned()), + } + } + anims +} + +/// Are `effects` (paragraph effects of one shape, in file order) what a by-paragraph build of the +/// first one plays: one effect per paragraph the build animates, in paragraph order, later top +/// paragraphs on click (after previous when the build doesn't start on click), deeper ones with +/// their parent, all with the same settings? +fn plays_as_build(effects: &[&Animation], text: &TextBody) -> bool { + let roles = paragraph_build_roles(text); + let expected = roles.iter().enumerate().filter(|(i, r)| match r { + BuildRole::Top => true, + BuildRole::Member(_) => text.paragraphs.get(*i).is_some_and(build_filled), + BuildRole::Unowned => false, + }); + let Some(first) = effects.first() else { return false }; + let settings = |a: &Animation| Animation { start: AnimStart::OnClick, paragraph: None, ..a.clone() }; + let base = settings(first); + let mut matched = 0; + for (k, (i, role)) in expected.enumerate() { + let Some(a) = effects.get(k) else { return false }; + let start_ok = match role { + BuildRole::Top if k == 0 => true, + BuildRole::Top if first.start == AnimStart::OnClick => a.start == AnimStart::OnClick, + BuildRole::Top => a.start == AnimStart::AfterPrevious, + _ => a.start == AnimStart::WithPrevious, + }; + if a.paragraph != u32::try_from(i).ok() || !start_ok || settings(a) != base { + return false; + } + matched = k + 1; + } + matched == effects.len() +} diff --git a/crates/pptx/src/write.rs b/crates/pptx/src/write.rs index f5c3113de..390330df4 100644 --- a/crates/pptx/src/write.rs +++ b/crates/pptx/src/write.rs @@ -2,6 +2,7 @@ pub(crate) mod chart; mod shapes; +mod timing; use std::collections::HashMap; use std::sync::Arc; @@ -765,8 +766,14 @@ fn master_xml(x: &mut Exp, o: &mut Out, m: &Master, layouts: &[(u64, String)]) - fn slide_xml(x: &mut Exp, o: &mut Out, s: &Slide) -> Vec { let mut w = Writer::document(); root_open(&mut w, "p:sld", A::new().o("showMasterSp", (!s.show_master_shapes).then_some("0")).o("show", s.hidden.then_some("0"))); - csld(&mut w, x, o, &s.name, s.background.as_ref(), &s.shapes, &s.tags); + let ids = csld(&mut w, x, o, &s.name, s.background.as_ref(), &s.shapes, &s.tags); clr_map_ovr(&mut w, s.color_map.as_deref()); + if let Some(t) = &s.transition { + timing::transition(&mut w, x, o, t); + } + if !s.animations.is_empty() { + timing::timing(&mut w, x, &s.animations, &s.shapes, &ids); + } ext_list(&mut w, s.raw_ext.as_deref()); w.close(); w.into_bytes() diff --git a/crates/pptx/src/write/timing.rs b/crates/pptx/src/write/timing.rs new file mode 100644 index 000000000..020384940 --- /dev/null +++ b/crates/pptx/src/write/timing.rs @@ -0,0 +1,784 @@ +//! Transitions and the animation timing tree. + +use crate::anim::{AnimClass, AnimStart, Animation, BuildRole, TextBuild, Transition, build_filled, paragraph_build_roles}; +use crate::{Shape, ShapeId, ShapeKind}; + +use super::shapes::IdMap; +use super::{Exp, Out}; +use craft_drawingml::write as dml; +use craft_drawingml::xmlx::{A, TagExt}; +use craft_xml::Writer; + +use super::{NS_MC, NS_P14}; + +const NS_P15: &str = "http://schemas.microsoft.com/office/powerpoint/2012/main"; +const NS_P159: &str = "http://schemas.microsoft.com/office/powerpoint/2015/09/main"; + +pub fn transition(w: &mut Writer, x: &mut Exp, o: &mut Out, t: &Transition) { + let adv_click = (!t.advance_on_click).then_some("0"); + let adv_tm = t.advance_after_ms.map(|v| v.min(86_400_000)); + let spd = match t.duration_ms { + 0..=600 => "fast", + 601..=900 => "med", + _ => "slow", + }; + let sound = t.sound.and_then(|m| x.media_rel(o, m, "audio").map(|r| (r, x.media(m).map(|i| i.name.clone()).unwrap_or_default()))); + let snd = |w: &mut Writer| { + if let Some((r, name)) = &sound { + w.open("p:sndAc", &[]); + w.tag("p:stSnd").attr_opt("loop", (t.sound_loop).then_some(1)).open(); + let name = if name.is_empty() { "sound.wav" } else { name.as_str() }; + w.tag("p:snd").attr("r:embed", r).attr("name", name).empty(); + w.close(); + w.close(); + } else if t.stop_sound { + w.open("p:sndAc", &[]); + w.empty("p:endSnd", &[]); + w.close(); + } + }; + let mut effect = Writer::new(); + let ns = if t.kind == "none" { Some("p") } else { crate::maps::transition_to_xml(&mut effect, &t.kind, &t.option) }; + let ns = match ns { + Some(ns) => ns, + None => { + // Unknown kind: write the kept element if there is one, else a fade. + if let Some(raw) = &t.raw + && let Ok(d) = crate::package::parse(raw.as_bytes()) + && let Some(e) = d.root.elements().find(|e| !matches!(e.local(), "sndAc" | "extLst")) + && !craft_drawingml::xmlx::has_rel_refs(e) + { + let prefix = e.prefix().to_string(); + let mut ns_decl = d.ns_decls(); + ns_decl.retain(|(k, _)| k == &format!("xmlns:{prefix}")); + let mut e2 = e.clone(); + for (k, v) in ns_decl { + e2.attrs.push((k, v)); + } + effect.raw(&e2.to_xml()); + match prefix.as_str() { + "p14" => "p14", + "p15" => "p15", + "p159" => "p159", + "p" => "p", + _ => { + effect = Writer::new(); + effect.empty("p:fade", &[]); + "p" + } + } + } else { + effect.empty("p:fade", &[]); + "p" + } + } + }; + let is_none = t.kind == "none"; + let dur = t.duration_ms.min(60_000); + w.tag("mc:AlternateContent").attr("xmlns:mc", NS_MC).open(); + let mut ch = A::new().a("xmlns:p14", NS_P14); + let req = match ns { + "p15" => { + ch = ch.a("xmlns:p15", NS_P15); + "p15" + } + "p159" => { + ch = ch.a("xmlns:p159", NS_P159); + "p159" + } + _ => "p14", + }; + w.tag("mc:Choice").list(ch.a("Requires", req)).open(); + w.tag("p:transition") + .attr("spd", spd) + .attr_opt("p14:dur", (!is_none).then_some(dur)) + .attr_opt("advClick", adv_click) + .attr_opt("advTm", adv_tm) + .open(); + if !is_none { + w.raw(effect.as_str()); + } + snd(w); + w.close(); + w.close(); + w.open("mc:Fallback", &[]); + w.tag("p:transition").attr("spd", spd).attr_opt("advClick", adv_click).attr_opt("advTm", adv_tm).open(); + if !is_none { + if ns == "p" { + w.raw(effect.as_str()); + } else { + w.empty("p:fade", &[]); + } + } + snd(w); + w.close(); + w.close(); + w.close(); +} + +/// One effect to write (an animation, or one paragraph of a by-paragraph build). +struct Eff<'a> { + a: &'a Animation, + spid: u32, + start: AnimStart, + paragraph: Option, + grp: u32, +} + +struct Ids(u32); + +impl Ids { + fn next(&mut self) -> u32 { + self.0 = self.0.saturating_add(1); + self.0 + } +} + +fn tgt(w: &mut Writer, spid: u32, para: Option) { + w.open("p:tgtEl", &[]); + match para { + Some(p) => { + w.tag("p:spTgt").attr("spid", spid).open(); + w.open("p:txEl", &[]); + w.tag("p:pRg").attr("st", p).attr("end", p).empty(); + w.close(); + w.close(); + } + None => w.tag("p:spTgt").attr("spid", spid).empty(), + } + w.close(); +} + +fn cbhvr(w: &mut Writer, ids: &mut Ids, e: &Eff, dur: u32, delay: Option, attrs: &[&str], additive: Option<&str>) { + w.tag("p:cBhvr").attr_opt("additive", additive).open(); + let a = A::new().a("id", ids.next()).a("dur", dur.max(1)).a("fill", "hold"); + match delay { + Some(d) => { + w.tag("p:cTn").list(a).open(); + w.open("p:stCondLst", &[]); + w.tag("p:cond").attr("delay", d).empty(); + w.close(); + w.close(); + } + None => w.tag("p:cTn").list(a).empty(), + } + tgt(w, e.spid, e.paragraph); + if !attrs.is_empty() { + w.open("p:attrNameLst", &[]); + for n in attrs { + w.leaf("p:attrName", &[], n); + } + w.close(); + } + w.close(); +} + +fn set_vis(w: &mut Writer, ids: &mut Ids, e: &Eff, delay: u32, visible: bool) { + w.open("p:set", &[]); + cbhvr(w, ids, e, 1, Some(delay), &["style.visibility"], None); + w.open("p:to", &[]); + w.tag("p:strVal").attr("val", if visible { "visible" } else { "hidden" }).empty(); + w.close(); + w.close(); +} + +fn anim_effect(w: &mut Writer, ids: &mut Ids, e: &Eff, dur: u32, filter: &str, out: bool) { + w.tag("p:animEffect").attr("transition", if out { "out" } else { "in" }).attr("filter", filter).open(); + cbhvr(w, ids, e, dur, None, &[], None); + w.close(); +} + +fn anim_prop(w: &mut Writer, ids: &mut Ids, e: &Eff, dur: u32, attr: &str, from: &str, to: &str) { + w.tag("p:anim").attr("calcmode", "lin").attr("valueType", "num").open(); + cbhvr(w, ids, e, dur, None, &[attr], Some("base")); + w.open("p:tavLst", &[]); + for (tm, v) in [(0, from), (100_000, to)] { + w.tag("p:tav").attr("tm", tm).open(); + w.open("p:val", &[]); + w.tag("p:strVal").attr("val", v).empty(); + w.close(); + w.close(); + } + w.close(); + w.close(); +} + +fn filter_for(effect: &str, option: &str) -> &'static str { + match effect { + "wipe" | "wipeOut" => match option { + "t" => "wipe(down)", + "l" => "wipe(right)", + "r" => "wipe(left)", + _ => "wipe(up)", + }, + "split" | "splitOut" => match option { + "horzOut" => "barn(outHorizontal)", + "vertIn" => "barn(inVertical)", + "vertOut" => "barn(outVertical)", + _ => "barn(inHorizontal)", + }, + "randomBars" | "randomBarsOut" => { + if option == "vert" { + "randombar(vertical)" + } else { + "randombar(horizontal)" + } + } + "blinds" | "blindsOut" => { + if option == "vert" { + "blinds(vertical)" + } else { + "blinds(horizontal)" + } + } + "box" | "boxOut" => { + if option == "out" { + "box(out)" + } else { + "box(in)" + } + } + "circle" => { + if option == "out" { + "circle(out)" + } else { + "circle(in)" + } + } + "diamond" | "diamondOut" => { + if option == "out" { + "diamond(out)" + } else { + "diamond(in)" + } + } + "shape" | "shapeOut" => match option { + "circleOut" => "circle(out)", + "boxIn" => "box(in)", + "boxOut" => "box(out)", + "diamondIn" => "diamond(in)", + "diamondOut" => "diamond(out)", + "plusIn" => "plus(in)", + "plusOut" => "plus(out)", + _ => "circle(in)", + }, + "checkerboard" | "checkerboardOut" => { + if option == "down" { + "checkerboard(down)" + } else { + "checkerboard(across)" + } + } + "dissolve" | "dissolveOut" => "dissolve", + "wheel" | "wheelOut" => match option { + "2" => "wheel(2)", + "3" => "wheel(3)", + "4" => "wheel(4)", + "8" => "wheel(8)", + _ => "wheel(1)", + }, + "strips" | "stripsOut" => match option { + "ru" => "strips(upRight)", + "ld" => "strips(downLeft)", + "rd" => "strips(downRight)", + _ => "strips(upLeft)", + }, + "peek" | "peekOut" => match option { + "t" => "slide(fromTop)", + "l" => "slide(fromLeft)", + "r" => "slide(fromRight)", + _ => "slide(fromBottom)", + }, + _ => "fade", + } +} + +fn fly_from(option: &str) -> (&'static str, &'static str) { + let x = if option.contains('l') { + "0-#ppt_w/2" + } else if option.contains('r') { + "1+#ppt_w/2" + } else { + "#ppt_x" + }; + let y = if option.contains('t') { + "0-#ppt_h/2" + } else if option.contains('b') || option.is_empty() { + "1+#ppt_h/2" + } else { + "#ppt_y" + }; + (x, y) +} + +fn behaviors(w: &mut Writer, x: &mut Exp, ids: &mut Ids, e: &Eff) { + let a = e.a; + let dur = a.duration_ms.clamp(1, 3_600_000); + match a.class { + AnimClass::Entrance => { + set_vis(w, ids, e, 0, true); + match a.effect.as_str() { + "appear" => {} + "fly" => { + let (fx, fy) = fly_from(&a.option); + anim_prop(w, ids, e, dur, "ppt_x", fx, "#ppt_x"); + anim_prop(w, ids, e, dur, "ppt_y", fy, "#ppt_y"); + } + "zoom" | "grow" | "expand" | "spinner" => { + anim_prop(w, ids, e, dur, "ppt_w", "0", "#ppt_w"); + anim_prop(w, ids, e, dur, "ppt_h", "0", "#ppt_h"); + anim_effect(w, ids, e, dur, "fade", false); + } + "float" | "rise" => { + anim_prop(w, ids, e, dur, "ppt_y", if a.option == "d" { "#ppt_y-.1" } else { "#ppt_y+.1" }, "#ppt_y"); + anim_effect(w, ids, e, dur, "fade", false); + } + other => anim_effect(w, ids, e, dur, filter_for(other, &a.option), false), + } + } + AnimClass::Exit => { + match a.effect.as_str() { + "disappear" => {} + "flyOut" => { + let (fx, fy) = fly_from(&a.option); + anim_prop(w, ids, e, dur, "ppt_x", "#ppt_x", fx); + anim_prop(w, ids, e, dur, "ppt_y", "#ppt_y", fy); + } + "floatOut" | "sinkDown" => { + let to = if a.effect == "sinkDown" || a.option == "d" { "#ppt_y+.1" } else { "#ppt_y-.1" }; + anim_prop(w, ids, e, dur, "ppt_y", "#ppt_y", to); + anim_effect(w, ids, e, dur, "fade", true); + } + "zoomOut" | "shrinkTurn" | "spinnerOut" | "contract" => { + anim_prop(w, ids, e, dur, "ppt_w", "#ppt_w", "0"); + anim_prop(w, ids, e, dur, "ppt_h", "#ppt_h", "0"); + anim_effect(w, ids, e, dur, "fade", true); + } + other => anim_effect(w, ids, e, dur, filter_for(other, &a.option), true), + } + set_vis(w, ids, e, if a.effect == "disappear" { 0 } else { dur.saturating_sub(1) }, false); + } + AnimClass::Emphasis => match a.effect.as_str() { + "spin" | "teeter" => { + let deg = a.amount.unwrap_or(if a.effect == "teeter" { 4.0 } else { 360.0 }); + let deg = if a.option == "counterClockwise" { -deg.abs() } else { deg }; + let by = (deg.clamp(-36_000.0, 36_000.0) * 60_000.0).round() as i64; + w.tag("p:animRot").attr("by", by).open(); + cbhvr(w, ids, e, dur, None, &["r"], None); + w.close(); + } + "transparency" => { + w.open("p:set", &[]); + cbhvr(w, ids, e, dur, None, &["style.opacity"], None); + w.open("p:to", &[]); + w.tag("p:strVal").attr("val", format!("{:.2}", (1.0 - a.amount.unwrap_or(0.5)).clamp(0.0, 1.0))).empty(); + w.close(); + w.close(); + } + "colorPulse" => { + // As PowerPoint writes it: text and fill go to the colour and back (autoRev). The behaviour + // duration is the one import reads as the effect duration. + let c = a.color.clone().unwrap_or(crate::ColorRef::rgb(craft_drawingml::color::Rgba::rgb(0xC0, 0x00, 0x00))); + for (attr, child) in [("style.color", true), ("fillcolor", false)] { + w.tag("p:animClr").attr("clrSpc", "rgb").attr("dir", "cw").open(); + w.tag("p:cBhvr").attr_opt("override", child.then_some("childStyle")).open(); + w.tag("p:cTn").attr("id", ids.next()).attr("dur", dur.max(1)).attr("autoRev", "1").attr("fill", "remove").empty(); + tgt(w, e.spid, e.paragraph); + w.open("p:attrNameLst", &[]); + w.leaf("p:attrName", &[], attr); + w.close(); + w.close(); + w.open("p:to", &[]); + dml::color(w, &c); + w.close(); + w.close(); + } + } + // As PowerPoint writes them: a relative HSL shift of text, fill and line colour. + "darken" | "desaturate" => { + let (s, l) = if a.effect == "darken" { (0, -25_098) } else { (-70_588, 0) }; + for attr in ["style.color", "fillcolor", "stroke.color"] { + w.tag("p:animClr").attr("clrSpc", "hsl").attr("dir", "cw").open(); + cbhvr(w, ids, e, dur, None, &[attr], None); + w.open("p:by", &[]); + w.tag("p:hsl").attr("h", 0).attr("s", s).attr("l", l).empty(); + w.close(); + w.close(); + } + } + "fillColor" | "objectColor" | "fontColor" | "lineColor" | "complementaryColor" | "lighten" => { + let attr = match a.effect.as_str() { + "fontColor" => "style.color", + "lineColor" => "stroke.color", + _ => "fillcolor", + }; + let c = a.color.clone().unwrap_or(crate::ColorRef::scheme(craft_drawingml::color::SchemeSlot::Accent2)); + w.tag("p:animClr").attr("clrSpc", "rgb").attr("dir", "cw").open(); + cbhvr(w, ids, e, dur, None, &[attr], None); + w.open("p:to", &[]); + dml::color(w, &c); + w.close(); + w.close(); + } + _ => { + let s = a.amount.filter(|v| v.is_finite() && *v > 0.0).unwrap_or(if a.effect == "growShrink" { 1.5 } else { 1.1 }); + let (sx, sy) = match a.option.as_str() { + "horz" => (s, 1.0), + "vert" => (1.0, s), + _ => (s, s), + }; + w.open("p:animScale", &[]); + cbhvr(w, ids, e, dur, None, &[], None); + w.tag("p:by") + .attr("x", (sx.clamp(0.01, 100.0) * 100_000.0).round() as i64) + .attr("y", (sy.clamp(0.01, 100.0) * 100_000.0).round() as i64) + .empty(); + w.close(); + } + }, + AnimClass::Path => { + let path = a + .path + .clone() + .filter(|p| !p.trim().is_empty() && p.chars().all(|c| c.is_ascii() && c != '"' && c != '<' && c != '&')) + .unwrap_or_else(|| { + match a.option.as_str() { + "up" => "M 0 0 L 0 -0.25 E", + "right" => "M 0 0 L 0.25 0 E", + "left" => "M 0 0 L -0.25 0 E", + _ => "M 0 0 L 0 0.25 E", + } + .to_string() + }); + w.tag("p:animMotion").attr("origin", "layout").attr("path", path).attr("pathEditMode", "relative").attr("ptsTypes", "").open(); + cbhvr(w, ids, e, dur, None, &["ppt_x", "ppt_y"], None); + w.close(); + } + AnimClass::Media => { + let cmd = match a.effect.as_str() { + "pause" => "togglePause", + "stop" => "stop", + _ => "playFrom(0.0)", + }; + w.tag("p:cmd").attr("type", "call").attr("cmd", cmd).open(); + cbhvr(w, ids, e, 1, None, &[], None); + w.close(); + } + } + let _ = x; +} + +fn effect_par(w: &mut Writer, x: &mut Exp, ids: &mut Ids, e: &Eff, first_in_click: bool) { + let a = e.a; + let node = match e.start { + AnimStart::OnClick => "clickEffect", + AnimStart::WithPrevious => "withEffect", + AnimStart::AfterPrevious => "afterEffect", + }; + let _ = first_in_click; + let preset = crate::maps::preset_for(a); + let sub = if preset == 47 { + 0 + } else { + crate::maps::subtype_from_option(&a.effect, &a.option).or(a.preset_subtype.filter(|_| a.preset_id == Some(preset))).unwrap_or(0) + }; + let repeat = match a.repeat { + u32::MAX => Some("indefinite".to_string()), + 0 | 1 => None, + n => Some((n.min(10_000) * 1000).to_string()), + }; + let accel = (a.smooth_start > 0.0).then(|| (a.smooth_start.clamp(0.0, 1.0) * 100_000.0).round() as i64); + let decel = (a.smooth_end > 0.0).then(|| (a.smooth_end.clamp(0.0, 1.0) * 100_000.0).round() as i64); + // `bounceEnd` is a positive percentage (100000 = 100%), same scale as accel/decel. The model stores 0–1. + let bounce = + (a.bounce_end.is_finite() && a.bounce_end > 0.0).then(|| (a.bounce_end.clamp(0.0, 1.0) * 100_000.0).round() as i64).filter(|n| *n > 0); + w.open("p:par", &[]); + w.tag("p:cTn") + .attr("id", ids.next()) + .attr("presetID", preset) + .attr("presetClass", a.class.xml()) + .attr("presetSubtype", sub) + .attr_opt("repeatCount", repeat) + .attr_opt("accel", accel) + .attr_opt("decel", decel) + .attr_opt("bounceEnd", bounce) + .attr_opt("autoRev", (a.auto_reverse).then_some(1)) + .attr("fill", if a.rewind { "remove" } else { "hold" }) + .attr("grpId", e.grp) + .attr("nodeType", node) + .open(); + w.open("p:stCondLst", &[]); + w.tag("p:cond").attr("delay", a.delay_ms.min(3_600_000)).empty(); + w.close(); + w.open("p:childTnLst", &[]); + behaviors(w, x, ids, e); + w.close(); + w.close(); + w.close(); +} + +fn end_time(e: &Eff) -> u32 { + let rep = match e.a.repeat { + 0 | 1 => 1, + u32::MAX => 1, + n => n.min(100), + }; + e.a.delay_ms.saturating_add(e.a.duration_ms.saturating_mul(rep)).min(3_600_000) +} + +/// click groups → after-groups → effects +type Groups<'a> = Vec<(bool, Vec>>)>; + +fn group<'a>(effects: Vec>) -> Groups<'a> { + let mut clicks: Groups = vec![]; + for e in effects { + match e.start { + AnimStart::OnClick => clicks.push((false, vec![vec![e]])), + AnimStart::WithPrevious => match clicks.last_mut().and_then(|c| c.1.last_mut()) { + Some(g) => g.push(e), + None => clicks.push((true, vec![vec![e]])), + }, + AnimStart::AfterPrevious => match clicks.last_mut() { + Some(c) => c.1.push(vec![e]), + None => clicks.push((true, vec![vec![e]])), + }, + } + } + clicks +} + +fn click_pars(w: &mut Writer, x: &mut Exp, ids: &mut Ids, clicks: &Groups, interactive: bool) { + for (ci, (auto, afters)) in clicks.iter().enumerate() { + w.open("p:par", &[]); + w.tag("p:cTn").attr("id", ids.next()).attr("fill", "hold").open(); + w.open("p:stCondLst", &[]); + if interactive && ci == 0 { + w.tag("p:cond").attr("delay", 0).empty(); + } else { + w.tag("p:cond").attr("delay", "indefinite").empty(); + if *auto { + w.tag("p:cond").attr("evt", "onBegin").attr("delay", 0).open(); + w.tag("p:tn").attr("val", 2).empty(); + w.close(); + } + } + w.close(); + w.open("p:childTnLst", &[]); + let mut t = 0u32; + for g in afters { + w.open("p:par", &[]); + w.tag("p:cTn").attr("id", ids.next()).attr("fill", "hold").open(); + w.open("p:stCondLst", &[]); + w.tag("p:cond").attr("delay", t).empty(); + w.close(); + w.open("p:childTnLst", &[]); + for (i, e) in g.iter().enumerate() { + effect_par(w, x, ids, e, i == 0); + } + w.close(); + w.close(); + w.close(); + t = t.saturating_add(g.iter().map(end_time).max().unwrap_or(0)); + } + w.close(); + w.close(); + w.close(); + } +} + +/// Most effects one slide's timing writes; animations past it are left out. +const MAX_EFFECTS: usize = 100_000; + +/// The shapes of the tree by id, the first in document order for a repeated id (as `find_shape` +/// finds it), so each animation resolves its shape without a tree walk. +fn shape_index<'a>(shapes: &'a [Shape], depth: usize, out: &mut std::collections::HashMap) { + if depth > 64 { + return; + } + for s in shapes { + out.entry(s.id).or_insert(s); + shape_index(s.children(), depth + 1, out); + } +} + +/// Write `p:timing` for a slide's animations. +pub fn timing(w: &mut Writer, x: &mut Exp, anims: &[Animation], shapes: &[Shape], idmap: &IdMap) { + let mut grp_count: std::collections::HashMap = Default::default(); + let mut main: Vec = vec![]; + let mut triggered: Vec<(u32, Vec)> = vec![]; + let mut bld: Vec<(u32, u32, bool)> = vec![]; + // The last paragraph effect written: (shape, class, trigger, grpId). + let mut prev_para: Option<(ShapeId, AnimClass, Option, u32)> = None; + let mut by_id = std::collections::HashMap::new(); + shape_index(shapes, 0, &mut by_id); + let mut roles_of: std::collections::HashMap> = Default::default(); + let mut effect_count = 0usize; + let mut warned = false; + for a in anims.iter().take(10_000) { + if effect_count >= MAX_EFFECTS { + if !warned { + log::warn!("pptx: more than {MAX_EFFECTS} animation effects; the rest are left out"); + } + break; + } + let Some(spid) = idmap.get(a.shape) else { + log::warn!("pptx: animation on missing shape {} skipped", a.shape); + continue; + }; + let Some(&shape) = by_id.get(&a.shape) else { continue }; + let text_sp = matches!(shape.kind, ShapeKind::Shape) && shape.text.is_some(); + let is_text_sp = matches!(shape.kind, ShapeKind::Shape) && shape.text.as_ref().is_some_and(|t| !t.is_empty()); + let text = shape.text.as_ref().filter(|_| a.text_build == TextBuild::ByParagraph && is_text_sp); + let no_roles = Vec::new(); + let roles = match text { + Some(t) => roles_of.entry(a.shape).or_insert_with(|| paragraph_build_roles(t)), + None => &no_roles, + }; + let by_para = roles.contains(&BuildRole::Top); + // PowerPoint keeps a shape's consecutive paragraph effects in one build (`grpId`) marked + // `build="p"`; the reader turns them back into a by-paragraph build only if that plays them + // the same way. + let para_effect = !by_para && text_sp && a.paragraph.is_some(); + let joins = para_effect && prev_para.is_some_and(|(s, c, t, _)| s == a.shape && c == a.class && t == a.trigger); + let grp = match prev_para { + Some((.., g)) if joins => g, + _ => { + let c = grp_count.entry(spid).or_insert(0); + let g = *c; + *c += 1; + g + } + }; + prev_para = para_effect.then_some((a.shape, a.class, a.trigger, grp)); + let mut effs = vec![]; + if by_para { + // One effect per paragraph the build animates on its own, deeper ones with their + // parent; indices in the file count empty paragraphs too. + let mut tops = 0; + for (i, role) in roles.iter().enumerate() { + let start = match role { + BuildRole::Top => { + tops += 1; + if tops == 1 { + a.start + } else if a.start == AnimStart::OnClick { + AnimStart::OnClick + } else { + AnimStart::AfterPrevious + } + } + BuildRole::Member(_) if text.and_then(|t| t.paragraphs.get(i)).is_some_and(build_filled) => AnimStart::WithPrevious, + _ => continue, + }; + let Ok(pi) = u32::try_from(i) else { break }; + if effect_count + effs.len() >= MAX_EFFECTS { + if !warned { + log::warn!("pptx: more than {MAX_EFFECTS} animation effects; the rest are left out"); + warned = true; + } + break; + } + effs.push(Eff { a, spid, start, paragraph: Some(pi), grp }); + } + } else { + effs.push(Eff { a, spid, start: a.start, paragraph: a.paragraph, grp }); + } + effect_count += effs.len(); + if text_sp && !joins { + bld.push((spid, grp, by_para || para_effect)); + } + match a.trigger.and_then(|t| idmap.get(t)) { + Some(t) => match triggered.iter_mut().find(|(s, _)| *s == t) { + Some((_, v)) => v.extend(effs), + None => triggered.push((t, effs)), + }, + None => main.extend(effs), + } + } + if main.is_empty() && triggered.is_empty() { + return; + } + let mut ids = Ids(0); + w.open("p:timing", &[]); + w.open("p:tnLst", &[]); + w.open("p:par", &[]); + w.tag("p:cTn").attr("id", ids.next()).attr("dur", "indefinite").attr("restart", "never").attr("nodeType", "tmRoot").open(); + w.open("p:childTnLst", &[]); + if !main.is_empty() { + let clicks = group(main); + w.tag("p:seq").attr("concurrent", 1).attr("nextAc", "seek").open(); + w.tag("p:cTn").attr("id", ids.next()).attr("dur", "indefinite").attr("nodeType", "mainSeq").open(); + w.open("p:childTnLst", &[]); + click_pars(w, x, &mut ids, &clicks, false); + w.close(); + w.close(); + w.open("p:prevCondLst", &[]); + w.tag("p:cond").attr("evt", "onPrev").attr("delay", 0).open(); + w.open("p:tgtEl", &[]); + w.empty("p:sldTgt", &[]); + w.close(); + w.close(); + w.close(); + w.open("p:nextCondLst", &[]); + w.tag("p:cond").attr("evt", "onNext").attr("delay", 0).open(); + w.open("p:tgtEl", &[]); + w.empty("p:sldTgt", &[]); + w.close(); + w.close(); + w.close(); + w.close(); + } + for (trig, effs) in triggered { + // The first step starts with the button's click. Later On Click effects must remain + // separate steps; With/After Previous effects still belong to their preceding step. + let effs: Vec = + effs.into_iter().enumerate().map(|(i, e)| Eff { start: if i == 0 { AnimStart::OnClick } else { e.start }, ..e }).collect(); + let clicks = group(effs); + let trig_cond = |w: &mut Writer| { + w.tag("p:cond").attr("evt", "onClick").attr("delay", 0).open(); + w.open("p:tgtEl", &[]); + w.tag("p:spTgt").attr("spid", trig).empty(); + w.close(); + w.close(); + }; + w.tag("p:seq").attr("concurrent", 1).attr("nextAc", "seek").open(); + w.tag("p:cTn") + .attr("id", ids.next()) + .attr("restart", "whenNotActive") + .attr("fill", "hold") + .attr("evtFilter", "cancelBubble") + .attr("nodeType", "interactiveSeq") + .open(); + w.open("p:stCondLst", &[]); + trig_cond(w); + w.close(); + w.tag("p:endSync").attr("evt", "end").attr("delay", 0).open(); + w.tag("p:rtn").attr("val", "all").empty(); + w.close(); + w.open("p:childTnLst", &[]); + click_pars(w, x, &mut ids, &clicks, true); + w.close(); + w.close(); + w.open("p:nextCondLst", &[]); + trig_cond(w); + w.close(); + w.close(); + } + w.close(); + w.close(); + w.close(); + w.close(); + if !bld.is_empty() { + w.open("p:bldLst", &[]); + for (spid, grp, build_p) in bld { + w.tag("p:bldP") + .attr("spid", spid) + .attr("grpId", grp) + .attr_opt("build", build_p.then_some("p")) + .attr_opt("animBg", (!build_p).then_some(1)) + .empty(); + } + w.close(); + } + w.close(); +} diff --git a/crates/pptx/tests/timing.rs b/crates/pptx/tests/timing.rs new file mode 100644 index 000000000..35a56803a --- /dev/null +++ b/crates/pptx/tests/timing.rs @@ -0,0 +1,91 @@ +//! Transitions and animations, written and read back. + +use std::sync::Arc; + +use craft_pptx::anim::{ANIMATIONS, AnimStart, Animation, TRANSITIONS, TextBuild, Transition}; +use craft_pptx::{Defaults, Plain, Presentation, Shape, ShapeId, Slide, SlideId, TextBody, Xfrm}; + +/// One slide per transition option, then one per animation option, each with a three-paragraph +/// box the animation targets. +fn deck() -> Presentation { + let mut p = Presentation::default(); + let (m, last) = Plain.master(p.slide_size, 0); + let layout = m.layouts[0].id; + p.masters.push(Arc::new(m)); + p.next_id = last; + let slide = |p: &mut Presentation, f: &mut dyn FnMut(&mut Slide, ShapeId)| { + let id = SlideId(p.alloc_id().unwrap()); + let shape = ShapeId(p.alloc_id().unwrap()); + let mut s = Slide { id, layout, ..Default::default() }; + s.shapes.push(Shape { + id: shape, + xfrm: Some(Xfrm::new(10.0, 10.0, 200.0, 100.0)), + text: Some(TextBody::from_text("One\nTwo\nThree")), + ..Default::default() + }); + f(&mut s, shape); + p.slides.push(Arc::new(s)); + }; + // `none` is no transition at all, and reads back as such. + for (kind, _, _, dur, opts) in TRANSITIONS.iter().filter(|t| t.0 != "none") { + let opts: Vec<&str> = if opts.is_empty() { vec![""] } else { opts.to_vec() }; + for (i, o) in opts.iter().enumerate() { + slide(&mut p, &mut |s, _| { + s.transition = Some(Transition { + kind: kind.to_string(), + option: o.to_string(), + duration_ms: *dur, + advance_after_ms: (i % 2 == 1).then_some(3000), + ..Default::default() + }); + }); + } + } + for (k, (effect, _, class, _, dur, opts)) in ANIMATIONS.iter().enumerate() { + let opts: Vec<&str> = if opts.is_empty() { vec![""] } else { opts.to_vec() }; + for (i, o) in opts.iter().enumerate() { + slide(&mut p, &mut |s, shape| { + let start = [AnimStart::OnClick, AnimStart::WithPrevious, AnimStart::AfterPrevious][(k + i) % 3]; + let text_build = if (k + i) % 2 == 0 { TextBuild::AsOne } else { TextBuild::ByParagraph }; + s.animations.push(Animation { + shape, + class: *class, + effect: effect.to_string(), + option: o.to_string(), + start, + duration_ms: *dur, + text_build, + ..Default::default() + }); + }); + } + } + p +} + +#[test] +fn every_transition_and_animation_survives_writing_and_reading() { + let p = deck(); + let q = craft_pptx::import(&craft_pptx::export(&p, &Plain).unwrap(), &Plain).unwrap(); + assert_eq!(q.slides.len(), p.slides.len()); + for (a, b) in p.slides.iter().zip(&q.slides) { + let (ta, tb) = (a.transition.as_ref(), b.transition.as_ref()); + assert_eq!(ta.map(|t| (&t.kind, &t.option, t.advance_after_ms)), tb.map(|t| (&t.kind, &t.option, t.advance_after_ms)), "slide {}", a.id); + assert_eq!(a.animations.len(), b.animations.len(), "slide {}", a.id); + for (x, y) in a.animations.iter().zip(&b.animations) { + // Effects and options are kept as far as PowerPoint's preset ids and subtypes tell + // them apart (`circle` reads back as `shape`, `boxIn` as `circleIn`): compare the + // preset. + let preset = |a: &Animation| craft_pptx::anim::animation_info(&a.effect, a.class).map(|i| i.3); + assert_eq!( + (preset(x), x.class, x.start, x.text_build), + (preset(y), y.class, y.start, y.text_build), + "slide {}: {} / {}", + a.id, + x.effect, + y.effect + ); + assert_eq!(y.shape, b.shapes[0].id); + } + } +}