mirror of
https://github.com/storytold/deckcraft.git
synced 2026-10-24 02:27:08 -06:00
Effects: fill overlays (a:fillOverlay) blend over the shape's fill, and round-trip
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
cdfaaf4a38
commit
8a7a4500ad
@@ -387,6 +387,16 @@ pub struct Effects {
|
|||||||
pub bevel: Option<String>,
|
pub bevel: Option<String>,
|
||||||
/// Unmodelled 3-D XML kept for round-trip (`scene3d`, `sp3d`).
|
/// Unmodelled 3-D XML kept for round-trip (`scene3d`, `sp3d`).
|
||||||
pub raw3d: Option<String>,
|
pub raw3d: Option<String>,
|
||||||
|
/// `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<FillOverlay>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct FillOverlay {
|
||||||
|
pub blend: String,
|
||||||
|
pub fill: Fill,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Effects {
|
impl Effects {
|
||||||
@@ -397,6 +407,7 @@ impl Effects {
|
|||||||
&& self.soft_edge.is_none()
|
&& self.soft_edge.is_none()
|
||||||
&& self.reflection.is_none()
|
&& self.reflection.is_none()
|
||||||
&& self.bevel.is_none()
|
&& self.bevel.is_none()
|
||||||
|
&& self.fill_overlay.is_none()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -257,6 +257,14 @@ pub fn effects(el: &El) -> Effects {
|
|||||||
"innerShdw" => fx.inner_shadow = Some(shadow(e, true)),
|
"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) }),
|
"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)),
|
"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" => {
|
"reflection" => {
|
||||||
fx.reflection = Some(Reflection {
|
fx.reflection = Some(Reflection {
|
||||||
blur: pt(e, "blurRad").unwrap_or(0.0),
|
blur: pt(e, "blurRad").unwrap_or(0.0),
|
||||||
|
|||||||
@@ -76,40 +76,7 @@ pub fn fill(w: &mut W, x: &mut Exp, o: &mut Out, f: &Fill) {
|
|||||||
match f {
|
match f {
|
||||||
Fill::None => w.empty0("a:noFill"),
|
Fill::None => w.empty0("a:noFill"),
|
||||||
Fill::Solid { color: c } => solid(w, c),
|
Fill::Solid { color: c } => solid(w, c),
|
||||||
Fill::Gradient(g) => {
|
Fill::Gradient(g) => gradient(w, 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::Picture(pf) => blip_fill(w, x, o, "a:blipFill", pf),
|
Fill::Picture(pf) => blip_fill(w, x, o, "a:blipFill", pf),
|
||||||
Fill::Pattern(p) => {
|
Fill::Pattern(p) => {
|
||||||
w.open("a:pattFill", A::new().a("prst", if p.preset.is_empty() { "pct5" } else { &p.preset }));
|
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);
|
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) {
|
pub fn effects(w: &mut W, fx: &Effects) {
|
||||||
w.open0("a:effectLst");
|
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 {
|
if let Some(g) = &fx.glow {
|
||||||
w.open("a:glow", A::new().a("rad", emu(g.radius).max(0)));
|
w.open("a:glow", A::new().a("rad", emu(g.radius).max(0)));
|
||||||
color(w, &g.color);
|
color(w, &g.color);
|
||||||
|
|||||||
@@ -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");
|
let s = q.slides[0].shapes.iter().find(|s| s.name == "Styled").expect("shape");
|
||||||
assert_eq!(s.fill, Some(Fill::Gradient(own)));
|
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));
|
||||||
|
}
|
||||||
|
|||||||
@@ -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.
|
// Outline.
|
||||||
if line.fill.as_ref().is_some_and(|f| !f.is_none()) {
|
if line.fill.as_ref().is_some_and(|f| !f.is_none()) {
|
||||||
let lw = line.width.unwrap_or(0.75).max(0.0);
|
let lw = line.width.unwrap_or(0.75).max(0.0);
|
||||||
|
|||||||
@@ -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");
|
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:?}");
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user