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