mirror of
https://github.com/storytold/craft-libs.git
synced 2026-11-13 22:12:12 +00:00
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:
1 parent
5fc172eeb5
commit
d70adc01fb
8 files changed
+1728
-2
No files matched your search
@@ -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.
|
||||
@@ -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;
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user