diff --git a/crates/pptx/src/read/timing.rs b/crates/pptx/src/read/timing.rs index d12041a8b..44f6d2a1a 100644 --- a/crates/pptx/src/read/timing.rs +++ b/crates/pptx/src/read/timing.rs @@ -245,7 +245,10 @@ pub fn animations(imp: &mut Imp, part: &Part, ctx: &IdCtx, timing: &El, shapes: .map(|b| { b.children_named("bldP") .filter_map(|p| Some(((p.u32("spid")?, p.attr("grpId").unwrap_or("0").to_string()), p.attr("build") == Some("p")))) - .collect() + .fold(std::collections::HashMap::new(), |mut m, (k, v)| { + m.entry(k).and_modify(|e| *e |= v).or_insert(v); + m + }) }) .unwrap_or_default(); let mut out: Vec<(Animation, u32, String, bool)> = vec![]; @@ -327,10 +330,9 @@ pub fn animations(imp: &mut Imp, part: &Part, ctx: &IdCtx, timing: &El, shapes: for run in out.chunk_by(same_build) { let effects: Vec<&Animation> = run.iter().map(|(a, ..)| a).collect(); let build = run.first().is_some_and(|r| r.3); - let explicit_bld = run.first().is_some_and(|r| bld_p.get(&(r.1, r.2.clone())) == Some(&true)); let text = effects.first().and_then(|a| find_shape(shapes, a.shape)).and_then(|s| s.text.as_ref()); match (effects.first(), text) { - (Some(first), Some(text)) if build && plays_as_build(&effects, text, explicit_bld) => { + (Some(first), Some(text)) if build && plays_as_build(&effects, text) => { anims.push(Animation { text_build: TextBuild::ByParagraph, paragraph: None, ..(*first).clone() }); } _ => anims.extend(effects.into_iter().cloned()), @@ -343,7 +345,7 @@ pub fn animations(imp: &mut Imp, part: &Part, ctx: &IdCtx, timing: &El, shapes: /// first one plays: one effect per paragraph the build animates, in paragraph order, later top /// paragraphs on click (after previous when the build doesn't start on click), deeper ones with /// their parent, all with the same settings? -fn plays_as_build(effects: &[&Animation], text: &TextBody, explicit_bld: bool) -> bool { +fn plays_as_build(effects: &[&Animation], text: &TextBody) -> bool { let roles = paragraph_build_roles(text); let expected = roles.iter().enumerate().filter(|(i, r)| match r { BuildRole::Top => true, @@ -351,9 +353,6 @@ fn plays_as_build(effects: &[&Animation], text: &TextBody, explicit_bld: bool) - BuildRole::Unowned => false, }); let Some(first) = effects.first() else { return false }; - if !explicit_bld && first.start != AnimStart::OnClick && (span_ms(first) != 500 || first.delay_ms != 0) { - return false; - } let settings = |a: &Animation| Animation { start: AnimStart::OnClick, paragraph: None, ..a.clone() }; let base = settings(first); let mut matched = 0; diff --git a/crates/pptx/tests/it/corpus_style.rs b/crates/pptx/tests/it/corpus_style.rs index e64e2f5e6..ed4f353ae 100644 --- a/crates/pptx/tests/it/corpus_style.rs +++ b/crates/pptx/tests/it/corpus_style.rs @@ -316,3 +316,79 @@ fn left_of(p: &Presentation) -> ShapeId { fn right_of(p: &Presentation) -> ShapeId { by_name(p, "Right").id } + +fn rewrite_zip_entry(bytes: &[u8], entry_name: &str, f: impl Fn(&str) -> String) -> Vec { + use std::io::{Cursor, Read, Write}; + let mut r = zip::ZipArchive::new(Cursor::new(bytes)).unwrap(); + let mut out = Cursor::new(Vec::new()); + { + let mut w = zip::ZipWriter::new(&mut out); + let opts = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Deflated); + for i in 0..r.len() { + let mut file = r.by_index(i).unwrap(); + let name = file.name().to_string(); + let mut buf = Vec::new(); + file.read_to_end(&mut buf).unwrap(); + w.start_file(&name, opts).unwrap(); + if name == entry_name { + let text = String::from_utf8(buf).unwrap(); + w.write_all(f(&text).as_bytes()).unwrap(); + } else { + w.write_all(&buf).unwrap(); + } + } + w.finish().unwrap(); + } + out.into_inner() +} + +#[test] +fn paragraph_build_without_bld_lst_still_folds_into_by_paragraph() { + use deckcraft_model::anim::{AnimClass, AnimStart, Animation, TextBuild}; + let mut pres = defaults::new_presentation(None); + let mut sh = shape(&mut pres, "Title 1", 50.0, 50.0, 400.0, 200.0); + let shape_id = sh.id; + sh.text = Some(TextBody { paragraphs: vec![Paragraph::new("First"), Paragraph::new("Second")], ..Default::default() }); + let s0 = Arc::make_mut(&mut pres.slides[0]); + s0.shapes.push(sh); + s0.animations.push(Animation { + shape: shape_id, + class: AnimClass::Entrance, + effect: "fade".into(), + start: AnimStart::OnClick, + duration_ms: 500, + text_build: TextBuild::ByParagraph, + ..Default::default() + }); + let bytes = deckcraft_pptx::export(&pres).unwrap(); + let without_bld = rewrite_zip_entry(&bytes, "ppt/slides/slide1.xml", |xml| { + let start = xml.find("").expect("bldLst"); + let end = xml.find("").expect("/bldLst") + "".len(); + format!("{}{}", &xml[..start], &xml[end..]) + }); + let back = deckcraft_pptx::import(&without_bld).unwrap(); + assert_eq!(back.slides[0].animations.len(), 1); + assert_eq!(back.slides[0].animations[0].text_build, TextBuild::ByParagraph); + + // Non-500ms After Previous paragraph build without bldLst is also inferred. + let mut pres_after = pres.clone(); + let s_after = Arc::make_mut(&mut pres_after.slides[0]); + s_after.animations[0].start = AnimStart::AfterPrevious; + s_after.animations[0].duration_ms = 800; + let bytes_after = deckcraft_pptx::export(&pres_after).unwrap(); + let without_bld_after = rewrite_zip_entry(&bytes_after, "ppt/slides/slide1.xml", |xml| { + let start = xml.find("").expect("bldLst"); + let end = xml.find("").expect("/bldLst") + "".len(); + format!("{}{}", &xml[..start], &xml[end..]) + }); + let back_after = deckcraft_pptx::import(&without_bld_after).unwrap(); + assert_eq!(back_after.slides[0].animations.len(), 1); + assert_eq!(back_after.slides[0].animations[0].text_build, TextBuild::ByParagraph); + assert_eq!(back_after.slides[0].animations[0].duration_ms, 800); + + // Explicit bldP with build!="p" (e.g. build="cust") opts out of folding. + let custom_bld = rewrite_zip_entry(&bytes, "ppt/slides/slide1.xml", |xml| xml.replace(r#"build="p""#, r#"build="cust""#)); + let back_cust = deckcraft_pptx::import(&custom_bld).unwrap(); + assert_eq!(back_cust.slides[0].animations.len(), 2); + assert!(back_cust.slides[0].animations.iter().all(|a| a.text_build == TextBuild::default())); +}