diff --git a/ROADMAP.md b/ROADMAP.md
index d54c160f..a4062400 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -134,6 +134,7 @@ app's JSON control channel (`deckcraft --control PORT`).
| Date | What landed |
|---|---|
+| 2026-10-24 | A picture's own fill shows behind its transparent pixels, and an embedded object (an old equation, `p:oleObj`) takes the fill and outline of its fallback picture, as PowerPoint for Mac draws them (synthetic probe: a half-transparent PNG on orange and on yellow with an outline); on a private 166-deck corpus 51 slides of equations on coloured boxes move closer to PowerPoint's PDF, none further |
| 2026-10-23 | Format Background ▸ Apply to All also sets the background of the slide masters the slides use (and clears their layouts' overrides), so new slides keep it (#891) |
| 2026-10-22 | Table text takes the master's other-text style (`otherStyle`), shapes and text boxes the presentation's default text style, as PowerPoint for Mac draws them (synthetic probe: 14 pt cells under a 14 pt `otherStyle` and a 26 pt `defaultTextStyle`); a private deck's table had been drawn 20 pt instead of 18 |
| 2026-10-18 | Find (Ctrl+F): "Find what:" has the keyboard as soon as the dialog opens, and keeps it after Enter finds a match, so the search can be typed and changed without clicking the field (#951) |
@@ -272,6 +273,7 @@ app's JSON control channel (`deckcraft --control PORT`).
| 2026-10-15 | trivial | Progress log: PPTX trigger click-step round trip. Feature scores and milestone names unchanged |
| 2026-10-16 | trivial | Progress log: PNG export with a transparent background (#784) |
| 2026-10-16 | trivial | Progress log: a `.ppsx` from the system starts the slide show (#862) |
+| 2026-10-24 | trivial | Progress log: picture and embedded object fills |
| 2026-10-22 | trivial | Progress log: table text takes the master's other-text style |
| 2026-10-16 | trivial | Progress log: responsive mouse-wheel navigation on the fitted canvas |
| 2026-10-21 | trivial | Progress log: theme `spDef` anchor no longer applied to a file's own shapes |
diff --git a/crates/pptx/src/read/shapes.rs b/crates/pptx/src/read/shapes.rs
index 0d6c8f8c..3bca8220 100644
--- a/crates/pptx/src/read/shapes.rs
+++ b/crates/pptx/src/read/shapes.rs
@@ -447,6 +447,17 @@ fn frame(imp: &mut Imp, part: &Part, ctx: &mut IdCtx, e: &El, depth: usize) -> S
pics.into_iter().find_map(|b| dml::media_ref(imp, part, b))
}
.or_else(|| gd.and_then(|g| g.find("oleObj")).and_then(|o| vml_preview(imp, part, o.attr("spid")?)));
+ // An embedded object's fallback picture (`p:oleObj/p:pic`) can carry a fill and an outline,
+ // which PowerPoint draws behind and around the preview (old equation objects on coloured
+ // boxes). The frame's own position stays.
+ if let Some(pr) = gd.and_then(|g| g.find("oleObj")).and_then(|o| o.find("pic")).and_then(|p| p.child("spPr")) {
+ if s.fill.is_none() {
+ s.fill = dml::fill_of(imp, part, pr);
+ }
+ if s.line.is_none() {
+ s.line = pr.child("ln").map(|ln| dml::line(imp, part, ln));
+ }
+ }
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") {
diff --git a/crates/pptx/tests/it/ole.rs b/crates/pptx/tests/it/ole.rs
index 5c0bc1db..1b0ef779 100644
--- a/crates/pptx/tests/it/ole.rs
+++ b/crates/pptx/tests/it/ole.rs
@@ -96,3 +96,43 @@ fn an_old_ole_object_shows_its_vml_preview() {
fn an_ole_object_without_a_matching_vml_shape_has_no_preview() {
assert_eq!(object_preview(&ole_deck("_x0000_s1")), None);
}
+
+/// PowerPoint 2010 and later: `p:oleObj` with a fallback `p:pic` inside `mc:AlternateContent`, whose
+/// `spPr` can give the object a fill and an outline (old equation objects on coloured boxes).
+#[test]
+fn an_ole_objects_fallback_picture_gives_it_its_fill_and_outline() {
+ let pic = |spr: &str| {
+ format!(
+ r#"{spr}"#
+ )
+ };
+ let deck = |frame: String| {
+ with_parts(&[("ppt/media/eq.png", png()), ("ppt/embeddings/oleObject1.bin", vec![0; 16])], move |name, s| {
+ if name == "ppt/slides/slide1.xml" {
+ s.replacen("", &format!("{frame}"), 1)
+ } else if name == "ppt/slides/_rels/slide1.xml.rels" {
+ s.replacen(
+ "",
+ r#""#,
+ 1,
+ )
+ } else {
+ s
+ }
+ })
+ };
+ let object = |bytes: Vec| {
+ let p = deckcraft_pptx::import(&bytes).expect("import");
+ let s = p.slides[0].shapes.iter().find(|s| s.name == "Object 3").expect("object").clone();
+ assert!(matches!(&s.kind, ShapeKind::Opaque { preview: Some(_), label, .. } if label == "Equation.3"), "{:?}", s.kind);
+ s
+ };
+ let filled = object(deck(pic(
+ r#""#,
+ )));
+ let orange = deckcraft_model::style::Fill::solid(deckcraft_model::ColorRef::rgb(deckcraft_color::Rgba::rgb(0xFF, 0x99, 0)));
+ assert_eq!(filled.fill, Some(orange));
+ assert_eq!(filled.line.as_ref().and_then(|l| l.width), Some(1.0));
+ // Without a fill of its own the object stays unfilled.
+ assert_eq!(object(deck(pic(""))).fill, None);
+}
diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs
index c47d2b6b..37102d9b 100644
--- a/crates/render/src/lib.rs
+++ b/crates/render/src/lib.rs
@@ -1024,6 +1024,51 @@ impl Renderer {
}
}
+ /// Fill the filled sub-paths of `geo` (a `w` × `h` shape placed by `m`) with `fl`, faded per
+ /// sub-path by `fade`: `useBgFill` shows the slide background through the shape and `grpFill`
+ /// the group's fill.
+ #[allow(clippy::too_many_arguments)]
+ fn fill_geometry(
+ &self,
+ ctx: &mut RenderContext,
+ f: &Frame,
+ rctx: &Ctx,
+ fl: &Fill,
+ fill_ph: Option,
+ geo: &Geometry,
+ (w, h): (f64, f64),
+ m: Affine,
+ fade: &dyn Fn(usize) -> deckcraft_geom::morph::Fade,
+ ) {
+ for (i, sp) in geo.paths.iter().enumerate() {
+ if sp.fill == FillMode::None || fade(i).fill <= 0.0 {
+ continue;
+ }
+ ctx.set_transform(m);
+ ctx.set_fill_rule(if sp.even_odd { peniko::Fill::EvenOdd } else { peniko::Fill::NonZero });
+ match (fl, &self.bg) {
+ // `useBgFill`: the slide's background, laid out on the slide (not the
+ // shape), shows through the shape.
+ (Fill::Background, Some((bg, ph, view))) => {
+ let slide = Rect::new(0.0, 0.0, f.pres.slide_size.width, f.pres.slide_size.height);
+ let path = (view.inverse() * m) * sp.path.clone();
+ paint::fill_path(ctx, rctx, bg, *ph, &path, slide, *view, sp.fill, fade(i).fill, f.pres);
+ }
+ // `grpFill`: the group's fill, laid out over the whole group.
+ (Fill::Group, _) => {
+ if let Some((gf, gm, gbox)) = &self.group_fill
+ && gm.determinant().abs() > 1e-12
+ {
+ let path = (gm.inverse() * m) * sp.path.clone();
+ paint::fill_path(ctx, rctx, gf, None, &path, *gbox, *gm, sp.fill, fade(i).fill, f.pres);
+ }
+ }
+ _ => paint::fill_path(ctx, rctx, fl, fill_ph, &sp.path, Rect::new(0.0, 0.0, w, h), m, sp.fill, fade(i).fill, f.pres),
+ }
+ ctx.set_fill_rule(peniko::Fill::NonZero);
+ }
+ }
+
#[allow(clippy::too_many_arguments)]
fn geometry_shape(
&mut self,
@@ -1120,6 +1165,10 @@ impl Renderer {
// Fill.
match &s.kind {
ShapeKind::Picture { fill: pf } => {
+ // The picture's own fill shows behind its transparent pixels, as in PowerPoint.
+ if let Some(fl) = s.fill.as_ref().filter(|f| !f.is_none()) {
+ self.fill_geometry(ctx, f, rctx, fl, None, &geo, (w, h), m, &fade);
+ }
ctx.set_transform(m);
let bounds = Rect::new(0.0, 0.0, w, h);
ctx.push_clip_layer(&outline);
@@ -1130,6 +1179,11 @@ impl Renderer {
// An object DeckCraft doesn't model (an embedded OLE object, an unknown graphic): its
// preview picture, stretched over the frame, as PowerPoint draws it.
ShapeKind::Opaque { preview: Some(media), .. } => {
+ // An embedded object's fallback picture can carry a fill (old equation objects
+ // drawn on coloured boxes): behind the preview, as for a picture.
+ if let Some(fl) = s.fill.as_ref().filter(|f| !f.is_none()) {
+ self.fill_geometry(ctx, f, rctx, fl, None, &geo, (w, h), m, &fade);
+ }
ctx.set_transform(m);
let pf = deckcraft_model::style::PictureFill { media: *media, ..Default::default() };
paint::picture(ctx, f.pres, &pf, Rect::new(0.0, 0.0, w, h), m, None);
@@ -1154,33 +1208,7 @@ impl Renderer {
if let Some(fl) = &fill
&& !(empty_ph && f.opts.edit && s.fill.is_none())
{
- for (i, sp) in geo.paths.iter().enumerate() {
- if sp.fill == FillMode::None || fade(i).fill <= 0.0 {
- continue;
- }
- ctx.set_transform(m);
- ctx.set_fill_rule(if sp.even_odd { peniko::Fill::EvenOdd } else { peniko::Fill::NonZero });
- match (fl, &self.bg) {
- // `useBgFill`: the slide's background, laid out on the slide (not the
- // shape), shows through the shape.
- (Fill::Background, Some((bg, ph, view))) => {
- let slide = Rect::new(0.0, 0.0, f.pres.slide_size.width, f.pres.slide_size.height);
- let path = (view.inverse() * m) * sp.path.clone();
- paint::fill_path(ctx, rctx, bg, *ph, &path, slide, *view, sp.fill, fade(i).fill, f.pres);
- }
- // `grpFill`: the group's fill, laid out over the whole group.
- (Fill::Group, _) => {
- if let Some((gf, gm, gbox)) = &self.group_fill
- && gm.determinant().abs() > 1e-12
- {
- let path = (gm.inverse() * m) * sp.path.clone();
- paint::fill_path(ctx, rctx, gf, None, &path, *gbox, *gm, sp.fill, fade(i).fill, f.pres);
- }
- }
- _ => paint::fill_path(ctx, rctx, fl, fill_ph, &sp.path, Rect::new(0.0, 0.0, w, h), m, sp.fill, fade(i).fill, f.pres),
- }
- ctx.set_fill_rule(peniko::Fill::NonZero);
- }
+ self.fill_geometry(ctx, f, rctx, fl, fill_ph, &geo, (w, h), m, &fade);
}
}
}
diff --git a/crates/render/src/tests.rs b/crates/render/src/tests.rs
index b65098b0..7d1f78a1 100644
--- a/crates/render/src/tests.rs
+++ b/crates/render/src/tests.rs
@@ -1910,6 +1910,38 @@ fn group_child_scale_does_not_scale_line_widths() {
}
}
+#[test]
+fn a_picture_and_an_embedded_objects_preview_draw_their_fill_behind_transparent_pixels() {
+ // PowerPoint draws a picture's own fill behind its transparent pixels (and an old equation
+ // object's fallback picture on its coloured box). Here the left half of the image is clear.
+ let png = {
+ let img = image::RgbaImage::from_fn(8, 4, |x, _| if x < 4 { image::Rgba([0, 0, 0, 0]) } else { image::Rgba([20, 20, 160, 255]) });
+ let mut out = std::io::Cursor::new(vec![]);
+ image::DynamicImage::ImageRgba8(img).write_to(&mut out, image::ImageFormat::Png).unwrap();
+ out.into_inner()
+ };
+ let orange = Fill::solid(ColorRef::rgb(deckcraft_color::Rgba::rgb(255, 153, 0)));
+ for (object, fill) in [(false, None), (false, Some(orange.clone())), (true, Some(orange.clone()))] {
+ let mut p = Presentation::default();
+ let media = p.add_media("half.png", "image/png", png.clone()).expect("media");
+ let kind = if object {
+ ShapeKind::Opaque { xml: String::new(), preview: Some(media), label: "Equation.3".into(), parts: vec![] }
+ } else {
+ ShapeKind::Picture { fill: deckcraft_model::style::PictureFill { media, ..Default::default() } }
+ };
+ let sh = Shape { id: ShapeId(630), xfrm: Some(Xfrm::new(100.0, 100.0, 400.0, 200.0)), kind, fill: fill.clone(), ..Default::default() };
+ std::sync::Arc::make_mut(&mut p.slides[0]).shapes = vec![sh];
+ let img = render_slide(&p, 0, &RenderOpts { scale: 1.0, ..Default::default() });
+ let (clear, opaque) = (img.pixel(200, 200), img.pixel(400, 200));
+ assert!(opaque[2] > 140 && opaque[0] < 40, "the picture's own pixels ({object}): {opaque:?}");
+ if fill.is_some() {
+ assert_eq!(clear[..3], [255, 153, 0], "the fill behind the clear half ({object})");
+ } else {
+ assert!(clear[..3].iter().all(|v| *v > 240), "no fill: the slide shows through: {clear:?}");
+ }
+ }
+}
+
#[test]
fn a_picture_casts_its_shadow_from_its_pixels() {
// PowerPoint shades only where a picture paints: a logo on a transparent background casts the