use super::fixtures::*;
use crate::shapes::{
radius_to_sigma, AlignContent, AlignItems, BlendMode, Blur, BlurType, Fill, FlexDirection,
ImageFill, ImageFillTransform, JustifyContent, JustifyItems, Shadow, ShadowStyle, SolidColor,
StrokeCap, StrokeKind, WrapType,
};
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_multiple_roots_in_one_svg_with_relative_placement() {
let mut pool = ShapesPool::new();
let left = uid(1);
let right = uid(2);
add_solid_rect(
&mut pool,
left,
Uuid::nil(),
(0.0, 0.0, 50.0, 50.0),
skia::Color::from_rgb(255, 0, 0),
);
add_solid_rect(
&mut pool,
right,
Uuid::nil(),
(100.0, 0.0, 150.0, 50.0),
skia::Color::from_rgb(0, 0, 255),
);
let svg = render_multi(&pool, &[left, right]);
assert_eq!(
svg.matches(": {svg}"
);
assert_eq!(
svg.matches(" ").count(),
1,
"multi-root export must emit one : {svg}"
);
// Joined page is 150×50; both fills present and the right rect keeps x=100.
assert!(
svg.contains("viewBox=\"0 0 150 50\""),
"joined viewBox should span both rects: {svg}"
);
assert!(svg.contains("fill=\"red\""), "missing left red fill: {svg}");
assert!(
svg.contains("fill=\"blue\"") && svg.contains("x=\"100\""),
"missing right blue fill at x=100: {svg}"
);
}
#[test]
fn exports_multi_root_all_hidden_or_missing_as_empty_svg() {
let mut pool = ShapesPool::new();
let hidden = uid(1);
add_solid_rect(
&mut pool,
hidden,
Uuid::nil(),
(0.0, 0.0, 50.0, 50.0),
skia::Color::from_rgb(255, 0, 0),
);
pool.get_mut(&hidden).unwrap().set_hidden(true);
let missing = uid(99);
let svg = render_multi(&pool, &[hidden, missing]);
assert_eq!(svg.matches("")
.map(|i| filter_open + i)
.expect("filter close");
let filter = &svg[filter_open..filter_close];
assert!(
!filter.contains("SourceGraphic"),
"leaf drop silhouette filter must not blend SourceGraphic: {filter}"
);
assert!(
!filter.contains("feMorphology"),
"leaf drop must not use feMorphology for spread: {filter}"
);
insta::assert_snapshot!(svg);
}
#[test]
fn leaf_drop_offset_follows_rotation_in_user_space() {
let mut pool = ShapesPool::new();
let id = uid(1);
add_solid_rect(
&mut pool,
id,
Uuid::nil(),
(0.0, 0.0, 100.0, 80.0),
skia::Color::from_rgb(255, 0, 0),
);
{
let shape = pool.get_mut(&id).unwrap();
// 90° CCW: local (+10, 0) → user-space (0, 10).
let (c, s) = (0.0_f32, 1.0_f32);
shape.set_transform(c, s, -s, c, 0.0, 0.0);
shape.set_rotation(90.0);
shape.add_shadow(Shadow::new(
skia::Color::from_argb(128, 56, 0, 238),
0.0,
0.0,
(10.0, 0.0),
ShadowStyle::Drop,
false,
));
}
let svg = render(&pool, id);
// Geometric silhouette: local offset is pre_translated before rotation.
let filter_open = svg.find("filter=\"url(#fx").expect("drop filter");
let filter_close = svg[filter_open..]
.find("")
.map(|i| filter_open + i)
.expect("silhouette group close");
let silhouette = &svg[filter_open..=filter_close];
assert!(
silhouette.contains("matrix(") || silhouette.contains("translate("),
"rotated leaf drop silhouette must carry a CTM: {silhouette}"
);
// Filter itself keeps zero offset (geometry moved instead).
let def_start = svg.find("")
.map(|i| def_start + i)
.expect("filter close");
let filter = &svg[def_start..def_end];
assert!(
filter.contains(r#"dx="0""#) && filter.contains(r#"dy="0""#),
"rotated leaf drop filter offset must stay zero: {filter}"
);
assert!(
!filter.contains(r#"dx="10""#),
"must not keep unmapped local dx in the filter: {filter}"
);
insta::assert_snapshot!(svg);
}
#[test]
fn small_leaf_drop_shadow_filter_covers_page_not_bbox_percent() {
// 16×16 + offset(4,4) blur 4: objectBoundingBox ±50% only leaves 8px margin,
// but reach is ~|offset|+3σ ≈ 12px — corners crop unless the filter is
// sized in userSpaceOnUse to the page (already padded via extrect).
let mut pool = ShapesPool::new();
let id = uid(1);
add_solid_rect(
&mut pool,
id,
Uuid::nil(),
(0.0, 0.0, 16.0, 16.0),
skia::Color::from_rgb(61, 123, 255),
);
{
let shape = pool.get_mut(&id).unwrap();
shape.add_shadow(Shadow::new(
skia::Color::from_argb(153, 0, 0, 0),
4.0,
0.0,
(4.0, 4.0),
ShadowStyle::Drop,
false,
));
}
let svg = render(&pool, id);
assert_filter_covers_page(&svg);
insta::assert_snapshot!(svg);
}
#[test]
fn large_blur_leaf_drop_shadow_filter_covers_page_not_bbox_percent() {
// 100×100 blur 40: ±50% of bbox = 50px, 3σ≈71px — halo crops with
// objectBoundingBox percentages.
let mut pool = ShapesPool::new();
let id = uid(1);
add_solid_rect(
&mut pool,
id,
Uuid::nil(),
(0.0, 0.0, 100.0, 100.0),
skia::Color::from_rgb(61, 123, 255),
);
{
let shape = pool.get_mut(&id).unwrap();
shape.add_shadow(Shadow::new(
skia::Color::from_argb(178, 0, 0, 0),
40.0,
0.0,
(0.0, 0.0),
ShadowStyle::Drop,
false,
));
}
let svg = render(&pool, id);
assert_filter_covers_page(&svg);
let sigma = radius_to_sigma(40.0);
assert!(
svg.contains(&format!("stdDeviation=\"{sigma}\"")),
"large blur sigma must remain in the filter: {svg}"
);
insta::assert_snapshot!(svg);
}
#[test]
fn sliver_leaf_drop_shadow_filter_covers_page_not_bbox_percent() {
// 200×2 + offset(0,12) blur 6: objectBoundingBox height is only 4px —
// the shadow disappears. userSpaceOnUse page region keeps it.
let mut pool = ShapesPool::new();
let id = uid(1);
add_solid_rect(
&mut pool,
id,
Uuid::nil(),
(0.0, 0.0, 200.0, 2.0),
skia::Color::from_rgb(61, 123, 255),
);
{
let shape = pool.get_mut(&id).unwrap();
shape.add_shadow(Shadow::new(
skia::Color::from_argb(204, 0, 0, 0),
6.0,
0.0,
(0.0, 12.0),
ShadowStyle::Drop,
false,
));
}
let svg = render(&pool, id);
assert_filter_covers_page(&svg);
// Geometric offset (0,12): silhouette y = page_pad_y + 12, content y = page_pad_y.
assert!(
svg.contains(r#"translate(11.8923 12)"#) && svg.contains(r#"translate(11.8923 0)"#),
"sliver drop must apply geometric offset (0,12): {svg}"
);
insta::assert_snapshot!(svg);
}
#[test]
fn exports_leaf_inner_shadow_as_svg_filter() {
let mut pool = ShapesPool::new();
let id = uid(1);
add_solid_rect(
&mut pool,
id,
Uuid::nil(),
(0.0, 0.0, 100.0, 80.0),
skia::Color::from_rgb(0, 128, 255),
);
{
let shape = pool.get_mut(&id).unwrap();
shape.add_shadow(Shadow::new(
skia::Color::from_argb(180, 0, 0, 0),
6.0,
0.0,
(2.0, 3.0),
ShadowStyle::Inner,
false,
));
}
let svg = render(&pool, id);
assert!(
svg.contains("feComposite") && svg.contains("hardAlpha"),
"inner shadow must use classic composite graph: {svg}"
);
assert!(
svg.contains("filter=\"url(#fx"),
"inner shadow must wrap the shape: {svg}"
);
insta::assert_snapshot!(svg);
}
#[test]
fn skips_hidden_shadows() {
let mut pool = ShapesPool::new();
let id = uid(1);
add_solid_rect(
&mut pool,
id,
Uuid::nil(),
(0.0, 0.0, 100.0, 80.0),
skia::Color::from_rgb(255, 0, 0),
);
{
let shape = pool.get_mut(&id).unwrap();
shape.add_shadow(Shadow::new(
skia::Color::BLACK,
8.0,
0.0,
(4.0, 4.0),
ShadowStyle::Drop,
true,
));
}
let svg = render(&pool, id);
assert!(
!svg.contains("feOffset") && !svg.contains("filter=\"url(#fx"),
"hidden shadow must not emit a filter: {svg}"
);
insta::assert_snapshot!(svg);
}
#[test]
fn exports_frame_drop_shadow_wrapping_children() {
let mut pool = ShapesPool::new();
let frame_id = uid(1);
let child = uid(2);
add_frame(
&mut pool,
frame_id,
Uuid::nil(),
(0.0, 0.0, 200.0, 120.0),
skia::Color::from_rgb(240, 240, 240),
false,
);
{
let frame = pool.get_mut(&frame_id).unwrap();
frame.add_shadow(Shadow::new(
skia::Color::from_argb(100, 0, 0, 0),
10.0,
0.0,
(0.0, 8.0),
ShadowStyle::Drop,
false,
));
}
add_solid_rect(
&mut pool,
child,
frame_id,
(20.0, 20.0, 100.0, 80.0),
skia::Color::from_rgb(0, 200, 0),
);
{
let frame = pool.get_mut(&frame_id).unwrap();
frame.add_child(child);
}
let svg = render(&pool, frame_id);
assert!(
svg.contains("filter=\"url(#fx"),
"frame drop shadow must emit a filter: {svg}"
);
assert!(
!svg.contains("SourceGraphic"),
"container drop filter must be shadow-only (no SourceGraphic): {svg}"
);
// Silhouette under the filter, then real content without nesting the filter.
let filter_pos = svg.find("filter=\"url(#fx").expect("frame filter");
let child_pos = svg.find("fill=\"#").expect("child fill");
assert!(
filter_pos < child_pos,
"frame shadow silhouette must precede content: {svg}"
);
let fill_count = svg.matches("fill=\"#").count();
assert!(
fill_count >= 2,
"silhouette + content must both draw fills: {svg}"
);
insta::assert_snapshot!(svg);
}
#[test]
fn clipped_frame_drop_shadow_silhouette_skips_content_clip() {
// clip=ON + drop offset: GPU paints the drop outside the content clip, so
// the silhouette must not use clipPath (F1a). Otherwise an unshifted clip
// truncates the offset shadow.
let mut pool = ShapesPool::new();
let frame_id = uid(1);
let child = uid(2);
add_frame(
&mut pool,
frame_id,
Uuid::nil(),
(0.0, 0.0, 200.0, 120.0),
skia::Color::from_rgb(240, 240, 240),
true,
);
{
let frame = pool.get_mut(&frame_id).unwrap();
frame.add_shadow(Shadow::new(
skia::Color::from_argb(140, 0, 0, 0),
0.0,
0.0,
(0.0, 24.0),
ShadowStyle::Drop,
false,
));
}
add_solid_rect(
&mut pool,
child,
frame_id,
(20.0, 20.0, 180.0, 100.0),
skia::Color::from_rgb(0, 200, 0),
);
{
let frame = pool.get_mut(&frame_id).unwrap();
frame.add_child(child);
}
let svg = render(&pool, frame_id);
assert!(
svg.contains("filter=\"url(#fx"),
"clipped frame drop shadow must emit a filter: {svg}"
);
let filter_open = svg.find("filter=\"url(#fx").expect("frame filter");
let filter_close = svg[filter_open..]
.find("")
.map(|i| filter_open + i)
.expect("silhouette group close");
let silhouette = &svg[filter_open..=filter_close];
assert!(
!silhouette.contains("clip-path="),
"drop silhouette must not use content clip: {silhouette}"
);
assert!(
silhouette.contains(r#"translate(0 24)"#),
"silhouette fills must still apply drop offset: {silhouette}"
);
assert!(
svg.contains("")
.map(|i| filter_open + i)
.expect("silhouette group close");
let silhouette = &svg[filter_open..=filter_close];
assert!(
!silhouette.contains("clip-path="),
"spread silhouette must not be content-clipped: {silhouette}"
);
// 200×120 + 2×24 spread, and child 160×80 + 2×24.
assert!(
silhouette.contains(r#"width="248""#) && silhouette.contains(r#"height="168""#),
"frame fill must outset by spread 24: {silhouette}"
);
assert!(
silhouette.contains(r#"width="208""#) && silhouette.contains(r#"height="128""#),
"child fill must outset by inherited spread 24: {silhouette}"
);
insta::assert_snapshot!(svg);
}
#[test]
fn group_drop_silhouette_shifts_image_fill_children() {
// Container drop silhouettes must offset linked fills the same way
// as solid fills (F3). Clip already follows silhouette_offset; the image
// CTM must use draw_matrix too.
let mut pool = ShapesPool::new();
let group_id = uid(1);
let image_child = uid(2);
let solid_child = uid(3);
let image_id = uid(42);
add_image_rect(
&mut pool,
image_child,
group_id,
(0.0, 0.0, 120.0, 120.0),
image_id,
true,
255,
);
add_solid_rect(
&mut pool,
solid_child,
group_id,
(170.0, 0.0, 290.0, 120.0),
skia::Color::from_rgb(61, 123, 255),
);
add_group(
&mut pool,
group_id,
Uuid::nil(),
(0.0, 0.0, 290.0, 120.0),
&[image_child, solid_child],
);
{
let group = pool.get_mut(&group_id).unwrap();
group.add_shadow(Shadow::new(
skia::Color::from_rgb(229, 16, 35),
0.0,
0.0,
(40.0, 40.0),
ShadowStyle::Drop,
false,
));
}
let svg = render_with(&pool, group_id, |resources| {
resources
.images
.set_source_url(image_id, TEST_IMAGE_URL.to_string());
});
assert!(
svg.contains("filter=\"url(#fx"),
"group drop shadow must emit a filter: {svg}"
);
let filter_open = svg.find("filter=\"url(#fx").expect("group filter");
let silhouette = &svg[filter_open..];
// Content pass repeats the image without the silhouette offset matrix.
let content_image = silhouette
.match_indices(" &svg[filter_open..end],
None => silhouette,
};
assert!(
silhouette.contains(" = svg.match_indices("filter=\"url(#fx").collect();
assert_eq!(
filter_attrs.len(),
2,
"expect frame silhouette filter + child content filter only: {svg}"
);
// Child's filter must not sit inside the frame's filtered group.
let frame_filter_open = svg.find("")
.map(|i| frame_filter_open + i)
.expect("close frame filter group");
let child_filter = svg.rfind(" frame_filter_close,
"child drop filter must be outside frame drop group to avoid shadow-of-shadow: {svg}"
);
// Child leaf drop offset is geometric (translate), not feOffset.
assert!(
svg.contains(r#"translate(10 10)"#),
"child leaf drop must keep geometric offset: {svg}"
);
// Frame container drops apply offset geometrically (filter dx=0).
assert!(
svg.contains(r#"dx="0""#) || svg.matches(r#"dx=""#).count() >= 1,
"frame drop filter must not re-offset in user space: {svg}"
);
// Stroke-ring silhouette (border shadow), not a solid board fill.
let silhouette = &svg[frame_filter_open..=frame_filter_close];
assert!(
silhouette.contains("fill-rule=\"evenodd\"") || silhouette.contains("")
.map(|i| frame_filter_open + i)
.expect("close frame filter group");
let silhouette = &svg[frame_filter_open..=frame_filter_close];
assert!(
silhouette.contains("")
.map(|i| content_text_start + i)
.expect("content text end");
let content_text = &content[content_text_start..=content_text_end];
assert!(
!content_text.contains("translate(20 20)"),
"content text must not carry silhouette offset: {content_text}"
);
insta::assert_snapshot!(svg);
}
#[test]
fn fill_less_frame_drop_shadow_ignores_stroke_spread_outset() {
let mut pool = ShapesPool::new();
let frame_id = uid(1);
add_frame(
&mut pool,
frame_id,
Uuid::nil(),
(0.0, 0.0, 200.0, 120.0),
skia::Color::TRANSPARENT,
false,
);
{
let frame = pool.get_mut(&frame_id).unwrap();
frame.clear_fills();
frame.add_stroke(solid_stroke(StrokeKind::Inner, 5.0, skia::Color::BLACK));
frame.add_shadow(Shadow::new(
skia::Color::from_argb(128, 229, 16, 35),
4.0,
4.0,
(20.0, 20.0),
ShadowStyle::Drop,
false,
));
}
let svg = render(&pool, frame_id);
assert!(
!svg.contains("feMorphology"),
"container drop must not use feMorphology: {svg}"
);
assert!(
svg.contains(r#"dx="0""#) && svg.contains("feGaussianBlur"),
"container drop filter must blur only (offset is geometric): {svg}"
);
let filter_open = svg.find("")
.map(|i| filter_open + i)
.expect("close drop group");
let silhouette = &svg[filter_open..=filter_close];
assert!(
silhouette.contains("fill-rule=\"evenodd\""),
"silhouette must stay a stroke ring: {silhouette}"
);
// GPU ignores Rect/Frame stroke outset — ring must match content selrect
// (200×120), not an expanded 208×128 path.
assert!(
silhouette.contains("M200 ") || silhouette.contains("L200 "),
"stroke silhouette must not grow with spread: {silhouette}"
);
assert!(
!silhouette.contains("M204 ") && !silhouette.contains("L204 "),
"spread must not outset frame stroke geometry: {silhouette}"
);
insta::assert_snapshot!(svg);
}
#[test]
fn exports_a_group_with_two_rects_and_group_opacity() {
let mut pool = ShapesPool::new();
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_masked_group_as_alpha_mask() {
// First child is the mask; content must be under mask="url(#…)" and the
// mask shape must not appear as unmasked sibling content (Closes #11378).
let mut pool = ShapesPool::new();
let group_id = uid(1);
let mask_id = uid(2);
let content_id = uid(3);
add_masked_group(
&mut pool,
group_id,
Uuid::nil(),
(0.0, 0.0, 200.0, 120.0),
&[mask_id, content_id],
);
add_solid_rect(
&mut pool,
mask_id,
group_id,
(40.0, 20.0, 160.0, 100.0),
skia::Color::BLACK,
);
add_solid_rect(
&mut pool,
content_id,
group_id,
(0.0, 0.0, 200.0, 120.0),
skia::Color::from_rgb(61, 123, 255),
);
let svg = render(&pool, group_id);
assert!(
svg.contains("mask-type=\"alpha\"") && svg.contains("maskUnits=\"userSpaceOnUse\""),
"masked group must emit an alpha : {svg}"
);
assert!(
svg.contains("mask=\"url(#mask"),
"content must reference the alpha mask: {svg}"
);
// Mask body lives in ; content fill appears under the mask group.
let defs_end = svg.find(" ").expect("defs");
let mask_def = svg[..defs_end]
.find("")
.map(|i| filter_open + i)
.expect("silhouette group close");
let silhouette = &svg[filter_open..=filter_close];
assert!(
silhouette.contains(r#"rx="50""#) && silhouette.contains(r#"stroke-width="32""#),
"drop silhouette must be the rx=50 ellipse stroked by 2×spread: {silhouette}"
);
// True-size content ellipse remains under the mask.
assert!(
svg.contains(r#"rx="50""#) || svg.matches("rx=\"50\"").count() >= 1,
"content ellipse must keep true size rx=50: {svg}"
);
insta::assert_snapshot!(svg);
}
#[test]
fn masked_group_drop_silhouette_is_masked() {
// Circle mask + oversized rect content + group drop: silhouette must be
// masked (circular), not the raw rect. Offset stays geometric.
let mut pool = ShapesPool::new();
let group_id = uid(1);
let mask_id = uid(2);
let content_id = uid(3);
add_masked_group(
&mut pool,
group_id,
Uuid::nil(),
(80.0, 60.0, 200.0, 180.0),
&[mask_id, content_id],
);
add_solid_circle(
&mut pool,
mask_id,
group_id,
(80.0, 60.0, 200.0, 180.0),
skia::Color::BLACK,
);
add_solid_rect(
&mut pool,
content_id,
group_id,
(40.0, 45.0, 240.0, 185.0),
skia::Color::from_rgb(255, 0, 110),
);
{
let group = pool.get_mut(&group_id).unwrap();
group.add_shadow(Shadow::new(
skia::Color::from_argb(140, 0, 0, 0),
10.0,
0.0,
(8.0, 12.0),
ShadowStyle::Drop,
false,
));
}
let svg = render(&pool, group_id);
let filter_open = svg.find("filter=\"url(#fx").expect("drop filter");
let filter_close = svg[filter_open..]
.find(" ")
.map(|i| filter_open + i)
.expect("silhouette group close");
let silhouette = &svg[filter_open..=filter_close];
assert!(
silhouette.contains("mask=\"url(#mask"),
"group drop silhouette must be masked to the mask shape: {silhouette}\nfull: {svg}"
);
// Silhouette mask body is a circle (ellipse), not only the rect content.
let defs_end = svg.find("").expect("defs");
let defs = &svg[..defs_end];
assert!(
defs.contains(" body is empty.
let mut pool = ShapesPool::new();
let group_id = uid(1);
let mask_group = uid(2);
let path_id = uid(3);
let content_id = uid(4);
add_masked_group(
&mut pool,
group_id,
Uuid::nil(),
(60.0, 55.0, 220.0, 175.0),
&[mask_group, content_id],
);
add_group(
&mut pool,
mask_group,
group_id,
(60.0, 55.0, 220.0, 175.0),
&[path_id],
);
{
let g = pool.get_mut(&mask_group).unwrap();
// Mirrors apply-svg-derived / resolve-shape-fills for groups with :svg-attrs.
g.set_fills(vec![Fill::Solid(SolidColor(skia::Color::BLACK))]);
g.svg_attrs = Some(crate::shapes::SvgAttrs::default());
}
add_empty_fill_closed_path(&mut pool, path_id, mask_group, (70.0, 65.0, 210.0, 165.0));
add_solid_rect(
&mut pool,
content_id,
group_id,
(30.0, 40.0, 250.0, 190.0),
skia::Color::from_rgb(6, 214, 160),
);
let svg = render(&pool, group_id);
let defs_end = svg.find("").expect("defs");
let mask_def = &svg[svg.find("` with no children means inheritance failed.
assert!(
!mask_def.contains(
" "
) && (mask_def.contains(": {svg}"
);
let defs_end = svg.find("").expect("defs");
let mask_def = &svg[svg.find("= 2,
"mask group must paint both children into the mask: {mask_def}"
);
assert!(
mask_def.contains("fill-opacity"),
"soft mask child alpha must reach the : {mask_def}"
);
insta::assert_snapshot!(svg);
}
#[test]
fn loads_svg_raw_dom_like_wasm_upload() {
// Production paints svg-raw via Dom::render after set_shape_svg_raw_content.
// Native SkSVGCanvas does not serialize those draws, so the export string
// stays empty here; we assert Dom parse + that export does not panic.
let mut pool = ShapesPool::new();
let id = uid(1);
add_svg_raw(
&mut pool,
id,
Uuid::nil(),
(0.0, 0.0, 307.0, 243.0),
concat!(
r#""#,
r#"HOLA "#,
r#" "#,
),
);
let resources = crate::render::RenderResources::try_new_headless().expect("headless");
let font_manager = skia::FontMgr::from(resources.fonts.font_provider().clone());
{
let shape = pool.get_mut(&id).unwrap();
shape.update_svg_raw_content(font_manager);
assert!(shape.svg.is_some(), "Dom must parse like WASM upload");
}
let _svg = render(&pool, id);
}
#[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("").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::().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. `stroke_to_path` now unions them into 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 unioned into the expanded outline by `stroke_to_path`, so they
// must not be emitted as extra draws: overlapping draws would double the
// alpha of translucent strokes. The snapshot below covers the geometry.
assert_eq!(
svg.matches("` (SkSVGDevice keeps stroke style).
#[test]
fn exports_text_with_solid_center_stroke() {
let mut pool = ShapesPool::new();
let id = uid(1);
add_text_with_stroke(
&mut pool,
id,
(0.0, 0.0, 560.0, 240.0),
"HOLA",
200.0,
skia::Color::from_rgb(0xE1, 0x7F, 0xDA),
Some((StrokeKind::Center, 2.0, skia::Color::BLUE)),
);
let svg = render(&pool, id);
assert!(svg.contains("`.
#[test]
fn exports_text_with_alpha_center_stroke() {
let mut pool = ShapesPool::new();
let id = uid(1);
add_text_with_stroke(
&mut pool,
id,
(0.0, 0.0, 560.0, 240.0),
"HOLA",
200.0,
skia::Color::from_rgb(0xE1, 0x7F, 0xDA),
Some((
StrokeKind::Center,
2.0,
skia::Color::from_argb(128, 0, 0, 255),
)),
);
let svg = render(&pool, id);
// 128/255 → Skia emits ~0.5019608, not a rounded 0.5.
assert!(
svg.contains("opacity=\"0.5"),
"alpha center stroke must wrap stroke in : {svg}"
);
assert!(
svg.contains("stroke=\"blue\"") || svg.contains("stroke=\"#0000ff\""),
"stroke paint must stay fully opaque: {svg}"
);
insta::assert_snapshot!(svg);
}
/// Inner text stroke: double-width stroke clipped to the glyph silhouette.
#[test]
fn exports_text_with_solid_inner_stroke() {
let mut pool = ShapesPool::new();
let id = uid(1);
add_text_with_stroke(
&mut pool,
id,
(0.0, 0.0, 560.0, 240.0),
"HOLA",
200.0,
skia::Color::from_rgb(0xE1, 0x7F, 0xDA),
Some((StrokeKind::Inner, 2.0, skia::Color::BLUE)),
);
let svg = render(&pool, id);
assert!(svg.contains(": {svg}"
);
// SVG clipPath ignores strokes; the silhouette must be a luminance mask.
assert!(
svg.contains(" in the body — only the masked image.
let body = svg.split("").last().unwrap_or("");
assert!(
!body.contains("fill=\"#"),
"nofill text must not emit a solid fill: {svg}"
);
insta::assert_snapshot!(svg);
}
#[test]
fn exports_image_fill_on_text() {
let mut pool = ShapesPool::new();
let id = uid(1);
let image_id = uid(42);
add_image_text(
&mut pool,
id,
(0.0, 0.0, 560.0, 240.0),
"HOLA",
200.0,
image_id,
);
let svg = render_with(&pool, id, |resources| {
resources
.images
.set_source_url(image_id, TEST_IMAGE_URL.to_string());
});
assert!(
svg.contains(": {svg}"
);
assert!(
svg.contains("clip-path=\"url(#"),
"text image fill must be clipped to glyph silhouette: {svg}"
);
assert!(
svg.contains(" element: {svg}"
);
assert!(
svg.contains(TEST_IMAGE_URL),
"image href must use the registered URL: {svg}"
);
assert!(
svg.contains("preserveAspectRatio=\"xMidYMid slice\""),
"keep-aspect image fill must slice: {svg}"
);
assert!(
svg.contains("clip-path=\"url(#"),
"image fill must be clipped to shape geometry: {svg}"
);
assert!(
!svg.contains("data:image"),
"must not base64-embed the image: {svg}"
);
insta::assert_snapshot!(svg);
}
#[test]
fn exports_image_fill_bounds_transform() {
let mut pool = ShapesPool::new();
let id = uid(1);
let image_id = uid(42);
add_rect_with_fills(
&mut pool,
id,
Uuid::nil(),
(0.0, 0.0, 100.0, 80.0),
vec![Fill::Image(ImageFill::new_with_transform(
image_id,
255,
200,
100,
false,
Some(ImageFillTransform {
x: 0.25,
y: 0.5,
width: 0.5,
height: 0.25,
}),
))],
);
let svg = render_with(&pool, id, |resources| {
resources
.images
.set_source_url(image_id, TEST_IMAGE_URL.to_string());
});
assert!(
svg.contains(r#"x="25" y="40" width="50" height="20""#),
"linked image must keep the independent image bounds: {svg}"
);
}
#[test]
fn exports_mixed_solid_and_image_fills_in_order() {
// Image under a translucent solid; stretch (keep-aspect off); partial image
// opacity; shape not at the page origin (page translate in CTM).
let mut pool = ShapesPool::new();
let id = uid(1);
let image_id = uid(42);
add_rect_with_fills(
&mut pool,
id,
Uuid::nil(),
(100.0, 50.0, 508.0, 178.0),
vec![
// fills[0] topmost — solid blue @ 50%
Fill::Solid(SolidColor(skia::Color::from_argb(128, 0, 63, 255))),
// fills[1] underneath — linked image, stretch, ~50% opacity
Fill::Image(ImageFill::new(image_id, 128, 400, 300, false)),
],
);
let svg = render_with(&pool, id, |resources| {
resources
.images
.set_source_url(image_id, TEST_IMAGE_URL.to_string());
});
let image_pos = svg.find(": {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 are part of the clip silhouette (unioned into 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");
// `stroke_to_path` unions the caps into the outline, so the clip is a
// single path covering both.
assert_eq!(
clip.matches(" 2,
"pattern must leave more than the two band contours: {svg}"
);
}
#[test]
fn exports_rect_with_per_side_dashed_center_stroke() {
let mut pool = ShapesPool::new();
let id = uid(1);
let mut stroke = dashed_stroke(
StrokeKind::Center,
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, 240.0, 180.0), stroke);
let svg = render(&pool, id);
assert_per_side_pattern(&svg, "#1040FF");
insta::assert_snapshot!(with_stable_clip_ids(&svg));
}
#[test]
fn exports_rect_with_per_side_dotted_center_stroke() {
let mut pool = ShapesPool::new();
let id = uid(1);
let mut stroke = dotted_stroke(
StrokeKind::Center,
16.0,
skia::Color::from_rgb(0x10, 0x40, 0xff),
);
stroke.widths = Some([6.0, 16.0, 6.0, 16.0]);
add_stroked_rect(&mut pool, id, Uuid::nil(), (0.0, 0.0, 240.0, 180.0), stroke);
let svg = render(&pool, id);
assert_per_side_pattern(&svg, "#1040FF");
insta::assert_snapshot!(with_stable_clip_ids(&svg));
}
#[test]
fn exports_rect_with_per_side_dotted_inner_stroke() {
let mut pool = ShapesPool::new();
let id = uid(1);
// Inner dots have radius = width against an advance of width + 5, so they
// only stay discrete below width 5.
let mut stroke = dotted_stroke(
StrokeKind::Inner,
4.0,
skia::Color::from_rgb(0x10, 0x40, 0xff),
);
stroke.widths = Some([2.0, 4.0, 2.0, 4.0]);
add_stroked_rect(&mut pool, id, Uuid::nil(), (0.0, 0.0, 60.0, 40.0), stroke);
let svg = render(&pool, id);
assert_per_side_pattern(&svg, "#1040FF");
insta::assert_snapshot!(with_stable_clip_ids(&svg));
}
#[test]
fn exports_rounded_rect_with_per_side_dashed_outer_stroke() {
let mut pool = ShapesPool::new();
let id = uid(1);
let mut stroke = dashed_stroke(
StrokeKind::Outer,
12.0,
skia::Color::from_rgb(0x10, 0x40, 0xff),
);
stroke.widths = Some([6.0, 12.0, 18.0, 24.0]);
add_stroked_rect_with_radius(
&mut pool,
id,
Uuid::nil(),
(0.0, 0.0, 240.0, 180.0),
24.0,
stroke,
);
let svg = render(&pool, id);
assert_per_side_pattern(&svg, "#1040FF");
insta::assert_snapshot!(with_stable_clip_ids(&svg));
}
#[test]
fn per_side_dashed_stroke_skips_zero_width_sides() {
let mut pool = ShapesPool::new();
let id = uid(1);
let mut stroke = dashed_stroke(
StrokeKind::Inner,
16.0,
skia::Color::from_rgb(0x10, 0x40, 0xff),
);
// Only the top and bottom sides are drawn.
stroke.widths = Some([16.0, 0.0, 16.0, 0.0]);
add_stroked_rect(&mut pool, id, Uuid::nil(), (0.0, 0.0, 240.0, 180.0), stroke);
let svg = render(&pool, id);
assert_per_side_pattern(&svg, "#1040FF");
insta::assert_snapshot!(with_stable_clip_ids(&svg));
}
/// Widths and colours for a rect whose four sides are four separate strokes.
fn per_side_colour_sides() -> [(f32, skia::Color); 4] {
[
(10.0, skia::Color::from_rgb(0xe1, 0x1d, 0x48)),
(20.0, skia::Color::from_rgb(0x05, 0x96, 0x69)),
(15.0, skia::Color::from_rgb(0x25, 0x63, 0xeb)),
(5.0, skia::Color::from_rgb(0xf5, 0x9e, 0x0b)),
]
}
#[test]
fn miters_a_rect_whose_sides_are_separate_strokes() {
let mut pool = ShapesPool::new();
let id = uid(1);
add_rect_with_per_side_strokes(
&mut pool,
id,
Uuid::nil(),
(0.0, 0.0, 220.0, 140.0),
0.0,
per_side_colour_sides(),
);
let svg = render(&pool, id);
// Each side is clipped to its wedge, so every stroke carries a clip path.
assert_eq!(
svg.matches(") -> ShapesPool {
let mut pool = ShapesPool::new();
add_group(
&mut pool,
uid(1),
Uuid::nil(),
(0.0, 0.0, 100.0, 100.0),
&[uid(2)],
);
pool.get_mut(&uid(1)).unwrap().set_fills(group_fills);
add_empty_fill_closed_path(&mut pool, uid(2), uid(1), (10.0, 10.0, 90.0, 90.0));
pool
}
fn raster_png(pool: &ShapesPool, id: Uuid) -> Vec {
let (bytes, _, _) = raster_png_size(pool, id);
bytes
}
fn raster_png_size(pool: &ShapesPool, id: Uuid) -> (Vec, i32, i32) {
let mut resources =
crate::render::RenderResources::try_new_headless().expect("headless resources");
crate::render::raster::render_to_raster(
&mut resources,
&id,
pool,
1.0,
crate::render::raster::RasterFormat::Png,
)
.expect("raster export")
}
/// #12388: multi-element (headless) raster must include a board's own drop
/// shadow, matching single-export sizing (spread 4 → +4px each side).
#[test]
fn raster_export_of_board_includes_own_drop_shadow_spread() {
let mut pool = ShapesPool::new();
let frame_id = uid(1);
add_frame(
&mut pool,
frame_id,
Uuid::nil(),
(0.0, 0.0, 200.0, 120.0),
skia::Color::from_rgb(240, 240, 240),
true,
);
{
let frame = pool.get_mut(&frame_id).unwrap();
frame.add_shadow(Shadow::new(
skia::Color::from_rgb(0, 200, 0),
0.0,
4.0,
(0.0, 0.0),
ShadowStyle::Drop,
false,
));
}
let (_, width, height) = raster_png_size(&pool, frame_id);
assert_eq!(
(width, height),
(208, 128),
"board raster must grow by drop-shadow spread, not stay at selrect"
);
}
/// Overflowing children stay cropped; only the board's own effects expand
/// the headless raster page (classic exporter board-box rule).
#[test]
fn raster_export_of_board_crops_overflowing_child_but_keeps_own_shadow() {
let mut pool = ShapesPool::new();
let frame_id = uid(1);
let child = uid(2);
add_frame(
&mut pool,
frame_id,
Uuid::nil(),
(0.0, 0.0, 200.0, 120.0),
skia::Color::from_rgb(240, 240, 240),
true,
);
{
let frame = pool.get_mut(&frame_id).unwrap();
frame.add_shadow(Shadow::new(
skia::Color::from_rgb(0, 200, 0),
0.0,
4.0,
(0.0, 0.0),
ShadowStyle::Drop,
false,
));
frame.add_child(child);
}
add_solid_rect(
&mut pool,
child,
frame_id,
(150.0, 80.0, 280.0, 200.0),
skia::Color::from_rgb(255, 0, 0),
);
let (_, width, height) = raster_png_size(&pool, frame_id);
assert_eq!(
(width, height),
(208, 128),
"overflowing child must not grow board raster; own shadow still must"
);
}
#[test]
fn exported_child_inherits_ancestor_group_fill() {
let pool = group_with_empty_path(vec![Fill::Solid(SolidColor(INHERITED_GROUP_COLOR))]);
let svg = render(&pool, uid(2));
assert!(
svg.to_ascii_lowercase().contains("fill=\"#112234\""),
"child export must paint the inherited group fill: {svg}"
);
}
#[test]
fn exported_child_does_not_inherit_past_a_frame() {
let mut pool = ShapesPool::new();
let group_id = uid(1);
let frame_id = uid(2);
let path_id = uid(3);
add_group(
&mut pool,
group_id,
Uuid::nil(),
(0.0, 0.0, 100.0, 100.0),
&[frame_id],
);
pool.get_mut(&group_id)
.unwrap()
.set_fills(vec![Fill::Solid(SolidColor(INHERITED_GROUP_COLOR))]);
add_frame(
&mut pool,
frame_id,
group_id,
(0.0, 0.0, 100.0, 100.0),
skia::Color::WHITE,
false,
);
{
let frame = pool.get_mut(&frame_id).unwrap();
frame.set_fills(vec![]);
frame.add_child(path_id);
}
add_empty_fill_closed_path(&mut pool, path_id, frame_id, (10.0, 10.0, 90.0, 90.0));
let svg = render(&pool, path_id);
assert!(
!svg.to_ascii_lowercase().contains("#112234"),
"a frame breaks group fill inheritance: {svg}"
);
}
#[test]
fn raster_export_of_child_paints_inherited_group_fill() {
let fill = Fill::Solid(SolidColor(INHERITED_GROUP_COLOR));
let inherited = group_with_empty_path(vec![fill.clone()]);
let mut own = group_with_empty_path(vec![]);
own.get_mut(&uid(2)).unwrap().set_fills(vec![fill]);
let bare = group_with_empty_path(vec![]);
let inherited_png = raster_png(&inherited, uid(2));
assert_eq!(inherited_png, raster_png(&own, uid(2)));
assert_ne!(inherited_png, raster_png(&bare, uid(2)));
}
/// Reads one pixel of an encoded raster export as unpremultiplied RGBA.
fn raster_pixel(png: &[u8], (x, y): (i32, i32)) -> skia::Color {
let image = skia::Image::from_encoded(skia::Data::new_copy(png)).expect("decode png");
let info = skia::ImageInfo::new(
(1, 1),
skia::ColorType::RGBA8888,
skia::AlphaType::Unpremul,
None,
);
let mut px = [0u8; 4];
assert!(image.read_pixels(
&info,
&mut px,
4,
(x, y),
skia::image::CachingHint::Disallow
));
skia::Color::from_argb(px[3], px[0], px[1], px[2])
}
/// #12334: headless raster/PDF must draw a masked group's inner shadow
/// inside the mask outline, as the GPU single export does.
#[test]
fn raster_export_of_masked_group_draws_inner_shadow() {
let mut pool = ShapesPool::new();
let group_id = uid(1);
let mask_id = uid(2);
let content_id = uid(3);
let blue = skia::Color::from_rgb(0, 63, 255);
add_masked_group(
&mut pool,
group_id,
Uuid::nil(),
(0.0, 0.0, 100.0, 100.0),
&[mask_id, content_id],
);
add_solid_circle(
&mut pool,
mask_id,
group_id,
(0.0, 0.0, 100.0, 100.0),
skia::Color::BLACK,
);
add_solid_rect(
&mut pool,
content_id,
group_id,
(0.0, 0.0, 100.0, 100.0),
blue,
);
pool.get_mut(&group_id).unwrap().add_shadow(Shadow::new(
skia::Color::BLACK,
0.0,
0.0,
(20.0, 0.0),
ShadowStyle::Inner,
false,
));
let (png, width, height) = raster_png_size(&pool, group_id);
assert_eq!((width, height), (100, 100));
// Left edge: the shifted silhouette leaves a band the shadow fills.
assert_eq!(raster_pixel(&png, (8, 50)), skia::Color::BLACK);
assert_eq!(raster_pixel(&png, (50, 50)), blue);
// Outside the circle the mask still clears everything.
assert_eq!(raster_pixel(&png, (3, 3)).a(), 0);
}
#[test]
fn exports_vertical_text_as_glyph_outlines() {
let mut pool = ShapesPool::new();
let id = uid(1);
add_solid_text(
&mut pool,
id,
(0.0, 0.0, 240.0, 560.0),
"HOLA",
200.0,
skia::Color::from_rgb(0, 63, 255),
);
let shape = pool.get_mut(&id).expect("text shape");
if let crate::shapes::Type::Text(content) = &mut shape.shape_type {
for paragraph in content.paragraphs_mut() {
paragraph.set_writing_mode(crate::shapes::WritingMode::VerticalRl);
}
}
let svg = render(&pool, id);
assert!(
!svg.contains(": {svg}"
);
assert!(
svg.contains("