mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-25 18:27:02 -06:00
Optical Margin Alignment and Roman Hanging Punctuation hung punctuation in point and area type only. Type on a path kept its first and last glyphs inside its brackets with either option on. When Optical Margin Alignment is on, or the first paragraph has Roman Hanging Punctuation on, the glyphs at the ends of left-to-right type on a path now hang outside its brackets by the same amounts, measured along the path. The type hangs at the start bracket when its line is placed there, hangs at the end bracket when the line is placed there, and hangs at both when it is centered. Glyphs that hang past the end bracket do not make the type overflow. On an open path a hang stops at the path's end, and on a closed path the two hangs together take at most the stretch between the end and start brackets. SVG export writes type on a path that hangs as outlines, with a warning. Docs: docs/mcp.md, docs/type-parity.md, docs/target-app-parity.md.
996 lines
48 KiB
Rust
996 lines
48 KiB
Rust
// 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.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("<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(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(),
|
||
})),
|
||
);
|
||
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 & 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_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:?}");
|
||
}
|
||
|
||
/// 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 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("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.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_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:"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:?}");
|
||
}
|
||
|
||
#[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());
|
||
}
|