diff --git a/render-wasm/src/render/pdf/to_unicode.rs b/render-wasm/src/render/pdf/to_unicode.rs index a724490bc7..ffb36f233e 100644 --- a/render-wasm/src/render/pdf/to_unicode.rs +++ b/render-wasm/src/render/pdf/to_unicode.rs @@ -781,7 +781,7 @@ mod tests { .add(family, LIGATURE_FONT, false, false) .expect("ligature font"); let _guard = crate::globals::TestRenderResourcesGuard::install(&mut resources); - let pdf = super::super::render_to_pdf(&mut resources, &id, &pool, 1.0).expect("pdf"); + let pdf = super::super::render_to_pdf(&mut resources, &id, &pool).expect("pdf"); let trailer = parse_xref(&pdf).expect("xref").trailer; assert!( diff --git a/render-wasm/src/render/svg/tests.rs b/render-wasm/src/render/svg/tests.rs index 0a047e8d07..02eda33b79 100644 --- a/render-wasm/src/render/svg/tests.rs +++ b/render-wasm/src/render/svg/tests.rs @@ -3795,3 +3795,73 @@ fn raster_export_of_child_paints_inherited_group_fill() { assert_eq!(inherited_png, raster_png(&own, uid(2))); assert_ne!(inherited_png, raster_png(&bare, uid(2))); } + +/// Reads one pixel of an encoded raster export as unpremultiplied RGBA. +fn raster_pixel(png: &[u8], (x, y): (i32, i32)) -> skia::Color { + let image = skia::Image::from_encoded(skia::Data::new_copy(png)).expect("decode png"); + let info = skia::ImageInfo::new( + (1, 1), + skia::ColorType::RGBA8888, + skia::AlphaType::Unpremul, + None, + ); + let mut px = [0u8; 4]; + assert!(image.read_pixels( + &info, + &mut px, + 4, + (x, y), + skia::image::CachingHint::Disallow + )); + skia::Color::from_argb(px[3], px[0], px[1], px[2]) +} + +/// #12334: headless raster/PDF must draw a masked group's inner shadow +/// inside the mask outline, as the GPU single export does. +#[test] +fn raster_export_of_masked_group_draws_inner_shadow() { + let mut pool = ShapesPool::new(); + let group_id = uid(1); + let mask_id = uid(2); + let content_id = uid(3); + let blue = skia::Color::from_rgb(0, 63, 255); + + add_masked_group( + &mut pool, + group_id, + Uuid::nil(), + (0.0, 0.0, 100.0, 100.0), + &[mask_id, content_id], + ); + add_solid_circle( + &mut pool, + mask_id, + group_id, + (0.0, 0.0, 100.0, 100.0), + skia::Color::BLACK, + ); + add_solid_rect( + &mut pool, + content_id, + group_id, + (0.0, 0.0, 100.0, 100.0), + blue, + ); + pool.get_mut(&group_id).unwrap().add_shadow(Shadow::new( + skia::Color::BLACK, + 0.0, + 0.0, + (20.0, 0.0), + ShadowStyle::Inner, + false, + )); + + let (png, width, height) = raster_png_size(&pool, group_id); + assert_eq!((width, height), (100, 100)); + + // Left edge: the shifted silhouette leaves a band the shadow fills. + assert_eq!(raster_pixel(&png, (8, 50)), skia::Color::BLACK); + assert_eq!(raster_pixel(&png, (50, 50)), blue); + // Outside the circle the mask still clears everything. + assert_eq!(raster_pixel(&png, (3, 3)).a(), 0); +} diff --git a/render-wasm/src/render/vector.rs b/render-wasm/src/render/vector.rs index e9d873f353..bfbeedf0d0 100644 --- a/render-wasm/src/render/vector.rs +++ b/render-wasm/src/render/vector.rs @@ -821,7 +821,22 @@ fn render_group( let child_opts = opts.for_children_of(element); if masked { + // Inner shadows filter the masked pixels, so they follow the mask's + // outline. The canvas matrix already carries `scale`. + let inner_shadows = element.masked_group_inner_shadow_filter(); + if let Some(filter) = &inner_shadows { + let mut paint = Paint::default(); + paint.set_image_filter(filter.clone()); + canvas.save_layer( + &skia::canvas::SaveLayerRec::default() + .bounds(&element.extrect(tree, scale)) + .paint(&paint), + ); + } render_masked_children(shared, canvas, element, &children, tree, scale, &child_opts)?; + if inner_shadows.is_some() { + canvas.restore(); // inner shadow layer + } } else { for child_id in &children { render_tree_inner(shared, canvas, child_id, tree, scale, &child_opts)?; diff --git a/render-wasm/src/shapes.rs b/render-wasm/src/shapes.rs index aaf4e55a49..3baa6dd66c 100644 --- a/render-wasm/src/shapes.rs +++ b/render-wasm/src/shapes.rs @@ -1998,6 +1998,25 @@ impl Shape { }) } + /// Source graphic with every visible inner shadow of the masked group over + /// it, for the CPU export renderer; `None` without inner shadows. + pub fn masked_group_inner_shadow_filter(&self) -> Option { + if !self.is_masked_group() { + return None; + } + + let mut layers: Vec> = vec![None]; + for shadow in self.inner_shadows_visible() { + layers.push(shadow.get_inner_shadow_filter()); + } + + if layers.len() > 1 { + skia::image_filters::merge(layers, None) + } else { + None + } + } + /// Image filter for the `save_layer` that wraps a masked group, i.e. for /// the result *after* the mask has been composited into it. /// @@ -2390,6 +2409,28 @@ mod tests { math::Rect::from_ltrb(0.0, 0.0, 100.0, 100.0) } + #[test] + fn masked_group_inner_shadow_filter_needs_an_inner_shadow() { + let drop_only = masked_group_with(vec![drop_shadow(4.0, 0.0, (10.0, 10.0))], None); + assert!(drop_only.masked_group_inner_shadow_filter().is_none()); + + let inner = masked_group_with(vec![inner_shadow(4.0, (10.0, 10.0))], None); + assert!(inner.masked_group_inner_shadow_filter().is_some()); + + let mut unmasked = inner.clone(); + unmasked.set_masked(false); + assert!(unmasked.masked_group_inner_shadow_filter().is_none()); + } + + #[test] + fn masked_group_inner_shadow_filter_keeps_imperceptible_shadows() { + // Too small for the workspace filter at 1x, but export draws it. + let shape = masked_group_with(vec![inner_shadow(1.0, (0.0, 0.0))], None); + + assert!(shape.masked_group_layer_filter(1.0, false, false).is_none()); + assert!(shape.masked_group_inner_shadow_filter().is_some()); + } + #[test] fn masked_group_layer_filter_is_none_without_effects() { let shape = masked_group_with(vec![], None);