Merge pull request #6061 from dotCooCoo/fix/export-unknown-options

Export: the export commands refuse an unknown option key
This commit is contained in:
BFlat
2026-11-09 07:47:15 +08:00
committed by GitHub
15 changed files with 320 additions and 20 deletions
+3 -3
View File
@@ -27,7 +27,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Serialize Document",
[],
None,
"{format?: vectorcraft (default)|template|svg|svgz|pdf|ai|png|jpg|webp|gif|png8|txt|dxf|eps|emf|wmf|tiff|bmp|tga|psd (ait, a .ait template, is written by file.saveAsTemplate), …the format's options (see document.formats; SVG ones also as svg: {…}), selectedOnly?: false (the selected objects alone, in their layers)} → {text, warnings} for svg (plus dataBase64, the file, when its encoding isn't UTF-8), else {dataBase64, warnings}; an SVG of several artboards also gives files: [{name, text}], linked images linked: [{name, dataBase64}]",
"{format?: vectorcraft (default)|template|svg|svgz|pdf|ai|png|jpg|webp|gif|png8|txt|dxf|eps|emf|wmf|tiff|bmp|tga|psd (ait, a .ait template, is written by file.saveAsTemplate), …the format's options (see document.formats; SVG ones also as svg: {…}), selectedOnly?: false (the selected objects alone, in their layers)} → {text, warnings} for svg (plus dataBase64, the file, when its encoding isn't UTF-8), else {dataBase64, warnings}; an SVG of several artboards also gives files: [{name, text}], linked images linked: [{name, dataBase64}]. A key no format takes, and an unknown key inside a PDF or EPS option's object, are refused; a key only another format takes is ignored",
has_doc,
export::serialize
),
@@ -36,7 +36,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Export Document",
[],
None,
"{path?, format?: svg|svgz|pdf|ai|png|jpg|webp|gif|png8|txt|dxf|eps|emf|wmf|tiff|bmp|tga|psd|vectorcraft|template (default: from the path's extension, else png; png8 writes an indexed .png; ait, a .ait template, is written by file.saveAsTemplate; an exporter plug-in's format is plugin.<plug-in id>, which an extension only it lists picks too), params?: {…} (an exporter plug-in's parameters; missing ones take their defaults, see plugin.info), selectedOnly?: false (the selected objects alone, in their layers), includeArtboardFill?: true (raster, PDF, SVG and EPS draw each exported artboard's background color behind its art, a transparent background too; false leaves it out), artboard?: 0, artboards?: [i…], rect?: [x, y, width, height] (raster formats: that area of the page in points, y down, instead of an artboard; not with artboard, artboards, range or useArtboards), range?: \"1-3, 5\" | \"all\" (1-based; PDF and .ai write one page per artboard, default all; SVG writes one file per artboard, {stem}-{artboard}.svg; raster formats write one artboard), useArtboards?: true (raster: one file per chosen artboard, default all, {stem}-{artboard}.{ext}; pdf/ai: every page) | false (pdf/ai/raster: the bounds of the visible art; SVG has it as an SVG option), ppi?: number (raster formats: pixels per inch, stored in the file; wins over scale; without either, the document's raster effects resolution), scale?: number (pixels per point), background?: transparent|white|black|\"#rrggbb\" (jpg: white when transparent; #rrggbbaa and #rgba are refused here and in matte), antiAlias?: none|art (default)|type (text snapped to pixels), interlaced?: false (png, Adam7), embedIcc?: true (png: an sRGB chunk marks the colours as sRGB), quality?: 90 (jpg: 0–100), colorModel?: rgb|cmyk|gray (jpg), method?: baseline|optimized|progressive, scans?: 3 (3–5, progressive), embedIcc?: true, imageMap?: none|client|server (an HTML or NCSA map of the objects with a URL, written as <stem>.html / <stem>.map), colors?: 256 (gif/png8: 2–256), reduction?: perceptual|selective (default)|adaptive|web|blackWhite|gray, dither?: none|diffusion (default)|pattern|noise, ditherAmount?: 100, transparency?: true, matte?: white|\"#rrggbb\"|none, interlaced?, lossless?: true (webp: lossy WebP isn't available yet: written lossless, with a warning), encoding?: utf8|utf16 (txt: the stories in stacking order, back to front, a thread once; utf16 with a byte order mark), lineEndings?: lf|crlf, selectionOnly?: false, svg?: {styling, outlineText, images, objectIds, decimals, minify, responsive, useArtboards, preserveEditing, metadata, fewerTspans, hiddenLayers, encoding, profile, embedFonts} (see document.formats), …the SVG options given flat instead, …the PDF options of document.exportPdf, …the DXF options of document.exportDxf (useArtboards: one drawing per artboard), …the EPS options of document.exportEps (useArtboards: one file per artboard, {stem}_{artboard}.eps, after a central {stem}.eps of the whole document when there are two or more and art shows; else the visible art), …emf and wmf write one picture of the artboard (useArtboards: true one file per chosen artboard, false the bounds of the visible art; EMF keeps curves, clipping, transparent images and gradients as images clipped to their shape; WMF flattens curves into polygons behind a placeable header; what a format leaves out comes back in warnings), colorModel?: rgb|cmyk|gray (tiff: rgb keeps transparency as an alpha channel), lzw?: true, byteOrder?: little|big, embedIcc?: true, colorModel?: rgb|gray (bmp), depth?: 24 (bmp: 1 black and white, 4|8 a palette by reduction and dither, 16, 24, 32 with alpha), fileFormat?: windows|os2 (1, 4, 8 or 24 bits), rle?: false (4 and 8 bits, windows), flipRows?: false (top-down rows), depth?: 24 (tga: 16 one-bit alpha, 24, 32 with alpha), colorModel?: rgb|cmyk|gray (psd), layers?: true (each top-level layer a pixel layer with its opacity and blend mode; a background colour is a Background layer; false: one flat image, on white), maxEditability?: false (layers and sublayers become groups, each object a layer named as in the Layers panel, text by its text; clipping, masked or knockout layers stay one layer), hiddenLayers?: false (hidden layers and objects written hidden instead of left out), embedIcc?: true (at most 30000 pixels a side; formats without alpha are flattened on white)} → {path, format, bytes, warnings, files?: [path…] (several), linked?: [path…] (linked images, image maps)}; no path → {dataBase64, format, bytes, warnings, files?: [{name, dataBase64}], linked?: [{name, dataBase64}]}. Never changes the document's path",
"{path?, format?: svg|svgz|pdf|ai|png|jpg|webp|gif|png8|txt|dxf|eps|emf|wmf|tiff|bmp|tga|psd|vectorcraft|template (default: from the path's extension, else png; png8 writes an indexed .png; ait, a .ait template, is written by file.saveAsTemplate; an exporter plug-in's format is plugin.<plug-in id>, which an extension only it lists picks too), params?: {…} (an exporter plug-in's parameters; missing ones take their defaults, see plugin.info), selectedOnly?: false (the selected objects alone, in their layers), includeArtboardFill?: true (raster, PDF, SVG and EPS draw each exported artboard's background color behind its art, a transparent background too; false leaves it out), artboard?: 0, artboards?: [i…], rect?: [x, y, width, height] (raster formats: that area of the page in points, y down, instead of an artboard; not with artboard, artboards, range or useArtboards), range?: \"1-3, 5\" | \"all\" (1-based; PDF and .ai write one page per artboard, default all; SVG writes one file per artboard, {stem}-{artboard}.svg; raster formats write one artboard), useArtboards?: true (raster: one file per chosen artboard, default all, {stem}-{artboard}.{ext}; pdf/ai: every page) | false (pdf/ai/raster: the bounds of the visible art; SVG has it as an SVG option), ppi?: number (raster formats: pixels per inch, stored in the file; wins over scale; without either, the document's raster effects resolution), scale?: number (pixels per point), background?: transparent|white|black|\"#rrggbb\" (jpg: white when transparent; #rrggbbaa and #rgba are refused here and in matte), antiAlias?: none|art (default)|type (text snapped to pixels), interlaced?: false (png, Adam7), embedIcc?: true (png: an sRGB chunk marks the colours as sRGB), quality?: 90 (jpg: 0–100), colorModel?: rgb|cmyk|gray (jpg), method?: baseline|optimized|progressive, scans?: 3 (3–5, progressive), embedIcc?: true, imageMap?: none|client|server (an HTML or NCSA map of the objects with a URL, written as <stem>.html / <stem>.map), colors?: 256 (gif/png8: 2–256), reduction?: perceptual|selective (default)|adaptive|web|blackWhite|gray, dither?: none|diffusion (default)|pattern|noise, ditherAmount?: 100, transparency?: true, matte?: white|\"#rrggbb\"|none, interlaced?, lossless?: true (webp: lossy WebP isn't available yet: written lossless, with a warning), encoding?: utf8|utf16 (txt: the stories in stacking order, back to front, a thread once; utf16 with a byte order mark), lineEndings?: lf|crlf, selectionOnly?: false, svg?: {styling, outlineText, images, objectIds, decimals, minify, responsive, useArtboards, preserveEditing, metadata, fewerTspans, hiddenLayers, encoding, profile, embedFonts} (see document.formats), …the SVG options given flat instead, …the PDF options of document.exportPdf, …the DXF options of document.exportDxf (useArtboards: one drawing per artboard), …the EPS options of document.exportEps (useArtboards: one file per artboard, {stem}_{artboard}.eps, after a central {stem}.eps of the whole document when there are two or more and art shows; else the visible art), …emf and wmf write one picture of the artboard (useArtboards: true one file per chosen artboard, false the bounds of the visible art; EMF keeps curves, clipping, transparent images and gradients as images clipped to their shape; WMF flattens curves into polygons behind a placeable header; what a format leaves out comes back in warnings), colorModel?: rgb|cmyk|gray (tiff: rgb keeps transparency as an alpha channel), lzw?: true, byteOrder?: little|big, embedIcc?: true, colorModel?: rgb|gray (bmp), depth?: 24 (bmp: 1 black and white, 4|8 a palette by reduction and dither, 16, 24, 32 with alpha), fileFormat?: windows|os2 (1, 4, 8 or 24 bits), rle?: false (4 and 8 bits, windows), flipRows?: false (top-down rows), depth?: 24 (tga: 16 one-bit alpha, 24, 32 with alpha), colorModel?: rgb|cmyk|gray (psd), layers?: true (each top-level layer a pixel layer with its opacity and blend mode; a background colour is a Background layer; false: one flat image, on white), maxEditability?: false (layers and sublayers become groups, each object a layer named as in the Layers panel, text by its text; clipping, masked or knockout layers stay one layer), hiddenLayers?: false (hidden layers and objects written hidden instead of left out), embedIcc?: true (at most 30000 pixels a side; formats without alpha are flattened on white)} → {path, format, bytes, warnings, files?: [path…] (several), linked?: [path…] (linked images, image maps)}; no path → {dataBase64, format, bytes, warnings, files?: [{name, dataBase64}], linked?: [{name, dataBase64}]}. A key no format takes, and an unknown key inside a PDF or EPS option's object, are refused; a key only another format takes is ignored. Never changes the document's path",
has_doc,
export::export
),
@@ -45,7 +45,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Export Selection…",
["File"],
None,
"{path?, format?: png|jpg|webp|svg|svgz|pdf|ai|gif|png8|tiff|bmp|tga|psd|txt|dxf|eps|emf|wmf|vectorcraft|template (default: from the extension, else png), ppi?: number (raster formats: pixels per inch, stored in the file; wins over scale; without either, the document's raster effects resolution), scale?: number (pixels per point), …the format's options} the selected objects cropped to their bounds (template layers left out) → {path, format, bytes, warnings, bounds} (no path → {dataBase64, format, bytes, warnings, bounds}); warnings say what the format approximated or left out, as for document.export",
"{path?, format?: png|jpg|webp|svg|svgz|pdf|ai|gif|png8|tiff|bmp|tga|psd|txt|dxf|eps|emf|wmf|vectorcraft|template (default: from the extension, else png), ppi?: number (raster formats: pixels per inch, stored in the file; wins over scale; without either, the document's raster effects resolution), scale?: number (pixels per point), …the format's options} the selected objects cropped to their bounds (template layers left out) → {path, format, bytes, warnings, bounds} (no path → {dataBase64, format, bytes, warnings, bounds}); warnings say what the format approximated or left out, as for document.export. A key no format takes, and an unknown key inside a PDF or EPS option's object, are refused; a key only another format takes is ignored",
has_selection,
export::export_selection
),
+2 -2
View File
@@ -21,14 +21,14 @@ pub fn specs() -> Vec<CommandSpec> {
"Export DXF",
[],
None,
"{path?, dxfPreset?: custom (default)|cnc (CNC / CAM: version 2000, the oldest version that has both lightweight polylines and lineweights; unit mm, scale 1; colors 256, all 2000 holds; preserve editability and alterPaths false, so strokes stay center lines; outlineText true, so type is paths; rasterFormat png; the options given override it), version?: R12|R13|R14|2000|2004|2007|2010|2013|2018 (default), unit?: mm (default; any unit name: pt, in, cm…), scale?: 1 (drawing units per unit), scaleLineweights?: false, colors?: 8|16|256|true (default; true colour needs 2004+, else 256), rasterFormat?: png (default)|jpeg (placed images, written next to the file), preserve?: appearance (default: type as outlines; strokes a CAD line can't draw as filled outlines; brush art)|editability (type as text; every stroke a line with its lineweight and dashes), alterPaths?: false (strokes a CAD line can't draw as their filled outlines, with editability too; also strokes wider than 2.11 mm, and every stroke in R13 and R14), outlineText?: false, selectedOnly?: false (the selected objects, in their layers), artboard?: 0 (the origin is its bottom-left corner), useArtboards?: true (one drawing per chosen artboard, artboards?/range?, holding the art over it: {stem}-{artboard}.dxf) | false (the origin at the art's bounds)} → {path, bytes, warnings, files?, linked?: [image files]}; no path → {dataBase64, …, linked?: [{name, dataBase64}]}. Layers become DXF layers (hidden: off; locked; non-printing: not plotted); paths become polylines and splines, fills their outlines (which laser and cutter software reads) and solid hatches. Same as document.export {format: dxf}",
"{path?, dxfPreset?: custom (default)|cnc (CNC / CAM: version 2000, the oldest version that has both lightweight polylines and lineweights; unit mm, scale 1; colors 256, all 2000 holds; preserve editability and alterPaths false, so strokes stay center lines; outlineText true, so type is paths; rasterFormat png; the options given override it), version?: R12|R13|R14|2000|2004|2007|2010|2013|2018 (default), unit?: mm (default; any unit name: pt, in, cm…), scale?: 1 (drawing units per unit), scaleLineweights?: false, colors?: 8|16|256|true (default; true colour needs 2004+, else 256), rasterFormat?: png (default)|jpeg (placed images, written next to the file), preserve?: appearance (default: type as outlines; strokes a CAD line can't draw as filled outlines; brush art)|editability (type as text; every stroke a line with its lineweight and dashes), alterPaths?: false (strokes a CAD line can't draw as their filled outlines, with editability too; also strokes wider than 2.11 mm, and every stroke in R13 and R14), outlineText?: false, selectedOnly?: false (the selected objects, in their layers), artboard?: 0 (the origin is its bottom-left corner), useArtboards?: true (one drawing per chosen artboard, artboards?/range?, holding the art over it: {stem}-{artboard}.dxf) | false (the origin at the art's bounds)} → {path, bytes, warnings, files?, linked?: [image files]}; no path → {dataBase64, …, linked?: [{name, dataBase64}]}. Layers become DXF layers (hidden: off; locked; non-printing: not plotted); paths become polylines and splines, fills their outlines (which laser and cutter software reads) and solid hatches. Same as document.export {format: dxf}. A key DXF doesn't take is refused",
has_doc,
export_dxf
)]
}
/// The DXF options `document.formats` lists.
pub(super) const OPTIONS: &[FormatOption] = &[
pub(crate) const OPTIONS: &[FormatOption] = &[
super::ARTBOARD,
super::ARTBOARDS,
super::RANGE,
+2 -2
View File
@@ -32,14 +32,14 @@ pub fn specs() -> Vec<CommandSpec> {
"Export EPS",
[],
None,
"{path?, level?: 2|3 (default; PostScript language level: 3 writes smooth gradients and masks transparent image pixels out, 2 steps gradients and writes those pixels white), previewFormat?: none|tiffBw|tiffColor (default: a TIFF preview other apps show), transparentPreview?: true (else on white), overprints?: preserve (default)|discard, flattenerPreset?: \"medium\" (default; high, low or a saved preset: flattener.presets.list), flattener?: {the options of object.flattenTransparency over the preset}, embedFonts?: true (type is always written as outlines, so no fonts are needed), includeLinkedFiles?: false (the embedded document keeps linked images whole, not just their previews), thumbnails?: true (a PNG thumbnail in the file), cmykPostScript?: true (RGB documents written in CMYK), compatibleGradients?: false (stepped gradients at level 3 too), selectedOnly?: false, useArtboards?: true (one file per chosen artboard, artboards?/range?, {stem}_{artboard}.eps, the bounding box the artboard; with two or more, also the central file {stem}.eps first: the whole document, the bounding box the visible art; none when no art shows) | false (one file, the bounding box the visible art, y up from the first artboard's bottom-left corner, even when artboards are named; the default when none are named), format?: \"eps\", artboard?: i | artboards?: [i…] | range?: \"1-3, 5\" (the artboards written; naming them without useArtboards works as useArtboards: true)} → {path, bytes, warnings, files?}; no path → {dataBase64, …}. Transparency is flattened with the preset (EPS has none); the native document rides along in comments, for reopening the file as it was. Same as document.export {format: eps}",
"{path?, level?: 2|3 (default; PostScript language level: 3 writes smooth gradients and masks transparent image pixels out, 2 steps gradients and writes those pixels white), previewFormat?: none|tiffBw|tiffColor (default: a TIFF preview other apps show), transparentPreview?: true (else on white), overprints?: preserve (default)|discard, flattenerPreset?: \"medium\" (default; high, low or a saved preset: flattener.presets.list), flattener?: {the options of object.flattenTransparency over the preset}, embedFonts?: true (type is always written as outlines, so no fonts are needed), includeLinkedFiles?: false (the embedded document keeps linked images whole, not just their previews), thumbnails?: true (a PNG thumbnail in the file), cmykPostScript?: true (RGB documents written in CMYK), compatibleGradients?: false (stepped gradients at level 3 too), selectedOnly?: false, useArtboards?: true (one file per chosen artboard, artboards?/range?, {stem}_{artboard}.eps, the bounding box the artboard; with two or more, also the central file {stem}.eps first: the whole document, the bounding box the visible art; none when no art shows) | false (one file, the bounding box the visible art, y up from the first artboard's bottom-left corner, even when artboards are named; the default when none are named), format?: \"eps\", artboard?: i | artboards?: [i…] | range?: \"1-3, 5\" (the artboards written; naming them without useArtboards works as useArtboards: true)} → {path, bytes, warnings, files?}; no path → {dataBase64, …}. Transparency is flattened with the preset (EPS has none); the native document rides along in comments, for reopening the file as it was. Same as document.export {format: eps}. A key EPS doesn't take, and an unknown key inside flattener, are refused",
has_doc,
export_eps
)]
}
/// The EPS options `document.formats` lists.
pub(super) const OPTIONS: &[FormatOption] = &[
pub(crate) const OPTIONS: &[FormatOption] = &[
super::ARTBOARD,
super::ARTBOARDS,
super::RANGE,
File diff suppressed because one or more lines are too long
+98 -4
View File
@@ -6,7 +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.
//! 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`]).
//!
//! [`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
@@ -500,9 +502,100 @@ pub const UNCHECKED: &[&str] = &[
"webExport.settings",
];
/// [`check_keys`] for an engine command, the steps of a `command.batch` or an `action.batch` included, and
/// [`check_effect_params`] for the effect `effect.apply` adds. A command the engine doesn't have
/// passes: running it reports that.
/// The object options of a PDF export, key by key: the Save PDF settings as a preset stores them,
/// with the passwords it leaves out.
fn pdf_option_tree() -> Value {
let mut tree = serde_json::to_value(vectorcraft_pdf::PdfSettings::default()).unwrap_or_default();
if let Some(security) = tree.get_mut("security").and_then(Value::as_object_mut) {
security.extend(["openPassword", "permissionsPassword"].map(|k| (k.to_string(), Value::Null)));
}
tree
}
/// `given`, the object at `within` (`marks.`), has only keys of `schema`, the objects inside it too.
fn check_option_tree(id: &str, within: &str, given: &serde_json::Map<String, Value>, schema: &serde_json::Map<String, Value>) -> Result<()> {
for (key, value) in given {
match (schema.get(key), value) {
(None, _) => {
let keys: Vec<&str> = schema.keys().map(String::as_str).collect();
let why = match nearest(key, &keys) {
Some(near) => format!("did you mean `{within}{near}`?"),
None => format!("`{}` takes: {}", within.trim_end_matches('.'), keys.join(", ")),
};
return Err(bad(id, format!("unknown parameter `{within}{key}`; {why}")));
}
(Some(Value::Object(inner)), Value::Object(value)) => check_option_tree(id, &format!("{within}{key}."), value, inner)?,
_ => {}
}
}
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.
fn check_export_options(id: &str, params: &Value) -> Result<()> {
use super::fileio::{FORMATS, dxf, eps, pdf};
let Value::Object(given) = params else { return Ok(()) };
let mut keys: Vec<&str> = vec!["path", "format", "acknowledgeLoss"];
let why_not = match id {
"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"
}
"document.exportPdf" | "document.pdfSettings" | "pdf.preset.save" => {
keys.push("includeArtboardFill");
keys.extend(pdf::OPTIONS.iter().map(|o| o.name));
match id {
"document.pdfSettings" => keys.push("includeDocument"),
"pdf.preset.save" => keys.extend(["name", "newName", "description"]),
_ => {}
}
"PDF doesn't take it"
}
"document.exportEps" => {
keys.push("includeArtboardFill");
keys.extend(eps::OPTIONS.iter().map(|o| o.name));
"EPS doesn't take it"
}
"document.exportDxf" => {
keys.extend(dxf::OPTIONS.iter().map(|o| o.name));
"DXF doesn't take it"
}
_ => 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());
}
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_keys`] for an engine command, the steps of a `command.batch` or an `action.batch` included,
/// [`check_effect_params`] for the effect `effect.apply` adds and [`check_export_options`] for an
/// export. A command the engine doesn't have passes: running it reports that.
pub fn check_params(id: &str, params: &Value) -> Result<()> {
check_params_in(id, params, false)
}
@@ -516,6 +609,7 @@ fn check_params_in(id: &str, params: &Value, in_batch: bool) -> Result<()> {
{
check_effect_params(id, effect, given)?;
}
check_export_options(id, params)?;
if UNCHECKED.contains(&id) {
return Ok(());
}
+1 -1
View File
@@ -46,7 +46,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Save PDF Preset",
[],
None,
"{name?: (default: a new \"PDF Preset N\"), newName?: rename it, description?, preset?: the preset to start from (default: the saved preset `name`, else VectorCraft Default), …options of document.exportPdf} create or change a saved preset (built-in presets are read-only; passwords are never stored) → {name, created, settings}",
"{name?: (default: a new \"PDF Preset N\"), newName?: rename it, description?, preset?: the preset to start from (default: the saved preset `name`, else VectorCraft Default), …options of document.exportPdf} create or change a saved preset (built-in presets are read-only; passwords are never stored) → {name, created, settings}. A key PDF doesn't take, and an unknown key inside one of the option objects, are refused",
always,
save
),
+104 -1
View File
@@ -64,7 +64,7 @@ fn an_unknown_key_is_named_with_the_nearest_accepted_one() {
("file.new", json!(null)),
// Keys listed by reference, and any key where the description says so.
("file.newPresets.save", json!({"name": "a", "width": 300})),
("document.export", json!({"format": "png", "anyFormatOption": 1})),
("document.export", json!({"format": "png", "styling": "style"})),
// Not a command: running it says so.
("no.such", json!({"x": 1})),
] {
@@ -74,6 +74,109 @@ fn an_unknown_key_is_named_with_the_nearest_accepted_one() {
assert!(check_keys("x", "{a, …as file.new}", &json!({"b": 1})).is_err());
}
/// An export's options: a key no format reads is refused, at the top level and in the PDF objects,
/// under each command that takes them.
#[test]
fn export_options_refuse_a_key_no_format_reads() {
for id in ["document.export", "document.exportSelection", "document.serialize"] {
assert_eq!(
refusal(id, json!({"format": "pdf", "standard": "pdfX4", "noSuchOption": true})),
"unknown parameter `noSuchOption`; no format takes it (see document.formats)"
);
assert_eq!(refusal(id, json!({"format": "pdf", "standardd": "pdfX4"})), "unknown parameter `standardd`; did you mean `standard`?");
assert_eq!(
refusal(id, json!({"format": "pdf", "options": {"standard": "pdfX4"}})),
"unknown parameter `options`; no format takes it (see document.formats)"
);
}
// A command that writes one format refuses another format's option, and says which format.
for (id, why) in [
("document.exportPdf", "PDF doesn't take it"),
("document.pdfSettings", "PDF doesn't take it"),
("pdf.preset.save", "PDF doesn't take it"),
("document.exportEps", "EPS doesn't take it"),
("document.exportDxf", "DXF doesn't take it"),
] {
for key in ["noSuchOption", "quality"] {
assert_eq!(refusal(id, json!({key: 1})), format!("unknown parameter `{key}`; {why} (see document.formats)"));
}
}
assert_eq!(
refusal("document.exportEps", json!({"flattener": {"balanse": 50}})),
"unknown parameter `flattener.balanse`; did you mean `flattener.balance`?"
);
assert!(refusal("document.exportDxf", json!({"flattener": {}})).contains("DXF doesn't take it"));
assert!(refusal("document.exportEps", json!({"marks": {}})).contains("EPS doesn't take it"));
for id in ["document.pdfSettings", "pdf.preset.save"] {
assert!(refusal(id, json!({"marks": {"useDocumentBleed": true}})).starts_with("unknown parameter `marks.useDocumentBleed`;"), "{id}");
}
for (given, message) in [
(json!({"marks": {"useDocumentBleed": true}}), "unknown parameter `marks.useDocumentBleed`; `marks` takes:"),
(json!({"bleed": {"useDocumen": true}}), "unknown parameter `bleed.useDocumen`; did you mean `bleed.useDocument`?"),
(json!({"output": {"intent": "x"}}), "unknown parameter `output.intent`;"),
(json!({"advanced": {"outlineTxt": true}}), "unknown parameter `advanced.outlineTxt`; did you mean `advanced.outlineText`?"),
(json!({"security": {"openPasswrd": "x"}}), "unknown parameter `security.openPasswrd`; did you mean `security.openPassword`?"),
(json!({"compression": {"color": {"dpi": 300}}}), "unknown parameter `compression.color.dpi`;"),
(json!({"flattener": {"balanse": 50}}), "unknown parameter `flattener.balanse`; did you mean `flattener.balance`?"),
] {
for id in ["document.export", "document.exportPdf"] {
let got = refusal(id, given.clone());
assert!(got.starts_with(message), "{id} {given}: {got}");
}
}
}
/// The check passes each key `document.formats` lists, each key a command's own description names,
/// and each field of the PDF settings and the flattener options. A key an encoder reads that none
/// of these names is outside this test.
#[test]
fn export_options_pass_every_listed_key() {
use crate::cmd::fileio::{FORMATS, dxf, eps, pdf::OPTIONS};
for f in FORMATS.iter().filter(|f| f.write) {
for o in f.options {
let params = json!({"format": f.id, o.name: null});
assert!(check_params("document.export", &params).is_ok(), "{params}");
}
}
for (id, options) in [("document.exportEps", eps::OPTIONS), ("document.exportDxf", dxf::OPTIONS), ("document.exportPdf", OPTIONS)] {
for o in options {
assert!(check_params(id, &json!({o.name: null})).is_ok(), "{id} {}", o.name);
}
}
for id in [
"document.export",
"document.exportSelection",
"document.serialize",
"document.exportPdf",
"document.pdfSettings",
"pdf.preset.save",
"document.exportEps",
"document.exportDxf",
] {
// An export reads these two when it writes over the file a lossy document came from. The
// PDF settings and preset commands take them through document.exportPdf's description.
check_params(id, &json!({"path": "a", "acknowledgeLoss": true})).unwrap_or_else(|e| panic!("{id}: {e}"));
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();
// `document.exportPdf`'s description explains the page boxes as `BleedBox = …` between its
// keys, which reads as two more keys.
let prose = if id == "document.exportPdf" { vec!["BleedBox", "MediaBox"] } else { vec![] };
assert_eq!(refused, prose, "{id} names and refuses these");
}
let settings = serde_json::to_value(vectorcraft_pdf::PdfSettings::default()).unwrap();
for key in settings.as_object().unwrap().keys() {
assert!(OPTIONS.iter().any(|o| o.name == key), "document.formats doesn't list the PDF option `{key}`");
}
let mut every = settings;
every["flattener"] = serde_json::to_value(crate::cmd::flatten::FlattenOptions::default()).unwrap();
every["security"]["openPassword"] = json!("a");
every["security"]["permissionsPassword"] = json!("b");
every["preset"] = json!("High Quality Print");
for id in ["document.export", "document.exportPdf", "document.pdfSettings", "pdf.preset.save"] {
check_params(id, &every).unwrap_or_else(|e| panic!("{id}: {e}"));
}
}
/// `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]
+16
View File
@@ -109,6 +109,22 @@ fn a_journal_with_a_key_no_longer_read_still_replays() {
assert!(s.execute("object.move", &json!({"dx": 5, "dyy": 5})).is_err());
}
/// `Session::replay` still runs an export with an option that is no longer read, and a preset
/// file that holds one still loads and exports. `Session::execute` refuses the same options.
#[test]
fn an_export_step_or_preset_with_a_key_no_longer_read_still_runs() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let old = json!({"format": "pdf", "noSuchOption": true, "marks": {"useDocumentBleed": true}});
assert!(check_params("document.export", &old).is_err());
assert!(s.execute("document.export", &old).is_err());
s.replay("document.export", &old).unwrap();
let file = json!({"format": "vcpdfpresets", "presets": [{"name": "Old", "settings": {"standard": "none", "noLongerRead": 1, "marks": {"retired": true}}}]});
s.execute("pdf.preset.import", &json!({"data": file.to_string()})).unwrap();
check_params("document.export", &json!({"format": "pdf", "preset": "Old"})).unwrap();
s.execute("document.export", &json!({"format": "pdf", "preset": "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]
+2
View File
@@ -315,6 +315,8 @@ impl Headless {
/// `app.export {path?, format?, …}`: `document.export` (format defaults to the path's extension).
pub fn export(&mut self, p: &Value) -> Result<Value, String> {
// The key check of `document.export` runs before the file is written.
self.session.check_params("document.export", p).map_err(|e| e.to_string())?;
let roots = self.roots.clone();
file_access::confine(roots.as_ref(), || self.session.execute("document.export", p)).map_err(|e| e.to_string())
}
+28
View File
@@ -16,6 +16,34 @@ fn three_boards() -> Headless {
h
}
/// The tool, `app.export` and `file.export` refuse a key no format takes, at the top level of
/// `options` and inside a PDF object.
#[test]
fn an_option_no_format_takes_is_an_error() {
let mut h = Headless::new();
h.call("engine.execute", json!({"command": "file.new", "params": {"width": 50, "height": 30}})).unwrap();
for (options, named) in [
(json!({"standard": "pdfX4", "noSuchOption": true}), "noSuchOption"),
(json!({"marks": {"useDocumentBleed": true}}), "marks.useDocumentBleed"),
] {
let r = call_tool(&mut h, "export", &json!({"format": "pdf", "options": options}));
assert!(r.is_error && r.content[0]["text"].as_str().unwrap().contains(named), "{r:?}");
let mut params = options.clone();
params["format"] = json!("pdf");
for method in ["app.export", "file.export"] {
let e = if method == "app.export" {
h.call(method, params.clone()).unwrap_err()
} else {
h.call("engine.execute", json!({"command": method, "params": params})).unwrap_err()
};
assert!(e.contains(named), "{method}: {e}");
}
}
// One object can still carry several formats' options.
let r = call_tool(&mut h, "export", &json!({"format": "pdf", "options": {"quality": 80, "styling": "style"}}));
assert!(!r.is_error, "{r:?}");
}
#[test]
fn raster_export_uses_document_resolution_unless_overridden() {
let mut h = Headless::new();
+6
View File
@@ -456,6 +456,12 @@ 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)
{
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) })
}
// As the menu's Quit: `{"pending": "saveChanges"}` while a modified document is asked
+22
View File
@@ -159,6 +159,28 @@ fn shortcut_sets_are_read_only_inside_the_read_root() {
let _ = std::fs::remove_dir_all(base);
}
/// `app.export` refuses a key no format takes before anything is written, with a path or without.
#[test]
fn control_export_refuses_an_option_no_format_takes() {
let (mut app, tx, base) = confined("export-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 mut params in [json!({"format": "pdf", "noSuchOption": true}), json!({"format": "pdf", "marks": {"useDocumentBleed": true}})] {
let r = request(&mut app, &tx, "app.export", params.clone());
assert!(refused(&r), "{r}");
params["path"] = json!(s(path.clone()));
params["wait"] = json!(true);
let r = request(&mut app, &tx, "app.export", params);
assert!(refused(&r), "{r}");
assert!(!path.exists());
}
let r = request(&mut app, &tx, "app.export", json!({"format": "pdf", "path": s(path.clone()), "wait": true}));
assert_eq!(r["ok"], true, "{r}");
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}` / `{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. `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. `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}` / `{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. `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.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 |
+18 -3
View File
@@ -1156,8 +1156,8 @@ refused before the command runs:
→ invalid parameters for `file.new`: unknown parameter `widht`; did you mean `width`?
```
Only the top level is checked: the keys of an object inside a value (`artboardLayout: {…}`) are the command's to
read. The one exception is an effect's `params`: `effect.apply` (and `appearance.addEffect`) and `effect.setParams`
Only the top level is checked, except for the export 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),
is refused the same way:
@@ -1195,7 +1195,7 @@ 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 any key when it names none, as `document.export` and `document.save` do for the options
(`…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
(`document.setup`, `text.areaOptions`) says so the same way.
@@ -1204,6 +1204,21 @@ key, the Batch dialog or `ui.action.play`), a key a command no longer reads, rec
and the action still plays. `Session::replay` plays a journal the same way. The command still refuses an effect
`color` or `channel` it can't read.
The export commands limit those keys. `document.export`, `document.exportSelection`, `document.serialize`, `app.export`
and `file.export` refuse a key that no format takes, and ignore a key that only another format takes
(`{"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:
```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.
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
@@ -733,6 +733,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
- Native macOS menu bar, vector tool cursors, a ⌘K command palette, middle-button panning with any tool. Native menus explicitly end tracking on command selection, outside window presses and Escape; menu updates wait until tracking ends, and a dismissing press never starts a canvas gesture (M14.8).
- 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.
- 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`).