diff --git a/render-wasm/src/render/svg/document.rs b/render-wasm/src/render/svg/document.rs index f06e086cb4..4de4d28bd1 100644 --- a/render-wasm/src/render/svg/document.rs +++ b/render-wasm/src/render/svg/document.rs @@ -136,13 +136,17 @@ impl SvgLayerCanvas { } /// Finalizes a fragment canvas as a `` def. + /// + /// Rewrite fill-rule to clip-rule: clipPaths ignore fill-rule, so evenodd + /// stroke rings would otherwise fill solid. pub(super) fn finish_clip_path_fragment(&mut self, id: &str, canvas: skia::svg::Canvas) { let data = canvas.end(); let doc = String::from_utf8_lossy(data.as_bytes()); let inner = extract_inner_svg(&doc); let prefix = format!("f{}_", self.frag_no); self.frag_no += 1; - let geometry = sanitize_skia_svg_fragment(&remap_ids(inner, &prefix)); + let geometry = sanitize_skia_svg_fragment(&remap_ids(inner, &prefix)) + .replace("fill-rule=", "clip-rule="); self.defs.push_str(&format!( "{geometry}" )); diff --git a/render-wasm/src/render/svg/fixtures.rs b/render-wasm/src/render/svg/fixtures.rs index 7bebcc1cf4..e97c39c36f 100644 --- a/render-wasm/src/render/svg/fixtures.rs +++ b/render-wasm/src/render/svg/fixtures.rs @@ -378,6 +378,24 @@ fn stroke_with_style( stroke } +fn image_stroke(kind: StrokeKind, style: StrokeStyle, width: f32, image_id: Uuid) -> Stroke { + let mut stroke = match kind { + StrokeKind::Inner => Stroke::new_inner_stroke(width, style, None, None, None, None), + StrokeKind::Outer => Stroke::new_outer_stroke(width, style, None, None, None, None), + StrokeKind::Center => Stroke::new_center_stroke(width, style, None, None, None, None), + }; + stroke.fill = test_image_fill(image_id); + stroke +} + +pub(super) fn image_solid_stroke(kind: StrokeKind, width: f32, image_id: Uuid) -> Stroke { + image_stroke(kind, StrokeStyle::Solid, width, image_id) +} + +pub(super) fn image_dotted_stroke(kind: StrokeKind, width: f32, image_id: Uuid) -> Stroke { + image_stroke(kind, StrokeStyle::Dotted, width, image_id) +} + /// Text with a linked image fill (register URL via `render_with`). pub(super) fn add_image_text( pool: &mut ShapesPool, diff --git a/render-wasm/src/render/svg/frames.rs b/render-wasm/src/render/svg/frames.rs index 0f2d0ddd77..56d341ea15 100644 --- a/render-wasm/src/render/svg/frames.rs +++ b/render-wasm/src/render/svg/frames.rs @@ -1,11 +1,9 @@ use crate::error::Result; -use crate::render::shape_renderer::ShapeRenderer; -use crate::render::vector::VectorRenderer; use crate::shapes::{Shape, Stroke}; use crate::state::ShapesPoolRef; use super::document::{effect_attrs, SvgLayerCanvas}; -use super::images::emit_fills; +use super::images::{emit_fills, emit_strokes}; use super::render_tree; use crate::render::RenderResources; @@ -16,8 +14,6 @@ pub(super) fn render_frame( tree: ShapesPoolRef, scale: f32, ) -> Result<()> { - let matrix = element.centered_transform(); - let effects = effect_attrs(element); if let Some(attrs) = &effects { builder.open_group(attrs); @@ -51,12 +47,7 @@ pub(super) fn render_frame( // Strokes over children (frame space), outside the content clip. let visible_strokes: Vec<&Stroke> = element.visible_strokes().collect(); if !visible_strokes.is_empty() { - let canvas = builder.canvas(); - canvas.save(); - canvas.concat(&matrix); - let mut renderer = VectorRenderer::new(canvas, shared, scale, false); - renderer.draw_strokes(element, &visible_strokes)?; - canvas.restore(); + emit_strokes(builder, shared, element, &visible_strokes, scale)?; } if effects.is_some() { diff --git a/render-wasm/src/render/svg/images.rs b/render-wasm/src/render/svg/images.rs index 992f38d548..e072100758 100644 --- a/render-wasm/src/render/svg/images.rs +++ b/render-wasm/src/render/svg/images.rs @@ -1,12 +1,14 @@ use crate::error::Result; +use crate::math::Rect as MathRect; +use crate::render::get_dest_rect; +use crate::render::get_image_dest_rect; use crate::render::shape_renderer::ShapeRenderer; -use crate::render::vector::VectorRenderer; -use crate::shapes::{Fill, ImageFill, Shape}; +use crate::render::vector::{paint_svg_stroke_silhouette, VectorRenderer}; +use crate::shapes::{Fill, ImageFill, Shape, Stroke}; use crate::state::ShapesPoolRef; use super::document::SvgLayerCanvas; -use crate::math::Rect as MathRect; -use crate::render::{get_image_dest_rect, RenderResources}; +use crate::render::RenderResources; /// Emits fills bottom -> top for SVG export. /// @@ -69,6 +71,94 @@ fn emit_image_fill( Ok(()) } +/// Emits strokes bottom -> top for SVG export. +/// +/// 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`]. +pub(super) fn emit_strokes( + builder: &mut SvgLayerCanvas, + shared: &mut RenderResources, + shape: &Shape, + strokes: &[&Stroke], + scale: f32, +) -> Result<()> { + if strokes.is_empty() { + return Ok(()); + } + + let matrix = 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)?; + } + _ => { + let canvas = builder.canvas(); + canvas.save(); + canvas.concat(&matrix); + let mut renderer = VectorRenderer::new(canvas, shared, scale, false); + renderer.draw_strokes(shape, std::slice::from_ref(stroke))?; + canvas.restore(); + } + } + } + Ok(()) +} + +/// Linked `` clipped to the stroke outline (opaque filled path). +fn emit_image_stroke( + builder: &mut SvgLayerCanvas, + shared: &RenderResources, + shape: &Shape, + stroke: &Stroke, + image_fill: &ImageFill, + scale: f32, +) -> Result<()> { + let Some(url) = shared.images.source_url(&image_fill.id()) else { + return Ok(()); + }; + + let clip_id = builder.unique("imgstrokeclip"); + let canvas = builder.new_fragment(); + { + let cv: &skia_safe::Canvas = &canvas; + cv.save(); + cv.concat(&shape.centered_transform()); + if !paint_svg_stroke_silhouette(cv, shape, stroke, scale) { + cv.restore(); + return Ok(()); + } + cv.restore(); + } + builder.finish_clip_path_fragment(&clip_id, canvas); + + 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); + Ok(()) +} + +/// Where to place the linked image for an image-filled stroke. +/// +/// Starts from the same dest as the GPU path (`selrect` + `stroke.delta()`), then +/// grows on open paths so marker caps are still covered by the ``. +fn image_stroke_dest_rect(shape: &Shape, stroke: &Stroke) -> MathRect { + let mut dest = get_dest_rect(&shape.selrect(), stroke.delta()); + if !shape.is_open() { + return dest; + } + let cap_margin = stroke.cap_bounds_margin(); + if cap_margin <= 0.0 { + return dest; + } + let mut with_caps = shape.selrect(); + with_caps.inset((-cap_margin, -cap_margin)); + dest.join(with_caps); + dest +} + /// Emits `` + `` at `dest_rect`, under the page CTM. pub(super) fn emit_linked_image_element( builder: &mut SvgLayerCanvas, diff --git a/render-wasm/src/render/svg/mod.rs b/render-wasm/src/render/svg/mod.rs index c192571c83..d990d95522 100644 --- a/render-wasm/src/render/svg/mod.rs +++ b/render-wasm/src/render/svg/mod.rs @@ -60,9 +60,9 @@ fn svg_page_bounds(shape: &Shape, tree: ShapesPoolRef, scale: f32) -> skia::Rect /// composed as native SVG `` wrappers. Frame `clip content` uses a native /// ``. /// -/// Special-case re-emission for shadows, layer blur, masks, text strokes, and -/// image strokes is intentionally out of scope for this cut. Solid Inner/Outer -/// and dotted/dashed strokes are emitted as filled outlines. +/// Shadows, layer blur, 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( shared: &mut RenderResources, id: &Uuid, @@ -135,7 +135,7 @@ use groups::render_group; use text::render_text_fill; use document::effect_attrs; -use images::emit_fills; +use images::{emit_fills, emit_strokes}; /// Renders `id`'s subtree to an SVG body, returning `(defs, body)`. fn render_body( @@ -211,17 +211,22 @@ fn render_leaf( canvas.concat(&matrix); let mut renderer = VectorRenderer::new(canvas, shared, scale, false); renderer.draw_fill_inner_shadows(element)?; + canvas.restore(); let visible_strokes: Vec<_> = element.visible_strokes().collect(); if !visible_strokes.is_empty() { - renderer.draw_strokes(element, &visible_strokes)?; + 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(); } } - canvas.restore(); } } diff --git a/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_closed_path_with_dotted_outer_image_stroke.snap b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_closed_path_with_dotted_outer_image_stroke.snap new file mode 100644 index 0000000000..afc467606b --- /dev/null +++ b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_closed_path_with_dotted_outer_image_stroke.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_closed_path_with_solid_outer_image_stroke.snap b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_closed_path_with_solid_outer_image_stroke.snap new file mode 100644 index 0000000000..22fa54bc00 --- /dev/null +++ b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_closed_path_with_solid_outer_image_stroke.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_open_path_with_image_stroke_and_caps.snap b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_open_path_with_image_stroke_and_caps.snap new file mode 100644 index 0000000000..a9a4c6b760 --- /dev/null +++ b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_open_path_with_image_stroke_and_caps.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__exports_open_path_with_solid_center_image_stroke.snap b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_open_path_with_solid_center_image_stroke.snap new file mode 100644 index 0000000000..f40de53a37 --- /dev/null +++ b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_open_path_with_solid_center_image_stroke.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_rect_with_dotted_center_image_stroke.snap b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_rect_with_dotted_center_image_stroke.snap new file mode 100644 index 0000000000..82c042efce --- /dev/null +++ b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_rect_with_dotted_center_image_stroke.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_rect_with_solid_center_image_stroke.snap b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_rect_with_solid_center_image_stroke.snap new file mode 100644 index 0000000000..198993db1f --- /dev/null +++ b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_rect_with_solid_center_image_stroke.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_rect_with_solid_inner_image_stroke.snap b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_rect_with_solid_inner_image_stroke.snap new file mode 100644 index 0000000000..51d4849123 --- /dev/null +++ b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_rect_with_solid_inner_image_stroke.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_rect_with_solid_outer_image_stroke.snap b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_rect_with_solid_outer_image_stroke.snap new file mode 100644 index 0000000000..213181290d --- /dev/null +++ b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_rect_with_solid_outer_image_stroke.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 0343555156..35aa75c35e 100644 --- a/render-wasm/src/render/svg/tests.rs +++ b/render-wasm/src/render/svg/tests.rs @@ -1257,3 +1257,239 @@ fn exports_image_fill_on_frame() { ); insta::assert_snapshot!(svg); } + +fn assert_linked_image_stroke(svg: &str) { + assert!( + svg.contains(": {svg}" + ); + assert!( + svg.contains("imgstrokeclip") && svg.contains("clip-path=\"url(#"), + "image stroke must clip to the stroke outline: {svg}" + ); + assert!( + !svg.contains("data:image"), + "must not base64-embed the stroke image: {svg}" + ); +} + +fn assert_evenodd_stroke_clip(svg: &str) { + assert!( + svg.contains("clip-rule=\"evenodd\""), + "stroke clip must use clip-rule=evenodd: {svg}" + ); + assert!( + !svg.contains("fill-rule=\"evenodd\""), + "clipPath should rewrite fill-rule to clip-rule: {svg}" + ); +} + +#[test] +fn exports_rect_with_solid_center_image_stroke() { + let mut pool = ShapesPool::new(); + let id = uid(1); + let image_id = uid(42); + add_stroked_rect( + &mut pool, + id, + Uuid::nil(), + (10.0, 10.0, 110.0, 90.0), + image_solid_stroke(StrokeKind::Center, 8.0, image_id), + ); + + let svg = render_with(&pool, id, |resources| { + resources + .images + .set_source_url(image_id, TEST_IMAGE_URL.to_string()); + }); + + assert_linked_image_stroke(&svg); + assert_evenodd_stroke_clip(&svg); + insta::assert_snapshot!(svg); +} + +#[test] +fn exports_rect_with_solid_inner_image_stroke() { + let mut pool = ShapesPool::new(); + let id = uid(1); + let image_id = uid(42); + add_stroked_rect( + &mut pool, + id, + Uuid::nil(), + (0.0, 0.0, 100.0, 80.0), + image_solid_stroke(StrokeKind::Inner, 10.0, image_id), + ); + + let svg = render_with(&pool, id, |resources| { + resources + .images + .set_source_url(image_id, TEST_IMAGE_URL.to_string()); + }); + + assert_linked_image_stroke(&svg); + assert_evenodd_stroke_clip(&svg); + insta::assert_snapshot!(svg); +} + +#[test] +fn exports_rect_with_solid_outer_image_stroke() { + let mut pool = ShapesPool::new(); + let id = uid(1); + let image_id = uid(42); + add_stroked_rect( + &mut pool, + id, + Uuid::nil(), + (20.0, 20.0, 120.0, 100.0), + image_solid_stroke(StrokeKind::Outer, 10.0, image_id), + ); + + let svg = render_with(&pool, id, |resources| { + resources + .images + .set_source_url(image_id, TEST_IMAGE_URL.to_string()); + }); + + assert_linked_image_stroke(&svg); + assert_evenodd_stroke_clip(&svg); + insta::assert_snapshot!(svg); +} + +#[test] +fn exports_rect_with_dotted_center_image_stroke() { + let mut pool = ShapesPool::new(); + let id = uid(1); + let image_id = uid(42); + add_stroked_rect( + &mut pool, + id, + Uuid::nil(), + (10.0, 10.0, 110.0, 90.0), + image_dotted_stroke(StrokeKind::Center, 8.0, image_id), + ); + + let svg = render_with(&pool, id, |resources| { + resources + .images + .set_source_url(image_id, TEST_IMAGE_URL.to_string()); + }); + + assert_linked_image_stroke(&svg); + assert_evenodd_stroke_clip(&svg); + insta::assert_snapshot!(svg); +} + +#[test] +fn exports_closed_path_with_solid_outer_image_stroke() { + let mut pool = ShapesPool::new(); + let id = uid(1); + let image_id = uid(42); + add_stroked_closed_path( + &mut pool, + id, + Uuid::nil(), + (0.0, 0.0, 100.0, 80.0), + image_solid_stroke(StrokeKind::Outer, 8.0, image_id), + ); + + let svg = render_with(&pool, id, |resources| { + resources + .images + .set_source_url(image_id, TEST_IMAGE_URL.to_string()); + }); + + assert_linked_image_stroke(&svg); + assert_evenodd_stroke_clip(&svg); + insta::assert_snapshot!(svg); +} + +#[test] +fn exports_open_path_with_solid_center_image_stroke() { + let mut pool = ShapesPool::new(); + let id = uid(1); + let image_id = uid(42); + add_stroked_open_path( + &mut pool, + id, + Uuid::nil(), + (0.0, 0.0, 100.0, 80.0), + image_solid_stroke(StrokeKind::Center, 8.0, image_id), + ); + + let svg = render_with(&pool, id, |resources| { + resources + .images + .set_source_url(image_id, TEST_IMAGE_URL.to_string()); + }); + + assert_linked_image_stroke(&svg); + assert_evenodd_stroke_clip(&svg); + insta::assert_snapshot!(svg); +} + +#[test] +fn exports_open_path_with_image_stroke_and_caps() { + // Caps go into the clip silhouette with the outline. Image dest must grow + // past stroke.delta() so triangle/circle markers stay textured. + let mut pool = ShapesPool::new(); + let id = uid(1); + let image_id = uid(42); + let mut stroke = image_solid_stroke(StrokeKind::Center, 12.0, image_id); + stroke.cap_start = Some(StrokeCap::TriangleArrow); + stroke.cap_end = Some(StrokeCap::CircleMarker); + add_stroked_open_path(&mut pool, id, Uuid::nil(), (0.0, 0.0, 140.0, 90.0), stroke); + + let svg = render_with(&pool, id, |resources| { + resources + .images + .set_source_url(image_id, TEST_IMAGE_URL.to_string()); + }); + + assert_linked_image_stroke(&svg); + assert_evenodd_stroke_clip(&svg); + let clip = svg + .split("").next()) + .expect("imgstroke clipPath"); + assert!( + clip.matches("= 2 + || clip.contains(" Option { + match &shape.shape_type { + Type::Rect(r) => Some(Path::new(rect_segments_local(shape, r.corners))), + Type::Frame(f) => Some(Path::new(rect_segments_local(shape, f.corners))), + Type::Circle => Some(Path::new(circle_segments_local(shape))), + Type::Path(_) | Type::Bool(_) => { + let path = shape.shape_type.path()?; + let mut local = path.clone(); + if let Some(t) = shape.to_path_transform() { + local.transform(&t); + } + Some(local) + } + Type::Text(_) | Type::SVGRaw(_) | Type::Group(_) => None, + } +} + +fn svg_stroke_solid_outline(stroke: &Stroke, is_open: bool) -> Option { + let kind = stroke.render_kind(is_open); + match stroke.style { + StrokeStyle::Solid => match kind { + // Solid Center already serializes as a native SVG stroke. + StrokeKind::Center => None, + StrokeKind::Inner | StrokeKind::Outer => { + if is_open { + None + } else { + Some(true) + } + } + }, + // PathEffects (path_1d / dash) do not survive SkSVGDevice; expand them. + StrokeStyle::Dotted | StrokeStyle::Dashed | StrokeStyle::Mixed => Some(false), + } +} + /// Draws a stroke as a filled path outline for SVG export. /// /// Handles solid Inner/Outer and all dotted/dashed/mixed alignments (including /// Center and open paths, which force Center). Returns `true` when handled. fn draw_svg_stroke_as_fill(canvas: &Canvas, shape: &Shape, stroke: &Stroke) -> bool { let is_open = shape.is_open(); - let kind = stroke.render_kind(is_open); // Per-side rect/frame strokes already expand to an evenodd band in // `draw_stroke_on_rect`. `stroke_to_path` only knows a uniform width. @@ -1109,41 +1145,12 @@ fn draw_svg_stroke_as_fill(canvas: &Canvas, shape: &Shape, stroke: &Stroke) -> b return false; } - let solid_outline = match stroke.style { - StrokeStyle::Solid => match kind { - // Solid Center already serializes as a native SVG stroke. - StrokeKind::Center => return false, - StrokeKind::Inner | StrokeKind::Outer => { - if is_open { - return false; - } - true - } - }, - // PathEffects (path_1d / dash) do not survive SkSVGDevice; expand them. - StrokeStyle::Dotted | StrokeStyle::Dashed | StrokeStyle::Mixed => false, + let Some(solid_outline) = svg_stroke_solid_outline(stroke, is_open) else { + return false; }; - // Local (untransformed) geometry: the SVG leaf canvas already has - // `centered_transform`. Using `rect_segments` / `circle_segments` here - // would bake the same transform into the path and double-rotate. - // Path/Bool content is stored in parent space; `to_path_transform` - // undoes that so the outline matches `get_skia_path` (fills) under CTM. - let shape_path = match &shape.shape_type { - Type::Rect(r) => Path::new(rect_segments_local(shape, r.corners)), - Type::Frame(f) => Path::new(rect_segments_local(shape, f.corners)), - Type::Circle => Path::new(circle_segments_local(shape)), - Type::Path(_) | Type::Bool(_) => match shape.shape_type.path() { - Some(path) => { - let mut local = path.clone(); - if let Some(t) = shape.to_path_transform() { - local.transform(&t); - } - local - } - None => return false, - }, - Type::Text(_) | Type::SVGRaw(_) | Type::Group(_) => return false, + let Some(shape_path) = svg_stroke_shape_path(shape) else { + return false; }; let Some(outline) = stroke_to_path( @@ -1166,18 +1173,88 @@ fn draw_svg_stroke_as_fill(canvas: &Canvas, shape: &Shape, stroke: &Stroke) -> b // where open-path caps are drawn. Overlay them here in local path space // (same as fills / the outline above under the leaf CTM). if is_open { - if let Some(cap_path) = transformed_skia_path(shape) { - let cap_paint = - stroke.to_stroked_paint(true, &shape.selrect, shape.svg_attrs.as_ref(), true); - super::strokes::handle_stroke_caps( - &cap_path, stroke, canvas, true, &cap_paint, None, true, - ); - } + paint_svg_stroke_caps(canvas, shape, stroke, false); } true } +/// Opaque stroke region for SVG clipPath silhouettes. +/// +/// Expands every alignment (including solid Center) to a filled outline so we +/// do not rely on save_layer + SrcIn. Returns false when there is nothing to draw. +pub(super) fn paint_svg_stroke_silhouette( + canvas: &Canvas, + shape: &Shape, + stroke: &Stroke, + scale: f32, +) -> bool { + let is_open = shape.is_open(); + + if stroke.per_side_widths().is_some() + && matches!(shape.shape_type, Type::Rect(_) | Type::Frame(_)) + { + let corners = shape.shape_type.corners(); + let mut paint = stroke.to_paint(&shape.selrect, shape.svg_attrs.as_ref(), true); + paint.set_shader(None); + paint.set_color(skia::Color::BLACK); + super::strokes::draw_stroke_on_rect( + canvas, + stroke, + &shape.selrect, + &corners, + &paint, + scale, + None, + None, + true, + ); + return true; + } + + let Some(shape_path) = svg_stroke_shape_path(shape) else { + return false; + }; + + // Expand Center too: a native stroke attribute cannot clip an image. + let solid_outline = matches!(stroke.style, StrokeStyle::Solid); + let Some(outline) = stroke_to_path( + stroke, + &shape_path, + None, + &shape.selrect, + shape.svg_attrs.as_ref(), + solid_outline, + ) else { + return false; + }; + + let mut paint = Paint::default(); + paint.set_style(skia::PaintStyle::Fill); + paint.set_anti_alias(true); + paint.set_color(skia::Color::BLACK); + canvas.draw_path(&outline.to_skia_path(shape.svg_attrs.as_ref()), &paint); + + if is_open { + paint_svg_stroke_caps(canvas, shape, stroke, true); + } + + true +} + +fn paint_svg_stroke_caps(canvas: &Canvas, shape: &Shape, stroke: &Stroke, opaque: bool) { + let Some(cap_path) = transformed_skia_path(shape) else { + return; + }; + let mut cap_paint = + stroke.to_stroked_paint(true, &shape.selrect, shape.svg_attrs.as_ref(), true); + if opaque { + cap_paint.set_shader(None); + cap_paint.set_color(skia::Color::BLACK); + } + super::strokes::handle_stroke_caps(&cap_path, stroke, canvas, true, &cap_paint, None, true); +} + /// Draws a stroke's geometry by shape type, kind and dash style. Rect/Circle /// reuse the GPU stroke fns (dash/alignment parity); Path/Bool use double-width /// + clip/clear + caps. `opaque` forces black for an image-stroke silhouette.