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
@@ -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),new file mode 100644@@ -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]);+}@@ -1,4 +1,5 @@ mod anchor;+mod anim_auto_reverse; mod chart_embed; mod comments; mod corpus;