// 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) -> 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("\n\"q\"", st); let id = d.alloc_id(); let mut n = Node::new(id, NodeKind::Text(Box::new(t))); n.name = Some("a".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")); 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 \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 `` 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("`. #[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(" assert!(s.contains("fill=\"#abcdef\"")), Styling::InlineStyle => assert!(s.contains("style=\"fill:#abcdef")), Styling::StyleEntities => { assert!(s.contains(" { assert!(s.contains(" Hello World "#, ); 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 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#" firstthirdfar"#, ); 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#" Hello worldnext"#, ); 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(" "#, ) .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#" Hi"#, ) .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#""#)).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##""##); 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("")).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()); }