From 283c0d12bb56d4c04d78b99e46a022718b06cb60 Mon Sep 17 00:00:00 2001 From: Don Omondi <282319807+mockydevs@users.noreply.github.com> Date: Thu, 5 Nov 2026 19:03:04 -0500 Subject: [PATCH] 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. --- crates/render/src/fx/renderer.rs | 39 +++++++++++++++++++++++++++++- crates/render/src/fx/tests.rs | 41 ++++++++++++++++++++++++++++++++ docs/ui-parity.md | 2 +- 3 files changed, 80 insertions(+), 2 deletions(-) diff --git a/crates/render/src/fx/renderer.rs b/crates/render/src/fx/renderer.rs index 987499710..fae143a23 100644 --- a/crates/render/src/fx/renderer.rs +++ b/crates/render/src/fx/renderer.rs @@ -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. diff --git a/crates/render/src/fx/tests.rs b/crates/render/src/fx/tests.rs index 031c5afa9..74ff632d6 100644 --- a/crates/render/src/fx/tests.rs +++ b/crates/render/src/fx/tests.rs @@ -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 = (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"); + } +} diff --git a/docs/ui-parity.md b/docs/ui-parity.md index dbf3ae1cd..c53620ef9 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -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