From aece0970969259510d98bfc4d22d4fd45d2b1018 Mon Sep 17 00:00:00 2001 From: Dominik Strebel Date: Sat, 24 Oct 2026 14:07:43 +0200 Subject: [PATCH] Render: a picture's fill shows behind its transparent pixels PowerPoint draws a picture's own fill (p:pic/p:spPr) behind the picture, so it shows through its transparent pixels: a synthetic deck with one half-transparent PNG three times (no fill, an orange fill, a yellow fill with a blue outline), exported as PDF, shows the orange and yellow boxes behind the image. DeckCraft drew the outline but never the fill. Old equation objects use this: an OLE object (p:oleObj, Equation.3) has a fallback picture whose spPr gives it a coloured box behind the equation's transparent metafile. The reader took only the picture's image as the object's preview and dropped its fill and line. - The shape-fill loop moves into Renderer::fill_geometry; a picture and an embedded object's preview paint their own explicit fill with it before the image (a style fill alone still draws nothing, as before). - An embedded object without a fill or line of its own takes them from its fallback picture's spPr; its frame keeps its own position. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 2 + crates/pptx/src/read/shapes.rs | 11 +++++ crates/pptx/tests/it/ole.rs | 40 +++++++++++++++++ crates/render/src/lib.rs | 82 +++++++++++++++++++++++----------- crates/render/src/tests.rs | 32 +++++++++++++ 5 files changed, 140 insertions(+), 27 deletions(-) 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