mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-25 02:07:09 -06:00
Layers ≡ and row menus collect art via assets.add; a layer id expands to the art under it (including sublayers) so MCP and the panel match Object › Collect for Export.
353 lines
18 KiB
Rust
353 lines
18 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. Art Optimized PNG exports are supersampled
|
||
//! so abutting shapes leave no ghost seam (#983 / #1037).
|
||
|
||
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);
|
||
// A layer id collects the art under it (Layers panel Collect for Export / MCP).
|
||
let layer = s.doc().unwrap().doc.layers[0].id.0;
|
||
let from_layer = run(&mut s, "assets.add", json!({"ids": [layer]}));
|
||
assert_eq!(from_layer["added"], 0, "those objects are assets already");
|
||
assert_eq!(from_layer["assets"], json!([list[0]["id"], list[1]["id"]]));
|
||
// An empty layer has nothing to collect.
|
||
let empty = run(&mut s, "layer.new", json!({}))["id"].as_u64().unwrap();
|
||
assert!(s.execute("assets.add", &json!({"ids": [empty]})).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 a_layer_id_collects_the_art_under_it_including_sublayers() {
|
||
let mut s = session();
|
||
let a = rect(&mut s, 10.0, 10.0, 20.0, 20.0);
|
||
let sub = run(&mut s, "layer.newSublayer", json!({}))["id"].as_u64().unwrap();
|
||
run(&mut s, "layer.setCurrent", json!({"id": sub}));
|
||
let b = rect(&mut s, 50.0, 10.0, 30.0, 20.0);
|
||
let layer = s.doc().unwrap().doc.layers[0].id.0;
|
||
let r = run(&mut s, "assets.add", json!({"ids": [layer]}));
|
||
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])));
|
||
let one = run(&mut s, "assets.add", json!({"ids": [layer], "multiple": false}));
|
||
assert_eq!(one["added"], 1);
|
||
assert_eq!(assets(&mut s).last().unwrap()["nodes"], json!([a, b]));
|
||
}
|
||
|
||
#[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);
|
||
}
|
||
|
||
/// #983: Asset Export Art Optimized PNG supersamples, so two abutting fills leave no white seam
|
||
/// on the shared edge (same idea as `supersampled_art_leaves_no_seam_between_shapes_that_meet`).
|
||
#[test]
|
||
fn assets_export_leaves_no_seam_between_abutting_shapes() {
|
||
let mut s = session();
|
||
s.paint.fill = Paint::solid(Color::rgb(1.0, 0.0, 0.0));
|
||
// Share the edge at x = 30; the crop is (10, 10)–(50, 30).
|
||
let (a, b) = (rect(&mut s, 10.0, 10.0, 20.0, 20.0), rect(&mut s, 30.0, 10.0, 20.0, 20.0));
|
||
run(&mut s, "assets.add", json!({"ids": [a, b], "multiple": false}));
|
||
let out = files(&run(
|
||
&mut s,
|
||
"assets.export",
|
||
json!({
|
||
"formats": [{"format": "png", "scale": "1x", "background": "white"}],
|
||
"antiAlias": "art",
|
||
}),
|
||
));
|
||
assert_eq!(out[0].0, "Asset-1.png");
|
||
let img = image::load_from_memory(&out[0].1).unwrap().to_rgba8();
|
||
assert_eq!(img.dimensions(), (40, 20));
|
||
// Column 20 is the shared edge in crop space; green must stay red (no white bleed).
|
||
let palest_g = (2..18).map(|y| img.get_pixel(20, y).0[1].max(img.get_pixel(19, y).0[1])).max().unwrap();
|
||
assert_eq!(palest_g, 0, "supersampled Asset Export: no seam on the shared edge (G={palest_g})");
|
||
}
|
||
|
||
#[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);
|
||
}
|