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);
+ }
+ }
+}