mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-27 11:17:01 -06:00
119 lines
5.1 KiB
Rust
119 lines
5.1 KiB
Rust
//! SVG export/import properties: never panics, always re-imports, and plain art survives the round
|
|
//! trip visually (render comparison with a perceptual tolerance).
|
|
|
|
use proptest::prelude::*;
|
|
use vectorcraft_color::{Color, Paint};
|
|
use vectorcraft_doc::Appearance;
|
|
use vectorcraft_geom::{PathData, Rect, shapes};
|
|
use vectorcraft_svg::{ExportOptions, Styling, export, import};
|
|
use vectorcraft_testkit::fixtures::{self, DocBuilder, art_nodes};
|
|
use vectorcraft_testkit::invariants::check_document;
|
|
use vectorcraft_testkit::raster::{assert_similar, render_artboard};
|
|
use vectorcraft_testkit::strategies::{arb_closed_shape, arb_ops, arb_path_data};
|
|
|
|
fn all_options() -> Vec<ExportOptions> {
|
|
let mut v = vec![];
|
|
for styling in [Styling::PresentationAttributes, Styling::InlineStyle, Styling::InternalCss] {
|
|
for (minify, decimals, object_ids, responsive) in [(false, 3, true, false), (true, 1, false, true), (false, 7, true, true)] {
|
|
v.push(ExportOptions { artboard: Some(0), styling, decimals, object_ids, minify, responsive, outline_text: false });
|
|
}
|
|
}
|
|
v.push(ExportOptions { artboard: None, ..Default::default() });
|
|
v
|
|
}
|
|
|
|
fn arb_color() -> impl Strategy<Value = Color> {
|
|
(0u8..=255, 0u8..=255, 0u8..=255).prop_map(|(r, g, b)| Color::rgb8(r, g, b))
|
|
}
|
|
|
|
proptest! {
|
|
#![proptest_config(ProptestConfig { cases: 48, failure_persistence: None, ..ProptestConfig::default() })]
|
|
|
|
/// Documents from random editing export (all option combos) and re-import to valid documents.
|
|
#[test]
|
|
fn random_documents_export_and_reimport(ops in arb_ops(5..40)) {
|
|
let mut s = fixtures::session();
|
|
for op in &ops {
|
|
let _ = op.apply(&mut s);
|
|
}
|
|
let d = s.doc().unwrap().doc.clone();
|
|
for o in all_options() {
|
|
let svg = export(&d, &o);
|
|
let back = import(&svg).map_err(|e| TestCaseError::fail(format!("{o:?}: {e}\n{svg}")))?;
|
|
check_document(&back).map_err(TestCaseError::fail)?;
|
|
}
|
|
}
|
|
|
|
/// Solid-filled shapes look the same after export → import.
|
|
#[test]
|
|
fn plain_art_survives_visually(shapes_ in prop::collection::vec((arb_closed_shape(), arb_color(), 0.2f32..1.0), 1..6)) {
|
|
let mut b = DocBuilder::new(200.0, 200.0);
|
|
for (p, c, o) in &shapes_ {
|
|
b.path(p.clone(), Appearance::basic(Paint::solid(*c), Paint::None, 0.0), |n| n.opacity = *o);
|
|
}
|
|
let d = b.build();
|
|
let back = import(&export(&d, &ExportOptions::default())).unwrap();
|
|
assert_similar(&render_artboard(&d), &render_artboard(&back), 6.0, 0.002);
|
|
}
|
|
|
|
/// Path geometry survives within the export precision.
|
|
#[test]
|
|
fn path_bounds_survive(p in arb_path_data(), decimals in 2u8..6) {
|
|
let mut b = DocBuilder::new(200.0, 200.0);
|
|
b.path(p.clone(), Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 1.0), |_| {});
|
|
let d = b.build();
|
|
let back = import(&export(&d, &ExportOptions { decimals, ..Default::default() })).unwrap();
|
|
let nodes = art_nodes(&back);
|
|
let paths: Vec<&PathData> = nodes.iter().filter_map(|n| n.path_data()).collect();
|
|
prop_assert!(!paths.is_empty(), "no path back");
|
|
// Multi-subpath paths may come back split into several objects; compare the union.
|
|
let bb = paths.iter().filter_map(|q| q.bounds()).reduce(|x, y| x.union(y)).unwrap();
|
|
let a = p.bounds().unwrap();
|
|
let eps = 2.0 * 10f64.powi(-(decimals as i32));
|
|
prop_assert!((a.x0 - bb.x0).abs() <= eps && (a.y0 - bb.y0).abs() <= eps && (a.x1 - bb.x1).abs() <= eps && (a.y1 - bb.y1).abs() <= eps, "{a:?} vs {bb:?}");
|
|
}
|
|
|
|
/// Importing arbitrary text never panics.
|
|
#[test]
|
|
fn import_garbage_never_panics(s in ".{0,200}") {
|
|
let _ = import(&s);
|
|
let _ = import(&format!("<svg xmlns=\"http://www.w3.org/2000/svg\">{s}</svg>"));
|
|
}
|
|
|
|
/// Importing random path data never panics and gives a valid document.
|
|
#[test]
|
|
fn import_random_path_data(d in "[MLCQZHVAmlcqzhva0-9 .,eE+-]{0,80}") {
|
|
let svg = format!("<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"100\" height=\"100\"><path d=\"{d}\"/></svg>");
|
|
if let Ok(doc) = import(&svg) {
|
|
check_document(&doc).map_err(TestCaseError::fail)?;
|
|
}
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn rich_fixture_exports_with_all_options() {
|
|
let s = fixtures::rich_session();
|
|
let d = s.doc().unwrap().doc.clone();
|
|
for o in all_options() {
|
|
let svg = export(&d, &o);
|
|
let back = import(&svg).unwrap_or_else(|e| panic!("{o:?}: {e}"));
|
|
check_document(&back).unwrap();
|
|
if o.minify {
|
|
assert!(!svg.contains("\n "), "minified output is indented");
|
|
}
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn rect_survives_visually_with_stroke() {
|
|
let mut b = DocBuilder::new(100.0, 100.0);
|
|
b.path(
|
|
shapes::rectangle(Rect::new(20.0, 20.0, 80.0, 70.0)),
|
|
Appearance::basic(Paint::solid(Color::rgb8(30, 120, 200)), Paint::solid(Color::BLACK), 3.0),
|
|
|_| {},
|
|
);
|
|
let d = b.build();
|
|
let back = import(&export(&d, &ExportOptions::default())).unwrap();
|
|
assert_similar(&render_artboard(&d), &render_artboard(&back), 4.0, 0.001);
|
|
}
|