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("