//! #844: a paragraph-by-paragraph build read from a PPTX file takes the clicks PowerPoint gives //! it. The file lists one effect per paragraph, each with its own start (On Click or With //! Previous); the show must not ask for more clicks, or fewer, than those effects. use std::io::{Cursor, Read, Write}; use std::sync::Arc; use deckcraft_anim::{ShowAction, ShowState}; use deckcraft_geom::Xfrm; use deckcraft_model::anim::{AnimStart, Animation, TextBuild}; use deckcraft_model::text::TextBody; use deckcraft_model::{LayoutType, Presentation, Shape, ShapeId, defaults}; use zip::write::SimpleFileOptions; fn shape(p: &mut Presentation, name: &str, text: &str, y: f64) -> Shape { Shape { id: ShapeId(p.alloc_id().expect("id")), name: name.into(), xfrm: Some(Xfrm::new(100.0, y, 600.0, 150.0)), text: Some(TextBody::from_text(text)), ..Default::default() } } /// Two slides: "Body" (paragraphs `body`, at `levels`) and "Picture" on the first, nothing on /// the second, saved as PPTX. fn saved(body: &str, levels: &[u8]) -> Vec { let mut p = defaults::new_presentation(None); p.slides.clear(); let mut b = shape(&mut p, "Body", body, 50.0); if let Some(t) = b.text.as_mut() { for (para, lvl) in t.paragraphs.iter_mut().zip(levels) { para.level = *lvl; } } let pic = shape(&mut p, "Picture", "Picture", 300.0); let anims = vec![ Animation { shape: b.id, text_build: TextBuild::ByParagraph, start: AnimStart::OnClick, duration_ms: 500, ..Default::default() }, Animation { shape: pic.id, start: AnimStart::WithPrevious, duration_ms: 500, ..Default::default() }, ]; for (shapes, animations) in [(vec![b, pic], anims), (vec![], vec![])] { let layout = defaults::layout_of_kind(&p, LayoutType::Blank).unwrap_or_default(); let mut s = defaults::new_slide(&mut p, layout).expect("slide"); s.shapes = shapes; s.animations = animations; p.slides.push(Arc::new(s)); } deckcraft_pptx::export(&p).expect("export") } fn spid(xml: &str, name: &str) -> String { let at = xml.find(&format!(r#"name="{name}""#)).expect("shape"); let head = &xml[..at]; let id = head.rfind(r#"id=""#).expect("id") + 4; head[id..].split('"').next().expect("spid").to_string() } /// One effect node as PowerPoint writes it: Fade in on `spid` (paragraph `para`). fn effect(ids: &mut u32, spid: &str, para: Option, node: &str) -> String { let mut id = || { *ids += 1; *ids }; let tgt = match para { Some(p) => format!(r#""#), None => format!(r#""#), }; format!( r#"{tgt}style.visibility{tgt}"#, id(), id(), id() ) } /// A click group holding `effects`. fn click(ids: &mut u32, effects: &[String]) -> String { *ids += 2; format!( r#"{}"#, *ids - 1, *ids, effects.concat() ) } /// `bytes` with the first slide's timing replaced by `clicks` (built with `effect` and `click` /// from the spids of Body and Picture), Body's build "By paragraph". fn with_timing(bytes: &[u8], clicks: impl Fn(&mut u32, &str, &str) -> Vec) -> Vec { let mut src = zip::ZipArchive::new(Cursor::new(bytes)).expect("zip"); let mut out = zip::ZipWriter::new(Cursor::new(Vec::new())); for i in 0..src.len() { let mut f = src.by_index(i).expect("entry"); let name = f.name().to_string(); let mut data = Vec::new(); f.read_to_end(&mut data).expect("read"); if name == "ppt/slides/slide1.xml" { let xml = String::from_utf8(data).expect("utf8"); let (body, pic) = (spid(&xml, "Body"), spid(&xml, "Picture")); let mut ids = 2; let main = clicks(&mut ids, &body, &pic).concat(); let timing = format!( r#"{main}"# ); let a = xml.find("").expect("timing"); let b = xml.find("").expect("timing end") + "".len(); data = format!("{}{timing}{}", &xml[..a], &xml[b..]).into_bytes(); } out.start_file(name, SimpleFileOptions::default()).expect("start"); out.write_all(&data).expect("write"); } out.finish().expect("finish").into_inner() } /// Clicks (Next with nothing playing) from the start of the first slide to the second one. fn clicks_to_second_slide(pres: &Presentation) -> usize { let mut s = ShowState::new(pres); for n in 1..=50 { match s.next(pres) { ShowAction::PlayStep(_) => { // The step clock runs out before the next click. s.step_done(pres); } ShowAction::Slide(1) => return n, a => panic!("unexpected {a:?}"), } } usize::MAX } #[test] fn a_paragraph_build_split_by_another_shapes_effect_takes_powerpoints_clicks() { // As PowerPoint writes "Body: By paragraph, Picture: With Previous after the first // paragraph": click 1 = Body ¶0 + Picture, click 2 = Body ¶1, click 3 = Body ¶2. let bytes = with_timing(&saved("One\nTwo\nThree", &[0, 0, 0]), |ids, body, pic| { let e0 = effect(ids, body, Some(0), "clickEffect"); let ep = effect(ids, pic, None, "withEffect"); let e1 = effect(ids, body, Some(1), "clickEffect"); let e2 = effect(ids, body, Some(2), "clickEffect"); vec![click(ids, &[e0, ep]), click(ids, &[e1]), click(ids, &[e2])] }); let pres = deckcraft_pptx::import(&bytes).expect("import"); assert_eq!(ShowState::steps(&pres, 0), 3, "three click steps, as in the file"); assert_eq!(clicks_to_second_slide(&pres), 4, "three builds and one click to move on"); } #[test] fn a_build_by_second_level_paragraphs_takes_a_click_per_paragraph() { // "By 2nd level paragraphs": the heading and each sub-point have their own click. // Picture comes in with the last one, so nothing splits Body's build. let bytes = with_timing(&saved("Question\nFirst\nSecond", &[0, 1, 1]), |ids, body, pic| { let e0 = effect(ids, body, Some(0), "clickEffect"); let e1 = effect(ids, body, Some(1), "clickEffect"); let e2 = effect(ids, body, Some(2), "clickEffect"); let ep = effect(ids, pic, None, "withEffect"); vec![click(ids, &[e0]), click(ids, &[e1]), click(ids, &[e2, ep])] }); let pres = deckcraft_pptx::import(&bytes).expect("import"); assert_eq!(ShowState::steps(&pres, 0), 3, "three click steps, as in the file"); } /// The first slide's XML in a saved PPTX. fn slide_xml(bytes: &[u8]) -> String { let mut zip = zip::ZipArchive::new(Cursor::new(bytes)).expect("zip"); let mut xml = String::new(); zip.by_name("ppt/slides/slide1.xml").expect("slide").read_to_string(&mut xml).expect("read"); xml } /// Paragraph and start of each animation on the first slide. fn effects(pres: &Presentation) -> Vec<(Option, AnimStart, TextBuild)> { pres.slides[0].animations.iter().map(|a| (a.paragraph, a.start, a.text_build)).collect() } /// "By 1st level paragraphs" as PowerPoint writes it: a sub-point comes in with its parent. fn first_level_build() -> Vec { with_timing(&saved("One\nSub\nTwo", &[0, 1, 0]), |ids, body, pic| { let e0 = effect(ids, body, Some(0), "clickEffect"); let e1 = effect(ids, body, Some(1), "withEffect"); let e2 = effect(ids, body, Some(2), "clickEffect"); let ep = effect(ids, pic, None, "withEffect"); vec![click(ids, &[e0, e1]), click(ids, &[e2, ep])] }) } #[test] fn a_whole_first_level_build_still_reads_as_one_by_paragraph_animation() { let pres = deckcraft_pptx::import(&first_level_build()).expect("import"); assert_eq!(effects(&pres)[0], (None, AnimStart::OnClick, TextBuild::ByParagraph)); assert_eq!(pres.slides[0].animations.len(), 2, "Body's build and Picture"); assert_eq!(ShowState::steps(&pres, 0), 2); } #[test] fn a_by_paragraph_build_with_sub_points_survives_saving() { let pres = deckcraft_pptx::import(&first_level_build()).expect("import"); let again = deckcraft_pptx::import(&deckcraft_pptx::export(&pres).expect("export")).expect("reimport"); assert_eq!(effects(&again), effects(&pres)); assert_eq!(ShowState::steps(&again, 0), 2, "the sub-point still comes in with its parent"); } #[test] fn paragraph_effects_save_as_powerpoint_writes_them_and_read_back_unchanged() { let bytes = with_timing(&saved("One\nTwo\nThree", &[0, 0, 0]), |ids, body, pic| { let e0 = effect(ids, body, Some(0), "clickEffect"); let ep = effect(ids, pic, None, "withEffect"); let e1 = effect(ids, body, Some(1), "clickEffect"); let e2 = effect(ids, body, Some(2), "clickEffect"); vec![click(ids, &[e0, ep]), click(ids, &[e1]), click(ids, &[e2])] }); let pres = deckcraft_pptx::import(&bytes).expect("import"); let saved = deckcraft_pptx::export(&pres).expect("export"); let xml = slide_xml(&saved); let body = spid(&xml, "Body"); let blds: Vec<&str> = xml.split(""), "the effect targets the whole shape"); } /// Body's three paragraphs under `build="p"`, effects in the order `effects` gives them, then /// Picture with the last one. fn three_paragraphs(effects: impl Fn(&mut u32, &str) -> Vec) -> Presentation { let bytes = with_timing(&saved("One\nTwo\nThree", &[0, 0, 0]), |ids, body, pic| { let mut clicks: Vec = effects(ids, body).iter().map(|e| click(ids, std::slice::from_ref(e))).collect(); let ep = effect(ids, pic, None, "clickEffect"); clicks.push(click(ids, &[ep])); clicks }); deckcraft_pptx::import(&bytes).expect("import") } #[test] fn a_reversed_build_keeps_its_order() { let pres = three_paragraphs(|ids, body| (0..3).rev().map(|p| effect(ids, body, Some(p), "clickEffect")).collect()); let paras: Vec> = pres.slides[0].animations.iter().map(|a| a.paragraph).collect(); assert_eq!(paras, vec![Some(2), Some(1), Some(0), None], "last paragraph first, as in the file"); } #[test] fn a_paragraph_with_its_own_duration_keeps_its_own_effect() { let pres = three_paragraphs(|ids, body| { (0..3) .map(|p| { let e = effect(ids, body, Some(p), "clickEffect"); if p == 1 { e.replace(r#"dur="500""#, r#"dur="900""#) } else { e } }) .collect() }); let durs: Vec<(Option, u32)> = pres.slides[0].animations.iter().map(|a| (a.paragraph, a.duration_ms)).collect(); assert_eq!(durs, vec![(Some(0), 500), (Some(1), 900), (Some(2), 500), (None, 500)]); }