Merge pull request #6234 from dotCooCoo/fix/save-screens-unknown-options

Save: the save commands and Export for Screens refuse an unknown option key
This commit is contained in:
BFlat
2026-11-09 12:49:37 +08:00
committed by GitHub
22 changed files with 667 additions and 63 deletions
+5 -4
View File
@@ -93,16 +93,17 @@ pub use save::{
remembered_save_options, save_filters, save_format, save_job, save_plan, save_with, stamp_save_dates, template_filters, templates_dialog_folder,
templates_folder,
};
pub(crate) use screens::{
PARAM_KEYS as SCREEN_PARAM_KEYS, ROW_KEYS as SCREEN_ROW_KEYS, UNWRITTEN_FORMATS as SCREEN_UNWRITTEN_FORMATS,
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 screens::{
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::{
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
/// file it replaces), for the apps' own writers too.
+2 -2
View File
@@ -54,7 +54,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Export for Screens",
["File", "Export"],
None,
"{folder?, zip?: false (one .zip of every file, each deflated or stored, whichever is smaller: no folder → {name, dataBase64, bytes, files: [name…]}; with a folder it is written there → {path, bytes, files}), artboards?: [index…] | range?: \"1-3\" (default all), assets?: [asset id…] (instead of artboards: each asset's art alone, cropped to its bounds and named after the asset; see assets.list), layers?: [layer id…] (instead of artboards: each layer's art alone, sublayers included, named after the layer), selection?: one|each (instead of artboards: the selected art as one file, or a file per selected object, named after it), fullDocument?: false (one file per format instead: a PDF of every artboard, other formats the bounds of all visible art, named after the document), includeBleed?: false (artboards grown by the document's bleed), includeArtboardFill?: true (Include Artboard Fill: each artboard's background color behind its art; false leaves it out), pdfs?: multiple|single (Export PDFs As: with several artboards, single writes them as the pages of one PDF per PDF row, named after the document), exportPreset?: (a preset of document.exportPresets.list: its settings, under these params), subfolders?: false (each row's files in a sub-folder: its folder, else its size for raster (1x, 2x, 100w…) or its format (SVG, PDF)), folders?: none|rows (as subfolders)|format (a folder per extension: png, svg, pdf…)|item (a folder per artboard, asset, layer or object)|itemFormat (item/extension) (default: rows with subfolders or a preset, else none), name?: (a file name pattern instead of the item's name, with <filename> for the document, <name> for the artboard, asset, layer or object, <index> from 1, #### for the counter zero-padded to the run of #, <format>, <scale>, <suffix> (else the row's suffix ends the name), YYYYMMDD, YYYY-MM-DD, YYYY, <date>, <time>, <YYYY>, <MM>, <DD>, <hh>, <mm> and <ss>; a trailing .ext is dropped, as in `COMPANY-Logo-<filename>-####-YYYYMMDD`; different files it names alike get -2, -3…), counterStart?: 1, counterPer?: item (every file of an item has its number)|file, date?: YYYY-MM-DD (the date the pattern uses; default today, UTC), preset?: mobile (PNG 1x, 2x, 3x) | density (PNG 0.75x–4x in ldpi…xxxhdpi sub-folders) instead of formats (turns subfolders on), formats?: [{format: png|png8|jpg|webp|gif|tiff|bmp|tga|psd|svg|svgz|pdf|eps|ai|vectorcraft, scale?: 1 | \"2x\" | \"100w\" | \"100h\" | \"72ppi\", width?: px | height?: px | ppi? (raster only; width, then height, then ppi win over scale), suffix?: (raster default: @2x, @100w, @100h, none at 1x; vector formats drop size suffixes), folder?: (its sub-folder), quality?: (jpg 0–100), …the format's options (antiAlias, background, preset (pdf)…)}], settings?: {png|png8|jpg|webp|gif|tiff|bmp|tga|psd|svg|svgz|pdf|eps|ai|vectorcraft: {…options for every row of that format}} (rows' own options win), prefix?, antiAlias?: none|art|type (raster rows without their own), openLocation?: (remembered; the app shows the files after exporting)} one file per artboard and format (a PDF holds its artboard alone; artboards with the same name, in any case, get -2, -3…; an unnamed one is Artboard-N; a row whose file would take a name another row's file has, in any case, gets -2, -3… too, and identical rows write one file; row folders that differ only in case are one folder, spelled as the first row spells it) → {files: [path…]}; no folder → {files: [{name, dataBase64}]} (names include sub-folders: 2x/Icon@2x.png); every reply has warnings: [the encoders' warnings for the files, each once: an image that couldn't be decoded, type written in the fallback font…]. eps, ai and vectorcraft rows write a document of the item alone (its one artboard). The params (but zip and assets) are remembered in the document (document.exportSettings; not an undo step)",
"{folder?, zip?: false (one .zip of every file, each deflated or stored, whichever is smaller: no folder → {name, dataBase64, bytes, files: [name…]}; with a folder it is written there → {path, bytes, files}), artboard?: index | artboards?: [index…] | range?: \"1-3\" (default all), assets?: [asset id…] (instead of artboards: each asset's art alone, cropped to its bounds and named after the asset; see assets.list), layers?: [layer id…] (instead of artboards: each layer's art alone, sublayers included, named after the layer), selection?: one|each (instead of artboards: the selected art as one file, or a file per selected object, named after it), fullDocument?: false (one file per format instead: a PDF of every artboard, other formats the bounds of all visible art, named after the document), includeBleed?: false (artboards grown by the document's bleed), includeArtboardFill?: true (Include Artboard Fill: each artboard's background color behind its art; false leaves it out), pdfs?: multiple|single (Export PDFs As: with several artboards, single writes them as the pages of one PDF per PDF row, named after the document), exportPreset?: (a preset of document.exportPresets.list: its settings, under these params), subfolders?: false (each row's files in a sub-folder: its folder, else its size for raster (1x, 2x, 100w…) or its format (SVG, PDF)), folders?: none|rows (as subfolders)|format (a folder per extension: png, svg, pdf…)|item (a folder per artboard, asset, layer or object)|itemFormat (item/extension) (default: rows with subfolders or a preset, else none), name?: (a file name pattern instead of the item's name, with <filename> for the document, <name> for the artboard, asset, layer or object, <index> from 1, #### for the counter zero-padded to the run of #, <format>, <scale>, <suffix> (else the row's suffix ends the name), YYYYMMDD, YYYY-MM-DD, YYYY, <date>, <time>, <YYYY>, <MM>, <DD>, <hh>, <mm> and <ss>; a trailing .ext is dropped, as in `COMPANY-Logo-<filename>-####-YYYYMMDD`; different files it names alike get -2, -3…), counterStart?: 1, counterPer?: item (every file of an item has its number)|file, date?: YYYY-MM-DD (the date the pattern uses; default today, UTC), preset?: mobile (PNG 1x, 2x, 3x) | density (PNG 0.75x–4x in ldpi…xxxhdpi sub-folders) instead of formats (turns subfolders on), formats?: [{format: png|png8|jpg|webp|gif|tiff|bmp|tga|psd|svg|svgz|pdf|eps|ai|vectorcraft, scale?: 1 | \"2x\" | \"100w\" | \"100h\" | \"72ppi\", width?: px | height?: px | ppi? (raster only; width, then height, then ppi win over scale), suffix?: (raster default: @2x, @100w, @100h, none at 1x; vector formats drop size suffixes), folder?: (its sub-folder), quality?: (jpg 0–100), …the format's options (antiAlias, background, preset (pdf)…)}], settings?: {png|png8|jpg|webp|gif|tiff|bmp|tga|psd|svg|svgz|pdf|eps|ai|vectorcraft: {…options for every row of that format}} (rows' own options win), prefix?, antiAlias?: none|art|type (raster rows without their own), openLocation?: (remembered; the app shows the files after exporting)} one file per artboard and format (a PDF holds its artboard alone; artboards with the same name, in any case, get -2, -3…; an unnamed one is Artboard-N; a row whose file would take a name another row's file has, in any case, gets -2, -3… too, and identical rows write one file; row folders that differ only in case are one folder, spelled as the first row spells it) → {files: [path…]}; no folder → {files: [{name, dataBase64}]} (names include sub-folders: 2x/Icon@2x.png); every reply has warnings: [the encoders' warnings for the files, each once: an image that couldn't be decoded, type written in the fallback font…]. eps, ai and vectorcraft rows write a document of the item alone (its one artboard). The params (but zip and assets) are remembered in the document (document.exportSettings; not an undo step). A key it doesn't take, a key no format takes in a format row or in settings, settings for a format it doesn't write, and an unknown key inside a PDF, EPS or SVG option's object, are refused",
has_doc,
screens::export_for_screens
),
@@ -72,7 +72,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Save Export Preset",
[],
None,
"{name, settings: {formats, settings, preset, name, prefix, counterStart, counterPer, folders, subfolders, includeBleed, includeArtboardFill, antiAlias, folder, fullDocument} (as document.exportForScreens; other keys are left out)} save (or replace) a batch export preset, kept with the preferences; built-in names are refused → {name, created, settings}",
"{name, settings: {formats, settings, preset, name, prefix, counterStart, counterPer, folders, subfolders, includeBleed, includeArtboardFill, antiAlias, folder, fullDocument} (as document.exportForScreens; its other keys are left out)} save (or replace) a batch export preset, kept with the preferences; built-in names are refused, and so are the keys document.exportForScreens refuses, in settings and in its format rows and settings → {name, created, settings}",
always,
screens::presets_save
),
+1 -1
View File
@@ -1102,7 +1102,7 @@ pub(super) fn specs() -> Vec<CommandSpec> {
"Save Document",
[],
None,
"{path?, format?: vectorcraft|template|pdf|svg|svgz|ai|eps (default: the path's extension, else the document's own format), options?: {…the format's options, see file.formatOptions; default: as last saved}, svg?: {…SVG options} (an SVG save keeps hidden layers, display:none, unless hiddenLayers is false), …the SVG and PDF options given flat instead (an option given flat or in svg and in options with different values is an error), compress?: bool (native, also in options: gzip; default: the useCompression preference; warnings say when the format doesn't read compress, version, preview or precision given flat, or a key given in options), version?: 4 (3, 2 or 1: for older VectorCraft versions; 1 and 2 are never compressed), preview?: false (embed a PNG of the first artboard, at most 256 px), precision?: 0..9 (native: round path anchors and handles to this many decimal places, a smaller file; colors, transforms and type stay exact; default: exact, and a later save without it writes exactly again), separateArtboards?: false (native and .ai: also save each artboard of `range` to <name>-<artboard>.<ext> beside the file, holding that artboard and the art touching it; the result's files lists every file written), range?: \"1-3, 5\"|\"all\" (default; with separateArtboards), includeLinked?: false (keep linked files' own pixels, not just their previews; they stay linked), embedProfiles?: true (carry the ICC profiles loaded from files that the document is tagged with; opening the file installs them where missing), pdfCompatible?: true (native: also carry a PDF of every artboard; false omits it) | true for .ai (false: blank pages, only the native document; a .ai file's compress, default true, is its compression.compressText), modified?: Unix seconds|null (the File Info modified date, and created date when there is none, a save to a file stamps; default now, recorded in the journal so a replay matches; null: leave the dates)} → {path, format, bytes, warnings, files?: [path…] (the file and its artboards' files), linked?: [path…] (images an SVG links to)}. Save writes one artboard, except a .ai file: a PDF-compatible file of every artboard carrying the native document (preserveEditing always on; PDF options flat or in options), which document.open restores exactly, on the active artboard (the app's, else the one the document opened on; with editingData: false, the first); separateArtboards writes more files, one per artboard. EPS saves the chosen artboard (first by default), retaining its page margins and the whole editable document; its PostScript appearance flattens transparency, as EPS requires. Without a path it writes the document's own file in its own format: a document opened from or saved as SVG/PDF/EPS saves as that again (warnings name what the format loses). No path known (never saved, converted from an older version, restored by Data Recovery, or another format) → {dataBase64, format, name, folder?, warnings} and the document stays modified",
"{path?, format?: vectorcraft|template|pdf|svg|svgz|ai|eps (default: the path's extension, else the document's own format), options?: {…the format's options, see file.formatOptions; default: as last saved}, svg?: {…SVG options} (an SVG save keeps hidden layers, display:none, unless hiddenLayers is false), …the SVG and PDF options given flat instead (an option given flat or in svg and in options with different values is an error), compress?: bool (native, also in options: gzip; default: the useCompression preference; warnings say when the format doesn't read compress, version, preview or precision given flat, or a key given in options), version?: 4 (3, 2 or 1: for older VectorCraft versions; 1 and 2 are never compressed), preview?: false (embed a PNG of the first artboard, at most 256 px), precision?: 0..9 (native: round path anchors and handles to this many decimal places, a smaller file; colors, transforms and type stay exact; default: exact, and a later save without it writes exactly again), separateArtboards?: false (native and .ai: also save each artboard of `range` to <name>-<artboard>.<ext> beside the file, holding that artboard and the art touching it; the result's files lists every file written), range?: \"1-3, 5\"|\"all\" (default; with separateArtboards), includeLinked?: false (keep linked files' own pixels, not just their previews; they stay linked), embedProfiles?: true (carry the ICC profiles loaded from files that the document is tagged with; opening the file installs them where missing), pdfCompatible?: true (native: also carry a PDF of every artboard; false omits it) | true for .ai (false: blank pages, only the native document; a .ai file's compress, default true, is its compression.compressText), modified?: Unix seconds|null (the File Info modified date, and created date when there is none, a save to a file stamps; default now, recorded in the journal so a replay matches; null: leave the dates)} → {path, format, bytes, warnings, files?: [path…] (the file and its artboards' files), linked?: [path…] (images an SVG links to)}. Save writes one artboard, except a .ai file: a PDF-compatible file of every artboard carrying the native document (preserveEditing always on; PDF options flat or in options), which document.open restores exactly, on the active artboard (the app's, else the one the document opened on; with editingData: false, the first); separateArtboards writes more files, one per artboard. EPS saves the chosen artboard (first by default), retaining its page margins and the whole editable document; its PostScript appearance flattens transparency, as EPS requires. Without a path it writes the document's own file in its own format: a document opened from or saved as SVG/PDF/EPS saves as that again (warnings name what the format loses). No path known (never saved, converted from an older version, restored by Data Recovery, or another format) → {dataBase64, format, name, folder?, warnings} and the document stays modified. A key that no save format takes, beside the path or in options, and an unknown key inside a PDF option's object or svg, are refused",
has_doc,
|s, p| save(s, SaveMode::Save, p)
),
+35 -2
View File
@@ -193,7 +193,10 @@ pub(super) fn name_without_device(name: &str) -> String {
}
/// Keys of a format row that aren't encoder options.
const ROW_KEYS: [&str; 7] = ["format", "scale", "ppi", "width", "height", "suffix", "folder"];
pub(crate) const ROW_KEYS: [&str; 7] = ["format", "scale", "ppi", "width", "height", "suffix", "folder"];
/// The writable formats a format row can't name.
pub(crate) const UNWRITTEN_FORMATS: [&str; 5] = ["template", "txt", "dxf", "emf", "wmf"];
/// One format row: what to write, at which size, with which suffix, into which sub-folder.
struct Row {
@@ -214,7 +217,7 @@ impl Row {
return Err(bad(C, "each format is an object {format, scale?, suffix?}"));
}
let format = writable(C, Some(str_param(row, "format").unwrap_or("png")), None)?;
if matches!(format.id, "template" | "txt" | "dxf" | "emf" | "wmf") {
if UNWRITTEN_FORMATS.contains(&format.id) {
return Err(bad(C, "Export for Screens writes png, png8, jpg, webp, gif, tiff, bmp, tga, psd, svg, svgz, pdf, eps, ai or vectorcraft"));
}
// Vector formats have no pixel size: it doesn't apply and adds no @2x suffix (not even one
@@ -285,6 +288,36 @@ impl Row {
/// or where to.
pub const SHARED_KEYS: [&str; 6] = ["formats", "preset", "settings", "prefix", "subfolders", "antiAlias"];
/// The keys `document.exportForScreens` reads: those of [`ScreenParams`], the formats and their
/// settings, `antiAlias`, `exportPreset` and `openLocation`, which the app reads.
pub(crate) const PARAM_KEYS: [&str; 25] = [
"artboard",
"artboards",
"range",
"folder",
"zip",
"fullDocument",
"includeBleed",
"includeArtboardFill",
"subfolders",
"preset",
"prefix",
"assets",
"layers",
"selection",
"name",
"counterStart",
"counterPer",
"date",
"folders",
"pdfs",
"formats",
"settings",
"antiAlias",
"exportPreset",
"openLocation",
];
/// What `document.exportForScreens` reads besides the formats.
#[derive(Default, Deserialize)]
#[serde(default, rename_all = "camelCase")]
+178 -34
View File
@@ -6,9 +6,9 @@
//! by `|`; a `?` marks an optional one, and a `:`, `=` or `(` starts what the entry says about it. An entry that starts
//! with `…` stands for keys listed elsewhere: those of each command it names after `as` or `of`
//! (`…settings as file.new`), or, naming none, any key (the options of the format an export picks). Objects inside a value aren't
//! described key by key, so only the top level is checked, except in an export's options: those
//! are checked against the options of the formats the command can write, in `marks`, `bleed` and
//! the other objects too ([`check_export_options`]).
//! described key by key, so only the top level is checked, except in the options of an export, a
//! save or Export for Screens: those are checked against the options of the formats the command
//! can write, in `marks`, `bleed` and the other objects too ([`check_export_options`]).
//!
//! [`Session::execute`] answers a key the description doesn't name with a parameter error
//! ([`check_params`]), for every caller (the app, the control channel, MCP, the CLI) and for the
@@ -531,25 +531,183 @@ fn check_option_tree(id: &str, within: &str, given: &serde_json::Map<String, Val
Ok(())
}
/// The options of an export command `id`. `document.export`, `document.exportSelection` and
/// `document.serialize` take a key that some format reads (one object can carry several formats'
/// options; `document.formats` lists them). `document.exportPdf`, `document.pdfSettings` and
/// `pdf.preset.save` take a key that PDF reads, `document.exportEps` one that EPS reads and
/// `document.exportDxf` one that DXF reads. Where a command takes `marks`, `bleed`, `flattener` or
/// another object option of PDF or EPS, only the object's own keys pass. An SVG export's `svg`
/// object is checked when it is read. Saved presets are not checked: one with a key that is no
/// longer read keeps loading.
/// `given`, the options object at `within` (`""` for the params themselves, `options.`,
/// `formats[0].`), has only `keys`, and only their own keys in the PDF and EPS object options
/// (`trees`). `why_not` says why a key that is not near any of `keys` is refused, and where the
/// keys are listed.
fn check_option_keys(id: &str, within: &str, given: &serde_json::Map<String, Value>, keys: &[&str], why_not: &str, trees: &Value) -> Result<()> {
for (key, value) in given {
if !keys.contains(&key.as_str()) {
return Err(bad(
id,
match nearest(key, keys) {
Some(near) => format!("unknown parameter `{within}{key}`; did you mean `{within}{near}`?"),
None => format!("unknown parameter `{within}{key}`; {why_not}"),
},
));
}
if let (Some(Value::Object(schema)), Value::Object(value)) = (trees.get(key), value) {
check_option_tree(id, &format!("{within}{key}."), value, schema)?;
}
}
Ok(())
}
/// Why an export option that no format reads is refused.
const NO_FORMAT: &str = "no format takes it (see document.formats)";
/// Why a save option that no save format reads is refused.
const NO_SAVE_FORMAT: &str = "no save format takes it (see file.formatOptions)";
/// The format ids Export for Screens writes: the writable formats, those exporter plug-ins add
/// included, but those it doesn't write.
pub fn screen_formats() -> Vec<&'static str> {
use super::fileio::{FORMATS, SCREEN_UNWRITTEN_FORMATS, plugin_formats};
let added = plugin_formats();
FORMATS.iter().chain(added.iter().copied()).filter(|f| f.write && !SCREEN_UNWRITTEN_FORMATS.contains(&f.id)).map(|f| f.id).collect()
}
/// The keys a format row of Export for Screens, and the settings of a format, take: the row's own
/// (`format`, `scale`…), `includeArtboardFill` and `richBlack`, which the encoders read from a
/// row's options (`with_artboard_fills`, the render options), `params`, an exporter plug-in's own,
/// and a key some format reads, as `document.export` takes them.
fn screen_option_keys() -> Vec<&'static str> {
use super::fileio::{FORMATS, SCREEN_ROW_KEYS};
let mut keys: Vec<&str> = SCREEN_ROW_KEYS.to_vec();
keys.extend(["includeArtboardFill", "richBlack", "params"]);
keys.extend(FORMATS.iter().filter(|f| f.write).flat_map(|f| f.options).map(|o| o.name));
keys
}
/// A format row of Export for Screens, or the settings of a format, without the keys the export
/// refuses in it ([`check_screen_rows`]), inside `marks`, `svg` and the other object options too:
/// a saved row or preset that holds a key that is no longer read loads without it.
pub fn drop_unread_row_options(row: &mut serde_json::Map<String, Value>) {
fn retain_tree(o: &mut serde_json::Map<String, Value>, schema: &serde_json::Map<String, Value>) {
o.retain(|k, _| schema.contains_key(k));
for (k, v) in o.iter_mut() {
if let (Some(Value::Object(inner)), Value::Object(v)) = (schema.get(k), v) {
retain_tree(v, inner);
}
}
}
let keys = screen_option_keys();
row.retain(|k, _| keys.contains(&k.as_str()));
let trees = screen_option_trees();
for (k, v) in row.iter_mut() {
if let (Some(Value::Object(schema)), Value::Object(v)) = (trees.get(k), v) {
retain_tree(v, schema);
}
}
}
/// [`option_trees`] of Export for Screens: its remembered settings mark PDF security whose
/// passwords were left out with `security.passwordRequired`, which the export reads.
fn screen_option_trees() -> Value {
let mut trees = option_trees();
if let Some(security) = trees.get_mut("security").and_then(Value::as_object_mut) {
security.insert("passwordRequired".into(), Value::Null);
}
trees
}
/// The object options an export or save checks key by key: PDF's `marks`, `bleed` and the others,
/// the flattener's, and SVG's `svg` with the artboard keys it may carry.
fn option_trees() -> Value {
let mut trees = pdf_option_tree();
if let Some(tree) = trees.as_object_mut() {
tree.insert("flattener".into(), serde_json::to_value(super::flatten::FlattenOptions::default()).unwrap_or_default());
let mut svg = serde_json::to_value(vectorcraft_svg::ExportOptions::default()).unwrap_or_default();
if let Some(svg) = svg.as_object_mut() {
svg.extend(super::fileio::ARTBOARD_PARAMS.iter().chain(&["useArtboards"]).map(|k| (k.to_string(), Value::Null)));
}
tree.insert("svg".into(), svg);
}
trees
}
/// The format rows (`formats`) and the per-format `settings` of Export for Screens params `p`, at
/// `within` (`settings.` in `document.exportPresets.save`). A row, and the settings of a format,
/// take [`screen_option_keys`]; `settings` takes the formats Export for Screens writes.
fn check_screen_rows(id: &str, within: &str, p: &serde_json::Map<String, Value>) -> Result<()> {
let keys = screen_option_keys();
let trees = &screen_option_trees();
if let Some(Value::Array(rows)) = p.get("formats") {
for (i, row) in rows.iter().enumerate() {
if let Value::Object(row) = row {
check_option_keys(id, &format!("{within}formats[{i}]."), row, &keys, NO_FORMAT, trees)?;
}
}
}
if let Some(Value::Object(settings)) = p.get("settings") {
let formats = screen_formats();
for (format, options) in settings {
if !formats.contains(&format.as_str()) {
let why = match nearest(format, &formats) {
Some(near) => format!("did you mean `{within}settings.{near}`?"),
None => format!("`{within}settings` takes: {}", formats.join(", ")),
};
return Err(bad(id, format!("unknown parameter `{within}settings.{format}`; {why}")));
}
if let Value::Object(options) = options {
check_option_keys(id, &format!("{within}settings.{format}."), options, &keys, NO_FORMAT, trees)?;
}
}
}
Ok(())
}
/// The options of an export or save command `id`. `document.export`, `document.exportSelection`
/// and `document.serialize` take a key that some format reads (one object can carry several
/// formats' options; `document.formats` lists them). `document.exportPdf`, `document.pdfSettings`
/// and `pdf.preset.save` take a key that PDF reads, `document.exportEps` one that EPS reads and
/// `document.exportDxf` one that DXF reads. `document.save`, `file.saveAs`, `file.saveCopy` and
/// `file.saveAsTemplate` take, beside the path and in `options`, a key that some save format
/// reads. `document.exportForScreens` takes the keys it reads, and checks its format rows and
/// settings ([`check_screen_rows`]), as `document.exportPresets.save` does in its `settings` and
/// `assets.export` and `assets.settings.set` do in theirs.
/// Where a command takes `marks`, `bleed`, `flattener`, `svg` or another object option of PDF,
/// EPS or SVG, only the object's own keys pass ([`option_trees`]). Saved presets are not checked:
/// one with a key that is no longer read keeps loading.
fn check_export_options(id: &str, params: &Value) -> Result<()> {
use super::fileio::{FORMATS, dxf, eps, pdf};
use super::fileio::{FORMATS, SAVE_FORMATS, SCREEN_PARAM_KEYS, TEMPLATE_FORMATS, dxf, eps, pdf};
let Value::Object(given) = params else { return Ok(()) };
match (id, given.get("settings")) {
("document.exportForScreens", _) => {
if let Some(key) = given.keys().find(|k| !SCREEN_PARAM_KEYS.contains(&k.as_str())) {
return Err(unknown(id, key, &SCREEN_PARAM_KEYS));
}
return check_screen_rows(id, "", given);
}
("document.exportPresets.save", Some(Value::Object(settings))) => {
let why = format!("`settings` takes: {}", SCREEN_PARAM_KEYS.join(", "));
check_option_keys(id, "settings.", settings, &SCREEN_PARAM_KEYS, &why, &Value::Null)?;
return check_screen_rows(id, "settings.", settings);
}
// Export Assets exports with, and stores, Export for Screens' format rows and settings.
("assets.export" | "assets.settings.set", _) => return check_screen_rows(id, "", given),
_ => {}
}
let mut keys: Vec<&str> = vec!["path", "format", "acknowledgeLoss"];
// A save's `options` and the keys they take.
let mut inner: Option<(&serde_json::Map<String, Value>, Vec<&str>)> = None;
let why_not = match id {
"document.save" | "file.saveAs" | "file.saveCopy" | "file.saveAsTemplate" => {
let saved = FORMATS.iter().filter(|f| SAVE_FORMATS.contains(&f.id) || TEMPLATE_FORMATS.contains(&f.id));
let options: Vec<&str> = saved.flat_map(|f| f.options).map(|o| o.name).collect();
if let Some(Value::Object(o)) = given.get("options") {
inner = Some((o, options.clone()));
}
keys.extend(["options", "modified"]);
keys.extend(options);
NO_SAVE_FORMAT
}
"document.export" | "document.exportSelection" | "document.serialize" => {
// `richBlack` is Appearance of Black's setting for a raster export, and `params` an
// exporter plug-in's own.
keys.extend(["includeArtboardFill", "selectedOnly", "richBlack", "params"]);
keys.extend(FORMATS.iter().filter(|f| f.write).flat_map(|f| f.options).map(|o| o.name));
"no format takes it"
NO_FORMAT
}
"document.exportPdf" | "document.pdfSettings" | "pdf.preset.save" => {
keys.push("includeArtboardFill");
@@ -559,38 +717,24 @@ fn check_export_options(id: &str, params: &Value) -> Result<()> {
"pdf.preset.save" => keys.extend(["name", "newName", "description"]),
_ => {}
}
"PDF doesn't take it"
"PDF doesn't take it (see document.formats)"
}
"document.exportEps" => {
keys.push("includeArtboardFill");
keys.extend(eps::OPTIONS.iter().map(|o| o.name));
"EPS doesn't take it"
"EPS doesn't take it (see document.formats)"
}
"document.exportDxf" => {
keys.extend(dxf::OPTIONS.iter().map(|o| o.name));
"DXF doesn't take it"
"DXF doesn't take it (see document.formats)"
}
_ => return Ok(()),
};
let mut trees = pdf_option_tree();
if let Some(tree) = trees.as_object_mut() {
tree.insert("flattener".into(), serde_json::to_value(super::flatten::FlattenOptions::default()).unwrap_or_default());
let trees = option_trees();
if let Some((o, options)) = inner {
check_option_keys(id, "options.", o, &options, NO_SAVE_FORMAT, &trees)?;
}
for (key, value) in given {
if !keys.contains(&key.as_str()) {
return Err(bad(
id,
match nearest(key, &keys) {
Some(near) => format!("unknown parameter `{key}`; did you mean `{near}`?"),
None => format!("unknown parameter `{key}`; {why_not} (see document.formats)"),
},
));
}
if let (Some(Value::Object(schema)), Value::Object(value)) = (trees.get(key), value) {
check_option_tree(id, &format!("{key}."), value, schema)?;
}
}
Ok(())
check_option_keys(id, "", given, &keys, why_not, &trees)
}
/// [`check_keys`] for an engine command, the steps of a `command.batch` or an `action.batch` included,
+202
View File
@@ -177,6 +177,208 @@ fn export_options_pass_every_listed_key() {
}
}
const SAVES: [&str; 4] = ["document.save", "file.saveAs", "file.saveCopy", "file.saveAsTemplate"];
/// A save refuses a key no save format reads, beside the path, in `options` and in the PDF and
/// SVG objects of either, under each save command.
#[test]
fn save_options_refuse_a_key_no_save_format_reads() {
let why = "no save format takes it (see file.formatOptions)";
for id in SAVES {
assert_eq!(refusal(id, json!({"path": "a.pdf", "noSuchOption": true})), format!("unknown parameter `noSuchOption`; {why}"));
assert_eq!(
refusal(id, json!({"path": "a.pdf", "options": {"noSuchOption": true}})),
format!("unknown parameter `options.noSuchOption`; {why}")
);
assert_eq!(refusal(id, json!({"compres": true})), "unknown parameter `compres`; did you mean `compress`?");
assert_eq!(refusal(id, json!({"options": {"compres": true}})), "unknown parameter `options.compres`; did you mean `options.compress`?");
// An export's own key is not a save's.
assert_eq!(refusal(id, json!({"path": "a.png", "quality": 80})), format!("unknown parameter `quality`; {why}"));
for (given, message) in [
(json!({"path": "a.pdf", "marks": {"useDocumentBleed": true}}), "unknown parameter `marks.useDocumentBleed`; `marks` takes:"),
(json!({"path": "a.pdf", "options": {"marks": {"x": 1}}}), "unknown parameter `options.marks.x`; `options.marks` takes:"),
(json!({"path": "a.ai", "bleed": {"useDocumen": true}}), "unknown parameter `bleed.useDocumen`; did you mean `bleed.useDocument`?"),
// The `svg` object is checked when the save writes no SVG too.
(json!({"path": "a.pdf", "svg": {"noSuchOption": true}}), "unknown parameter `svg.noSuchOption`; `svg` takes:"),
(
json!({"path": "a.pdf", "options": {"svg": {"minfy": true}}}),
"unknown parameter `options.svg.minfy`; did you mean `options.svg.minify`?",
),
] {
let got = refusal(id, given.clone());
assert!(got.starts_with(message), "{id} {given}: {got}");
}
// A key that another format a save writes reads still passes: the save leaves it out.
for given in [
json!({"path": "a.svg", "compress": true}),
json!({"path": "a.pdf", "options": {"preview": true, "styling": "style"}}),
json!({"path": "a.vectorcraft", "standard": "pdfX4", "marks": {"trim": true}}),
json!({"path": "a.ai", "acknowledgeLoss": true, "modified": null, "svg": {"minify": true}}),
json!({"path": "a.svg", "svg": {"styling": "css", "artboard": 0, "useArtboards": true}}),
] {
check_params(id, &given).unwrap_or_else(|e| panic!("{id} {given}: {e}"));
}
}
}
/// A save passes each option of each format it writes, beside the path and in `options`, and
/// each key its description names.
#[test]
fn save_options_pass_every_listed_key() {
use crate::cmd::fileio::{FORMATS, SAVE_FORMATS, TEMPLATE_FORMATS};
for f in FORMATS.iter().filter(|f| SAVE_FORMATS.contains(&f.id) || TEMPLATE_FORMATS.contains(&f.id)) {
for o in f.options {
for params in [json!({"format": f.id, o.name: null}), json!({"format": f.id, "options": {o.name: null}})] {
for id in SAVES {
check_params(id, &params).unwrap_or_else(|e| panic!("{id} {params}: {e}"));
}
}
}
}
for id in SAVES {
let keys = doc_keys(find_command(id).unwrap().params).unwrap().keys;
let refused: Vec<&str> = keys.into_iter().filter(|key| check_params(id, &json!({*key: null})).is_err()).collect();
assert_eq!(refused, Vec::<&str>::new(), "{id} names and refuses these");
}
}
/// Export for Screens refuses a key it doesn't take, and in its rows and settings a key no format
/// reads, by the row's path; `document.exportPresets.save` checks the rows of its settings.
#[test]
fn export_for_screens_refuses_a_key_no_format_reads() {
let id = "document.exportForScreens";
assert!(
refusal(id, json!({"folder": "out", "noSuchOption": true})).starts_with("unknown parameter `noSuchOption`; the command takes: artboard, ")
);
assert_eq!(refusal(id, json!({"subfolder": true})), "unknown parameter `subfolder`; did you mean `subfolders`?");
let no_format = "no format takes it (see document.formats)";
for (given, message) in [
(json!({"formats": [{"format": "png", "noSuchOption": 1}]}), format!("unknown parameter `formats[0].noSuchOption`; {no_format}")),
(
json!({"formats": [{"format": "png"}, {"format": "pdf", "marks": {"useDocumentBleed": true}}]}),
"unknown parameter `formats[1].marks.useDocumentBleed`; `formats[1].marks` takes:".into(),
),
(
json!({"formats": [{"format": "pdf", "security": {"openPasswrd": "x"}}]}),
"unknown parameter `formats[0].security.openPasswrd`; did you mean `formats[0].security.openPassword`?".into(),
),
(
json!({"formats": [{"format": "eps", "flattener": {"balanse": 50}}]}),
"unknown parameter `formats[0].flattener.balanse`; did you mean `formats[0].flattener.balance`?".into(),
),
(json!({"formats": [{"format": "jpg", "sufix": "@2x"}]}), "unknown parameter `formats[0].sufix`; did you mean `formats[0].suffix`?".into()),
(json!({"settings": {"pnj": {}}}), "unknown parameter `settings.pnj`; did you mean `settings.png`?".into()),
(json!({"settings": {"dxf": {}}}), "unknown parameter `settings.dxf`; `settings` takes: ".into()),
(
json!({"settings": {"png": {"bckground": "white"}}}),
"unknown parameter `settings.png.bckground`; did you mean `settings.png.background`?".into(),
),
(json!({"settings": {"pdf": {"bleed": {"top": 9, "inner": 1}}}}), "unknown parameter `settings.pdf.bleed.inner`;".into()),
] {
let got = refusal(id, given.clone());
assert!(got.starts_with(&message), "{given}: {got}");
}
let save = "document.exportPresets.save";
assert_eq!(
refusal(save, json!({"name": "a", "settings": {"formats": [{"format": "png", "noSuchOption": 1}]}})),
format!("unknown parameter `settings.formats[0].noSuchOption`; {no_format}")
);
assert!(
refusal(save, json!({"name": "a", "settings": {"settings": {"pdf": {"marks": {"x": 1}}}}}))
.starts_with("unknown parameter `settings.settings.pdf.marks.x`;")
);
assert_eq!(
refusal(save, json!({"name": "a", "settings": {"formtas": [{"format": "png"}]}})),
"unknown parameter `settings.formtas`; did you mean `settings.formats`?"
);
assert!(
refusal(save, json!({"name": "a", "settings": {"noSuchOption": true}}))
.starts_with("unknown parameter `settings.noSuchOption`; `settings` takes: artboard, ")
);
// Export Assets takes the same rows and settings, and stores them.
for asset in ["assets.export", "assets.settings.set"] {
assert_eq!(
refusal(asset, json!({"formats": [{"format": "png", "noSuchOption": 1}]})),
format!("unknown parameter `formats[0].noSuchOption`; {no_format}")
);
assert!(refusal(asset, json!({"settings": {"pdf": {"marks": {"x": 1}}}})).starts_with("unknown parameter `settings.pdf.marks.x`;"));
check_params(asset, &json!({"formats": [{"format": "jpg", "scale": "2x", "quality": 80}]})).unwrap();
}
// An exporter plug-in's `params` in a row (its format is a plug-in's: tests/plugin_export.rs).
check_params(id, &json!({"formats": [{"format": "png", "params": {}}]})).unwrap();
// Keys the preset leaves out are still Export for Screens params.
check_params(save, &json!({"name": "a", "settings": {"zip": true, "formats": [{"format": "jpg", "quality": 80}]}})).unwrap();
// The encoders read Include Artboard Fill and Appearance of Black from a row or a format's
// settings.
for given in [
json!({"formats": [{"format": "png", "includeArtboardFill": false, "richBlack": true}]}),
json!({"settings": {"png": {"includeArtboardFill": false, "richBlack": true}}}),
] {
check_params(id, &given).unwrap_or_else(|e| panic!("{given}: {e}"));
}
}
/// Export for Screens passes each key its description names, each option of a format in a row or
/// in settings, and the built-in presets; `settings` takes the formats a row can write.
#[test]
fn export_for_screens_passes_every_listed_key() {
use crate::cmd::fileio::{FORMATS, check_screen_settings, screen_export_builtin_presets};
let id = "document.exportForScreens";
let keys = doc_keys(find_command(id).unwrap().params).unwrap().keys;
assert!(keys.len() > 20, "{keys:?}");
// The keys the check takes are the ones the description names.
let (mut named, mut taken) = (keys.clone(), crate::cmd::fileio::SCREEN_PARAM_KEYS.to_vec());
named.sort_unstable();
taken.sort_unstable();
assert_eq!(named, taken);
let refused: Vec<&str> = keys.into_iter().filter(|key| check_params(id, &json!({*key: null})).is_err()).collect();
assert_eq!(refused, Vec::<&str>::new(), "{id} names and refuses these");
check_params(id, &json!({"artboard": 0, "openLocation": true})).unwrap();
let s = Session::new();
for f in FORMATS.iter().filter(|f| f.write) {
for o in f.options {
let row = json!({"formats": [{"format": "png", "scale": 2, "suffix": "", "folder": "x", o.name: null}]});
check_params(id, &row).unwrap_or_else(|e| panic!("{row}: {e}"));
}
let written = check_screen_settings(&s, &json!({"formats": [{"format": f.id}]})).is_ok();
let settings = json!({"settings": {f.id: {}}});
assert_eq!(check_params(id, &settings).is_ok(), written, "{settings}");
}
for preset in screen_export_builtin_presets() {
let settings = serde_json::Value::Object(preset.settings);
check_params(id, &settings).unwrap_or_else(|e| panic!("{}: {e}", preset.name));
check_params("document.exportPresets.save", &json!({"name": "a", "settings": settings})).unwrap();
}
}
/// A saved row loses the keys Export for Screens refuses in it, inside its object options too, and
/// keeps the others.
#[test]
fn a_saved_row_drops_the_keys_no_format_reads() {
use crate::cmd::params::drop_unread_row_options;
let serde_json::Value::Object(mut row) = json!({
"format": "pdf", "scale": "1x", "noSuchOption": 1, "params": {"x": 1},
"marks": {"useDocumentBleed": true, "trim": true}, "preset": "High Quality Print",
"security": {"passwordRequired": true, "noSuchLock": 1}
}) else {
panic!("an object")
};
drop_unread_row_options(&mut row);
assert_eq!(
serde_json::Value::Object(row.clone()),
json!({
"format": "pdf", "scale": "1x", "params": {"x": 1}, "marks": {"trim": true}, "preset": "High Quality Print",
"security": {"passwordRequired": true}
})
);
check_params("document.exportForScreens", &json!({"formats": [row]})).unwrap();
// Remembered security whose passwords were left out, with one given again.
let security = json!({"passwordRequired": true, "openPassword": "x"});
for id in ["document.exportForScreens", "assets.export", "assets.settings.set"] {
check_params(id, &json!({"formats": [{"format": "pdf", "security": security}], "settings": {"pdf": {"security": security}}})).unwrap();
}
}
/// `stroke.set` names every key it reads (its own, `item`, and `ids`/`id`/`target`), so a
/// misnamed one is refused instead of answering `objects: 1` with nothing changed.
#[test]
+1 -1
View File
@@ -72,7 +72,7 @@ fn save_as_takes_on_path_format_and_options() {
let d = dir("saveas");
let mut s = session();
let pdf = path(&d, "doc.pdf");
s.execute("file.saveAs", &json!({"path": pdf, "options": {"range": "1", "bogus": 1}})).unwrap();
s.execute("file.saveAs", &json!({"path": pdf, "options": {"range": "1", "preview": true}})).unwrap();
let st = s.doc().unwrap();
assert_eq!((st.format, st.title()), ("pdf", "doc.pdf".to_string()));
assert_eq!(Value::Object(st.save_options.clone()), json!({"range": "1"}), "only the format's own options are kept");
+21
View File
@@ -125,6 +125,27 @@ fn an_export_step_or_preset_with_a_key_no_longer_read_still_runs() {
s.execute("document.export", &json!({"format": "pdf", "preset": "Old"})).unwrap();
}
/// So does a save, or an Export for Screens, with a key that is no longer read, and an Export for
/// Screens preset file that holds one in a row still loads and exports. `Session::execute`
/// refuses the same options.
#[test]
fn a_save_or_screens_step_with_a_key_no_longer_read_still_runs() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let save = json!({"format": "pdf", "noSuchOption": true, "options": {"retired": 1, "marks": {"useDocumentBleed": true}}});
assert!(check_params("file.saveCopy", &save).is_err());
assert!(s.execute("file.saveCopy", &save).is_err());
s.replay("file.saveCopy", &save).unwrap();
let screens = json!({"noSuchOption": true, "formats": [{"format": "png", "retired": 1}], "settings": {"pdf": {"marks": {"retired": true}}}});
assert!(check_params("document.exportForScreens", &screens).is_err());
assert!(s.execute("document.exportForScreens", &screens).is_err());
s.replay("document.exportForScreens", &screens).unwrap();
let file = json!({"format": "vcexportpresets", "presets": [{"name": "Old", "settings": {"formats": [{"format": "png", "noLongerRead": 1}]}}]});
s.execute("document.exportPresets.import", &json!({"data": file.to_string()})).unwrap();
check_params("document.exportForScreens", &json!({"exportPreset": "Old"})).unwrap();
s.execute("document.exportForScreens", &json!({"exportPreset": "Old"})).unwrap();
}
/// So do an effect's own parameters (#1592): a journal or a file with a key the effect doesn't
/// take, or a value of another type, replays and opens; the effect reads what it knows, clamped.
#[test]
+16
View File
@@ -157,6 +157,22 @@ fn the_exporter_is_given_its_parameters_and_the_file_it_writes() {
let _ = std::fs::remove_dir_all(&dir);
}
/// Export for Screens writes an exporter's format from a row, with the row's `params`, and takes
/// settings for that format.
#[test]
fn export_for_screens_takes_an_exporter_s_format_and_params() {
let mut s = session();
let params = r#"{"density":{"type":"number","min":0,"max":10,"default":4}}"#;
install(&mut s, &tpl::echo_exporter(&tpl::exporter("test.screens", r#"["tss"]"#, params), Echo::Params));
let r = s.execute("document.exportForScreens", &json!({"formats": [{"format": "plugin.test.screens", "params": {"density": 7}}]})).unwrap();
let file = &r["files"][0];
assert!(file["name"].as_str().unwrap().ends_with(".tss"), "{r}");
let got: Value = serde_json::from_slice(&vectorcraft_format::base64_decode(file["dataBase64"].as_str().unwrap()).unwrap()).unwrap();
assert_eq!(got["density"], json!(7.0));
let settings = json!({"formats": [{"format": "plugin.test.screens"}], "settings": {"plugin.test.screens": {"params": {"density": 2}}}});
s.execute("document.exportForScreens", &settings).unwrap();
}
/// Only the selected objects, with `selectedOnly`, as for the built-in formats.
#[test]
fn selected_only_hands_over_the_selection() {
+32
View File
@@ -44,6 +44,38 @@ fn an_option_no_format_takes_is_an_error() {
assert!(!r.is_error, "{r:?}");
}
/// `app.save`, `file.save` and `file.exportForScreens` refuse a key no format takes before they
/// write anything.
#[test]
fn a_save_or_screens_option_no_format_takes_is_an_error() {
let dir = std::env::temp_dir().join(format!("vc-mcp-save-options-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let mut h = Headless::new();
h.call("engine.execute", json!({"command": "file.new", "params": {"width": 50, "height": 30}})).unwrap();
let pdf = dir.join("a.pdf").to_string_lossy().into_owned();
for (params, named) in [
(json!({"path": pdf, "noSuchOption": true}), "noSuchOption"),
(json!({"path": pdf, "options": {"marks": {"useDocumentBleed": true}}}), "options.marks.useDocumentBleed"),
] {
let e = h.call("app.save", params.clone()).unwrap_err();
assert!(e.contains(named), "app.save: {e}");
let e = h.call("engine.execute", json!({"command": "file.save", "params": params})).unwrap_err();
assert!(e.contains(named), "file.save: {e}");
}
let folder = dir.join("screens").to_string_lossy().into_owned();
for (params, named) in [
(json!({"folder": folder, "noSuchOption": true}), "noSuchOption"),
(json!({"folder": folder, "formats": [{"format": "pdf", "marks": {"useDocumentBleed": true}}]}), "formats[0].marks.useDocumentBleed"),
] {
let e = h.call("engine.execute", json!({"command": "file.exportForScreens", "params": params})).unwrap_err();
assert!(e.contains(named), "file.exportForScreens: {e}");
}
assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 0, "nothing was written");
h.call("app.save", json!({"path": pdf})).unwrap();
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn raster_export_uses_document_resolution_unless_overridden() {
let mut h = Headless::new();
+3 -4
View File
@@ -470,10 +470,9 @@ pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context, req: &ControlReques
o.remove("wait");
}
let method = req.method.as_str();
// The key check of `document.export` runs before the file is written.
if method == "app.export"
&& let Err(e) = crate::menus::check_params(&app.session, "document.export", &p)
{
// The key check of `document.export` or `document.save` runs before the file is written.
let checked = if method == "app.export" { "document.export" } else { "document.save" };
if let Err(e) = crate::menus::check_params(&app.session, checked, &p) {
return err(e);
}
wrap(if wait { crate::background::in_foreground(app, |app| save_or_export(app, method, &p)) } else { save_or_export(app, method, &p) })
+1
View File
@@ -144,6 +144,7 @@ pub use document_setup::open as open_document_setup;
pub use effect::open as open_effect_dialog;
pub(crate) use effect::{FILM_GRAIN_FIELDS, PAINT_DAUB_BRUSH_TYPE_LABELS, SHADES, shown_choices};
pub use export_as::open as open_export_as;
pub(crate) use export_for_screens::drop_unread_screen_settings;
pub use export_for_screens::open as open_export_for_screens;
pub(crate) use export_for_screens::{
KIND as EXPORT_FOR_SCREENS, PresetPick as ScreenPresetPick, export_preset as screen_export_preset, formats as screen_formats,
@@ -27,6 +27,7 @@
use serde::Deserialize;
use serde_json::{Map, Value, json};
use vectorcraft_engine::cmd::fileio::{self, ArtboardPick, ScreenSize, pdf};
use vectorcraft_engine::cmd::params::{drop_unread_row_options, screen_formats};
use super::{DialogSpec, form, png_options, svg_options};
use crate::state::Dialog;
@@ -213,12 +214,29 @@ pub(crate) fn saved_options(app: &VectorcraftApp, saved: &Map<String, Value>, id
options.insert("antiAlias".into(), aa.clone());
}
if let Some(o) = saved.get("settings").and_then(|v| v.get(id)).and_then(Value::as_object) {
options.extend(o.iter().map(|(k, v)| (k.clone(), v.clone())));
let mut o = o.clone();
drop_unread_row_options(&mut o);
options.extend(o);
}
options
}
/// A row with its size as the text the Scale field shows (`2x`, `100w`, `100h`, `72ppi`).
/// Export settings `settings` (as `document.exportSettings` or a preset holds them) without the
/// keys the export no longer reads in its rows and per-format settings, and without settings for a
/// format it doesn't write.
pub(crate) fn drop_unread_screen_settings(settings: &mut Map<String, Value>) {
if let Some(rows) = settings.get_mut("formats").and_then(Value::as_array_mut) {
rows.iter_mut().filter_map(Value::as_object_mut).for_each(drop_unread_row_options);
}
if let Some(all) = settings.get_mut("settings").and_then(Value::as_object_mut) {
let formats = screen_formats();
all.retain(|id, _| formats.contains(&id.as_str()));
all.values_mut().filter_map(Value::as_object_mut).for_each(drop_unread_row_options);
}
}
/// A row with its size as the text the Scale field shows (`2x`, `100w`, `100h`, `72ppi`), without
/// the keys the export no longer reads.
fn editable_row(row: &Value) -> Value {
let num = |k: &str| row.get(k).and_then(Value::as_f64);
let text = match (num("width"), num("height"), num("ppi"), row.get("scale")) {
@@ -231,6 +249,7 @@ fn editable_row(row: &Value) -> Value {
};
let mut r = row.clone();
if let Some(o) = r.as_object_mut() {
drop_unread_row_options(o);
for k in ["width", "height", "ppi"] {
o.remove(k);
}
@@ -887,7 +906,11 @@ pub(super) fn apply_preset(d: &mut Dialog, preset: &Value) {
d.fields.insert("formats".into(), json!(rows));
d.fields.insert("preset".into(), json!(p));
}
if let (Some(all), Some(given)) = (d.fields.get_mut("settings").and_then(Value::as_object_mut), st["settings"].as_object()) {
// Settings for a format the export doesn't write, and keys it no longer reads, are left out.
let mut given = Map::from_iter([("settings".to_string(), st["settings"].clone())]);
drop_unread_screen_settings(&mut given);
if let (Some(all), Some(given)) = (d.fields.get_mut("settings").and_then(Value::as_object_mut), given.get("settings").and_then(Value::as_object))
{
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()))),
+15 -1
View File
@@ -112,7 +112,8 @@ pub fn open(app: &mut VectorcraftApp, mode: Mode, path: Option<&str>) {
/// only when saving).
fn options(d: &Dialog) -> Value {
let mode = Mode::of(d);
let mut o: Map<String, Value> = d.fields.iter().filter(|(k, _)| !UI_KEYS.contains(&k.as_str())).map(|(k, v)| (k.clone(), v.clone())).collect();
let mut o: Map<String, Value> =
d.fields.iter().filter(|(k, _)| !UI_KEYS.contains(&k.as_str()) && !k.starts_with("__")).map(|(k, v)| (k.clone(), v.clone())).collect();
if mode == Mode::Export {
o.remove("preserveEditing");
if d.bool("useArtboards") {
@@ -379,6 +380,19 @@ mod tests {
assert_eq!(app.ui.dialog.as_ref().unwrap().f64("decimals", 0.0), 1.0);
}
/// A dialog a save panel opened carries the dialog layer's own `__` fields; the save is given
/// only the SVG options, so it is not refused for an unknown key.
#[test]
fn save_as_svg_from_a_save_panel_passes_only_the_options() {
let (mut app, written) = app("/tmp/panel.svg");
app.run("file.saveAs", Value::Null).unwrap();
app.ui.dialog.as_mut().unwrap().fields.insert(crate::dialogs::AFTER_PANEL.into(), json!(true));
assert!(options(app.ui.dialog.as_ref().unwrap()).get(crate::dialogs::AFTER_PANEL).is_none());
let d = app.ui.dialog.take().unwrap();
confirm(&mut app, &d).unwrap_or_else(|e| panic!("{e}"));
assert_eq!(written.borrow()[0].0, "/tmp/panel.svg");
}
#[test]
fn all_artboards_export_one_file_each() {
let (mut app, written) = app("/tmp/set.svg");
@@ -957,6 +957,34 @@ fn saving_an_applied_preset_keeps_its_bleed_and_fill() {
assert_eq!((s.get("includeBleed"), s.get("includeArtboardFill")), (Some(&json!(true)), Some(&json!(false))));
}
/// Remembered settings and a preset that hold keys the export no longer reads, in a row, in a
/// format's settings and inside an object option, and settings for a format it doesn't write, open
/// without them, so the dialog's export is not refused.
#[test]
fn saved_keys_no_longer_read_are_left_out_of_the_dialog_s_export() {
let (mut app, _) = app(1, false);
let stale = json!({
"formats": [{"format": "png", "scale": "2x", "noSuchOption": 1}, {"format": "pdf", "marks": {"trim": true, "noSuchMark": 1}}],
"settings": {"png": {"noSuchSetting": true}, "dxf": {}, "pdf": {"bleed": {"top": 1, "inner": 2}}}
});
let Value::Object(saved) = stale.clone() else { panic!("an object") };
std::sync::Arc::make_mut(&mut app.session.active_mut().unwrap().doc).export_settings = saved;
let folder = temp_folder("stale");
let check = |app: &VectorcraftApp| {
let p = export_for_screens::params(app, app.ui.dialog.as_ref().unwrap()).unwrap();
app.session.check_params("document.exportForScreens", &p).unwrap_or_else(|e| panic!("{e}: {p}"));
p
};
open(&mut app);
set(&mut app, "folder", json!(folder.to_string_lossy()));
let p = check(&app);
assert_eq!(p["formats"][0]["scale"], json!("2x"));
assert_eq!(p["formats"][1]["marks"]["trim"], json!(true));
export_for_screens::apply_preset(app.ui.dialog.as_mut().unwrap(), &json!({"name": "Old", "settings": stale}));
check(&app);
let _ = std::fs::remove_dir_all(&folder);
}
/// A preset that puts each item in its own folder (`item` or `itemFormat`, which the dialog has no
/// control for) keeps that folder mode when it is exported or saved again from the dialog, until
/// the sub-folder controls change (in the dialog or by `ui.dialog.set`).
+6
View File
@@ -29,7 +29,13 @@ pub(crate) fn opt_bool(p: &Value, key: &str) -> Result<Option<bool>, String> {
/// `params` has only keys the params description of engine command `id` names, or the parameter
/// error naming the one it doesn't (#1442). The UI's own commands aren't checked yet: their
/// descriptions haven't been audited against what they read.
/// `file.save` and `file.exportForScreens` take the params of the engine command they run.
pub fn check_params(session: &vectorcraft_engine::Session, id: &str, params: &Value) -> Result<(), String> {
let id = match id {
"file.save" => "document.save",
"file.exportForScreens" => "document.exportForScreens",
id => id,
};
if UI_COMMANDS.iter().any(|c| c.0 == id) {
return Ok(());
}
+8 -3
View File
@@ -25,8 +25,8 @@ use vectorcraft_engine::cmd::fileio::{FORMATS_3D, SCREEN_SETTINGS_KEYS, format_3
use super::{pstate, set_pstate};
use crate::dialogs::{
EXPORT_FOR_SCREENS, ScreenPresetPick, open_export_for_screens_assets, screen_export_preset, screen_formats, screen_import_presets,
screen_preset_list, screen_saved_options, screen_saved_rows,
EXPORT_FOR_SCREENS, ScreenPresetPick, drop_unread_screen_settings, open_export_for_screens_assets, screen_export_preset, screen_formats,
screen_import_presets, screen_preset_list, screen_saved_options, screen_saved_rows,
};
use crate::state::Dialog;
use crate::theme::Tokens;
@@ -240,7 +240,10 @@ fn export_now(app: &mut VectorcraftApp, ids: Vec<u64>, files_each: usize) {
let saved = &st.doc.export_settings;
let mut settings = saved.get("settings").and_then(Value::as_object).cloned().unwrap_or_default();
settings.insert("tiff".into(), Value::Object(screen_saved_options(app, saved, "tiff")));
p["settings"] = Value::Object(settings);
// Without the keys the export no longer reads, which it would refuse.
let mut given = serde_json::Map::from_iter([("settings".to_string(), Value::Object(settings))]);
drop_unread_screen_settings(&mut given);
p["settings"] = given.remove("settings").unwrap_or_default();
}
if io::is_web(app) {
p["zip"] = json!(ids.len() * files_each > 1);
@@ -532,6 +535,7 @@ fn preset_settings(app: &VectorcraftApp, ctx: &egui::Context, doc: u64) -> Value
settings.remove("preset");
}
settings.extend(shared);
drop_unread_screen_settings(&mut settings);
Value::Object(settings)
}
@@ -554,6 +558,7 @@ fn shared_settings(app: &VectorcraftApp) -> Value {
settings.insert("formats".into(), json!(rows));
}
}
drop_unread_screen_settings(&mut settings);
Value::Object(settings)
}
@@ -598,6 +598,29 @@ fn tiff_photometric(bytes: &[u8]) -> u16 {
short(entry + 8)
}
/// Remembered format settings that hold keys the export no longer reads, and settings for a format
/// it doesn't write, are left out of the panel's export, so it is not refused.
#[test]
fn export_leaves_out_remembered_keys_no_longer_read() {
let (mut app, log, [a, _]) = app(true);
app.run("assets.add", json!({"ids": [a], "name": "Badge"})).unwrap();
let serde_json::Value::Object(saved) = json!({
"formats": [{"format": "tiff", "scale": "1x", "noSuchOption": 1}],
"settings": {"png": {"noSuchSetting": true}, "dxf": {}, "tiff": {"lzw": false, "noSuchSetting": 1}}
}) else {
panic!("an object")
};
std::sync::Arc::make_mut(&mut app.session.active_mut().unwrap().doc).export_settings = saved;
let ctx = egui::Context::default();
crate::theme::install_fonts(&ctx);
frame(&ctx, &mut app, vec![], false);
let texts = frame(&ctx, &mut app, vec![], false);
frame(&ctx, &mut app, click(at(&texts, "Export")), false);
let log = log.borrow();
let [(name, _)] = log.as_slice() else { panic!("expected one TIFF: {}", app.ui.status) };
assert_eq!(name, "Badge.tif");
}
#[test]
fn tiff_cmyk_defaults_match_screens_and_preserve_explicit_rgb() {
for rgb_override in [false, true] {
+34
View File
@@ -181,6 +181,40 @@ fn control_export_refuses_an_option_no_format_takes() {
let _ = std::fs::remove_dir_all(base);
}
/// `app.save`, and `file.save` and `file.exportForScreens` run as commands, refuse a key no save
/// format or no format takes before anything is written.
#[test]
fn control_save_refuses_an_option_no_format_takes() {
let (mut app, tx, base) = confined("save-options");
let r = request(&mut app, &tx, "app.open", json!({"path": s(base.join("in/a.svg"))}));
assert_eq!(r["ok"], true, "{r}");
let path = base.join("out/a.pdf");
let refused = |r: &Value| r["error"].as_str().is_some_and(|e| e.contains("unknown parameter"));
for params in [json!({"noSuchOption": true}), json!({"options": {"marks": {"useDocumentBleed": true}}})] {
let mut params = params;
params["path"] = json!(s(path.clone()));
let r = request(&mut app, &tx, "engine.execute", json!({"command": "file.save", "params": params.clone()}));
assert!(refused(&r), "{r}");
assert!(!path.exists());
params["wait"] = json!(true);
let r = request(&mut app, &tx, "app.save", params);
assert!(refused(&r), "{r}");
assert!(!path.exists());
}
let folder = base.join("out/screens");
for params in [json!({"noSuchOption": true}), json!({"formats": [{"format": "png", "noSuchOption": 1}]})] {
let mut params = params;
params["folder"] = json!(s(folder.clone()));
let r = request(&mut app, &tx, "engine.execute", json!({"command": "file.exportForScreens", "params": params}));
assert!(refused(&r), "{r}");
assert!(!folder.exists());
}
let r = request(&mut app, &tx, "app.save", json!({"path": s(path.clone()), "wait": true}));
assert_eq!(r["ok"], true, "{r}");
assert!(path.exists());
let _ = std::fs::remove_dir_all(base);
}
/// An action set another app saved (`.aia`) is read only from inside the read root, whichever way a
/// control request names it: `actionSet.read` and Load Actions….
#[test]
+1 -1
View File
@@ -107,7 +107,7 @@ the two can still win that race.
| `ui.render` | `{path?, scale?, artboard?, data?, traceViews?}` | render an artboard headlessly (PNG with the pixels of `app.export {format: "png", background: "white"}` at the same scale; `artboard`: its 0-based index, default the first; `traceViews: true`: Image Trace objects draw in their View, as on the canvas, instead of their tracing result): written to `path`, else (or with `data: true` as well) sent back as `pngBase64` |
| `ui.render` | `{path?, scale?, artboard?, data?}` | render an artboard headlessly (PNG with the pixels of `app.export {format: "png", background: "white"}` at the same scale; `artboard`: its 0-based index, default the first): written to `path`, else (or with `data: true` as well) sent back as `pngBase64` |
| `ui.resize` / `ui.focus` | | |
| `app.open` / `app.save` / `app.export` / `app.quit` | `{path, importer?, params?}` / `{path?}` / `{path?, format?, artboard?, range?, scale?, …}` | `app.open` reads every format `document.open` reads (see `document.formats`) and returns its result (`{index, title, format, warnings, …}`; `warnings` say what didn't come in as it was, such as an EPS shown as its preview image and why its PostScript couldn't be read), or null when a dialog asks first or a library loads. With `importer` (an importer plug-in's format id, `plugin.<id>`) it runs `file.openWith`: the file opens with that importer, also when a built-in format reads its extension, with the importer's parameters `params` (their defaults when omitted; no dialog asks for them). Without `importer`, `params` go to the importer that opens the file, and a built-in format does not read them. `app.export` encodes through the engine's `document.export` (same options; the document keeps its path) and writes `path` through the host; without `path` it returns `{dataBase64, format, bytes}`, as headless mode does. It refuses a key that no format takes, and an unknown key inside a PDF or EPS option object, before it writes anything (`docs/mcp.md`, Parameter keys). `app.quit`, `file.close` and `file.closeAll` first open a `saveChanges` dialog for each modified document (they return `{"pending": "saveChanges"}`): `ui.dialog.confirm` saves, `ui.dialog.set {field: "discard", value: true}` then confirm discards, `ui.dialog.cancel` cancels the whole close or quit |
| `app.open` / `app.save` / `app.export` / `app.quit` | `{path, importer?, params?}` / `{path?}` / `{path?, format?, artboard?, range?, scale?, …}` | `app.open` reads every format `document.open` reads (see `document.formats`) and returns its result (`{index, title, format, warnings, …}`; `warnings` say what didn't come in as it was, such as an EPS shown as its preview image and why its PostScript couldn't be read), or null when a dialog asks first or a library loads. With `importer` (an importer plug-in's format id, `plugin.<id>`) it runs `file.openWith`: the file opens with that importer, also when a built-in format reads its extension, with the importer's parameters `params` (their defaults when omitted; no dialog asks for them). Without `importer`, `params` go to the importer that opens the file, and a built-in format does not read them. `app.export` encodes through the engine's `document.export` (same options; the document keeps its path) and writes `path` through the host; without `path` it returns `{dataBase64, format, bytes}`, as headless mode does. It refuses a key that no format takes, and an unknown key inside a PDF or EPS option object, before it writes anything (`docs/mcp.md`, Parameter keys). `app.save` refuses a key that no save format takes, beside the path or in `options`, the same way. `app.quit`, `file.close` and `file.closeAll` first open a `saveChanges` dialog for each modified document (they return `{"pending": "saveChanges"}`): `ui.dialog.confirm` saves, `ui.dialog.set {field: "discard", value: true}` then confirm discards, `ui.dialog.cancel` cancels the whole close or quit |
| `app.export` with `useArtboards` | `{path, useArtboards: true, range? \| artboards?, …}` | Export As: PNG, JPEG and WebP write one file per chosen artboard (default all; SVG does so for a `range`), `<path stem>-<artboard>.<ext>`, and return `{path, files: [path…]}`; a PDF keeps them as pages of `path`. `useArtboards: false` covers the bounds of the visible art. The menu's Export As… is `file.exportAs`: the `exportAs` dialog (`format`, `useArtboards`, `all`, `range`), then a save dialog and the format's options: `pngOptions` / `jpgOptions` / `webpOptions`, `svgOptions`, or `savePdf` for a PDF with Use Artboards |
| `ui.dialog.*` on `gradientStop` | `{field: "color" \| "opacity" \| "location", value}` | double-clicking a stop on the Gradient tool's annotator opens its popover (fields `index`, `x`, `y`, `tab`); set `color` (hex), `opacity` or `location` (percentages) and `ui.dialog.confirm` to apply them to the selected stop. `gradient.selectStop {index}` picks the stop the annotator, the panels and Delete/←/→ (`ui.key`) act on |
| `engine.execute` `ui.savePdfDialog` | `{path?, preset?, range?, …document.exportPdf options}` | opens the Save PDF dialog (`file.export.pdf` with no params opens it too). Its fields are the `document.exportPdf` options: set `compatibility` with `ui.dialog.set {field: "compatibility", value: "1.5"}`, a whole section with `{field: "compression", value: {…}}`, the artboards with `__allArtboards: false` and `range`, the visible section with `__section` (General, Compression, Marks and Bleeds, Output, Advanced, Security, Summary). `ui.dialog.confirm` writes `path` (else asks for one), returns `{path, bytes, warnings}` and opens the file when `viewAfterSaving` is set |
+28 -7
View File
@@ -464,7 +464,7 @@ opens the picker through `run_command` with the remote backend.
| `screenshot` | `{path?, scale?, artboard?, window?, max_side?, traceViews?}` | Returns MCP image content (`image/png`, base64) plus a text block. Renders the artboard with the pixels of a PNG `export` with `background: "white"` at the same scale; `traceViews:true` draws Image Trace objects in the view set with `imageTrace.setView`, as in the app's window; `window:true` captures the app window (remote only), as `ui_screenshot` does, and `max_side` (1–4096, window captures only) scales the returned image down as described for `ui_screenshot`. With `path` the backend (the headless session or the app) writes the PNG there too; a window capture written there keeps its full size with `max_side`. |
| `open_file` | `{path, importer?, params?}` | Opens any readable file as a new active document: `.vectorcraft`/`.drawcraft`, `.vctemplate`, `.svg`/`.svgz`, `.pdf`/`.ai`, `.ait`, `.eps`/`.epsf`, `.ps`, `.dxf`, `.emf`, `.wmf`, PNG (`.png`/`.pns`), JPEG, GIF, WebP, TIFF, BMP (`.bmp`/`.dib`/`.rle`), Targa (`.tga`/`.vda`/`.icb`/`.vst`), PCX, Pixar (`.pxr`), JPEG 2000 (`.jpf`/`.jpx`/`.jp2`/`.j2k`/`.j2c`/`.jpc`), HEIC/HEIF (`.heic`/`.heif`), JPEG XL (`.jxl`), PSD (`.psd`/`.pdd`)/PSB (an image opens as a document of its pixel size; a Photoshop file as its merged image; a JPEG XL file as its first frame). Templates (`.vctemplate`, `.ait`) open as a new untitled document. PDF, `.ai` and SVG files saved with Preserve Editing reopen as the document they carry. The reply is `document.open`'s: its `warnings` say what didn't come in as it was (an EPS whose PostScript can't be read opens as its preview, and the warning names the PostScript error, the operator and the procedure). A file of an extension only an installed importer plug-in lists opens with that importer ([Plug-ins](#plug-ins)); `importer: "plugin.<id>"` opens the file with the importer it names, which must list the file's extension, also one a built-in format reads (in the app, as File › Open With does), and `params` gives that importer's parameters (their defaults when omitted). `run_command document.formats` lists the formats. |
| `save_file` | `{path?, compress?, version?, preview?, precision?, options?}` | Runs `document.save`: the document's own file in its own format (native `.vectorcraft` unless it was opened from or saved as SVG, PDF or a restorable `.ai`; then `warnings` say what that format loses). A path's extension picks the format (`.vectorcraft`, `.vctemplate`, `.pdf`, `.svg`, `.svgz`, `.ai`: a PDF carrying the native document, which reopens editable). For a native file, `compress: true` gzips it (default: the `useCompression` preference, Preferences › File Handling › Use Compression), `version` (1 to 4) writes it for an older VectorCraft (1 and 2 are never compressed, and `version: 2` with `compress: true` is an error), `preview: true` embeds a PNG of the first artboard, and `precision` (0 to 9) rounds path anchors and handles to that many decimal places for that save only (colors, transforms and type stay exact). `options` passes the format's save options as `run_command document.formats` lists them. When `compress`, `version`, `preview` or `precision` is given both at the top level and in `options` with different values, the call is an error (`` `compress: true` and `options.compress: false` disagree: give the option once ``) and nothing is written. The save drops each option the written format doesn't read. The reply's `warnings` then name each such option given (for example `` `compress` was not used: SVG files don't read it `` for an `.svg` path, and `` `options.preview` was not used: … `` for a key in `options`; a `.ai` file reads `compress` but not `version`, `preview` or `precision`). `run_command document.save` and the other save commands refuse any option given beside the path and in `options` with different values, and give the same warning for a top-level `compress`, `version`, `preview` or `precision`. The headless session and the app receive the same `app.save` params, and the app is also sent `wait: true`: the call returns once the file is written, or with the error (such as a path outside the write root), even with Background Save on. When the save asks first (the question before replacing a file that opened with losses, or on macOS and Linux the file dialog for an untitled document without `path`), the reply is `{pending}` or `"cancelled"` as before, and the save that runs after the answer uses the Background Save preference. |
| `save_file` | `{path?, compress?, version?, preview?, precision?, options?}` | Runs `document.save`: the document's own file in its own format (native `.vectorcraft` unless it was opened from or saved as SVG, PDF or a restorable `.ai`; then `warnings` say what that format loses). A path's extension picks the format (`.vectorcraft`, `.vctemplate`, `.pdf`, `.svg`, `.svgz`, `.ai`: a PDF carrying the native document, which reopens editable). For a native file, `compress: true` gzips it (default: the `useCompression` preference, Preferences › File Handling › Use Compression), `version` (1 to 4) writes it for an older VectorCraft (1 and 2 are never compressed, and `version: 2` with `compress: true` is an error), `preview: true` embeds a PNG of the first artboard, and `precision` (0 to 9) rounds path anchors and handles to that many decimal places for that save only (colors, transforms and type stay exact). `options` passes the format's save options as `run_command document.formats` lists them. When `compress`, `version`, `preview` or `precision` is given both at the top level and in `options` with different values, the call is an error (`` `compress: true` and `options.compress: false` disagree: give the option once ``) and nothing is written. The save drops each option the written format doesn't read. The reply's `warnings` then name each such option given (for example `` `compress` was not used: SVG files don't read it `` for an `.svg` path, and `` `options.preview` was not used: … `` for a key in `options`; a `.ai` file reads `compress` but not `version`, `preview` or `precision`). For a `.ai` file, these warnings name the kind of `.ai` written: a legacy .ai (version 7) for `aiVersion: "7"`, a non-PDF-compatible .ai for `pdfCompatible: false`, and a PDF-compatible .ai otherwise, including a save that reuses the options the document was last saved with. `run_command document.save` and the other save commands refuse any option given beside the path and in `options` with different values, and give the same warnings for a top-level `compress`, `version`, `preview` or `precision` and for each key in `options` that the format doesn't read. The headless session and the app receive the same `app.save` params, and the app is also sent `wait: true`: the call returns once the file is written, or with the error (such as a path outside the write root), even with Background Save on. When the save asks first (the question before replacing a file that opened with losses, or on macOS and Linux the file dialog for an untitled document without `path`), the reply is `{pending}` or `"cancelled"` as before, and the save that runs after the answer uses the Background Save preference. |
| `save_file` | `{path?, compress?, version?, preview?, precision?, options?}` | Runs `document.save`: the document's own file in its own format (native `.vectorcraft` unless it was opened from or saved as SVG, PDF or a restorable `.ai`; then `warnings` say what that format loses). A path's extension picks the format (`.vectorcraft`, `.vctemplate`, `.pdf`, `.svg`, `.svgz`, `.ai`: a PDF carrying the native document, which reopens editable). For a native file, `compress: true` gzips it (default: the `useCompression` preference, Preferences › File Handling › Use Compression), `version` (1 to 4) writes it for an older VectorCraft (1 and 2 are never compressed, and `version: 2` with `compress: true` is an error), `preview: true` embeds a PNG of the first artboard, and `precision` (0 to 9) rounds path anchors and handles to that many decimal places for that save only (colors, transforms and type stay exact). `options` passes the format's save options as `run_command document.formats` lists them. When `compress`, `version`, `preview` or `precision` is given both at the top level and in `options` with different values, the call is an error (`` `compress: true` and `options.compress: false` disagree: give the option once ``) and nothing is written. A key in `options` that no save format takes is an error, and nothing is written (see Parameter keys). The save drops each option the written format doesn't read. The reply's `warnings` then name each such option given (for example `` `compress` was not used: SVG files don't read it `` for an `.svg` path, and `` `options.preview` was not used: … `` for a key in `options`; a `.ai` file reads `compress` but not `version`, `preview` or `precision`). For a `.ai` file, these warnings name the kind of `.ai` written: a legacy .ai (version 7) for `aiVersion: "7"`, a non-PDF-compatible .ai for `pdfCompatible: false`, and a PDF-compatible .ai otherwise, including a save that reuses the options the document was last saved with. `run_command document.save` and the other save commands refuse any option given beside the path and in `options` with different values, and give the same warnings for a top-level `compress`, `version`, `preview` or `precision` and for each key in `options` that the format doesn't read. The headless session and the app receive the same `app.save` params, and the app is also sent `wait: true`: the call returns once the file is written, or with the error (such as a path outside the write root), even with Background Save on. When the save asks first (the question before replacing a file that opened with losses, or on macOS and Linux the file dialog for an untitled document without `path`), the reply is `{pending}` or `"cancelled"` as before, and the save that runs after the answer uses the Background Save preference. |
| `export` | `{path?, format?, scale?, artboard?, range?, selection?, outlineText?, options?}` | `svg`, `svgz`, `pdf`, `ai` (the PDF-compatible `.ai` that Save writes, which carries the document when every artboard is written in order), `eps`, `dxf`, `emf`, `wmf`, `png`, `jpg`, `webp`, `gif`, `png8` (an indexed `.png`), `tiff`, `bmp`, `tga`, `psd` (layered), `txt` (the document's text), `vectorcraft` or `template` (a native template). The tool's `format` enum and `document.formats` list them, generated from the engine's format table. When `format` is omitted, it comes from the path's extension. PDF and `.ai` write one page per artboard: all of them, or `artboard` (0-based) / `range` (`"1-3, 5"`, 1-based). SVG and SVGZ write one file per chosen artboard. EPS writes one file of the visible art; with `artboard`, `range`, `options.artboards` or `options: {"useArtboards": true}` it writes one file per chosen artboard, after a central file of the visible art when there are two or more, and `useArtboards: false` keeps the one file. Rasters, DXF, EMF and WMF write one artboard, or one file per chosen artboard with `options: {"useArtboards": true}`. Rasters also take `options: {"rect": [x, y, width, height]}` (page points, y down): that area of the page at the given `ppi`/`scale`, without an artboard and without changing the document; it is refused with `artboard`, `artboards`, `range` or `useArtboards`, with a non-positive or non-finite size, and for non-raster formats. `options` carries more format options (e.g. `{"quality": 80}` for JPEG). `selection: true` exports the selected objects cropped to their bounds (the reply adds their `bounds`, and reports `format` and the encoder's `warnings` as a whole-document export does); `outlineText: true` writes SVG text as paths. Template layers are left out, live effects are kept, and exporting `vectorcraft` never changes the document's path. Without `path` the bytes come back as `dataBase64`. Both backends run the same `document.export` call. |
| `add_text` | `{text, x?, y?, width?, height?, fit?, fitMinPercent?, path?, mode?, pathEffect?, size?, font?, color?, justify?, vertical?}` | Point type at (x, y) (`justify`: `left`, `center` or `right` starts, centres or ends each line on x); area type with `width`/`height` (`fit`: `none`, `autoHeight`, `shrinkText`, as `text.areaOptions`); or `path` + `mode` (`area`/`onPath`) to flow text in or along a path, with `pathEffect` (`rainbow`, `skew`, `3dRibbon`, `stairStep`, `gravity`; SVG export writes type on a path with any effect other than `rainbow` as outlines, with a warning); `justify` (also `auto`, `justifyAll`) aligns the paragraphs of every kind, as `text.setStyle` does later. `vertical: true` uses the existing vertical writing mode for every kind (default `false`, horizontal). Returns `{id}`, with `overflow: true` when the text doesn't fit its frame or path. |
| `apply_effect` | `{effect?, params?, ids?}` | Appends a live effect. Without `effect`, returns the effect catalogue with parameters and defaults. |
@@ -1172,7 +1172,7 @@ refused before the command runs:
→ invalid parameters for `file.new`: unknown parameter `widht`; did you mean `width`?
```
Only the top level is checked, except for the export commands below: the keys of an object inside a value
Only the top level is checked, except for the export and save commands below: the keys of an object inside a value
(`artboardLayout: {…}`) are the command's to read. Another exception is an effect's `params`: `effect.apply` (and `appearance.addEffect`) and `effect.setParams`
check them against the effect's own entry in `effect.list`, its `defaults` and the keys its `params` text names. A key
the effect doesn't take, or a value of another type than its default (a string for a number, a number for a switch),
@@ -1211,8 +1211,8 @@ A number outside the range the effect documents is still stored as given and cla
plug-in effect's parameters are resolved against its manifest instead. `effect.setParams` as a step of
`command.batch` is checked when the step runs, against the effects the steps before it added, and the batch then
fails with no change. A description with a `…` entry takes more keys than it lists: those of the command it names
(`…settings as file.new`), or, except for the export commands below, any key when it names none, as `document.save` does for the options
of the format they write (one object can carry several formats' options). A command that takes its own reply back
(`…settings as file.new`), or, except for the export and save commands below, any key when it names none (one
object can carry several formats' options). A command that takes its own reply back
(`document.setup`, `text.areaOptions`) says so the same way.
Saved actions aren't checked this way. When the Actions panel plays an action (Play at any speed, an action's function
@@ -1225,15 +1225,36 @@ and `file.export` refuse a key that no format takes, and ignore a key that only
(`{"format": "pdf", "quality": 80}` writes the PDF). `document.exportPdf`, `document.pdfSettings` and `pdf.preset.save`
refuse a key that PDF doesn't take, `document.exportEps` one that EPS doesn't take and `document.exportDxf` one that DXF
doesn't take. Each of them refuses an unknown key inside an option object it takes (`marks`, `bleed`, `compression`,
`output`, `advanced`, `security`, `flattener`), naming its path:
`output`, `advanced`, `security`, `flattener`, `svg`), naming its path:
```json
{"id": "document.export", "params": {"format": "pdf", "marks": {"useDocumentBleed": true}}}
→ invalid parameters for `document.export`: unknown parameter `marks.useDocumentBleed`; `marks` takes: …
```
A saved PDF preset that holds a key that is no longer read keeps loading. `document.save`, `file.saveAs`, `file.saveCopy`
and `document.exportForScreens` don't apply this check.
The save commands limit them the same way. `document.save`, `file.saveAs`, `file.saveCopy`, `file.saveAsTemplate`,
`app.save` and `file.save` refuse a key that no save format takes, given beside the path or in `options`, and an
unknown key inside a PDF option object or the `svg` object, also when the file is not SVG. A save still leaves out a key
that only another save format takes. It warns when that key is given in `options` or is one of `compress`, `version`,
`preview` and `precision`:
```json
{"id": "document.save", "params": {"path": "a.pdf", "options": {"noSuchOption": true}}}
→ invalid parameters for `document.save`: unknown parameter `options.noSuchOption`; no save format takes it (see file.formatOptions)
```
`document.exportForScreens` and `file.exportForScreens` refuse a key the command doesn't take. In each row of `formats`
and in the options `settings` gives a format, they refuse a key that no format takes, as `document.export` does, and an
unknown key inside a PDF, EPS or SVG option object; `settings` takes only the formats the command writes. A row and a
format's settings also take `includeArtboardFill`, `richBlack`, `params` (an exporter plug-in's own) and
`security.passwordRequired` (which remembered settings hold where PDF passwords were left out), and `settings`
takes an exporter plug-in's format. The error names the row (`formats[1].marks.useDocumentBleed`).
`document.exportPresets.save` checks its `settings`, with the rows and settings in it, the same way, and `assets.export`
and `assets.settings.set` check the rows and settings they are given.
A saved PDF preset or Export for Screens preset that holds a key that is no longer read keeps loading. The Export for
Screens dialog opens remembered settings and presets without such keys, and the Asset Export panel leaves them out of
the settings it sends, so their export is not refused.
An unknown command id is matched the same way as an unknown parameter key: the error names the nearest command id when
one is near, ignoring case. A step of `command.batch` or `action.batch` gets the same suggestion in its step error:
+1
View File
@@ -745,6 +745,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
- The errors of the blend spine commands (Move Spine Point, Reshape Spine Segment, Add Spine Point and Delete Spine Point) show in the status bar in the interface language: each has a row in every catalog that translates all status and error messages and in Bulgarian (`blend_spine_param_errors_are_translated`).
- The errors of the parameter checks that many commands share show in the status bar in the interface language: a value that is not a number where a number goes (`object.move` with `"dx": "60 mm"`, and `object.rotate`, `object.setBounds` and the other commands that call `check_numbers`), a missing number or index, `ids` that are not a list of object ids, an object id that is not a non-negative integer or names no object, and a date that is not Unix seconds. Each has a row in every catalog that translates all status and error messages, and in Bulgarian, Danish, Indonesian and Dutch (`shared_param_check_errors_are_translated`).
- Export commands run through MCP, the control channel or the CLI refuse a key that no format takes, and a command that writes one format (`document.exportPdf`, `document.exportEps`, `document.exportDxf`) or takes one format's options (`document.pdfSettings`, `pdf.preset.save`) refuses a key that format doesn't take. An unknown key inside `marks`, `bleed`, `compression`, `output`, `advanced`, `security` or `flattener` is refused by its path.
- Save commands run through MCP, the control channel or the CLI (`document.save`, `file.saveAs`, `file.saveCopy`, `file.saveAsTemplate`, `app.save`) refuse a key that no save format takes, beside the path or in `options`. Export for Screens refuses a key it doesn't take, and a key that no format takes in a format row or in a format's settings, by its path (`formats[1].marks.useDocumentBleed`); Export Assets (`assets.export`, `assets.settings.set`) checks the rows and settings it is given the same way.
- Commands that read their `ids` (or `slices`) list themselves refuse a list with an entry that is not a non-negative integer, and a value that is not a list, and change nothing; they no longer drop the entry and act on the rest (Copy Appearance From, the Edit Colors commands and Overprint Black, Find and Replace, `layer.highlight`, `layer.move`, `library.add`, the Links panel commands, `object.transform`, Make Pattern, the Repeat commands, the slice commands, Join, Average and Join, `plugin.run`, `symbol.edit`, Make Opacity Mask; `tests::ids_without_selection::an_ids_entry_that_is_not_an_id_is_refused_and_changes_nothing`). The error for an entry of an object id list names the key, the entry and its value ("`ids` entry 1 is true, not a non-negative integer object id"), with a row in every catalog that has the other object id errors.
- The errors of Enter Isolation Mode (`object.isolate`) for a type object, for hidden or locked art and for a call with no `id` while nothing is selected ("missing id", which other commands that take an `id` also give), and the error of the Layers panel's target circle (`layer.target`) for an object outside the isolated group, show in the status bar in the interface language: each has a row in every catalog that translates all status and error messages, and in Bulgarian, Danish, Indonesian and Dutch.
- An EPS or PostScript file that stops on a PostScript error gives the error's name as PostScript spells it (`typecheck`, `rangecheck` and the other names the reader raises, except `undefined`, whose message names the unknown name) followed by what it means, in the warning after opening and in the error when the file can't be opened. The status bar and the open-error dialog show the meaning in the interface language: each meaning has a row in every catalog (`postscript_error_meanings_are_translated`).