craft-pptx: transitions and animations (from DeckCraft) (#1067)

* Add craft-drawingml: units, colours, preset shapes, custom geometry and text warps

The parts of DrawingML that need no document model, moved from DeckCraft
(60f91f78): crates/color, crates/geom (preset, warp, Xfrm and the EMU
conversions) and crates/pptx/src/custgeom.rs with CustomPath from
crates/model.

The code is unchanged except that custgeom reads craft-xml trees and writes
to a craft-xml Writer instead of DeckCraft's own XML helper, and matches
elements by local name. The 59 tests of the moved code come with it; six
more cover the custom geometry limits.

* drawingml: evaluate a custom geometry's guide lists once per coordinate space

read_cust_geom rebuilt Guides and evaluated avLst and gdLst again for every
path: up to 1,000 paths times up to 10,000 guides, each name copied and
hashed.

Guides are now cached per path coordinate space (w, h). At most 16 spaces
and 4 MiB of guide names are kept per geometry; paths in further spaces are
left out. The first space is always kept, and a geometry without guides is
not capped.

Ported from storytold/deckcraft#1789. A list counts as having guides only
when it has gd children, as that PR's review asked.

* drawingml: the model types for fills, lines, text, tables, charts and diagrams

Moved from DeckCraft (60f91f78) crates/model/src: style.rs, text.rs,
table.rs, chart.rs, numfmt.rs and trendline.rs, plus Diagram, DiagramNode
and the MediaId and SlideId newtypes from lib.rs.

The files are unchanged except that deckcraft_color:: is crate::color:: and
Fill and TableBorder allow clippy::large_enum_variant. Their 60 tests come
with them. Readers and writers follow.

* drawingml: the theme model (colour, font and format schemes)

Moved from DeckCraft crates/model/src/theme.rs at 60f91f78: FontSet, ScriptFont, FontScheme,
FormatScheme, Theme and default_format. The app's theme gallery, its variants and its own default
theme stay in the app. Theme::default() is a plain theme built in place, so nothing here depends
on a gallery.

* drawingml: readers and writers for fills, lines, text, charts, themes, table styles and diagrams

Moved from DeckCraft crates/pptx/src at 60f91f78: read/dml.rs, write/dml.rs, read/chart.rs,
read/chartex.rs, write/chart.rs, read/theme.rs, write/theme.rs, tables.rs and dgm.rs, onto
craft-xml. Package lookups (Imp, Part, Exp, Out) are the caller's part::Source and part::Sink.
The chart frame budget and kept-part capture stay with the presentation reader.

* drawingml: what WordCraft and GridCraft read that DeckCraft's copy did not

Every ST_PresetColorVal name has its colour, in any case and with the dk/lt/med abbreviations;
a sysClr without lastClr takes the usual value of its name; a Strict percentage (50%) reads
anywhere a percentage does; a chart series keeps its c:f formulas and the writer uses them.

* craft-drawingml: extra named theme colours and explicit tint rows

Ported from the format part of storytold/deckcraft#1746 by @jagjordi: ColorScheme::{extra, tints,
computed_tints}, ExtraColor, GalleryVariant, variants_of, and the a:clrScheme extension that
keeps them in a file.

* drawingml xmlx: copy only used, bounded namespace declarations onto kept fragments and mc branches

From storytold/deckcraft#1740 (988a4920), which landed after the extraction commit 60f91f78.

* Add craft-pptx: the PresentationML model (from DeckCraft)

Presentation, masters, layouts, slides, shapes, placeholders, media,
comments, sections, show settings, properties, embedded fonts, kept parts
and drawing guides from DeckCraft's crates/model/src/lib.rs, and
transitions and animations from anim.rs. The DrawingML types come from
craft-drawingml. The placeholder matching from DeckCraft's resolve.rs
moves as placeholder::parents, without the rest of resolve.

Presentation::default() is now an empty 16:9 deck with no masters; the
starting deck stays with the application.

* craft-pptx: the reader (from DeckCraft), on craft-opc and craft-drawingml

DeckCraft's crates/pptx reader (read.rs, read/shapes.rs and the chart
frame code of read/chart.rs) moved onto craft-opc's Package and
craft-xml trees, with the DrawingML readers of craft-drawingml behind a
part::Source over the import state. What DeckCraft took from its own
defaults (a master for a file without one, the theme of a master that
names none) comes from a caller-supplied Defaults; Plain is the neutral
one. Placeholder geometry inheritance uses placeholder::parents.

Transitions, animations, notes and comments follow in later changes.

* craft-pptx: drop DeckCraft issue numbers from reader comments

* craft-pptx: link the package budgets from the tag budget's doc

* craft-pptx: the writer (from DeckCraft)

DeckCraft's crates/pptx writer (write.rs, write/shapes.rs and the kept
chart parts of write/chart.rs) moved onto craft-opc's PackageOut and
craft-xml's Writer, with craft-drawingml's DrawingML writers behind a
part::Sink over the export state. What DeckCraft took from its own
defaults (a master for a deck without one, master and default text
styles, its name in docProps/app.xml and in table style names) comes
from Defaults. export and export_as take the defaults.

Transitions, animations, notes slides and comments follow in later
changes.

* craft-pptx: transitions and animations (from DeckCraft)

DeckCraft's read/timing.rs, write/timing.rs and maps.rs on craft-xml:
p:transition with the PowerPoint 2010+ effects, and p:timing flattened
into anim::Animation on reading and built back on writing, with the
media settings of the timing tree.

---------

Co-authored-by: Jordi Altayó <jordiag@kth.se>
This commit is contained in:
Dylan M. TaylorandJordi Altayó authored and GitHub committed 2026-11-11 21:59:03 -05:00
1 parent 5fc172eeb5
commit d70adc01fb
8 files changed
+1728 -2

No files matched your search

+5 -1
View File
@@ -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.
+1
View File
@@ -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;
+453
View File
@@ -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 `<p:push/>`.
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<u32> {
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}");
}
}
}
+8
View File
@@ -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());
}
+378
View File
@@ -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<u32> {
let v = e.attr_local(name)?.trim();
if v == "indefinite" {
return None;
}
v.parse::<f64>().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<Transition> {
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<u32>,
/// 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<u32>,
group: usize,
offset: u32,
}
fn collect<'a>(e: &'a El, at: At, groups: &mut usize, out: &mut Vec<Found<'a>>, 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<u32> {
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<u32>, usize, u32)]) {
let mut groups: Vec<Vec<usize>> = vec![];
let mut slot: std::collections::HashMap<(Option<u32>, 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<u32> = 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::<Vec<_>>());
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<usize> = (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<u32> {
if depth > 64 {
return None;
}
let mut best: Option<u32> = 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::<f64>().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<Animation> {
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<u32>, 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::<f64>().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()
}
+8 -1
View File
@@ -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<u8> {
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()
+784
View File
@@ -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<u32>,
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<u32>) {
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<u32>, 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<Vec<Eff<'a>>>)>;
fn group<'a>(effects: Vec<Eff<'a>>) -> 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<ShapeId, &'a Shape>) {
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<u32, u32> = Default::default();
let mut main: Vec<Eff> = vec![];
let mut triggered: Vec<(u32, Vec<Eff>)> = 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<ShapeId>, u32)> = None;
let mut by_id = std::collections::HashMap::new();
shape_index(shapes, 0, &mut by_id);
let mut roles_of: std::collections::HashMap<ShapeId, Vec<BuildRole>> = 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<Eff> =
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();
}
+91
View File
@@ -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);
}
}
}