Files
vectorcraft/crates/engine/src/tests/assets.rs
T
Dylan M. Taylor 1ce015de86 Export for Screens: settings saved without their PDF passwords don't export unprotected
Follow-up to #5305. Stripping passwords from remembered settings made the next export of a password-protected PDF row write an unencrypted file without a word. The stripped settings now keep a non-secret passwordRequired flag, and an export refuses them until a password is given again (or security without the flag is given).
2026-11-03 22:52:30 +07:00

475 lines
26 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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 crate::*;
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");
}
/// An export preset sets the shared settings as the Asset Export panel applies one: its rows (or
/// mobile/density), its format settings over the saved ones, its prefix and its sub-folders.
#[test]
fn an_export_preset_sets_the_shared_settings() {
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]}));
run(
&mut s,
"assets.settings.set",
json!({"preset": "mobile", "prefix": "old_", "settings": {"png": {"background": "white"}, "jpg": {"quality": 30}}}),
);
let pack =
json!({"formats": [{"format": "png"}, {"format": "svg"}], "folders": "rows", "settings": {"png": {"antiAlias": "type"}}, "name": "x-<name>"});
run(&mut s, "document.exportPresets.save", json!({"name": "Pack", "settings": pack}));
run(&mut s, "assets.settings.set", json!({"exportPreset": "Pack"}));
let settings = run(&mut s, "document.exportSettings", json!({}))["settings"].clone();
assert!(settings.get("preset").is_none() && settings.get("prefix").is_none(), "{settings}");
assert_eq!(settings["formats"], pack["formats"]);
assert_eq!(settings["subfolders"], true);
assert_eq!(settings["settings"], json!({"png": {"background": "white", "antiAlias": "type"}, "jpg": {"quality": 30}}));
let out = files(&run(&mut s, "assets.export", json!({})));
assert_eq!(out.iter().map(|f| f.0.as_str()).collect::<Vec<_>>(), ["1x/Asset-1.png", "SVG/Asset-1.svg"]);
// A built-in preset; the call's own keys go over it.
run(&mut s, "assets.settings.set", json!({"exportPreset": "Print-Ready PDF", "prefix": "p_"}));
let settings = run(&mut s, "document.exportSettings", json!({}))["settings"].clone();
assert_eq!((settings["formats"][0]["format"].as_str(), settings["prefix"].as_str()), (Some("pdf"), Some("p_")));
for bad in [json!({"exportPreset": "Never saved"}), json!({"exportPreset": 3})] {
assert!(s.execute("assets.settings.set", &bad).is_err(), "{bad}");
}
assert_eq!(run(&mut s, "document.exportSettings", json!({}))["settings"]["prefix"], "p_");
}
/// An export preset applied to the shared settings keeps its folders, with Create Sub-folders on
/// for a folder per row; with Create Sub-folders off every file goes into the folder itself,
/// mobile and density rows too.
#[test]
fn create_sub_folders_off_puts_every_asset_file_in_the_folder() {
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 saved = [("None", json!({"folders": "none"})), ("Items", json!({"folders": "itemFormat"})), ("Panel", json!({"subfolders": true}))];
for (name, mut settings) in saved {
settings["formats"] = json!([{"format": "svg"}]);
run(&mut s, "document.exportPresets.save", json!({"name": name, "settings": settings}));
}
// (preset, the folders and Create Sub-folders it leaves).
let want = [("Logo Package", "format", false), ("None", "none", false), ("Items", "itemFormat", false), ("Panel", "rows", true)];
for (preset, folders, sub) in want {
run(&mut s, "assets.settings.set", json!({"exportPreset": preset}));
let settings = run(&mut s, "document.exportSettings", json!({}))["settings"].clone();
assert_eq!((settings["folders"].as_str(), settings["subfolders"].as_bool()), (Some(folders), Some(sub)), "{preset}: {settings}");
}
let paths = |s: &mut Session, p: Value| -> Vec<String> { files(&run(s, "assets.export", p)).into_iter().map(|f| f.0).collect() };
assert_eq!(paths(&mut s, json!({})), ["SVG/Asset-1.svg"]);
assert_eq!(paths(&mut s, json!({"subfolders": false})), ["Asset-1.svg"]);
run(&mut s, "assets.settings.set", json!({"exportPreset": "Logo Package"}));
let out = paths(&mut s, json!({}));
assert_eq!(out.len(), 5, "{out:?}");
assert!(out.iter().all(|f| !f.contains('/')), "{out:?}");
// Create Sub-folders set in the panel is the dialog's folder per row, on and off.
let folders = |s: &mut Session| run(s, "document.exportSettings", json!({}))["settings"]["folders"].clone();
run(&mut s, "assets.settings.set", json!({"subfolders": true}));
assert_eq!(folders(&mut s), "rows");
run(&mut s, "assets.settings.set", json!({"subfolders": false}));
assert_eq!(folders(&mut s), "none");
run(&mut s, "assets.settings.set", json!({"exportPreset": "Items", "subfolders": false}));
assert_eq!(folders(&mut s), "itemFormat");
// A mobile preset saved with no sub-folders exports its rows into the folder itself.
run(&mut s, "document.exportPresets.save", json!({"name": "Flat Mobile", "settings": {"preset": "mobile", "folders": "none"}}));
run(&mut s, "assets.settings.set", json!({"exportPreset": "Flat Mobile"}));
let out = paths(&mut s, json!({}));
assert_eq!(out.len(), 3, "{out:?}");
assert!(out.iter().all(|f| !f.contains('/')), "{out:?}");
// Create Sub-folders turned off keeps mobile rows in the folder, set or given to the export.
run(&mut s, "assets.settings.set", json!({"exportPreset": "Flat Mobile", "subfolders": false}));
assert!(paths(&mut s, json!({})).iter().all(|f| !f.contains('/')));
run(&mut s, "assets.settings.set", json!({"subfolders": true}));
assert_eq!(paths(&mut s, json!({})).iter().filter(|f| f.starts_with("3x/")).count(), 1);
assert!(paths(&mut s, json!({"subfolders": false})).iter().all(|f| !f.contains('/')));
run(&mut s, "assets.settings.set", json!({"subfolders": false}));
assert!(paths(&mut s, json!({})).iter().all(|f| !f.contains('/')));
}
/// An export preset whose `folders` is empty puts every file in the folder itself, whatever its
/// `subfolders`: exported with `document.exportForScreens` and applied to the shared settings.
#[test]
fn an_export_preset_with_empty_folders_exports_into_the_folder() {
let mut s = session();
let a = rect(&mut s, 10.0, 10.0, 20.0, 20.0);
let ids = run(&mut s, "assets.add", json!({"ids": [a]}))["assets"].clone();
let settings = json!({"formats": [{"format": "svg"}, {"format": "png", "scale": "2x"}], "folders": "", "subfolders": true});
run(&mut s, "document.exportPresets.save", json!({"name": "Flat", "settings": settings}));
let names = |r: &Value| -> Vec<String> { files(r).into_iter().map(|f| f.0).collect() };
let direct = names(&run(&mut s, "document.exportForScreens", json!({"assets": ids, "exportPreset": "Flat"})));
assert_eq!(direct.len(), 2, "{direct:?}");
assert!(direct.iter().all(|f| !f.contains('/')), "{direct:?}");
run(&mut s, "assets.settings.set", json!({"exportPreset": "Flat"}));
let shared = run(&mut s, "document.exportSettings", json!({}))["settings"].clone();
assert_eq!((shared["folders"].as_str(), shared["subfolders"].as_bool()), (Some("none"), Some(false)), "{shared}");
let panel = names(&run(&mut s, "assets.export", json!({})));
assert_eq!(panel, direct);
}
#[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);
}
/// Settings saved without the PDF passwords they were given export only once a password is given
/// again, never as an unprotected PDF; security given without `passwordRequired` exports as given.
#[test]
fn settings_saved_without_their_pdf_passwords_refuse_to_export_until_given_again() {
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 row = |security: Value| json!({"formats": [{"format": "pdf", "security": security}]});
run(&mut s, "assets.settings.set", row(json!({"openPassword": "open sesame"})));
let err = s.execute("assets.export", &json!({})).unwrap_err().to_string();
assert!(err.contains("without their passwords"), "{err}");
let encrypted = |r: &Value| files(r)[0].1.windows(8).any(|w| w == b"/Encrypt");
let r = run(&mut s, "assets.export", row(json!({"openPassword": "open sesame", "passwordRequired": true})));
assert!(encrypted(&r), "given again: protected");
let r = run(&mut s, "assets.export", row(json!({})));
assert!(!encrypted(&r), "left out on purpose: unprotected");
}