From f58f4f425f428eb334382886cdf528be8237a8de Mon Sep 17 00:00:00 2001 From: justinjohn0306 Date: Mon, 12 Oct 2026 17:17:55 +0530 Subject: [PATCH] PPTX: legacy OLE objects are saved in PowerPoint's fallback form (#203) A legacy OLE object finds its preview in the slide's VML drawing through spid; DeckCraft doesn't write that drawing, and PowerPoint refused to open a file whose oleObj has a spid but no VML shape. The reader now takes the preview picture from the VML drawing (so DeckCraft shows it too), and the writer drops spid and adds that preview as a p:pic, the form PowerPoint itself writes as the fallback. Checked in PowerPoint: the issue's 45545_Comment.pptx saved by DeckCraft opens with its three embedded objects and draws slide 8's table. Co-Authored-By: Claude Opus 5.5 --- crates/pptx/src/read/shapes.rs | 15 ++++++++++++++- crates/pptx/src/write/shapes.rs | 31 +++++++++++++++++++++++++++++++ crates/pptx/tests/roundtrip.rs | 22 ++++++++++++++++++++++ 3 files changed, 67 insertions(+), 1 deletion(-) diff --git a/crates/pptx/src/read/shapes.rs b/crates/pptx/src/read/shapes.rs index 3954cee8..45f819e3 100644 --- a/crates/pptx/src/read/shapes.rs +++ b/crates/pptx/src/read/shapes.rs @@ -346,7 +346,8 @@ fn frame(imp: &mut Imp, part: &Part, ctx: &mut IdCtx, e: &El, depth: usize) -> S let mut pics = vec![]; e.find_all("blip", &mut pics); pics.into_iter().find_map(|b| dml::media_ref(imp, part, b)) - }; + } + .or_else(|| vml_preview(imp, part, gd?.find("oleObj")?.attr("spid")?)); let label = if let Some(o) = gd.and_then(|g| g.find("oleObj")) { o.attr("progId").or(o.attr("name")).unwrap_or("Object").to_string() } else if uri.ends_with("/diagram") { @@ -375,6 +376,18 @@ fn frame(imp: &mut Imp, part: &Part, ctx: &mut IdCtx, e: &El, depth: usize) -> S s } +/// A legacy OLE object's preview: the picture of its shape (`spid`) in the slide's VML drawing. +fn vml_preview(imp: &mut Imp, part: &Part, spid: &str) -> Option { + let found = part.rels.all_of("vmlDrawing").find_map(|r| { + let doc = imp.pkg.xml(&r.target)?; + let mut shapes = vec![]; + doc.root.find_all("shape", &mut shapes); + let relid = shapes.into_iter().find(|s| s.attr("id") == Some(spid))?.find("imagedata")?.attr("o:relid")?.to_string(); + Some(imp.pkg.rels(&r.target).get(&relid).filter(|i| !i.external)?.target.clone()) + })?; + imp.media_for(&found) +} + /// A diagram's data part, without the link to its cached drawing: that link is a slide /// relationship id, which a save renumbers. Only diagrams whose drawing is missing or empty get /// here, and those are drawn from their data. diff --git a/crates/pptx/src/write/shapes.rs b/crates/pptx/src/write/shapes.rs index 3df45c6c..09c3af2c 100644 --- a/crates/pptx/src/write/shapes.rs +++ b/crates/pptx/src/write/shapes.rs @@ -637,6 +637,7 @@ fn opaque( { let mut root = doc.root; set_rel_ids(&mut root, &ids, 0); + ole_fallback(&mut root, x, o, preview); let id = cx.id_for(s); set_cnvpr_id(&mut root, id); w.raw(&root.to_xml()); @@ -686,6 +687,36 @@ fn shape_with_id(w: &mut W, x: &mut Exp, o: &mut Out, cx: &mut Ctx, s: &Shape, i } } +/// A legacy OLE object finds its preview in the slide's VML drawing through `spid`, and PowerPoint +/// won't open the slide without it. We don't write that drawing, so write PowerPoint's own +/// fallback form instead: no `spid`, and the preview as a `p:pic`. +fn ole_fallback(root: &mut crate::xml::El, x: &mut Exp, o: &mut Out, preview: Option) { + let xfrm = root.child("xfrm").map(|t| { + let at = |e: &str, a: &str| t.child(e).and_then(|c| c.attr(a)).unwrap_or("0").to_string(); + (at("off", "x"), at("off", "y"), at("ext", "cx"), at("ext", "cy")) + }); + let Some(ole) = root + .elements_mut() + .find(|e| e.is("graphic")) + .and_then(|g| g.elements_mut().find(|e| e.is("graphicData"))) + .and_then(|d| d.elements_mut().find(|e| e.is("oleObj"))) + else { + return; + }; + if ole.child("pic").is_some() || ole.attr("spid").is_none() { + return; + } + ole.attrs.retain(|(k, _)| k != "spid"); + let rel = preview.and_then(|m| x.media_rel(o, m, "image")).unwrap_or_else(|| x.placeholder_rel(o)); + let (ox, oy, cx, cy) = xfrm.unwrap_or_default(); + let pic = format!( + r#""# + ); + if let Ok(d) = crate::xml::parse(pic.as_bytes()) { + ole.children.push(crate::xml::Node::El(d.root)); + } +} + fn set_rel_ids(e: &mut crate::xml::El, ids: &std::collections::HashMap, depth: usize) { if depth > crate::xml::MAX_DEPTH { return; diff --git a/crates/pptx/tests/roundtrip.rs b/crates/pptx/tests/roundtrip.rs index ddd62e17..7e4745fb 100644 --- a/crates/pptx/tests/roundtrip.rs +++ b/crates/pptx/tests/roundtrip.rs @@ -762,3 +762,25 @@ fn smartart_without_a_drawing_keeps_its_parts() { assert!(data.contains("Manager"), "{data}"); assert!(!data.contains("dataModelExt"), "the link to a drawing that isn't written is dropped: {data}"); } + +/// #203: a legacy OLE object (preview in the VML drawing we don't write) is saved in PowerPoint's +/// fallback form, without `spid` and with a `p:pic` preview, or PowerPoint won't open the file. +#[test] +fn legacy_ole_object_is_written_in_the_fallback_form() { + let mut p = defaults::new_presentation(None); + let bin = p.add_media("oleObject1.bin", "application/vnd.openxmlformats-officedocument.oleObject", b"\xD0\xCF\x11\xE0not-a-real-ole".to_vec()); + let prev = p.add_media("image1.png", "image/png", png(4, 3, [10, 20, 30])); + let xml = r#""#; + let parts = vec![("rId4".to_string(), "http://schemas.openxmlformats.org/officeDocument/2006/relationships/oleObject".to_string(), bin)]; + let mut s = new_slide(&mut p, "OLE"); + let mut frame = shape(&mut p, "Object 3", 0.0, 0.0, 72.0, 72.0); + frame.kind = ShapeKind::Opaque { xml: xml.into(), preview: Some(prev), label: "Slide".into(), parts }; + s.shapes.push(frame); + p.slides.push(Arc::new(s)); + + let q = round(&round(&p)); + let ShapeKind::Opaque { xml, parts, preview, .. } = &by_name(&q.slides[1], "Object 3").kind else { panic!("not kept as an object") }; + assert!(xml.contains("") && !xml.contains("spid"), "{xml}"); + assert!(preview.is_some(), "the preview picture is kept"); + assert!(parts.iter().any(|(_, k, _)| k.ends_with("/oleObject")), "the embedding is kept: {parts:?}"); +}