Merge pull request #1751

This commit is contained in:
Brandon Thomas
2026-11-03 08:52:49 -05:00
3 changed files with 258 additions and 6 deletions
+112 -6
View File
@@ -53,25 +53,128 @@ pub fn transition(imp: &mut Imp, part: &Part, t: &El) -> Option<Transition> {
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,
}
fn collect<'a>(e: &'a El, trigger: Option<u32>, out: &mut Vec<Found<'a>>, depth: usize) {
/// 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("cTn") && c.has_attr("presetClass") {
out.push(Found { ctn: c, trigger });
out.push(Found { ctn: c, trigger: at.trigger, group: at.group, offset: at.offset });
continue;
}
let mut trig = trigger;
let mut at = at;
if c.is("seq")
&& let Some(ctn) = c.child("cTn")
&& ctn.attr("nodeType") == Some("interactiveSeq")
{
trig = ctn.path(&["stCondLst", "cond", "tgtEl", "spTgt"]).and_then(|s| s.u32("spid"));
at.trigger = ctn.path(&["stCondLst", "cond", "tgtEl", "spTgt"]).and_then(|s| s.u32("spid"));
}
if c.is("cTn")
&& let Some(cond) = c.path(&["stCondLst", "cond"])
{
if cond.attr("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;
}
collect(c, trig, out, depth + 1);
}
}
@@ -135,7 +238,7 @@ pub fn animations(imp: &mut Imp, part: &Part, ctx: &IdCtx, timing: &El, shapes:
let _ = (imp, part);
let Some(tn) = timing.child("tnLst") else { return vec![] };
let mut found = vec![];
collect(tn, None, &mut found, 0);
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("bldLst")
@@ -147,6 +250,7 @@ pub fn animations(imp: &mut Imp, part: &Part, ctx: &IdCtx, timing: &El, shapes:
})
.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("presetClass").unwrap_or("entr"));
@@ -212,7 +316,9 @@ pub fn animations(imp: &mut Imp, part: &Part, ctx: &IdCtx, timing: &El, shapes:
let grp = c.attr("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 (#844).
+1
View File
@@ -18,6 +18,7 @@ mod media_timing;
mod notes;
mod ole;
mod ole_vml;
mod parent_delays;
mod placeholders;
mod roundtrip;
mod sample;
+145
View File
@@ -0,0 +1,145 @@
//! PowerPoint places an effect at the sum of its enclosing time nodes' delays, so a file can list
//! effects out of time order. The BNI meeting-deck generator writes a 1 s countdown chain first,
//! then "swap to the visual at 5 s" effects placed only by their parent `p:par` delay: read in file
//! order, those all fired together when the countdown ended.
use std::io::{Cursor, Read, Write};
use deckcraft_geom::Xfrm;
use deckcraft_model::anim::AnimStart;
use deckcraft_model::{Shape, ShapeId, defaults};
/// An "appear"/"disappear" effect (preset 1) in its own `p:par`, as PowerPoint writes it.
fn eff(id: u32, class: &str, node: &str, delay: u32, spid: u32) -> String {
let vis = if class == "entr" { "visible" } else { "hidden" };
format!(
r#"<p:par><p:cTn id="{id}" presetID="1" presetClass="{class}" presetSubtype="0" fill="hold" nodeType="{node}"><p:stCondLst><p:cond delay="{delay}"/></p:stCondLst><p:childTnLst><p:set><p:cBhvr><p:cTn id="{}" dur="1" fill="hold"><p:stCondLst><p:cond delay="0"/></p:stCondLst></p:cTn><p:tgtEl><p:spTgt spid="{spid}"/></p:tgtEl><p:attrNameLst><p:attrName>style.visibility</p:attrName></p:attrNameLst></p:cBhvr><p:to><p:strVal val="{vis}"/></p:to></p:set></p:childTnLst></p:cTn></p:par>"#,
id + 1000
)
}
/// A group `p:par` starting `delay` ms into the click, holding `inner`.
fn group(id: u32, delay: u32, inner: &str) -> String {
format!(
r#"<p:par><p:cTn id="{id}" fill="hold"><p:stCondLst><p:cond delay="{delay}"/></p:stCondLst><p:childTnLst>{inner}</p:childTnLst></p:cTn></p:par>"#
)
}
fn import_with_timing(build: impl Fn(&[u32]) -> String) -> Vec<(String, AnimStart, u32)> {
let mut p = defaults::new_presentation(None);
let shapes: Vec<Shape> = ["Three", "Two", "One", "Visual"]
.into_iter()
.map(|name| Shape {
id: ShapeId(p.alloc_id().expect("id")),
name: name.into(),
xfrm: Some(Xfrm::new(10.0, 10.0, 100.0, 50.0)),
..Default::default()
})
.collect();
std::sync::Arc::make_mut(&mut p.slides[0]).shapes = shapes;
let mut z = zip::ZipArchive::new(Cursor::new(deckcraft_pptx::export(&p).expect("export"))).expect("zip");
let mut out = zip::ZipWriter::new(Cursor::new(Vec::new()));
for i in 0..z.len() {
let mut f = z.by_index(i).expect("entry");
let name = f.name().to_string();
let mut d = vec![];
f.read_to_end(&mut d).expect("read");
if name == "ppt/slides/slide1.xml" {
let xml = String::from_utf8(d).expect("utf8");
let spids: Vec<u32> = ["Three", "Two", "One", "Visual"]
.iter()
.map(|n| {
let at = xml.find(&format!(r#"name="{n}""#)).expect("shape written");
let open = xml[..at].rfind("<p:cNvPr ").expect("cNvPr");
let tag = &xml[open..at];
let id = tag.split(r#"id=""#).nth(1).expect("id").split('"').next().expect("id");
id.parse().expect("number")
})
.collect();
let timing = format!(
r#"<p:timing><p:tnLst><p:par><p:cTn id="1" dur="indefinite" restart="never" nodeType="tmRoot"><p:childTnLst><p:seq concurrent="1" nextAc="seek"><p:cTn id="2" dur="indefinite" nodeType="mainSeq"><p:childTnLst><p:par><p:cTn id="3" fill="hold"><p:stCondLst><p:cond delay="indefinite"/></p:stCondLst><p:childTnLst>{}</p:childTnLst></p:cTn></p:par></p:childTnLst></p:cTn><p:prevCondLst><p:cond evt="onPrev" delay="0"><p:tgtEl><p:sldTgt/></p:tgtEl></p:cond></p:prevCondLst><p:nextCondLst><p:cond evt="onNext" delay="0"><p:tgtEl><p:sldTgt/></p:tgtEl></p:cond></p:nextCondLst></p:seq></p:childTnLst></p:cTn></p:par></p:tnLst></p:timing>"#,
build(&spids)
);
let xml = match (xml.find("<p:timing>"), xml.find("</p:timing>")) {
(Some(a), Some(b)) => format!("{}{}{}", &xml[..a], timing, &xml[b + "</p:timing>".len()..]),
_ => xml.replacen("</p:sld>", &format!("{timing}</p:sld>"), 1),
};
d = xml.into_bytes();
}
out.start_file(name, zip::write::SimpleFileOptions::default()).expect("start");
out.write_all(&d).expect("write");
}
let q = deckcraft_pptx::import(&out.finish().expect("finish").into_inner()).expect("import");
let s = &q.slides[0];
s.animations
.iter()
.map(|a| {
let name = s.shapes.iter().find(|x| x.id == a.shape).map(|x| x.name.clone()).unwrap_or_default();
(name, a.start, a.delay_ms)
})
.collect()
}
/// Start (ms after the click) of each effect when the flat list is played effect after effect.
fn flat(effects: &[(String, AnimStart, u32)]) -> Vec<(String, u32)> {
let (mut begin, mut end) = (0u32, 0u32);
effects
.iter()
.map(|(n, start, delay)| {
if *start == AnimStart::AfterPrevious {
begin = end;
}
let t = begin + delay;
end = end.max(t);
(n.clone(), t)
})
.collect()
}
#[test]
fn effects_placed_by_parent_delay_play_when_powerpoint_plays_them() {
// Click: "Three" goes, then "Two" after 1 s, "One" after 1 s more (a countdown chain), then —
// written last — the visual appears at 1 s, placed only by its parent's delay.
let fx = import_with_timing(|s| {
[
group(10, 0, &eff(11, "exit", "clickEffect", 0, s[0])),
group(20, 0, &eff(21, "exit", "afterEffect", 1000, s[1])),
group(30, 1000, &eff(31, "exit", "afterEffect", 1000, s[2])),
group(40, 1000, &eff(41, "entr", "afterEffect", 0, s[3])),
]
.concat()
});
let got = flat(&fx);
let at = |n: &str| got.iter().find(|(x, _)| x == n).map(|(_, t)| *t);
assert_eq!((at("Three"), at("Two"), at("Visual"), at("One")), (Some(0), Some(1000), Some(1000), Some(2000)), "{fx:?}");
}
#[test]
fn a_timeline_already_in_order_is_read_unchanged() {
let fx = import_with_timing(|s| {
[
group(10, 0, &[eff(11, "exit", "clickEffect", 0, s[0]), eff(12, "entr", "withEffect", 500, s[3])].concat()),
group(20, 500, &eff(21, "exit", "afterEffect", 1000, s[1])),
]
.concat()
});
let starts: Vec<_> = fx.iter().map(|(n, st, d)| (n.as_str(), *st, *d)).collect();
assert_eq!(starts, [("Three", AnimStart::OnClick, 0), ("Visual", AnimStart::WithPrevious, 500), ("Two", AnimStart::AfterPrevious, 1000)]);
}
#[test]
fn a_delayed_click_effect_sorted_after_a_with_effect_keeps_the_click_on_the_group() {
// The click effect waits 500 ms; a with-effect in the same click plays at once, and a later
// effect is placed only by its parent's delay. Sorted, the with-effect plays first and takes
// the click, so the group still waits for one.
let fx = import_with_timing(|s| {
[
group(10, 0, &[eff(11, "exit", "clickEffect", 500, s[0]), eff(12, "entr", "withEffect", 0, s[3])].concat()),
group(20, 1000, &eff(21, "exit", "afterEffect", 0, s[1])),
]
.concat()
});
let starts: Vec<_> = fx.iter().map(|(n, st, _)| (n.as_str(), *st)).collect();
assert_eq!(starts[0], ("Visual", AnimStart::OnClick), "{fx:?}");
assert_eq!(starts.iter().filter(|(_, st)| *st == AnimStart::OnClick).count(), 1, "{fx:?}");
}