From da44630200f3bb5ef26dfbdae66fa1851768b580 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Mon, 12 Oct 2026 22:09:25 -0400 Subject: [PATCH] ActiveX controls show their pictures A slide's p:controls are read: each control with a p:pic (the image PowerPoint shows for it, often a WMF) becomes a picture above the slide's shapes. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/pptx/src/read/mod.rs | 4 ++ crates/pptx/src/read/shapes.rs | 16 +++++++ crates/pptx/tests/ole_vml.rs | 84 ++++++++++++++++++++++++++++++++++ 3 files changed, 104 insertions(+) create mode 100644 crates/pptx/tests/ole_vml.rs diff --git a/crates/pptx/src/read/mod.rs b/crates/pptx/src/read/mod.rs index 692cfe5..057614e 100644 --- a/crates/pptx/src/read/mod.rs +++ b/crates/pptx/src/read/mod.rs @@ -711,6 +711,10 @@ fn read_slide(imp: &mut Imp, part: &Part, doc: &Doc, id: SlideId, layout: Layout if let Some(tree) = csld.child("spTree") { s.shapes = shapes::sp_tree(imp, part, &mut ctx, tree, 0); } + if let Some(c) = csld.child("controls") { + let pics = shapes::controls(imp, part, &mut ctx, c); + s.shapes.extend(pics); + } } if let Some(t) = root.child("transition") { s.transition = timing::transition(imp, part, t); diff --git a/crates/pptx/src/read/shapes.rs b/crates/pptx/src/read/shapes.rs index db778de..51b2b86 100644 --- a/crates/pptx/src/read/shapes.rs +++ b/crates/pptx/src/read/shapes.rs @@ -87,6 +87,22 @@ pub fn sp_tree(imp: &mut Imp, part: &Part, ctx: &mut IdCtx, tree: &El, depth: us out } +/// ActiveX controls (`p:controls`): each control's picture (`p:pic`), the image PowerPoint shows +/// for it, as a picture above the slide's shapes. Controls without one are left out. +pub fn controls(imp: &mut Imp, part: &Part, ctx: &mut IdCtx, controls: &El) -> Vec { + let mut out = vec![]; + for c in controls.children_named("control") { + if ctx.count >= MAX_SHAPES { + break; + } + if let Some(p) = c.child("pic") { + ctx.count += 1; + out.push(pic(imp, part, ctx, p)); + } + } + out +} + fn remap_connectors(shapes: &mut [Shape], ctx: &IdCtx, depth: usize) { if depth > MAX_TREE_DEPTH { return; diff --git a/crates/pptx/tests/ole_vml.rs b/crates/pptx/tests/ole_vml.rs new file mode 100644 index 0000000..8f6c007 --- /dev/null +++ b/crates/pptx/tests/ole_vml.rs @@ -0,0 +1,84 @@ +//! Legacy OLE objects whose preview picture lives in the slide's VML drawing, and ActiveX controls +//! shown by their pictures. + +use std::io::{Cursor, Read, Write}; + +use deckcraft_model::ShapeKind; +use deckcraft_model::defaults; + +const FRAME: &str = r#""#; + +const VML: &str = r#""#; + +const VML_RELS: &str = r#""#; + +const SLIDE_RELS: &str = r#""#; + +/// An ActiveX control as PowerPoint writes it: a VML-only choice, and a fallback with its picture. +const CONTROLS: &str = r#""#; + +fn png(v: u8) -> Vec { + let mut out = Cursor::new(vec![]); + image::DynamicImage::ImageRgb8(image::RgbImage::from_pixel(2, 2, image::Rgb([v, v, v]))) + .write_to(&mut out, image::ImageFormat::Png) + .expect("png"); + out.into_inner() +} + +/// A new deck whose first slide holds [`FRAME`], with the VML drawing, its rels and two pictures. +fn deck() -> Vec { + let bytes = deckcraft_pptx::export(&defaults::new_presentation(None)).expect("export"); + let mut z = zip::ZipArchive::new(Cursor::new(bytes)).expect("zip"); + let mut out = zip::ZipWriter::new(Cursor::new(Vec::new())); + let o = zip::write::SimpleFileOptions::default(); + for i in 0..z.len() { + let mut f = z.by_index(i).expect("entry"); + let mut d = vec![]; + f.read_to_end(&mut d).expect("read"); + let name = f.name().to_string(); + if name == "ppt/slides/slide1.xml" { + let s = String::from_utf8(d).expect("utf8"); + let at = s.rfind("").expect("spTree"); + let s = format!("{}{FRAME}{}", &s[..at], &s[at..]); + let at = s.rfind("").expect("cSld"); + d = format!("{}{CONTROLS}{}", &s[..at], &s[at..]).into_bytes(); + } else if name == "ppt/slides/_rels/slide1.xml.rels" { + let s = String::from_utf8(d).expect("utf8"); + d = s.replace("", &format!("{SLIDE_RELS}")).into_bytes(); + } + out.start_file(name, o).expect("start"); + out.write_all(&d).expect("write"); + } + for (name, data) in [ + ("ppt/drawings/vmlDrawing1.vml", VML.as_bytes().to_vec()), + ("ppt/drawings/_rels/vmlDrawing1.vml.rels", VML_RELS.as_bytes().to_vec()), + ("ppt/media/other.png", png(10)), + ("ppt/media/preview.png", png(200)), + ] { + out.start_file(name, o).expect("start"); + out.write_all(&data).expect("write"); + } + out.finish().expect("finish").into_inner() +} + +#[test] +fn an_ole_object_without_a_picture_shows_its_vml_preview() { + let p = deckcraft_pptx::import(&deck()).expect("import"); + let s = p.slides[0].shapes.iter().find(|s| s.name == "Object 3").expect("frame"); + let ShapeKind::Opaque { preview: Some(m), label, .. } = &s.kind else { panic!("{:?}", s.kind) }; + assert_eq!(label, "Thing.1"); + let media = p.media.iter().find(|x| x.id == *m).expect("media"); + // The shape whose id is the object's spid, not the first one in the drawing. + assert_eq!(media.name, "preview.png"); +} + +#[test] +fn activex_controls_show_their_pictures() { + let p = deckcraft_pptx::import(&deck()).expect("import"); + let s = p.slides[0].shapes.iter().find(|s| s.name == "CheckBox1").expect("control picture"); + let ShapeKind::Picture { fill } = &s.kind else { panic!("{:?}", s.kind) }; + assert_eq!(p.media.iter().find(|m| m.id == fill.media).map(|m| m.name.as_str()), Some("preview.png")); + let x = s.xfrm.expect("xfrm"); + assert_eq!((x.x, x.y, x.w, x.h), (1.0, 2.0, 10.0, 5.0)); + assert!(!p.slides[0].shapes.iter().any(|s| s.name == "NoPicture"), "a control without a picture draws nothing"); +}