mirror of
https://github.com/storytold/deckcraft.git
synced 2026-10-25 19:17:06 -06:00
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 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
7be391a1be
commit
aece097096
@@ -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 |
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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#"<p:graphicFrame><p:nvGraphicFramePr><p:cNvPr id="900" name="Object 3"/><p:cNvGraphicFramePr/><p:nvPr/></p:nvGraphicFramePr><p:xfrm><a:off x="0" y="0"/><a:ext cx="1828800" cy="914400"/></p:xfrm><a:graphic><a:graphicData uri="http://schemas.openxmlformats.org/presentationml/2006/ole"><mc:AlternateContent xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"><mc:Choice xmlns:v="urn:schemas-microsoft-com:vml" Requires="v"><p:oleObj name="Equation" r:id="rIdOle" imgW="1663700" imgH="228600" progId="Equation.3"><p:embed/></p:oleObj></mc:Choice><mc:Fallback><p:oleObj name="Equation" r:id="rIdOle" imgW="1663700" imgH="228600" progId="Equation.3"><p:embed/><p:pic><p:nvPicPr><p:cNvPr id="0" name="Object 3"/><p:cNvPicPr/><p:nvPr/></p:nvPicPr><p:blipFill><a:blip r:embed="rIdImg"/><a:stretch><a:fillRect/></a:stretch></p:blipFill><p:spPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="1828800" cy="914400"/></a:xfrm><a:prstGeom prst="rect"><a:avLst/></a:prstGeom>{spr}</p:spPr></p:pic></p:oleObj></mc:Fallback></mc:AlternateContent></a:graphicData></a:graphic></p:graphicFrame>"#
|
||||
)
|
||||
};
|
||||
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("</p:spTree>", &format!("{frame}</p:spTree>"), 1)
|
||||
} else if name == "ppt/slides/_rels/slide1.xml.rels" {
|
||||
s.replacen(
|
||||
"</Relationships>",
|
||||
r#"<Relationship Id="rIdImg" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/image" Target="../media/eq.png"/><Relationship Id="rIdOle" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/oleObject" Target="../embeddings/oleObject1.bin"/></Relationships>"#,
|
||||
1,
|
||||
)
|
||||
} else {
|
||||
s
|
||||
}
|
||||
})
|
||||
};
|
||||
let object = |bytes: Vec<u8>| {
|
||||
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#"<a:solidFill><a:srgbClr val="FF9900"/></a:solidFill><a:ln w="12700"><a:solidFill><a:srgbClr val="0000FF"/></a:solidFill></a:ln>"#,
|
||||
)));
|
||||
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);
|
||||
}
|
||||
|
||||
+55
-27
@@ -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<Rgba>,
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user