//! SVG import details: linked images read from the SVG's folder and kept linked (a placeholder //! and a warning when missing), SVG images as vector art, nested clips, undisplayed objects as //! hidden objects, and reflected or repeated gradients. // 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::path::{Path, PathBuf}; use vectorcraft_doc::{Document, ImageObject, LinkInfo, Node, NodeKind}; use vectorcraft_geom::Rect; use vectorcraft_svg::{ImportOptions, import_with, import_with_report}; use vectorcraft_testkit::raster::{Image, assert_similar, render_artboard}; fn svg(body: &str) -> String { format!(r#"{body}"#) } /// 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 images(d: &Document) -> Vec<(&Node, &ImageObject)> { art(d) .into_iter() .filter_map(|n| match &n.kind { NodeKind::Image(im) => Some((n, &**im)), _ => None, }) .collect() } /// The SVG rendered by resvg on white at 1 px/pt. fn resvg_render(svg: &str, w: u32, h: u32) -> Image { let tree = resvg::usvg::Tree::from_str(svg, &resvg::usvg::Options::default()).expect("parse"); let mut pm = resvg::tiny_skia::Pixmap::new(w, h).unwrap(); pm.fill(resvg::tiny_skia::Color::WHITE); resvg::render(&tree, resvg::tiny_skia::Transform::identity(), &mut pm.as_mut()); Image { width: w, height: h, rgba: pm.data().to_vec() } } fn png(w: u32, h: u32) -> Vec { let mut out = vec![]; image::RgbaImage::from_pixel(w, h, image::Rgba([10, 200, 30, 255])) .write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png) .unwrap(); out } /// A fresh folder for one test's files. fn folder(name: &str) -> PathBuf { let dir = std::env::temp_dir().join(format!("vectorcraft-svg-{name}-{}", std::process::id())); let _ = std::fs::remove_dir_all(&dir); std::fs::create_dir_all(dir.join("img")).unwrap(); dir } /// Reads files as the app does (the link knows the size and hash). fn read(path: &str) -> Option<(Vec, LinkInfo)> { let bytes = std::fs::read(path).ok()?; let link = LinkInfo { size: Some(bytes.len() as u64), hash: Some(vectorcraft_doc::links::hash_bytes(&bytes)), ..LinkInfo::new(path) }; Some((bytes, link)) } fn open_in(dir: &Path, body: &str) -> (Document, Vec) { let folder = dir.to_string_lossy(); import_with(&svg(body), &ImportOptions { folder: Some(&folder), read: Some(&read) }).unwrap() } #[test] fn a_linked_png_resolves_against_the_svg_folder_and_stays_linked() { let dir = folder("linked"); let bytes = png(40, 20); std::fs::write(dir.join("img").join("photo one.png"), &bytes).unwrap(); let (d, w) = open_in(&dir, r#""#); assert!(w.is_empty(), "{w:?}"); let [(_, im)] = images(&d)[..] else { panic!("{:?}", art(&d)) }; let link = im.link.as_ref().unwrap(); assert_eq!(Path::new(&link.path), dir.join("img").join("photo one.png")); assert_eq!(link.hash, Some(vectorcraft_doc::links::hash_bytes(&bytes))); assert_eq!((im.width, im.height), (40, 20)); assert_eq!(*d.images[&im.key].bytes, bytes, "the file's pixels"); let b = im.xf.transform_rect_bbox(Rect::new(0.0, 0.0, 40.0, 20.0)); assert!((b.x0 - 10.0).abs() < 1e-6 && (b.y0 - 20.0).abs() < 1e-6 && (b.width() - 80.0).abs() < 1e-6, "{b:?}"); } #[test] fn a_missing_link_warns_and_keeps_a_placeholder_in_its_box() { let dir = folder("missing"); let (d, w) = open_in(&dir, r#""#); assert!(w.iter().any(|w| w.contains("gone.png") && w.contains("not found")), "{w:?}"); let [(_, im)] = images(&d)[..] else { panic!("{:?}", art(&d)) }; assert_eq!(Path::new(&im.link.as_ref().unwrap().path), dir.join("img").join("gone.png")); assert!(d.images[&im.key].is_proxy(), "a placeholder, not the file"); // Stretched to the image's box, whatever its aspect. let b = im.xf.transform_rect_bbox(Rect::new(0.0, 0.0, im.width as f64, im.height as f64)); assert!((b.x0 - 10.0).abs() < 1e-6 && (b.width() - 100.0).abs() < 1e-6 && (b.height() - 50.0).abs() < 1e-6, "{b:?}"); // Without a reader nothing is read: the same placeholder. let (d, w) = import_with_report(&svg(r#""#)).unwrap(); assert_eq!(images(&d)[0].1.link.as_ref().unwrap().path, "/abs/photo.png"); assert!(!w.is_empty()); } #[test] fn svg_images_import_as_vector_art() { let dir = folder("svgimage"); let inner = r##""##; std::fs::write(dir.join("img").join("logo.svg"), inner).unwrap(); let data = format!("data:image/svg+xml;base64,{}", vectorcraft_svg::base64_encode(inner.as_bytes())); let body = format!(r#""#); let (d, w) = open_in(&dir, &body); assert!(w.is_empty(), "{w:?}"); assert!(images(&d).is_empty()); let mut boxes: Vec = art(&d).iter().filter(|n| n.path_data().is_some()).filter_map(|n| n.geometric_bounds()).collect(); boxes.sort_by(|a, b| a.x0.total_cmp(&b.x0)); assert_eq!(boxes.len(), 2, "{:?}", art(&d)); assert!((boxes[0].x0 - 10.0).abs() < 1e-6 && (boxes[0].width() - 20.0).abs() < 1e-6, "{boxes:?}"); assert!((boxes[1].x0 - 100.0).abs() < 1e-6 && (boxes[1].height() - 20.0).abs() < 1e-6, "{boxes:?}"); } /// usvg unpacks gzipped SVG images itself (its `svgz` feature, which the workspace keeps). #[test] fn svgz_images_import_as_vector_art() { let dir = folder("svgzimage"); let inner = r##""##; let gz = vectorcraft_svg::compress(inner); std::fs::write(dir.join("img").join("logo.svgz"), &gz).unwrap(); let data = format!("data:image/svg+xml;base64,{}", vectorcraft_svg::base64_encode(&gz)); let body = format!(r#""#); let (d, w) = open_in(&dir, &body); assert!(w.is_empty(), "{w:?}"); assert!(images(&d).is_empty()); let mut boxes: Vec = art(&d).iter().filter(|n| n.path_data().is_some()).filter_map(|n| n.geometric_bounds()).collect(); boxes.sort_by(|a, b| a.x0.total_cmp(&b.x0)); assert_eq!(boxes.len(), 2, "{:?}", art(&d)); assert!((boxes[0].x0 - 10.0).abs() < 1e-6 && (boxes[0].width() - 20.0).abs() < 1e-6, "{boxes:?}"); assert!((boxes[1].x0 - 100.0).abs() < 1e-6 && (boxes[1].height() - 20.0).abs() < 1e-6, "{boxes:?}"); } #[test] fn nested_clips_intersect() { let body = r##" "##; let (d, w) = import_with_report(&svg(body)).unwrap(); assert!(w.is_empty(), "{w:?}"); let a = art(&d); assert!(matches!(&a[0].kind, NodeKind::Group { clip: true, children } if matches!(children[1].kind, NodeKind::Group { clip: true, .. }))); let mine = render_artboard(&d); assert_similar(&mine, &resvg_render(&svg(body), 200, 120), 24.0, 0.003); // The left half of the circle is cut. assert_eq!(mine.over_white(45, 60), [255, 255, 255]); assert_ne!(mine.over_white(90, 60), [255, 255, 255]); } #[test] fn undisplayed_objects_import_hidden() { let body = r##"