diff --git a/crates/pptx/src/maps.rs b/crates/pptx/src/maps.rs index 70b9f5cf..1d40c574 100644 --- a/crates/pptx/src/maps.rs +++ b/crates/pptx/src/maps.rs @@ -310,6 +310,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, diff --git a/crates/pptx/src/read/timing.rs b/crates/pptx/src/read/timing.rs index 500bb485..9548f3f4 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 56ba5a9d..fcb8fe58 100644 --- a/crates/pptx/src/write/timing.rs +++ b/crates/pptx/src/write/timing.rs @@ -430,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 420a3921..3f67f821 100644 --- a/crates/pptx/tests/roundtrip.rs +++ b/crates/pptx/tests/roundtrip.rs @@ -730,3 +730,28 @@ fn date_fields_keep_their_type_and_saved_text() { assert_eq!(by_name(&q.slides[0], name).text, by_name(&p.slides[0], name).text, "{name}"); } } + +/// #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")]); +}