vectorcraft/crates/svg/tests/roundtrip.rs

959 lines
46 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal 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 vectorcraft_color::{BlendMode, Color, Gradient, GradientGeom, GradientKind, GradientPaint, Paint};
use vectorcraft_doc::{
Appearance, AppearanceItem, CharStyle, Dash, Document, FillLayer, ImageBlob, ImageObject, LineCap, LineJoin, Node, NodeId, NodeKind, StrokeAlign,
StrokeLayer, TextKind, TextObject,
};
use vectorcraft_geom::{Affine, FillRule, PathData, Point, Rect, shapes};
use vectorcraft_svg::{ExportOptions, ObjectIds, Styling, export, import, import_with_report};
fn doc_with(nodes: Vec<Node>) -> Document {
let mut d = Document::new(200.0, 200.0);
let l = d.layers[0].id;
for n in nodes {
d.insert(Some(l), usize::MAX, n).unwrap();
}
d
}
fn rect_node(d: &mut Document, r: Rect, ap: Appearance) -> Node {
Node::path(d.alloc_id(), shapes::rectangle(r), ap)
}
fn roundtrip(d: &Document) -> Document {
let s = export(d, &ExportOptions::default());
import(&s).unwrap_or_else(|e| panic!("{e}\n{s}"))
}
/// 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
}
fn close_rect(a: Rect, b: Rect, tol: f64) -> bool {
(a.x0 - b.x0).abs() < tol && (a.y0 - b.y0).abs() < tol && (a.x1 - b.x1).abs() < tol && (a.y1 - b.y1).abs() < tol
}
fn solid(hex: &str) -> Paint {
Paint::solid(Color::from_hex(hex).unwrap())
}
#[test]
fn rect_roundtrip_bounds_and_colours() {
let mut d = Document::new(200.0, 200.0);
let n = rect_node(&mut d, Rect::new(10.0, 20.0, 110.0, 70.0), Appearance::basic(solid("#ff8000"), solid("#0000ff"), 2.0));
let d = doc_with(vec![n]);
let r = roundtrip(&d);
assert_eq!(r.node_count(), d.node_count());
let a = art(&r);
assert!(close_rect(a[0].geometric_bounds().unwrap(), Rect::new(10.0, 20.0, 110.0, 70.0), 0.01));
assert_eq!(a[0].appearance.fill_paint().color().unwrap().to_hex(), "#ff8000");
assert_eq!(a[0].appearance.stroke_paint().color().unwrap().to_hex(), "#0000ff");
assert!((a[0].appearance.stroke_width() - 2.0).abs() < 1e-6);
assert_eq!(r.artboards[0].rect, Rect::new(0.0, 0.0, 200.0, 200.0));
}
#[test]
fn curves_roundtrip() {
let mut d = Document::new(200.0, 200.0);
let id = d.alloc_id();
let n = Node::path(id, shapes::ellipse(Rect::new(20.0, 30.0, 120.0, 90.0)), Appearance::default_art());
let d = doc_with(vec![n]);
let s = export(&d, &ExportOptions::default());
assert!(s.contains(" C"), "{s}");
let r = roundtrip(&d);
let a = art(&r);
assert!(close_rect(a[0].geometric_bounds().unwrap(), Rect::new(20.0, 30.0, 120.0, 90.0), 0.01));
assert_eq!(a[0].path_data().unwrap().anchor_count(), 4);
assert!(a[0].path_data().unwrap().is_closed());
}
#[test]
fn groups_and_names_roundtrip() {
let mut d = Document::new(200.0, 200.0);
let a = rect_node(&mut d, Rect::new(0.0, 0.0, 10.0, 10.0), Appearance::default_art());
let mut b = rect_node(&mut d, Rect::new(20.0, 0.0, 30.0, 10.0), Appearance::default_art());
b.name = Some("Box B".into());
let gid = d.alloc_id();
let mut g = Node::group(gid, vec![Arc::new(a), Arc::new(b)]);
g.name = Some("My Group".into());
let d = doc_with(vec![g]);
let s = export(&d, &ExportOptions::default());
assert!(s.contains("id=\"My_Group\"") && s.contains("id=\"Box_B\"") && s.contains("id=\"Layer_1\""), "{s}");
let r = roundtrip(&d);
assert_eq!(r.node_count(), d.node_count());
// data-name carries the names back exactly.
assert_eq!(r.layers[0].name.as_deref(), Some("Layer 1"));
let a = art(&r);
assert!(matches!(a[0].kind, NodeKind::Group { clip: false, .. }));
assert_eq!(a[0].name.as_deref(), Some("My Group"));
assert!(close_rect(a[0].geometric_bounds().unwrap(), Rect::new(0.0, 0.0, 30.0, 10.0), 0.01));
}
#[test]
fn multiple_layers_roundtrip() {
let mut d = Document::new(100.0, 100.0);
let n1 = rect_node(&mut d, Rect::new(0.0, 0.0, 10.0, 10.0), Appearance::default_art());
let l1 = d.layers[0].id;
d.insert(Some(l1), 0, n1).unwrap();
let l2 = d.add_layer(Some("Top"));
let n2 = rect_node(&mut d, Rect::new(5.0, 5.0, 50.0, 50.0), Appearance::default_art());
d.insert(Some(l2), 0, n2).unwrap();
let r = roundtrip(&d);
assert_eq!(r.layers.len(), 2);
assert_eq!(r.layers[1].name.as_deref(), Some("Top"));
assert_eq!(r.node_count(), d.node_count());
}
#[test]
fn clip_group_roundtrip() {
let mut d = Document::new(200.0, 200.0);
let cid = d.alloc_id();
let clip = Node::new(
cid,
NodeKind::Path { path: shapes::ellipse(Rect::new(0.0, 0.0, 50.0, 50.0)), rule: FillRule::NonZero, live: None, clipping: true, guide: false },
);
let a = rect_node(&mut d, Rect::new(-20.0, -20.0, 80.0, 80.0), Appearance::basic(solid("#00ff00"), Paint::None, 1.0));
let gid = d.alloc_id();
let g = Node::new(gid, NodeKind::Group { children: vec![Arc::new(clip), Arc::new(a)], clip: true });
let d = doc_with(vec![g]);
let s = export(&d, &ExportOptions::default());
assert!(s.contains("<clipPath") && s.contains("clip-path=\"url(#"), "{s}");
let r = roundtrip(&d);
assert_eq!(r.node_count(), d.node_count());
let a = art(&r);
assert!(matches!(a[0].kind, NodeKind::Group { clip: true, .. }));
assert!(matches!(a[1].kind, NodeKind::Path { clipping: true, .. }));
assert!(close_rect(a[0].geometric_bounds().unwrap(), Rect::new(0.0, 0.0, 50.0, 50.0), 0.01));
assert_eq!(a[2].appearance.fill_paint().color().unwrap().to_hex(), "#00ff00");
}
#[test]
fn compound_evenodd_roundtrip() {
let mut d = Document::new(200.0, 200.0);
let o = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), Appearance::default());
let i = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(25.0, 25.0, 75.0, 75.0)), Appearance::default());
let cid = d.alloc_id();
let mut c = Node::new(cid, NodeKind::Compound { children: vec![Arc::new(o), Arc::new(i)], rule: FillRule::EvenOdd });
c.appearance = Appearance::basic(solid("#123456"), Paint::None, 1.0).into();
let d = doc_with(vec![c]);
let s = export(&d, &ExportOptions::default());
assert_eq!(s.matches("<path").count(), 1, "{s}");
assert!(s.contains("fill-rule=\"evenodd\""));
let r = roundtrip(&d);
assert_eq!(r.node_count(), d.node_count());
let a = art(&r);
assert!(matches!(a[0].kind, NodeKind::Compound { rule: FillRule::EvenOdd, .. }));
assert_eq!(a[0].appearance.fill_paint().color().unwrap().to_hex(), "#123456");
assert!(close_rect(a[0].geometric_bounds().unwrap(), Rect::new(0.0, 0.0, 100.0, 100.0), 0.01));
}
#[test]
fn opacity_and_blend_roundtrip() {
let mut d = Document::new(200.0, 200.0);
let mut n = rect_node(&mut d, Rect::new(0.0, 0.0, 10.0, 10.0), Appearance::basic(solid("#ff0000"), Paint::None, 1.0));
n.opacity = 0.5;
n.blend = BlendMode::Multiply;
let d = doc_with(vec![n]);
let s = export(&d, &ExportOptions::default());
assert!(s.contains("opacity=\"0.5\"") && s.contains("mix-blend-mode:multiply"), "{s}");
let r = roundtrip(&d);
assert_eq!(r.node_count(), d.node_count());
let a = art(&r);
assert!((a[0].opacity - 0.5).abs() < 1e-3);
assert_eq!(a[0].blend, BlendMode::Multiply);
}
#[test]
fn fill_and_stroke_opacity_roundtrip() {
let mut d = Document::new(200.0, 200.0);
let mut ap = Appearance::basic(solid("#ff0000"), solid("#000000"), 3.0);
if let AppearanceItem::Fill(f) = &mut ap.items[0] {
f.opacity = 0.25;
}
ap.stroke_mut().unwrap().opacity = 0.75;
let n = rect_node(&mut d, Rect::new(0.0, 0.0, 10.0, 10.0), ap);
let r = roundtrip(&doc_with(vec![n]));
let a = art(&r);
assert!((a[0].appearance.fill().unwrap().opacity - 0.25).abs() < 1e-3);
assert!((a[0].appearance.stroke().unwrap().opacity - 0.75).abs() < 1e-3);
}
#[test]
fn stroke_attributes_roundtrip() {
let mut d = Document::new(200.0, 200.0);
let mut s = StrokeLayer::new(solid("#336699"), 4.0);
s.cap = LineCap::Round;
s.join = LineJoin::Bevel;
s.dash = Some(Dash { pattern: vec![6.0, 3.0], offset: 1.5, align_corners: false });
let ap = Appearance { items: vec![AppearanceItem::Stroke(s)], ..Default::default() };
let id = d.alloc_id();
let n = Node::path(id, shapes::line(Point::new(0.0, 0.0), Point::new(100.0, 50.0)), ap);
let d = doc_with(vec![n]);
let out = export(&d, &ExportOptions::default());
assert!(out.contains("fill=\"none\""), "{out}");
let r = roundtrip(&d);
let a = art(&r);
let st = a[0].appearance.stroke().unwrap();
assert_eq!(st.cap, LineCap::Round);
assert_eq!(st.join, LineJoin::Bevel);
assert!((st.width - 4.0).abs() < 1e-6);
let dash = st.dash.as_ref().unwrap();
assert_eq!(dash.pattern, vec![6.0, 3.0]);
assert!((dash.offset - 1.5).abs() < 1e-6);
assert!(a[0].appearance.fill().is_none());
let mut m = StrokeLayer::new(solid("#000000"), 1.0);
m.miter_limit = 7.0;
let n = Node::path(
NodeId(99),
shapes::rectangle(Rect::new(0.0, 0.0, 5.0, 5.0)),
Appearance { items: vec![AppearanceItem::Stroke(m)], ..Default::default() },
);
let r = roundtrip(&doc_with(vec![n]));
assert!((art(&r)[0].appearance.stroke().unwrap().miter_limit - 7.0).abs() < 1e-6);
}
#[test]
fn linear_gradient_roundtrip() {
let mut d = Document::new(200.0, 200.0);
let mut g = Gradient::default();
g.stops[1].opacity = 0.5;
let gp = GradientPaint {
gradient: g,
geom: Some(GradientGeom { start: Point::new(10.0, 20.0), end: Point::new(90.0, 60.0), aspect: 1.0, focal: None }),
angle: 0.0,
swatch: None,
freeform: None,
};
let n = rect_node(&mut d, Rect::new(0.0, 0.0, 100.0, 100.0), Appearance::basic(Paint::Gradient(Box::new(gp)), Paint::None, 1.0));
let d = doc_with(vec![n]);
let s = export(&d, &ExportOptions::default());
assert!(s.contains("<linearGradient") && s.contains("gradientUnits=\"userSpaceOnUse\"") && s.contains("stop-opacity=\"0.5\""), "{s}");
let r = roundtrip(&d);
let Paint::Gradient(g) = art(&r)[0].appearance.fill_paint() else { panic!() };
assert_eq!(g.gradient.kind, GradientKind::Linear);
let geom = g.geom.unwrap();
assert!(geom.start.distance(Point::new(10.0, 20.0)) < 0.01 && geom.end.distance(Point::new(90.0, 60.0)) < 0.01);
assert_eq!(g.gradient.stops.len(), 2);
assert_eq!(g.gradient.stops[0].color.to_hex(), "#ffffff");
assert!((g.gradient.stops[1].opacity - 0.5).abs() < 1e-3);
}
#[test]
fn radial_gradient_and_fit_roundtrip() {
let mut d = Document::new(200.0, 200.0);
let g = Gradient { kind: GradientKind::Radial, ..Gradient::default() };
// geom None: resolved against the object bounds.
let n = rect_node(
&mut d,
Rect::new(0.0, 0.0, 100.0, 50.0),
Appearance::basic(Paint::Gradient(Box::new(GradientPaint::new(g.clone()))), Paint::None, 1.0),
);
// Elliptical radial via gradientTransform.
let gp = GradientPaint {
gradient: g,
geom: Some(GradientGeom { start: Point::new(150.0, 150.0), end: Point::new(150.0, 180.0), aspect: 0.5, focal: None }),
angle: 0.0,
swatch: None,
freeform: None,
};
let n2 = rect_node(&mut d, Rect::new(100.0, 100.0, 200.0, 200.0), Appearance::basic(Paint::Gradient(Box::new(gp)), Paint::None, 1.0));
let r = roundtrip(&doc_with(vec![n, n2]));
let a = art(&r);
let Paint::Gradient(g) = a[0].appearance.fill_paint() else { panic!() };
let geom = g.geom.unwrap();
assert_eq!(g.gradient.kind, GradientKind::Radial);
assert!(geom.start.distance(Point::new(50.0, 25.0)) < 0.01, "{geom:?}");
assert!((geom.length() - 50.0).abs() < 0.01);
let Paint::Gradient(g) = a[1].appearance.fill_paint() else { panic!() };
let geom = g.geom.unwrap();
assert!(geom.start.distance(Point::new(150.0, 150.0)) < 0.01 && geom.end.distance(Point::new(150.0, 180.0)) < 0.01, "{geom:?}");
assert!((geom.aspect - 0.5).abs() < 1e-3);
}
#[test]
fn multiple_fills_stack_in_paint_order() {
let mut d = Document::new(200.0, 200.0);
let ap = Appearance {
items: vec![
AppearanceItem::Fill(FillLayer::new(solid("#ff0000"))),
AppearanceItem::Fill(FillLayer::new(solid("#00ff00"))),
AppearanceItem::Stroke(StrokeLayer::new(solid("#0000ff"), 2.0)),
],
..Default::default()
};
let n = rect_node(&mut d, Rect::new(0.0, 0.0, 10.0, 10.0), ap);
let s = export(&doc_with(vec![n]), &ExportOptions::default());
let red = s.find("#ff0000").unwrap();
let green = s.find("#00ff00").unwrap();
let blue = s.find("#0000ff").unwrap();
assert!(red < green && green < blue, "{s}");
assert_eq!(s.matches("<path").count(), 3);
let r = import(&s).unwrap();
let a = art(&r);
assert!(matches!(a[0].kind, NodeKind::Group { .. }));
assert_eq!(a.len(), 4);
}
#[test]
fn stroke_alignment_approximations() {
let mut d = Document::new(200.0, 200.0);
let mut ap = Appearance::basic(solid("#ffffff"), solid("#000000"), 4.0);
ap.stroke_mut().unwrap().align = StrokeAlign::Inside;
let a = rect_node(&mut d, Rect::new(0.0, 0.0, 50.0, 50.0), ap.clone());
ap.stroke_mut().unwrap().align = StrokeAlign::Outside;
let b = rect_node(&mut d, Rect::new(60.0, 0.0, 110.0, 50.0), ap);
let s = export(&doc_with(vec![a, b]), &ExportOptions::default());
assert!(s.contains("<clipPath") && s.contains("<mask") && s.contains("stroke-width=\"8\""), "{s}");
// The outside stroke's mask comes back as an opacity mask (same pixels, no warning).
let (r, warnings) = import_with_report(&s).unwrap();
assert!(art(&r).iter().any(|n| n.mask.is_some()), "{warnings:?}");
}
#[test]
fn hidden_and_guides_omitted() {
let mut d = Document::new(200.0, 200.0);
let mut a = rect_node(&mut d, Rect::new(0.0, 0.0, 10.0, 10.0), Appearance::default_art());
a.visible = false;
let b = rect_node(&mut d, Rect::new(0.0, 0.0, 20.0, 20.0), Appearance::default_art());
let s = export(&doc_with(vec![a, b]), &ExportOptions::default());
assert_eq!(s.matches("<path").count(), 1);
let r = import(&s).unwrap();
assert_eq!(art(&r).len(), 1);
}
#[test]
fn image_roundtrip() {
let mut png = Vec::new();
let img = image::RgbaImage::from_pixel(4, 2, image::Rgba([255, 0, 0, 255]));
img.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap();
let mut d = Document::new(200.0, 200.0);
let id = d.alloc_id();
let n = Node::new(
id,
NodeKind::Image(ImageObject {
key: "k".into(),
width: 4,
height: 2,
xf: Affine::translate((10.0, 20.0)) * Affine::scale(10.0),
link: None,
placement: Default::default(),
}),
);
let mut d = doc_with(vec![n]);
d.images.insert("k".into(), ImageBlob::new("image/png", png.clone()));
let s = export(&d, &ExportOptions::default());
assert!(s.contains("data:image/png;base64,"), "{s}");
let r = roundtrip(&d);
assert_eq!(r.node_count(), d.node_count());
let a = art(&r);
let NodeKind::Image(im) = &a[0].kind else { panic!("{:?}", a[0].kind) };
assert_eq!((im.width, im.height), (4, 2));
assert_eq!(*r.images[&im.key].bytes, png);
assert!(close_rect(a[0].geometric_bounds().unwrap(), Rect::new(10.0, 20.0, 50.0, 40.0), 0.01));
}
#[test]
fn text_roundtrip_and_escaping() {
let mut d = Document::new(200.0, 200.0);
let st = CharStyle { font_family: "Source Sans 3".into(), size: 18.0, fill: solid("#ff0000"), ..CharStyle::default() };
let t = TextObject::point(Point::new(20.0, 50.0), "A & B <C>\n\"q\"", st);
let id = d.alloc_id();
let mut n = Node::new(id, NodeKind::Text(Box::new(t)));
n.name = Some("a<b>".into());
let d = doc_with(vec![n]);
let s = export(&d, &ExportOptions::default());
assert!(s.contains("A &amp; B &lt;C&gt;") && s.contains("&quot;q&quot;") && s.contains("xml:space=\"preserve\""), "{s}");
assert!(!s.contains("a<b>"));
let r = roundtrip(&d);
assert_eq!(r.node_count(), d.node_count());
let a = art(&r);
let NodeKind::Text(t) = &a[0].kind else { panic!() };
assert_eq!(t.plain_text(), "A & B <C>\n\"q\"");
let f = t.first_style();
assert_eq!(f.font_family, "Source Sans 3");
assert!((f.size - 18.0).abs() < 1e-6);
assert_eq!(f.fill.color().unwrap().to_hex(), "#ff0000");
assert!(t.xf.translation().to_point().distance(Point::new(20.0, 50.0)) < 0.01);
}
#[test]
fn scaled_text_roundtrips_at_the_size_it_draws() {
// Type scaled with its object (its transform scales it) comes back at the size it draws at,
// drawn the same, and stays so on the next round trip; a stretch stays the transform.
let at = Affine::translate((20.0, 50.0));
let stretch = at * Affine::rotate(0.5) * Affine::scale_non_uniform(2.0, 1.0);
for (xf, size) in [(at * Affine::scale(2.0), 18.0), (at * Affine::rotate(0.5) * Affine::scale(3.0), 27.0), (stretch, 9.0)] {
let mut t = TextObject::point(Point::ZERO, "Scaled\ntype", CharStyle { size: 9.0, leading: Some(11.0), ..CharStyle::default() });
t.xf = xf;
let drawn = |t: &TextObject| t.xf.transform_rect_bbox(vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t).bounds);
let before = drawn(&t);
let id = Document::new(200.0, 200.0).alloc_id();
let d = doc_with(vec![Node::new(id, NodeKind::Text(Box::new(t)))]);
let once = roundtrip(&d);
let NodeKind::Text(t) = &art(&once)[0].kind else { panic!() };
// Within the precision of the exported matrix (3 decimals); the second line carries the
// leading.
let near = |a: f64, b: f64| (a - b).abs() < b * 1e-3;
let st = &t.runs.last().unwrap().style;
assert!(near(st.size, size) && near(st.leading.unwrap(), 11.0 * size / 9.0), "{xf:?}: {st:?}");
assert!(close_rect(drawn(t), before, 0.05), "{xf:?}: {:?} {before:?}", drawn(t));
let twice = roundtrip(&once);
let NodeKind::Text(t2) = &art(&twice)[0].kind else { panic!() };
assert!(near(t2.first_style().size, size) && close_rect(drawn(t2), before, 0.05), "{xf:?}: {:?} {:?}", t2.runs, drawn(t2));
}
}
#[test]
fn text_on_path_roundtrip() {
let mut d = Document::new(200.0, 200.0);
let mut curve = vectorcraft_geom::BezPath::new();
curve.move_to((20.0, 150.0));
curve.curve_to((60.0, 40.0), (140.0, 40.0), (180.0, 150.0));
let mut t = TextObject::point(Point::new(0.0, 0.0), "On a curve", CharStyle { size: 14.0, ..CharStyle::default() });
t.kind = TextKind::OnPath { path: PathData::from_bezpath(&curve), start: 0.25, end: None };
let id = d.alloc_id();
let d = doc_with(vec![Node::new(id, NodeKind::Text(Box::new(t)))]);
let r = roundtrip(&d);
let a = art(&r);
let NodeKind::Text(t) = &a[0].kind else { panic!() };
assert_eq!(t.plain_text(), "On a curve");
let TextKind::OnPath { path, start, .. } = &t.kind else { panic!("not type on a path: {:?}", t.kind) };
assert!((start - 0.25).abs() < 1e-6, "start {start}");
let doc_path = path.bounds().map(|b| t.xf.transform_rect_bbox(b)).unwrap();
assert!(close_rect(doc_path, PathData::from_bezpath(&curve).bounds().unwrap(), 0.01), "{doc_path:?}");
}
/// An end bracket, Align to Path or Spacing, which `<textPath>` can't express, keep their look as
/// outlines (#429).
#[test]
fn text_on_path_options_svg_cannot_express_are_outlined() {
let mut curve = vectorcraft_geom::BezPath::new();
curve.move_to((20.0, 150.0));
curve.curve_to((60.0, 40.0), (140.0, 40.0), (180.0, 150.0));
for set in [0, 1, 2] {
let mut d = Document::new(200.0, 200.0);
let mut t = TextObject::point(Point::ZERO, "On a curve", CharStyle { size: 14.0, ..CharStyle::default() });
t.kind = TextKind::OnPath { path: PathData::from_bezpath(&curve), start: 0.1, end: (set == 0).then_some(0.8) };
if set == 1 {
t.path_align = vectorcraft_doc::PathAlign::Center;
}
if set == 2 {
t.path_spacing = 4.0;
}
let id = d.alloc_id();
let svg = export(&doc_with(vec![Node::new(id, NodeKind::Text(Box::new(t)))]), &ExportOptions::default());
assert!(!svg.contains("<textPath"), "{set}: {svg}");
}
}
#[test]
fn styling_modes_roundtrip() {
for styling in [Styling::PresentationAttributes, Styling::InlineStyle, Styling::StyleEntities, Styling::InternalCss] {
let mut d = Document::new(200.0, 200.0);
let a = rect_node(&mut d, Rect::new(0.0, 0.0, 10.0, 10.0), Appearance::basic(solid("#abcdef"), solid("#010203"), 2.0));
let b = rect_node(&mut d, Rect::new(20.0, 0.0, 30.0, 10.0), Appearance::basic(solid("#abcdef"), solid("#010203"), 2.0));
let t = TextObject::point(Point::new(0.0, 50.0), "Hi", CharStyle { fill: solid("#00ff00"), size: 20.0, ..CharStyle::default() });
let tn = Node::new(d.alloc_id(), NodeKind::Text(Box::new(t)));
let d = doc_with(vec![a, b, tn]);
let opts = ExportOptions { styling, ..Default::default() };
let s = export(&d, &opts);
match styling {
Styling::PresentationAttributes => assert!(s.contains("fill=\"#abcdef\"")),
Styling::InlineStyle => assert!(s.contains("style=\"fill:#abcdef")),
Styling::StyleEntities => {
assert!(s.contains("<!DOCTYPE svg [") && s.contains("<!ENTITY st1 \"fill:#abcdef") && s.contains("style=\"&st1;\""), "{s}");
assert_eq!(s.matches("<!ENTITY").count(), 2, "{s}");
}
Styling::InternalCss => {
assert!(s.contains("<style>") && s.contains("class=\"cls-1\""));
assert_eq!(s.matches(".cls-").count(), 2, "{s}");
}
}
let r = import(&s).unwrap();
let a = art(&r);
assert_eq!(a.len(), 3);
assert_eq!(a[1].appearance.fill_paint().color().unwrap().to_hex(), "#abcdef");
assert_eq!(a[1].appearance.stroke_paint().color().unwrap().to_hex(), "#010203");
let NodeKind::Text(t) = &a[2].kind else { panic!() };
assert_eq!(t.first_style().fill.color().unwrap().to_hex(), "#00ff00", "{styling:?}\n{s}");
assert!((t.first_style().size - 20.0).abs() < 1e-6);
}
}
#[test]
fn options_decimals_minify_responsive_artboard() {
let mut d = Document::new(200.0, 200.0);
let n = rect_node(&mut d, Rect::new(110.123456, 120.0, 150.0, 150.0), Appearance::default_art());
let mut d = doc_with(vec![n]);
d.artboards[0].rect = Rect::new(100.0, 100.0, 300.0, 200.0);
let s = export(&d, &ExportOptions { decimals: 2, minify: true, responsive: true, ..Default::default() });
assert!(!s.trim_end().contains('\n'), "{s}");
assert!(!s.contains("<?xml"));
assert!(!s.contains(" width=\"200\""));
assert!(s.contains("viewBox=\"0 0 200 100\""), "{s}");
assert!(s.contains("M10.12 20"), "{s}");
let s = export(&d, &ExportOptions::default());
assert!(s.contains("width=\"200\" height=\"100\"") && s.contains("M10.123 20"), "{s}");
// All art bounds: the rectangle and half its 1 pt stroke (right-angle miters stay inside).
let s = export(&d, &ExportOptions { artboard: None, ..Default::default() });
assert!(s.contains("viewBox=\"0 0 40.877 31\""), "{s}");
let s = export(&d, &ExportOptions { object_ids: ObjectIds::Minimal, ..Default::default() });
assert!(!s.contains("id=\"Layer_1\""));
}
#[test]
fn unique_sanitized_ids() {
let mut d = Document::new(200.0, 200.0);
let mut a = rect_node(&mut d, Rect::new(0.0, 0.0, 1.0, 1.0), Appearance::default_art());
a.name = Some("1 thing".into());
let mut b = rect_node(&mut d, Rect::new(0.0, 0.0, 1.0, 1.0), Appearance::default_art());
b.name = Some("1 thing".into());
let mut c = rect_node(
&mut d,
Rect::new(0.0, 0.0, 1.0, 1.0),
Appearance::basic(Paint::Gradient(Box::new(GradientPaint::new(Gradient::default()))), Paint::None, 1.0),
);
c.name = Some("linear-gradient-1".into());
let s = export(&doc_with(vec![a, b, c]), &ExportOptions::default());
assert!(s.contains("id=\"_1_thing\"") && s.contains("id=\"_1_thing-2\""), "{s}");
assert!(s.contains("id=\"linear-gradient-2\""), "{s}");
}
// ---- hand-written SVG imports ----
fn import_art(svg: &str) -> Document {
import(svg).unwrap()
}
#[test]
fn import_basic_shapes() {
let d = import_art(
r##"<svg xmlns="http://www.w3.org/2000/svg" width="300" height="200">
<rect x="10" y="10" width="50" height="30" fill="red"/>
<circle cx="100" cy="50" r="20" fill="#00f"/>
<ellipse cx="200" cy="50" rx="30" ry="10" fill="none" stroke="black" stroke-width="2"/>
<polygon points="10,100 60,100 35,150" fill="rgb(0,128,0)"/>
<polyline points="100,100 150,150 200,100" fill="none" stroke="#333"/>
<line x1="0" y1="190" x2="300" y2="190" stroke="black"/>
</svg>"##,
);
assert_eq!(d.artboards[0].rect, Rect::new(0.0, 0.0, 300.0, 200.0));
let a = art(&d);
assert_eq!(a.len(), 6);
assert!(close_rect(a[0].geometric_bounds().unwrap(), Rect::new(10.0, 10.0, 60.0, 40.0), 0.01));
assert_eq!(a[0].appearance.fill_paint().color().unwrap().to_hex(), "#ff0000");
assert!(close_rect(a[1].geometric_bounds().unwrap(), Rect::new(80.0, 30.0, 120.0, 70.0), 0.01));
assert!(close_rect(a[2].geometric_bounds().unwrap(), Rect::new(170.0, 40.0, 230.0, 60.0), 0.01));
assert!(a[2].appearance.fill().is_none());
assert_eq!(a[3].path_data().unwrap().anchor_count(), 3);
assert!(a[3].path_data().unwrap().is_closed());
assert_eq!(a[3].appearance.fill_paint().color().unwrap().to_hex(), "#008000");
assert!(!a[4].path_data().unwrap().is_closed());
assert_eq!(a[5].path_data().unwrap().anchor_count(), 2);
}
#[test]
fn import_path_with_arcs() {
let d = import_art(
r#"<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100"><path d="M10 50 A40 40 0 0 1 90 50 A40 40 0 0 1 10 50 Z"/></svg>"#,
);
let a = art(&d);
assert_eq!(a.len(), 1);
assert!(close_rect(a[0].geometric_bounds().unwrap(), Rect::new(10.0, 10.0, 90.0, 90.0), 0.05), "{:?}", a[0].geometric_bounds());
// Default fill is black.
assert_eq!(a[0].appearance.fill_paint().color().unwrap().to_hex(), "#000000");
}
#[test]
fn import_transforms_and_nested_groups() {
let d = import_art(
r#"<svg xmlns="http://www.w3.org/2000/svg" width="200" height="200">
<g id="outer" transform="translate(50 50)">
<g id="inner" transform="scale(2)">
<rect width="10" height="10" transform="rotate(90)" stroke="black" stroke-width="1"/>
</g>
</g></svg>"#,
);
// One top-level <g id> → it becomes a layer.
assert_eq!(d.layers.len(), 1);
assert_eq!(d.layers[0].name.as_deref(), Some("outer"));
let a = art(&d);
assert_eq!(a.len(), 2);
assert_eq!(a[0].name.as_deref(), Some("inner"));
assert!(close_rect(a[1].geometric_bounds().unwrap(), Rect::new(30.0, 50.0, 50.0, 70.0), 0.01), "{:?}", a[1].geometric_bounds());
assert!((a[1].appearance.stroke_width() - 2.0).abs() < 1e-6);
}
#[test]
fn import_viewbox_scaling() {
let d = import_art(
r#"<svg xmlns="http://www.w3.org/2000/svg" width="200" height="100" viewBox="10 10 20 10"><rect x="10" y="10" width="10" height="10"/><text x="20" y="15" font-size="2">T</text></svg>"#,
);
assert_eq!(d.artboards[0].rect, Rect::new(0.0, 0.0, 200.0, 100.0));
let a = art(&d);
assert!(close_rect(a[0].geometric_bounds().unwrap(), Rect::new(0.0, 0.0, 100.0, 100.0), 0.01));
let NodeKind::Text(t) = &a[1].kind else { panic!() };
assert!(t.xf.translation().to_point().distance(Point::new(100.0, 50.0)) < 0.01, "{:?}", t.xf);
}
#[test]
fn import_gradient_with_transform() {
let d = import_art(
r##"<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100">
<defs>
<linearGradient id="g" x1="0" y1="0" x2="10" y2="0" gradientUnits="userSpaceOnUse" gradientTransform="translate(10 0) rotate(90)">
<stop offset="0" stop-color="#f00"/><stop offset="1" stop-color="#00f" stop-opacity="0.5"/>
</linearGradient>
<radialGradient id="r"><stop offset="0" stop-color="white"/><stop offset="1" stop-color="black"/></radialGradient>
</defs>
<rect width="100" height="50" fill="url(#g)"/>
<rect y="50" width="100" height="50" fill="url(#r)"/>
</svg>"##,
);
let a = art(&d);
let Paint::Gradient(g) = a[0].appearance.fill_paint() else { panic!() };
let geom = g.geom.unwrap();
assert!(geom.start.distance(Point::new(10.0, 0.0)) < 1e-3 && geom.end.distance(Point::new(10.0, 10.0)) < 1e-3, "{geom:?}");
assert_eq!(g.gradient.stops[0].color.to_hex(), "#ff0000");
assert!((g.gradient.stops[1].opacity - 0.5).abs() < 1e-3);
// objectBoundingBox radial on a 100x50 box → centre (50,75), elliptical.
let Paint::Gradient(g) = a[1].appearance.fill_paint() else { panic!() };
let geom = g.geom.unwrap();
assert_eq!(g.gradient.kind, GradientKind::Radial);
assert!(geom.start.distance(Point::new(50.0, 75.0)) < 1e-3, "{geom:?}");
assert!((geom.length() - 50.0).abs() < 1e-3 && (geom.aspect - 0.5).abs() < 1e-3, "{geom:?}");
}
#[test]
fn import_use_and_defs() {
let d = import_art(
r##"<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="100" height="100">
<defs><rect id="r" width="10" height="10" fill="#0f0"/></defs>
<use xlink:href="#r" x="20" y="30"/>
<use href="#r" x="50" y="60"/>
</svg>"##,
);
let a = art(&d);
let paths: Vec<_> = a.iter().filter(|n| n.path_data().is_some()).collect();
assert_eq!(paths.len(), 2);
assert!(close_rect(paths[0].geometric_bounds().unwrap(), Rect::new(20.0, 30.0, 30.0, 40.0), 0.01));
assert!(close_rect(paths[1].geometric_bounds().unwrap(), Rect::new(50.0, 60.0, 60.0, 70.0), 0.01));
assert_eq!(paths[1].appearance.fill_paint().color().unwrap().to_hex(), "#00ff00");
}
#[test]
fn import_clip_mask_and_warnings() {
let (d, w) = import_with_report(
r##"<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100">
<defs>
<clipPath id="c"><circle cx="50" cy="50" r="25"/></clipPath>
<mask id="m"><rect width="100" height="100" fill="white"/></mask>
<pattern id="p" width="4" height="4" patternUnits="userSpaceOnUse"><rect width="2" height="2"/></pattern>
</defs>
<g clip-path="url(#c)"><rect width="100" height="100" fill="blue"/></g>
<g mask="url(#m)"><rect width="10" height="10" fill="url(#p)"/></g>
</svg>"##,
)
.unwrap();
let a = art(&d);
assert!(matches!(a[0].kind, NodeKind::Group { clip: true, .. }));
assert!(close_rect(a[0].geometric_bounds().unwrap(), Rect::new(25.0, 25.0, 75.0, 75.0), 0.01));
assert!(a.iter().any(|n| n.mask.is_some()), "<mask> imports as an opacity mask");
assert!(!w.iter().any(|w| w.contains("mask")), "{w:?}");
// <pattern> becomes a pattern swatch.
assert_eq!(d.patterns.len(), 1, "{w:?}");
assert!(a.iter().any(|n| matches!(n.appearance.fill_paint(), Paint::Pattern { pattern, .. } if pattern == "p")));
}
#[test]
fn import_opacity_wrapper_folds_into_object() {
let d = import_art(
r#"<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100"><rect width="10" height="10" opacity="0.4" transform="translate(5 5)" style="mix-blend-mode:screen"/></svg>"#,
);
let a = art(&d);
assert_eq!(a.len(), 1);
assert!((a[0].opacity - 0.4).abs() < 1e-3);
assert_eq!(a[0].blend, BlendMode::Screen);
assert!(close_rect(a[0].geometric_bounds().unwrap(), Rect::new(5.0, 5.0, 15.0, 15.0), 0.01));
}
#[test]
fn area_type_exports_its_wrapped_lines() {
// Area type wraps at the frame edge; the SVG has to carry those soft line breaks (SVG text never
// wraps), with centred lines anchored on the frame's centre, not its left edge.
let mut d = Document::new(400.0, 300.0);
let text = "Centred area text that wraps onto more than one line here";
let mut t = TextObject::point(Point::new(0.0, 0.0), text, CharStyle { size: 18.0, ..CharStyle::default() });
t.kind = vectorcraft_doc::TextKind::Area { frame: shapes::rectangle(Rect::new(0.0, 0.0, 200.0, 150.0)) };
t.xf = Affine::translate((100.0, 50.0));
t.para.justify = vectorcraft_doc::Justify::Center;
let id = d.alloc_id();
let d = doc_with(vec![Node::new(id, NodeKind::Text(Box::new(t)))]);
let svg = export(&d, &ExportOptions::default());
let line = svg.lines().find(|l| l.contains("<text")).unwrap();
let ys: Vec<&str> = line.split("<tspan").skip(1).filter_map(|t| t.split(" y=\"").nth(1)?.split('"').next()).collect();
assert!(ys.len() >= 3, "one positioned tspan per line: {line}");
assert!(ys.windows(2).all(|w| w[0] != w[1]), "{line}");
assert!(line.contains("text-anchor=\"middle\""), "{line}");
// Lines are anchored at x = 100 in text space = 200 in the document, the frame's centre.
assert!(line.contains("transform=\"matrix(1 0 0 1 100 50)\"") && line.contains("<tspan x=\"100\""), "{line}");
// Re-import: the same words, now with hard line breaks where the lines wrapped.
let r = import(&svg).unwrap();
let a = art(&r);
let NodeKind::Text(t) = &a[0].kind else { panic!() };
let lines: Vec<String> = t.plain_text().lines().map(str::to_string).collect();
assert_eq!(lines.len(), ys.len(), "{lines:?}");
assert_eq!(lines.join(" "), text);
assert!((t.xf.translation().x - 200.0).abs() < 0.01, "{:?}", t.xf);
}
#[test]
fn import_text_tspans_and_css() {
let d = import_art(
r#"<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100">
<style>.big { font-size: 30px; fill: #00f }</style>
<text x="5" y="40" font-family="'Helvetica Neue', sans-serif" font-size="10">Hello <tspan class="big" font-weight="bold">World</tspan>
</text></svg>"#,
);
let a = art(&d);
let NodeKind::Text(t) = &a[0].kind else { panic!() };
assert_eq!(t.plain_text(), "Hello World");
assert_eq!(t.runs.len(), 2);
assert_eq!(t.runs[0].style.font_family, "Helvetica Neue");
assert!((t.runs[1].style.size - 30.0).abs() < 1e-6);
assert_eq!(t.runs[1].style.font_style, "Bold");
assert_eq!(t.runs[1].style.fill.color().unwrap().to_hex(), "#0000ff");
}
#[test]
fn import_tspan_far_below_is_bounded() {
// A <tspan y> far below the text's first line is a line break per line of gap, but not without
// limit: y="1e15" used to ask for ~7e13 line breaks and aborted the process (out of memory).
let d = import_art(
r#"<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100">
<text x="5" y="20" font-size="12">first<tspan x="5" y="48.8">third</tspan><tspan x="5" y="2000000">far</tspan></text></svg>"#,
);
let a = art(&d);
let NodeKind::Text(t) = &a[0].kind else { panic!() };
let text = t.plain_text();
// One blank line between "first" and "third" (auto leading 14.4 pt: two lines down).
assert!(text.starts_with("first\n\nthird\n"), "{text:?}");
assert!(text.ends_with("far"), "{text:?}");
let breaks = text.matches('\n').count();
assert!(breaks <= 10_002, "{breaks} line breaks");
}
#[test]
fn import_tspan_on_the_same_baseline_stays_on_the_line() {
// Styled runs positioned on the line's own baseline (as Illustrator writes them, with x and y on
// every tspan) are one line, not a line break before each run.
let d = import_art(
r#"<svg xmlns="http://www.w3.org/2000/svg" width="300" height="100">
<text transform="translate(10 40)" font-size="20"><tspan x="0" y="0">Hello </tspan><tspan x="55.6" y="0" fill="red">world</tspan><tspan x="0" y="24">next</tspan></text></svg>"#,
);
let a = art(&d);
let NodeKind::Text(t) = &a[0].kind else { panic!() };
assert_eq!(t.plain_text(), "Hello world\nnext");
// The styled runs stay (a kerned space may split the first one: "world" keeps its x).
let red: Vec<&str> = t.runs.iter().filter(|r| r.style.fill.color().is_some_and(|c| c.to_hex() == "#ff0000")).map(|r| r.text.trim()).collect();
assert_eq!(red, ["world"], "{:?}", t.runs);
let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t);
let w = lay.glyphs.iter().find(|g| t.plain_text()[g.byte..].starts_with('w')).unwrap();
assert!(((t.xf * w.origin).x - 65.6).abs() < 0.05, "{:?}", t.xf * w.origin);
}
#[test]
fn import_invalid_is_error() {
assert!(import("not svg").is_err());
assert!(import("<svg xmlns=\"http://www.w3.org/2000/svg\"><rect").is_err());
}
#[test]
fn import_absolute_units_keep_their_physical_size() {
// An A4 drawing in millimetres (the usual Inkscape document) is 595.3 × 841.9 pt, not its CSS
// pixel size (793.7 × 1122.5, i.e. 280 × 396 mm): 1 px = 1 pt as we export, so absolute units go
// through 72 pt per inch.
let a4 = import(
r#"<svg xmlns="http://www.w3.org/2000/svg" width="210mm" height="297mm" viewBox="0 0 210 297">
<rect x="10" y="20" width="100" height="50" fill="red" stroke="blue" stroke-width="2"/></svg>"#,
)
.unwrap();
let mm = 72.0 / 25.4;
assert!(close_rect(a4.artboards[0].rect, Rect::new(0.0, 0.0, 210.0 * mm, 297.0 * mm), 0.01), "{:?}", a4.artboards[0].rect);
let r = art(&a4)[0];
assert!(close_rect(r.geometric_bounds().unwrap(), Rect::new(10.0 * mm, 20.0 * mm, 110.0 * mm, 70.0 * mm), 0.01), "{:?}", r.geometric_bounds());
assert!((r.appearance.stroke_width() - 2.0 * mm).abs() < 0.01, "{}", r.appearance.stroke_width());
// Inches without a viewBox: user units are CSS px (96 per inch).
let letter = import(
r#"<svg xmlns="http://www.w3.org/2000/svg" width="8.5in" height="11in"><rect x="96" y="96" width="96" height="48"/>
<text x="96" y="300" font-size="24">Hi</text></svg>"#,
)
.unwrap();
assert!(close_rect(letter.artboards[0].rect, Rect::new(0.0, 0.0, 612.0, 792.0), 0.01), "{:?}", letter.artboards[0].rect);
let a = art(&letter);
assert!(close_rect(a[0].geometric_bounds().unwrap(), Rect::new(72.0, 72.0, 144.0, 108.0), 0.01), "{:?}", a[0].geometric_bounds());
let NodeKind::Text(t) = &a[1].kind else { panic!() };
assert!(t.xf.translation().to_point().distance(Point::new(72.0, 225.0)) < 0.01, "{:?}", t.xf);
// Pixels and unitless sizes stay 1 px = 1 pt.
for size in [r#"width="400px" height="300""#, r#"viewBox="0 0 400 300""#] {
let d = import(&format!(r#"<svg xmlns="http://www.w3.org/2000/svg" {size}><rect width="10" height="10"/></svg>"#)).unwrap();
assert!(close_rect(d.artboards[0].rect, Rect::new(0.0, 0.0, 400.0, 300.0), 1e-6), "{size}");
}
}
/// #1010: a root with one absolute side and one unitless or px side opens as if both were
/// absolute (CSS pixels at 96 per inch), so nothing is stretched and the viewport keeps its size.
#[test]
fn import_mixed_root_units_scale_both_sides_alike() {
let rect = |d: &Document| art(d)[0].geometric_bounds().unwrap();
for (size, board) in [
(r#"width="96pt" height="96""#, Rect::new(0.0, 0.0, 96.0, 72.0)),
(r#"width="96pt" height="96px""#, Rect::new(0.0, 0.0, 96.0, 72.0)),
(r#"width="96pt" height="72pt""#, Rect::new(0.0, 0.0, 96.0, 72.0)),
(r#"width="128" height="72pt""#, Rect::new(0.0, 0.0, 96.0, 72.0)),
] {
let svg =
|extra: &str| format!(r##"<svg xmlns="http://www.w3.org/2000/svg" {size} {extra}><rect width="32" height="32" fill="#f00"/></svg>"##);
let d = import(&svg("")).unwrap();
assert!(close_rect(d.artboards[0].rect, board, 1e-6), "{size}: {:?}", d.artboards[0].rect);
assert!(close_rect(rect(&d), Rect::new(0.0, 0.0, 24.0, 24.0), 1e-6), "{size}: {:?}", rect(&d));
// With a viewBox: fitted uniformly (xMidYMid meet), unless preserveAspectRatio is none.
let d = import(&svg(r#"viewBox="0 0 32 32""#)).unwrap();
assert!(close_rect(d.artboards[0].rect, board, 1e-6), "{size}: {:?}", d.artboards[0].rect);
assert!(close_rect(rect(&d), Rect::new(12.0, 0.0, 84.0, 72.0), 1e-6), "{size} viewBox: {:?}", rect(&d));
let d = import(&svg(r#"viewBox="0 0 32 32" preserveAspectRatio="none""#)).unwrap();
assert!(close_rect(rect(&d), board, 1e-6), "{size} none: {:?}", rect(&d));
}
}
#[test]
fn full_document_roundtrip_is_stable() {
// export → import → export produces identical SVG.
let mut d = Document::new(300.0, 300.0);
let a = rect_node(&mut d, Rect::new(10.0, 10.0, 60.0, 60.0), Appearance::basic(solid("#112233"), solid("#445566"), 3.0));
let e = Node::path(d.alloc_id(), shapes::ellipse(Rect::new(100.0, 100.0, 200.0, 150.0)), Appearance::default_art());
let gid = d.alloc_id();
let g = Node::group(gid, vec![Arc::new(a), Arc::new(e)]);
let d = doc_with(vec![g]);
let s1 = export(&d, &ExportOptions::default());
let r = import(&s1).unwrap();
let s2 = export(&r, &ExportOptions { object_ids: ObjectIds::Minimal, ..Default::default() });
let s1b = export(&d, &ExportOptions { object_ids: ObjectIds::Minimal, ..Default::default() });
let strip = |s: &str| s.lines().filter(|l| !l.contains("<title>")).collect::<Vec<_>>().join("\n");
assert_eq!(strip(&s1b), strip(&s2));
let _ = PathData::default();
}
fn masked_doc(clip: bool, invert: bool) -> Document {
let mut d = Document::new(200.0, 200.0);
let red = Appearance::basic(Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::None, 0.0);
let white = Appearance::basic(Paint::solid(Color::WHITE), Paint::None, 0.0);
let mut n = rect_node(&mut d, Rect::new(10.0, 10.0, 90.0, 90.0), red);
let art = rect_node(&mut d, Rect::new(10.0, 10.0, 50.0, 90.0), white);
let mut m = vectorcraft_doc::OpacityMask::new(art, clip);
m.invert = invert;
n.mask = Some(Box::new(m));
let l = d.layers[0].id;
d.insert(Some(l), 0, n).unwrap();
d
}
#[test]
fn opacity_mask_exports_as_svg_mask_and_imports_back() {
let d = masked_doc(true, false);
let s = export(&d, &ExportOptions::default());
assert!(s.contains("<mask id=\"mask-1\""), "{s}");
assert!(s.contains("mask=\"url(#mask-1)\""), "{s}");
let back = import(&s).unwrap();
let masked: Vec<&Node> = art(&back).into_iter().filter(|n| n.mask.is_some()).collect();
assert_eq!(masked.len(), 1, "{s}");
let m = masked[0].mask.as_deref().unwrap();
assert!(close_rect(m.art.geometric_bounds().unwrap(), Rect::new(10.0, 10.0, 50.0, 90.0), 1e-6));
assert!(close_rect(masked[0].geometric_bounds().unwrap(), Rect::new(10.0, 10.0, 90.0, 90.0), 1e-6));
}
#[test]
fn unclipped_inverted_mask_exports_backdrop_and_filter() {
let s = export(&masked_doc(false, true), &ExportOptions::default());
assert!(s.contains("<feColorMatrix"), "{s}");
assert!(s.contains("fill=\"white\""), "{s}");
let disabled = {
let mut d = masked_doc(true, false);
let id = d.layers[0].children().unwrap()[0].id;
d.node_mut(id).unwrap().mask.as_mut().unwrap().disabled = true;
export(&d, &ExportOptions::default())
};
assert!(!disabled.contains("<mask"), "disabled masks are not exported");
}
#[test]
fn opentype_features_export_as_css() {
let mut d = Document::new(200.0, 200.0);
let st = CharStyle { features: vec!["-liga".into(), "dlig".into()], ..Default::default() };
let n = Node::new(d.alloc_id(), NodeKind::Text(Box::new(TextObject::point(Point::new(10.0, 50.0), "office", st))));
let l = d.layers[0].id;
d.insert(Some(l), 0, n).unwrap();
let s = export(&d, &ExportOptions::default());
assert!(s.contains("font-feature-settings:&quot;liga&quot; 0, &quot;dlig&quot; 1"), "{s}");
}
#[test]
fn live_effects_export_as_geometry_and_filters() {
let mut d = Document::new(400.0, 400.0);
let mut n = rect_node(&mut d, Rect::new(100.0, 100.0, 200.0, 200.0), Appearance::default_art());
let fx = |id: &str, p: serde_json::Value| vectorcraft_doc::Effect { id: id.into(), params: p, visible: true };
n.appearance.effects.push(fx("path.offsetPath", serde_json::json!({"offset": 10.0})));
n.appearance.effects.push(fx("stylize.dropShadow", serde_json::json!({"x": 7.0, "y": 7.0, "blur": 5.0})));
let l = d.layers[0].id;
d.insert(Some(l), 0, n).unwrap();
let s = export(&d, &ExportOptions { artboard: Some(0), ..Default::default() });
assert!(s.contains("<filter id=") && s.contains("feGaussianBlur") && s.contains("dx=\"7\""), "{s}");
assert!(s.contains("filter=\"url(#"), "{s}");
// The offset path (120 × 120) comes back on import.
let back = import(&s).unwrap();
let b = art(&back).iter().filter_map(|n| n.geometric_bounds()).fold(None, |a: Option<Rect>, b| Some(a.map_or(b, |a| a.union(b)))).unwrap();
assert!((b.width() - 120.0).abs() < 1.0, "{b:?}");
}
#[test]
fn outline_text_writes_glyph_paths() {
let mut d = Document::new(300.0, 100.0);
let mut st = CharStyle { size: 30.0, ..CharStyle::default() };
st.fill = Paint::solid(Color::rgb(1.0, 0.0, 0.0));
let t = TextObject::point(Point::new(10.0, 50.0), "Hi there", st);
let id = d.alloc_id();
let l = d.layers[0].id;
d.insert(Some(l), 0, Node::new(id, NodeKind::Text(Box::new(t)))).unwrap();
let plain = export(&d, &ExportOptions::default());
assert!(plain.contains("<text"));
let s = export(&d, &ExportOptions { outline_text: true, ..Default::default() });
assert!(!s.contains("<text") && s.contains("<path") && s.contains("#ff0000"), "{s}");
// The outlines come back as paths spanning the text's width.
let back = import(&s).unwrap();
let b = art(&back).iter().filter_map(|n| n.geometric_bounds()).reduce(|a, b| a.union(b)).unwrap();
assert!(b.width() > 80.0 && b.x0 >= 9.0, "{b:?}");
}
#[test]
fn bidirectional_svg_export_preserves_shaped_appearance_as_outlines() {
let t = TextObject::point(Point::new(10.0, 40.0), "שלום Rust 123 مرحبا", CharStyle::default());
let expected =
vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t).glyphs.iter().filter(|g| !g.outline.elements().is_empty()).count();
assert!(expected > 0);
let d = doc_with(vec![Node::new(NodeId(100), NodeKind::Text(Box::new(t)))]);
let svg = export(&d, &ExportOptions::default());
assert!(!svg.contains("<text"), "visual clusters must not be emitted as reversed live text");
assert!(svg.contains("<path"));
assert!(import(&svg).is_ok());
}