Effects: an image's Inner Glow follows its visible pixels, not its frame (#5308)

Inner Glow clipped to the art's outline and bled in from outside it; for a
placed image that outline is the frame, so a PNG with transparent areas
glowed along the frame's edge, over pixels that don't show. For an image the
glow is now cast by its pixels' alpha (everything around them, blurred, or
the pixels themselves with Center) and kept to where the image shows, as
much as it shows.
This commit is contained in:
Don Omondi
2026-11-06 07:25:10 +07:00
committed by bflatastic
parent 1f7f341c21
commit 283c0d12bb
3 changed files with 80 additions and 2 deletions
+38 -1
View File
@@ -697,11 +697,48 @@ impl Renderer {
} else {
paint(self, ctx, f);
}
// Above the content: inner glows, clipped to its outline.
// Above the content: inner glows, clipped to its outline. An image's glow follows its
// visible pixels, not its frame (#5308).
let Some((g, rule)) = content.outline else { return };
let pixels = matches!(content.node.kind, NodeKind::Image(_));
for fx in rfx {
let RasterFx::InnerGlow { mode, opacity, blur, color, center } = fx else { continue };
let filter = shadow_filter(0.0, 0.0, *blur, pcolor(f.ink, color));
if pixels {
self.with_filters(ctx, f, g.bounding_box(), *blur, Some((*mode, *opacity)), |r, c, fr, comp| {
if let Some((m, o)) = comp {
c.set_transform(Affine::IDENTITY);
c.push_layer(None, Some(blend_mode(m)), Some(o), None, None);
}
c.set_transform(fr.view);
c.set_fill_rule(fill_rule(rule));
c.push_clip_layer(g);
c.push_layer(None, None, None, None, Some(filter));
if !*center {
// Everything around the visible pixels, so the glow bleeds in from their edges.
let pad = blur * 2.0 + 4.0 * fr.px;
c.set_transform(fr.view);
c.set_paint(peniko::Color::BLACK);
c.fill_rect(&g.bounding_box().inflate(pad, pad));
c.push_layer(None, Some(peniko::BlendMode::new(peniko::Mix::Normal, peniko::Compose::DestOut)), None, None, None);
}
// The glow is cast by the pixels' alpha alone (the filter takes no color from them).
paint(r, c, fr);
if !*center {
c.pop_layer();
}
c.pop_layer();
// Only where the image shows, as much as it shows.
c.push_layer(None, Some(peniko::BlendMode::new(peniko::Mix::Normal, peniko::Compose::DestIn)), None, None, None);
paint(r, c, fr);
c.pop_layer();
c.pop_layer();
if comp.is_some() {
c.pop_layer();
}
});
continue;
}
self.with_filters(ctx, f, g.bounding_box(), *blur, Some((*mode, *opacity)), |_, c, fr, comp| {
// The blend layer goes outside the clip: a clip layer is isolated, so a blend
// inside it would mix with nothing instead of the content below.
+41
View File
@@ -1076,3 +1076,44 @@ fn photoshop_filters_on_content_larger_than_the_budget() {
assert!(lum(img.pixel(190, 100)) >= WHITE - 3 * 25, "{id}: outside {:?}", img.pixel(190, 100));
}
}
/// #5308: an image's Inner Glow follows its visible pixels, not its frame. A 40 × 40 image whose
/// left half is transparent and right half opaque black, on a black page, with a white glow from
/// the edges: the transparent half stays black, and the glow lights the opaque half's left edge
/// (inside the frame), on the single-threaded and the multithreaded renderer alike.
#[test]
fn an_image_inner_glow_follows_its_visible_pixels() {
use vectorcraft_doc::{ImageBlob, ImageObject, NodeKind};
let rgba: Vec<u8> = (0..40 * 40).flat_map(|i| if i % 40 < 20 { [0, 0, 0, 0] } else { [0, 0, 0, 255] }).collect();
let png = crate::encode::png::encode(&rgba, 40, 40, &Default::default()).unwrap();
let mut d = Document::new(100.0, 100.0);
d.images.insert("half".into(), ImageBlob::new("image/png", png));
let im = ImageObject {
key: "half".into(),
width: 40,
height: 40,
xf: Affine::translate((30.0, 30.0)),
link: None,
placement: Default::default(),
pixels: None,
};
let mut n = Node::new(d.alloc_id(), NodeKind::Image(Box::new(im)));
n.appearance.effects = vec![Effect {
id: "stylize.innerGlow".into(),
params: json!({"color": "#ffffff", "mode": "normal", "opacity": 100, "blur": 4}),
visible: true,
}];
d.insert(Some(d.layers[0].id), 0, n).unwrap();
let opts = RenderOptions { background: Some([0, 0, 0, 255]), ..Default::default() };
for threads in [0, 3] {
let mut r = Renderer::new();
r.threads = threads;
let img = r.render(&d, 100, 100, Affine::IDENTITY, &opts);
for (x, y) in [(31, 50), (40, 31), (45, 50)] {
assert!(lum(img.pixel(x, y)) < 10, "threads {threads}: no glow on the transparent pixels at ({x}, {y}): {:?}", img.pixel(x, y));
}
assert!(lum(img.pixel(51, 50)) > 60, "threads {threads}: the glow lights the visible pixels' edge: {:?}", img.pixel(51, 50));
assert!(lum(img.pixel(68, 50)) > 60, "threads {threads}: and the frame's edge where the pixels reach it: {:?}", img.pixel(68, 50));
assert!(lum(img.pixel(60, 50)) < lum(img.pixel(51, 50)), "threads {threads}: fading inward");
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
# UI and interaction parity
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-06 · **Change:** minor (Properties: Quick Actions has Repeat Options for one selected repeat, #5181; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; Units › East Asian Type acts, with Q and Ha units; scores unchanged)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-06 · **Change:** minor (Properties: Quick Actions has Repeat Options for one selected repeat, #5181; Direct Selection: Shift-click picks several blend spine points, which drag together, #5285; Direct Selection: Cmd/Ctrl held lends the Selection tool and shows its bounding box, #3901; Join tool trims two crossing open paths and joins them at the crossing, #5844; Control bar: Paragraph: link and Align left/center/right while type is selected, #5372; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; Units › East Asian Type acts, with Q and Ha units; scores unchanged)
## Feature submission evidence