Browse / github.com/storytold/deckcraft / patch
PPTX: media-bookmark triggers and Show media controls survive a save and reopen (part of #362)
PPTX: media-bookmark triggers and Show media controls survive a save and reopen (part of #362) Two round-trip gaps in the .pptx reader and writer, found by agent runs that saved a deck with media and reopened it. - **#362** (first point): an interactive sequence started by `onMediaBookmark` was imported as a click trigger on the media shape and saved back as a click sequence, so the bookmark trigger was lost and a click took its place. The reader now leaves such sequences out (logged), so nothing is turned into a click. The other points of #362 (a video pause shortcut, pen colours on dark slides) are not touched here. - `show.setup {showMediaControls: false}` is stored as PowerPoint's
- Status
- open
- Score
- 0.00
- Adopters
- 0 other operators
- Author
- zberry (imported from pull request #1320, open)
- Base
38d2ade2b77de39c0e06278ddfd44d1082ff80a8- Size
- +61 −0 in 5 files
- Review
- pending
- Shared
- 2026-10-28
- Id
ccbc54bbf5d32a9e9c8f148b
How it went
No other operator has reported using it yet.
Diff
raw · crates/pptx/src/read/mod.rs crates/pptx/src/read/timing.rs crates/pptx/src/write/mod.rs crates/pptx/tests/it/roundtrip.rs crates/pptx/tests/it/trigger_steps.rs
@@ -1210,6 +1210,7 @@ fn read_show(sp: &El, shows: &HashMap<String, String>) -> ShowSettings { without_narration: sp.bool("showNarration") == Some(false), without_animation: sp.bool("showAnimation") == Some(false), use_timings: sp.bool("useTimings").unwrap_or(true),+ show_media_controls: sp.find("showMediaCtrls").and_then(|e| e.bool("val")).unwrap_or(true), ..Default::default() }; if sp.child("browse").is_some() {@@ -69,6 +69,15 @@ fn collect<'a>(e: &'a El, trigger: Option<u32>, out: &mut Vec<Found<'a>>, depth: && let Some(ctn) = c.child("cTn") && ctn.attr("nodeType") == Some("interactiveSeq") {+ // Only a click starts a button sequence we can write back. A media bookmark+ // (`onMediaBookmark`) or other start has no model: leave its effects out rather+ // than save them as clicks on the media shape (#362).+ let other_start =+ ctn.child("stCondLst").is_some_and(|l| l.children_named("cond").any(|c| c.attr("evt").is_some_and(|evt| evt != "onClick")));+ if other_start {+ log::warn!("pptx: an animation sequence with a start other than a click was left out");+ continue;+ } trig = ctn.path(&["stCondLst", "cond", "tgtEl", "spTgt"]).and_then(|s| s.u32("spid")); } collect(c, trig, out, depth + 1);@@ -1181,6 +1181,14 @@ fn pres_props_xml(p: &Presentation) -> Vec<u8> { w.open0("p:penClr"); w.val("a:srgbClr", s.pen_color.hex()); w.close("p:penClr");+ if !s.show_media_controls {+ // "Show media controls" is PowerPoint's p14 extension; absent means shown.+ w.open0("p:extLst");+ w.open("p:ext", A::new().a("uri", "{2FDB2607-1784-4EEB-B798-7EB5836EED8A}"));+ w.empty("p14:showMediaCtrls", A::new().a("xmlns:p14", NS_P14).a("val", 0));+ w.close("p:ext");+ w.close("p:extLst");+ } w.close("p:showPr"); w.close("p:presentationPr"); w.finish()@@ -1925,3 +1925,12 @@ 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()); }++/// Show media controls is PowerPoint's `p14:showMediaCtrls` extension inside `p:showPr`.+#[test]+fn hidden_media_controls_survive_save_and_reopen() {+ let mut p = Presentation::default();+ p.show.show_media_controls = false;+ assert!(!round(&p).show.show_media_controls);+ assert!(round(&Presentation::default()).show.show_media_controls);+}@@ -93,3 +93,37 @@ fn triggered_paragraph_build_keeps_a_click_for_each_paragraph() { assert_eq!(back.slides[0].animations[0].trigger, Some(ids[0])); } }++/// A sequence that starts at a media bookmark (`onMediaBookmark`, #362) is not a click on the+/// media shape. Reading it as one made a save turn the bookmark effects into click effects, so+/// they are left out instead of being given the wrong trigger.+#[test]+fn a_media_bookmark_sequence_is_not_read_as_a_click_trigger() {+ use std::io::Write;+ let mut p = deck();+ let ids: Vec<_> = p.slides[0].shapes.iter().map(|s| s.id).collect();+ Arc::make_mut(&mut p.slides[0]).animations.push(Animation {+ shape: ids[2],+ trigger: Some(ids[0]),+ effect: "appear".into(),+ text_build: TextBuild::AsOne,+ ..Default::default()+ });+ assert_eq!(round(&p).slides[0].animations.len(), 1, "a click trigger still round-trips");+ let bytes = deckcraft_pptx::export(&p).expect("export");+ let mut z = zip::ZipArchive::new(Cursor::new(bytes)).expect("zip");+ let mut out = zip::ZipWriter::new(Cursor::new(Vec::new()));+ for i in 0..z.len() {+ let mut f = z.by_index(i).expect("entry");+ let mut data = Vec::new();+ f.read_to_end(&mut data).expect("read");+ if f.name() == "ppt/slides/slide1.xml" {+ let xml = String::from_utf8(data).expect("utf8").replace(r#"evt="onClick""#, r#"evt="onMediaBookmark""#);+ data = xml.into_bytes();+ }+ out.start_file(f.name(), zip::write::SimpleFileOptions::default()).expect("start");+ out.write_all(&data).expect("write");+ }+ let q = deckcraft_pptx::import(&out.finish().expect("finish").into_inner()).expect("import");+ assert!(q.slides[0].animations.is_empty(), "{:?}", q.slides[0].animations);+}