753 lines
34 KiB
Rust
753 lines
34 KiB
Rust
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, 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());
|
||
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);
|
||
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)], effects: vec![] };
|
||
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)], effects: vec![] },
|
||
);
|
||
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 }),
|
||
angle: 0.0,
|
||
swatch: 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 }),
|
||
angle: 0.0,
|
||
swatch: 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)),
|
||
],
|
||
effects: vec![],
|
||
};
|
||
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 }),
|
||
);
|
||
let mut d = doc_with(vec![n]);
|
||
d.images.insert("k".into(), ImageBlob { mime: "image/png".into(), bytes: Arc::new(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 & B <C>") && s.contains(""q"") && 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 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 };
|
||
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:?}");
|
||
}
|
||
|
||
#[test]
|
||
fn styling_modes_roundtrip() {
|
||
for styling in [Styling::PresentationAttributes, Styling::InlineStyle, 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::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.
|
||
let s = export(&d, &ExportOptions { artboard: None, ..Default::default() });
|
||
assert!(s.contains("viewBox=\"0 0 43.877 34\""), "{s}");
|
||
let s = export(&d, &ExportOptions { object_ids: false, ..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:?}");
|
||
assert!(w.iter().any(|w| w.contains("pattern")), "{w:?}");
|
||
}
|
||
|
||
#[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 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_invalid_is_error() {
|
||
assert!(import("not svg").is_err());
|
||
assert!(import("<svg xmlns=\"http://www.w3.org/2000/svg\"><rect").is_err());
|
||
}
|
||
|
||
#[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: false, ..Default::default() });
|
||
let s1b = export(&d, &ExportOptions { object_ids: false, ..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:"liga" 0, "dlig" 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:?}");
|
||
}
|