diff --git a/crates/anim/src/effects.rs b/crates/anim/src/effects.rs index 37776f83..51a82a71 100644 --- a/crates/anim/src/effects.rs +++ b/crates/anim/src/effects.rs @@ -44,6 +44,15 @@ pub(crate) fn exit_base(effect: &str) -> &str { "swivelOut" => "swivel", "bounceOut" => "bounce", "dissolveOut" => "dissolve", + "blindsOut" => "blinds", + "boxOut" => "box", + "checkerboardOut" => "checkerboard", + "diamondOut" => "diamond", + "peekOut" => "peek", + "stripsOut" => "strips", + "sinkDown" => "rise", + "spinnerOut" => "spinner", + "contract" => "expand", other => other, } } diff --git a/crates/anim/src/tests.rs b/crates/anim/src/tests.rs index 18d934fa..d34eb205 100644 --- a/crates/anim/src/tests.rs +++ b/crates/anim/src/tests.rs @@ -819,3 +819,15 @@ fn honeycomb_cells_reach_the_bottom_before_the_slide_ends() { } } } + +/// #147: exits PowerPoint plays downward leave downward here too. +#[test] +fn sink_down_and_peek_out_to_bottom_move_down() { + for (effect, option) in [("sinkDown", ""), ("peekOut", "b"), ("floatOut", "d")] { + let mut a = anim(1, AnimClass::Exit, effect, AnimStart::OnClick); + a.option = option.into(); + let tl = Timeline::new(&slide_with(vec![a]), W, H); + let s = tl.state(ShapeId(1), None, 0, 0.5); + assert!(s.offset_y > 0.0, "{effect} {option}: {s:?}"); + } +} diff --git a/crates/anim/src/timeline.rs b/crates/anim/src/timeline.rs index dcb55484..db4ea8ba 100644 --- a/crates/anim/src/timeline.rs +++ b/crates/anim/src/timeline.rs @@ -505,7 +505,8 @@ fn build_spec(a: &Animation, xf: Xfrm, sw: f64, sh: f64, resolve: &dyn Fn(&deckc EffectSpec { class: a.class, effect, - option: a.option.clone(), + // Sink Down is Rise Up played backwards, leaving downward. + option: if a.effect == "sinkDown" { "d".into() } else { a.option.clone() }, accel: a.smooth_start, decel: a.smooth_end, bounce: a.bounce_end, diff --git a/crates/model/src/anim.rs b/crates/model/src/anim.rs index 7c45aa0f..370844c9 100644 --- a/crates/model/src/anim.rs +++ b/crates/model/src/anim.rs @@ -365,6 +365,15 @@ pub static ANIMATIONS: &[(&str, &str, AnimClass, u32, u32, &[&str])] = &[ ("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"]), + ("checkerboardOut", "Checkerboard", AnimClass::Exit, 5, 500, &["across", "down"]), + ("diamondOut", "Diamond", AnimClass::Exit, 8, 500, &["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, &[]), ("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 dba4b5c2..0dfc8aec 100644 --- a/crates/pptx/src/maps.rs +++ b/crates/pptx/src/maps.rs @@ -304,6 +304,12 @@ const SUBTYPES: &[(&str, &[(&str, u32)])] = &[ ("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), @@ -352,6 +358,10 @@ pub fn fallback_effect(class: AnimClass) -> &'static str { /// The preset id to write for an animation. pub fn preset_for(a: &deckcraft_model::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, @@ -377,6 +387,25 @@ mod tests { use super::*; use crate::xml::parse; + /// #147: 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 deckcraft_model::anim::TRANSITIONS { diff --git a/crates/pptx/src/read/timing.rs b/crates/pptx/src/read/timing.rs index f331ecfe..89e238b6 100644 --- a/crates/pptx/src/read/timing.rs +++ b/crates/pptx/src/read/timing.rs @@ -119,6 +119,12 @@ pub fn animations(imp: &mut Imp, part: &Part, ctx: &IdCtx, timing: &El, _shapes: let Some(shape) = ctx.lookup(file_id) else { continue }; let paragraph = tgt.path(&["txEl", "pRg"]).and_then(|p| p.u32("st")); 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("nodeType") { Some("clickEffect") => AnimStart::OnClick, Some("afterEffect") => AnimStart::AfterPrevious, @@ -128,7 +134,7 @@ pub fn animations(imp: &mut Imp, part: &Part, ctx: &IdCtx, timing: &El, _shapes: shape, class, effect: effect.to_string(), - option: maps::option_from_subtype(effect, sub), + option, start, duration_ms: max_dur(c, 0).unwrap_or(0), delay_ms: c.path(&["stCondLst", "cond"]).and_then(|d| ms_attr(d, "delay")).unwrap_or(0), diff --git a/crates/pptx/src/write/timing.rs b/crates/pptx/src/write/timing.rs index 09b101dc..9b4ae338 100644 --- a/crates/pptx/src/write/timing.rs +++ b/crates/pptx/src/write/timing.rs @@ -208,14 +208,14 @@ fn filter_for(effect: &str, option: &str) -> &'static str { "randombar(horizontal)" } } - "blinds" => { + "blinds" | "blindsOut" => { if option == "vert" { "blinds(vertical)" } else { "blinds(horizontal)" } } - "box" => { + "box" | "boxOut" => { if option == "out" { "box(out)" } else { @@ -229,7 +229,7 @@ fn filter_for(effect: &str, option: &str) -> &'static str { "circle(in)" } } - "diamond" => { + "diamond" | "diamondOut" => { if option == "out" { "diamond(out)" } else { @@ -246,7 +246,7 @@ fn filter_for(effect: &str, option: &str) -> &'static str { "plusOut" => "plus(out)", _ => "circle(in)", }, - "checkerboard" => { + "checkerboard" | "checkerboardOut" => { if option == "down" { "checkerboard(down)" } else { @@ -261,13 +261,13 @@ fn filter_for(effect: &str, option: &str) -> &'static str { "8" => "wheel(8)", _ => "wheel(1)", }, - "strips" => match option { + "strips" | "stripsOut" => match option { "ru" => "strips(upRight)", "ld" => "strips(downLeft)", "rd" => "strips(downRight)", _ => "strips(upLeft)", }, - "peek" => match option { + "peek" | "peekOut" => match option { "t" => "slide(fromTop)", "l" => "slide(fromLeft)", "r" => "slide(fromRight)", @@ -328,7 +328,12 @@ fn behaviors(w: &mut W, x: &mut Exp, ids: &mut Ids, e: &Eff) { anim_prop(w, ids, e, dur, "ppt_x", "#ppt_x", fx); anim_prop(w, ids, e, dur, "ppt_y", "#ppt_y", fy); } - "zoomOut" | "shrinkTurn" => { + "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); @@ -425,7 +430,11 @@ fn effect_par(w: &mut W, x: &mut Exp, ids: &mut Ids, e: &Eff, first_in_click: bo }; let _ = first_in_click; let preset = crate::maps::preset_for(a); - let sub = crate::maps::subtype_from_option(&a.effect, &a.option).or(a.preset_subtype.filter(|_| a.preset_id == Some(preset))).unwrap_or(0); + 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, diff --git a/crates/pptx/tests/roundtrip.rs b/crates/pptx/tests/roundtrip.rs index 39a28f0e..e61fd578 100644 --- a/crates/pptx/tests/roundtrip.rs +++ b/crates/pptx/tests/roundtrip.rs @@ -1079,3 +1079,28 @@ fn single_guide_names_follow_the_preset() { assert!(xml.contains(&format!(r#""#)), "{prst} → {gd}: {xml}"); } } + +/// #147: PowerPoint's Float Down is preset 47 (Float Up is 42); it imported as a plain fade. +#[test] +fn float_down_is_preset_47_both_ways() { + let mut p = defaults::new_presentation(None); + let mut s = new_slide(&mut p, "Float"); + let sh = shape(&mut p, "Building", 0.0, 0.0, 100.0, 100.0); + let id = sh.id; + s.shapes.push(sh); + s.animations = vec![ + Animation { shape: id, class: AnimClass::Entrance, effect: "float".into(), option: "d".into(), ..Default::default() }, + Animation { shape: id, class: AnimClass::Exit, effect: "floatOut".into(), option: "d".into(), ..Default::default() }, + Animation { shape: id, class: AnimClass::Exit, effect: "floatOut".into(), option: "u".into(), ..Default::default() }, + ]; + p.slides.push(Arc::new(s)); + let bytes = deckcraft_pptx::export(&p).expect("export"); + let mut z = zip::ZipArchive::new(std::io::Cursor::new(&bytes)).expect("zip"); + let xml = std::io::read_to_string(z.by_name("ppt/slides/slide2.xml").expect("slide")).expect("read"); + assert!(xml.contains(r#"presetID="47" presetClass="entr" presetSubtype="0""#), "{xml}"); + assert!(xml.contains(r#"presetID="47" presetClass="exit" presetSubtype="0""#), "{xml}"); + assert!(xml.contains(r#"presetID="42" presetClass="exit" presetSubtype="0""#), "{xml}"); + let q = deckcraft_pptx::import(&bytes).expect("import"); + let got: Vec<(&str, &str)> = q.slides[1].animations.iter().map(|a| (a.effect.as_str(), a.option.as_str())).collect(); + assert_eq!(got, [("float", "d"), ("floatOut", "d"), ("floatOut", "u")]); +}