diff --git a/ROADMAP.md b/ROADMAP.md
index 46c94626..b9c65f80 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -4,6 +4,7 @@
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-24 · **Change:** trivial (progress log: Edit Points handle drags with Shift, Ctrl/Cmd or Alt/Option set the point type; previous: trivial, progress log: scroll bars on the zoomed slide pane, #1157; previous: minor, native chart payloads stored in bounded ZIP members; previous: major, community PR wave 2026-10-14: ~60 landed items in the progress log, among them the Font, Paragraph, Photo Album, theme colour and font and Date and Time dialogs, presenter and show controls, the SmartArt Text Pane and PPTX corpus slices; breadth 84% after `cargo xtask parity`, ready stays ~55%, stage stays alpha; duplicated rows and headers folded; previous: minor, hours to ~95% per audience added) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-24 · **Change:** trivial (progress log: Ctrl+wheel zoom during the slide show, #1286; previous: trivial, scroll bars on the zoomed slide pane, #1157; previous: minor, native chart payloads stored in bounded ZIP members; previous: major, community PR wave 2026-10-14: ~60 landed items in the progress log, among them the Font, Paragraph, Photo Album, theme colour and font and Date and Time dialogs, presenter and show controls, the SmartArt Text Pane and PPTX corpus slices; breadth 84% after `cargo xtask parity`, ready stays ~55%, stage stays alpha; duplicated rows and headers folded; previous: minor, hours to ~95% per audience added) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
+> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-24 · **Change:** trivial (progress log: picture and embedded object fills; previous: trivial, scroll bars on the zoomed slide pane, #1157; previous: minor, native chart payloads stored in bounded ZIP members; previous: major, community PR wave 2026-10-14: ~60 landed items in the progress log, among them the Font, Paragraph, Photo Album, theme colour and font and Date and Time dialogs, presenter and show controls, the SmartArt Text Pane and PPTX corpus slices; breadth 84% after `cargo xtask parity`, ready stays ~55%, stage stays alpha; duplicated rows and headers folded; previous: minor, hours to ~95% per audience added) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-22 · **Change:** trivial (fill and outline edits reach group members, #1079; stage and scores unchanged) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-20 · **Change:** trivial (empty chart number-format sections hide values; previous: minor, native chart payloads stored in bounded ZIP members; previous: major, community PR wave 2026-10-14: ~60 landed items in the progress log, among them the Font, Paragraph, Photo Album, theme colour and font and Date and Time dialogs, presenter and show controls, the SmartArt Text Pane and PPTX corpus slices; breadth 84% after `cargo xtask parity`, ready stays ~55%, stage stays alpha; duplicated rows and headers folded; previous: minor, hours to ~95% per audience added) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-18 · **Change:** minor (Symbol-font characters (U+F0xx) remap to the installed Symbol face's real Unicode on macOS, so they draw from Symbol instead of a look-alike; previous: minor, native chart payloads stored in bounded ZIP members; previous: major, community PR wave 2026-10-14: ~60 landed items in the progress log, among them the Font, Paragraph, Photo Album, theme colour and font and Date and Time dialogs, presenter and show controls, the SmartArt Text Pane and PPTX corpus slices; breadth 84% after `cargo xtask parity`, ready stays ~55%, stage stays alpha; duplicated rows and headers folded; previous: minor, hours to ~95% per audience added) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
@@ -138,6 +139,7 @@ app's JSON control channel (`deckcraft --control PORT`).
| 2026-10-24 | AutoFormat as you type: a paragraph that starts with `*`, `-`, `--`, `>`, `<>`, `1.`, `1)`, `I.`, `i.`, `A.`, `a.` or `a)` and a space or tab becomes a bulleted or numbered list in that style; Backspace right after puts the typed text back, and Tools ▸ AutoCorrect and `text.autocorrect {lists}` turn it off |
| 2026-10-24 | Tables: while the caret is in a cell, dragging into another cell selects that range of cells (shaded, merged cells taken in whole), and Shift+arrow grows or shrinks it; formatting applies to the selected cells only, and Delete clears their text and keeps the table |
| 2026-10-23 | Chart text colour: `chart.options {textColor}` and Font Color on a selected chart colour its title, axes, legend and data labels; the colour is written to the chart part (`c:txPr` on the axes, legend, labels and chart, the title's run defaults) so an imported chart keeps its `txPr` colours through an edit and a save; text without a colour of its own takes PowerPoint's default, the theme's text colour at 65 % plus 35 % (595959 on a light theme, light grey on a dark one) |
+| 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) |
@@ -302,6 +304,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/src/write/shapes.rs b/crates/pptx/src/write/shapes.rs
index 5ef269f3..87200a89 100644
--- a/crates/pptx/src/write/shapes.rs
+++ b/crates/pptx/src/write/shapes.rs
@@ -909,8 +909,12 @@ fn shape_with_id(w: &mut W, x: &mut Exp, o: &mut Out, cx: &mut Ctx, s: &Shape, i
w.empty0("p:nvPr");
w.close("p:nvPicPr");
dml::blip_fill(w, x, o, "p:blipFill", fill);
+ // As for any picture, a colour or pattern fill shows behind the image (an object's preview
+ // keeps its fallback picture's fill).
let mut s2 = s.clone();
- s2.fill = None;
+ if matches!(s2.fill, Some(Fill::Picture(_) | Fill::Background | Fill::Group)) {
+ s2.fill = None;
+ }
sp_pr(w, x, o, &s2, true);
w.close("p:pic");
} else {
diff --git a/crates/pptx/tests/it/ole.rs b/crates/pptx/tests/it/ole.rs
index 5c0bc1db..f24d0ac6 100644
--- a/crates/pptx/tests/it/ole.rs
+++ b/crates/pptx/tests/it/ole.rs
@@ -96,3 +96,54 @@ 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.clone()));
+ assert_eq!(filled.line.as_ref().and_then(|l| l.width), Some(1.0));
+ // Saved as its preview picture (its kept XML can't be written back), it keeps the fill.
+ let mut p = deckcraft_pptx::import(&deck(pic(r#""#))).expect("import");
+ for s in &mut std::sync::Arc::make_mut(&mut p.slides[0]).shapes {
+ if let ShapeKind::Opaque { xml, .. } = &mut s.kind {
+ xml.clear();
+ }
+ }
+ let back = deckcraft_pptx::import(&deckcraft_pptx::export(&p).expect("export")).expect("reimport");
+ let saved = back.slides[0].shapes.iter().find(|s| s.name == "Object 3").expect("object");
+ assert!(matches!(saved.kind, ShapeKind::Picture { .. }), "{:?}", saved.kind);
+ assert_eq!(saved.fill, Some(orange));
+ // 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 00ebe093..f4435899 100644
--- a/crates/render/src/lib.rs
+++ b/crates/render/src/lib.rs
@@ -1025,6 +1025,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,
@@ -1121,6 +1166,13 @@ impl Renderer {
// Fill.
match &s.kind {
ShapeKind::Picture { fill: pf } => {
+ // The picture's own fill shows behind its transparent pixels, as in PowerPoint (an
+ // emphasis tint recolours it, as it does any shape's fill).
+ if s.fill.as_ref().is_some_and(|f| !f.is_none())
+ && let Some(fl) = &fill
+ {
+ self.fill_geometry(ctx, f, rctx, fl, fill_ph, &geo, (w, h), m, &fade);
+ }
ctx.set_transform(m);
let bounds = Rect::new(0.0, 0.0, w, h);
ctx.push_clip_layer(&outline);
@@ -1131,6 +1183,13 @@ 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 s.fill.as_ref().is_some_and(|f| !f.is_none())
+ && let Some(fl) = &fill
+ {
+ self.fill_geometry(ctx, f, rctx, fl, fill_ph, &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);
@@ -1155,33 +1214,7 @@ impl Renderer {
if let Some(fl) = &fill
&& !(empty_ph && f.opts.edit && s.fill.is_none() && s.line.is_none() && s.style.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 7510e407..27e80215 100644
--- a/crates/render/src/tests.rs
+++ b/crates/render/src/tests.rs
@@ -2013,6 +2013,42 @@ 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})");
+ // A fill-colour emphasis recolours that fill, as it does any shape's.
+ let tint = |_: ShapeId| ShapeState { tint: Some(deckcraft_color::Rgba::rgb(0, 160, 60)), ..Default::default() };
+ let img = render_slide(&p, 0, &RenderOpts { scale: 1.0, state: Some(&tint), ..Default::default() });
+ assert_eq!(img.pixel(200, 200)[..3], [0, 160, 60], "the tinted 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