Alejandro Alonso 269aa36f82
✨ Export solid and patterned strokes to WASM SVG (#11513)
* ✨ Export solid inner and outer strokes to WASM SVG

SkSVGDevice drops save_layer+Clear used for path Outer strokes, so
solid Inner/Outer strokes on closed shapes are expanded to filled
outlines via stroke_to_path. Center and open paths stay on the shared
stroke path.

Closes #11381

* ✨ Export dotted, dashed, and mixed strokes to WASM SVG

PathEffects do not survive SkSVGDevice, so dotted/dashed/mixed strokes
expand via stroke_to_path with the effect kept, producing filled
outline geometry for all alignments including Center.

Closes #11382

* 🐛 Fix WASM SVG stroke export edge cases

Keep expanded stroke outlines in local space under the leaf CTM so
rotated rects/paths are not double-transformed; overlay open-path caps
after dotted expansion; draw frame strokes outside the content clip;
and route per-side rect/frame strokes through the evenodd band path.
2026-09-07 13:50:58 +02:00

965 lines
28 KiB
Rust

use super::fixtures::*;
use crate::shapes::{BlendMode, Fill, SolidColor, StrokeCap, StrokeKind};
use crate::state::ShapesPool;
use crate::uuid::Uuid;
use skia_safe as skia;
#[test]
fn exports_a_rect_with_multiple_solid_fills() {
let mut pool = ShapesPool::new();
let id = uid(1);
add_rect_with_fills(
&mut pool,
id,
Uuid::nil(),
(0.0, 0.0, 100.0, 80.0),
vec![
// fills[0] is topmost in Penpot (red 50%).
Fill::Solid(SolidColor(skia::Color::from_argb(128, 245, 0, 0))),
// fills[1] is underneath (blue 100%).
Fill::Solid(SolidColor(skia::Color::from_rgb(0, 63, 255))),
],
);
let svg = render(&pool, id);
assert!(
svg.matches("fill=\"#").count() >= 2,
"each solid fill must emit a fill attribute: {svg}"
);
let blue_pos = svg.to_ascii_lowercase().find("fill=\"#003fff\"");
let red_pos = svg.to_ascii_lowercase().find("fill=\"#f50000\"");
assert!(blue_pos.is_some(), "missing bottom blue fill: {svg}");
assert!(red_pos.is_some(), "missing top red fill: {svg}");
assert!(
blue_pos.unwrap() < red_pos.unwrap(),
"bottom fill must appear before top fill in SVG: {svg}"
);
insta::assert_snapshot!(svg);
}
#[test]
fn exports_a_solid_rect() {
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),
);
insta::assert_snapshot!(render(&pool, id));
}
#[test]
fn exports_leaf_opacity_and_blend_mode_as_group_wrappers() {
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(0, 128, 255),
);
{
let shape = pool.get_mut(&id).unwrap();
shape.set_opacity(0.5);
shape.set_blend_mode(BlendMode(skia::BlendMode::Multiply));
}
let svg = render(&pool, id);
assert!(
svg.contains("opacity=\"0.5\""),
"missing opacity wrapper: {svg}"
);
assert!(
svg.contains("mix-blend-mode:multiply"),
"missing blend-mode wrapper: {svg}"
);
insta::assert_snapshot!(svg);
}
#[test]
fn exports_a_group_with_two_rects_and_group_opacity() {
let mut pool = ShapesPool::new();
let group_id = uid(1);
let a = uid(2);
let b = uid(3);
add_group(
&mut pool,
group_id,
Uuid::nil(),
(0.0, 0.0, 200.0, 100.0),
&[a, b],
);
{
let group = pool.get_mut(&group_id).unwrap();
group.set_opacity(0.7);
}
add_solid_rect(
&mut pool,
a,
group_id,
(0.0, 0.0, 90.0, 100.0),
skia::Color::from_rgb(0, 0, 255),
);
add_solid_rect(
&mut pool,
b,
group_id,
(110.0, 0.0, 200.0, 100.0),
skia::Color::from_rgb(0, 200, 0),
);
let svg = render(&pool, group_id);
assert!(
svg.contains("opacity=\"0.7\""),
"missing group opacity wrapper: {svg}"
);
insta::assert_snapshot!(svg);
}
#[test]
fn exports_a_clipped_frame_with_overflowing_child() {
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, 100.0, 100.0),
skia::Color::from_rgb(240, 240, 240),
true,
);
{
let frame = pool.get_mut(&frame_id).unwrap();
frame.add_child(child);
}
// Child extends past the frame's right/bottom edge.
add_solid_rect(
&mut pool,
child,
frame_id,
(50.0, 50.0, 150.0, 150.0),
skia::Color::from_rgb(255, 0, 0),
);
let svg = render(&pool, frame_id);
assert!(
svg.contains("clip-path=\"url(#"),
"missing frame clip-path wrapper: {svg}"
);
assert!(svg.contains("<clipPath "), "missing clipPath def: {svg}");
// Clipped boards keep the frame's own page size.
assert!(
svg.contains("width=\"100\" height=\"100\""),
"clipped frame should export at selrect size: {svg}"
);
insta::assert_snapshot!(svg);
}
#[test]
fn exports_an_unclipped_frame_with_overflowing_child() {
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, 100.0, 100.0),
skia::Color::from_rgb(240, 240, 240),
false,
);
{
let frame = pool.get_mut(&frame_id).unwrap();
frame.add_child(child);
}
// Child extends past the frame's right/bottom edge.
add_solid_rect(
&mut pool,
child,
frame_id,
(50.0, 50.0, 150.0, 150.0),
skia::Color::from_rgb(255, 0, 0),
);
let svg = render(&pool, frame_id);
assert!(
!svg.contains("clip-path=\"url(#"),
"unclipped frame must not emit clip-path: {svg}"
);
// Page must grow to include the overflowing child (0..150).
assert!(
svg.contains("width=\"150\" height=\"150\""),
"unclipped frame should export at extrect size: {svg}"
);
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("</g>").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();
let id = uid(1);
add_text_with_fills(
&mut pool,
id,
(0.0, 0.0, 560.0, 240.0),
"HOLA",
200.0,
vec![
Fill::Solid(SolidColor(skia::Color::from_argb(128, 245, 0, 0))),
Fill::Solid(SolidColor(skia::Color::from_rgb(0, 63, 255))),
],
);
let svg = render(&pool, id);
assert!(
svg.matches("fill=\"#").count() >= 2,
"each solid fill must emit a fill attribute: {svg}"
);
let blue_pos = svg.to_ascii_lowercase().find("fill=\"#003fff\"");
let red_pos = svg.to_ascii_lowercase().find("fill=\"#f50000\"");
assert!(blue_pos.is_some(), "missing bottom blue fill: {svg}");
assert!(red_pos.is_some(), "missing top red fill: {svg}");
assert!(
blue_pos.unwrap() < red_pos.unwrap(),
"bottom fill must appear before top fill in SVG: {svg}"
);
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::<f32>().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::<f32>().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("<path ").count() >= 2 || svg.contains("<circle"),
"expected separate cap geometry besides the dotted outline: {svg}"
);
insta::assert_snapshot!(svg);
}
#[test]
fn exports_rect_with_dashed_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),
dashed_stroke(StrokeKind::Center, 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\""),
"dashed center stroke must emit filled geometry: {svg}"
);
insta::assert_snapshot!(svg);
}
#[test]
fn exports_rect_with_dashed_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),
dashed_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\""),
"dashed outer stroke must emit filled geometry: {svg}"
);
insta::assert_snapshot!(svg);
}
#[test]
fn exports_closed_path_with_dashed_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),
dashed_stroke(StrokeKind::Inner, 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 dashed inner stroke must emit filled geometry: {svg}"
);
insta::assert_snapshot!(svg);
}
#[test]
fn exports_rect_with_mixed_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),
mixed_stroke(StrokeKind::Center, 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\""),
"mixed center stroke must emit filled geometry: {svg}"
);
insta::assert_snapshot!(svg);
}
#[test]
fn exports_closed_path_with_mixed_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),
mixed_stroke(StrokeKind::Outer, 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\""),
"closed path mixed outer stroke must emit filled geometry: {svg}"
);
insta::assert_snapshot!(svg);
}
#[test]
fn exports_solid_text_with_font_face() {
let mut pool = ShapesPool::new();
let id = uid(1);
add_solid_text(
&mut pool,
id,
(0.0, 0.0, 560.0, 240.0),
"HOLA",
200.0,
skia::Color::from_rgb(0xE1, 0x7F, 0xDA),
);
let svg = render(&pool, id);
assert!(svg.contains("<text"), "text glyphs must be present: {svg}");
assert!(
svg.contains("@font-face") && svg.contains(TEST_FONT_URL),
"missing @font-face for registered font URL: {svg}"
);
// Fixed-size text exports at the selrect, not tight glyph bounds.
assert!(
svg.contains("width=\"560\" height=\"240\""),
"fixed text should export at selrect size: {svg}"
);
insta::assert_snapshot!(svg);
}