Files
vectorcraft/crates/svg/tests/it/roundtrip.rs
T
Dylan M. Taylor 566f720868 SVG import: references follow an id's first element, one read per linked file, bounded blank lines (#5409)
* fix(svg): first element of an id, shared linked file reads, bounded blank lines

* address review: cache failed linked-image reads but give each href its own placeholder and link

* address review: charge only blank lines to the line-break budget

* address review: a collapsed line break keeps its measured leading in tiny type

* address review: blank lines use the same scaled leading tolerance
2026-11-11 16:11:34 -05:00

1131 lines
55 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.
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("<ellipse cx=\"70\" cy=\"60\" rx=\"50\" ry=\"30\""), "{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("<rect").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.extras().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("<rect").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(Box::new(ImageObject {
key: "k".into(),
width: 4,
height: 2,
xf: Affine::translate((10.0, 20.0)) * Affine::scale(10.0),
link: None,
placement: Default::default(),
pixels: None,
})),
);
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 text_with_control_characters_exports_well_formed_svg() {
// A pasted manual line break (U+000B) or other C0 control character is not allowed in XML 1.0.
let t = TextObject::point(Point::new(20.0, 50.0), "A\u{0}\u{8}B\u{b}C\u{fffe}D", CharStyle::default());
let id = Document::new(200.0, 200.0).alloc_id();
let d = doc_with(vec![Node::new(id, NodeKind::Text(Box::new(t)))]);
let s = export(&d, &ExportOptions::default());
let r = import(&s).unwrap_or_else(|e| panic!("exported SVG doesn't import: {e:?}"));
let NodeKind::Text(t) = &art(&r)[0].kind else { panic!() };
assert_eq!(t.plain_text(), "AB CD");
}
#[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:?}");
}
/// Centred and right-aligned type on a path anchors where the layout aligns it: the middle or the
/// end of its span, wrapped on a closed path. It reads back drawn where it was.
#[test]
fn aligned_text_on_path_anchors_within_its_span() {
use vectorcraft_doc::Justify;
use vectorcraft_geom::Shape;
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 circle = vectorcraft_geom::kurbo::Circle::new((100.0, 100.0), 60.0).to_path(0.01);
let drawn = |t: &TextObject| t.xf.transform_rect_bbox(vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t).bounds);
for (path, start, justify, offset) in [
(&curve, 0.25, Justify::Center, "62.5%"),
(&curve, 0.25, Justify::Right, "100%"),
(&curve, 0.25, Justify::Left, "25%"),
(&circle, 0.7, Justify::Center, "20%"),
] {
let mut d = Document::new(200.0, 200.0);
let mut t = TextObject::point(Point::ZERO, "Aligned", CharStyle { size: 14.0, ..CharStyle::default() });
t.kind = TextKind::OnPath { path: PathData::from_bezpath(path), start, end: None };
t.para.justify = justify;
let before = drawn(&t);
let id = d.alloc_id();
let d = doc_with(vec![Node::new(id, NodeKind::Text(Box::new(t)))]);
let svg = export(&d, &ExportOptions::default());
assert!(svg.contains(&format!("startOffset=\"{offset}\"")), "{justify:?} at {start}: {svg}");
if path == &curve {
let r = import(&svg).unwrap();
let NodeKind::Text(t) = &art(&r)[0].kind else { panic!() };
assert!(close_rect(drawn(t), before, 0.5), "{justify:?}: {:?} {before:?}", drawn(t));
}
}
}
/// 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}");
}
}
/// Type on a path with Skew, 3D Ribbon, Stair Step or Gravity, whose glyphs a `<textPath>` would
/// turn with the path as Rainbow does, keeps its look as outlines, with spacing or without it;
/// Rainbow stays a `<textPath>`.
#[test]
fn text_on_path_effects_svg_cannot_express_are_outlined() {
use vectorcraft_doc::PathEffect;
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 effect in PathEffect::ALL {
for spacing in [0.0, 4.0] {
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: None };
t.path_effect = effect;
t.path_spacing = spacing;
let id = d.alloc_id();
let (svg, warnings) =
vectorcraft_svg::export_with_report(&doc_with(vec![Node::new(id, NodeKind::Text(Box::new(t)))]), &ExportOptions::default());
let live = effect == PathEffect::Rainbow && spacing == 0.0;
assert_eq!(svg.contains("<textPath"), live, "{effect:?} {spacing}: {svg}");
assert_eq!(warnings.iter().any(|w| w.contains("outlined")), !live, "{effect:?} {spacing}: {warnings:?}");
}
}
}
/// Type that SVG text can't draw is outlined with its text as the group's accessible name: type on
/// a path with an effect other than Rainbow, vertical type, right-to-left text, and type on a path
/// with inline graphics. With Convert to Outlines the paths carry no name, and empty type gets none
/// either.
#[test]
fn text_outlined_for_svg_keeps_an_accessible_name() {
use vectorcraft_doc::PathEffect;
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 style = || CharStyle { size: 14.0, ..CharStyle::default() };
let svg_of = |t: TextObject, outline_text: bool| {
let mut d = Document::new(200.0, 200.0);
let id = d.alloc_id();
let opts = ExportOptions { outline_text, ..ExportOptions::default() };
export(&doc_with(vec![Node::new(id, NodeKind::Text(Box::new(t)))]), &opts)
};
let mut skewed = TextObject::point(Point::ZERO, "On a curve", style());
skewed.kind = TextKind::OnPath { path: PathData::from_bezpath(&curve), start: 0.1, end: None };
skewed.path_effect = PathEffect::Skew;
let mut vertical = TextObject::point(Point::new(50.0, 20.0), "On a curve", style());
vertical.vertical = true;
let mut rtl = TextObject::point(Point::new(20.0, 50.0), "On a curve", style());
rtl.runs[0].style.char_direction = Some(vectorcraft_doc::ParaDirection::RightToLeft);
for (name, t) in [("skew", skewed), ("vertical", vertical), ("rtl", rtl)] {
let svg = svg_of(t.clone(), false);
assert!(!svg.contains("<text"), "{name}: {svg}");
assert!(svg.contains("role=\"img\" aria-label=\"On a curve\""), "{name}: {svg}");
assert!(!svg_of(t, true).contains("aria-label"), "{name} with Convert to Outlines");
}
let mut empty = TextObject::point(Point::new(50.0, 20.0), "", style());
empty.vertical = true;
assert!(!svg_of(empty, false).contains("aria-label"));
// An inline graphic's placeholder character stays out of the name; type on a path holding
// only inline graphics gets none.
let inline = || vectorcraft_doc::TextRun::inline(vectorcraft_doc::InlineArt::new("Mark"), style());
let mut mixed = TextObject::point(Point::ZERO, "On a curve", style());
mixed.kind = TextKind::OnPath { path: PathData::from_bezpath(&curve), start: 0.1, end: None };
mixed.runs.insert(0, inline());
let mut only = mixed.clone();
only.runs = vec![inline()];
let svg = svg_of(mixed, false);
assert!(svg.contains("aria-label=\"On a curve\""), "{svg}");
assert!(!svg.contains('\u{FFFC}'), "{svg}");
let svg = svg_of(only, false);
assert!(!svg.contains("aria-label"), "{svg}");
// A named object keeps its name when the file is opened again, whether the name is its id or not.
for name in ["Heading", "Main heading"] {
let mut t = TextObject::point(Point::new(50.0, 20.0), "Hello", style());
t.vertical = true;
let mut d = Document::new(200.0, 200.0);
let mut node = Node::new(d.alloc_id(), NodeKind::Text(Box::new(t)));
node.name = Some(name.to_string());
let svg = export(&doc_with(vec![node]), &ExportOptions::default());
assert!(svg.contains("aria-label=\"Hello\""), "{svg}");
let r = import(&svg).unwrap();
assert!(art(&r).iter().any(|n| n.name.as_deref() == Some(name)), "{name}: {svg}");
}
}
/// Type on a path whose punctuation hangs outside its brackets (Optical Margin Alignment or Roman
/// Hanging Punctuation) keeps its look as outlines; with the option on and nothing at its ends to
/// hang, it stays a `<textPath>`.
#[test]
fn text_on_path_with_hanging_punctuation_is_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 (text, optical, roman, live) in [
("“On a curve", true, false, false),
("“On a curve", false, true, false),
("On a curve", true, true, true),
("“On a curve", false, false, true),
] {
let mut d = Document::new(200.0, 200.0);
let mut t = TextObject::point(Point::ZERO, text, CharStyle { size: 14.0, ..CharStyle::default() });
t.kind = TextKind::OnPath { path: PathData::from_bezpath(&curve), start: 0.1, end: None };
t.optical_margin = optical;
t.para.roman_hanging = roman;
let id = d.alloc_id();
let svg = export(&doc_with(vec![Node::new(id, NodeKind::Text(Box::new(t)))]), &ExportOptions::default());
assert_eq!(svg.contains("<textPath"), live, "{text} {optical} {roman}: {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("<rect x=\"10.12\" y=\"20\""), "{s}");
let s = export(&d, &ExportOptions::default());
assert!(s.contains("width=\"200\" height=\"100\"") && s.contains("<rect x=\"10.123\" y=\"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.extras().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_many_tspans_far_apart_are_bounded_together() {
// Each tspan 9,999 lines (auto leading 14.4 pt) below the last: each within the limit of one gap,
// two hundred of them were two million line breaks.
let spans: String = (1..=200).map(|i| format!(r#"<tspan x="5" y="{}">{i}</tspan>"#, 20.0 + 14.4 * 9_999.0 * i as f64)).collect();
let d = import_art(&format!(
r#"<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100"><text x="5" y="20" font-size="12">a{spans}</text></svg>"#
));
let a = art(&d);
let NodeKind::Text(t) = &a[0].kind else { panic!() };
let text = t.plain_text();
assert!(text.ends_with("200"), "{}", &text[text.len().saturating_sub(20)..]);
let breaks = text.matches('\n').count();
assert!(breaks <= 10_200, "{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.extras_mut().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.extras().mask.is_some()).collect();
assert_eq!(masked.len(), 1, "{s}");
let m = masked[0].extras().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().extras_mut().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());
}