From a68da9f51dd0265b45150a7cd02c1042df88a23f Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Mon, 12 Oct 2026 23:50:02 -0400 Subject: [PATCH] Effects: fill overlays (a:fillOverlay) blend over the shape's fill, and round-trip (#342) Co-authored-by: Claude Opus 5.5 (1M context) --- crates/model/src/style.rs | 11 +++++ crates/pptx/src/read/dml.rs | 8 ++++ crates/pptx/src/write/dml.rs | 82 +++++++++++++++++++++--------------- crates/pptx/tests/fills.rs | 31 ++++++++++++++ crates/render/src/lib.rs | 19 +++++++++ crates/render/src/tests.rs | 31 ++++++++++++++ 6 files changed, 148 insertions(+), 34 deletions(-) diff --git a/crates/model/src/style.rs b/crates/model/src/style.rs index ff1b4e0..bb25397 100644 --- a/crates/model/src/style.rs +++ b/crates/model/src/style.rs @@ -387,6 +387,16 @@ pub struct Effects { pub bevel: Option, /// Unmodelled 3-D XML kept for round-trip (`scene3d`, `sp3d`). pub raw3d: Option, + /// `a:fillOverlay`: a fill blended over the shape's fill (`blend`: `over`, `mult`, `screen`, + /// `darken`, `lighten`). Only solid and gradient overlays are kept. + pub fill_overlay: Option, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct FillOverlay { + pub blend: String, + pub fill: Fill, } impl Effects { @@ -397,6 +407,7 @@ impl Effects { && self.soft_edge.is_none() && self.reflection.is_none() && self.bevel.is_none() + && self.fill_overlay.is_none() } } diff --git a/crates/pptx/src/read/dml.rs b/crates/pptx/src/read/dml.rs index c902776..7bec2e6 100644 --- a/crates/pptx/src/read/dml.rs +++ b/crates/pptx/src/read/dml.rs @@ -257,6 +257,14 @@ pub fn effects(el: &El) -> Effects { "innerShdw" => fx.inner_shadow = Some(shadow(e, true)), "glow" => fx.glow = Some(Glow { color: color(e).unwrap_or(ColorRef::rgb(Rgba::BLACK)), radius: pt(e, "rad").unwrap_or(0.0).max(0.0) }), "softEdge" => fx.soft_edge = Some(pt(e, "rad").unwrap_or(0.0).max(0.0)), + "fillOverlay" => { + let fill = e.elements().find_map(|f| match f.local() { + "solidFill" => color(f).map(|c| Fill::Solid { color: c }), + "gradFill" => Some(Fill::Gradient(gradient(f))), + _ => None, + }); + fx.fill_overlay = fill.map(|fill| deckcraft_model::style::FillOverlay { blend: e.attr("blend").unwrap_or("over").to_string(), fill }); + } "reflection" => { fx.reflection = Some(Reflection { blur: pt(e, "blurRad").unwrap_or(0.0), diff --git a/crates/pptx/src/write/dml.rs b/crates/pptx/src/write/dml.rs index 6761f0b..7bae42d 100644 --- a/crates/pptx/src/write/dml.rs +++ b/crates/pptx/src/write/dml.rs @@ -76,40 +76,7 @@ pub fn fill(w: &mut W, x: &mut Exp, o: &mut Out, f: &Fill) { match f { Fill::None => w.empty0("a:noFill"), Fill::Solid { color: c } => solid(w, c), - Fill::Gradient(g) => { - w.open("a:gradFill", A::new().a("rotWithShape", if g.rotate_with_shape { "1" } else { "0" })); - // No stops: no `a:gsLst`, so the gradient keeps taking them from its style's fill. - let mut stops = g.stops.clone(); - if stops.len() == 1 { - let mut s = stops[0].clone(); - s.pos = 1.0; - stops.push(s); - } - if !stops.is_empty() { - w.open0("a:gsLst"); - for s in &stops { - w.open("a:gs", A::new().a("pos", pct(s.pos.clamp(0.0, 1.0)))); - color(w, &s.color); - w.close("a:gs"); - } - w.close("a:gsLst"); - } - match &g.shape { - GradientShape::Linear { angle, scaled } => { - w.empty("a:lin", A::new().a("ang", ang(*angle)).a("scaled", if *scaled { "1" } else { "0" })) - } - GradientShape::Path { path, focus } => { - let p = match path.as_str() { - "rect" | "shape" | "circle" => path.as_str(), - _ => "circle", - }; - w.open("a:path", A::new().a("path", p)); - w.empty("a:fillToRect", A::new().a("l", pct(focus[0])).a("t", pct(focus[1])).a("r", pct(focus[2])).a("b", pct(focus[3]))); - w.close("a:path"); - } - } - w.close("a:gradFill"); - } + Fill::Gradient(g) => gradient(w, g), Fill::Picture(pf) => blip_fill(w, x, o, "a:blipFill", pf), Fill::Pattern(p) => { w.open("a:pattFill", A::new().a("prst", if p.preset.is_empty() { "pct5" } else { &p.preset })); @@ -275,8 +242,55 @@ fn shadow(w: &mut W, tag: &str, s: &deckcraft_model::style::Shadow) { w.close(tag); } +fn gradient(w: &mut W, g: &deckcraft_model::style::Gradient) { + w.open("a:gradFill", A::new().a("rotWithShape", if g.rotate_with_shape { "1" } else { "0" })); + // No stops: no `a:gsLst`, so the gradient keeps taking them from its style's fill. + let mut stops = g.stops.clone(); + if stops.len() == 1 { + let mut s = stops[0].clone(); + s.pos = 1.0; + stops.push(s); + } + if !stops.is_empty() { + w.open0("a:gsLst"); + for s in &stops { + w.open("a:gs", A::new().a("pos", pct(s.pos.clamp(0.0, 1.0)))); + color(w, &s.color); + w.close("a:gs"); + } + w.close("a:gsLst"); + } + match &g.shape { + GradientShape::Linear { angle, scaled } => w.empty("a:lin", A::new().a("ang", ang(*angle)).a("scaled", if *scaled { "1" } else { "0" })), + GradientShape::Path { path, focus } => { + let p = match path.as_str() { + "rect" | "shape" | "circle" => path.as_str(), + _ => "circle", + }; + w.open("a:path", A::new().a("path", p)); + w.empty("a:fillToRect", A::new().a("l", pct(focus[0])).a("t", pct(focus[1])).a("r", pct(focus[2])).a("b", pct(focus[3]))); + w.close("a:path"); + } + } + w.close("a:gradFill"); +} + pub fn effects(w: &mut W, fx: &Effects) { w.open0("a:effectLst"); + // Schema order: blur, fillOverlay, glow, innerShdw, outerShdw, prstShdw, reflection, softEdge. + if let Some(ov) = &fx.fill_overlay { + let blend = match ov.blend.as_str() { + b @ ("over" | "mult" | "screen" | "darken" | "lighten") => b, + _ => "over", + }; + w.open("a:fillOverlay", A::new().a("blend", blend)); + match &ov.fill { + Fill::Solid { color: c } => solid(w, c), + Fill::Gradient(g) => gradient(w, g), + _ => w.empty0("a:noFill"), + } + w.close("a:fillOverlay"); + } if let Some(g) = &fx.glow { w.open("a:glow", A::new().a("rad", emu(g.radius).max(0))); color(w, &g.color); diff --git a/crates/pptx/tests/fills.rs b/crates/pptx/tests/fills.rs index 956af5f..b129e64 100644 --- a/crates/pptx/tests/fills.rs +++ b/crates/pptx/tests/fills.rs @@ -102,3 +102,34 @@ fn a_gradient_without_stops_is_saved_without_stops() { let s = q.slides[0].shapes.iter().find(|s| s.name == "Styled").expect("shape"); assert_eq!(s.fill, Some(Fill::Gradient(own))); } + +#[test] +fn a_fill_overlay_round_trips() { + use deckcraft_model::style::{FillOverlay, Gradient, GradientShape, GradientStop}; + let mut p = defaults::new_presentation(None); + let id = ShapeId(p.alloc_id()); + let first = p.slides[0].id; + let overlay = FillOverlay { + blend: "mult".into(), + fill: Fill::Gradient(Gradient { + stops: vec![ + GradientStop { pos: 0.0, color: deckcraft_model::ColorRef::scheme(deckcraft_color::SchemeSlot::Accent1) }, + GradientStop { pos: 1.0, color: deckcraft_model::ColorRef::rgb(deckcraft_color::Rgba::rgb(10, 20, 30)) }, + ], + shape: GradientShape::Linear { angle: 4.5, scaled: true }, + rotate_with_shape: true, + }), + }; + p.slide_mut(first).expect("slide").shapes.push(Shape { + id, + name: "Overlaid".into(), + xfrm: Some(Xfrm::new(10.0, 10.0, 200.0, 100.0)), + fill: Some(Fill::solid(deckcraft_model::ColorRef::rgb(deckcraft_color::Rgba::WHITE))), + effects: Some(deckcraft_model::Effects { fill_overlay: Some(overlay.clone()), ..Default::default() }), + ..Default::default() + }); + let bytes = deckcraft_pptx::export(&p).expect("export"); + let q = deckcraft_pptx::import(&bytes).expect("import"); + let s = q.slides[0].shapes.iter().find(|s| s.name == "Overlaid").expect("shape"); + assert_eq!(s.effects.as_ref().and_then(|e| e.fill_overlay.clone()), Some(overlay)); +} diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 2e13922..47a0f1e 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -872,6 +872,25 @@ impl Renderer { } } } + // `a:fillOverlay`: a fill blended over the shape's fill (PowerPoint's "Flow" themes multiply + // a gradient over a tiled picture), inside the filled outline, under the line. + if let Some(ov) = effects.as_ref().and_then(|e| e.fill_overlay.as_ref()) + && fill.as_ref().is_some_and(|f| !f.is_none()) + { + let mix = match ov.blend.as_str() { + "mult" => peniko::Mix::Multiply, + "screen" => peniko::Mix::Screen, + "darken" => peniko::Mix::Darken, + "lighten" => peniko::Mix::Lighten, + _ => peniko::Mix::Normal, + }; + for (i, sp) in geo.paths.iter().enumerate().filter(|(i, sp)| sp.fill != FillMode::None && fade(*i).fill > 0.0) { + ctx.set_transform(m); + ctx.push_layer(Some(&sp.path), Some(peniko::BlendMode::new(mix, peniko::Compose::SrcOver)), None, None, None); + paint::fill_path(ctx, rctx, &ov.fill, fx_ph, &sp.path, Rect::new(0.0, 0.0, w, h), m, FillMode::Norm, fade(i).fill, f.pres); + ctx.pop_layer(); + } + } // Outline. if line.fill.as_ref().is_some_and(|f| !f.is_none()) { let lw = line.width.unwrap_or(0.75).max(0.0); diff --git a/crates/render/src/tests.rs b/crates/render/src/tests.rs index 7c18f73..7fd3db5 100644 --- a/crates/render/src/tests.rs +++ b/crates/render/src/tests.rs @@ -1583,3 +1583,34 @@ fn text_in_a_scaled_group_keeps_its_point_size() { assert!(x0 >= 100 && x1 <= 500 && sy0 >= 100 && sy1 <= 300, "scale {scale}: ink inside the box"); } } + +#[test] +fn a_fill_overlay_blends_over_the_fill() { + // `a:fillOverlay blend="mult"`: a 50% grey multiplied over a light fill darkens it; "screen" + // lightens a dark one; the overlay stays inside the shape. + use deckcraft_model::style::FillOverlay; + let draw = |base: [u8; 3], blend: &str| { + let mut p = Presentation::default(); + let mut sh = Shape { + id: ShapeId(600), + xfrm: Some(Xfrm::new(100.0, 100.0, 200.0, 100.0)), + fill: Some(Fill::solid(ColorRef::rgb(deckcraft_color::Rgba::rgb(base[0], base[1], base[2])))), + line: Some(deckcraft_model::style::Line { fill: Some(Fill::None), ..Default::default() }), + ..Default::default() + }; + sh.effects = Some(deckcraft_model::Effects { + fill_overlay: Some(FillOverlay { blend: blend.into(), fill: Fill::solid(ColorRef::rgb(deckcraft_color::Rgba::rgb(128, 128, 128))) }), + ..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() }); + (img.pixel(200, 150), img.pixel(50, 50)) + }; + let (mult, outside) = draw([200, 100, 50], "mult"); + assert!((mult[0] as i32 - 100).abs() <= 3 && (mult[1] as i32 - 50).abs() <= 3 && (mult[2] as i32 - 25).abs() <= 3, "{mult:?}"); + assert_eq!(&outside[..3], &[255, 255, 255], "nothing outside the shape"); + let (screen, _) = draw([0, 0, 0], "screen"); + assert!((screen[0] as i32 - 128).abs() <= 3, "{screen:?}"); + let (over, _) = draw([200, 100, 50], "over"); + assert!((over[0] as i32 - 128).abs() <= 3 && (over[1] as i32 - 128).abs() <= 3, "{over:?}"); +}