mirror of
https://github.com/storytold/figcraft.git
synced 2026-10-26 03:16:53 -06:00
SVG import comes in editable and looking right: groups named from their ids with transforms baked into the layers (rotated, scaled and skewed groups, viewBox scaling and <use> offsets used to be lost); rectangles, ellipses and lines recognised and kept as those shapes; gradients with exact handles, reflect/repeat and focal points; patterns as image fills; clip paths and masks as vector, luminance and alpha masks; shadow and blur filter chains as drop shadows, inner shadows and layer blurs; blend modes; text as text layers (it used to vanish); images and nested SVG images. What can't be represented is reported as import warnings (file.import / file.open warnings, figcraft-cli info notes). Linked files are never read, and documents nested too deep for the parser are refused instead of crashing. Pasted SVG is recognised after XML declarations, comments and doctypes and lands centred in the view (file.import center). SVG export writes vector masks as clip paths and alpha/luminance masks as the mask layer drawn, so masks survive a round trip. Tests: a corpus of 21 synthetic SVG files with hand-worked expected pixels (158 probes), structure checks and export round trips; unit tests for shape recognition, paints, filters, structure, mask export; hostile input and byte-mangled corpus fuzzing; engine file command tests. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
68 lines
3.2 KiB
Rust
68 lines
3.2 KiB
Rust
//! SVG through the file commands: imported centred where it's pasted, with warnings for what it
|
|
//! left out, opened with them, undone in one step; clipboard text recognised as SVG.
|
|
|
|
use base64::Engine as _;
|
|
use serde_json::json;
|
|
|
|
use crate::Session;
|
|
use crate::params::id_of;
|
|
|
|
const SVG: &str = r##"<svg xmlns="http://www.w3.org/2000/svg" width="40" height="20"><filter id="noise"><feTurbulence baseFrequency="0.1"/></filter><rect id="Card" width="40" height="20" rx="4" fill="#3366cc"/><circle cx="10" cy="10" r="5" filter="url(#noise)"/></svg>"##;
|
|
|
|
fn data(svg: &str) -> String {
|
|
base64::engine::general_purpose::STANDARD.encode(svg)
|
|
}
|
|
|
|
#[test]
|
|
fn imported_svg_is_editable_centred_and_reports_what_it_left_out() {
|
|
let mut s = Session::new();
|
|
let r = s.execute("file.import", &json!({"data": data(SVG), "name": "Pasted SVG.svg", "x": 100, "y": 50, "center": true})).unwrap();
|
|
let id = id_of(&r["id"]).unwrap();
|
|
assert_eq!(s.selection(), [id]);
|
|
let doc = s.doc().unwrap();
|
|
let frame = doc.node(id).unwrap();
|
|
assert_eq!((frame.name.as_str(), frame.width, frame.height), ("Pasted SVG", 40.0, 20.0));
|
|
let o = doc.world_transform(id).translation();
|
|
assert_eq!((o.x, o.y), (80.0, 40.0), "centred on the point");
|
|
let card = doc.subtree(id).into_iter().filter_map(|i| doc.node(i)).find(|n| n.name == "Card").unwrap();
|
|
assert_eq!((card.kind.type_name(), card.corners.radii[0]), ("RECTANGLE", 4.0));
|
|
assert!(r["warnings"][0].as_str().is_some_and(|w| w.contains("noise")), "{r}");
|
|
// Top-left at the point without `center`; one undo takes it away.
|
|
let r = s.execute("file.import", &json!({"data": data(SVG), "name": "x.svg", "x": 100, "y": 50})).unwrap();
|
|
let id2 = id_of(&r["id"]).unwrap();
|
|
assert_eq!(s.doc().unwrap().world_transform(id2).translation().x, 100.0);
|
|
s.execute("edit.undo", &json!({})).unwrap();
|
|
assert!(s.doc().unwrap().node(id2).is_none());
|
|
// Not SVG: an error, nothing added.
|
|
let before = s.doc().unwrap().node_count();
|
|
assert!(s.execute("file.import", &json!({"data": data("<svg"), "name": "bad.svg"})).is_err());
|
|
assert_eq!(s.doc().unwrap().node_count(), before);
|
|
}
|
|
|
|
#[test]
|
|
fn opened_svg_lists_its_warnings() {
|
|
let dir = std::env::temp_dir().join(format!("figcraft-svg-open-{}", std::process::id()));
|
|
std::fs::create_dir_all(&dir).unwrap();
|
|
let path = dir.join("icon.svg");
|
|
std::fs::write(&path, SVG).unwrap();
|
|
let mut s = Session::new();
|
|
let r = s.execute("file.open", &json!({"path": path.to_string_lossy()})).unwrap();
|
|
assert!(r["warnings"].as_array().is_some_and(|w| w.len() == 1), "{r}");
|
|
assert!(r.get("repairs").is_none(), "{r}");
|
|
let _ = std::fs::remove_dir_all(&dir);
|
|
}
|
|
|
|
#[test]
|
|
fn clipboard_text_is_recognised_as_svg() {
|
|
for yes in [
|
|
"<svg/>",
|
|
" \n<svg xmlns=\"http://www.w3.org/2000/svg\"></svg>",
|
|
"\u{feff}<?xml version=\"1.0\"?>\n<!-- Generator: x -->\n<!DOCTYPE svg PUBLIC \"-//W3C//DTD SVG 1.1//EN\" \"x.dtd\">\n<svg>",
|
|
] {
|
|
assert!(crate::looks_like_svg(yes), "{yes}");
|
|
}
|
|
for no in ["", "svg", "<html><svg/></html>", "<?xml version=\"1.0\"?><note/>", "<!-- unclosed", "Hello <svg>"] {
|
|
assert!(!crate::looks_like_svg(no), "{no}");
|
|
}
|
|
}
|