Files
vectorcraft/crates/engine/src/tests_assets.rs
T
dotCooCoo 98d1d738a2 Export Selection: the crop takes in what each object's effects draw
Export Selection, Asset Export, the Assets tab of Export for Screens
and the rasterized art of CSS Properties cropped each object to its
visual bounds, which leave out live effects. A path with an 8 pt drop
shadow was exported with the shadow cut off at the path's edge, and
a group's own shadow was cut off the same way.

The crop is now the bounds the renderer measures for what it draws
(`vectorcraft_render::effect_bounds`): `painted_bounds` without its
room for brush art. A brushed path is cropped to its visual bounds as
before, and a Shaper group leaves out its transparent source art as
its visual bounds do. `assets.list` reports the same crop. The
renderer measures a shadow's or glow's reach on every side of the
object, so the image also has transparent room on the side away from
an offset shadow.

Docs: docs/mcp.md, docs/target-app-parity.md.
2026-10-16 02:26:14 +07:00

304 lines
16 KiB
Rust

//! Asset Export: collecting art as assets (single and multiple), assets following their art
//! (moves, deletions), names, undo, the native round trip, the shared export settings and
//! exporting assets alone, cropped to their art.
use serde_json::{Value, json};
use vectorcraft_color::{Color, Paint};
use super::*;
fn session() -> Session {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 200, "height": 200})).unwrap();
// Unstroked art: visual bounds are the geometry.
s.paint.stroke = Paint::None;
s
}
fn run(s: &mut Session, id: &str, p: Value) -> Value {
s.execute(id, &p).unwrap_or_else(|e| panic!("{id}: {e}"))
}
fn rect(s: &mut Session, x: f64, y: f64, w: f64, h: f64) -> u64 {
run(s, "shape.rectangle", json!({"x": x, "y": y, "width": w, "height": h}))["id"].as_u64().unwrap()
}
fn assets(s: &mut Session) -> Vec<Value> {
run(s, "assets.list", json!({}))["assets"].as_array().unwrap().clone()
}
fn names(s: &mut Session) -> Vec<String> {
assets(s).iter().map(|a| a["name"].as_str().unwrap().to_string()).collect()
}
fn undo_len(s: &Session) -> usize {
s.doc().unwrap().history.undo.len()
}
/// `(name, bytes)` of each file an export returned.
fn files(r: &Value) -> Vec<(String, Vec<u8>)> {
r["files"]
.as_array()
.unwrap()
.iter()
.map(|f| (f["name"].as_str().unwrap().to_string(), vectorcraft_format::base64_decode(f["dataBase64"].as_str().unwrap()).unwrap()))
.collect()
}
#[test]
fn multiple_gives_an_asset_per_object_and_single_one_of_them_all() {
let mut s = session();
let (a, b) = (rect(&mut s, 10.0, 10.0, 20.0, 20.0), rect(&mut s, 50.0, 10.0, 30.0, 20.0));
run(&mut s, "select.set", json!({"ids": [a, b]}));
let r = run(&mut s, "assets.add", json!({}));
assert_eq!(r["added"], 2);
let list = assets(&mut s);
assert_eq!(list.len(), 2);
assert_eq!((list[0]["nodes"].clone(), list[1]["nodes"].clone()), (json!([a]), json!([b])));
assert_eq!(r["assets"], json!([list[0]["id"], list[1]["id"]]));
let single = run(&mut s, "assets.add", json!({"multiple": false}));
assert_eq!(single["added"], 1);
let list = assets(&mut s);
assert_eq!(list.len(), 3);
assert_eq!(list[2]["nodes"], json!([a, b]), "one asset of both, back to front");
assert_eq!(list[2]["bounds"], json!([10.0, 10.0, 70.0, 20.0]), "the crop is the art's bounds");
// Collecting the same art again keeps its assets (and isn't an undo step).
let steps = undo_len(&s);
let again = run(&mut s, "assets.add", json!({"ids": [b, a]}));
assert_eq!((again["added"].clone(), again["assets"].clone()), (json!(0), json!([list[0]["id"], list[1]["id"]])));
assert_eq!(undo_len(&s), steps);
// Layers aren't art; nothing to collect is an error.
let layer = s.doc().unwrap().doc.layers[0].id.0;
assert!(s.execute("assets.add", &json!({"ids": [layer]})).is_err());
run(&mut s, "select.set", json!({"ids": []}));
assert!(s.execute("assets.add", &json!({})).is_err());
assert!(s.execute("assets.add", &json!({"ids": [a, b], "name": "Two"})).is_err(), "a name names one asset");
}
#[test]
fn deleting_the_object_prunes_it_and_undo_brings_it_back() {
let mut s = session();
let (a, b) = (rect(&mut s, 10.0, 10.0, 20.0, 20.0), rect(&mut s, 50.0, 10.0, 30.0, 20.0));
run(&mut s, "assets.add", json!({"ids": [a]}));
run(&mut s, "assets.add", json!({"ids": [a, b], "multiple": false}));
run(&mut s, "select.set", json!({"ids": [a]}));
run(&mut s, "edit.clear", json!({}));
let list = assets(&mut s);
assert_eq!(list.len(), 1, "the asset of the deleted object goes");
assert_eq!(list[0]["nodes"], json!([b]), "the other keeps the art that is left");
run(&mut s, "edit.undo", json!({}));
let list = assets(&mut s);
assert_eq!((list.len(), list[1]["nodes"].clone()), (2, json!([a, b])));
}
#[test]
fn moving_the_art_moves_the_crop() {
let mut s = session();
let a = rect(&mut s, 10.0, 10.0, 20.0, 20.0);
run(&mut s, "assets.add", json!({"ids": [a]}));
let before = files(&run(&mut s, "assets.export", json!({})));
run(&mut s, "select.set", json!({"ids": [a]}));
run(&mut s, "object.move", json!({"dx": 40, "dy": 25}));
assert_eq!(assets(&mut s)[0]["bounds"], json!([50.0, 35.0, 20.0, 20.0]));
let after = files(&run(&mut s, "assets.export", json!({})));
assert_eq!(before, after, "the same art, cropped the same way");
assert_eq!(after[0].0, "Asset-1.png");
}
#[test]
fn an_asset_exports_its_art_alone() {
let mut s = session();
s.paint.fill = Paint::solid(Color::rgb(1.0, 0.0, 0.0));
let red = rect(&mut s, 10.0, 10.0, 20.0, 20.0);
// Blue art over it isn't part of the asset.
s.paint.fill = Paint::solid(Color::rgb(0.0, 0.0, 1.0));
rect(&mut s, 0.0, 0.0, 25.0, 25.0);
run(&mut s, "assets.add", json!({"ids": [red]}));
let out = files(&run(&mut s, "assets.export", json!({"formats": [{"format": "png", "scale": "2x"}]})));
assert_eq!(out[0].0, "Asset-1@2x.png");
let img = image::load_from_memory(&out[0].1).unwrap().to_rgba8();
assert_eq!(img.dimensions(), (40, 40));
assert!(img.pixels().all(|p| p.0 == [255, 0, 0, 255]), "only the red square");
// Vector formats crop to it too.
let svg = files(&run(&mut s, "assets.export", json!({"formats": [{"format": "svg"}]})));
let text = String::from_utf8(svg[0].1.clone()).unwrap();
assert_eq!(svg[0].0, "Asset-1.svg");
assert!(text.contains("viewBox=\"10 10 20 20\"") || text.contains("viewBox=\"0 0 20 20\""), "{text}");
assert!(!text.to_lowercase().contains("#0000ff"), "no blue: {text}");
}
/// The crop of an asset takes in its effects: the drop shadow is inside the exported image, and
/// assets.list gives that crop.
#[test]
fn an_assets_crop_takes_in_its_drop_shadow() {
let mut s = session();
let a = rect(&mut s, 50.0, 50.0, 20.0, 20.0);
run(&mut s, "assets.add", json!({"ids": [a]}));
assert_eq!(assets(&mut s)[0]["bounds"], json!([50.0, 50.0, 20.0, 20.0]), "the plain square");
run(&mut s, "effect.apply", json!({"effect": "stylize.dropShadow", "ids": [a], "params": {"x": 10, "y": 0, "blur": 0}}));
// The shadow's reach (10 pt) on every side.
assert_eq!(assets(&mut s)[0]["bounds"], json!([40.0, 40.0, 40.0, 40.0]));
let out = files(&run(&mut s, "assets.export", json!({"formats": [{"format": "png", "scale": "1x"}]})));
let img = image::load_from_memory(&out[0].1).unwrap().to_rgba8();
assert_eq!(img.dimensions(), (40, 40));
// The shadow's right half, x 70 to 80.
assert!(img.get_pixel(35, 20)[3] > 0, "the shadow is in the image");
}
#[test]
fn names_follow_the_object_or_count_up_and_can_be_changed() {
let mut s = session();
let (a, b, c) = (rect(&mut s, 10.0, 10.0, 20.0, 20.0), rect(&mut s, 50.0, 10.0, 20.0, 20.0), rect(&mut s, 90.0, 10.0, 20.0, 20.0));
run(&mut s, "layer.setProps", json!({"id": b, "name": "Icon"}));
run(&mut s, "assets.add", json!({"ids": [a, b]}));
assert_eq!(names(&mut s), ["Asset 1", "Icon"]);
let first = assets(&mut s)[0]["id"].as_u64().unwrap();
run(&mut s, "assets.rename", json!({"asset": first, "name": " Asset 7 "}));
run(&mut s, "assets.add", json!({"ids": [c]}));
assert_eq!(names(&mut s), ["Asset 7", "Icon", "Asset 8"], "trimmed; numbering goes on from the highest");
run(&mut s, "assets.add", json!({"ids": [a, c], "multiple": false, "name": "Pair"}));
assert_eq!(names(&mut s)[3], "Pair");
assert!(s.execute("assets.rename", &json!({"asset": first, "name": " "})).is_err());
assert!(s.execute("assets.rename", &json!({"asset": 999_999, "name": "X"})).is_err());
// Assets that share a name (in any case) get -2, -3… instead of overwriting.
let ids: Vec<u64> = assets(&mut s).iter().map(|a| a["id"].as_u64().unwrap()).collect();
run(&mut s, "assets.rename", json!({"asset": ids[0], "name": "icon"}));
let out = files(&run(&mut s, "assets.export", json!({"assets": [ids[0], ids[1]]})));
assert_eq!(out.iter().map(|f| f.0.as_str()).collect::<Vec<_>>(), ["icon.png", "Icon-2.png"]);
}
#[test]
fn add_remove_and_rename_are_undo_steps() {
let mut s = session();
let a = rect(&mut s, 10.0, 10.0, 20.0, 20.0);
let steps = undo_len(&s);
let id = run(&mut s, "assets.add", json!({"ids": [a]}))["assets"][0].as_u64().unwrap();
run(&mut s, "assets.rename", json!({"asset": id, "name": "Logo"}));
run(&mut s, "assets.remove", json!({"assets": [id]}));
assert_eq!(undo_len(&s), steps + 3);
assert!(assets(&mut s).is_empty());
run(&mut s, "edit.undo", json!({}));
assert_eq!(names(&mut s), ["Logo"]);
run(&mut s, "edit.undo", json!({}));
assert_eq!(names(&mut s), ["Asset 1"]);
run(&mut s, "edit.undo", json!({}));
assert!(assets(&mut s).is_empty());
run(&mut s, "edit.redo", json!({}));
assert_eq!(names(&mut s), ["Asset 1"]);
assert!(s.execute("assets.remove", &json!({"assets": [12345]})).is_err());
assert!(s.execute("assets.remove", &json!({})).is_err());
}
#[test]
fn assets_round_trip_through_the_native_format() {
let mut s = session();
let (a, b) = (rect(&mut s, 10.0, 10.0, 20.0, 20.0), rect(&mut s, 50.0, 10.0, 30.0, 20.0));
run(&mut s, "assets.add", json!({"ids": [a, b], "multiple": false, "name": "Both"}));
let saved = s.doc().unwrap().doc.assets.clone();
let b64 = run(&mut s, "document.serialize", json!({"format": "vectorcraft"}))["dataBase64"].as_str().unwrap().to_string();
run(&mut s, "document.open", json!({"name": "assets.vectorcraft", "dataBase64": b64}));
assert_eq!(s.doc().unwrap().doc.assets, saved);
// A new asset never takes an id already used.
let c = rect(&mut s, 90.0, 10.0, 20.0, 20.0);
let id = run(&mut s, "assets.add", json!({"ids": [c]}))["assets"][0].as_u64().unwrap();
assert!(saved.iter().all(|x| x.id != id));
// Documents without assets don't write the key, and files without it load.
let mut d = vectorcraft_doc::Document::new(10.0, 10.0);
let text = serde_json::to_string(&d).unwrap();
assert!(!text.contains("\"assets\""));
d = serde_json::from_str(&text).unwrap();
assert!(d.assets.is_empty());
}
#[test]
fn the_shared_settings_drive_the_export_and_arent_an_undo_step() {
let mut s = session();
let a = rect(&mut s, 10.0, 10.0, 20.0, 20.0);
run(&mut s, "assets.add", json!({"ids": [a]}));
let steps = undo_len(&s);
let r = run(&mut s, "assets.settings.set", json!({"formats": [{"format": "png", "scale": "1x"}, {"format": "svg"}], "prefix": "ic_"}));
assert_eq!(r["settings"]["prefix"], "ic_");
assert_eq!(undo_len(&s), steps);
assert!(s.doc().unwrap().is_dirty());
// Export for Screens reads them too (they are its remembered settings).
assert_eq!(run(&mut s, "document.exportSettings", json!({}))["settings"]["formats"][1]["format"], "svg");
let out = files(&run(&mut s, "assets.export", json!({})));
assert_eq!(out.iter().map(|f| f.0.as_str()).collect::<Vec<_>>(), ["ic_Asset-1.png", "ic_Asset-1.svg"]);
// A preset replaces the rows (and turns on sub-folders); rows replace a preset.
run(&mut s, "assets.settings.set", json!({"preset": "mobile"}));
let settings = run(&mut s, "document.exportSettings", json!({}))["settings"].clone();
assert!(settings.get("formats").is_none() && settings["preset"] == "mobile");
let out = files(&run(&mut s, "assets.export", json!({})));
assert_eq!(out.iter().map(|f| f.0.as_str()).collect::<Vec<_>>(), ["1x/ic_Asset-1.png", "2x/ic_Asset-1@2x.png", "3x/ic_Asset-1@3x.png"]);
run(&mut s, "assets.settings.set", json!({"formats": [{"format": "jpg", "quality": 80}], "preset": ""}));
let settings = run(&mut s, "document.exportSettings", json!({}))["settings"].clone();
assert!(settings.get("preset").is_none() && settings["formats"][0]["format"] == "jpg");
// Params given to one export win without changing the settings.
let out = files(&run(&mut s, "assets.export", json!({"formats": [{"format": "webp"}], "prefix": null})));
assert_eq!(out[0].0, "Asset-1.webp");
assert_eq!(run(&mut s, "document.exportSettings", json!({}))["settings"]["formats"][0]["format"], "jpg");
// Bad settings are refused and change nothing.
for bad in [json!({"formats": [{"format": "dxf"}]}), json!({"preset": "tiny"}), json!({"folder": "/tmp"}), json!([])] {
assert!(s.execute("assets.settings.set", &bad).is_err(), "{bad}");
}
assert_eq!(run(&mut s, "document.exportSettings", json!({}))["settings"]["formats"][0]["format"], "jpg");
}
#[test]
fn export_for_screens_writes_chosen_assets_and_keeps_its_rules() {
let mut s = session();
let (a, b) = (rect(&mut s, 10.0, 10.0, 20.0, 20.0), rect(&mut s, 50.0, 10.0, 30.0, 20.0));
let ids = run(&mut s, "assets.add", json!({"ids": [a, b]}))["assets"].clone();
let r = run(&mut s, "document.exportForScreens", json!({"assets": [ids[1]], "formats": [{"format": "png", "scale": "60w"}]}));
let out = files(&r);
assert_eq!(out.len(), 1);
assert_eq!(out[0].0, "Asset-2@60w.png");
assert_eq!(image::load_from_memory(&out[0].1).unwrap().to_rgba8().dimensions(), (60, 40), "the width of the asset, not the artboard");
// The choice of art isn't remembered; the formats are.
let settings = run(&mut s, "document.exportSettings", json!({}))["settings"].clone();
assert!(settings.get("assets").is_none() && settings["formats"][0]["scale"] == "60w");
// A zip of every asset.
let zip = run(&mut s, "document.exportForScreens", json!({"assets": ids, "zip": true}));
assert_eq!(zip["files"], json!(["Asset-1.png", "Asset-2.png"]));
for bad in [json!({"assets": [ids[0]], "fullDocument": true}), json!({"assets": [424242]}), json!({"assets": []}), json!({"assets": "all"})] {
assert!(s.execute("document.exportForScreens", &bad).is_err(), "{bad}");
}
// No assets: nothing for assets.export to do.
run(&mut s, "assets.remove", json!({"assets": ids}));
assert!(s.execute("assets.export", &json!({})).is_err());
}
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn assets_export_into_a_folder() {
let dir = std::env::temp_dir().join(format!("vc-assets-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let folder = dir.to_string_lossy().replace('\\', "/");
let mut s = session();
let a = rect(&mut s, 10.0, 10.0, 20.0, 20.0);
run(&mut s, "assets.add", json!({"ids": [a], "name": "Logo"}));
let r =
run(&mut s, "assets.export", json!({"folder": folder, "subfolders": true, "formats": [{"format": "png", "scale": "2x"}, {"format": "pdf"}]}));
assert_eq!(r["files"], json!([format!("{folder}/2x/Logo@2x.png"), format!("{folder}/PDF/Logo.pdf")]));
assert!(dir.join("2x/Logo@2x.png").is_file() && dir.join("PDF/Logo.pdf").is_file());
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn collecting_explicit_assets_rejects_invalid_ids_without_adding_anything() {
let mut s = session();
let a = rect(&mut s, 10.0, 10.0, 20.0, 20.0);
let b = rect(&mut s, 50.0, 10.0, 20.0, 20.0);
let original_selection = s.doc().unwrap().selection.objects.clone();
let before = undo_len(&s);
for ids in [json!([a, "not an object"]), json!([a, 3.5]), json!([a, u64::MAX]), json!({"id": a})] {
assert!(s.execute("assets.add", &json!({"ids": ids})).is_err(), "invalid ids: {ids}");
assert!(assets(&mut s).is_empty(), "invalid ids must not collect only the valid subset");
assert_eq!(undo_len(&s), before);
assert_eq!(s.doc().unwrap().selection.objects, original_selection);
}
// Valid ids still collect art in document paint order.
assert_eq!(run(&mut s, "assets.add", json!({"ids": [b, a]}))["added"], 2);
assert_eq!(assets(&mut s).len(), 2);
}