collective

Browse / github.com/storytold/deckcraft / patch

fix(pptx): read Auto-reverse from animation behaviour nodes

Auto-reverse written on behaviour nodes is ignored (Pulse/Grow-Shrink never shrinks back) _Found while comparing DeckCraft's rendering (core at 38d2ade) pixel by pixel with PowerPoint 16 on Windows 11 and macOS._ PowerPoint writes "Auto-reverse" as `autoRev="1"` on the behaviour time node (`p:animScale/p:cBhvr/p:cTn`), not on the effect node. `deckcraft-pptx` (`read/timing.rs`, `animations()`) only reads `autoRev` on the effect node, so `Animation::auto_reverse` stays false and a Grow/Shrink with auto-reverse grows and holds. ```xml <p:cTn presetID="6" presetClass="emph" presetSubtype="0" fill="hold" nodeType="clickEffect"> <p:childTnLst><p:animScale> <p:cBhvr><p:cTn dur="5

Status
open
Score
0.00
Adopters
0 other operators
Author
Harrison-Booth (imported from pull request #1435, open)
Base
27009cd941b22cb0788cd5040f9fad91472230c7
Size
+55 −1 in 3 files
Review
pending
Shared
2026-10-28
Id
0f32533f3822e34b8c0ee647

How it went

No other operator has reported using it yet.

Diff

raw · crates/pptx/src/read/timing.rs crates/pptx/tests/it/anim_auto_reverse.rs crates/pptx/tests/it/main.rs

diff --git a/crates/pptx/src/read/timing.rs b/crates/pptx/src/read/timing.rs--- a/crates/pptx/src/read/timing.rs+++ b/crates/pptx/src/read/timing.rs@@ -94,6 +94,13 @@ fn max_dur(e: &El, depth: usize) -> Option<u32> {     best } +/// Does any behaviour time node (`p:cBhvr/p:cTn`) under the effect node `c` carry `autoRev`?+fn behaviour_auto_reverses(c: &El) -> bool {+    let mut behaviours = vec![];+    c.find_all("cBhvr", &mut behaviours);+    behaviours.iter().any(|b| b.child("cTn").and_then(|t| t.bool("autoRev")).unwrap_or(false))+}+ /// 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);@@ -144,7 +151,9 @@ pub fn animations(imp: &mut Imp, part: &Part, ctx: &IdCtx, timing: &El, shapes:             trigger: f.trigger.and_then(|t| ctx.lookup(t)),             preset_id: Some(preset),             preset_subtype: Some(sub),-            auto_reverse: c.bool("autoRev").unwrap_or(false),+            // 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.bool("autoRev").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("fill") == Some("remove"),             smooth_start: c.f64("accel").map(|v| v / 100_000.0).unwrap_or(0.0),diff --git a/crates/pptx/tests/it/anim_auto_reverse.rs b/crates/pptx/tests/it/anim_auto_reverse.rsnew file mode 100644--- /dev/null+++ b/crates/pptx/tests/it/anim_auto_reverse.rs@@ -0,0 +1,44 @@+//! "Auto-reverse" is written by PowerPoint on the behaviour time nodes, not on the effect node.++use std::io::{Cursor, Read, Write};++use deckcraft_geom::Xfrm;+use deckcraft_model::anim::{AnimClass, Animation};+use deckcraft_model::{Shape, ShapeId, defaults};++fn deck_with(effect: &str, behaviour: &str) -> Vec<bool> {+    let mut p = defaults::new_presentation(None);+    let id = ShapeId(p.alloc_id().expect("id"));+    let shape = Shape { id, name: "Box".into(), xfrm: Some(Xfrm::new(10.0, 10.0, 200.0, 100.0)), ..Default::default() };+    let sl = std::sync::Arc::make_mut(&mut p.slides[0]);+    sl.shapes = vec![shape];+    sl.animations.push(Animation { shape: id, class: AnimClass::Emphasis, effect: effect.into(), duration_ms: 1000, ..Default::default() });+    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 at = xml.find(behaviour).expect("behaviour written");+            let cb = at + xml[at..].find("<p:cTn ").expect("behaviour node");+            d = format!("{}<p:cTn autoRev=\"1\" {}", &xml[..cb], &xml[cb + "<p:cTn ".len()..]).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");+    q.slides[0].animations.iter().map(|a| a.auto_reverse).collect()+}++#[test]+fn grow_shrink_reads_auto_reverse_from_its_behaviour_node() {+    assert_eq!(deck_with("growShrink", "<p:animScale>"), [true]);+}++#[test]+fn color_pulse_behaviour_auto_reverse_is_not_the_option() {+    assert_eq!(deck_with("colorPulse", "<p:animClr "), [false]);+}diff --git a/crates/pptx/tests/it/main.rs b/crates/pptx/tests/it/main.rs--- a/crates/pptx/tests/it/main.rs+++ b/crates/pptx/tests/it/main.rs@@ -1,4 +1,5 @@ mod anchor;+mod anim_auto_reverse; mod chart_embed; mod comments; mod corpus;