From 3adf9ade14f60eb440c5fe3d667b4286069d95e4 Mon Sep 17 00:00:00 2001 From: Alejandro Alonso Date: Fri, 11 Sep 2026 13:52:57 +0200 Subject: [PATCH] :sparkles: Export drop and inner shadows to WASM SVG (#11593) SkSVGDevice drops save_layer image-filters, so re-emit visible drop and inner shadows (plus optional layer blur) as one native SVG filter chain matching classic order: flood, drops, SourceGraphic, inners, blur. Closes #11379 --- render-wasm/src/render/svg/document.rs | 432 ++++++++- render-wasm/src/render/svg/frames.rs | 96 +- render-wasm/src/render/svg/groups.rs | 57 +- render-wasm/src/render/svg/images.rs | 50 +- render-wasm/src/render/svg/mod.rs | 68 +- ...shadow_clip_follows_silhouette_offset.snap | 16 + ...s_frame_drop_shadow_wrapping_children.snap | 12 + ...ts_group_layer_blur_wrapping_children.snap | 2 +- ...xports_leaf_drop_shadow_as_svg_filter.snap | 8 + ...ports_leaf_inner_shadow_as_svg_filter.snap | 8 + ...s_leaf_layer_blur_as_fe_gaussian_blur.snap | 2 +- ...p_shadow_ignores_stroke_spread_outset.snap | 10 + ...rop_silhouette_inherits_board_opacity.snap | 10 + ...me_drop_silhouette_offsets_child_text.snap | 18 + ...silhouette_shifts_image_fill_children.snap | 14 + ...w_filter_covers_page_not_bbox_percent.snap | 8 + ...offset_follows_rotation_in_user_space.snap | 8 + ...rop_shadow_is_not_refiltered_by_frame.snap | 13 + ...fill_outsets_under_parent_drop_spread.snap | 12 + ...der__svg__tests__skips_hidden_shadows.snap | 8 + ...w_filter_covers_page_not_bbox_percent.snap | 8 + ...w_filter_covers_page_not_bbox_percent.snap | 8 + render-wasm/src/render/svg/tests.rs | 877 +++++++++++++++++- render-wasm/src/render/svg/text.rs | 25 +- 24 files changed, 1669 insertions(+), 101 deletions(-) create mode 100644 render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__clipped_frame_drop_shadow_clip_follows_silhouette_offset.snap create mode 100644 render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_frame_drop_shadow_wrapping_children.snap create mode 100644 render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_leaf_drop_shadow_as_svg_filter.snap create mode 100644 render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_leaf_inner_shadow_as_svg_filter.snap create mode 100644 render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__fill_less_frame_drop_shadow_ignores_stroke_spread_outset.snap create mode 100644 render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__frame_drop_silhouette_inherits_board_opacity.snap create mode 100644 render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__frame_drop_silhouette_offsets_child_text.snap create mode 100644 render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__group_drop_silhouette_shifts_image_fill_children.snap create mode 100644 render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__large_blur_leaf_drop_shadow_filter_covers_page_not_bbox_percent.snap create mode 100644 render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__leaf_drop_offset_follows_rotation_in_user_space.snap create mode 100644 render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__nested_child_drop_shadow_is_not_refiltered_by_frame.snap create mode 100644 render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__nested_frame_fill_outsets_under_parent_drop_spread.snap create mode 100644 render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__skips_hidden_shadows.snap create mode 100644 render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__sliver_leaf_drop_shadow_filter_covers_page_not_bbox_percent.snap create mode 100644 render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__small_leaf_drop_shadow_filter_covers_page_not_bbox_percent.snap diff --git a/render-wasm/src/render/svg/document.rs b/render-wasm/src/render/svg/document.rs index a36b9ee741..538a91b053 100644 --- a/render-wasm/src/render/svg/document.rs +++ b/render-wasm/src/render/svg/document.rs @@ -1,6 +1,6 @@ use skia_safe::{self as skia, Paint}; -use crate::shapes::{radius_to_sigma, Shape, Type}; +use crate::shapes::{radius_to_sigma, Shadow, Shape, Type}; use crate::state::ShapesPoolRef; use crate::render::vector::draw_shape_geometry; @@ -25,6 +25,17 @@ pub(crate) struct SvgLayerCanvas { pending: Option, next_id: usize, frag_no: usize, + /// When true, skip SVG `` effects so a parent drop-shadow pass can + /// sample silhouettes without nested child shadows (shadow-of-shadow). + pub(super) suppress_filters: bool, + /// Design-space outset applied while drawing a container drop-shadow + /// silhouette. Matches GPU geometric spread (avoids `feMorphology` fattening + /// stroke rings on both edges). + pub(super) silhouette_spread: f32, + /// Design-space drop offset applied in local shape space while drawing a + /// container silhouette (GPU `pre_translate` before rotation). The SVG + /// filter itself uses a zero offset so rotated shadows stay correct. + pub(super) silhouette_offset: (f32, f32), } impl SvgLayerCanvas { @@ -39,9 +50,23 @@ impl SvgLayerCanvas { pending: None, next_id: 0, frag_no: 0, + suppress_filters: false, + silhouette_spread: 0.0, + silhouette_offset: (0.0, 0.0), } } + /// CTM for silhouette geometry: original centered transform, then local + /// drop offset (spread is applied by outsetting selrect separately). + pub(super) fn silhouette_draw_matrix(&self, element: &Shape) -> skia::Matrix { + let mut matrix = element.centered_transform(); + let (dx, dy) = self.silhouette_offset; + if dx != 0.0 || dy != 0.0 { + matrix.pre_translate((dx, dy)); + } + matrix + } + pub(super) fn unique(&mut self, prefix: &str) -> String { let id = format!("{prefix}{}", self.next_id); self.next_id += 1; @@ -103,11 +128,11 @@ impl SvgLayerCanvas { self.out.push_str(markup); } - /// CTM for leaf content placed in page space: Scale * Translate * Centered. - pub(super) fn page_shape_matrix_attr(&self, shape: &Shape) -> String { + /// CTM for leaf content placed in page space: Scale * Translate * `draw_matrix`. + pub(super) fn page_draw_matrix_attr(&self, draw_matrix: &skia::Matrix) -> String { let mut ctm = skia::Matrix::scale((self.scale, self.scale)); ctm = ctm * skia::Matrix::translate((self.tx, self.ty)); - ctm = ctm * shape.centered_transform(); + ctm = ctm * *draw_matrix; format!( "matrix({} {} {} {} {} {})", ctm.scale_x(), @@ -123,6 +148,9 @@ impl SvgLayerCanvas { /// /// A mask can be a group too. Since a group has no geometry of its own, we /// recurse into its descendants and accumulate their geometry. + /// + /// Uses [`Self::silhouette_offset`] so clipped container drop silhouettes + /// move their clip with the offset content (GPU parity). pub(super) fn push_clip_path(&mut self, id: &str, shape: &Shape, tree: ShapesPoolRef) { let canvas = self.new_fragment(); { @@ -130,7 +158,7 @@ impl SvgLayerCanvas { let mut paint = Paint::default(); paint.set_anti_alias(true); paint.set_color(skia::Color::BLACK); - draw_clip_geometry(cv, shape, tree, &paint); + draw_clip_geometry(cv, shape, tree, &paint, self.silhouette_offset); } self.finish_clip_path_fragment(id, canvas); } @@ -152,49 +180,242 @@ impl SvgLayerCanvas { )); } - /// Registers a layer-blur `` and returns its id. + /// Registers a composite effects `` (drop/inner shadows + optional + /// layer blur) matching classic SVG filter order, and returns its id. /// - /// `sigma` is Skia/canvas stdDeviation (`radius_to_sigma(value * scale)`). - /// Padding (±50%) avoids the default 10% objectBoundingBox clip on large blurs. - pub(super) fn push_layer_blur_filter(&mut self, sigma: f32) -> String { - let id = self.unique("blur"); + /// Order: transparent flood → drop shadows → SourceGraphic → inner shadows + /// → layer blur. Shadow blur uses canvas sigma (`radius_to_sigma`); offsets + /// and spread are scaled by the export scale. + /// + /// When `blend_source_graphic` is false, the filter ends after the drop + /// chain (used for container silhouette passes that must not re-emit + /// content — content is drawn in a separate unfiltered group). + pub(super) fn push_effects_filter( + &mut self, + drops: &[&Shadow], + inners: &[&Shadow], + layer_blur_sigma: Option, + scale: f32, + blend_source_graphic: bool, + ) -> String { + let id = self.unique("fx"); + let mut body = String::new(); + body.push_str(r#""#); + let mut prev = "bg".to_string(); + + for (i, shadow) in drops.iter().enumerate() { + let result = format!("drop{i}"); + append_drop_shadow_primitives( + &mut body, shadow, scale, &prev, &result, /* morph_spread */ true, + ); + prev = result; + } + + if blend_source_graphic { + body.push_str(&format!( + r#""# + )); + prev = "shape".to_string(); + + for (i, shadow) in inners.iter().enumerate() { + let result = format!("inner{i}"); + append_inner_shadow_primitives(&mut body, shadow, scale, &prev, &result); + prev = result; + } + + if let Some(sigma) = layer_blur_sigma { + body.push_str(&format!( + r#""# + )); + } + } + self.defs.push_str(&format!( concat!( - "", - "", - "" + "", + "{body}" ), id = id, - sigma = sigma + region = filter_region_attrs(self), + body = body )); id } } +fn color_matrix_values(color: skia::Color) -> String { + let r = f32::from(color.r()) / 255.0; + let g = f32::from(color.g()) / 255.0; + let b = f32::from(color.b()) / 255.0; + let a = f32::from(color.a()) / 255.0; + format!("0 0 0 0 {r} 0 0 0 0 {g} 0 0 0 0 {b} 0 0 0 {a} 0") +} + +/// Filter subregion covering the export page in user space. +/// +/// Default SVG `objectBoundingBox` + `x/y=-50% width/height=200%` is a percent +/// of the *shape* bbox. Shadow reach (offset + blur sigma + spread) is absolute +/// pixels, so small/thin shapes crop the halo. Page bounds already include +/// shadow/blur via `extrect`; sizing the filter to the page matches that. +fn filter_region_attrs(builder: &SvgLayerCanvas) -> String { + let w = builder.page_rect.width(); + let h = builder.page_rect.height(); + format!(r#"filterUnits="userSpaceOnUse" x="0" y="0" width="{w}" height="{h}""#) +} + +fn append_drop_shadow_primitives( + body: &mut String, + shadow: &Shadow, + scale: f32, + in2: &str, + result: &str, + morph_spread: bool, +) { + let sigma = radius_to_sigma(shadow.blur * scale); + // `shadow.offset` must already be in the SVG filter user space (parent of + // the transformed leaf). Callers map local design offsets through + // `centered_transform().map_vector` for rotated/flipped leaves. + let dx = shadow.offset.0 * scale; + let dy = shadow.offset.1 * scale; + let spread = shadow.spread * scale; + let color = color_matrix_values(shadow.color); + + body.push_str( + r#""#, + ); + // Container silhouettes apply spread geometrically (GPU). Morphology on a + // stroke ring expands both edges and makes border shadows look too thick. + let after_morph = if morph_spread && spread > 0.0 { + body.push_str(&format!( + r#""# + )); + "spread" + } else if morph_spread && spread < 0.0 { + body.push_str(&format!( + r#""#, + -spread + )); + "spread" + } else { + "alpha" + }; + body.push_str(&format!( + r#""# + )); + body.push_str(&format!( + r#""# + )); + body.push_str(&format!( + r#""# + )); + body.push_str(&format!( + r#""# + )); +} + +fn append_inner_shadow_primitives( + body: &mut String, + shadow: &Shadow, + scale: f32, + in2: &str, + result: &str, +) { + let sigma = radius_to_sigma(shadow.blur * scale); + let dx = shadow.offset.0 * scale; + let dy = shadow.offset.1 * scale; + let spread = shadow.spread * scale; + let color = color_matrix_values(shadow.color); + + // Classic inner-shadow graph: hard alpha, optional erode for +spread, + // offset+blur, subtract from hard alpha, tint, blend over prior result. + body.push_str( + r#""#, + ); + let morph_in = if spread > 0.0 { + body.push_str(&format!( + r#""# + )); + "spread" + } else { + "hardAlpha" + }; + body.push_str(&format!( + r#""# + )); + body.push_str(&format!( + r#""# + )); + body.push_str( + r#""#, + ); + body.push_str(&format!( + r#""# + )); + body.push_str(&format!( + r#""# + )); +} + /// Draws a clip geometry into `cv` (already set up with the page transform). -fn draw_clip_geometry(cv: &skia::Canvas, shape: &Shape, tree: ShapesPoolRef, paint: &Paint) { +/// +/// `silhouette_offset` is applied in local space (same as +/// [`SvgLayerCanvas::silhouette_draw_matrix`]) so board `clip content` during a +/// container drop pass tracks the shifted silhouette. +fn draw_clip_geometry( + cv: &skia::Canvas, + shape: &Shape, + tree: ShapesPoolRef, + paint: &Paint, + silhouette_offset: (f32, f32), +) { if let Type::Group(_) = &shape.shape_type { for child_id in shape.children_ids_iter_forward(true) { if let Some(child) = tree.get(child_id) { - draw_clip_geometry(cv, child, tree, paint); + draw_clip_geometry(cv, child, tree, paint, silhouette_offset); } } return; } cv.save(); - cv.concat(&shape.centered_transform()); + let mut matrix = shape.centered_transform(); + let (dx, dy) = silhouette_offset; + if dx != 0.0 || dy != 0.0 { + matrix.pre_translate((dx, dy)); + } + cv.concat(&matrix); draw_shape_geometry(cv, shape, paint); cv.restore(); } -/// Builds the `` attribute string for a shape's composite effects (opacity, -/// blend mode, layer blur). Returns `None` when the shape needs no wrapper. +/// Returns a shape whose `selrect` is expanded/shrunk by `outset` (design space). +/// Used for GPU-matching geometric drop-shadow spread. +pub(super) fn shape_with_selrect_outset(shape: &Shape, outset: f32) -> Shape { + let mut out = shape.clone(); + if outset > 0.0 { + out.selrect.outset((outset, outset)); + } else if outset < 0.0 { + out.selrect.inset((-outset, -outset)); + } + out +} + +/// Builds the `` attribute string for a leaf shape's composite effects +/// (opacity, blend mode, drop/inner shadows, layer blur). Returns `None` when +/// the shape needs no wrapper. /// -/// Layer blur is a native SVG `` (SkSVGDevice drops paint image-filters). -/// Shadows still need dedicated re-emission in a later PR. +/// Shadows and layer blur are native SVG ``s — `SkSVGDevice` drops the +/// GPU `save_layer` image-filter path. When `suppress_filters` is set (parent +/// drop-shadow silhouette pass), only opacity/blend are emitted. pub(super) fn effect_attrs(builder: &mut SvgLayerCanvas, element: &Shape) -> Option { + wrapper_attrs(builder, element, EffectFilterMode::LeafComposite) +} + +/// Opacity / blend for a container, wrapping both drop silhouettes and content. +/// +/// Matches GPU `render_shape_enter` (opacity save_layer before shadow composite) +/// so container drop shadows inherit the board's opacity. +pub(super) fn opacity_blend_attrs(element: &Shape) -> Option { let mut parts: Vec = Vec::new(); let opacity = element.opacity(); @@ -206,11 +427,168 @@ pub(super) fn effect_attrs(builder: &mut SvgLayerCanvas, element: &Shape) -> Opt parts.push(format!("style=\"mix-blend-mode:{css}\"")); } - if let Some(blur) = element.visible_layer_blur() { - // Match canvas `Shape::image_filter`: sigma from radius × export scale. - let sigma = radius_to_sigma(blur.value * builder.scale); - let id = builder.push_layer_blur_filter(sigma); - parts.push(format!("filter=\"url(#{id})\"")); + if parts.is_empty() { + None + } else { + Some(parts.join(" ")) + } +} + +/// Inner shadows / layer blur for a container's real content group. +/// +/// Opacity and blend are applied by [`opacity_blend_attrs`] around silhouette + +/// content. Drop shadows are a separate silhouette pass. +pub(super) fn content_effect_attrs( + builder: &mut SvgLayerCanvas, + element: &Shape, +) -> Option { + wrapper_attrs(builder, element, EffectFilterMode::ContentWithoutDrops) +} + +/// Drop-shadow-only filter for one container silhouette pass. +/// +/// Offset and spread are applied geometrically while drawing (local space, +/// matching GPU). The filter only hardens alpha, blurs, and tints. +pub(super) fn push_container_drop_filter(builder: &mut SvgLayerCanvas, shadow: &Shadow) -> String { + let scale = builder.scale; + let id = builder.unique("fx"); + let mut body = String::from(r#""#); + // Zero offset: geometric `pre_translate` already moved the silhouette. + let mut filter_shadow = *shadow; + filter_shadow.offset = (0.0, 0.0); + append_drop_shadow_primitives( + &mut body, + &filter_shadow, + scale, + "bg", + "drop0", + /* morph_spread */ false, + ); + builder.defs.push_str(&format!( + concat!( + "", + "{body}" + ), + id = id, + region = filter_region_attrs(builder), + body = body + )); + id +} + +/// Dilates/erodes glyph alpha for text drawn inside a container drop silhouette. +/// +/// Container drop filters omit `feMorphology` so stroke-ring silhouettes stay +/// thin (morph fattens both edges). Text children still need spread: GPU paints +/// them with `Shadow::get_drop_shadow_filter`, which wraps `drop_shadow_only` +/// in `dilate(spread)` (thicken the already blurred shadow). +/// +/// Approximation: we nest `feMorphology` on the glyph alpha *before* the +/// parent container blur. That is morph-then-blur, not dilate-after-drop like +/// Skia. Close enough for export parity; halo softness can differ slightly. +pub(super) fn push_text_silhouette_spread_filter( + builder: &mut SvgLayerCanvas, + spread: f32, +) -> Option { + let radius = spread * builder.scale; + if radius == 0.0 { + return None; + } + let id = builder.unique("txmorph"); + let (op, r) = if radius > 0.0 { + ("dilate", radius) + } else { + ("erode", -radius) + }; + builder.defs.push_str(&format!( + concat!( + "", + "", + "", + "", + "" + ), + id = id, + region = filter_region_attrs(builder), + op = op, + r = r + )); + Some(id) +} + +#[derive(Clone, Copy)] +enum EffectFilterMode { + /// Flood → drops → SourceGraphic → inners → blur (leaves). + LeafComposite, + /// Flood → SourceGraphic → inners → blur (container content; drops separate). + ContentWithoutDrops, +} + +/// Maps a design-space shadow offset into SVG filter user space. +/// +/// Leaf geometry is drawn with `centered_transform` (rotation / flip). GPU +/// `drop_shadow_only` offsets in that local space, then the CTM maps it. SVG +/// `feOffset` runs after the leaf is painted into the parent group, so the +/// offset must be `map_vector` of the local offset or rotated shadows drift. +fn shadow_with_user_space_offset(shape: &Shape, shadow: &Shadow) -> Shadow { + let mut out = *shadow; + let mapped = shape.centered_transform().map_vector(shadow.offset); + out.offset = (mapped.x, mapped.y); + out +} + +fn wrapper_attrs( + builder: &mut SvgLayerCanvas, + element: &Shape, + mode: EffectFilterMode, +) -> Option { + let mut parts: Vec = Vec::new(); + + // Leaves keep opacity/blend on the same wrapper as their filter. Containers + // use [`opacity_blend_attrs`] outside silhouette + content instead. + if matches!(mode, EffectFilterMode::LeafComposite) { + let opacity = element.opacity(); + if opacity < 1.0 { + parts.push(format!("opacity=\"{opacity}\"")); + } + + if let Some(css) = blend_css(element.blend_mode().0) { + parts.push(format!("style=\"mix-blend-mode:{css}\"")); + } + } + + if !builder.suppress_filters { + let scale = builder.scale; + let mapped_inners: Vec = element + .inner_shadows_visible() + .map(|s| shadow_with_user_space_offset(element, s)) + .collect(); + let inners: Vec<&Shadow> = mapped_inners.iter().collect(); + let layer_blur_sigma = element + .visible_layer_blur() + .map(|blur| radius_to_sigma(blur.value * scale)); + + match mode { + EffectFilterMode::LeafComposite => { + let mapped_drops: Vec = element + .drop_shadows_visible() + .map(|s| shadow_with_user_space_offset(element, s)) + .collect(); + let drops: Vec<&Shadow> = mapped_drops.iter().collect(); + if !drops.is_empty() || !inners.is_empty() || layer_blur_sigma.is_some() { + let id = + builder.push_effects_filter(&drops, &inners, layer_blur_sigma, scale, true); + parts.push(format!("filter=\"url(#{id})\"")); + } + } + EffectFilterMode::ContentWithoutDrops => { + if !inners.is_empty() || layer_blur_sigma.is_some() { + let id = + builder.push_effects_filter(&[], &inners, layer_blur_sigma, scale, true); + parts.push(format!("filter=\"url(#{id})\"")); + } + } + } } if parts.is_empty() { diff --git a/render-wasm/src/render/svg/frames.rs b/render-wasm/src/render/svg/frames.rs index f4f9f2de9c..1a896fc25b 100644 --- a/render-wasm/src/render/svg/frames.rs +++ b/render-wasm/src/render/svg/frames.rs @@ -1,8 +1,11 @@ use crate::error::Result; -use crate::shapes::{Shape, Stroke}; +use crate::shapes::{Shadow, Shape}; use crate::state::ShapesPoolRef; -use super::document::{effect_attrs, SvgLayerCanvas}; +use super::document::{ + content_effect_attrs, opacity_blend_attrs, push_container_drop_filter, + shape_with_selrect_outset, SvgLayerCanvas, +}; use super::images::{emit_fills, emit_strokes}; use super::render_tree; use crate::render::RenderResources; @@ -14,11 +17,61 @@ pub(super) fn render_frame( tree: ShapesPoolRef, scale: f32, ) -> Result<()> { - let effects = effect_attrs(builder, element); + // Opacity/blend wrap silhouette + content (GPU opens the opacity save_layer + // before the shadow composite). + let composite = opacity_blend_attrs(element); + if let Some(attrs) = &composite { + builder.open_group(attrs); + } + + // One silhouette pass per drop: geometric offset+spread in local space + // (GPU), filter only blurs/tints — keeps stroke-ring width and rotation. + if !builder.suppress_filters { + let drops: Vec = element.drop_shadows_visible().copied().collect(); + for shadow in &drops { + let id = push_container_drop_filter(builder, shadow); + builder.open_group(&format!("filter=\"url(#{id})\"")); + let prev_suppress = builder.suppress_filters; + let prev_spread = builder.silhouette_spread; + let prev_offset = builder.silhouette_offset; + builder.suppress_filters = true; + builder.silhouette_spread = shadow.spread; + builder.silhouette_offset = shadow.offset; + render_frame_body(builder, shared, element, tree, scale)?; + builder.silhouette_offset = prev_offset; + builder.silhouette_spread = prev_spread; + builder.suppress_filters = prev_suppress; + builder.close_group(); + } + } + + let effects = content_effect_attrs(builder, element); if let Some(attrs) = &effects { builder.open_group(attrs); } + // Content pass uses builder.silhouette_spread (0 after this frame's own + // drop; still set when nested inside a parent silhouette — matches + // render_leaf so nested board fills outset with inherited spread). + render_frame_body(builder, shared, element, tree, scale)?; + + if effects.is_some() { + builder.close_group(); + } + if composite.is_some() { + builder.close_group(); + } + Ok(()) +} + +fn render_frame_body( + builder: &mut SvgLayerCanvas, + shared: &mut RenderResources, + element: &Shape, + tree: ShapesPoolRef, + scale: f32, +) -> Result<()> { + let spread = builder.silhouette_spread; let clipped = element.clip_content; if clipped { let clip_id = builder.unique("clip"); @@ -26,32 +79,43 @@ pub(super) fn render_frame( builder.open_group(&format!("clip-path=\"url(#{clip_id})\"")); } - // Frame background (frame space), with linked `` for image fills. if !element.fills.is_empty() { - emit_fills(builder, shared, element, &element.fills, tree, scale)?; + // Fills: GPU `fills::render` outsets the rect for drop-shadow spread. + let mask = shape_with_selrect_outset(element, spread); + let matrix = builder.silhouette_draw_matrix(element); + emit_fills( + builder, + shared, + &mask, + &mask.fills, + tree, + scale, + Some(matrix), + )?; } - // Children (absolute coords). let children: Vec<_> = element.children_ids_iter_forward(false).copied().collect(); for child_id in &children { render_tree(builder, shared, child_id, tree, scale)?; } - // Close content clip before strokes. Outer (and half of center) strokes - // extend past the frame bounds; keeping them under clip-path hides them. - // Matches GPU: clipped-frame strokes render in exit without the frame clip. if clipped { builder.close_group(); } - // Strokes over children (frame space), outside the content clip. - let visible_strokes: Vec<&Stroke> = element.visible_strokes().collect(); + // Strokes: GPU ignores Rect/Frame stroke outset for drop spread. Use + // emit_strokes (image + solid) under the silhouette/content CTM. + let visible_strokes: Vec<_> = element.visible_strokes().collect(); if !visible_strokes.is_empty() { - emit_strokes(builder, shared, element, &visible_strokes, scale)?; - } - - if effects.is_some() { - builder.close_group(); + let matrix = builder.silhouette_draw_matrix(element); + emit_strokes( + builder, + shared, + element, + &visible_strokes, + scale, + Some(matrix), + )?; } Ok(()) } diff --git a/render-wasm/src/render/svg/groups.rs b/render-wasm/src/render/svg/groups.rs index cd2359baef..9ce454c122 100644 --- a/render-wasm/src/render/svg/groups.rs +++ b/render-wasm/src/render/svg/groups.rs @@ -1,8 +1,10 @@ use crate::error::Result; -use crate::shapes::Shape; +use crate::shapes::{Shadow, Shape}; use crate::state::ShapesPoolRef; -use super::document::{effect_attrs, SvgLayerCanvas}; +use super::document::{ + content_effect_attrs, opacity_blend_attrs, push_container_drop_filter, SvgLayerCanvas, +}; use super::render_tree; use crate::render::RenderResources; @@ -13,7 +15,33 @@ pub(super) fn render_group( tree: ShapesPoolRef, scale: f32, ) -> Result<()> { - let effects = effect_attrs(builder, element); + // Opacity/blend wrap silhouette + content (GPU opens the opacity save_layer + // before the shadow composite). + let composite = opacity_blend_attrs(element); + if let Some(attrs) = &composite { + builder.open_group(attrs); + } + + if !builder.suppress_filters { + let drops: Vec = element.drop_shadows_visible().copied().collect(); + for shadow in &drops { + let id = push_container_drop_filter(builder, shadow); + builder.open_group(&format!("filter=\"url(#{id})\"")); + let prev_suppress = builder.suppress_filters; + let prev_spread = builder.silhouette_spread; + let prev_offset = builder.silhouette_offset; + builder.suppress_filters = true; + builder.silhouette_spread = shadow.spread; + builder.silhouette_offset = shadow.offset; + render_group_children(builder, shared, element, tree, scale)?; + builder.silhouette_offset = prev_offset; + builder.silhouette_spread = prev_spread; + builder.suppress_filters = prev_suppress; + builder.close_group(); + } + } + + let effects = content_effect_attrs(builder, element); if let Some(attrs) = &effects { builder.open_group(attrs); } @@ -22,14 +50,27 @@ pub(super) fn render_group( // will land in a later PR. For now we still emit the full child list // (including the mask shape as normal content) so basic group opacity // keeps working. - - let children: Vec<_> = element.children_ids_iter_forward(false).copied().collect(); - for child_id in &children { - render_tree(builder, shared, child_id, tree, scale)?; - } + render_group_children(builder, shared, element, tree, scale)?; if effects.is_some() { builder.close_group(); } + if composite.is_some() { + builder.close_group(); + } + Ok(()) +} + +fn render_group_children( + builder: &mut SvgLayerCanvas, + shared: &mut RenderResources, + element: &Shape, + tree: ShapesPoolRef, + scale: f32, +) -> Result<()> { + let children: Vec<_> = element.children_ids_iter_forward(false).copied().collect(); + for child_id in &children { + render_tree(builder, shared, child_id, tree, scale)?; + } Ok(()) } diff --git a/render-wasm/src/render/svg/images.rs b/render-wasm/src/render/svg/images.rs index e072100758..eadef967af 100644 --- a/render-wasm/src/render/svg/images.rs +++ b/render-wasm/src/render/svg/images.rs @@ -15,6 +15,9 @@ use crate::render::RenderResources; /// Non-image fills go through Skia's SVG canvas. Image fills with a registered /// source URL become native linked `` elements (see `store_image_url`); /// without a URL they fall back to Skia (base64-embed) when a CPU image exists. +/// +/// `draw_matrix` is the leaf CTM (container drop silhouettes pass a local-offset +/// matrix so linked images move with solid fills). pub(super) fn emit_fills( builder: &mut SvgLayerCanvas, shared: &mut RenderResources, @@ -22,19 +25,20 @@ pub(super) fn emit_fills( fills: &[Fill], tree: ShapesPoolRef, scale: f32, + draw_matrix: Option, ) -> Result<()> { if fills.is_empty() { return Ok(()); } + let matrix = draw_matrix.unwrap_or_else(|| shape.centered_transform()); // fills[0] is the topmost layer; draw bottom → top. for fill in fills.iter().rev() { match fill { Fill::Image(image_fill) if shared.images.source_url(&image_fill.id()).is_some() => { - emit_image_fill(builder, shared, shape, image_fill, tree)?; + emit_image_fill(builder, shared, shape, image_fill, tree, matrix)?; } fill => { - let matrix = shape.centered_transform(); let canvas = builder.canvas(); canvas.save(); canvas.concat(&matrix); @@ -58,16 +62,27 @@ fn emit_image_fill( shape: &Shape, image_fill: &ImageFill, tree: ShapesPoolRef, + draw_matrix: skia_safe::Matrix, ) -> Result<()> { let Some(url) = shared.images.source_url(&image_fill.id()) else { return Ok(()); }; let clip_id = builder.unique("imgclip"); + // Clip uses builder.silhouette_offset (same space as draw_matrix during a + // container drop silhouette pass). builder.push_clip_path(&clip_id, shape, tree); let href = xml_escape_attr(url); let dest_rect = get_image_dest_rect(&shape.selrect(), image_fill); - emit_linked_image_element(builder, shape, image_fill, dest_rect, &href, &clip_id); + emit_linked_image_element( + builder, + shape, + image_fill, + dest_rect, + &href, + &clip_id, + draw_matrix, + ); Ok(()) } @@ -76,23 +91,27 @@ fn emit_image_fill( /// Image strokes with a registered URL become a linked `` clipped to the /// stroke silhouette (Skia drops the GPU save_layer + SrcIn path). Other strokes /// go through [`VectorRenderer`]. +/// +/// `draw_matrix` overrides the leaf CTM (container drop silhouettes pass a +/// local-offset matrix; `None` uses `centered_transform`). pub(super) fn emit_strokes( builder: &mut SvgLayerCanvas, shared: &mut RenderResources, shape: &Shape, strokes: &[&Stroke], scale: f32, + draw_matrix: Option, ) -> Result<()> { if strokes.is_empty() { return Ok(()); } - let matrix = shape.centered_transform(); + let matrix = draw_matrix.unwrap_or_else(|| shape.centered_transform()); // strokes[0] is topmost; draw bottom -> top. for stroke in strokes.iter().rev() { match &stroke.fill { Fill::Image(image_fill) if shared.images.source_url(&image_fill.id()).is_some() => { - emit_image_stroke(builder, shared, shape, stroke, image_fill, scale)?; + emit_image_stroke(builder, shared, shape, stroke, image_fill, scale, matrix)?; } _ => { let canvas = builder.canvas(); @@ -115,6 +134,7 @@ fn emit_image_stroke( stroke: &Stroke, image_fill: &ImageFill, scale: f32, + draw_matrix: skia_safe::Matrix, ) -> Result<()> { let Some(url) = shared.images.source_url(&image_fill.id()) else { return Ok(()); @@ -125,7 +145,7 @@ fn emit_image_stroke( { let cv: &skia_safe::Canvas = &canvas; cv.save(); - cv.concat(&shape.centered_transform()); + cv.concat(&draw_matrix); if !paint_svg_stroke_silhouette(cv, shape, stroke, scale) { cv.restore(); return Ok(()); @@ -136,7 +156,15 @@ fn emit_image_stroke( let href = xml_escape_attr(url); let dest = image_stroke_dest_rect(shape, stroke); - emit_linked_image_element(builder, shape, image_fill, dest, &href, &clip_id); + emit_linked_image_element( + builder, + shape, + image_fill, + dest, + &href, + &clip_id, + draw_matrix, + ); Ok(()) } @@ -160,13 +188,17 @@ fn image_stroke_dest_rect(shape: &Shape, stroke: &Stroke) -> MathRect { } /// Emits `` + `` at `dest_rect`, under the page CTM. +/// +/// `draw_matrix` is the shape-local CTM (must include container drop silhouette +/// offset when drawing under a parent shadow filter). pub(super) fn emit_linked_image_element( builder: &mut SvgLayerCanvas, - shape: &Shape, + _shape: &Shape, image_fill: &ImageFill, dest_rect: MathRect, href: &str, clip_id: &str, + draw_matrix: skia_safe::Matrix, ) { let opacity = image_fill.opacity() as f32 / 255.0; let preserve = if image_fill.keep_aspect_ratio() { @@ -174,7 +206,7 @@ pub(super) fn emit_linked_image_element( } else { "none" }; - let transform = builder.page_shape_matrix_attr(shape); + let transform = builder.page_draw_matrix_attr(&draw_matrix); let opacity_attr = if (opacity - 1.0).abs() < f32::EPSILON { String::new() diff --git a/render-wasm/src/render/svg/mod.rs b/render-wasm/src/render/svg/mod.rs index e3dde7cf84..95d49b7d34 100644 --- a/render-wasm/src/render/svg/mod.rs +++ b/render-wasm/src/render/svg/mod.rs @@ -60,8 +60,8 @@ fn svg_page_bounds(shape: &Shape, tree: ShapesPoolRef, scale: f32) -> skia::Rect /// composed as native SVG `` wrappers. Frame `clip content` uses a native /// ``. /// -/// Layer blur is re-emitted as a native SVG `feGaussianBlur` filter wrapper. -/// Shadows, masks, and text strokes still need dedicated SVG re-emission. +/// Layer blur and drop/inner shadows are re-emitted as a native SVG `` +/// wrapper. Masks and text strokes still need dedicated SVG re-emission. /// Solid Inner/Outer and dotted/dashed strokes go out as filled outlines; /// image-filled strokes use a linked `` clipped to the stroke. pub fn render_to_svg( @@ -135,7 +135,7 @@ use frames::render_frame; use groups::render_group; use text::render_text_fill; -use document::effect_attrs; +use document::{effect_attrs, push_text_silhouette_spread_filter, shape_with_selrect_outset}; use images::{emit_fills, emit_strokes}; /// Renders `id`'s subtree to an SVG body, returning `(defs, body)`. @@ -193,40 +193,58 @@ fn render_leaf( } { + let spread = builder.silhouette_spread; + // Spread outsets fills only (GPU). Rect/Frame strokes ignore outset. + // Text keeps its selrect: GPU dilates shadow alpha, not layout bounds. + let fill_shape = shape_with_selrect_outset(element, spread); + // Always from the original element (not outset selrect) so the pivot + // matches content; offset comes from the parent silhouette pass. + let draw_matrix = builder.silhouette_draw_matrix(element); if matches!(element.shape_type, Type::Text(_)) { - render_text_fill(builder, shared, element)?; + // See `push_text_silhouette_spread_filter`: morph-before-blur approx + // of GPU dilate(drop_shadow) for inherited container spread. + let morph_id = push_text_silhouette_spread_filter(builder, spread); + if let Some(id) = &morph_id { + builder.open_group(&format!("filter=\"url(#{id})\"")); + } + render_text_fill(builder, shared, element, draw_matrix)?; + if morph_id.is_some() { + builder.close_group(); + } } else if matches!(element.shape_type, Type::SVGRaw(_)) { - let matrix = element.centered_transform(); let canvas = builder.canvas(); canvas.save(); - canvas.concat(&matrix); + canvas.concat(&draw_matrix); let mut renderer = VectorRenderer::new(canvas, shared, scale, false); renderer.draw_svg(element)?; canvas.restore(); } else { - emit_fills(builder, shared, element, &element.fills, tree, scale)?; + emit_fills( + builder, + shared, + &fill_shape, + &fill_shape.fills, + tree, + scale, + Some(draw_matrix), + )?; - let matrix = element.centered_transform(); - let canvas = builder.canvas(); - canvas.save(); - canvas.concat(&matrix); - let mut renderer = VectorRenderer::new(canvas, shared, scale, false); - renderer.draw_fill_inner_shadows(element)?; - canvas.restore(); + // Drop/inner shadows are native SVG filters on the effects `` — + // do not draw them via Skia image-filters (SkSVGDevice drops them). + // Stroke geometry stays on the original selrect (GPU Rect/Frame + // drop-shadow outset is a no-op for single strokes). Image strokes + // go through emit_strokes (linked + stroke clip). let visible_strokes: Vec<_> = element.visible_strokes().collect(); if !visible_strokes.is_empty() { - emit_strokes(builder, shared, element, &visible_strokes, scale)?; - if !element.has_fills() { - let canvas = builder.canvas(); - canvas.save(); - canvas.concat(&matrix); - let mut renderer = VectorRenderer::new(canvas, shared, scale, false); - for stroke in &visible_strokes { - renderer.draw_stroke_inner_shadows(element, stroke)?; - } - canvas.restore(); - } + emit_strokes( + builder, + shared, + element, + &visible_strokes, + scale, + Some(draw_matrix), + )?; } } } diff --git a/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__clipped_frame_drop_shadow_clip_follows_silhouette_offset.snap b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__clipped_frame_drop_shadow_clip_follows_silhouette_offset.snap new file mode 100644 index 0000000000..9b8a4c38bf --- /dev/null +++ b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__clipped_frame_drop_shadow_clip_follows_silhouette_offset.snap @@ -0,0 +1,16 @@ +--- +source: src/render/svg/tests.rs +expression: svg +--- + + + + + + + + + + + + diff --git a/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_frame_drop_shadow_wrapping_children.snap b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_frame_drop_shadow_wrapping_children.snap new file mode 100644 index 0000000000..62a272f4f6 --- /dev/null +++ b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_frame_drop_shadow_wrapping_children.snap @@ -0,0 +1,12 @@ +--- +source: src/render/svg/tests.rs +expression: svg +--- + + + + + + + + diff --git a/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_group_layer_blur_wrapping_children.snap b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_group_layer_blur_wrapping_children.snap index c34757a0ad..150b067273 100644 --- a/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_group_layer_blur_wrapping_children.snap +++ b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_group_layer_blur_wrapping_children.snap @@ -3,7 +3,7 @@ source: src/render/svg/tests.rs expression: svg --- - + diff --git a/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_leaf_drop_shadow_as_svg_filter.snap b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_leaf_drop_shadow_as_svg_filter.snap new file mode 100644 index 0000000000..ef061a4ca1 --- /dev/null +++ b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_leaf_drop_shadow_as_svg_filter.snap @@ -0,0 +1,8 @@ +--- +source: src/render/svg/tests.rs +expression: svg +--- + + + + diff --git a/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_leaf_inner_shadow_as_svg_filter.snap b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_leaf_inner_shadow_as_svg_filter.snap new file mode 100644 index 0000000000..feb3c531dd --- /dev/null +++ b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_leaf_inner_shadow_as_svg_filter.snap @@ -0,0 +1,8 @@ +--- +source: src/render/svg/tests.rs +expression: svg +--- + + + + diff --git a/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_leaf_layer_blur_as_fe_gaussian_blur.snap b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_leaf_layer_blur_as_fe_gaussian_blur.snap index 208edb618a..bd7accb01e 100644 --- a/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_leaf_layer_blur_as_fe_gaussian_blur.snap +++ b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_leaf_layer_blur_as_fe_gaussian_blur.snap @@ -3,6 +3,6 @@ source: src/render/svg/tests.rs expression: svg --- - + diff --git a/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__fill_less_frame_drop_shadow_ignores_stroke_spread_outset.snap b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__fill_less_frame_drop_shadow_ignores_stroke_spread_outset.snap new file mode 100644 index 0000000000..9f53cc6a9e --- /dev/null +++ b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__fill_less_frame_drop_shadow_ignores_stroke_spread_outset.snap @@ -0,0 +1,10 @@ +--- +source: src/render/svg/tests.rs +expression: svg +--- + + + + + + diff --git a/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__frame_drop_silhouette_inherits_board_opacity.snap b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__frame_drop_silhouette_inherits_board_opacity.snap new file mode 100644 index 0000000000..08ad2cf9a2 --- /dev/null +++ b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__frame_drop_silhouette_inherits_board_opacity.snap @@ -0,0 +1,10 @@ +--- +source: src/render/svg/tests.rs +expression: svg +--- + + + + + + diff --git a/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__frame_drop_silhouette_offsets_child_text.snap b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__frame_drop_silhouette_offsets_child_text.snap new file mode 100644 index 0000000000..b9b389c939 --- /dev/null +++ b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__frame_drop_silhouette_offsets_child_text.snap @@ -0,0 +1,18 @@ +--- +source: src/render/svg/tests.rs +expression: svg +--- + + + + HOLA + + + + + + HOLA + + + + diff --git a/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__group_drop_silhouette_shifts_image_fill_children.snap b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__group_drop_silhouette_shifts_image_fill_children.snap new file mode 100644 index 0000000000..17199291d0 --- /dev/null +++ b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__group_drop_silhouette_shifts_image_fill_children.snap @@ -0,0 +1,14 @@ +--- +source: src/render/svg/tests.rs +expression: svg +--- + + + + + + + + + + diff --git a/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__large_blur_leaf_drop_shadow_filter_covers_page_not_bbox_percent.snap b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__large_blur_leaf_drop_shadow_filter_covers_page_not_bbox_percent.snap new file mode 100644 index 0000000000..793d98f3ca --- /dev/null +++ b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__large_blur_leaf_drop_shadow_filter_covers_page_not_bbox_percent.snap @@ -0,0 +1,8 @@ +--- +source: src/render/svg/tests.rs +expression: svg +--- + + + + diff --git a/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__leaf_drop_offset_follows_rotation_in_user_space.snap b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__leaf_drop_offset_follows_rotation_in_user_space.snap new file mode 100644 index 0000000000..644760473e --- /dev/null +++ b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__leaf_drop_offset_follows_rotation_in_user_space.snap @@ -0,0 +1,8 @@ +--- +source: src/render/svg/tests.rs +expression: svg +--- + + + + diff --git a/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__nested_child_drop_shadow_is_not_refiltered_by_frame.snap b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__nested_child_drop_shadow_is_not_refiltered_by_frame.snap new file mode 100644 index 0000000000..70bcafea7f --- /dev/null +++ b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__nested_child_drop_shadow_is_not_refiltered_by_frame.snap @@ -0,0 +1,13 @@ +--- +source: src/render/svg/tests.rs +expression: svg +--- + + + + + + + + + diff --git a/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__nested_frame_fill_outsets_under_parent_drop_spread.snap b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__nested_frame_fill_outsets_under_parent_drop_spread.snap new file mode 100644 index 0000000000..d014aed9ed --- /dev/null +++ b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__nested_frame_fill_outsets_under_parent_drop_spread.snap @@ -0,0 +1,12 @@ +--- +source: src/render/svg/tests.rs +expression: svg +--- + + + + + + + + diff --git a/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__skips_hidden_shadows.snap b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__skips_hidden_shadows.snap new file mode 100644 index 0000000000..ed30b2c40d --- /dev/null +++ b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__skips_hidden_shadows.snap @@ -0,0 +1,8 @@ +--- +source: src/render/svg/tests.rs +expression: svg +--- + + + + diff --git a/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__sliver_leaf_drop_shadow_filter_covers_page_not_bbox_percent.snap b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__sliver_leaf_drop_shadow_filter_covers_page_not_bbox_percent.snap new file mode 100644 index 0000000000..ed1a8b3558 --- /dev/null +++ b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__sliver_leaf_drop_shadow_filter_covers_page_not_bbox_percent.snap @@ -0,0 +1,8 @@ +--- +source: src/render/svg/tests.rs +expression: svg +--- + + + + diff --git a/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__small_leaf_drop_shadow_filter_covers_page_not_bbox_percent.snap b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__small_leaf_drop_shadow_filter_covers_page_not_bbox_percent.snap new file mode 100644 index 0000000000..6f867f5750 --- /dev/null +++ b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__small_leaf_drop_shadow_filter_covers_page_not_bbox_percent.snap @@ -0,0 +1,8 @@ +--- +source: src/render/svg/tests.rs +expression: svg +--- + + + + diff --git a/render-wasm/src/render/svg/tests.rs b/render-wasm/src/render/svg/tests.rs index b7048272a0..06a9dcea7f 100644 --- a/render-wasm/src/render/svg/tests.rs +++ b/render-wasm/src/render/svg/tests.rs @@ -1,8 +1,8 @@ use super::fixtures::*; use crate::shapes::{ - radius_to_sigma, BlendMode, Blur, BlurType, Fill, ImageFill, ImageFillTransform, SolidColor, - StrokeCap, StrokeKind, + radius_to_sigma, BlendMode, Blur, BlurType, Fill, ImageFill, ImageFillTransform, Shadow, + ShadowStyle, SolidColor, StrokeCap, StrokeKind, }; use crate::state::ShapesPool; use crate::uuid::Uuid; @@ -114,8 +114,8 @@ fn exports_leaf_layer_blur_as_fe_gaussian_blur() { "stdDeviation must match canvas radius_to_sigma(value * scale): {svg}" ); assert!( - svg.contains("filter=\"url(#blur"), - "shape group must reference the blur filter: {svg}" + svg.contains("filter=\"url(#fx"), + "shape group must reference the effects filter: {svg}" ); insta::assert_snapshot!(svg); } @@ -138,7 +138,7 @@ fn skips_hidden_layer_blur() { let svg = render(&pool, id); assert!( - !svg.contains("feGaussianBlur") && !svg.contains("filter=\"url(#blur"), + !svg.contains("feGaussianBlur") && !svg.contains("filter=\"url(#fx"), "hidden layer blur must not emit a filter: {svg}" ); insta::assert_snapshot!(svg); @@ -185,9 +185,7 @@ fn exports_group_layer_blur_wrapping_children() { "group layer blur stdDeviation: {svg}" ); // Filter wrapper must open before child geometry. - let filter_pos = svg - .find("filter=\"url(#blur") - .expect("group filter wrapper"); + let filter_pos = svg.find("filter=\"url(#fx").expect("group filter wrapper"); let child_pos = svg.find("fill=\"#").expect("child fill"); assert!( filter_pos < child_pos, @@ -196,6 +194,843 @@ fn exports_group_layer_blur_wrapping_children() { insta::assert_snapshot!(svg); } +#[test] +fn exports_leaf_drop_shadow_as_svg_filter() { + let mut pool = ShapesPool::new(); + let id = uid(1); + add_solid_rect( + &mut pool, + id, + Uuid::nil(), + (0.0, 0.0, 100.0, 80.0), + skia::Color::from_rgb(255, 0, 0), + ); + { + let shape = pool.get_mut(&id).unwrap(); + shape.add_shadow(Shadow::new( + skia::Color::from_argb(128, 0, 0, 0), + 8.0, + 0.0, + (4.0, 6.0), + ShadowStyle::Drop, + false, + )); + } + + let svg = render(&pool, id); + let expected_sigma = radius_to_sigma(8.0); + assert!( + svg.contains("feOffset") && svg.contains(r#"dx="4""#) && svg.contains(r#"dy="6""#), + "drop shadow must offset: {svg}" + ); + assert!( + svg.contains(&format!("stdDeviation=\"{expected_sigma}\"")), + "drop blur sigma must match canvas: {svg}" + ); + assert!( + svg.contains("filter=\"url(#fx") && svg.contains("SourceGraphic"), + "drop shadow filter must blend SourceGraphic: {svg}" + ); + insta::assert_snapshot!(svg); +} + +#[test] +fn leaf_drop_offset_follows_rotation_in_user_space() { + let mut pool = ShapesPool::new(); + let id = uid(1); + add_solid_rect( + &mut pool, + id, + Uuid::nil(), + (0.0, 0.0, 100.0, 80.0), + skia::Color::from_rgb(255, 0, 0), + ); + { + let shape = pool.get_mut(&id).unwrap(); + // 90° CCW: local (+10, 0) → user-space (0, 10). + let (c, s) = (0.0_f32, 1.0_f32); + shape.set_transform(c, s, -s, c, 0.0, 0.0); + shape.set_rotation(90.0); + shape.add_shadow(Shadow::new( + skia::Color::from_argb(128, 56, 0, 238), + 0.0, + 0.0, + (10.0, 0.0), + ShadowStyle::Drop, + false, + )); + } + + let svg = render(&pool, id); + assert!( + svg.contains(r#"dx="0""#) && svg.contains(r#"dy="10""#), + "rotated leaf drop must map local offset into filter user space: {svg}" + ); + assert!( + !svg.contains(r#"dx="10""#), + "must not keep unmapped local dx for rotated leaf: {svg}" + ); + insta::assert_snapshot!(svg); +} + +#[test] +fn small_leaf_drop_shadow_filter_covers_page_not_bbox_percent() { + // 16×16 + offset(4,4) blur 4: objectBoundingBox ±50% only leaves 8px margin, + // but reach is ~|offset|+3σ ≈ 12px — corners crop unless the filter is + // sized in userSpaceOnUse to the page (already padded via extrect). + let mut pool = ShapesPool::new(); + let id = uid(1); + add_solid_rect( + &mut pool, + id, + Uuid::nil(), + (0.0, 0.0, 16.0, 16.0), + skia::Color::from_rgb(61, 123, 255), + ); + { + let shape = pool.get_mut(&id).unwrap(); + shape.add_shadow(Shadow::new( + skia::Color::from_argb(153, 0, 0, 0), + 4.0, + 0.0, + (4.0, 4.0), + ShadowStyle::Drop, + false, + )); + } + + let svg = render(&pool, id); + assert_filter_covers_page(&svg); + insta::assert_snapshot!(svg); +} + +#[test] +fn large_blur_leaf_drop_shadow_filter_covers_page_not_bbox_percent() { + // 100×100 blur 40: ±50% of bbox = 50px, 3σ≈71px — halo crops with + // objectBoundingBox percentages. + let mut pool = ShapesPool::new(); + let id = uid(1); + add_solid_rect( + &mut pool, + id, + Uuid::nil(), + (0.0, 0.0, 100.0, 100.0), + skia::Color::from_rgb(61, 123, 255), + ); + { + let shape = pool.get_mut(&id).unwrap(); + shape.add_shadow(Shadow::new( + skia::Color::from_argb(178, 0, 0, 0), + 40.0, + 0.0, + (0.0, 0.0), + ShadowStyle::Drop, + false, + )); + } + + let svg = render(&pool, id); + assert_filter_covers_page(&svg); + let sigma = radius_to_sigma(40.0); + assert!( + svg.contains(&format!("stdDeviation=\"{sigma}\"")), + "large blur sigma must remain in the filter: {svg}" + ); + insta::assert_snapshot!(svg); +} + +#[test] +fn sliver_leaf_drop_shadow_filter_covers_page_not_bbox_percent() { + // 200×2 + offset(0,12) blur 6: objectBoundingBox height is only 4px — + // the shadow disappears. userSpaceOnUse page region keeps it. + let mut pool = ShapesPool::new(); + let id = uid(1); + add_solid_rect( + &mut pool, + id, + Uuid::nil(), + (0.0, 0.0, 200.0, 2.0), + skia::Color::from_rgb(61, 123, 255), + ); + { + let shape = pool.get_mut(&id).unwrap(); + shape.add_shadow(Shadow::new( + skia::Color::from_argb(204, 0, 0, 0), + 6.0, + 0.0, + (0.0, 12.0), + ShadowStyle::Drop, + false, + )); + } + + let svg = render(&pool, id); + assert_filter_covers_page(&svg); + assert!( + svg.contains(r#"dy="12""#), + "sliver drop must keep its offset: {svg}" + ); + insta::assert_snapshot!(svg); +} + +#[test] +fn exports_leaf_inner_shadow_as_svg_filter() { + let mut pool = ShapesPool::new(); + let id = uid(1); + add_solid_rect( + &mut pool, + id, + Uuid::nil(), + (0.0, 0.0, 100.0, 80.0), + skia::Color::from_rgb(0, 128, 255), + ); + { + let shape = pool.get_mut(&id).unwrap(); + shape.add_shadow(Shadow::new( + skia::Color::from_argb(180, 0, 0, 0), + 6.0, + 0.0, + (2.0, 3.0), + ShadowStyle::Inner, + false, + )); + } + + let svg = render(&pool, id); + assert!( + svg.contains("feComposite") && svg.contains("hardAlpha"), + "inner shadow must use classic composite graph: {svg}" + ); + assert!( + svg.contains("filter=\"url(#fx"), + "inner shadow must wrap the shape: {svg}" + ); + insta::assert_snapshot!(svg); +} + +#[test] +fn skips_hidden_shadows() { + let mut pool = ShapesPool::new(); + let id = uid(1); + add_solid_rect( + &mut pool, + id, + Uuid::nil(), + (0.0, 0.0, 100.0, 80.0), + skia::Color::from_rgb(255, 0, 0), + ); + { + let shape = pool.get_mut(&id).unwrap(); + shape.add_shadow(Shadow::new( + skia::Color::BLACK, + 8.0, + 0.0, + (4.0, 4.0), + ShadowStyle::Drop, + true, + )); + } + + let svg = render(&pool, id); + assert!( + !svg.contains("feOffset") && !svg.contains("filter=\"url(#fx"), + "hidden shadow must not emit a filter: {svg}" + ); + insta::assert_snapshot!(svg); +} + +#[test] +fn exports_frame_drop_shadow_wrapping_children() { + let mut pool = ShapesPool::new(); + let frame_id = uid(1); + let child = uid(2); + add_frame( + &mut pool, + frame_id, + Uuid::nil(), + (0.0, 0.0, 200.0, 120.0), + skia::Color::from_rgb(240, 240, 240), + false, + ); + { + let frame = pool.get_mut(&frame_id).unwrap(); + frame.add_shadow(Shadow::new( + skia::Color::from_argb(100, 0, 0, 0), + 10.0, + 0.0, + (0.0, 8.0), + ShadowStyle::Drop, + false, + )); + } + add_solid_rect( + &mut pool, + child, + frame_id, + (20.0, 20.0, 100.0, 80.0), + skia::Color::from_rgb(0, 200, 0), + ); + { + let frame = pool.get_mut(&frame_id).unwrap(); + frame.add_child(child); + } + + let svg = render(&pool, frame_id); + assert!( + svg.contains("filter=\"url(#fx"), + "frame drop shadow must emit a filter: {svg}" + ); + assert!( + !svg.contains("SourceGraphic"), + "container drop filter must be shadow-only (no SourceGraphic): {svg}" + ); + // Silhouette under the filter, then real content without nesting the filter. + let filter_pos = svg.find("filter=\"url(#fx").expect("frame filter"); + let child_pos = svg.find("fill=\"#").expect("child fill"); + assert!( + filter_pos < child_pos, + "frame shadow silhouette must precede content: {svg}" + ); + let fill_count = svg.matches("fill=\"#").count(); + assert!( + fill_count >= 2, + "silhouette + content must both draw fills: {svg}" + ); + insta::assert_snapshot!(svg); +} + +#[test] +fn clipped_frame_drop_shadow_clip_follows_silhouette_offset() { + // clip=ON + drop offset: silhouette fills/children move with + // silhouette_draw_matrix, so the board clipPath must move too — otherwise + // the unshifted clip truncates the shadow (F1a / show-content=false). + let mut pool = ShapesPool::new(); + let frame_id = uid(1); + let child = uid(2); + add_frame( + &mut pool, + frame_id, + Uuid::nil(), + (0.0, 0.0, 200.0, 120.0), + skia::Color::from_rgb(240, 240, 240), + true, + ); + { + let frame = pool.get_mut(&frame_id).unwrap(); + frame.add_shadow(Shadow::new( + skia::Color::from_argb(140, 0, 0, 0), + 0.0, + 0.0, + (0.0, 24.0), + ShadowStyle::Drop, + false, + )); + } + add_solid_rect( + &mut pool, + child, + frame_id, + (20.0, 20.0, 180.0, 100.0), + skia::Color::from_rgb(0, 200, 0), + ); + { + let frame = pool.get_mut(&frame_id).unwrap(); + frame.add_child(child); + } + + let svg = render(&pool, frame_id); + assert!( + svg.contains("filter=\"url(#fx"), + "clipped frame drop shadow must emit a filter: {svg}" + ); + assert!( + svg.matches("= 2, + "silhouette and content each need a clipPath: {svg}" + ); + + let filter_open = svg.find("filter=\"url(#fx").expect("frame filter"); + let filter_close = svg[filter_open..] + .find("") + .map(|i| filter_open + i) + .expect("silhouette group close"); + let silhouette = &svg[filter_open..=filter_close]; + let clip_ref = silhouette + .find("clip-path=\"url(#") + .and_then(|i| { + let start = i + "clip-path=\"url(#".len(); + let end = silhouette[start..].find(')')?; + Some(&silhouette[start..start + end]) + }) + .expect("silhouette must reference a clipPath"); + + let clip_def_start = svg + .find(&format!("") + .map(|i| clip_def_start + i) + .expect("clipPath close"); + let clip_geom = &svg[clip_def_start..clip_def_end]; + + // Content clip (second clipPath) stays unshifted; silhouette clip must + // carry the local drop offset (0, 24) like silhouette fills. + assert!( + clip_geom.contains("translate(") && clip_geom.contains(" 24"), + "silhouette clipPath must follow drop offset (0,24): {clip_geom}\nfull: {svg}" + ); + insta::assert_snapshot!(svg); +} + +#[test] +fn group_drop_silhouette_shifts_image_fill_children() { + // Container drop silhouettes must offset linked fills the same way + // as solid fills (F3). Clip already follows silhouette_offset; the image + // CTM must use draw_matrix too. + let mut pool = ShapesPool::new(); + let group_id = uid(1); + let image_child = uid(2); + let solid_child = uid(3); + let image_id = uid(42); + + add_image_rect( + &mut pool, + image_child, + group_id, + (0.0, 0.0, 120.0, 120.0), + image_id, + true, + 255, + ); + add_solid_rect( + &mut pool, + solid_child, + group_id, + (170.0, 0.0, 290.0, 120.0), + skia::Color::from_rgb(61, 123, 255), + ); + add_group( + &mut pool, + group_id, + Uuid::nil(), + (0.0, 0.0, 290.0, 120.0), + &[image_child, solid_child], + ); + { + let group = pool.get_mut(&group_id).unwrap(); + group.add_shadow(Shadow::new( + skia::Color::from_rgb(229, 16, 35), + 0.0, + 0.0, + (40.0, 40.0), + ShadowStyle::Drop, + false, + )); + } + + let svg = render_with(&pool, group_id, |resources| { + resources + .images + .set_source_url(image_id, TEST_IMAGE_URL.to_string()); + }); + + assert!( + svg.contains("filter=\"url(#fx"), + "group drop shadow must emit a filter: {svg}" + ); + + let filter_open = svg.find("filter=\"url(#fx").expect("group filter"); + let silhouette = &svg[filter_open..]; + // Content pass repeats the image without the silhouette offset matrix. + let content_image = silhouette + .match_indices(" &svg[filter_open..end], + None => silhouette, + }; + + assert!( + silhouette.contains(" = svg.match_indices("filter=\"url(#fx").collect(); + assert_eq!( + filter_attrs.len(), + 2, + "expect frame silhouette filter + child content filter only: {svg}" + ); + // Child's filter must not sit inside the frame's filtered group. + let frame_filter_open = svg.find("") + .map(|i| frame_filter_open + i) + .expect("close frame filter group"); + let child_filter = svg.rfind(" frame_filter_close, + "child drop filter must be outside frame drop group to avoid shadow-of-shadow: {svg}" + ); + assert!( + svg.contains(r#"dx="10""#), + "child leaf drop must keep filter offset: {svg}" + ); + // Frame container drops apply offset geometrically (filter dx=0). + assert!( + svg.contains(r#"dx="0""#) || svg.matches(r#"dx=""#).count() >= 1, + "frame drop filter must not re-offset in user space: {svg}" + ); + // Stroke-ring silhouette (border shadow), not a solid board fill. + let silhouette = &svg[frame_filter_open..=frame_filter_close]; + assert!( + silhouette.contains("fill-rule=\"evenodd\"") || silhouette.contains("") + .map(|i| frame_filter_open + i) + .expect("close frame filter group"); + let silhouette = &svg[frame_filter_open..=frame_filter_close]; + assert!( + silhouette.contains("") + .map(|i| content_text_start + i) + .expect("content text end"); + let content_text = &content[content_text_start..=content_text_end]; + assert!( + !content_text.contains("translate(20 20)"), + "content text must not carry silhouette offset: {content_text}" + ); + insta::assert_snapshot!(svg); +} + +#[test] +fn fill_less_frame_drop_shadow_ignores_stroke_spread_outset() { + let mut pool = ShapesPool::new(); + let frame_id = uid(1); + add_frame( + &mut pool, + frame_id, + Uuid::nil(), + (0.0, 0.0, 200.0, 120.0), + skia::Color::TRANSPARENT, + false, + ); + { + let frame = pool.get_mut(&frame_id).unwrap(); + frame.clear_fills(); + frame.add_stroke(solid_stroke(StrokeKind::Inner, 5.0, skia::Color::BLACK)); + frame.add_shadow(Shadow::new( + skia::Color::from_argb(128, 229, 16, 35), + 4.0, + 4.0, + (20.0, 20.0), + ShadowStyle::Drop, + false, + )); + } + + let svg = render(&pool, frame_id); + assert!( + !svg.contains("feMorphology"), + "container drop must not use feMorphology: {svg}" + ); + assert!( + svg.contains(r#"dx="0""#) && svg.contains("feGaussianBlur"), + "container drop filter must blur only (offset is geometric): {svg}" + ); + let filter_open = svg.find("") + .map(|i| filter_open + i) + .expect("close drop group"); + let silhouette = &svg[filter_open..=filter_close]; + assert!( + silhouette.contains("fill-rule=\"evenodd\""), + "silhouette must stay a stroke ring: {silhouette}" + ); + // GPU ignores Rect/Frame stroke outset — ring must match content selrect + // (200×120), not an expanded 208×128 path. + assert!( + silhouette.contains("M200 ") || silhouette.contains("L200 "), + "stroke silhouette must not grow with spread: {silhouette}" + ); + assert!( + !silhouette.contains("M204 ") && !silhouette.contains("L204 "), + "spread must not outset frame stroke geometry: {silhouette}" + ); + insta::assert_snapshot!(svg); +} + #[test] fn exports_a_group_with_two_rects_and_group_opacity() { let mut pool = ShapesPool::new(); @@ -1369,6 +2204,32 @@ fn exports_image_fill_on_frame() { insta::assert_snapshot!(svg); } +fn assert_filter_covers_page(svg: &str) { + let width = svg + .split_once("width=\"") + .and_then(|(_, rest)| rest.split_once('"').map(|(w, _)| w)) + .expect("svg width"); + let height = svg + .split_once("height=\"") + .and_then(|(_, rest)| rest.split_once('"').map(|(h, _)| h)) + .expect("svg height"); + assert!( + svg.contains("filterUnits=\"userSpaceOnUse\""), + "shadow/blur filters must use userSpaceOnUse (not objectBoundingBox %): {svg}" + ); + assert!( + !svg.contains("x=\"-50%\"") && !svg.contains("width=\"200%\""), + "must not size filters as a percent of the shape bbox: {svg}" + ); + assert!( + svg.contains(&format!("width=\"{width}\"")) + && svg.contains(&format!("height=\"{height}\"")) + && svg.contains(r#"x="0""#) + && svg.contains(r#"y="0""#), + "filter region must cover the export page ({width}×{height}): {svg}" + ); +} + fn assert_linked_image_stroke(svg: &str) { assert!( svg.contains("` clipped to the glyph silhouette; /// other fills go through Skia as native ``. Strokes are a later PR. +/// +/// `draw_matrix` is the leaf CTM (normally `centered_transform`). During a +/// parent drop-shadow silhouette pass it must include the geometric offset +/// (`silhouette_draw_matrix`); using only `centered_transform` leaves child +/// text unshifted while strokes/fills move. pub(super) fn render_text_fill( builder: &mut SvgLayerCanvas, shared: &RenderResources, element: &Shape, + draw_matrix: skia_safe::Matrix, ) -> Result<()> { let text_content = element.get_text_content(); let text_content = text_content.new_bounds(element.selrect()); @@ -25,14 +31,12 @@ pub(super) fn render_text_fill( return Ok(()); } - let matrix = element.centered_transform(); - for layer in 0..max_layers { let linked = linked_image_fills_at_layer(&text_content, layer, shared); let skip_ids: HashSet = linked.iter().map(|img| img.id()).collect(); for image_fill in &linked { - emit_text_image_fill(builder, shared, element, image_fill, layer)?; + emit_text_image_fill(builder, shared, element, image_fill, layer, draw_matrix)?; } if layer_has_skia_fills(&text_content, layer, &skip_ids) { @@ -44,7 +48,7 @@ pub(super) fn render_text_fill( }; let canvas = builder.canvas(); canvas.save(); - canvas.concat(&matrix); + canvas.concat(&draw_matrix); text::paint_text_paragraphs(canvas, element, &mut paragraph_builders); canvas.restore(); } @@ -96,6 +100,7 @@ fn emit_text_image_fill( shape: &Shape, image_fill: &ImageFill, layer: usize, + draw_matrix: skia_safe::Matrix, ) -> Result<()> { let Some(url) = shared.images.source_url(&image_fill.id()) else { return Ok(()); @@ -110,13 +115,21 @@ fn emit_text_image_fill( { let cv: &skia_safe::Canvas = &canvas; cv.save(); - cv.concat(&shape.centered_transform()); + cv.concat(&draw_matrix); text::paint_text_paragraphs(cv, shape, &mut paragraph_builders); cv.restore(); } builder.finish_clip_path_fragment(&clip_id, canvas); let href = xml_escape_attr(url); - emit_linked_image_element(builder, shape, image_fill, shape.selrect(), &href, &clip_id); + emit_linked_image_element( + builder, + shape, + image_fill, + shape.selrect(), + &href, + &clip_id, + draw_matrix, + ); Ok(()) }