//! SVG import keeps what the canvas edits live: `` becomes a pattern swatch, `` //! with `` a symbol and its instances, blurs and drop-shadow chains live effects; and //! export → import keeps them. // Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md › Robustness). #![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] use std::sync::Arc; use serde_json::json; use vectorcraft_color::{BlendMode, Color, Paint}; use vectorcraft_doc::{Appearance, Document, Effect, Node, NodeKind}; use vectorcraft_effects::RasterFx; use vectorcraft_geom::{Point, Rect, shapes}; use vectorcraft_svg::{ExportOptions, export, import, import_with_report}; use vectorcraft_testkit::raster::{Image, assert_similar, render_artboard}; fn svg(body: &str) -> String { format!(r#"{body}"#) } fn open(body: &str) -> (Document, Vec) { import_with_report(&svg(body)).unwrap() } /// All non-layer nodes in paint order. fn art(d: &Document) -> Vec<&Node> { let mut v = Vec::new(); d.walk(|n| { if !n.is_layer() { v.push(n) } }); v } /// The SVG rendered by resvg on white at 1 px/pt. fn resvg_render(svg: &str, w: u32, h: u32) -> Image { let tree = resvg::usvg::Tree::from_str(svg, &resvg::usvg::Options::default()).expect("parse"); let mut pm = resvg::tiny_skia::Pixmap::new(w, h).unwrap(); pm.fill(resvg::tiny_skia::Color::WHITE); resvg::render(&tree, resvg::tiny_skia::Transform::identity(), &mut pm.as_mut()); Image { width: w, height: h, rgba: pm.data().to_vec() } } /// The effects of the only object. fn effects(d: &Document) -> Vec { let a = art(d); assert_eq!(a.len(), 1, "{a:?}"); a[0].appearance.effects.clone() } #[test] fn a_checker_pattern_becomes_a_pattern_swatch() { let (d, w) = open( r##" "##, ); assert!(w.is_empty(), "{w:?}"); assert_eq!(d.patterns.len(), 1); let p = &d.patterns[0]; assert_eq!((p.name.as_str(), p.tile), ("Checker", Rect::new(0.0, 0.0, 20.0, 20.0))); assert!(matches!(art(&d)[0].appearance.fill_paint(), Paint::Pattern { pattern, .. } if pattern == "Checker")); assert_similar( &render_artboard(&d), &resvg_render( &svg( r##""##, ), 200, 120, ), 24.0, 0.01, ); } const SYMBOL: &str = r##""##; #[test] fn two_uses_give_one_symbol_and_two_instances() { let body = format!(r##"{SYMBOL}"##); let (d, w) = open(&body); assert!(w.is_empty(), "{w:?}"); assert_eq!(d.symbols.len(), 1); assert_eq!(d.symbols[0].name, "Gold Star"); assert!(matches!(&d.symbols[0].art.kind, NodeKind::Group { children, .. } if children.len() == 2)); let instances: Vec<_> = art(&d) .into_iter() .filter_map(|n| match &n.kind { NodeKind::SymbolInstance { symbol, xf } => Some((symbol.clone(), *xf)), _ => None, }) .collect(); assert_eq!(instances.len(), 2, "{:?}", art(&d)); assert!(instances.iter().all(|(s, _)| s == "Gold Star")); assert!((instances[0].1 * Point::ORIGIN).distance(Point::new(20.0, 30.0)) < 1e-9); assert!((instances[1].1 * Point::ORIGIN).distance(Point::new(100.0, 50.0)) < 1e-9); assert_similar(&render_artboard(&d), &resvg_render(&svg(&body), 200, 120), 24.0, 0.003); } #[test] fn an_instance_is_measured_by_its_symbols_art() { let (d, _) = open(&format!(r##"{SYMBOL}"##)); let id = art(&d)[0].id; // The art, (0, 0) to (20, 20), drawn from (20, 30): not the 20 × 20 square around the origin, // which the same transform places from (10, 20) to (30, 40). assert_eq!(d.bounds_of(&[id], false), Some(Rect::new(20.0, 30.0, 40.0, 50.0))); assert_eq!(d.bounds_of(&[id], true), Some(Rect::new(20.0, 30.0, 40.0, 50.0))); } #[test] fn uses_the_symbol_cant_stand_for_stay_art() { // Inherited paint: the second use shows the symbol in another colour. Scaled: the canvas keeps // stroke weights as they are. let body = r##" "##; let (d, _) = open(body); assert_eq!(d.symbols.len(), 1); let a = art(&d); let instances = a.iter().filter(|n| matches!(n.kind, NodeKind::SymbolInstance { .. })).count(); assert_eq!(instances, 2, "the red ones not scaled: {a:?}"); assert_similar(&render_artboard(&d), &resvg_render(&svg(body), 200, 120), 24.0, 0.003); } #[test] fn symbols_round_trip_through_export() { let (d, _) = open(&format!(r##"{SYMBOL}"##)); let back = import(&export(&d, &ExportOptions::default())).unwrap(); assert_eq!(back.symbols.len(), 1); assert_eq!(back.symbols[0].name, "Gold Star"); assert_eq!(art(&back).iter().filter(|n| matches!(n.kind, NodeKind::SymbolInstance { .. })).count(), 2); assert_similar(&render_artboard(&d), &render_artboard(&back), 8.0, 0.001); } /// `name="…"` of the first element `tag` in `svg`, as a number. fn number_attr(svg: &str, tag: &str, name: &str) -> f64 { let at = svg.find(&format!("<{tag} ")).unwrap_or_else(|| panic!("no <{tag}> in {svg}")); let rest = &svg[at..]; let from = rest.find(&format!(" {name}=\"")).unwrap() + name.len() + 3; rest[from..from + rest[from..].find('"').unwrap()].parse().unwrap() } #[test] fn an_instances_shadow_filter_and_the_art_export_cover_its_symbols_art() { let (mut d, _) = open(&format!(r##"{SYMBOL}"##)); let id = art(&d)[0].id; let shadow = Effect { id: "stylize.dropShadow".into(), params: json!({"x": 0, "y": 0, "blur": 0}), visible: true }; d.node_mut(id).unwrap().appearance.effects.push(shadow); // The filter region holds the art, (20, 30) to (40, 50). let svg = export(&d, &ExportOptions::default()); let (x, y) = (number_attr(&svg, "filter", "x"), number_attr(&svg, "filter", "y")); let (w, h) = (number_attr(&svg, "filter", "width"), number_attr(&svg, "filter", "height")); assert!(x <= 20.0 && y <= 30.0 && x + w >= 40.0 && y + h >= 50.0, "({x}, {y}) {w} × {h}"); // Exported without an artboard, the file is the art's size and the instance draws the art from // the file's top left corner. d.node_mut(id).unwrap().appearance.effects.clear(); let svg = export(&d, &ExportOptions { artboard: None, ..ExportOptions::default() }); assert_eq!((number_attr(&svg, "svg", "width"), number_attr(&svg, "svg", "height")), (20.0, 20.0), "{svg}"); let back = import(&svg).unwrap(); let NodeKind::SymbolInstance { xf, .. } = &art(&back)[0].kind else { panic!("an instance: {svg}") }; assert!((*xf * Point::ORIGIN).distance(Point::ORIGIN) < 1e-9, "{svg}"); } #[test] fn a_figma_blurred_shape_filter_becomes_gaussian_blur() { let (d, w) = open( r##""##, ); assert!(w.is_empty(), "{w:?}"); let fx = effects(&d); assert_eq!(fx.len(), 1); assert_eq!((fx[0].id.as_str(), &fx[0].params), ("blur.gaussian", &json!({"radius": 6.0}))); } #[test] fn figma_blurred_shapes_inside_mask_import_with_effects_and_no_warnings() { let body = r##" "##; let (d, w) = open(body); assert!(w.is_empty(), "{w:?}"); let mut blurs = vec![]; d.walk(|n| { for e in &n.appearance.effects { if e.id == "blur.gaussian" { blurs.push(e.params["radius"].as_f64().unwrap()); } } }); assert_eq!(blurs, vec![4.0, 8.0]); } #[test] fn a_gaussian_blur_becomes_the_effect_with_its_radius() { let (d, w) = open( r##""##, ); assert!(w.is_empty(), "{w:?}"); let fx = effects(&d); assert_eq!(fx.len(), 1); assert_eq!((fx[0].id.as_str(), &fx[0].params), ("blur.gaussian", &json!({"radius": 6.0}))); // In a scaled group, the radius scales too. let (d, _) = open( r##""##, ); assert_eq!(effects(&d)[0].params["radius"], json!(12.0)); } /// The one drop shadow of the only object: (dx, dy, blur, colour, opacity %). fn shadow(d: &Document) -> (f64, f64, f64, Color, f32) { let fx = vectorcraft_effects::raster_effects(&effects(d)); match fx.as_slice() { [RasterFx::DropShadow { dx, dy, blur, color, opacity, mode, darkness }] => { assert_eq!(*mode, BlendMode::Normal, "filters composite normally"); assert_eq!(*darkness, None); (*dx, *dy, *blur, *color, (opacity * 100.0).round()) } other => panic!("{other:?}"), } } #[test] fn drop_shadow_chains_become_drop_shadows() { let rect = r##""##; let chains = [ // feDropShadow. r##""##, // Offset, blur, flood, composite, merge. r##""##, // Flood in the source, blur, offset, the source over it. r##""##, // A colour matrix paints the blurred, moved silhouette. r##""##, ]; for chain in chains { let (d, w) = open(&format!(r#"{chain}{rect}"#)); assert!(w.is_empty(), "{chain}: {w:?}"); let (dx, dy, blur, color, opacity) = shadow(&d); assert_eq!((dx, dy, blur, opacity), (4.0, 5.0, 4.0, 50.0), "{chain}"); assert_eq!(color.to_hex(), "#ff0000", "{chain}"); } } /// The object blurred, moved and its colors scaled under it is a drop shadow with Darkness when /// the blur and the scale work on sRGB values, or when the scale is 0; otherwise, on linear RGB /// values (the SVG default), it is reported. A blur on linear values whose result goes unused /// does not count. #[test] fn a_darkened_copy_of_the_object_becomes_a_drop_shadow_with_darkness() { let chain = |space: &str, transfer: &str, slope: f64| { format!( r##""## ) }; let srgb = r#" color-interpolation-filters="sRGB""#; // The scale 0 makes black whatever values it works on. for (space, slope, darkness) in [(srgb, 0.5, 50.0), ("", 0.0, 100.0)] { let (d, w) = open(&chain(space, "", slope)); assert!(w.is_empty(), "{w:?}"); let fx = effects(&d); let p = &fx[0].params; assert_eq!( (fx[0].id.as_str(), &p["shade"], &p["darkness"], &p["opacity"]), ("stylize.dropShadow", &json!("darkness"), &json!(darkness), &json!(60.0)) ); assert_eq!((&p["x"], &p["y"], &p["blur"]), (&json!(4.0), &json!(5.0), &json!(4.0))); } // Scaled on linear values, or blurred on them before an sRGB scale. for (space, transfer) in [("", ""), ("", srgb)] { let (d, w) = open(&chain(space, transfer, 0.5)); assert!(w.iter().any(|w| w.contains("filter")), "{transfer}: {w:?}"); assert!(effects(&d).is_empty()); } } #[test] fn other_filters_are_reported() { let (d, w) = open(r##""##); assert!(w.iter().any(|w| w.contains("filter")), "{w:?}"); assert!(effects(&d).is_empty()); } #[test] fn a_filter_and_a_clip_on_one_object_filter_first() { let body = r##" "##; let (d, w) = open(body); assert!(w.is_empty(), "{w:?}"); let a = art(&d); assert!(matches!(a[0].kind, NodeKind::Group { clip: true, .. }) && a[0].appearance.effects.is_empty()); assert!(a.iter().any(|n| n.appearance.effects.iter().any(|e| e.id == "blur.gaussian"))); assert_similar(&render_artboard(&d), &resvg_render(&svg(body), 200, 120), 40.0, 0.02); } #[test] fn exported_effects_import_as_the_same_effects() { let fx = |id: &str, p: serde_json::Value| Effect { id: id.into(), params: p, visible: true }; let cases = [ vec![fx("stylize.dropShadow", json!({"x": 7.0, "y": -3.0, "blur": 5.0, "opacity": 60.0, "color": "#203040"}))], vec![fx("stylize.dropShadow", json!({"x": 7.0, "y": -3.0, "blur": 5.0, "opacity": 60.0, "shade": "darkness", "darkness": 40.0}))], vec![fx("stylize.outerGlow", json!({"blur": 8.0, "color": "#ffcc00", "opacity": 80.0}))], vec![fx("stylize.innerGlow", json!({"blur": 6.0, "color": "#ffffff", "source": "edge", "mode": "normal"}))], vec![fx("stylize.innerGlow", json!({"blur": 6.0, "color": "#00ff00", "source": "center", "mode": "multiply"}))], vec![fx("stylize.feather", json!({"radius": 4.0}))], // Stacked: the first innermost. vec![fx("blur.gaussian", json!({"radius": 3.0})), fx("stylize.dropShadow", json!({"x": 2.0, "y": 2.0, "blur": 1.0}))], ]; for effects in cases { let mut d = Document::new(200.0, 120.0); let mut n = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(50.0, 30.0, 110.0, 90.0)), Appearance::default_art()); n.appearance.effects = effects.clone(); let l = d.layers[0].id; d.insert(Some(l), 0, n).unwrap(); let (back, w) = import_with_report(&export(&d, &ExportOptions::default())).unwrap(); assert!(w.is_empty(), "{w:?}"); let got = vectorcraft_effects::raster_effects(&art(&back)[0].appearance.effects); assert_eq!(got, vectorcraft_effects::raster_effects(&effects), "{effects:?}"); } } #[test] fn a_drop_shadow_with_darkness_exports_as_the_canvas_draws_it() { let mut d = Document::new(200.0, 120.0); let mut n = Node::path( d.alloc_id(), shapes::rectangle(Rect::new(40.0, 20.0, 100.0, 70.0)), Appearance::basic(Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::None, 0.0), ); let shadow = json!({"x": 30.0, "y": 30.0, "blur": 0.0, "opacity": 100.0, "mode": "normal", "shade": "darkness", "darkness": 50.0}); n.appearance.effects = vec![Effect { id: "stylize.dropShadow".into(), params: shadow, visible: true }]; let l = d.layers[0].id; d.insert(Some(l), 0, n).unwrap(); let svg = export(&d, &ExportOptions::default()); let (canvas, exported) = (render_artboard(&d), resvg_render(&svg, 200, 120)); // Where only the shadow lies: the red at half its value. for img in [&canvas, &exported] { let [r, g, b] = img.over_white(115, 90); assert!(r.abs_diff(128) <= 2 && g <= 2 && b <= 2, "{:?}", [r, g, b]); } assert_similar(&canvas, &exported, 40.0, 0.02); } #[test] fn an_exported_outer_glow_round_faint_art_draws_as_the_canvas_does() { // GUARDS: the export's glow blurs a solid silhouette like the renderer, not the art's faint alpha. let mut d = Document::new(200.0, 120.0); let mut n = Node::path( d.alloc_id(), shapes::rectangle(Rect::new(70.0, 40.0, 130.0, 80.0)), Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0), ); n.appearance.fill_mut().unwrap().opacity = 0.2; n.appearance.effects = vec![Effect { id: "stylize.outerGlow".into(), params: json!({"blur": 12.0, "color": "#ff0000", "opacity": 100.0, "mode": "normal"}), visible: true, }]; let l = d.layers[0].id; d.insert(Some(l), 0, n).unwrap(); let svg = export(&d, &ExportOptions::default()); assert_similar(&render_artboard(&d), &resvg_render(&svg, 200, 120), 40.0, 0.02); } #[test] fn symbols_and_effects_export_import_export_is_stable() { // A doc with a symbol instance carrying a shadow: export → import → export is stable. let mut d = Document::new(200.0, 120.0); let dot = Node::path( d.alloc_id(), shapes::ellipse(Rect::new(0.0, 0.0, 20.0, 20.0)), Appearance::basic(Paint::solid(Color::rgb8(200, 30, 30)), Paint::None, 0.0), ); d.symbols.push(vectorcraft_doc::Symbol { name: "Dot".into(), art: Arc::new(dot) }); let l = d.layers[0].id; for x in [10.0, 60.0] { let id = d.alloc_id(); d.insert( Some(l), usize::MAX, Node::new(id, NodeKind::SymbolInstance { symbol: "Dot".into(), xf: vectorcraft_geom::Affine::translate((x, 40.0)) }), ) .unwrap(); } let mut blurred = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(120.0, 30.0, 180.0, 90.0)), Appearance::default_art()); blurred.appearance.effects.push(Effect { id: "stylize.dropShadow".into(), params: json!({"x": 3.0, "y": 3.0, "blur": 2.0}), visible: true }); d.insert(Some(l), usize::MAX, blurred).unwrap(); let opts = ExportOptions { object_ids: vectorcraft_svg::ObjectIds::Minimal, ..Default::default() }; let first = export(&d, &opts); let second = export(&import(&first).unwrap(), &opts); let strip = |s: &str| s.lines().filter(|l| !l.contains("")).collect::<Vec<_>>().join("\n"); assert_eq!(strip(&first), strip(&second)); }