M8: the SVG export chains layer blurs and casts shadows from the blurred layer

The canvas applies layer blurs one after another and casts drop shadows from the layer they leave, cut out by it. The SVG filter blurred SourceGraphic once per blur into the same result, so only the last was drawn, and its shadows came from the unblurred alpha: blurs of 12 and 16 with a shadow came back from an export 73 levels off. The blurs now chain, the shadows are cast from the last blur's alpha and cut out by it, and the import reads that back as one blur as strong as the stack with the same shadows. Coloured copies of a blurred layer are still reported, not read as black shadows.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
samhyun
2026-10-30 10:20:30 +09:00
co-authored by Claude Opus 5.5
parent e2adcd92c7
commit 97f502cb42
3 changed files with 180 additions and 43 deletions
+22 -3
View File
@@ -4,8 +4,10 @@
//! it, shadows cut into it, or blurred, maps exactly onto drop shadows, inner shadows and a layer
//! blur: `feDropShadow`; `feOffset` + `feGaussianBlur` + `feFlood` + `feComposite` + `feMerge`;
//! the colour-matrix form (alpha → offset → blur → matrix tint → blend), with `feMorphology` as
//! spread and arithmetic or `out` composites for inner shadows. Anything else is reported back as
//! unsupported and the layer is drawn without it.
//! spread and arithmetic or `out` composites for inner shadows. A shadow made from the blurred
//! layer's tinted alpha, and cut out by that layer (what our exports write, as the canvas casts
//! drop shadows from what the layer blurs leave), is a drop shadow of that blurred layer. Anything
//! else is reported back as unsupported and the layer is drawn without it.
use std::collections::HashMap;
@@ -32,11 +34,14 @@ struct Sh {
/// Everything but the shadow (a flood with the shadow cut out): becomes an inner shadow once
/// it's cut to the layer.
inverted: bool,
/// Cast from the layer blurred by this sigma rather than from its alpha (our exports: a drop
/// shadow is cast from the layer its layer blurs leave).
cast: f64,
}
impl Sh {
fn alpha() -> Self {
Sh { dx: 0.0, dy: 0.0, sigma: 0.0, spread: 0.0, color: Color::BLACK, knockout: false, inner: false, inverted: false }
Sh { dx: 0.0, dy: 0.0, sigma: 0.0, spread: 0.0, color: Color::BLACK, knockout: false, inner: false, inverted: false, cast: 0.0 }
}
/// Still exactly the layer's alpha.
fn is_plain(&self) -> bool {
@@ -132,6 +137,8 @@ fn primitive(k: &Kind, get: &dyn Fn(&Input) -> Option<Img>) -> Option<Img> {
let s = match get(cm.input())? {
Img::Shadow(s) => s,
Img::Layer(0.0) => Sh::alpha(),
// A tint of the blurred layer: a shadow cast from it.
Img::Layer(b) => Sh { cast: b, ..Sh::alpha() },
_ => return None,
};
tint(v, s)?
@@ -193,6 +200,10 @@ fn composite(a: Img, b: Img, op: CompositeOperator) -> Option<Img> {
(CompositeOperator::Out, Img::Shadow(s), Img::Shadow(l)) if l.is_plain() && !s.inner && !s.inverted => {
Img::Shadow(Sh { knockout: true, ..s })
}
// Cut out where the blurred layer it was cast from is.
(CompositeOperator::Out, Img::Shadow(s), Img::Layer(b)) if b > 0.0 && b == s.cast && !s.inner && !s.inverted => {
Img::Shadow(Sh { knockout: true, ..s })
}
// layer − shadow: inside the layer where the offset copy isn't.
(CompositeOperator::Arithmetic { k1, k2, k3, k4 }, Img::Shadow(s), Img::Shadow(l))
if k1 == 0.0 && k4 == 0.0 && k2 == -1.0 && k3 == 1.0 && l.is_plain() && !s.inner =>
@@ -211,6 +222,14 @@ fn to_effects(img: Img, world: Affine) -> Option<Vec<Effect>> {
return None;
}
let k = length_scale(world);
// A shadow cast from a blurred layer is one of its drop shadows only when that is the layer drawn.
let drawn = match layers.get(at) {
Some(Img::Layer(b)) => *b,
_ => 0.0,
};
if layers.iter().any(|l| matches!(l, Img::Shadow(s) if s.cast > 0.0 && (s.cast != drawn || s.inner || s.inverted))) {
return None;
}
let mut out = Vec::new();
for (i, l) in layers.iter().enumerate() {
match l {
+100
View File
@@ -128,3 +128,103 @@ fn exported_effects_come_back_as_they_were() {
let c = s.color;
assert!((c.r - 0.1).abs() < 1e-3 && (c.g - 0.2).abs() < 1e-3 && (c.b - 0.3).abs() < 1e-3 && (c.a - 0.4).abs() < 1e-3, "{c:?}");
}
/// Layer blurs one after another, and a drop shadow cast from what they leave, as the canvas
/// draws them: the export chains the blurs and casts the shadow from the last, and the import
/// reads that back as one blur as strong as the stack (blurs add their variances) and the same
/// shadow, drawing as the original did.
#[test]
fn stacked_blurs_and_their_shadow_come_back_drawing_the_same() {
let mut d = Document::new("t");
let p = d.first_page().unwrap();
let r = d.create(p, NodeKind::Rectangle, "R", 40.0, 30.0, 80.0, 50.0).unwrap();
let shadow = Shadow { color: Color::new(0.0, 0.0, 0.0, 0.8), offset: (0.0, 24.0), radius: 4.0, ..Shadow::default() };
let node = d.get_mut(r).unwrap();
node.fills = vec![Paint::solid(Color::from_hex("336699").unwrap())];
node.effects = vec![
Effect { kind: EffectKind::LayerBlur(Blur { radius: 12.0, ..Blur::default() }), visible: true },
Effect { kind: EffectKind::LayerBlur(Blur { radius: 16.0, ..Blur::default() }), visible: true },
Effect { kind: EffectKind::DropShadow(shadow.clone()), visible: true },
];
let svg = crate::export_svg(&d, &[r], &crate::SvgOptions::default()).unwrap();
assert!(
svg.contains(
"<feGaussianBlur in=\"SourceGraphic\" stdDeviation=\"6\" result=\"l0\"/><feGaussianBlur in=\"l0\" stdDeviation=\"8\" result=\"l1\"/>"
),
"{svg}"
);
assert!(
svg.contains("<feColorMatrix in=\"l1\"") && svg.contains("<feOffset in=\"la\"") && svg.contains("in2=\"l1\" operator=\"out\""),
"the shadow is cast from the blurred layer: {svg}"
);
let mut d2 = Document::new("back");
let p2 = d2.first_page().unwrap();
let f = crate::import_svg(&mut d2, svg.as_bytes(), p2, 0.0, 0.0, "x").unwrap();
let back = d2.subtree(f).into_iter().filter_map(|i| d2.node(i)).find(|n| n.kind == NodeKind::Rectangle).unwrap();
let [EffectKind::DropShadow(s), EffectKind::LayerBlur(b)] = &back.effects.iter().map(|e| e.kind.clone()).collect::<Vec<_>>()[..] else {
panic!("{:?}", back.effects)
};
assert!((b.radius - 20.0).abs() < 1e-3, "two blurs of 12 and 16 as one of 20: {}", b.radius);
assert_eq!((s.offset, s.radius, s.show_behind), (shadow.offset, shadow.radius, false));
// Drawn by the canvas, the import and the original match (one blur of 20 for two of 12 and
// 16: the canvas's box blurs add their variances only nearly).
let draw = |d: &Document, id| {
let at = d.world_render_bounds(id).unwrap();
let v = figcraft_render::View { origin: (at.x0 - 10.0, at.y0 - 10.0), scale: 1.0, width: 220, height: 220 };
figcraft_render::render_view_single(d, d.first_page().unwrap(), &v, &figcraft_render::Options::default()).unwrap()
};
let (a, z) = (draw(&d, r), draw(&d2, back.id));
let worst = a.data().iter().zip(z.data()).map(|(x, y)| x.abs_diff(*y)).max().unwrap_or(0);
assert!(worst <= 6, "the import draws {worst} levels off the original");
}
/// What our exports write for a blurred layer's shadows comes back as the same effects: a glow
/// (no offset), a shadow showing behind, one with spread, two shadows; and a blur of 0 changes
/// nothing about the shadow.
#[test]
fn blurred_layers_shadows_come_back_as_they_were() {
let sh = |offset: (f64, f64), spread: f64, show_behind: bool| {
EffectKind::DropShadow(Shadow { color: Color::new(0.2, 0.0, 0.4, 0.6), offset, radius: 6.0, spread, show_behind, ..Shadow::default() })
};
let blur = |r: f64| EffectKind::LayerBlur(Blur { radius: r, ..Blur::default() });
let cases = [
("a glow", vec![blur(8.0), sh((0.0, 0.0), 0.0, false)]),
("showing behind", vec![blur(8.0), sh((4.0, 6.0), 0.0, true)]),
("two shadows", vec![blur(8.0), sh((4.0, 6.0), 0.0, false), sh((-3.0, 0.0), 0.0, true)]),
("a blur of 0", vec![blur(0.0), sh((4.0, 6.0), 0.0, true)]),
];
for (why, effects) in cases {
let mut d = Document::new("t");
let p = d.first_page().unwrap();
let r = d.create(p, NodeKind::Rectangle, "R", 20.0, 20.0, 60.0, 40.0).unwrap();
let node = d.get_mut(r).unwrap();
node.fills = vec![Paint::solid(Color::from_hex("336699").unwrap())];
node.effects = effects.iter().cloned().map(|kind| Effect { kind, visible: true }).collect();
let svg = crate::export_svg(&d, &[r], &crate::SvgOptions::default()).unwrap();
let mut d2 = Document::new("back");
let p2 = d2.first_page().unwrap();
let i = import_svg_with_notes(&mut d2, svg.as_bytes(), p2, 0.0, 0.0, "x").unwrap();
assert!(i.notes.is_empty(), "{why}: {:?}", i.notes);
let back = d2.subtree(i.frame).into_iter().filter_map(|id| d2.node(id)).find(|n| n.kind == NodeKind::Rectangle).unwrap();
let shadows = |fx: &mut dyn Iterator<Item = EffectKind>| {
fx.filter_map(|k| if let EffectKind::DropShadow(s) = k { Some((s.offset, s.radius, s.show_behind)) } else { None }).collect::<Vec<_>>()
};
assert_eq!(shadows(&mut back.effects.iter().map(|e| e.kind.clone())), shadows(&mut effects.into_iter()), "{why}: {:?}", back.effects);
let blurs = back.effects.iter().filter(|e| matches!(e.kind, EffectKind::LayerBlur(_))).count();
assert_eq!(blurs, usize::from(why != "a blur of 0"), "{why}: {:?}", back.effects);
}
}
/// Copies of the blurred layer in its own colours aren't shadows: still reported, not imported
/// as black ones.
#[test]
fn coloured_copies_of_a_blurred_layer_arent_shadows() {
for filter in [
r##"<feGaussianBlur stdDeviation="3" result="b"/><feOffset in="b" dx="6" dy="6" result="o"/><feMerge><feMergeNode in="o"/><feMergeNode in="b"/></feMerge>"##,
r##"<feGaussianBlur stdDeviation="3" result="b"/><feMorphology in="b" operator="dilate" radius="4" result="m"/><feMerge><feMergeNode in="m"/><feMergeNode in="b"/></feMerge>"##,
] {
let (fx, notes) = effects(filter, "");
assert!(!fx.iter().any(|k| matches!(k, EffectKind::DropShadow(_))), "{filter}: {fx:?}");
assert!(!notes.is_empty(), "{filter}: reported");
}
}
+58 -40
View File
@@ -330,7 +330,8 @@ impl W<'_> {
}
/// The layer's filter: drop shadows (but those drawn as `exact` geometry: see
/// [`W::drop_shadows`]) and layer blur.
/// [`W::drop_shadows`]) and layer blurs, as the canvas draws them: the blurs one after another,
/// the shadows cast from and cut out by the layer they leave.
fn filter_for(&mut self, n: &Node, exact: bool, xf: Affine) -> Option<String> {
let fx: Vec<&figcraft_doc::Effect> = n
.effects
@@ -362,56 +363,73 @@ impl W<'_> {
),
None => format!("<filter id=\"{fid}\" x=\"-50%\" y=\"-50%\" width=\"200%\" height=\"200%\" color-interpolation-filters=\"sRGB\">"),
};
// Shadows that don't show behind transparent areas are cut out where the layer is: by
// what its paints cover, or by its pixels (like the renderer).
// Layer blurs one after another, each blurring what the one before it left (like the
// renderer's layer effects); the last result is the layer drawn. A blur of 0 does nothing.
let mut layer = "SourceGraphic".to_string();
for (i, e) in fx.iter().enumerate() {
if let EffectKind::LayerBlur(b) = &e.kind
&& b.sanitized().radius > 0.0
{
let _ = write!(f, "<feGaussianBlur in=\"{layer}\" stdDeviation=\"{}\" result=\"l{i}\"/>", num(b.sanitized().radius / 2.0));
layer = format!("l{i}");
}
}
let blurred = layer != "SourceGraphic";
let has_shadows = fx.iter().any(|e| matches!(e.kind, EffectKind::DropShadow(_)));
// Shadows are cast from the blurred layer's alpha, and those that don't show behind
// transparent areas are cut out where the layer is: by what its paints cover, or by its
// pixels (blurred).
if blurred && has_shadows {
let _ = write!(f, "<feColorMatrix in=\"{layer}\" type=\"matrix\" values=\"0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 0\" result=\"la\"/>");
}
let cover = self.cover_input(n, xf);
let knock = if cover.is_some() { "cover" } else { "SourceAlpha" };
let cast = if blurred { "la" } else { "SourceAlpha" };
let knock = if cover.is_some() {
"cover"
} else if blurred {
layer.as_str()
} else {
"SourceAlpha"
};
f.push_str(cover.as_deref().unwrap_or_default());
let mut merge = Vec::new();
for (i, e) in fx.iter().enumerate() {
match &e.kind {
EffectKind::DropShadow(s) => {
// Spread here (layers that aren't one outline) grows the pixels, like the renderer.
let morph = if s.spread != 0.0 {
format!(
"<feMorphology in=\"SourceAlpha\" operator=\"{}\" radius=\"{}\" result=\"s{i}m\"/>",
if s.spread > 0.0 { "dilate" } else { "erode" },
num(s.spread.abs())
)
} else {
if let EffectKind::DropShadow(s) = &e.kind {
// Spread here (layers that aren't one outline) grows the pixels, like the renderer.
let morph = if s.spread != 0.0 {
format!(
"<feMorphology in=\"{cast}\" operator=\"{}\" radius=\"{}\" result=\"s{i}m\"/>",
if s.spread > 0.0 { "dilate" } else { "erode" },
num(s.spread.abs())
)
} else {
String::new()
};
let src = if morph.is_empty() { cast.to_string() } else { format!("s{i}m") };
let _ = write!(
f,
"{morph}<feOffset in=\"{src}\" dx=\"{}\" dy=\"{}\"/><feGaussianBlur stdDeviation=\"{}\"/><feColorMatrix type=\"matrix\" values=\"0 0 0 0 {} 0 0 0 0 {} 0 0 0 0 {} 0 0 0 {} 0\" result=\"s{i}\"/>{}",
num(s.offset.0),
num(s.offset.1),
num(s.radius / 2.0),
num(f64::from(s.color.r)),
num(f64::from(s.color.g)),
num(f64::from(s.color.b)),
num(f64::from(s.color.a)),
if s.show_behind {
String::new()
};
let src = if morph.is_empty() { "SourceAlpha".to_string() } else { format!("s{i}m") };
let _ = write!(
f,
"{morph}<feOffset in=\"{src}\" dx=\"{}\" dy=\"{}\"/><feGaussianBlur stdDeviation=\"{}\"/><feColorMatrix type=\"matrix\" values=\"0 0 0 0 {} 0 0 0 0 {} 0 0 0 0 {} 0 0 0 {} 0\" result=\"s{i}\"/>{}",
num(s.offset.0),
num(s.offset.1),
num(s.radius / 2.0),
num(f64::from(s.color.r)),
num(f64::from(s.color.g)),
num(f64::from(s.color.b)),
num(f64::from(s.color.a)),
if s.show_behind {
String::new()
} else {
format!("<feComposite in=\"s{i}\" in2=\"{knock}\" operator=\"out\" result=\"s{i}\"/>")
}
);
merge.push(format!("s{i}"));
}
EffectKind::LayerBlur(b) => {
let _ = write!(f, "<feGaussianBlur in=\"SourceGraphic\" stdDeviation=\"{}\" result=\"blur\"/>", num(b.sanitized().radius / 2.0));
}
_ => {}
} else {
format!("<feComposite in=\"s{i}\" in2=\"{knock}\" operator=\"out\" result=\"s{i}\"/>")
}
);
merge.push(format!("s{i}"));
}
}
let has_blur = fx.iter().any(|e| matches!(e.kind, EffectKind::LayerBlur { .. }));
f.push_str("<feMerge>");
for m in &merge {
let _ = write!(f, "<feMergeNode in=\"{m}\"/>");
}
let _ = write!(f, "<feMergeNode in=\"{}\"/></feMerge></filter>", if has_blur { "blur" } else { "SourceGraphic" });
let _ = write!(f, "<feMergeNode in=\"{layer}\"/></feMerge></filter>");
self.defs.push_str(&f);
Some(fid)
}