Asset Export: Import Preset and Export Preset read and write preset files

Squashed from #5032 onto main.
This commit is contained in:
dotCooCoo
2026-11-02 14:33:29 +07:00
committed by bflatastic
parent 57a76f4f8d
commit 2fafe25c27
52 changed files with 2026 additions and 83 deletions
+20 -5
View File
@@ -7,7 +7,7 @@ use serde_json::{Map, Value, json};
use vectorcraft_doc::{ExportAsset, NodeId};
use super::edit::{roots_of, selected_roots};
use super::fileio::{SCREEN_SETTINGS_KEYS, check_screen_settings, export_crop, export_screens, store_screen_settings};
use super::fileio::{SCREEN_SETTINGS_KEYS, check_screen_settings, export_crop, export_screens, store_screen_settings, with_screen_export_preset};
use super::{CommandSpec, EngineError, Result, Session, bad, bool_or, checked_ids_param, cmd, has_doc, ok, str_param};
/// The longest asset name kept (characters).
@@ -48,7 +48,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Export Assets",
[],
None,
"{assets?: [asset id…] (default all), folder?, zip?, openLocation?, formats?, preset?, settings?, prefix?, subfolders?, antiAlias?} each asset's art alone, cropped to it, in every format row: document.exportForScreens {assets} at the document's export settings (document.exportSettings) under the ones given → what document.exportForScreens returns (no folder: the files, or one zip, as dataBase64). Leaves the remembered settings as they are (assets.settings.set changes them)",
"{assets?: [asset id…] (default all), folder?, zip?, openLocation?, formats?, preset?, settings?, prefix?, subfolders?, antiAlias?} each asset's art alone, cropped to it, in every format row: document.exportForScreens {assets} at the document's export settings (document.exportSettings) under the ones given; with subfolders false every file goes into the folder itself, mobile and density rows too → what document.exportForScreens returns (no folder: the files, or one zip, as dataBase64). Leaves the remembered settings as they are (assets.settings.set changes them)",
has_doc,
export
),
@@ -57,7 +57,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Asset Export Settings",
[],
None,
"{formats?, preset?: mobile|density|\"\" (none), settings?, prefix?, subfolders?, antiAlias?} change the export settings the Asset Export panel and Export for Screens share (document.exportSettings; the values of document.exportForScreens; null removes one): formats replace a preset, a preset replaces the formats → {settings}. Not an undo step",
"{exportPreset?: (a preset of document.exportPresets.list: its formats or preset, its format settings over the saved ones, its prefix (none when it has none), its folders (or subfolders: rows or none) with subfolders on for rows, and antiAlias; the other keys go over it), formats?, preset?: mobile|density|\"\" (none), settings?, prefix?, subfolders?, antiAlias?} change the export settings the Asset Export panel and Export for Screens share (document.exportSettings; the values of document.exportForScreens; null removes one): formats replace a preset, a preset replaces the formats → {settings}. Not an undo step",
has_doc,
settings_set
),
@@ -223,6 +223,10 @@ fn export(s: &mut Session, p: &Value) -> Result<Value> {
if let Some(o) = p.as_object() {
overlay(&mut q, o);
}
// Create Sub-folders off puts every file in the folder itself, mobile and density rows too.
if q.get("subfolders").and_then(Value::as_bool) == Some(false) && p.get("folders").is_none() {
q.insert("folders".into(), json!("none"));
}
if !q.contains_key("assets") {
let all: Vec<u64> = s.doc()?.doc.assets.iter().map(|a| a.id).collect();
if all.is_empty() {
@@ -236,11 +240,22 @@ fn export(s: &mut Session, p: &Value) -> Result<Value> {
fn settings_set(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "assets.settings.set";
let o = p.as_object().ok_or_else(|| bad(C, "give the settings to change, e.g. {\"formats\": [{\"format\": \"png\", \"scale\": \"2x\"}]}"))?;
if let Some(k) = o.keys().find(|k| !SCREEN_SETTINGS_KEYS.contains(&k.as_str())) {
if let Some(k) = o.keys().find(|k| !SCREEN_SETTINGS_KEYS.contains(&k.as_str()) && k.as_str() != "exportPreset") {
return Err(bad(C, format!("`{k}` isn't an export setting: {}", SCREEN_SETTINGS_KEYS.join(", "))));
}
let mut settings = s.doc()?.doc.export_settings.clone();
overlay(&mut settings, o);
let mut given = o.clone();
if let Some(name) = given.remove("exportPreset") {
let name = name.as_str().ok_or_else(|| bad(C, "`exportPreset` is the name of a preset of document.exportPresets.list"))?;
with_screen_export_preset(s, C, &mut settings, name)?;
}
// Create Sub-folders is Export for Screens' folder per row: the dialog opens on the choice.
match given.get("subfolders").and_then(Value::as_bool) {
Some(true) => _ = settings.insert("folders".into(), json!("rows")),
Some(false) if settings.get("folders").and_then(Value::as_str) == Some("rows") => _ = settings.insert("folders".into(), json!("none")),
_ => {}
}
overlay(&mut settings, &given);
check_screen_settings(s, &Value::Object(settings.clone())).map_err(|e| of_cmd(C, e))?;
store_screen_settings(s, settings)?;
Ok(json!({ "settings": shared_settings(s)? }))
+7 -5
View File
@@ -83,12 +83,14 @@ pub use save::{
templates_folder,
};
pub use screens::{
PRESET_KEYS as SCREEN_EXPORT_PRESET_KEYS, PRESETS as SCREEN_PRESETS, ScreenExport, ScreenExportPreset, ScreenSize,
builtin_presets as screen_export_builtin_presets, first_file as screen_first_file, preset_rows as screen_preset_rows,
PRESET_FILE_EXTS as SCREEN_EXPORT_PRESET_EXTS, PRESET_FILE_FORMAT as SCREEN_EXPORT_PRESET_FORMAT, PRESET_KEYS as SCREEN_EXPORT_PRESET_KEYS,
PRESETS as SCREEN_PRESETS, SHARED_KEYS as SCREEN_SETTINGS_KEYS, ScreenExport, ScreenExportPreset, ScreenSize,
builtin_presets as screen_export_builtin_presets, first_file as screen_first_file, preset_list as screen_export_preset_list,
preset_rows as screen_preset_rows,
};
pub(crate) use screens::{
SHARED_KEYS as SCREEN_SETTINGS_KEYS, check_settings as check_screen_settings, export as export_screens, safe_file_name,
store_settings as store_screen_settings,
check_settings as check_screen_settings, export as export_screens, safe_file_name, store_settings as store_screen_settings,
with_export_preset as with_screen_export_preset,
};
pub use svg::options_map as svg_options;
/// Atomic file writes (a temporary file renamed over the target: a failed write never damages the
@@ -122,7 +124,7 @@ pub use commands::specs;
pub use export_names::artboard_file_names;
pub(crate) use export_names::{default_name, merge, unique_file_names, with_black_output, write_or_return};
use export_names::{distinct_names_by, write_encoded};
use formats::{ARTBOARD, ARTBOARDS, RANGE, USE_ARTBOARDS, writable};
use formats::{ARTBOARD, ARTBOARDS, RANGE, USE_ARTBOARDS, import_name, writable};
pub use formats::{
DOC_EXTS, DOCX_EXTS, FORMATS, Format, FormatOption, OPEN_EXTS, PLACE_EXTS, RTF_EXTS, TEXT_EXTS, UNASSOCIATED_EXTS, export_extensions, extension,
file_stem, format, format_for_name, has_own_title, library_open_filters, open_filters, place_filters, plugin_formats, titled_for_export,
+18
View File
@@ -85,6 +85,24 @@ pub fn specs() -> Vec<CommandSpec> {
always,
screens::presets_delete
),
cmd!(
query "document.exportPresets.import",
"Import Export Presets",
[],
None,
"{path? | data?: file text | dataBase64?, replace?: false (replace saved presets of the same names; else the imported ones get a number)} add the presets of a .vcexportpresets file (as document.exportPresets.export writes) to the saved ones; a built-in name gets a number; a taken name that ends in a number gets the next free one (Web 2 comes in as Web 3); settings that document.exportPresets.save refuses, more than 1000 presets, or a file larger than 4194304 bytes fail the whole file → {imported: [names]}",
always,
screens::presets_import
),
cmd!(
query "document.exportPresets.export",
"Export Export Presets",
[],
None,
"{names?: [preset names, built-in ones too] (default: every saved preset), path?} write the presets as a .vcexportpresets file (JSON); more than 1000 presets or a file larger than 4194304 bytes is an error → {path, count}; without path → {data: the file's text, count}",
always,
screens::presets_export
),
cmd!(
query "command.batch",
"Batch",
+26 -3
View File
@@ -1,4 +1,7 @@
use super::{Result, Session, TEMPLATE_FORMATS, UNSUPPORTED, Unsupported, Value, bad, dxf, eps, json, metafile, native, pdf, svg, text, unsupported};
use super::super::unique_name;
use super::{
Result, Session, TEMPLATE_FORMATS, UNSUPPORTED, Unsupported, Value, bad, dxf, eps, json, metafile, native, pdf, screens, svg, text, unsupported,
};
/// One option a format's encoder reads from the export params.
#[derive(Clone, Copy, Debug)]
@@ -625,8 +628,8 @@ fn format_filters() -> impl Iterator<Item = (&'static str, &'static [&'static st
}
/// Open-dialog filters: "All readable files" first, then one per readable format, then swatch
/// libraries (which open in the library panel), Libraries panel libraries, flattener, PDF and print
/// presets (imported), scripts (checked without running them) and plug-ins (installed).
/// libraries (which open in the library panel), Libraries panel libraries, flattener, PDF, print
/// and export presets (imported), scripts (checked without running them) and plug-ins (installed).
pub fn open_filters() -> impl Iterator<Item = (&'static str, &'static [&'static str])> {
std::iter::once(("All readable files", OPEN_EXTS))
.chain(format_filters())
@@ -635,6 +638,7 @@ pub fn open_filters() -> impl Iterator<Item = (&'static str, &'static [&'static
.chain(std::iter::once(("Flattener presets", crate::cmd::flatten::PRESET_EXTS)))
.chain(std::iter::once(("PDF presets", crate::cmd::pdfcmds::PRESET_EXTS)))
.chain(std::iter::once(("Print presets", crate::cmd::printpresets::PRESET_EXTS)))
.chain(std::iter::once(("Export presets", screens::PRESET_FILE_EXTS)))
.chain(std::iter::once(("Shortcut sets", crate::cmd::shortcutset::EXTS)))
.chain(std::iter::once(("Scripts", crate::cmd::scripts::EXTS)))
.chain(std::iter::once(("Plug-ins", crate::cmd::plugin::EXTS)))
@@ -714,6 +718,25 @@ pub fn file_stem(name: &str) -> String {
std::path::Path::new(name).file_stem().map(|s| s.to_string_lossy().to_string()).unwrap_or_else(|| name.to_string())
}
/// The name an imported preset `base` takes: `base` if `taken` doesn't claim it, else for a name
/// that ends in a space and a number ("Logo Package 2") the first free name with a higher number
/// ("Logo Package 3"), and for any other name the first free "base 2", "base 3"… ([`unique_name`]).
pub(crate) fn import_name(base: &str, taken: impl Fn(&str) -> bool) -> String {
if !taken(base) {
return base.to_string();
}
let numbered = base
.rsplit_once(' ')
.filter(|(stem, n)| !stem.trim().is_empty() && !n.is_empty() && n.bytes().all(|b| b.is_ascii_digit()))
.and_then(|(stem, n)| Some((stem, n.parse::<u64>().ok()?)));
match numbered {
Some((stem, n)) => {
(n.saturating_add(1)..=u64::MAX).map(|i| format!("{stem} {i}")).find(|c| !taken(c)).unwrap_or_else(|| unique_name(base, &taken))
}
None => unique_name(base, taken),
}
}
/// What a `format` export of `doc`, whose file is `path`, writes when it isn't `doc` itself: a
/// document that only has the Untitled-N name New gave it is titled after its file, as its tab is
/// (the title PDF, PNG and SVG metadata carry). A title of its own stays, and native files keep
+204 -11
View File
@@ -16,7 +16,7 @@ use serde_json::{Map, Value, json};
use vectorcraft_doc::Document;
use vectorcraft_geom::Rect;
use super::super::{NodeId, Result, Session, bad, str_param};
use super::super::{EngineError, NodeId, Result, Session, bad, bool_or, str_param};
use super::encode::single_artboard;
use super::export::{export_crop, isolated, selected_artwork, without_artboards};
use super::naming::{self, NameParts};
@@ -277,7 +277,7 @@ impl Row {
/// The settings the Asset Export panel shares with the dialog: what is exported, not which art
/// or where to.
pub(crate) const SHARED_KEYS: [&str; 6] = ["formats", "preset", "settings", "prefix", "subfolders", "antiAlias"];
pub const SHARED_KEYS: [&str; 6] = ["formats", "preset", "settings", "prefix", "subfolders", "antiAlias"];
/// What `document.exportForScreens` reads besides the formats.
#[derive(Default, Deserialize)]
@@ -667,24 +667,45 @@ fn with_preset(s: &Session, p: &Value) -> Result<Value> {
Ok(Value::Object(merged))
}
pub(super) fn presets_list(s: &mut Session, _: &Value) -> Result<Value> {
/// The rows of `document.exportPresets.list`: the built-in presets, then `saved`, each
/// `{name, builtIn, settings}`.
pub fn preset_list(saved: &[ScreenExportPreset]) -> Value {
let row = |p: &ScreenExportPreset, built_in: bool| json!({"name": p.name, "builtIn": built_in, "settings": p.settings});
let rows: Vec<Value> =
builtin_presets().iter().map(|p| row(p, true)).chain(s.prefs.screen_export_presets.iter().map(|p| row(p, false))).collect();
Ok(json!({ "presets": rows }))
Value::Array(builtin_presets().iter().map(|p| row(p, true)).chain(saved.iter().map(|p| row(p, false))).collect())
}
pub(super) fn presets_list(s: &mut Session, _: &Value) -> Result<Value> {
Ok(json!({ "presets": preset_list(&s.prefs.screen_export_presets) }))
}
/// The [`PRESET_KEYS`] of `given`, refused as an error of command `cmd` when an export would
/// refuse them.
fn preset_settings(s: &Session, cmd: &str, given: &Map<String, Value>) -> Result<Map<String, Value>> {
let settings: Map<String, Value> = given.iter().filter(|(k, _)| PRESET_KEYS.contains(&k.as_str())).map(|(k, v)| (k.clone(), v.clone())).collect();
// Refuse what an export would refuse, now rather than when the preset is used.
check_settings(s, &Value::Object(settings.clone())).map_err(|e| bad(cmd, e.to_string()))?;
Folders::of(&ScreenParams::of(&Value::Object(settings.clone()))?).map_err(|e| bad(cmd, e.to_string()))?;
Ok(settings)
}
/// The longest export preset name kept (characters), as for asset names (`assets::MAX_NAME`).
const MAX_PRESET_NAME: usize = 255;
/// `name` trimmed and at most [`MAX_PRESET_NAME`] characters long (`None` when empty), as
/// `assets::clean_name` keeps an asset name.
fn preset_name(name: &str) -> Option<String> {
let n: String = name.trim().chars().take(MAX_PRESET_NAME).collect();
(!n.is_empty()).then_some(n)
}
pub(super) fn presets_save(s: &mut Session, p: &Value) -> Result<Value> {
const SAVE: &str = "document.exportPresets.save";
let name = str_param(p, "name").map(str::trim).filter(|n| !n.is_empty()).ok_or_else(|| bad(SAVE, "give the preset a name"))?.to_string();
let name = str_param(p, "name").and_then(preset_name).ok_or_else(|| bad(SAVE, "give the preset a name"))?;
if builtin_presets().iter().any(|b| b.name == name) {
return Err(bad(SAVE, format!("`{name}` is a built-in preset: save under another name")));
}
let given = p.get("settings").and_then(Value::as_object).ok_or_else(|| bad(SAVE, "settings is an object of document.exportForScreens params"))?;
let settings: Map<String, Value> = given.iter().filter(|(k, _)| PRESET_KEYS.contains(&k.as_str())).map(|(k, v)| (k.clone(), v.clone())).collect();
// Refuse what an export would refuse, now rather than when the preset is used.
check_settings(s, &Value::Object(settings.clone())).map_err(|e| bad(SAVE, e.to_string()))?;
Folders::of(&ScreenParams::of(&Value::Object(settings.clone()))?).map_err(|e| bad(SAVE, e.to_string()))?;
let settings = preset_settings(s, SAVE, given)?;
let preset = ScreenExportPreset { name: name.clone(), settings };
let created = match s.prefs.screen_export_presets.iter_mut().find(|q| q.name == name) {
Some(slot) => {
@@ -713,6 +734,178 @@ pub(super) fn presets_delete(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({ "deleted": name }))
}
/// What `document.exportPresets.export` writes and `document.exportPresets.import` reads.
#[derive(Serialize, Deserialize)]
struct PresetFile {
format: String,
#[serde(default)]
presets: Vec<ScreenExportPreset>,
}
/// The `format` of an export presets file, also its extension.
pub const PRESET_FILE_FORMAT: &str = "vcexportpresets";
/// The extensions of export presets files (opening one imports it).
pub const PRESET_FILE_EXTS: &[&str] = &[PRESET_FILE_FORMAT];
/// The most presets one file imports.
const MAX_IMPORT: usize = 1000;
/// The most bytes in an export presets file.
const MAX_PRESET_FILE_BYTES: usize = 4 << 20;
pub(super) fn presets_export(s: &mut Session, p: &Value) -> Result<Value> {
const EXPORT: &str = "document.exportPresets.export";
// A file holds no more presets than an import reads.
let too_many = || bad(EXPORT, format!("more than {MAX_IMPORT} presets"));
let presets = match p.get("names") {
Some(Value::Array(names)) if names.len() > MAX_IMPORT => return Err(too_many()),
Some(Value::Array(names)) => {
let find = |n: &Value| {
let n = n.as_str().ok_or_else(|| bad(EXPORT, "`names` lists preset names"))?;
find_preset(s, n).ok_or_else(|| bad(EXPORT, format!("no export preset `{n}` (see document.exportPresets.list)")))
};
names.iter().map(find).collect::<Result<Vec<_>>>()?
}
None | Some(Value::Null) => s.prefs.screen_export_presets.clone(),
Some(_) => return Err(bad(EXPORT, "`names` is a list of preset names")),
};
if presets.is_empty() {
return Err(bad(EXPORT, "no saved export presets to export (name built-in ones in `names`)"));
}
if presets.len() > MAX_IMPORT {
return Err(too_many());
}
let count = presets.len();
let text = serde_json::to_string_pretty(&PresetFile { format: PRESET_FILE_FORMAT.into(), presets }).map_err(|e| bad(EXPORT, e.to_string()))?;
if text.len() > MAX_PRESET_FILE_BYTES {
return Err(bad(EXPORT, format!("export presets file is larger than {MAX_PRESET_FILE_BYTES} bytes")));
}
match str_param(p, "path") {
Some(path) => {
write_file(path, text.as_bytes())?;
Ok(json!({"path": path, "count": count}))
}
None => Ok(json!({"data": text, "count": count})),
}
}
pub(super) fn presets_import(s: &mut Session, p: &Value) -> Result<Value> {
const IMPORT: &str = "document.exportPresets.import";
let bytes = match (str_param(p, "path"), str_param(p, "data"), str_param(p, "dataBase64")) {
(Some(path), ..) => super::read_file_head(path, MAX_PRESET_FILE_BYTES + 1)?,
(None, Some(text), _) => {
if text.len() > MAX_PRESET_FILE_BYTES {
return Err(bad(IMPORT, format!("export presets file is larger than {MAX_PRESET_FILE_BYTES} bytes")));
}
text.as_bytes().to_vec()
}
(None, None, Some(b64)) => {
if b64.bytes().filter(|b| !b.is_ascii_whitespace()).count() > MAX_PRESET_FILE_BYTES.div_ceil(3) * 4 {
return Err(bad(IMPORT, format!("export presets file is larger than {MAX_PRESET_FILE_BYTES} bytes")));
}
vectorcraft_format::base64_decode(b64).ok_or_else(|| bad(IMPORT, "bad dataBase64"))?
}
_ => return Err(bad(IMPORT, "give `path`, `data` or `dataBase64`")),
};
if bytes.len() > MAX_PRESET_FILE_BYTES {
return Err(bad(IMPORT, format!("export presets file is larger than {MAX_PRESET_FILE_BYTES} bytes")));
}
let file: PresetFile = serde_json::from_slice(&bytes).map_err(|e| bad(IMPORT, format!("not an export presets file: {e}")))?;
if file.format != PRESET_FILE_FORMAT {
return Err(bad(IMPORT, format!("not an export presets file (format `{}`)", file.format)));
}
if file.presets.len() > MAX_IMPORT {
return Err(bad(IMPORT, format!("more than {MAX_IMPORT} presets")));
}
// Settings a hand-edited file may carry are refused as document.exportPresets.save refuses
// them, before any preset is added.
let mut presets = vec![];
for q in file.presets {
let settings = preset_settings(s, IMPORT, &q.settings).map_err(|e| match e {
EngineError::BadParams { msg, .. } => bad(IMPORT, format!("`{}`: {msg}", q.name)),
e => e,
})?;
let name = preset_name(&q.name).unwrap_or_else(|| "Export Preset".to_string());
presets.push((name, settings));
}
let replace = bool_or(p, "replace", false);
let saved = &mut s.prefs.screen_export_presets;
let saved_before = saved.len();
// A built-in name, or a saved one that stays, gets a number.
let mut taken: std::collections::HashSet<String> =
builtin_presets().into_iter().map(|b| b.name).chain(saved.iter().map(|q| q.name.clone())).collect();
let mut imported = vec![];
for (base, settings) in presets {
if replace && let Some(slot) = saved.iter_mut().take(saved_before).find(|q| q.name == base) {
slot.settings = settings;
imported.push(base);
continue;
}
let mut name = super::import_name(&base, |n| taken.contains(n));
if name.chars().count() > MAX_PRESET_NAME {
// A number after a long name stays within the limit: the name is cut to leave room for
// a space and the longest number.
let short: String = base.chars().take(MAX_PRESET_NAME - 21).collect();
let short = short.trim_end();
name = super::import_name(short, |n| n == short || taken.contains(n));
}
taken.insert(name.clone());
saved.push(ScreenExportPreset { name: name.clone(), settings });
imported.push(name);
}
Ok(json!({ "imported": imported }))
}
/// `settings` (a document's export settings) with export preset `name` applied as the Asset
/// Export panel applies one: its formats (or mobile/density `preset`), its format settings over
/// the ones there, its prefix (none when it has none), its folders (`folders`, or `subfolders`)
/// with Create Sub-folders on for a folder per row, and its anti-aliasing.
pub(crate) fn with_export_preset(s: &Session, cmd: &str, settings: &mut Map<String, Value>, name: &str) -> Result<()> {
let preset = find_preset(s, name).ok_or_else(|| bad(cmd, format!("no export preset `{name}` (see document.exportPresets.list)")))?.settings;
if let Some(formats) = preset.get("formats") {
settings.insert("formats".into(), formats.clone());
settings.remove("preset");
} else if let Some(p) = preset.get("preset") {
settings.insert("preset".into(), p.clone());
settings.remove("formats");
}
if let Some(given) = preset.get("settings").and_then(Value::as_object) {
let all = settings.entry("settings").or_insert_with(|| json!({}));
if !all.is_object() {
*all = json!({});
}
if let Some(all) = all.as_object_mut() {
for (id, o) in given {
match (all.get_mut(id).and_then(Value::as_object_mut), o.as_object()) {
(Some(mine), Some(o)) => mine.extend(o.iter().map(|(k, v)| (k.clone(), v.clone()))),
_ => _ = all.insert(id.clone(), o.clone()),
}
}
}
}
match preset.get("prefix") {
Some(prefix) => _ = settings.insert("prefix".into(), prefix.clone()),
None => _ = settings.remove("prefix"),
}
// Its `folders` (Export for Screens shows it; empty is none, as an export reads it), else its
// `subfolders` (a folder per row or none). Create Sub-folders is on for a folder per row, as
// the dialog sets it.
let folders = preset
.get("folders")
.and_then(Value::as_str)
.map(|f| if f.is_empty() { "none" } else { f })
.or_else(|| preset.get("subfolders").and_then(Value::as_bool).map(|b| if b { "rows" } else { "none" }));
if let Some(f) = folders {
settings.insert("folders".into(), json!(f));
settings.insert("subfolders".into(), json!(f == "rows"));
}
if let Some(aa) = preset.get("antiAlias") {
settings.insert("antiAlias".into(), aa.clone());
}
Ok(())
}
/// Every chosen artboard (asset, layer, selected object) in every format row, one file each (a PDF
/// holds its artboard alone), or with `fullDocument` one file per row (a PDF of every artboard,
/// else the bounds of all art). Items that share a name get `-2`, `-3`… instead of overwriting, and
+3 -2
View File
@@ -377,9 +377,10 @@ fn open_exts_cover_every_readable_format() {
assert_eq!(filters[0], ("All readable files", OPEN_EXTS));
assert_eq!(
filters.len(),
9 + FORMATS.iter().filter(|f| f.read).count(),
"and swatch libraries, libraries, flattener, PDF and print presets, shortcut sets, scripts, plug-ins"
10 + FORMATS.iter().filter(|f| f.read).count(),
"and swatch libraries, libraries, flattener, PDF, print and export presets, shortcut sets, scripts, plug-ins"
);
assert!(filters.contains(&("Export presets", SCREEN_EXPORT_PRESET_EXTS)), "File › Open imports export presets");
assert!(filters.contains(&("Scripts", crate::cmd::scripts::EXTS)), "File › Open checks scripts");
assert_eq!(filters.last(), Some(&("Plug-ins", crate::cmd::plugin::EXTS)), "File › Open installs plug-ins");
}
+228
View File
@@ -222,3 +222,231 @@ fn export_presets_save_use_and_delete() {
assert!(s.execute(bad.0, &bad.1).is_err(), "{bad:?}");
}
}
/// Export presets go to a file and come back from one: a name in use gets a number unless
/// `replace`, a built-in name is never replaced, and a file in another format or with a setting an
/// export refuses adds nothing.
#[test]
fn export_presets_export_and_import() {
let mut s = session(2);
assert!(s.execute("document.exportPresets.export", &json!({})).is_err(), "nothing saved to export");
let web = json!({"name": "ACME-<name>", "folders": "item", "formats": [{"format": "jpg", "quality": 80}]});
s.execute("document.exportPresets.save", &json!({"name": "Web JPG", "settings": web})).unwrap();
let out = s.execute("document.exportPresets.export", &json!({})).unwrap();
assert_eq!(out["count"], 1);
let data = out["data"].as_str().unwrap().to_string();
assert_eq!(serde_json::from_str::<Value>(&data).unwrap()["format"], "vcexportpresets");
// Into a session with no saved presets: the preset as it was saved, and it exports.
let mut other = session(2);
let r = other.execute("document.exportPresets.import", &json!({ "data": data })).unwrap();
assert_eq!(r["imported"], json!(["Web JPG"]));
assert_eq!(other.prefs.screen_export_presets, s.prefs.screen_export_presets);
assert_eq!(names(&export(&mut other, json!({"exportPreset": "Web JPG", "range": "2"}))), ["Artboard-2/ACME-Artboard 2.jpg"]);
// Again: a second copy with a number; with replace, the saved one changes.
let r = other.execute("document.exportPresets.import", &json!({ "data": data })).unwrap();
assert_eq!(r["imported"], json!(["Web JPG 2"]));
s.execute("document.exportPresets.save", &json!({"name": "Web JPG", "settings": {"formats": [{"format": "png"}]}})).unwrap();
let changed = s.execute("document.exportPresets.export", &json!({"names": ["Web JPG"]})).unwrap()["data"].clone();
let r = other.execute("document.exportPresets.import", &json!({"data": changed, "replace": true})).unwrap();
assert_eq!(r["imported"], json!(["Web JPG"]));
let saved: Vec<(&str, &Value)> =
other.prefs.screen_export_presets.iter().map(|p| (p.name.as_str(), &p.settings["formats"][0]["format"])).collect();
assert_eq!(saved, [("Web JPG", &json!("png")), ("Web JPG 2", &json!("jpg"))]);
// A built-in preset exports by name and comes back under a new name; the built-in one stays.
let path = std::env::temp_dir().join(format!("vc-export-presets-{}.vcexportpresets", std::process::id()));
let path = path.to_string_lossy().to_string();
let out = s.execute("document.exportPresets.export", &json!({"names": ["Logo Package"], "path": path})).unwrap();
assert_eq!((out["path"].as_str(), out["count"].as_u64()), (Some(path.as_str()), Some(1)));
let r = other.execute("document.exportPresets.import", &json!({"path": path, "replace": true})).unwrap();
assert_eq!(r["imported"], json!(["Logo Package 2"]));
let builtin = screens::builtin_presets();
assert_eq!(other.prefs.screen_export_presets.last().map(|p| &p.settings), builtin.first().map(|p| &p.settings));
let _ = std::fs::remove_file(&path);
// Refused files add nothing.
let before = other.prefs.screen_export_presets.clone();
let preset = |settings: Value| {
json!({"format": "vcexportpresets", "presets": [{"name": "Fine", "settings": {"formats": [{"format": "png"}]}}, {"name": "Bad", "settings": settings}]}).to_string()
};
for bad in [
json!({"data": "{\"format\": \"vcflattener\", \"presets\": []}"}),
json!({"data": "not json"}),
json!({"data": preset(json!({"formats": [{"format": "txt"}]}))}),
json!({"data": preset(json!({"folders": "sideways"}))}),
json!({"dataBase64": "%%"}),
json!({}),
] {
assert!(other.execute("document.exportPresets.import", &bad).is_err(), "{bad}");
}
assert_eq!(other.prefs.screen_export_presets, before);
assert!(s.execute("document.exportPresets.export", &json!({"names": ["Never saved"]})).is_err());
// `names` that is not a list of names is an error, not every saved preset.
for bad in [json!({"names": "Web JPG"}), json!({"names": [3]}), json!({"names": {"Web JPG": true}})] {
assert!(s.execute("document.exportPresets.export", &bad).is_err(), "{bad}");
}
// One file imports at most 1,000 presets; names repeated in a file are numbered in turn.
let many = |n: usize| {
let one = json!({"name": "Same", "settings": {"formats": [{"format": "png"}]}});
json!({"format": "vcexportpresets", "presets": vec![one; n]}).to_string()
};
assert!(other.execute("document.exportPresets.import", &json!({"data": many(1001)})).is_err(), "more than 1,000");
assert_eq!(other.prefs.screen_export_presets, before);
let r = other.execute("document.exportPresets.import", &json!({"data": many(1000)})).unwrap();
let imported = r["imported"].as_array().unwrap();
assert_eq!((imported.len(), imported.first(), imported.last()), (1000, Some(&json!("Same")), Some(&json!("Same 1000"))));
// An export writes no file that an import refuses: at most 1,000 presets.
assert!(other.prefs.screen_export_presets.len() > 1000);
assert!(other.execute("document.exportPresets.export", &json!({})).is_err(), "every saved preset");
let names: Vec<String> = other.prefs.screen_export_presets.iter().take(1001).map(|p| p.name.clone()).collect();
assert!(other.execute("document.exportPresets.export", &json!({ "names": names })).is_err(), "1,001 names");
let r = other.execute("document.exportPresets.export", &json!({ "names": names[..1000] })).unwrap();
assert_eq!(r["count"], 1000);
}
/// An imported file's unknown keys are dropped, a scale that is not a positive number refuses
/// the file, and a prefix with folder separators names files inside the export folder.
#[test]
fn export_presets_import_drops_unknown_keys_and_checks_values() {
let file = |settings: Value| {
json!({"format": "vcexportpresets", "comment": "extra", "presets": [{"name": "P", "extra": 1, "settings": settings}]}).to_string()
};
let mut s = session(2);
let r = s.execute("document.exportPresets.import", &json!({"data": file(json!({"formats": [{"format": "svg"}], "unknown": true}))})).unwrap();
assert_eq!(r["imported"], json!(["P"]));
let saved = &s.prefs.screen_export_presets[0].settings;
assert_eq!(saved.keys().collect::<Vec<_>>(), ["formats"], "{saved:?}");
for scale in [json!("0x"), json!("-2x"), json!("1e9x"), json!("2y"), json!("nanx")] {
let bad = file(json!({"formats": [{"format": "png", "scale": scale}]}));
assert!(s.execute("document.exportPresets.import", &json!({ "data": bad })).is_err(), "{scale}");
}
assert_eq!(s.prefs.screen_export_presets.len(), 1);
let climb = file(json!({"formats": [{"format": "svg"}], "prefix": "../..\\up/"}));
let r = s.execute("document.exportPresets.import", &json!({ "data": climb })).unwrap();
assert_eq!(r["imported"], json!(["P 2"]));
assert_eq!(names(&export(&mut s, json!({"exportPreset": "P 2", "range": "2"}))), ["..-..-up-Artboard-2.svg"]);
}
#[test]
fn export_presets_import_replace_numbers_duplicates_from_one_file() {
let mut source = session(1);
source.execute("document.exportPresets.save", &json!({"name": "A", "settings": {"formats": [{"format": "png"}]}})).unwrap();
let data = source.execute("document.exportPresets.export", &json!({"names": ["A", "A"]})).unwrap()["data"].clone();
let mut empty = session(1);
let reply = empty.execute("document.exportPresets.import", &json!({"data": data, "replace": true})).unwrap();
assert_eq!(reply["imported"], json!(["A", "A 2"]));
assert_eq!(empty.prefs.screen_export_presets.iter().map(|p| p.name.as_str()).collect::<Vec<_>>(), ["A", "A 2"]);
let mut existing = session(1);
existing.execute("document.exportPresets.save", &json!({"name": "A", "settings": {"formats": [{"format": "jpg"}]}})).unwrap();
let reply = existing.execute("document.exportPresets.import", &json!({"data": data, "replace": true})).unwrap();
assert_eq!(reply["imported"], json!(["A", "A"]));
assert_eq!(existing.prefs.screen_export_presets.len(), 1);
assert_eq!(existing.prefs.screen_export_presets[0].settings["formats"][0]["format"], "png");
}
#[test]
fn export_presets_import_refuses_oversize_input_from_every_source() {
let mut s = session(1);
let good = s.execute("document.exportPresets.export", &json!({"names": ["Logo Package"]})).unwrap()["data"].as_str().unwrap().to_string();
let oversized = format!("{good}{}", " ".repeat((4 << 20) + 1));
let path = std::env::temp_dir().join(format!("vc-oversize-export-presets-{}.vcexportpresets", std::process::id()));
std::fs::write(&path, &oversized).unwrap();
for params in [
json!({"path": path.to_string_lossy()}),
json!({"data": oversized}),
json!({"dataBase64": vectorcraft_format::base64_encode(oversized.as_bytes())}),
] {
let error = s.execute("document.exportPresets.import", &params).unwrap_err().to_string();
assert!(error.contains("larger than 4194304 bytes"), "{error}");
assert!(s.prefs.screen_export_presets.is_empty());
}
std::fs::remove_file(path).unwrap();
}
#[test]
fn export_presets_import_accepts_wrapped_base64_near_size_limit() {
const LIMIT: usize = 4 << 20;
let empty = json!({
"format": "vcexportpresets",
"presets": [{"name": "Wrapped", "settings": {"formats": [{"format": "png"}], "prefix": ""}}],
});
let prefix = "x".repeat(LIMIT - 1 - empty.to_string().len());
let file = json!({
"format": "vcexportpresets",
"presets": [{"name": "Wrapped", "settings": {"formats": [{"format": "png"}], "prefix": prefix}}],
})
.to_string();
assert_eq!(file.len(), LIMIT - 1);
let encoded = vectorcraft_format::base64_encode(file.as_bytes());
let wrapped = encoded.as_bytes().chunks(76).map(|line| std::str::from_utf8(line).unwrap()).collect::<Vec<_>>().join("\n");
assert!(wrapped.len() > LIMIT.div_ceil(3) * 4);
assert_eq!(vectorcraft_format::base64_decode(&wrapped).as_deref(), Some(file.as_bytes()));
let mut s = session(1);
let reply = s.execute("document.exportPresets.import", &json!({"dataBase64": wrapped})).unwrap();
assert_eq!(reply["imported"], json!(["Wrapped"]));
assert_eq!(s.prefs.screen_export_presets[0].settings["prefix"], prefix);
}
#[test]
fn export_presets_export_refuses_oversize_output() {
let mut s = session(1);
let prefix = "x".repeat((4 << 20) + 1);
s.execute("document.exportPresets.save", &json!({"name": "Large", "settings": {"prefix": prefix}})).unwrap();
let error = s.execute("document.exportPresets.export", &json!({})).unwrap_err().to_string();
assert!(error.contains("larger than 4194304 bytes"), "{error}");
let path = std::env::temp_dir().join(format!("vc-oversize-preset-output-{}.vcexportpresets", std::process::id()));
assert!(!path.exists());
let error = s.execute("document.exportPresets.export", &json!({"path": path.to_string_lossy()})).unwrap_err().to_string();
assert!(error.contains("larger than 4194304 bytes"), "{error}");
assert!(!path.exists());
}
/// An imported preset whose name is taken and ends in a number gets the next free number, not a
/// second one; any other taken name gets the first free number.
#[test]
fn an_imported_numbered_name_counts_on() {
let taken = ["Logo Package", "Logo Package 2", "Logo Package 4", "Web", "Web 2", "2", "Max 18446744073709551615"];
let name = |base: &str| import_name(base, |n| taken.contains(&n));
assert_eq!(name("Print"), "Print");
assert_eq!(name("Logo Package 3"), "Logo Package 3");
assert_eq!(name("Logo Package"), "Logo Package 3");
assert_eq!(name("Logo Package 2"), "Logo Package 3");
assert_eq!(name("Logo Package 4"), "Logo Package 5");
assert_eq!(name("Web"), "Web 3");
// A number alone, or one past the largest, is not counted on.
assert_eq!(name("2"), "2 2");
assert_eq!(name("Max 18446744073709551615"), "Max 18446744073709551615 2");
// Through the command: a built-in preset imported twice is Logo Package 2, then Logo Package 3.
let mut s = session(1);
let data = s.execute("document.exportPresets.export", &json!({"names": ["Logo Package"]})).unwrap()["data"].clone();
assert_eq!(s.execute("document.exportPresets.import", &json!({ "data": data })).unwrap()["imported"], json!(["Logo Package 2"]));
let data = s.execute("document.exportPresets.export", &json!({"names": ["Logo Package 2"]})).unwrap()["data"].clone();
assert_eq!(s.execute("document.exportPresets.import", &json!({ "data": data })).unwrap()["imported"], json!(["Logo Package 3"]));
}
/// A preset name, imported or saved, keeps at most 255 characters, as an asset name does.
#[test]
fn export_preset_names_are_capped() {
let mut s = session(1);
let long = "é".repeat(300);
let capped = "é".repeat(255);
let file = json!({"format": "vcexportpresets", "presets": [{"name": long, "settings": {"formats": [{"format": "png"}]}}]}).to_string();
let r = s.execute("document.exportPresets.import", &json!({ "data": file })).unwrap();
assert_eq!(r["imported"], json!([capped]));
let r = s.execute("document.exportPresets.save", &json!({"name": format!(" {}", "w".repeat(400)), "settings": {}})).unwrap();
assert_eq!(r["name"], "w".repeat(255));
let names: Vec<usize> = s.prefs.screen_export_presets.iter().map(|p| p.name.chars().count()).collect();
assert_eq!(names, [255, 255]);
// The same long name imported again gets a number within the limit, and saving under the name
// it got changes that preset alone.
let r = s.execute("document.exportPresets.import", &json!({ "data": file })).unwrap();
let again = r["imported"][0].as_str().unwrap().to_string();
assert!(again.chars().count() <= 255 && again != capped && again.ends_with(" 2"), "{again}");
s.execute("document.exportPresets.save", &json!({"name": again, "settings": {"formats": [{"format": "svg"}]}})).unwrap();
let formats: Vec<Option<&str>> =
s.prefs.screen_export_presets.iter().map(|p| p.settings.get("formats").and_then(|f| f[0]["format"].as_str())).collect();
assert_eq!(formats, [Some("png"), None, Some("svg")]);
}