Save, Export for Screens: warnings name an option the file doesn't read

The save commands' warnings now follow an export's .ai rules: a legacy
.ai file (aiVersion "7") reads only its own options, and the other .ai
and .ait files don't read outlineText, hiddenObjects, transparentImages
or preserveEditing. They name any option given beside the path that the
file doesn't read, not only compress, version, preview and precision,
and any such key in options. The bytes a save writes don't change.

The Save Options dialog sends a .ai save only the options the chosen
version reads, so a save from the dialog gets none of these warnings.
When the document's own file is already a .ai, the .ai options that a
save doesn't give and its file doesn't read keep their remembered
values, so the dialog still shows them; the save doesn't write them, and
a retry in another folder doesn't send them.

Export for Screens and Export Assets already accepted a key in a format
row or in a format's settings that only another format takes. Their
warnings now name it as given (formats[0].quality, settings.png.quality).
A key in a format's settings is named only when no row of that format,
as the export writes it, reads it. The options of an exportPreset, and
for assets.export the settings the document keeps, get no warning.

Docs: params text of document.save, document.exportForScreens and
assets.export; docs/mcp.md; docs/target-app-parity.md.
This commit is contained in:
dotCooCoo
2026-11-13 15:34:25 -08:00
parent c9b2119552
commit 3429bbc76a
16 changed files with 452 additions and 50 deletions
+2 -2
View File
@@ -53,7 +53,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Export Assets",
[],
None,
"{assets?: [asset id…] (default all but the 3D assets, which assets.export3D writes), 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)",
"{assets?: [asset id…] (default all but the 3D assets, which assets.export3D writes), 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; warnings name a key in the rows or settings given that only another format takes, not one in the remembered settings). Leaves the remembered settings as they are (assets.settings.set changes them)",
has_doc,
export
),
@@ -429,7 +429,7 @@ fn export(s: &mut Session, p: &Value) -> Result<Value> {
}
q.insert("assets".into(), json!(all));
}
export_screens(s, &Value::Object(q)).map_err(|e| of_cmd(C, e))
export_screens(s, &Value::Object(q), p).map_err(|e| of_cmd(C, e))
}
fn settings_set(s: &mut Session, p: &Value) -> Result<Value> {
+1 -1
View File
@@ -92,7 +92,7 @@ pub use pdfimport::{LoadOptions, page_document};
pub(crate) use save::job_for;
pub(crate) use save::save as save_document;
pub use save::{
SAVE_FORMATS, SaveJob, SaveMode, SavePlan, StreamWrite, TEMPLATE_FORMATS, dialog_folder, documents_dialog_folder, export_folder,
SAVE_FORMATS, SaveJob, SaveMode, SavePlan, StreamWrite, TEMPLATE_FORMATS, dialog_folder, documents_dialog_folder, export_folder, file_reads,
leave_out_unreadable_save_paths, reads_option as save_reads_option, remembered_save_options, save_filters, save_format, save_job, save_plan,
save_with, stamp_save_dates, template_filters, templates_dialog_folder, templates_folder,
};
+1 -1
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}), 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…]} (into the folder of exportPreset, outside the automation read roots: no ZIP path, and files relative to that folder); 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",
"{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…]} (into the folder of exportPreset, outside the automation read roots: no ZIP path, and files relative to that folder); 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; a key only another format takes in a row or in settings is not used, and warnings name it as given (formats[0].quality; a key in settings only when no row of that format reads it; not a key of an exportPreset)",
has_doc,
screens::export_for_screens
),
+13 -14
View File
@@ -31,26 +31,25 @@ pub fn unused_option_notes(cmd: &str, f: &Format, p: &Value) -> Vec<String> {
}
let listed = |k: &str| super::FORMATS.iter().filter(|f| f.write).flat_map(|f| f.options).any(|o| o.name == k);
let written = super::save::written_as(cmd, f, p);
let legacy = f.id == "ai" && super::legacy_ai::wanted(cmd, f, p).unwrap_or(false);
let reads = |k: &str| match (f.id, k) {
// A legacy `.ai` file is not a PDF: it reads its own options only.
("ai", _) if legacy => super::legacy_ai::reads(k),
// The other `.ai` files don't read the legacy file's options, and `preserveEditing` is
// on for them whatever is given.
("ai", "preserveEditing") => false,
("ai", _) if super::legacy_ai::LEGACY_ONLY.contains(&k) => false,
// An export writes one file: only a save writes each artboard to a file of its own, and
// a native file's `range` names those files.
("vectorcraft" | "template" | "ai", "separateArtboards") | ("vectorcraft" | "template", "range") => false,
_ => super::save::reads_option(f, k),
};
let legacy = super::save::legacy_file(cmd, f, p);
given
.iter()
.filter(|(k, v)| !v.is_null() && !EXPORT_PARAMS.contains(&k.as_str()) && listed(k) && !reads(k))
.filter(|(k, v)| !v.is_null() && !EXPORT_PARAMS.contains(&k.as_str()) && listed(k) && !export_reads(f, legacy, k))
.map(|(k, _)| super::save::unused_option_note(k, &written))
.collect()
}
/// Does the file an export writes in `f` read option `key`, `legacy` when it is a legacy `.ai`
/// file? As a save's file does ([`super::save::file_reads`]), except that an export writes one
/// file: only a save writes each artboard to a file of its own, and a native file's `range` names
/// those files.
pub(super) fn export_reads(f: &Format, legacy: bool, key: &str) -> bool {
match (f.id, key) {
("vectorcraft" | "template" | "ai", "separateArtboards") | ("vectorcraft" | "template", "range") => false,
_ => super::save::file_reads(f, legacy, key),
}
}
pub(super) fn serialize(s: &mut Session, p: &Value) -> Result<Value> {
let f = writable("document.serialize", Some(text_param(p, "format", "document.serialize")?.unwrap_or("vectorcraft")), None)?;
let unused = unused_option_notes("document.serialize", f, p);
+69 -10
View File
@@ -95,14 +95,18 @@ pub struct SavePlan {
/// The `modified` param (as `date_param` reads it): the File Info date a Save or Save As to a file
/// stamps; `None`: the time it is written.
pub modified: Option<Option<i64>>,
/// Warnings about the params themselves: each native option (`compress`, `version`,
/// `preview`, `precision`) given beside the path, and each key of `options`, that the format
/// doesn't read, naming the file the save writes.
/// Warnings about the params themselves: each option a save format takes given beside the
/// path, and each key of `options`, that the file the save writes doesn't read
/// ([`file_reads`]), naming that file.
pub notes: Vec<String>,
/// The file the document was read from and what reading it left out, when the save needs
/// `acknowledgeLoss` to write over it and was not given it: every file the save writes is
/// checked against it before the first is written.
pub lossy_source: Option<(String, Vec<String>)>,
/// The `.ai` options the file doesn't read, with the values the document remembers from its
/// last `.ai` save: not in `options`, they go back into the remembered options once the file
/// is the document's own.
pub kept: Map<String, Value>,
}
impl SavePlan {
@@ -279,7 +283,8 @@ pub fn save_plan(s: &Session, mode: SaveMode, p: &Value) -> Result<SavePlan> {
}
}
// None given: the ones the document was last saved with in this format.
let mut options = if given.is_empty() && format.id == st.format { remembered_save_options(st) } else { given };
let from_memory = given.is_empty() && format.id == st.format;
let mut options = if from_memory { remembered_save_options(st) } else { given };
// Remember the effective choice, including the encoder's default, so another document's
// later save cannot change what this one writes. Invalid non-null values still reach validation.
if matches!(format.id, "vectorcraft" | "template") {
@@ -311,21 +316,54 @@ pub fn save_plan(s: &Session, mode: SaveMode, p: &Value) -> Result<SavePlan> {
let given = str_param(p, "path").is_some_and(|g| !g.is_empty());
super::check_not_lossy_overwrite(st, path, p, cmd).map_err(|e| if given { e } else { super::access::naming_in_error(e, path) })?;
}
let written = written_as(cmd, format, &Value::Object(options.clone()));
let top = NATIVE_OPTIONS.iter().filter(|k| p.get(**k).is_some_and(|v| !v.is_null())).map(|k| k.to_string());
let chosen = Value::Object(options.clone());
let written = written_as(cmd, format, &chosen);
let legacy = legacy_file(cmd, format, &chosen);
// Beside the path, a key some save format takes; in `options`, any key.
let top = p.as_object().into_iter().flatten().filter(|(k, v)| !v.is_null() && save_listed(k)).map(|(k, _)| k.to_string());
let nested = p.get("options").and_then(Value::as_object).into_iter().flatten().filter(|(_, v)| !v.is_null()).map(|(k, _)| k);
let notes = top
.filter(|k| !reads_option(format, k))
.chain(nested.filter(|k| !reads_option(format, k)).map(|k| format!("options.{k}")))
.filter(|k| !file_reads(format, legacy, k))
.chain(nested.filter(|k| !file_reads(format, legacy, k)).map(|k| format!("options.{k}")))
.map(|k| unused_option_note(&k, &written))
.collect();
// When the document's own file is a `.ai`, the `.ai` options this file doesn't read and the
// request doesn't give keep their remembered values ([`SavePlan::kept`]), so that Save Options
// still shows them (it sends only the ones the file reads). They are neither written nor sent
// again.
let mut kept = Map::new();
if format.id == "ai" && format.id == st.format {
let last = remembered_save_options(st);
for o in format.options.iter().filter(|o| !file_reads(format, legacy, o.name)) {
let value = if from_memory {
options.remove(o.name)
} else if options.contains_key(o.name) {
None
} else {
last.get(o.name).cloned()
};
if let Some(v) = value {
kept.insert(o.name.to_string(), v);
}
}
}
let lossy_source = super::lossy_source(st, p);
Ok(SavePlan { mode, path, format, options, name, folder, modified, notes, lossy_source })
Ok(SavePlan { mode, path, format, options, name, folder, modified, notes, lossy_source, kept })
}
/// The native save options, which a save also takes beside the path.
const NATIVE_OPTIONS: [&str; 4] = ["compress", "version", "preview", "precision"];
/// Does some save format (or template format) take option `key`?
fn save_listed(key: &str) -> bool {
NATIVE_OPTIONS.contains(&key)
|| super::FORMATS
.iter()
.filter(|f| SAVE_FORMATS.contains(&f.id) || TEMPLATE_FORMATS.contains(&f.id))
.flat_map(|f| f.options)
.any(|o| o.name == key)
}
/// The first option `p` gives both beside the path and in `options` with different values, as
/// `(key, top-level value, options value)`. For an SVG save (`svg`), an SVG option's value on
/// either side is the one its `svg: {…}` object gives, else the flat one.
@@ -467,6 +505,26 @@ pub fn reads_option(f: &Format, key: &str) -> bool {
f.options.iter().any(|o| o.name == key) || (is_pdf(f) && (super::pdf::OPTIONS.iter().any(|o| o.name == key) || super::pdf::is_setting(key)))
}
/// Does the file a save or an export writes in `f` read option `key`, `legacy` when it is a legacy
/// `.ai` file ([`super::legacy_ai::wanted`])? As [`reads_option`], except for `.ai` and `.ait`
/// files: a legacy file reads its own options only ([`super::legacy_ai::reads`]), and the others
/// don't read the legacy file's options or `preserveEditing`, which is on for them whatever is
/// given.
pub fn file_reads(f: &Format, legacy: bool, key: &str) -> bool {
match (f.id, key) {
("ai", _) if legacy => super::legacy_ai::reads(key),
("ai" | "ait", "preserveEditing") => false,
("ai" | "ait", _) if super::legacy_ai::LEGACY_ONLY.contains(&key) => false,
_ => reads_option(f, key),
}
}
/// Is a save or an export of `cmd` in `f` with options `p` to write a legacy `.ai` file? An
/// unknown version is not one (the save or export refuses it).
pub(super) fn legacy_file(cmd: &str, f: &Format, p: &Value) -> bool {
f.id == "ai" && super::legacy_ai::wanted(cmd, f, p).unwrap_or(false)
}
/// The note a save or an export gives for option `key`, as the request names it, which the file it
/// writes (`written`, see [`written_as`]) doesn't read.
pub(super) fn unused_option_note(key: &str, written: &Format) -> String {
@@ -1036,6 +1094,7 @@ impl SaveJob {
st.path = Some(path);
st.format = self.plan.format.id;
st.save_options = self.plan.options;
st.save_options.extend(self.plan.kept);
// A later save rounds only when it gives `precision` too.
st.save_options.remove("precision");
st.save_artboards = self.snapshot.artboards.iter().map(|a| a.id).collect();
@@ -1238,7 +1297,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 and EPS (below): a .ai file is 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 takes the options of document.exportEps: by default one file whose bounding box fits the visible art (the first artboard when no art shows), carrying the whole editable document; useArtboards: true (also when artboard, artboards or range names artboards; file.formatOptions then reports it true) makes the chosen artboard the page, drawn with its background color (without Use Artboards, only the first artboard's page when no art shows has its color), and with two or more chosen the file at the path is the central file of the visible art (again the first artboard when none shows) and each artboard is also written to <name>_<artboard>.eps beside it, holding that artboard; selectedOnly is refused; 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. Under automation read roots, a reply leaves out the document's own file outside them when no path is given (files and linked then name the files by their file names), and a suggested folder outside them. 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. A terminal filesystem permission/read-only refusal returns READ_ONLY_LOCATION: followed by the reason; recovery-copy failures keep their original error. Non-UI callers never open a save panel",
"{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 name an option given flat or in options that the file doesn't read: a legacy .ai reads only its own options, and other .ai files don't read outlineText, hiddenObjects, transparentImages or preserveEditing), 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 and EPS (below): a .ai file is 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 takes the options of document.exportEps: by default one file whose bounding box fits the visible art (the first artboard when no art shows), carrying the whole editable document; useArtboards: true (also when artboard, artboards or range names artboards; file.formatOptions then reports it true) makes the chosen artboard the page, drawn with its background color (without Use Artboards, only the first artboard's page when no art shows has its color), and with two or more chosen the file at the path is the central file of the visible art (again the first artboard when none shows) and each artboard is also written to <name>_<artboard>.eps beside it, holding that artboard; selectedOnly is refused; 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. Under automation read roots, a reply leaves out the document's own file outside them when no path is given (files and linked then name the files by their file names), and a suggested folder outside them. 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. A terminal filesystem permission/read-only refusal returns READ_ONLY_LOCATION: followed by the reason; recovery-copy failures keep their original error. Non-UI callers never open a save panel",
has_doc,
|s, p| save(s, SaveMode::Save, p)
),
+87 -7
View File
@@ -18,7 +18,7 @@ use vectorcraft_geom::Rect;
use super::super::{EngineError, NodeId, Result, Session, bad, bool_or, str_param};
use super::encode::{add_note, single_artboard};
use super::export::{export_crop, isolated, selected_artwork, without_artboards};
use super::export::{export_crop, export_reads, isolated, selected_artwork, without_artboards};
use super::naming::{self, NameParts};
use super::{
ARTBOARD_PARAMS, ArtboardPick, Format, Note, artboard_file_names_keeping_dots, create_dir, distinct_names_by, encode_with_notes, merge,
@@ -524,6 +524,77 @@ fn rows_of(s: &Session, p: &Value, preset: Option<&str>) -> Result<Vec<Row>> {
Ok(rows)
}
/// A note for each option of a format row (`formats`) or of a format's `settings` in the request
/// `asked` that some format takes but the file the row writes, or every row of that format writes,
/// doesn't read (`{format: png, quality: 80}`), in the wording of a save's note and named as the
/// request names it (`formats[0].quality`, `settings.png.quality`). The rows and settings the
/// export writes with are those of `p` (`asked` with a preset or the remembered settings laid
/// under it). A row's own keys (`scale`, `suffix`…), the keys the export reads for every format
/// (`includeArtboardFill`, `richBlack`, `params`) and an artboard choice (the export picks the
/// artboards) get none, and so does an exporter plug-in's format.
pub(crate) fn unused_option_notes(asked: &Value, p: &Value) -> Vec<String> {
let listed = |k: &str| super::FORMATS.iter().filter(|f| f.write).flat_map(|f| f.options).any(|o| o.name == k);
let own = |k: &str| {
ROW_KEYS.contains(&k) || ARTBOARD_PARAMS.contains(&k) || matches!(k, "useArtboards" | "includeArtboardFill" | "richBlack" | "params")
};
// The keys of `given` that `f`, writing with `options` (the format's settings and the row's
// own), doesn't read.
let unread = |f: &Format, given: &Map<String, Value>, options: &Value| -> Vec<String> {
if vectorcraft_plugins::export::by_format(f.id).is_some() {
return Vec::new();
}
let legacy = super::save::legacy_file(C, f, options);
given.iter().filter(|(k, v)| !v.is_null() && !own(k) && listed(k) && !export_reads(f, legacy, k)).map(|(k, _)| k.clone()).collect()
};
let note = |key: String, f: &Format, options: &Value| super::save::unused_option_note(&key, &super::save::written_as(C, f, options));
let settings = p.get("settings").and_then(Value::as_object);
let of_format = |id: &str| settings.and_then(|s| s.get(id)).and_then(Value::as_object).cloned().unwrap_or_default();
// A row's format and the options it writes with.
let row_of = |row: &Value| -> Option<(&'static Format, Value)> {
let (Some(row), Ok(f)) = (row.as_object(), writable(C, Some(str_param(row, "format").unwrap_or("png")), None)) else { return None };
let mut options = of_format(f.id);
options.extend(row.iter().map(|(k, v)| (k.clone(), v.clone())));
Some((f, Value::Object(options)))
};
let mut out: Vec<String> = Vec::new();
if let Some(Value::Array(rows)) = asked.get("formats") {
for (i, row) in rows.iter().enumerate() {
let (Some((f, options)), Some(given)) = (row_of(row), row.as_object()) else { continue };
out.extend(unread(f, given, &options).into_iter().map(|k| note(format!("formats[{i}].{k}"), f, &options)));
}
}
// The rows written, each with its own keys: a format's settings are read as each of its rows
// writes them (a row may name another `.ai` version), else as they are.
let written: Vec<(&Format, Value, Map<String, Value>)> = match p.get("formats") {
Some(Value::Array(rows)) => {
rows.iter().filter_map(|row| row_of(row).map(|(f, options)| (f, options, row.as_object().cloned().unwrap_or_default()))).collect()
}
_ => Vec::new(),
};
for (id, given) in asked.get("settings").and_then(Value::as_object).into_iter().flatten() {
let (Some(given), Ok(f)) = (given.as_object(), writable(C, Some(id), None)) else { continue };
let mut rows: Vec<(&Value, Option<&Map<String, Value>>)> =
written.iter().filter(|(g, ..)| g.id == f.id).map(|(_, o, row)| (o, Some(row))).collect();
let alone = Value::Object(of_format(f.id));
if rows.is_empty() {
rows.push((&alone, None));
}
let unread_by: Vec<Vec<String>> = rows.iter().map(|(o, _)| unread(f, given, o)).collect();
// A setting a row reads and doesn't give itself is used. One no row uses is noted when a
// row doesn't read it, naming the first such row's file.
for (k, v) in given {
// A row's own key, null too, replaces the setting.
let overridden = |row: Option<&Map<String, Value>>| row.is_some_and(|r| r.contains_key(k));
let used = rows.iter().zip(&unread_by).any(|((_, row), u)| !u.contains(k) && !overridden(*row));
let unread_row = rows.iter().zip(&unread_by).find(|(_, u)| u.contains(k));
if let (false, false, Some(((options, _), _))) = (v.is_null(), used, unread_row) {
out.push(note(format!("settings.{id}.{k}"), f, options));
}
}
}
out
}
/// Check the export settings in `p` (formats, preset, settings…) as an export would read them.
pub(crate) fn check_settings(s: &Session, p: &Value) -> Result<()> {
rows_of(s, p, ScreenParams::of(p)?.preset.as_deref()).map(|_| ())
@@ -646,9 +717,10 @@ impl Output {
pub(super) fn export_for_screens(s: &mut Session, p: &Value) -> Result<Value> {
// A folder the client gave, as against the one its `exportPreset` keeps.
let given = p.get("folder").is_some();
let asked = p;
let p = with_preset(s, p)?;
let hidden = if given { None } else { str_param(&p, "folder").filter(|f| !f.is_empty() && !super::access::shown(f)) };
let mut r = export(s, &p).map_err(|e| match hidden {
let mut r = export(s, &p, asked).map_err(|e| match hidden {
Some(folder) => super::access::naming_in_error(e, folder),
None => e,
})?;
@@ -1022,9 +1094,12 @@ pub(crate) fn with_export_preset(s: &Session, cmd: &str, settings: &mut Map<Stri
/// else the bounds of all art). Items that share a name get `-2`, `-3`… instead of overwriting, and
/// so do different files that would share a name, with or without the `name` pattern. The reply's
/// `warnings` lists every warning the encoders give for the files, each once, what the files lose
/// first ([`super::note_texts`]).
pub(crate) fn export(s: &mut Session, p: &Value) -> Result<Value> {
/// first ([`super::note_texts`]), and a note for each option of the request `asked` (`p` before a
/// preset or the remembered settings were laid under it) that the files the rows write don't read
/// ([`unused_option_notes`]).
pub(crate) fn export(s: &mut Session, p: &Value, asked: &Value) -> Result<Value> {
check_passwords(p)?;
let unused = unused_option_notes(asked, p);
let o = ScreenParams::of(p)?;
let rows = rows_of(s, p, o.preset.as_deref())?;
o.one_source()?;
@@ -1050,7 +1125,7 @@ pub(crate) fn export(s: &mut Session, p: &Value) -> Result<Value> {
// Include Artboard Fill off: the encoders leave the background colors out.
let fill = if o.include_artboard_fill == Some(false) { json!({ "includeArtboardFill": false }) } else { json!({}) };
let rows = rows.into_iter().map(|r| Row { options: merge(with_black_output(s, &r.options), fill.clone()), ..r }).collect();
let export = ScreenExport { cmd: C, o, rows, naming, items, hidden: None };
let export = ScreenExport { cmd: C, o, rows, naming, items, unused, hidden: None };
match &mut s.screens_hand_off {
Some(slot) => {
*slot = Some(export);
@@ -1071,6 +1146,8 @@ pub struct ScreenExport {
rows: Vec<Row>,
naming: Naming,
items: Vec<Item>,
/// The notes on options the files don't read ([`unused_option_notes`]).
unused: Vec<String>,
/// The export folder and the name the request that prepared the export may see it by, when the
/// folder came from the `exportPreset` and is outside that request's read roots: the reply and
/// the errors of [`ScreenExport::write`] name it as [`export_for_screens`] does.
@@ -1135,14 +1212,17 @@ impl ScreenExport {
/// [`ScreenExport::write`].
fn encode_and_write(export: ScreenExport, writer: &mut dyn FnMut(&str, &[u8]) -> Result<()>) -> Result<Value> {
let ScreenExport { o, rows, naming, items, .. } = export;
let ScreenExport { o, rows, naming, items, unused, .. } = export;
let folder = o.folder.as_deref().filter(|f| !f.is_empty());
if let Some(dir) = folder {
create_dir(dir)?;
}
let mut out = Output::default();
// The encoders' notes for every file, each once.
// The notes on the options, then the encoders' notes for every file, each once.
let mut notes: Vec<Note> = Vec::new();
for text in unused {
add_note(&mut notes, Note::setting(text));
}
// Include Bleed: a grown artboard already holds the bleed. Assets, layers and a selection are
// written on a page of their own bounds, which it doesn't grow.
let bleed_on_page = o.include_bleed && o.assets.is_none() && o.layers.is_none() && o.selection.is_none();
+2 -2
View File
@@ -748,8 +748,8 @@ fn a_native_save_streams_the_bytes_the_encoder_makes() {
}
/// An option given beside the path (an SVG option flat or in `svg`) and in `options` with different
/// values is refused before anything is written; `compress`, `version`, `preview` or `precision` beside the path of a format
/// that doesn't read it is named in the warnings.
/// values is refused before anything is written; a native option beside the path of a format that doesn't read it is named
/// in the warnings.
#[test]
fn a_save_refuses_disagreeing_options_and_names_unread_native_ones() {
let d = tmp_dir("save-options");
@@ -280,3 +280,102 @@ fn note_texts_give_the_losses_first_each_kind_in_order() {
assert_eq!(note_texts(&[Note::setting("s1"), Note::setting("s2")]), ["s1", "s2"]);
assert!(note_texts(&[]).is_empty());
}
/// A save notes the `.ai` options its file doesn't read as an export does: a PDF-compatible file
/// doesn't read the legacy file's options or `preserveEditing`, and a legacy file reads its own
/// options only. The notes cover the options beside the path and in `options`.
#[test]
fn a_save_notes_the_ai_options_its_file_does_not_read() {
let (mut s, notes) = (with_art(), unused);
let r = s
.execute("file.saveCopy", &json!({"format": "ai", "outlineText": true, "preserveEditing": false, "options": {"hiddenObjects": "omit"}}))
.unwrap();
assert_eq!(
notes(&r),
[
"`outlineText` was not used: PDF-compatible .ai files don't read it",
"`preserveEditing` was not used: PDF-compatible .ai files don't read it",
"`options.hiddenObjects` was not used: PDF-compatible .ai files don't read it"
],
"{r}"
);
let p = json!({"format": "ai", "aiVersion": "7", "compress": true, "marks": {"trim": true}, "options": {"pdfCompatible": false}});
let r = s.execute("file.saveCopy", &p).unwrap();
assert_eq!(
notes(&r),
[
"`compress` was not used: legacy .ai (version 7) files don't read it",
"`marks` was not used: legacy .ai (version 7) files don't read it",
"`options.pdfCompatible` was not used: legacy .ai (version 7) files don't read it"
],
"{r}"
);
// The file's own options get none.
for p in [
json!({"format": "ai", "aiVersion": "7", "outlineText": true, "options": {"hiddenObjects": "omit", "range": "1"}}),
json!({"format": "ai", "compress": true, "marks": {"trim": true}, "options": {"editingData": false}}),
json!({"format": "pdf", "marks": {"trim": true}, "preserveEditing": false}),
] {
let r = s.execute("file.saveCopy", &p).unwrap();
assert!(notes(&r).is_empty(), "{p}: {r}");
}
}
/// A save notes an option beside the path that only another save format takes, as it notes one in
/// `options`.
#[test]
fn a_save_notes_an_option_beside_the_path_that_only_another_save_format_takes() {
let (mut s, notes) = (with_art(), unused);
let r = s.execute("file.saveCopy", &json!({"format": "vectorcraft", "marks": {"trim": true}, "svg": {"minify": true}})).unwrap();
assert_eq!(notes(&r), ["`marks` was not used: VectorCraft files don't read it", "`svg` was not used: VectorCraft files don't read it"], "{r}");
}
/// The `.ai` options a save's file doesn't read keep the values the document's last save in that
/// format gave them, so a save from Save Options, which sends only the options the file reads,
/// leaves the other version's choices as they were.
#[test]
fn a_save_keeps_the_ai_options_its_file_does_not_read_from_the_last_save() {
let mut s = with_art();
let dir = tmp_dir("ai-kept");
let path = dir.join("kept.ai").to_string_lossy().to_string();
let value = |s: &mut Session, key: &str| s.execute("file.formatOptions", &json!({"format": "ai"})).unwrap()["options"][key]["value"].clone();
s.execute("file.saveAs", &json!({"path": path, "format": "ai", "options": {"aiVersion": "7", "outlineText": true}})).unwrap();
s.execute("file.saveAs", &json!({"path": path, "format": "ai", "options": {"aiVersion": "pdf", "editingData": false}})).unwrap();
assert_eq!(value(&mut s, "outlineText"), json!(true));
assert_eq!(value(&mut s, "editingData"), json!(false));
s.execute("file.saveAs", &json!({"path": path, "format": "ai", "options": {"aiVersion": "7", "hiddenObjects": "omit"}})).unwrap();
assert_eq!(value(&mut s, "editingData"), json!(false));
// An option the request gives is the one saved.
s.execute("file.saveAs", &json!({"path": path, "format": "ai", "options": {"aiVersion": "7", "outlineText": false}})).unwrap();
assert_eq!(value(&mut s, "outlineText"), json!(false));
// The kept options are neither written nor sent again: a plain save's options (which a retry
// in another folder sends to Save As) hold only what the legacy file reads.
for p in [json!({}), json!({"options": {"aiVersion": "7"}})] {
let plan = save_plan(&s, SaveMode::Save, &p).unwrap();
assert!(!plan.options.contains_key("editingData") && !plan.options.contains_key("compress"), "{:?}", plan.options);
assert_eq!(plan.kept.get("editingData"), Some(&json!(false)));
assert!(plan.notes.is_empty(), "{:?}", plan.notes);
let retry = json!({"path": path, "format": "ai", "options": plan.options});
let r = s.execute("file.saveAs", &retry).unwrap();
assert!(!r["warnings"].to_string().contains("was not used"), "{r}");
assert_eq!(value(&mut s, "editingData"), json!(false));
}
std::fs::remove_dir_all(&dir).unwrap();
}
/// The first `.ai` save of a document whose own file is another format remembers only the options
/// it was given: nothing from that format's options is kept.
#[test]
fn a_first_ai_save_keeps_only_the_options_it_is_given() {
let mut s = with_art();
let dir = tmp_dir("ai-first");
let native = dir.join("first.vectorcraft").to_string_lossy().to_string();
let path = dir.join("first.ai").to_string_lossy().to_string();
s.execute("file.saveAs", &json!({"path": native, "options": {"compress": false}})).unwrap();
let plan = save_plan(&s, SaveMode::SaveAs, &json!({"path": path, "format": "ai", "options": {"aiVersion": "7"}})).unwrap();
assert!(plan.kept.is_empty(), "{:?}", plan.kept);
s.execute("file.saveAs", &json!({"path": path, "format": "ai", "options": {"aiVersion": "7"}})).unwrap();
let o = s.execute("file.formatOptions", &json!({"format": "ai"})).unwrap();
assert_eq!(o["options"]["compress"]["value"], json!(true), "{o}");
std::fs::remove_dir_all(&dir).unwrap();
}
@@ -1163,3 +1163,93 @@ fn single_file_pdfs_hold_every_chosen_artboard() {
assert_eq!(names(&r), ["Artboard-1.pdf", "Artboard-2.pdf"]);
assert!(s.execute("document.exportForScreens", &json!({"pdfs": "both", "formats": [{"format": "pdf"}]})).is_err());
}
/// The warnings of reply `r` that name an option the file doesn't read.
fn unused(r: &Value) -> Vec<String> {
r["warnings"].as_array().unwrap().iter().filter_map(Value::as_str).filter(|w| w.contains("was not used")).map(str::to_string).collect()
}
/// An option of a format row, or of a format's settings, that only another format reads is
/// accepted, and the reply's warnings name it as the request does, in the words of a save's note.
#[test]
fn a_row_option_only_another_format_reads_gets_a_note() {
let mut s = session(1);
let p = json!({
"formats": [{"format": "png", "quality": 80}, {"format": "jpg", "quality": 80}, {"format": "ai", "aiVersion": "7", "compress": true}],
"settings": {"png": {"quality": 50, "interlaced": true}, "svg": {"interlaced": true}},
});
let r = export(&mut s, p);
assert_eq!(
unused(&r),
[
"`formats[0].quality` was not used: PNG files don't read it",
"`formats[2].compress` was not used: legacy .ai (version 7) files don't read it",
"`settings.png.quality` was not used: PNG files don't read it",
"`settings.svg.interlaced` was not used: SVG files don't read it"
],
"{r}"
);
// A row's own keys, the keys every row takes and the artboard choices get none.
let p = json!({"formats": [
{"format": "png", "scale": "2x", "suffix": "@2x", "folder": "x", "includeArtboardFill": false, "artboard": 0, "useArtboards": true},
{"format": "pdf", "marks": {"trim": true}, "preset": "High Quality Print"},
{"format": "vectorcraft", "compress": true, "range": "1"},
]});
let r = export(&mut s, p);
assert!(unused(&r).is_empty(), "{r}");
// A format's settings are read as each row of that format writes them: a row can name another
// `.ai` version than the settings do.
let r = export(&mut s, json!({"formats": [{"format": "ai", "aiVersion": "7"}], "settings": {"ai": {"marks": {"trim": true}}}}));
assert_eq!(unused(&r), ["`settings.ai.marks` was not used: legacy .ai (version 7) files don't read it"], "{r}");
let r = export(&mut s, json!({"formats": [{"format": "ai", "aiVersion": "pdf"}], "settings": {"ai": {"aiVersion": "7", "compress": true}}}));
assert!(unused(&r).is_empty(), "{r}");
// A setting another row of the format reads gets none; one no row reads names the first row's
// file.
let rows = json!([{"format": "ai", "aiVersion": "7"}, {"format": "ai"}]);
let r = export(&mut s, json!({"formats": rows, "settings": {"ai": {"editingData": false}}}));
assert!(unused(&r).is_empty(), "{r}");
let r = export(&mut s, json!({"formats": rows, "settings": {"ai": {"quality": 80}}}));
assert_eq!(unused(&r), ["`settings.ai.quality` was not used: legacy .ai (version 7) files don't read it"], "{r}");
// A row that gives the key itself doesn't use the setting.
let rows = json!([{"format": "ai", "aiVersion": "7"}, {"format": "ai", "editingData": true}]);
let r = export(&mut s, json!({"formats": rows, "settings": {"ai": {"editingData": false}}}));
assert_eq!(unused(&r), ["`settings.ai.editingData` was not used: legacy .ai (version 7) files don't read it"], "{r}");
let rows = json!([{"format": "ai", "aiVersion": "7"}, {"format": "ai", "editingData": null}]);
let r = export(&mut s, json!({"formats": rows, "settings": {"ai": {"editingData": false}}}));
assert_eq!(unused(&r), ["`settings.ai.editingData` was not used: legacy .ai (version 7) files don't read it"], "{r}");
// An export writes one file per row and item: a native row doesn't read `separateArtboards`.
let r = export(&mut s, json!({"formats": [{"format": "vectorcraft", "separateArtboards": true}]}));
assert_eq!(unused(&r), ["`formats[0].separateArtboards` was not used: VectorCraft files don't read it"], "{r}");
}
/// The Asset Export panel's export notes the options of its request, not those of the settings
/// the document remembers.
#[test]
fn an_asset_export_notes_the_options_it_is_given() {
let mut s = session(1);
s.execute("select.all", &json!({})).unwrap();
s.execute("assets.add", &json!({"multiple": false})).unwrap();
s.execute("assets.settings.set", &json!({"formats": [{"format": "png", "quality": 80}]})).unwrap();
let r = s.execute("assets.export", &json!({})).unwrap();
assert!(unused(&r).is_empty(), "{r}");
let r = s.execute("assets.export", &json!({"formats": [{"format": "svg", "quality": 80}]})).unwrap();
assert_eq!(unused(&r), ["`formats[0].quality` was not used: SVG files don't read it"], "{r}");
// Settings given are read as the remembered rows write them.
s.execute("assets.settings.set", &json!({"formats": [{"format": "ai", "aiVersion": "7"}]})).unwrap();
let r = s.execute("assets.export", &json!({"settings": {"ai": {"editingData": false}}})).unwrap();
assert_eq!(unused(&r), ["`settings.ai.editingData` was not used: legacy .ai (version 7) files don't read it"], "{r}");
s.execute("assets.settings.set", &json!({"formats": [{"format": "png", "quality": 80}]})).unwrap();
// So does the export a client prepares.
let p = json!({"formats": [{"format": "svg", "quality": 80}]});
let r = s.prepare_screen_export("assets.export", &p).unwrap().write(&mut |_: &str, _: &[u8]| Ok(())).unwrap();
assert_eq!(unused(&r), ["`formats[0].quality` was not used: SVG files don't read it"], "{r}");
}
/// A preset's options add no notes: Export for Screens notes the options of its request.
#[test]
fn an_export_preset_adds_no_notes() {
let mut s = session(1);
s.execute("document.exportPresets.save", &json!({"name": "Mixed", "settings": {"formats": [{"format": "png", "quality": 80}]}})).unwrap();
let r = export(&mut s, json!({"exportPreset": "Mixed"}));
assert!(unused(&r).is_empty(), "{r}");
}
+1
View File
@@ -1164,6 +1164,7 @@ pub fn jobs(s: &mut Session, store: &Arc<dyn RecoveryStore>) -> Result<(Vec<Reco
modified: None,
notes: Vec::new(),
lossy_source: None,
kept: serde_json::Map::new(),
};
let save = fileio::job_for(s, st, plan)?;
let meta = Meta {
+7 -3
View File
@@ -284,7 +284,7 @@ fn save_file_warns_about_options_it_does_not_use() {
}
/// The notes on `options` name the `.ai` file the save writes, as the note on a top-level option
/// does: a legacy `.ai` for `aiVersion: "7"` (beside `pdfCompatible: false` too), a
/// does: a legacy `.ai` for `aiVersion: "7"` (beside `pdfCompatible: false` too, which it notes), a
/// non-PDF-compatible `.ai` for `pdfCompatible: false`, and a PDF-compatible `.ai` otherwise, also
/// when the save reuses the options the document was last saved with.
#[test]
@@ -301,8 +301,12 @@ fn save_file_notes_name_the_ai_file_it_writes() {
("pdf.ai", json!({"pdfCompatible": true, "preview": true}), "PDF-compatible .ai"),
] {
let r = text(&call_tool(&mut h, "save_file", &json!({"path": tmp(&dir, name), "version": 3, "options": options})));
let expected =
[format!("`version` was not used: {label} files don't read it"), format!("`options.preview` was not used: {label} files don't read it")];
// A legacy `.ai` doesn't read `pdfCompatible` either.
let unread = |k: &str| options.get(k).is_some() && (k != "pdfCompatible" || label.starts_with("legacy"));
let expected: Vec<String> = std::iter::once("version".to_string())
.chain(["pdfCompatible", "preview"].into_iter().filter(|k| unread(k)).map(|k| format!("options.{k}")))
.map(|k| format!("`{k}` was not used: {label} files don't read it"))
.collect();
assert_eq!(notes(&r), expected, "{name}: {r}");
}
// A save given no option the file reads writes it as last saved: a legacy `.ai` again.
+5 -5
View File
@@ -253,7 +253,7 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
}
// Version first; the legacy version takes only its own options.
let legacy = legacy_ai(d);
let mut rows: Vec<&FormatOption> = shown(f).filter(|o| LEGACY_ONLY.contains(&o.name) == legacy || o.name == "aiVersion").collect();
let mut rows: Vec<&FormatOption> = shown(f).filter(|o| fileio::file_reads(f, legacy, o.name)).collect();
// EPS's PostScript level comes last, as in Export's EPS Options.
rows.sort_by_key(|o| (o.name != "aiVersion", f.id == "eps" && o.name == "level"));
for o in rows {
@@ -269,10 +269,6 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
false
}
/// The `.ai` options only the legacy version takes (and the only ones it takes, besides the
/// version).
const LEGACY_ONLY: &[&str] = &["transparentImages", "hiddenObjects", "outlineText"];
/// Is a `.ai` file to be saved in the legacy version 7 (`aiVersion: "7"`)?
fn legacy_ai(d: &Dialog) -> bool {
d.str("format") == "ai" && d.str("aiVersion").trim() == "7"
@@ -615,9 +611,13 @@ fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> DialogResult {
return Err("name the file".into());
}
let path = if d.bool("__pick") { with_extension(path.trim(), f) } else { path };
// Only the options the file reads: a `.ai` file's version takes its own (when the document's
// own file is a `.ai`, the save remembers the others as they were).
let legacy = legacy_ai(d);
let mut options: Map<String, Value> = f
.options
.iter()
.filter(|o| fileio::file_reads(f, legacy, o.name))
.filter_map(|o| d.fields.get(o.name).filter(|v| !v.is_null() && v.as_str() != Some("")).map(|v| (o.name.to_string(), v.clone())))
.collect();
if uncompressed(d) {
@@ -125,6 +125,33 @@ fn save_as_ai_version_7_writes_a_file_per_artboard() {
assert_eq!((st.save_options["hiddenObjects"].clone(), st.save_options["outlineText"].clone()), (json!("visible"), json!(true)));
}
/// Save Options sends a `.ai` save only the options its version reads, so a person's save gets no
/// note about an option the file doesn't read, and the other version's choices stay as the last
/// save left them.
#[test]
fn ai_save_options_send_only_the_options_the_version_reads() {
let (mut app, _, _) = desktop("art.ai");
for (round, legacy) in [true, false, true].into_iter().enumerate() {
assert_eq!(app.run("file.saveAs", json!({})).unwrap()["pending"], KIND);
frame(&mut app, false);
match round {
1 => assert!(dialog(&app).bool("outlineText"), "the legacy choice is shown as last saved"),
2 => assert!(!dialog(&app).bool("editingData"), "the PDF-compatible choice is shown as last saved"),
_ => {}
}
let d = app.ui.dialog.as_mut().unwrap();
d.fields.insert("aiVersion".into(), json!(if legacy { "7" } else { "pdf" }));
let (key, value) = if legacy { ("outlineText", true) } else { ("editingData", false) };
d.fields.insert(key.into(), json!(value));
let d = d.clone();
app.ui.dialog = None;
let r = confirm(&mut app, &d).unwrap();
assert!(!r["warnings"].to_string().contains("was not used"), "round {round}: {r}");
}
let st = app.session.active().unwrap();
assert_eq!((st.save_options["outlineText"].clone(), st.save_options["editingData"].clone()), (json!(true), json!(false)));
}
/// An Illustrator file opened, then saved as `.ai`: the options dialog stays open through the
/// Enter that closed the save panel, and only an Enter after it is let go confirms it.
#[test]
@@ -88,6 +88,39 @@ fn opens_on_defaults_and_draws_every_view() {
assert_eq!((spec(export_for_screens::KIND).ok_label.unwrap())(&app), "Export Artboard");
}
/// The dialog's own request, with a row of every format its Format menu offers and every format's
/// settings as it opens, gets no note about an option a file doesn't read.
#[test]
fn the_dialogs_own_rows_and_settings_get_no_unused_option_notes() {
let (mut app, _) = app(1, true);
open(&mut app);
let rows: Vec<Value> = [
json!({"format": "png"}),
json!({"format": "png8"}),
json!({"format": "jpg", "quality": 100}),
json!({"format": "webp"}),
json!({"format": "gif"}),
json!({"format": "tiff"}),
json!({"format": "svg"}),
json!({"format": "pdf"}),
json!({"format": "pdf", "marks": {"trim": true}, "suffix": "-print"}),
json!({"format": "eps"}),
json!({"format": "ai"}),
json!({"format": "vectorcraft"}),
]
.into_iter()
.map(|mut r| {
r["scale"] = json!("1x");
r
})
.collect();
set(&mut app, "formats", json!(rows));
let p = export_for_screens::params(&app, app.ui.dialog.as_ref().unwrap()).unwrap();
let r = app.session.execute("document.exportForScreens", &p).unwrap();
let unused: Vec<&str> = r["warnings"].as_array().unwrap().iter().filter_map(Value::as_str).filter(|w| w.contains("was not used")).collect();
assert!(unused.is_empty(), "{unused:?}");
}
#[test]
fn checked_artboards_read_as_a_range() {
let (mut app, _) = app(5, false);
+14 -4
View File
@@ -637,7 +637,7 @@ sampler.
| `open_panel` | `{panel}` | Remote only. `panel` is a panel id (`layers`, `swatches`, `colorGuide`, …, as `window.panel` takes) or its display label (`"Color Guide"`), in any case. |
| `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`. An app that does not send the image back writes a window capture without `path` to a file in a new folder inside the system's temporary folder (on Unix only the user can open it); the server reads the file and then removes the folder, also when the capture or the read fails. |
| `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`, PICT (`.pct`/`.pic`/`.pict`, and a version 2 picture of any extension), HP-GL (`.plt`/`.hpgl`/`.hgl`/`.hpg`, and a file of an extension no other format reads that starts with plotter instructions), 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 PSD or PSB file with its layers as layers, or as its merged image through `run_command document.open` with `layers: false`; 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; `.eps`: by default one file whose bounding box fits the visible art, the first artboard when no art shows; `options.useArtboards: true` makes the chosen artboard the page and, with two or more chosen, the file at the path is the central file of the visible art and each artboard is also written to `<name>_<artboard>.eps` beside it, listed in the reply's `files`). 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. Location refusals return `READ_ONLY_LOCATION: <reason>` without a fallback panel for automation; see [Save location errors](#save-location-errors). |
| `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; `.eps`: by default one file whose bounding box fits the visible art, the first artboard when no art shows; `options.useArtboards: true` makes the chosen artboard the page and, with two or more chosen, the file at the path is the central file of the visible art and each artboard is also written to `<name>_<artboard>.eps` beside it, listed in the reply's `files`). 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 PDF-compatible `.ai` file reads `compress` but not `version`, `preview`, `precision`, `outlineText`, `hiddenObjects`, `transparentImages` or `preserveEditing`, and a legacy `.ai` file reads only its own options). 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 each key given beside the path or in `options` that the file 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. Location refusals return `READ_ONLY_LOCATION: <reason>` without a fallback panel for automation; see [Save location errors](#save-location-errors). |
| `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`, `hpgl` (a `.plt` plot file for plotters and cutters), `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, WMF and HP-GL 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. |
@@ -1670,8 +1670,13 @@ the same as leaving the key out.
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`:
that only another save format takes, and its `warnings` name the key, given beside the path or in `options`
("`options.marks` was not used: VectorCraft files don't read it"). These warnings follow an export's `.ai` rules: a
legacy `.ai` file reads only its own options, and the other `.ai` and `.ait` files don't read `outlineText`,
`hiddenObjects`, `transparentImages` or `preserveEditing`. The Save Options dialog sends a `.ai` save only the options
the chosen version reads. When the document's own file is already a `.ai`, the `.ai` options that a save doesn't give
and its file doesn't read keep their remembered values, so the dialog shows them as they were. The save doesn't write
them:
```json
{"id": "document.save", "params": {"path": "a.pdf", "options": {"noSuchOption": true}}}
@@ -1683,7 +1688,12 @@ and in the options `settings` gives a format, they refuse a key that no format t
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`).
takes an exporter plug-in's format. The error names the row (`formats[1].marks.useDocumentBleed`). A key that only
another format takes is accepted and not used, and the reply's `warnings` name it as the request does
("`formats[0].quality` was not used: PNG files don't read it", `settings.png.quality`), with the `.ai` rules of an
export. A key in a format's settings gets a warning only when no row of that format, as the export writes it, reads it.
A row's own keys (`scale`, `suffix`…), `includeArtboardFill`, `richBlack`, `params` and an artboard choice get no
warning, and neither do the options of an `exportPreset` or, for `assets.export`, the settings the document keeps.
`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.
+1 -1
View File
@@ -900,7 +900,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
- 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`).
- A parameter schema's errors show in the status bar with what the value must be in the interface language, for every type a schema has (a number with each kind of range and with a unit, an integer and its range, true or false, a string, a list of names, an object id, a list of object ids, a color, a point, an object and a list), in every catalog that translates all status and error messages; the path, the refused value, the bounds, the unit and the names stay as the English gives them. The error for a value that is not one of its names is also translated in Bulgarian, Bosnian, Danish, Indonesian and Dutch (`parameter_schema_expected_phrases_are_translated`, `bracketed_message_parts_translate_only_as_exact_messages`).
- Export commands run through MCP, the control channel or the CLI refuse a key that no format takes, and warn about a key that only another format takes (`{"format": "pdf", "quality": 80}` writes the PDF with the warning "`quality` was not used: PDF files don't read it"; `an_option_only_another_format_reads_gets_a_note`, `control_export_notes_an_option_the_format_does_not_read`, `convert_warns_about_an_unused_option_only_when_one_was_given`), 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.
- 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`, and warn about a key the file doesn't read, beside the path or in `options`, with an export's `.ai` rules; Save Options sends a `.ai` save only the options the chosen version reads (`a_save_notes_the_ai_options_its_file_does_not_read`, `ai_save_options_send_only_the_options_the_version_reads`). 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`), and warns about a key there that only another format takes (`a_row_option_only_another_format_reads_gets_a_note`); 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`).