From 1e842137b0a21ccf29e15de5ac356b1dacfbc727 Mon Sep 17 00:00:00 2001 From: Harrison Booth <73434116+Harrison-Booth@users.noreply.github.com> Date: Tue, 27 Oct 2026 18:53:24 -0500 Subject: [PATCH] Animations: Darken and Desaturate use PowerPoint's preset ids and HSL shift, and 14 effects get its default durations --- crates/model/src/anim.rs | 32 +++++++++++----------- crates/pptx/src/maps.rs | 11 ++++++++ crates/pptx/src/write/timing.rs | 14 +++++++++- crates/pptx/tests/it/roundtrip.rs | 44 +++++++++++++++++++++++++++++++ 4 files changed, 84 insertions(+), 17 deletions(-) diff --git a/crates/model/src/anim.rs b/crates/model/src/anim.rs index a1f5943a..2e273910 100644 --- a/crates/model/src/anim.rs +++ b/crates/model/src/anim.rs @@ -367,37 +367,37 @@ pub static ANIMATIONS: &[(&str, &str, AnimClass, u32, u32, &[&str])] = &[ ("shape", "Shape", AnimClass::Entrance, 6, 2000, &["circleIn", "circleOut", "boxIn", "boxOut", "diamondIn", "diamondOut", "plusIn", "plusOut"]), ("wheel", "Wheel", AnimClass::Entrance, 21, 2000, &["1", "2", "3", "4", "8"]), ("randomBars", "Random Bars", AnimClass::Entrance, 14, 500, &["horz", "vert"]), - ("grow", "Grow & Turn", AnimClass::Entrance, 31, 500, &[]), + ("grow", "Grow & Turn", AnimClass::Entrance, 31, 1000, &[]), ("zoom", "Zoom", AnimClass::Entrance, 53, 500, &["in", "out"]), ("swivel", "Swivel", AnimClass::Entrance, 45, 2000, &["horz", "vert"]), ("bounce", "Bounce", AnimClass::Entrance, 26, 2000, &[]), ("blinds", "Blinds", AnimClass::Entrance, 3, 500, &["horz", "vert"]), - ("box", "Box", AnimClass::Entrance, 4, 500, &["in", "out"]), + ("box", "Box", AnimClass::Entrance, 4, 2000, &["in", "out"]), ("checkerboard", "Checkerboard", AnimClass::Entrance, 5, 500, &["across", "down"]), - ("circle", "Circle", AnimClass::Entrance, 6, 500, &["in", "out"]), - ("diamond", "Diamond", AnimClass::Entrance, 8, 500, &["in", "out"]), + ("circle", "Circle", AnimClass::Entrance, 6, 2000, &["in", "out"]), + ("diamond", "Diamond", AnimClass::Entrance, 8, 2000, &["in", "out"]), ("dissolve", "Dissolve In", AnimClass::Entrance, 9, 500, &[]), ("peek", "Peek In", AnimClass::Entrance, 12, 500, &["b", "l", "t", "r"]), ("strips", "Strips", AnimClass::Entrance, 18, 500, &["lu", "ru", "ld", "rd"]), - ("expand", "Expand", AnimClass::Entrance, 55, 500, &[]), + ("expand", "Expand", AnimClass::Entrance, 55, 1000, &[]), ("rise", "Rise Up", AnimClass::Entrance, 37, 1000, &[]), - ("spinner", "Spinner", AnimClass::Entrance, 49, 1000, &[]), + ("spinner", "Spinner", AnimClass::Entrance, 49, 500, &[]), ("pulse", "Pulse", AnimClass::Emphasis, 26, 500, &[]), - ("colorPulse", "Color Pulse", AnimClass::Emphasis, 27, 500, &[]), + ("colorPulse", "Color Pulse", AnimClass::Emphasis, 27, 250, &[]), ("teeter", "Teeter", AnimClass::Emphasis, 32, 1000, &[]), ("spin", "Spin", AnimClass::Emphasis, 8, 2000, &["clockwise", "counterClockwise"]), ("growShrink", "Grow/Shrink", AnimClass::Emphasis, 6, 2000, &["both", "horz", "vert"]), - ("desaturate", "Desaturate", AnimClass::Emphasis, 35, 500, &[]), - ("darken", "Darken", AnimClass::Emphasis, 25, 500, &[]), + ("desaturate", "Desaturate", AnimClass::Emphasis, 25, 500, &[]), + ("darken", "Darken", AnimClass::Emphasis, 24, 500, &[]), ("lighten", "Lighten", AnimClass::Emphasis, 30, 500, &[]), ("transparency", "Transparency", AnimClass::Emphasis, 9, 500, &[]), - ("objectColor", "Object Color", AnimClass::Emphasis, 19, 2000, &[]), + ("objectColor", "Object Color", AnimClass::Emphasis, 19, 500, &[]), ("complementaryColor", "Complementary Color", AnimClass::Emphasis, 21, 500, &[]), ("lineColor", "Line Color", AnimClass::Emphasis, 7, 2000, &[]), ("fillColor", "Fill Color", AnimClass::Emphasis, 1, 2000, &[]), ("fontColor", "Font Color", AnimClass::Emphasis, 3, 2000, &[]), ("underline", "Underline", AnimClass::Emphasis, 18, 500, &[]), - ("boldFlash", "Bold Flash", AnimClass::Emphasis, 10, 500, &[]), + ("boldFlash", "Bold Flash", AnimClass::Emphasis, 10, 2000, &[]), ("wave", "Wave", AnimClass::Emphasis, 34, 500, &[]), ("disappear", "Disappear", AnimClass::Exit, 1, 0, &[]), ("fadeOut", "Fade", AnimClass::Exit, 10, 500, &[]), @@ -408,20 +408,20 @@ pub static ANIMATIONS: &[(&str, &str, AnimClass, u32, u32, &[&str])] = &[ ("shapeOut", "Shape", AnimClass::Exit, 6, 2000, &["circleIn", "circleOut"]), ("wheelOut", "Wheel", AnimClass::Exit, 21, 2000, &["1", "2", "3", "4", "8"]), ("randomBarsOut", "Random Bars", AnimClass::Exit, 14, 500, &["horz", "vert"]), - ("shrinkTurn", "Shrink & Turn", AnimClass::Exit, 31, 500, &[]), + ("shrinkTurn", "Shrink & Turn", AnimClass::Exit, 31, 1000, &[]), ("zoomOut", "Zoom", AnimClass::Exit, 53, 500, &["in", "out"]), ("swivelOut", "Swivel", AnimClass::Exit, 45, 2000, &["horz", "vert"]), ("bounceOut", "Bounce", AnimClass::Exit, 26, 2000, &[]), ("dissolveOut", "Dissolve Out", AnimClass::Exit, 9, 500, &[]), ("blindsOut", "Blinds", AnimClass::Exit, 3, 500, &["horz", "vert"]), - ("boxOut", "Box", AnimClass::Exit, 4, 500, &["in", "out"]), + ("boxOut", "Box", AnimClass::Exit, 4, 2000, &["in", "out"]), ("checkerboardOut", "Checkerboard", AnimClass::Exit, 5, 500, &["across", "down"]), - ("diamondOut", "Diamond", AnimClass::Exit, 8, 500, &["in", "out"]), + ("diamondOut", "Diamond", AnimClass::Exit, 8, 2000, &["in", "out"]), ("peekOut", "Peek Out", AnimClass::Exit, 12, 500, &["b", "l", "t", "r"]), ("stripsOut", "Strips", AnimClass::Exit, 18, 500, &["lu", "ru", "ld", "rd"]), ("sinkDown", "Sink Down", AnimClass::Exit, 37, 1000, &[]), - ("spinnerOut", "Spinner", AnimClass::Exit, 49, 1000, &[]), - ("contract", "Contract", AnimClass::Exit, 55, 500, &[]), + ("spinnerOut", "Spinner", AnimClass::Exit, 49, 500, &[]), + ("contract", "Contract", AnimClass::Exit, 55, 1000, &[]), ("lines", "Lines", AnimClass::Path, 64, 2000, &["down", "up", "right", "left"]), ("arcs", "Arcs", AnimClass::Path, 37, 2000, &["down", "up", "right", "left"]), ("turns", "Turns", AnimClass::Path, 43, 2000, &["down", "up", "right", "left"]), diff --git a/crates/pptx/src/maps.rs b/crates/pptx/src/maps.rs index 0dfc8aec..6ce81a9b 100644 --- a/crates/pptx/src/maps.rs +++ b/crates/pptx/src/maps.rs @@ -435,4 +435,15 @@ mod tests { 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 = deckcraft_model::Animation { class: AnimClass::Emphasis, effect: effect.into(), ..Default::default() }; + assert_eq!(preset_for(&a), id, "{effect}"); + } + } } diff --git a/crates/pptx/src/write/timing.rs b/crates/pptx/src/write/timing.rs index 66708bb4..0f3dc8ee 100644 --- a/crates/pptx/src/write/timing.rs +++ b/crates/pptx/src/write/timing.rs @@ -383,7 +383,19 @@ fn behaviors(w: &mut W, x: &mut Exp, ids: &mut Ids, e: &Eff) { w.close("p:animClr"); } } - "fillColor" | "objectColor" | "fontColor" | "lineColor" | "complementaryColor" | "darken" | "lighten" => { + // 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.open("p:animClr", A::new().a("clrSpc", "hsl").a("dir", "cw")); + cbhvr(w, ids, e, dur, None, &[attr], None); + w.open0("p:by"); + w.empty("p:hsl", A::new().a("h", 0).a("s", s).a("l", l)); + w.close("p:by"); + w.close("p:animClr"); + } + } + "fillColor" | "objectColor" | "fontColor" | "lineColor" | "complementaryColor" | "lighten" => { let attr = match a.effect.as_str() { "fontColor" => "style.color", "lineColor" => "stroke.color", diff --git a/crates/pptx/tests/it/roundtrip.rs b/crates/pptx/tests/it/roundtrip.rs index f4d2c2b7..370cc9a9 100644 --- a/crates/pptx/tests/it/roundtrip.rs +++ b/crates/pptx/tests/it/roundtrip.rs @@ -1925,3 +1925,47 @@ fn zoom_inserts_linked_slide_pictures() { assert!(s.execute("insert.zoom", &json!({"slides": [42]})).is_err()); assert!(s.execute("insert.zoom", &json!({"kind": "slide"})).is_err()); } + +#[test] +fn darken_and_desaturate_write_powerpoints_hsl_shift() { + for (effect, preset, shift) in [("darken", 24, r#"s="0" l="-25098""#), ("desaturate", 25, r#"s="-70588" l="0""#)] { + let mut p = defaults::new_presentation(None); + let mut s = p.slides[0].as_ref().clone(); + let sh = shape(&mut p, "Target", 100.0, 100.0, 100.0, 100.0); + let id = sh.id; + s.shapes.push(sh); + s.animations.push(Animation { shape: id, class: AnimClass::Emphasis, effect: effect.into(), duration_ms: 500, ..Default::default() }); + p.slides[0] = Arc::new(s); + let bytes = deckcraft_pptx::export(&p).expect("export"); + let mut zip = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap(); + let mut xml = String::new(); + std::io::Read::read_to_string(&mut zip.by_name("ppt/slides/slide1.xml").unwrap(), &mut xml).unwrap(); + assert!(xml.contains(&format!(r#"presetID="{preset}""#)), "{effect}"); + assert!(xml.contains(r#"clrSpc="hsl""#) && xml.contains(shift), "{effect}: {xml}"); + assert_eq!(round(&p).slides[0].animations[0].effect, effect); + } +} + +/// PowerPoint's default durations (COM `AddEffect`), which differ from the 0.5 s most effects get. +#[test] +fn default_durations_match_powerpoint() { + for (effect, ms) in [ + ("grow", 1000), + ("shrinkTurn", 1000), + ("box", 2000), + ("circle", 2000), + ("diamond", 2000), + ("boxOut", 2000), + ("diamondOut", 2000), + ("expand", 1000), + ("contract", 1000), + ("spinner", 500), + ("spinnerOut", 500), + ("colorPulse", 250), + ("objectColor", 500), + ("boldFlash", 2000), + ] { + let a = deckcraft_model::anim::ANIMATIONS.iter().find(|a| a.0 == effect).unwrap(); + assert_eq!(a.4, ms, "{effect}"); + } +}