diff --git a/render-wasm/src/render/svg/fixtures.rs b/render-wasm/src/render/svg/fixtures.rs index 46729b94b0..3ef1feee20 100644 --- a/render-wasm/src/render/svg/fixtures.rs +++ b/render-wasm/src/render/svg/fixtures.rs @@ -5,8 +5,9 @@ use skia_safe as skia; use crate::globals::TestRenderResourcesGuard; use crate::render::{FontStore, RenderResources}; use crate::shapes::{ - Fill, FontFamily, FontStyle, Frame, Group, GrowType, Paragraph, Rect, SolidColor, TextAlign, - TextContent, TextDirection, TextSpan, Type, + Fill, FontFamily, FontStyle, Frame, Group, GrowType, Paragraph, Path, Rect, Segment, + SolidColor, Stroke, StrokeKind, StrokeStyle, TextAlign, TextContent, TextDirection, TextSpan, + Type, }; use crate::state::ShapesPool; use crate::utils::uuid_from_u32_quartet; @@ -153,6 +154,101 @@ pub(super) fn add_text_with_fills( shape.set_shape_type(Type::Text(content)); } +/// Adds a rectangle with a single solid stroke (no fill). +pub(super) fn add_stroked_rect( + pool: &mut ShapesPool, + id: Uuid, + parent: Uuid, + (l, t, r, b): (f32, f32, f32, f32), + stroke: Stroke, +) { + let shape = pool.add_shape(id); + shape.set_parent(parent); + shape.set_shape_type(Type::Rect(Rect::default())); + shape.set_selrect(l, t, r, b); + shape.set_fills(vec![]); + shape.add_stroke(stroke); +} + +/// Adds a closed rectangular path with a single solid stroke (no fill). +pub(super) fn add_stroked_closed_path( + pool: &mut ShapesPool, + id: Uuid, + parent: Uuid, + bounds: (f32, f32, f32, f32), + stroke: Stroke, +) { + add_stroked_path(pool, id, parent, bounds, stroke, true); +} + +/// Adds an open polyline path with a single solid stroke (no fill). +pub(super) fn add_stroked_open_path( + pool: &mut ShapesPool, + id: Uuid, + parent: Uuid, + bounds: (f32, f32, f32, f32), + stroke: Stroke, +) { + add_stroked_path(pool, id, parent, bounds, stroke, false); +} + +fn add_stroked_path( + pool: &mut ShapesPool, + id: Uuid, + parent: Uuid, + (l, t, r, b): (f32, f32, f32, f32), + stroke: Stroke, + closed: bool, +) { + let mut segments = vec![ + Segment::MoveTo((l, t)), + Segment::LineTo((r, t)), + Segment::LineTo((r, b)), + Segment::LineTo((l, b)), + ]; + if closed { + segments.push(Segment::Close); + } + let path = Path::new(segments); + let shape = pool.add_shape(id); + shape.set_parent(parent); + shape.set_shape_type(Type::Path(path)); + shape.set_selrect(l, t, r, b); + shape.set_fills(vec![]); + shape.add_stroke(stroke); +} + +pub(super) fn solid_stroke(kind: StrokeKind, width: f32, color: skia::Color) -> Stroke { + stroke_with_style(kind, StrokeStyle::Solid, width, color) +} + +pub(super) fn dotted_stroke(kind: StrokeKind, width: f32, color: skia::Color) -> Stroke { + stroke_with_style(kind, StrokeStyle::Dotted, width, color) +} + +pub(super) fn dashed_stroke(kind: StrokeKind, width: f32, color: skia::Color) -> Stroke { + stroke_with_style(kind, StrokeStyle::Dashed, width, color) +} + +pub(super) fn mixed_stroke(kind: StrokeKind, width: f32, color: skia::Color) -> Stroke { + stroke_with_style(kind, StrokeStyle::Mixed, width, color) +} + +fn stroke_with_style( + kind: StrokeKind, + style: StrokeStyle, + width: f32, + color: skia::Color, +) -> 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 = Fill::Solid(SolidColor(color)); + stroke +} + pub(super) fn render(pool: &ShapesPool, root: Uuid) -> String { let mut resources = RenderResources::try_new_headless().expect("headless resources"); register_test_font_urls(&mut resources.fonts); diff --git a/render-wasm/src/render/svg/frames.rs b/render-wasm/src/render/svg/frames.rs index fead20bbf4..ecb0be1227 100644 --- a/render-wasm/src/render/svg/frames.rs +++ b/render-wasm/src/render/svg/frames.rs @@ -45,7 +45,14 @@ pub(super) fn render_frame( render_tree(builder, shared, child_id, tree, scale)?; } - // Strokes over children (frame space). + // 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(); if !visible_strokes.is_empty() { let canvas = builder.canvas(); @@ -56,9 +63,6 @@ pub(super) fn render_frame( canvas.restore(); } - if clipped { - builder.close_group(); - } if effects.is_some() { builder.close_group(); } diff --git a/render-wasm/src/render/svg/mod.rs b/render-wasm/src/render/svg/mod.rs index e992b1ad66..956c6a8e90 100644 --- a/render-wasm/src/render/svg/mod.rs +++ b/render-wasm/src/render/svg/mod.rs @@ -60,7 +60,8 @@ fn svg_page_bounds(shape: &Shape, tree: ShapesPoolRef, scale: f32) -> skia::Rect /// ``. /// /// Special-case re-emission for shadows, layer blur, masks, text strokes, and -/// deferred strokes is intentionally out of scope for this cut. +/// image strokes is intentionally out of scope for this cut. Solid Inner/Outer +/// and dotted/dashed strokes are emitted as filled outlines. pub fn render_to_svg( shared: &mut RenderResources, id: &Uuid, diff --git a/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_clipped_frame_with_solid_outer_stroke.snap b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_clipped_frame_with_solid_outer_stroke.snap new file mode 100644 index 0000000000..2ef3bd3c17 --- /dev/null +++ b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_clipped_frame_with_solid_outer_stroke.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_closed_path_with_dashed_inner_stroke.snap b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_closed_path_with_dashed_inner_stroke.snap new file mode 100644 index 0000000000..070cd8441d --- /dev/null +++ b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_closed_path_with_dashed_inner_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_dotted_center_stroke.snap b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_closed_path_with_dotted_center_stroke.snap new file mode 100644 index 0000000000..d6fa94869e --- /dev/null +++ b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_closed_path_with_dotted_center_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_dotted_inner_stroke.snap b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_closed_path_with_dotted_inner_stroke.snap new file mode 100644 index 0000000000..180801df6e --- /dev/null +++ b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_closed_path_with_dotted_inner_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_dotted_outer_stroke.snap b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_closed_path_with_dotted_outer_stroke.snap new file mode 100644 index 0000000000..b25f12faa4 --- /dev/null +++ b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_closed_path_with_dotted_outer_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_mixed_outer_stroke.snap b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_closed_path_with_mixed_outer_stroke.snap new file mode 100644 index 0000000000..0022609790 --- /dev/null +++ b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_closed_path_with_mixed_outer_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_center_stroke.snap b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_closed_path_with_solid_center_stroke.snap new file mode 100644 index 0000000000..69b2aedb7c --- /dev/null +++ b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_closed_path_with_solid_center_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_inner_stroke.snap b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_closed_path_with_solid_inner_stroke.snap new file mode 100644 index 0000000000..8144ac1dd2 --- /dev/null +++ b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_closed_path_with_solid_inner_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_stroke.snap b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_closed_path_with_solid_outer_stroke.snap new file mode 100644 index 0000000000..be53344b75 --- /dev/null +++ b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_closed_path_with_solid_outer_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_dotted_center_stroke.snap b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_open_path_with_dotted_center_stroke.snap new file mode 100644 index 0000000000..375278612c --- /dev/null +++ b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_open_path_with_dotted_center_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_dotted_stroke_and_caps.snap b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_open_path_with_dotted_stroke_and_caps.snap new file mode 100644 index 0000000000..8977724407 --- /dev/null +++ b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_open_path_with_dotted_stroke_and_caps.snap @@ -0,0 +1,11 @@ +--- +source: src/render/svg/tests.rs +assertion_line: 744 +expression: svg +--- + + + + + + diff --git a/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_open_path_with_solid_center_stroke.snap b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_open_path_with_solid_center_stroke.snap new file mode 100644 index 0000000000..19f8aedab2 --- /dev/null +++ b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_open_path_with_solid_center_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_solid_inner_stroke.snap b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_open_path_with_solid_inner_stroke.snap new file mode 100644 index 0000000000..0d3d73b1a8 --- /dev/null +++ b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_open_path_with_solid_inner_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_solid_outer_stroke.snap b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_open_path_with_solid_outer_stroke.snap new file mode 100644 index 0000000000..8abc827a1f --- /dev/null +++ b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_open_path_with_solid_outer_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_dashed_center_stroke.snap b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_rect_with_dashed_center_stroke.snap new file mode 100644 index 0000000000..cd08038591 --- /dev/null +++ b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_rect_with_dashed_center_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_dashed_outer_stroke.snap b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_rect_with_dashed_outer_stroke.snap new file mode 100644 index 0000000000..036f56eab7 --- /dev/null +++ b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_rect_with_dashed_outer_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_stroke.snap b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_rect_with_dotted_center_stroke.snap new file mode 100644 index 0000000000..94e23b3876 --- /dev/null +++ b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_rect_with_dotted_center_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_inner_stroke.snap b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_rect_with_dotted_inner_stroke.snap new file mode 100644 index 0000000000..bf0ba3795e --- /dev/null +++ b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_rect_with_dotted_inner_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_outer_stroke.snap b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_rect_with_dotted_outer_stroke.snap new file mode 100644 index 0000000000..b71a5e4ebc --- /dev/null +++ b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_rect_with_dotted_outer_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_mixed_center_stroke.snap b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_rect_with_mixed_center_stroke.snap new file mode 100644 index 0000000000..cac412a2da --- /dev/null +++ b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_rect_with_mixed_center_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_per_side_solid_inner_stroke.snap b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_rect_with_per_side_solid_inner_stroke.snap new file mode 100644 index 0000000000..5f9da0dcc4 --- /dev/null +++ b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_rect_with_per_side_solid_inner_stroke.snap @@ -0,0 +1,9 @@ +--- +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_stroke.snap b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_rect_with_solid_center_stroke.snap new file mode 100644 index 0000000000..83935d88e1 --- /dev/null +++ b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_rect_with_solid_center_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_stroke.snap b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_rect_with_solid_inner_stroke.snap new file mode 100644 index 0000000000..66caf336f1 --- /dev/null +++ b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_rect_with_solid_inner_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_stroke.snap b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_rect_with_solid_outer_stroke.snap new file mode 100644 index 0000000000..96183dfc50 --- /dev/null +++ b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_rect_with_solid_outer_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_rotated_closed_path_with_solid_inner_stroke.snap b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_rotated_closed_path_with_solid_inner_stroke.snap new file mode 100644 index 0000000000..bfc037fe93 --- /dev/null +++ b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_rotated_closed_path_with_solid_inner_stroke.snap @@ -0,0 +1,10 @@ +--- +source: src/render/svg/tests.rs +assertion_line: 454 +expression: svg +--- + + + + + diff --git a/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_rotated_rect_with_solid_outer_stroke.snap b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_rotated_rect_with_solid_outer_stroke.snap new file mode 100644 index 0000000000..be951f44c1 --- /dev/null +++ b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_rotated_rect_with_solid_outer_stroke.snap @@ -0,0 +1,10 @@ +--- +source: src/render/svg/tests.rs +assertion_line: 367 +expression: svg +--- + + + + + diff --git a/render-wasm/src/render/svg/tests.rs b/render-wasm/src/render/svg/tests.rs index 5ed0fc81c3..5e5f24f0df 100644 --- a/render-wasm/src/render/svg/tests.rs +++ b/render-wasm/src/render/svg/tests.rs @@ -1,6 +1,6 @@ use super::fixtures::*; -use crate::shapes::{BlendMode, Fill, SolidColor}; +use crate::shapes::{BlendMode, Fill, SolidColor, StrokeCap, StrokeKind}; use crate::state::ShapesPool; use crate::uuid::Uuid; @@ -208,6 +208,47 @@ fn exports_an_unclipped_frame_with_overflowing_child() { insta::assert_snapshot!(svg); } +#[test] +fn exports_clipped_frame_with_solid_outer_stroke() { + // Regression: frame content clip must not wrap strokes — outer strokes + // sit outside the selrect and would be fully clipped away. + let mut pool = ShapesPool::new(); + let id = uid(1); + add_frame( + &mut pool, + id, + Uuid::nil(), + (0.0, 0.0, 140.0, 100.0), + skia::Color::from_rgb(0xee, 0xee, 0xee), + true, + ); + { + let shape = pool.get_mut(&id).unwrap(); + shape.add_stroke(solid_stroke( + StrokeKind::Outer, + 12.0, + skia::Color::from_rgb(0x10, 0x40, 0xff), + )); + } + + let svg = render(&pool, id); + assert!( + svg.contains("clip-path=\"url(#"), + "clipped frame must keep content clip: {svg}" + ); + assert!( + svg.contains("fill-rule=\"evenodd\""), + "outer stroke must emit an evenodd outline: {svg}" + ); + let stroke_pos = svg.find("fill-rule=\"evenodd\"").expect("stroke outline"); + let clip_close = svg.find("").expect("clip group close"); + assert!( + stroke_pos > clip_close, + "outer stroke must be outside the content clip group: {svg}" + ); + insta::assert_snapshot!(svg); +} + #[test] fn exports_text_with_multiple_solid_fills() { let mut pool = ShapesPool::new(); @@ -240,6 +281,661 @@ fn exports_text_with_multiple_solid_fills() { insta::assert_snapshot!(svg); } +#[test] +fn exports_rect_with_solid_inner_stroke() { + let mut pool = ShapesPool::new(); + let id = uid(1); + add_stroked_rect( + &mut pool, + id, + Uuid::nil(), + (10.0, 10.0, 110.0, 90.0), + solid_stroke(StrokeKind::Inner, 10.0, skia::Color::from_rgb(0, 0, 255)), + ); + + let svg = render(&pool, id); + assert!( + svg.contains("fill=\"blue\"") || svg.to_ascii_lowercase().contains("fill=\"#0000ff\""), + "inner stroke must emit a filled outline: {svg}" + ); + assert!( + svg.contains("fill-rule=\"evenodd\""), + "aligned stroke outline should use evenodd: {svg}" + ); + insta::assert_snapshot!(svg); +} + +#[test] +fn exports_rect_with_per_side_solid_inner_stroke() { + // Regression: solid Inner/Outer SVG expansion used stroke_to_path with a + // uniform width and ignored stroke.widths. Per-side must use the GPU + // evenodd band (top/right/bottom/left). + let mut pool = ShapesPool::new(); + let id = uid(1); + let mut stroke = solid_stroke( + StrokeKind::Inner, + 20.0, + skia::Color::from_rgb(0x10, 0x40, 0xff), + ); + stroke.widths = Some([4.0, 12.0, 24.0, 40.0]); // top, right, bottom, left + add_stroked_rect(&mut pool, id, Uuid::nil(), (0.0, 0.0, 140.0, 100.0), stroke); + { + let shape = pool.get_mut(&id).unwrap(); + shape.set_fills(vec![Fill::Solid(SolidColor(skia::Color::from_rgb( + 0xff, 0xd4, 0x00, + )))]); + } + + let svg = render(&pool, id); + assert!( + svg.contains("fill-rule=\"evenodd\""), + "per-side inner stroke must emit an evenodd band: {svg}" + ); + // Inner hole for (0,0)-(140,100) with [4,12,24,40]: (40,4)-(128,76). + // Uniform width=20 would incorrectly hole at (20,20)-(120,80). + assert!( + svg.contains("40") && svg.contains("128") && svg.contains("76"), + "per-side hole must reflect left=40 / right=12 / bottom=24, got: {svg}" + ); + assert!( + !svg.contains("M20 20") && !svg.contains("L20 20"), + "must not use uniform width=20 inset: {svg}" + ); + insta::assert_snapshot!(svg); +} + +#[test] +fn exports_rect_with_solid_center_stroke() { + let mut pool = ShapesPool::new(); + let id = uid(1); + add_stroked_rect( + &mut pool, + id, + Uuid::nil(), + (10.0, 10.0, 110.0, 90.0), + solid_stroke(StrokeKind::Center, 8.0, skia::Color::from_rgb(0, 0, 255)), + ); + + let svg = render(&pool, id); + assert!( + svg.contains("stroke=\"blue\"") || svg.to_ascii_lowercase().contains("stroke=\"#0000ff\""), + "center stroke must keep a stroke attribute: {svg}" + ); + assert!( + !svg.contains("fill-rule=\"evenodd\""), + "center stroke must not expand to an evenodd outline: {svg}" + ); + insta::assert_snapshot!(svg); +} + +#[test] +fn exports_rect_with_solid_outer_stroke() { + let mut pool = ShapesPool::new(); + let id = uid(1); + add_stroked_rect( + &mut pool, + id, + Uuid::nil(), + (20.0, 20.0, 120.0, 100.0), + solid_stroke(StrokeKind::Outer, 10.0, skia::Color::from_rgb(255, 0, 0)), + ); + + let svg = render(&pool, id); + assert!( + svg.contains("fill=\"red\"") || svg.to_ascii_lowercase().contains("fill=\"#ff0000\""), + "outer stroke must emit a filled outline: {svg}" + ); + assert!( + svg.contains("fill-rule=\"evenodd\""), + "aligned stroke outline should use evenodd: {svg}" + ); + insta::assert_snapshot!(svg); +} + +#[test] +fn exports_rotated_rect_with_solid_outer_stroke() { + // Regression: outline strokes must use local selrect geometry. Baking + // `centered_transform` into the path (via rect_segments) while the leaf + // canvas also concatenates it double-rotates the stroke. + let mut pool = ShapesPool::new(); + let id = uid(1); + add_stroked_rect( + &mut pool, + id, + Uuid::nil(), + (0.0, 0.0, 140.0, 100.0), + solid_stroke( + StrokeKind::Outer, + 12.0, + skia::Color::from_rgb(0x10, 0x40, 0xff), + ), + ); + { + let shape = pool.get_mut(&id).unwrap(); + // 30° rotation (cos≈0.866, sin=0.5), matching a workspace export case. + let c = 0.866_025_4_f32; + let s = 0.5_f32; + shape.set_transform(c, s, -s, c, 0.0, 0.0); + shape.set_rotation(30.0); + shape.set_fills(vec![Fill::Solid(SolidColor(skia::Color::from_rgb( + 0xff, 0xd4, 0x00, + )))]); + } + + let svg = render(&pool, id); + assert!( + svg.contains("fill-rule=\"evenodd\""), + "rotated outer stroke must emit an evenodd outline: {svg}" + ); + // Fill rect + stroke path should both carry the same leaf CTM (one rotation). + assert!( + svg.matches("matrix(0.866025").count() >= 2, + "fill and stroke must each use the rotation matrix once: {svg}" + ); + // Outline path data must stay in local selrect space (roughly [-stroke, w+stroke]). + // Double rotation bakes world-space points into `d` before the CTM is applied. + let d_attr = svg + .split("d=\"") + .nth(1) + .and_then(|s| s.split('"').next()) + .unwrap_or(""); + let first_num = d_attr + .trim_start_matches(|c: char| !c.is_ascii_digit() && c != '-' && c != '.') + .split(|c: char| !c.is_ascii_digit() && c != '-' && c != '.') + .find(|s| !s.is_empty()) + .and_then(|s| s.parse::().ok()); + assert!( + matches!(first_num, Some(n) if (-40.0..180.0).contains(&n)), + "stroke path d= should start in local coords, got {first_num:?} from {d_attr}: {svg}" + ); + insta::assert_snapshot!(svg); +} + +#[test] +fn exports_closed_path_with_solid_inner_stroke() { + let mut pool = ShapesPool::new(); + let id = uid(1); + add_stroked_closed_path( + &mut pool, + id, + Uuid::nil(), + (0.0, 0.0, 100.0, 80.0), + solid_stroke(StrokeKind::Inner, 8.0, skia::Color::from_rgb(0, 0, 255)), + ); + + let svg = render(&pool, id); + assert!( + svg.contains("fill=\"blue\"") || svg.to_ascii_lowercase().contains("fill=\"#0000ff\""), + "closed path inner stroke must emit a filled outline: {svg}" + ); + assert!( + svg.contains("fill-rule=\"evenodd\""), + "aligned stroke outline should use evenodd: {svg}" + ); + insta::assert_snapshot!(svg); +} + +#[test] +fn exports_rotated_closed_path_with_solid_inner_stroke() { + // Regression: path content is stored in parent space; stroke outlines must + // apply `to_path_transform` (like fills via get_skia_path) before drawing + // under the leaf `centered_transform`, or the stroke double-rotates. + use crate::shapes::Type; + + let mut pool = ShapesPool::new(); + let id = uid(1); + add_stroked_closed_path( + &mut pool, + id, + Uuid::nil(), + (0.0, 0.0, 140.0, 100.0), + solid_stroke( + StrokeKind::Inner, + 12.0, + skia::Color::from_rgb(0x10, 0x40, 0xff), + ), + ); + { + let shape = pool.get_mut(&id).unwrap(); + let c = 0.866_025_4_f32; + let s = 0.5_f32; + shape.set_transform(c, s, -s, c, 0.0, 0.0); + shape.set_rotation(30.0); + shape.set_fills(vec![Fill::Solid(SolidColor(skia::Color::from_rgb( + 0xff, 0xd4, 0x00, + )))]); + + // Bake rotation into path points (Penpot path storage model). + let bake = shape.centered_transform(); + if let Type::Path(ref mut path) = shape.shape_type { + path.transform(&bake); + let b = path.bounds(); + shape.set_selrect(b.min_x(), b.min_y(), b.max_x(), b.max_y()); + } + } + + let svg = render(&pool, id); + assert!( + svg.contains("fill-rule=\"evenodd\""), + "rotated path inner stroke must emit an evenodd outline: {svg}" + ); + assert!( + svg.matches("matrix(0.866025").count() >= 2, + "fill and stroke must each use the rotation matrix once: {svg}" + ); + // Stroke outline `d` must stay in local (unrotated) space like the fill. + let stroke_d = svg + .split("fill-rule=\"evenodd\"") + .next() + .and_then(|before| before.rsplit("d=\"").next()) + .and_then(|s| s.split('"').next()) + .unwrap_or(""); + let first_num = stroke_d + .trim_start_matches(|c: char| !c.is_ascii_digit() && c != '-' && c != '.') + .split(|c: char| !c.is_ascii_digit() && c != '-' && c != '.') + .find(|s| !s.is_empty()) + .and_then(|s| s.parse::().ok()); + assert!( + matches!(first_num, Some(n) if (-40.0..180.0).contains(&n)), + "stroke path d= should start in local coords, got {first_num:?} from {stroke_d}: {svg}" + ); + insta::assert_snapshot!(svg); +} + +#[test] +fn exports_closed_path_with_solid_center_stroke() { + let mut pool = ShapesPool::new(); + let id = uid(1); + add_stroked_closed_path( + &mut pool, + id, + Uuid::nil(), + (0.0, 0.0, 100.0, 80.0), + solid_stroke(StrokeKind::Center, 8.0, skia::Color::from_rgb(0, 128, 0)), + ); + + let svg = render(&pool, id); + assert!( + svg.contains("stroke=\"green\"") || svg.to_ascii_lowercase().contains("stroke=\"#008000\""), + "closed path center stroke must keep a stroke attribute: {svg}" + ); + assert!( + !svg.contains("fill-rule=\"evenodd\""), + "center stroke must not expand to an evenodd outline: {svg}" + ); + insta::assert_snapshot!(svg); +} + +#[test] +fn exports_closed_path_with_solid_outer_stroke() { + let mut pool = ShapesPool::new(); + let id = uid(1); + add_stroked_closed_path( + &mut pool, + id, + Uuid::nil(), + (0.0, 0.0, 100.0, 80.0), + solid_stroke(StrokeKind::Outer, 8.0, skia::Color::from_rgb(0, 128, 0)), + ); + + let svg = render(&pool, id); + // Path outer previously used save_layer+Clear (dropped by SkSVG). Must not + // be a bare stroked path with no visible paint. + assert!( + svg.contains("fill=\"green\"") || svg.to_ascii_lowercase().contains("fill=\"#008000\""), + "closed path outer stroke must emit a filled outline: {svg}" + ); + assert!( + svg.contains("fill-rule=\"evenodd\""), + "aligned stroke outline should use evenodd: {svg}" + ); + insta::assert_snapshot!(svg); +} + +#[test] +fn exports_open_path_with_solid_inner_stroke() { + // Open paths force Center alignment regardless of the requested kind. + let mut pool = ShapesPool::new(); + let id = uid(1); + add_stroked_open_path( + &mut pool, + id, + Uuid::nil(), + (0.0, 0.0, 100.0, 80.0), + solid_stroke(StrokeKind::Inner, 8.0, skia::Color::from_rgb(0, 0, 255)), + ); + + let svg = render(&pool, id); + assert!( + svg.contains("stroke=\"blue\"") || svg.to_ascii_lowercase().contains("stroke=\"#0000ff\""), + "open path inner stroke must render as center stroke: {svg}" + ); + assert!( + !svg.contains("fill-rule=\"evenodd\""), + "open path must not expand to an evenodd outline: {svg}" + ); + insta::assert_snapshot!(svg); +} + +#[test] +fn exports_open_path_with_solid_center_stroke() { + let mut pool = ShapesPool::new(); + let id = uid(1); + add_stroked_open_path( + &mut pool, + id, + Uuid::nil(), + (0.0, 0.0, 100.0, 80.0), + solid_stroke(StrokeKind::Center, 8.0, skia::Color::from_rgb(255, 0, 0)), + ); + + let svg = render(&pool, id); + assert!( + svg.contains("stroke=\"red\"") || svg.to_ascii_lowercase().contains("stroke=\"#ff0000\""), + "open path center stroke must keep a stroke attribute: {svg}" + ); + assert!( + !svg.contains("fill-rule=\"evenodd\""), + "open path must not expand to an evenodd outline: {svg}" + ); + insta::assert_snapshot!(svg); +} + +#[test] +fn exports_open_path_with_solid_outer_stroke() { + // Open paths force Center alignment regardless of the requested kind. + let mut pool = ShapesPool::new(); + let id = uid(1); + add_stroked_open_path( + &mut pool, + id, + Uuid::nil(), + (0.0, 0.0, 100.0, 80.0), + solid_stroke(StrokeKind::Outer, 8.0, skia::Color::from_rgb(0, 128, 0)), + ); + + let svg = render(&pool, id); + assert!( + svg.contains("stroke=\"green\"") || svg.to_ascii_lowercase().contains("stroke=\"#008000\""), + "open path outer stroke must render as center stroke: {svg}" + ); + assert!( + !svg.contains("fill-rule=\"evenodd\""), + "open path must not expand to an evenodd outline: {svg}" + ); + insta::assert_snapshot!(svg); +} + +#[test] +fn exports_rect_with_dotted_inner_stroke() { + let mut pool = ShapesPool::new(); + let id = uid(1); + add_stroked_rect( + &mut pool, + id, + Uuid::nil(), + (10.0, 10.0, 110.0, 90.0), + dotted_stroke(StrokeKind::Inner, 10.0, skia::Color::from_rgb(0, 0, 255)), + ); + + let svg = render(&pool, id); + assert!( + svg.contains("fill=\"blue\"") || svg.to_ascii_lowercase().contains("fill=\"#0000ff\""), + "dotted inner stroke must emit filled geometry: {svg}" + ); + insta::assert_snapshot!(svg); +} + +#[test] +fn exports_rect_with_dotted_center_stroke() { + let mut pool = ShapesPool::new(); + let id = uid(1); + add_stroked_rect( + &mut pool, + id, + Uuid::nil(), + (10.0, 10.0, 110.0, 90.0), + dotted_stroke(StrokeKind::Center, 8.0, skia::Color::from_rgb(0, 0, 255)), + ); + + let svg = render(&pool, id); + // PathEffect does not serialize; dots expand to filled outline geometry. + assert!( + svg.contains("fill=\"blue\"") || svg.to_ascii_lowercase().contains("fill=\"#0000ff\""), + "dotted center stroke must emit filled geometry: {svg}" + ); + insta::assert_snapshot!(svg); +} + +#[test] +fn exports_rect_with_dotted_outer_stroke() { + let mut pool = ShapesPool::new(); + let id = uid(1); + add_stroked_rect( + &mut pool, + id, + Uuid::nil(), + (20.0, 20.0, 120.0, 100.0), + dotted_stroke(StrokeKind::Outer, 10.0, skia::Color::from_rgb(255, 0, 0)), + ); + + let svg = render(&pool, id); + assert!( + svg.contains("fill=\"red\"") || svg.to_ascii_lowercase().contains("fill=\"#ff0000\""), + "dotted outer stroke must emit filled geometry: {svg}" + ); + insta::assert_snapshot!(svg); +} + +#[test] +fn exports_closed_path_with_dotted_inner_stroke() { + let mut pool = ShapesPool::new(); + let id = uid(1); + add_stroked_closed_path( + &mut pool, + id, + Uuid::nil(), + (0.0, 0.0, 100.0, 80.0), + dotted_stroke(StrokeKind::Inner, 8.0, skia::Color::from_rgb(0, 0, 255)), + ); + + let svg = render(&pool, id); + assert!( + svg.contains("fill=\"blue\"") || svg.to_ascii_lowercase().contains("fill=\"#0000ff\""), + "closed path dotted inner stroke must emit filled geometry: {svg}" + ); + insta::assert_snapshot!(svg); +} + +#[test] +fn exports_closed_path_with_dotted_center_stroke() { + let mut pool = ShapesPool::new(); + let id = uid(1); + add_stroked_closed_path( + &mut pool, + id, + Uuid::nil(), + (0.0, 0.0, 100.0, 80.0), + dotted_stroke(StrokeKind::Center, 8.0, skia::Color::from_rgb(0, 128, 0)), + ); + + let svg = render(&pool, id); + assert!( + svg.contains("fill=\"green\"") || svg.to_ascii_lowercase().contains("fill=\"#008000\""), + "closed path dotted center stroke must emit filled geometry: {svg}" + ); + insta::assert_snapshot!(svg); +} + +#[test] +fn exports_closed_path_with_dotted_outer_stroke() { + let mut pool = ShapesPool::new(); + let id = uid(1); + add_stroked_closed_path( + &mut pool, + id, + Uuid::nil(), + (0.0, 0.0, 100.0, 80.0), + dotted_stroke(StrokeKind::Outer, 8.0, skia::Color::from_rgb(0, 128, 0)), + ); + + let svg = render(&pool, id); + assert!( + svg.contains("fill=\"green\"") || svg.to_ascii_lowercase().contains("fill=\"#008000\""), + "closed path dotted outer stroke must emit filled geometry: {svg}" + ); + insta::assert_snapshot!(svg); +} + +#[test] +fn exports_open_path_with_dotted_center_stroke() { + let mut pool = ShapesPool::new(); + let id = uid(1); + add_stroked_open_path( + &mut pool, + id, + Uuid::nil(), + (0.0, 0.0, 100.0, 80.0), + dotted_stroke(StrokeKind::Center, 8.0, skia::Color::from_rgb(255, 0, 0)), + ); + + let svg = render(&pool, id); + assert!( + svg.contains("fill=\"red\"") || svg.to_ascii_lowercase().contains("fill=\"#ff0000\""), + "open path dotted stroke must emit filled geometry: {svg}" + ); + insta::assert_snapshot!(svg); +} + +#[test] +fn exports_open_path_with_dotted_stroke_and_caps() { + // Regression: dotted/dashed SVG expansion used stroke_to_path and returned + // before draw_stroke_geometry, so open-path caps (triangle/circle/…) were + // dropped. Caps must be overlaid after the expanded outline. + let mut pool = ShapesPool::new(); + let id = uid(1); + let mut stroke = dotted_stroke( + StrokeKind::Center, + 12.0, + skia::Color::from_rgb(0x10, 0x40, 0xff), + ); + 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(&pool, id); + assert!( + svg.contains("fill=\"#1040FF\"") || svg.to_ascii_lowercase().contains("fill=\"#1040ff\""), + "dotted stroke with caps must emit filled geometry: {svg}" + ); + // Caps are separate filled draws (triangle + circle), not only the dotted outline. + assert!( + svg.matches("= 2 || svg.contains(" { canvas: &'a Canvas, shared: &'a mut RenderResources, scale: f32, - /// When `true`, multiple fills are composited into a single shader (PDF). - /// When `false`, each fill is drawn separately so SkSVGDevice can emit - /// `fill` attributes (SVG export). + /// When `true`, use PDF/GPU-friendly compositing (`merge_fills`, + /// `save_layer` for outer strokes). When `false` (SVG export), avoid + /// techniques that `SkSVGDevice` drops: draw fills individually and emit + /// solid Inner/Outer strokes as filled outlines. compose_fills: bool, } @@ -82,8 +84,16 @@ impl ShapeRenderer for VectorRenderer<'_> { } fn draw_strokes(&mut self, shape: &Shape, strokes: &[&Stroke]) -> Result<()> { + let svg_export = !self.compose_fills; for stroke in strokes.iter().rev() { - draw_single_stroke(self.canvas, self.shared, self.scale, shape, stroke)?; + draw_single_stroke( + self.canvas, + self.shared, + self.scale, + shape, + stroke, + svg_export, + )?; } Ok(()) } @@ -840,9 +850,9 @@ fn render_frame( canvas.save_layer(&layer_rec); } - // Clip to frame bounds in the frame's own space, then undo the transform so - // children draw at their absolute coords while staying clipped (mirrors the - // GPU clip). Outset ~0.5px like the GPU clip to avoid an AA seam. + // Clip fills + children only. Strokes render outside the content clip so + // outer/center strokes are not trimmed (same as GPU render_shape_exit). + canvas.save(); if element.clip_content { canvas.concat(&matrix); clip_to_frame_content(canvas, element, scale); @@ -866,8 +876,9 @@ fn render_frame( for child_id in &children { render_tree_inner(shared, canvas, child_id, tree, scale, opts)?; } + canvas.restore(); // content clip - // Strokes over children (clipped frames), in the frame's space. + // Strokes over children, outside the frame content clip. let visible_strokes: Vec<&Stroke> = element.visible_strokes().collect(); if !visible_strokes.is_empty() { canvas.save(); @@ -1064,16 +1075,109 @@ fn draw_single_stroke( scale: f32, shape: &Shape, stroke: &Stroke, + svg_export: bool, ) -> Result<()> { // Image-fill strokes: the stroke masks the visible area of the image. if let Fill::Image(image_fill) = &stroke.fill { return draw_image_stroke(canvas, shared, scale, shape, stroke, image_fill); } + // Techniques SkSVGDevice cannot keep (save_layer+Clear/clip for Outer, + // PathEffect stamps for dots/dashes): expand to a filled outline instead. + // Solid Center stays on the shared stroke path. + if svg_export && draw_svg_stroke_as_fill(canvas, shape, stroke) { + return Ok(()); + } + draw_stroke_geometry(canvas, scale, shape, stroke, false); Ok(()) } +/// 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. + if stroke.per_side_widths().is_some() + && matches!(shape.shape_type, Type::Rect(_) | Type::Frame(_)) + { + 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, + }; + + // 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(outline) = stroke_to_path( + stroke, + &shape_path, + None, + &shape.selrect, + shape.svg_attrs.as_ref(), + solid_outline, + ) else { + return false; + }; + + let mut paint = stroke.fill.to_paint(&shape.selrect, true); + paint.set_style(skia::PaintStyle::Fill); + paint.set_anti_alias(true); + canvas.draw_path(&outline.to_skia_path(shape.svg_attrs.as_ref()), &paint); + + // Expanded dotted/dashed strokes skip `draw_stroke_geometry`, which is + // 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, + ); + } + } + + 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. diff --git a/render-wasm/src/shapes/shape_to_path.rs b/render-wasm/src/shapes/shape_to_path.rs index 32e058dcb6..a797e52837 100644 --- a/render-wasm/src/shapes/shape_to_path.rs +++ b/render-wasm/src/shapes/shape_to_path.rs @@ -102,9 +102,18 @@ fn fix_radius( } pub fn rect_segments(shape: &Shape, corners: Option) -> Vec { + transform_segments(rect_segments_local(shape, corners), shape) +} + +/// Axis-aligned rect path in selrect space (no `shape.transform`). +/// +/// Use when the caller already applies [`Shape::centered_transform`] on the +/// canvas (e.g. SVG leaf export); [`rect_segments`] would bake the transform +/// into the path and double-rotate. +pub fn rect_segments_local(shape: &Shape, corners: Option) -> Vec { let sr = shape.selrect; - let segments = if let Some([r1, r2, r3, r4]) = corners { + if let Some([r1, r2, r3, r4]) = corners { let (r1, r2, r3, r4) = fix_radius(r1, r2, r3, r4, sr.width(), sr.height()); let p1 = (sr.x(), sr.y() + r1.y); @@ -139,9 +148,7 @@ pub fn rect_segments(shape: &Shape, corners: Option) -> Vec { Segment::LineTo(p4), Segment::Close, ] - }; - - transform_segments(segments, shape) + } } fn transform_point(p: (f32, f32), matrix: &skia_safe::Matrix) -> (f32, f32) { @@ -151,6 +158,11 @@ fn transform_point(p: (f32, f32), matrix: &skia_safe::Matrix) -> (f32, f32) { } pub fn circle_segments(shape: &Shape) -> Vec { + transform_segments(circle_segments_local(shape), shape) +} + +/// Circle path in selrect space (no `shape.transform`). See [`rect_segments_local`]. +pub fn circle_segments_local(shape: &Shape) -> Vec { let sr = shape.selrect; let c = BEZIER_CIRCLE_C; let c1x = sr.x() + (sr.width() / 2.0 * (1.0 - c)); @@ -168,15 +180,13 @@ pub fn circle_segments(shape: &Shape) -> Vec { let p3 = (mx, ey); let p4 = (sr.x(), my); - let segments = vec![ + vec![ Segment::MoveTo(p1), Segment::CurveTo(((c2x, p1.1), (p2.0, c1y), p2)), Segment::CurveTo(((p2.0, c2y), (c2x, p3.1), p3)), Segment::CurveTo(((c1x, p3.1), (p4.0, c2y), p4)), Segment::CurveTo(((p4.0, c1y), (c1x, p1.1), p1)), - ]; - - transform_segments(segments, shape) + ] } fn join_paths(path: Path, other: Path) -> Path {