Fix #827: shadows and glows keep their blend modes in PDF

A raster effect painted below its object (Drop Shadow, Outer Glow) is
written as an image under the object's vector art. The image was
rendered over nothing and placed with Normal blending, so a Screen glow
or a Multiply shadow came out as Normal in the PDF. Each image is now
composited with its effects' blend mode, one image per mode when an
object's shadows and glows use several.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
bflatastic 2026-10-10 06:23:28 +07:00
parent 522c1dabd6
commit 99671cabd8
3 changed files with 85 additions and 6 deletions

View File

@ -52,6 +52,13 @@ impl RasterFx {
pub fn is_below(&self) -> bool {
matches!(self, RasterFx::DropShadow { .. } | RasterFx::OuterGlow { .. })
}
/// The blend mode it paints with (shadows and glows).
pub fn mode(&self) -> Option<BlendMode> {
match self {
RasterFx::DropShadow { mode, .. } | RasterFx::OuterGlow { mode, .. } | RasterFx::InnerGlow { mode, .. } => Some(*mode),
_ => None,
}
}
/// How far this effect reaches beyond `bounds`, what it paints around (Radial Blur reaches
/// further around larger objects).
pub fn outset(&self, bounds: Rect) -> f64 {

View File

@ -15,7 +15,7 @@
use std::sync::Arc;
use serde_json::{Map, Value, json};
use vectorcraft_color::Paint;
use vectorcraft_color::{BlendMode, Paint};
use vectorcraft_doc::rastersettings::MAX_ADD_AROUND;
use vectorcraft_doc::{
Appearance, AppearanceItem, ColorMode, Document, Effect, ImageBlob, ImageObject, Node, NodeId, NodeKind, RasterColorModel, StrokeLayer,
@ -217,18 +217,55 @@ pub(crate) fn raster_fx(n: &Node) -> Vec<RasterFx> {
/// Remove the raster effects of `n` (its own and its fills' and strokes') and, with `members`, of
/// everything inside it.
pub(crate) fn strip_raster(n: &mut Node, members: bool) {
let keep = |e: &Effect| !effects::is_raster(&e.id);
retain_effects(n, members, &|e| !effects::is_raster(&e.id));
}
/// Keep the effects of `n` (its own and its fills' and strokes') that `keep` accepts and, with
/// `members`, of everything inside it.
fn retain_effects(n: &mut Node, members: bool, keep: &dyn Fn(&Effect) -> bool) {
n.appearance.effects.retain(keep);
for it in &mut n.appearance.items {
it.effects_mut().retain(keep);
}
if members {
for c in n.children_mut().into_iter().flatten() {
strip_raster(Arc::make_mut(c), true);
retain_effects(Arc::make_mut(c), true, keep);
}
}
}
/// The blend mode raster effect `e` paints with (`None`: not a raster effect, or one without).
fn effect_mode(e: &Effect) -> Option<BlendMode> {
effects::raster_effects(std::slice::from_ref(e)).first().and_then(RasterFx::mode)
}
/// The images of the shadows and outer glows of `n`, one per blend mode they use (in the order
/// the modes first come), each composited with its mode: rendered alone, over nothing, they can't
/// blend with the art below the object, so the image does.
fn below_images(d: &mut Document, n: &Node) -> Option<Vec<Node>> {
let mut modes: Vec<BlendMode> = vec![];
for mode in raster_fx(n).iter().filter_map(RasterFx::mode) {
if !modes.contains(&mode) {
modes.push(mode);
}
}
if modes.len() < 2 {
let mut image = raster_image(d, n, true, false)?;
image.blend = modes.first().copied().unwrap_or_default();
return Some(vec![image]);
}
modes
.into_iter()
.map(|mode| {
let mut only = n.clone();
retain_effects(&mut only, false, &|e| effect_mode(e).is_none_or(|m| m == mode));
let mut image = raster_image(d, &only, true, false)?;
image.blend = mode;
Some(image)
})
.collect()
}
/// The raster effects of `n` (its own, its fills' and strokes') as an embedded image of `d`
/// rendered at the document's raster effects resolution, without `n`'s transparency (that stays
/// on the object). With `below` (they all paint below it: shadows and outer glows) the image holds
@ -405,15 +442,18 @@ fn walk(out: &mut Document, n: &Node, which: Which) -> Option<Node> {
return Some(walk(out, &g, which).unwrap_or(g));
}
// An image that can't be made leaves the object as it is (the writer reports its effects).
let image = raster_image(out, n, below, false)?;
let mut v = n.clone();
strip_raster(&mut v, false);
let mut m = if below {
let images = below_images(out, n)?;
let mut m = walk(out, &v, which).unwrap_or(v);
put_below(out, &mut m, image);
// Each goes in first: the last one put is the lowest.
for image in images.into_iter().rev() {
put_below(out, &mut m, image);
}
m
} else {
replace_with_image(v, image)
replace_with_image(v, raster_image(out, n, false, false)?)
};
walk_mask(out, &mut m, which);
Some(m)

View File

@ -150,3 +150,35 @@ fn live_objects_and_single_fills_and_strokes_keep_their_raster_effects() {
assert_eq!(pieces, 2, "item {item}: one piece per fill and stroke");
}
}
#[test]
fn shadows_and_glows_keep_their_blend_modes() {
// #827: a red Screen glow over grey can't darken it, as Normal would.
let glow = Effect {
id: "stylize.outerGlow".into(),
params: json!({"mode": "screen", "color": "#ff0000", "opacity": 100.0, "blur": 20.0}),
visible: true,
};
let mut d = Document::new(200.0, 200.0);
let back = rect(&mut d, Rect::new(0.0, 0.0, 200.0, 200.0), Color::rgb(0.5, 0.5, 0.5));
add(&mut d, back);
let dot = rect(&mut d, Rect::new(70.0, 70.0, 130.0, 130.0), Color::rgb(0.12, 0.12, 0.12));
add(&mut d, with(dot, glow.clone()));
assert_written("screen glow", &d);
let r = crate::cmd::rasterfx::export_pdf_with_report(&d, &Default::default()).unwrap();
let back = render(&vectorcraft_pdf::import(&r.bytes).unwrap());
for x in [133, 138, 145] {
let i = (100 * back.width as usize + x) * 4;
let (red, green, blue) = (back.pixels[i], back.pixels[i + 1], back.pixels[i + 2]);
assert!(red > green && green >= 124 && blue >= 124, "x {x}: {:?}", (red, green, blue));
}
// A Multiply shadow and the Screen glow: an image each, composited with its mode.
let mut d = Document::new(200.0, 200.0);
let dot = rect(&mut d, Rect::new(70.0, 70.0, 130.0, 130.0), Color::rgb(0.12, 0.12, 0.12));
add(&mut d, with(with(dot, shadow()), glow));
let flat = crate::flatten_raster_effects(&d).unwrap();
let modes: Vec<_> =
flat.layers[0].children().unwrap()[0].children().unwrap().iter().filter(|c| matches!(c.kind, NodeKind::Image(_))).map(|c| c.blend).collect();
assert_eq!(modes, [vectorcraft_color::BlendMode::Multiply, vectorcraft_color::BlendMode::Screen]);
}