Files
vectorcraft/crates/engine/src/cmd/params.rs
T
dotCooCoo f5bfe0c425 Effects: effect.apply refuses a wrongly typed parameter that has no default
effect.apply, appearance.addEffect and effect.setParams checked an effect
parameter's type only when the effect has a dialog default for the key. Keys
that only the params text names were stored as given: an Outline Stroke with
`width: "10 pt"` drew at the stroke's own weight, and a Gaussian Blur with
`linear: 1` blurred in linear light.

- The earlier 3D `perspective`, `lightAzimuth` and `lightElevation`,
  Scribble's `variation` and Outline Stroke's `width` must be numbers, and
  Gaussian Blur's `linear` must be true or false. A test walks every effect
  so a new key without a default needs a listed type or a check of its own.
- The `style` of Crop Marks must be `roman` or `japanese`, and `null` is
  refused. Any other value drew as Roman.
- An installed plug-in effect's parameters are checked against its manifest
  when the command runs, as plugin.run already checks the effect records a
  filter returns.
- effect.setParams with `warpStyle` checks the parameters against the new
  Warp style's effect, as it already did for a 3D effect's new
  `threeDType`. It checked them against the old style.
- effect.setParams refuses a `visible` that is not true or false, such as
  `1` or `"false"`, when called and when a step plays, and nothing changes.
  Before, the value was ignored and the rest of the call applied. `null`
  still leaves the visibility as it is.

A step of command.batch is refused the same way. A replayed journal or
action still plays a wrongly typed effect parameter, as it plays a key the
effect no longer takes.

Docs: docs/mcp.md, docs/target-app-parity.md.
2026-11-12 20:27:09 -08:00

873 lines
40 KiB
Rust

//! The parameter keys a command accepts, read from its params description (#1442).
//!
//! A description starts with the params object: `{x, width?: pt, name?|title?: …, sides=6, …}`, or
//! with the objects a command takes instead of one another: `{path} or {name, dataBase64}`,
//! `{name} | {names: […]}`. Each entry of an object starts with a key, or with alternatives split
//! by `|`; a `?` marks an optional one, and a `:`, `=` or `(` starts what the entry says about it. An entry that starts
//! with `…` stands for keys listed elsewhere: those of each command it names after `as` or `of`
//! (`…settings as file.new`), or, naming none, any key (the options of the format an export picks). Objects inside a value aren't
//! described key by key, so only the top level is checked, except in the options of an export, a
//! save or Export for Screens: those are checked against the options of the formats the command
//! can write, in `marks`, `bleed` and the other objects too ([`check_export_options`]).
//!
//! [`Session::execute`] answers a key the description doesn't name with a parameter error
//! ([`check_params`]), for every caller (the app, the control channel, MCP, the CLI) and for the
//! commands a command runs, the steps of a `command.batch` or an `action.batch` included: a
//! misspelled `widht` no longer runs the command with its default. So are the `params` of an
//! effect (#1592): `effect.apply` and
//! `effect.setParams` answer a key the effect doesn't take, or a value of another type than the
//! effect reads, with a parameter error ([`check_effect_params`]).
//!
//! A command with a parameter schema (`CommandSpec::schema`) is also checked against the schema
//! before it runs ([`crate::schemas::check`]): the schema refuses a value of the wrong type or out
//! of range, a missing required parameter and an unknown key at any depth, and fills in declared
//! defaults, so the command runs with the filled-in params. The check here still runs first for
//! such a command, so a misspelled top-level key gets the same error from either, and a
//! `command.batch` or an `action.batch` checks each step's schema before the first step runs. The
//! keys of commands without a schema, and the options of exports and effects, are checked only as
//! this module describes.
//!
//! Saved steps play through [`Session::replay`] instead: a journal or an action recorded by an older
//! version, with a key a command no longer reads or an effect parameter it no longer takes, still
//! plays. A key a schema doesn't name passes there too, and the values of the parameters it names
//! are still checked. Even there, an effect's `color` that the effect can't read and a Levels or Curves
//! `channel` that the effect doesn't have are refused by `effect.apply` and `effect.setParams`
//! themselves ([`check_effect_color`], [`check_effect_channel`]).
use serde_json::Value;
use vectorcraft_apischema::shown_key;
use super::{bad, command_specs, find_command};
use crate::Result;
/// What a params description says about the keys of the params object.
#[derive(Debug, Default, PartialEq)]
pub struct ParamKeys<'a> {
/// The keys it names.
pub keys: Vec<&'a str>,
/// Its `…` entries, without the `…`.
pub more: Vec<&'a str>,
}
/// The text between the braces of the object `doc` starts with and what follows it, or why it
/// has none.
pub(crate) fn object_body(doc: &str) -> std::result::Result<(&str, &str), String> {
// (Named, not written in the `Err`: these are for the tests of the descriptions, not messages
// the app shows, which the message catalogs would have to translate.)
let (no_object, not_closed) = ("the description doesn't start with the params object `{…}`", "the params object `{…}` isn't closed");
if !doc.starts_with('{') {
return Err(no_object.into());
}
let (mut depth, mut quote) = (0usize, None);
for (i, c) in doc.char_indices() {
match (quote, c) {
(Some(q), _) if c == q => quote = None,
(Some(_), _) => {}
(None, '"' | '`') => quote = Some(c),
(None, '{' | '[' | '(') => depth += 1,
(None, '}' | ']' | ')') => {
depth = depth.saturating_sub(1);
if depth == 0 {
return Ok((doc.get(1..i).unwrap_or_default(), doc.get(i + 1..).unwrap_or_default()));
}
}
_ => {}
}
}
Err(not_closed.into())
}
/// `s` split at each `sep` that is outside brackets, quotes and backticks.
pub(crate) fn split_top(s: &str, sep: char) -> Vec<&str> {
let (mut out, mut depth, mut quote, mut start) = (Vec::new(), 0usize, None, 0);
for (i, c) in s.char_indices() {
match (quote, c) {
(Some(q), _) if c == q => quote = None,
(Some(_), _) => {}
(None, '"' | '`') => quote = Some(c),
(None, '{' | '[' | '(') => depth += 1,
(None, '}' | ']' | ')') => depth = depth.saturating_sub(1),
(None, _) if c == sep && depth == 0 => {
out.push(s.get(start..i).unwrap_or_default());
start = i + c.len_utf8();
}
_ => {}
}
}
out.push(s.get(start..).unwrap_or_default());
out
}
pub(crate) fn is_key(s: &str) -> bool {
let mut chars = s.chars();
chars.next().is_some_and(|c| c.is_ascii_alphabetic() || c == '_') && chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
}
/// The keys a params description names, or what in it doesn't read as one.
pub fn doc_keys(doc: &str) -> std::result::Result<ParamKeys<'_>, String> {
let mut out = ParamKeys::default();
let (mut body, mut rest) = object_body(doc)?;
let mut entries = split_top(body, ',');
// `{…} or {…}`, `{…} | {…}`: the objects the command takes instead.
while let Some(next) = rest.trim_start().strip_prefix("or ").or_else(|| rest.trim_start().strip_prefix("| ")).filter(|r| r.starts_with('{')) {
(body, rest) = object_body(next)?;
entries.extend(split_top(body, ','));
}
for entry in entries.into_iter().map(str::trim).filter(|e| !e.is_empty()) {
if let Some(more) = entry.strip_prefix('…') {
out.more.push(more.trim());
continue;
}
// `a?|b?: note` names two keys, and so does `a?: note | b?: note`; in `a?: x|y` the `y`
// is a value.
let mut noted = false;
for alternative in split_top(entry, '|') {
let head = alternative.split([':', '=', '(']).next().unwrap_or_default();
let rest = alternative.get(head.len()..).unwrap_or_default();
let name = head.trim().trim_end_matches('?').trim_end();
if !noted {
if !is_key(name) {
let start: String = entry.chars().take(60).collect();
let why = format!("`{start}` doesn't start with a parameter name");
return Err(why);
}
out.keys.push(name);
noted = !rest.is_empty();
} else if is_key(name) && (head.trim_end().ends_with('?') || rest.starts_with(':')) {
out.keys.push(name);
}
}
}
Ok(out)
}
/// The commands whose keys a `…` entry stands for: those it names after `as` or `of` (`settings as
/// file.new`, `the PDF options of document.exportPdf`), not one it only points to (`see …`).
fn named_commands(more: &str) -> impl Iterator<Item = &'static super::CommandSpec> + '_ {
let words: Vec<&str> = more.split_whitespace().collect();
let id = |w: &str| find_command(w.trim_matches(|c: char| !c.is_ascii_alphanumeric()));
(1..words.len()).filter_map(move |i| id(words.get(i)?).filter(|_| matches!(words.get(i - 1), Some(&"as" | &"of"))))
}
/// Every key command `doc` accepts, the keys of the commands its `…` entries name included.
/// `None`: any key (a `…` entry naming no command, or a description that doesn't parse, which
/// the tests rule out).
fn accepted(doc: &'static str, depth: usize) -> Option<Vec<&'static str>> {
let parsed = doc_keys(doc).ok()?;
let mut keys = parsed.keys;
for more in parsed.more {
let mut named = named_commands(more).peekable();
// A reference that leads back here would never end: descriptions refer one or two deep.
if named.peek().is_none() || depth > 4 {
return None;
}
for spec in named {
// A command that isn't checked yet doesn't vouch for the ones that take its keys.
if UNCHECKED.contains(&spec.id) {
return None;
}
keys.extend(accepted(spec.params, depth + 1)?);
}
}
Some(keys)
}
/// Does the description `doc` name `key` as a parameter (its own keys, and those of the commands
/// its `…` entries name)?
pub(crate) fn takes_key(doc: &'static str, key: &str) -> bool {
doc_keys(doc).is_ok_and(|parsed| parsed.keys.contains(&key)) || accepted(doc, 0).is_some_and(|keys| keys.contains(&key))
}
/// Does the description `doc` take `id` as `ids` of one object (`{ids?|id?}`, `{id?, ids?: …
/// (instead of id)}`, `{id} or {ids: […]}`): it names both keys, and no params object of it names
/// `ids` beside a required `id`, which is something else (`plugin.run {id, ids?}`: the plug-in's)?
pub(crate) fn id_stands_for_ids(doc: &'static str) -> bool {
fn head(alternative: &str) -> &str {
alternative.split([':', '=', '(']).next().unwrap_or_default().trim()
}
if !takes_key(doc, "ids") || !takes_key(doc, "id") {
return false;
}
let mut rest = doc;
while let Ok((body, after)) = object_body(rest) {
let entries = split_top(body, ',');
let required_id = entries.iter().any(|e| split_top(e, '|').len() == 1 && head(e) == "id");
let names_ids = entries.iter().any(|e| split_top(e, '|').iter().any(|a| head(a).trim_end_matches('?').trim_end() == "ids"));
if required_id && names_ids {
return false;
}
match after.trim_start().strip_prefix("or ").or_else(|| after.trim_start().strip_prefix("| ")).filter(|r| r.starts_with('{')) {
Some(next) => rest = next,
None => break,
}
}
true
}
/// The edits (insert, delete, replace, swap of neighbours) that turn `a` into `b`, ignoring case.
fn distance(a: &str, b: &str) -> usize {
let (a, b): (Vec<char>, Vec<char>) = (a.chars().map(|c| c.to_ascii_lowercase()).collect(), b.chars().map(|c| c.to_ascii_lowercase()).collect());
let (mut before, mut prev): (Vec<usize>, Vec<usize>) = (Vec::new(), (0..=b.len()).collect());
for (i, ca) in a.iter().enumerate() {
let mut row = vec![i + 1];
for (j, cb) in b.iter().enumerate() {
let at = |r: &[usize], k: usize| r.get(k).copied().unwrap_or(usize::MAX / 2);
let mut d = (at(&prev, j) + usize::from(ca != cb)).min(at(&prev, j + 1) + 1).min(at(&row, j) + 1);
if i > 0 && j > 0 && a.get(i - 1) == Some(cb) && b.get(j - 1) == Some(ca) {
d = d.min(at(&before, j - 1) + 1);
}
row.push(d);
}
before = std::mem::replace(&mut prev, row);
}
prev.last().copied().unwrap_or(0)
}
/// The key of `keys` a misspelt `key` most likely meant: the nearest one, when it is near.
fn nearest<'a>(key: &str, keys: &[&'a str]) -> Option<&'a str> {
let len = key.chars().count();
let most = (len / 3).max(1);
// A key whose length differs by more than `most` is more than `most` edits away and isn't
// measured.
let near = keys.iter().filter(|k| k.chars().count().abs_diff(len) <= most).map(|k| (distance(key, k), *k)).min()?;
(near.0 <= most).then_some(near.1)
}
/// `params` has only keys the description `doc` of command `id` names, or the parameter error
/// naming the first one it doesn't, with the nearest accepted key or the accepted ones.
pub fn check_keys(id: &str, doc: &'static str, params: &Value) -> Result<()> {
let Value::Object(given) = params else { return Ok(()) };
if given.is_empty() {
return Ok(());
}
let Some(keys) = accepted(doc, 0) else { return Ok(()) };
match given.keys().find(|k| !keys.contains(&k.as_str())) {
Some(key) => Err(unknown(id, key, &keys)),
None => Ok(()),
}
}
/// The parameter error of command `id` for `key`, which isn't one of `keys`: with the nearest of
/// them, or all of them.
pub(crate) fn unknown(id: &str, key: &str, keys: &[&str]) -> crate::EngineError {
let near = nearest(key, keys);
let key = shown_key(key);
bad(
id,
match near {
Some(near) => format!("unknown parameter `{key}`; did you mean `{near}`?"),
None if keys.is_empty() => format!("unknown parameter `{key}`; the command takes no parameters"),
None => format!("unknown parameter `{key}`; the command takes: {}", keys.join(", ")),
},
)
}
/// `params` of built-in effect `effect`, as command `id` was given them (#1592): only keys the
/// effect takes (those of its dialog defaults and of its params description), each of the type
/// the effect reads ([`check_effect_types`]), a `color` the effect can draw ([`check_effect_color`])
/// and a Levels or Curves `channel` the effect has ([`check_effect_channel`]). Numbers out of range
/// pass: the effect clamps them when it draws. An installed effect plug-in's are checked against its
/// manifest, and an unknown effect is the command's to report.
pub fn check_effect_params(id: &str, effect: &str, params: &Value) -> Result<()> {
let Value::Object(given) = params else { return Ok(()) };
if effect.starts_with(vectorcraft_plugins::effect::PREFIX) {
return check_effect_types(id, effect, params);
}
let Some(info) = vectorcraft_render::effects::effect_info(effect) else { return Ok(()) };
let defaults = info.defaults.as_object();
let mut keys: Vec<&str> = defaults.into_iter().flat_map(|d| d.keys().map(String::as_str)).collect();
// A description that doesn't parse names no more keys (the tests rule that out).
for key in doc_keys(info.params).map(|d| d.keys).unwrap_or_default() {
if !keys.contains(&key) {
keys.push(key);
}
}
if let Some(key) = given.keys().find(|k| !keys.contains(&k.as_str())) {
return Err(unknown(id, key, &keys));
}
check_effect_types(id, effect, params)?;
// An SVG filter's XML is one drawable <filter> element.
if effect == vectorcraft_render::effects::SVG_FILTER
&& let Some(xml) = given.get("xml")
{
let xml = xml.as_str().ok_or_else(|| bad(id, "`xml` must be a string"))?;
vectorcraft_render::effects::svg_filter::check(xml).map_err(|e| bad(id, e))?;
}
if effect == vectorcraft_render::effects::SVG_FILTER
&& let Some(scale) = given.get("scale")
&& !scale.as_f64().is_some_and(|k| (0.001..=1000.0).contains(&k))
{
return Err(bad(id, "`scale` must be a number from 0.001 to 1000"));
}
if effect == vectorcraft_render::effects::SVG_FILTER
&& let Some(origin) = given.get("origin")
&& !origin.as_array().is_some_and(|o| o.len() == 2 && o.iter().all(|v| v.as_f64().is_some_and(f64::is_finite)))
{
return Err(bad(id, "`origin` must be [x, y] in points"));
}
// Its name is the filter's id.
if effect == vectorcraft_render::effects::SVG_FILTER && given.contains_key("name") && !given.contains_key("xml") {
return Err(bad(id, "an SVG filter is named by its id: give `xml` with `name`"));
}
// A Warp's map is one this build draws: 0, the earlier map, or its style's Bézier net.
let newest = vectorcraft_render::effects::WARP_NET;
if effect.starts_with("warp.")
&& let Some(v) = given.get("version")
&& vectorcraft_render::effects::warp_version(v).is_none()
{
return Err(bad(id, format!("`version` must be 0 or {newest}")));
}
check_effect_color(id, effect, params)?;
check_effect_channel(id, effect, params)
}
/// The type of the values in `params` of effect `effect`, as command `id` was given them: a key
/// the effect has a dialog default for takes a value of its default's type, a key only the
/// effect's params description names takes the type the effect reads ([`undefaulted_type`]), and
/// an installed effect plug-in's take the types its manifest gives them. The `style` of Crop Marks
/// is `roman` or `japanese`. A number given as text (`"10 pt"`), `true` for a number, `1` for a
/// switch and `null` for either are refused.
fn check_effect_types(id: &str, effect: &str, params: &Value) -> Result<()> {
let Value::Object(given) = params else { return Ok(()) };
if effect.starts_with(vectorcraft_plugins::effect::PREFIX) {
return match vectorcraft_plugins::effect::installed(effect) {
Some(plugin) => plugin.manifest().resolve_params(params).map(drop).map_err(|why| bad(id, why.to_string())),
None => Ok(()),
};
}
let Some(info) = vectorcraft_render::effects::effect_info(effect) else { return Ok(()) };
for (key, v) in given {
let want = info.defaults.get(key).cloned().or_else(|| undefaulted_type(effect, key));
match want {
Some(Value::Number(_)) if !v.is_number() => return Err(bad(id, format!("`{key}` must be a number"))),
Some(Value::Bool(_)) if !v.is_boolean() => return Err(bad(id, format!("`{key}` must be true or false"))),
// (A colour is also taken as `[r, g, b]` or as a colour object.)
Some(Value::String(_)) if v.is_number() || v.is_boolean() => return Err(bad(id, format!("`{key}` must be a string"))),
_ => {}
}
}
// Crop Marks draw one of two styles.
if effect == vectorcraft_render::effects::CROP_MARKS
&& let Some(style) = given.get("style")
&& style.as_str().and_then(vectorcraft_doc::marks::MarkStyle::parse).is_none()
{
return Err(bad(id, "`style` must be roman or japanese"));
}
Ok(())
}
/// A value of the type effect `effect` reads for `key`, a parameter its params description names
/// without a dialog default: the earlier 3D Perspective and light (`perspective`, `lightAzimuth`,
/// `lightElevation`) and Scribble `variation` are numbers, an Outline Stroke's `width` is a number
/// and a Gaussian Blur's `linear` is true or false. A Glass or Texturizer `textureImage`, an SVG
/// filter's `name` and `xml`, a Warp's `version` and the `style` of Crop Marks have checks of their
/// own; they aren't listed.
pub(crate) fn undefaulted_type(effect: &str, key: &str) -> Option<Value> {
use vectorcraft_render::effects;
let number = || Some(Value::from(0.0));
match key {
"perspective" | "lightAzimuth" | "lightElevation" if effects::is_three_d(effect) => number(),
"variation" if effect == "stylize.scribble" => number(),
"width" if effect == "path.outlineStroke" => number(),
"linear" if effect == "blur.gaussian" => Some(Value::Bool(false)),
_ => None,
}
}
/// Can an effect read `v` as its `color`: a string [`Color::from_hex`] reads (`#rrggbb`, `#rgb`,
/// `#rrggbbaa`, black, white, red, green, blue), `[r, g, b]` whose first three entries are numbers
/// (read on a 0..1 scale) or a color object (`{"model": "rgb", "r": …}`)? The effect draws its
/// default color for any other value, except that an array of three or more entries draws an
/// entry that isn't a number as 0.
///
/// [`Color::from_hex`]: vectorcraft_color::Color::from_hex
fn is_effect_color(v: &Value) -> bool {
match v {
Value::String(s) => vectorcraft_color::Color::from_hex(s).is_some(),
Value::Array(a) => a.len() >= 3 && a.iter().take(3).all(Value::is_number),
Value::Object(_) => serde_json::from_value::<vectorcraft_color::Color>(v.clone()).is_ok(),
_ => false,
}
}
/// The `color` in `params` of built-in effect `effect`, when the effect has a `color` parameter,
/// as command `id` was given it: a parameter error naming it unless the effect can read it
/// ([`is_effect_color`]). `effect.apply` and `effect.setParams` run this themselves before they
/// edit, so the app's own edits, the steps of a `command.batch` and a replayed journal are held to
/// it too.
pub(crate) fn check_effect_color(id: &str, effect: &str, params: &Value) -> Result<()> {
let key = "color";
let Some(v) = params.get(key) else { return Ok(()) };
if effect_takes_color(effect) && !is_effect_color(v) {
return Err(bad(id, format!("`{key}` must be a #rrggbb color")));
}
Ok(())
}
/// Does built-in effect `effect` have a `color` parameter (one whose default is a string)? Plug-in
/// effects have none here.
pub(crate) fn effect_takes_color(effect: &str) -> bool {
!effect.starts_with(vectorcraft_plugins::effect::PREFIX)
&& vectorcraft_render::effects::effect_info(effect).is_some_and(|info| info.defaults.get("color").is_some_and(Value::is_string))
}
/// The color that built-in effect `effect` draws with `params`: their `color`, else the effect's
/// default color. `None` when the effect has no `color` parameter ([`effect_takes_color`]) or
/// `params` hold a `color` it can't read ([`is_effect_color`]).
pub(crate) fn effect_color(effect: &str, params: &Value) -> Option<vectorcraft_color::Color> {
if !effect_takes_color(effect) || params.get("color").is_some_and(|v| !is_effect_color(v)) {
return None;
}
let merged = vectorcraft_render::effects::merged_params(effect, params);
Some(vectorcraft_render::effects::color_param(&merged, vectorcraft_color::Color::BLACK))
}
/// The `channel` in `params` of Levels or Curves (`effect`), as command `id` was given it: a
/// parameter error naming the channels unless the effect reads it
/// ([`vectorcraft_render::effects::is_channel`]); the effect draws any other value as RGB.
/// `effect.apply` and `effect.setParams` run this themselves before they edit, as they run
/// [`check_effect_color`].
pub(crate) fn check_effect_channel(id: &str, effect: &str, params: &Value) -> Result<()> {
let key = "channel";
let Some(v) = params.get(key) else { return Ok(()) };
if !matches!(effect, "adjust.levels" | "adjust.curves") || v.as_str().is_some_and(vectorcraft_render::effects::is_channel) {
return Ok(());
}
Err(bad(id, format!("`{key}` must be one of {}", vectorcraft_render::effects::CHANNELS.join(", "))))
}
/// Commands whose params aren't checked yet (#1442): they read keys in ways the audit of the
/// descriptions couldn't follow (a list of keys in a loop, a whole object read as settings), so
/// their descriptions aren't proven to name every key they take. Sorted; take a command out once
/// its description is checked against its code.
pub const UNCHECKED: &[&str] = &[
"appearance.copyFrom",
"appearance.setItem",
"artboard.rearrange",
"assets.export",
"assets.settings.set",
"attributes.info",
"attributes.set",
"brush.freehand",
"css.export",
"css.generate",
"css.selection",
"document.dxfInfo",
"document.exportDxf",
"document.exportEps",
"document.exportForScreens",
"document.exportForWeb",
"document.exportForWeb.preview",
"document.exportPdf",
"document.find",
"document.inspect",
"document.node",
"document.open",
"document.pdfInfo",
"document.pdfSettings",
"document.rasterEffectsSettings",
"edit.assignProfile",
"edit.colors.adjustBalance",
"edit.colors.invert",
"edit.colors.saturate",
"edit.colors.toCMYK",
"edit.colors.toGrayscale",
"edit.colors.toRGB",
"edit.paste",
"edit.pasteInBack",
"edit.pasteInFront",
"edit.pasteInPlace",
"edit.pasteOnAllArtboards",
"edit.pasteWithoutFormatting",
"effect.apply",
"eyedropper.setOptions",
"file.info",
"file.newFromTemplate",
"file.newPresets.save",
"file.place",
"file.place.info",
"file.place.queue",
"flattener.presets.save",
"flattener.preview",
"graph.design",
"graph.setType",
"graphicStyle.loadLibrary",
"layer.new",
"layer.newSublayer",
"layer.releaseToLayersBuild",
"layer.setProps",
"library.add",
"library.deleteGroup",
"library.moveItem",
"library.removeItem",
"library.renameGroup",
"library.use",
"links.placementOptions",
"livePaint.fill",
"livePaint.strokeEdge",
"magicWand.set",
"object.align",
"object.blend.make",
"object.blend.options",
"object.distribute",
"object.distributeSpacing",
"object.expand",
"object.flattenTransparency",
"object.liquify",
"object.path.splitIntoGrid",
"object.puppetWarp",
"object.rasterize",
"object.repeat.options",
"object.setBounds",
"object.setLiveShape",
"object.setOverprint",
"object.setProps",
"object.slice.divide",
"object.slice.options",
"object.transformAgain",
"paint.complement",
"paint.editGradient",
"paint.freeform.addLine",
"paint.freeform.addPoint",
"paint.freeform.deletePoint",
"paint.freeform.setPoint",
"paint.freeform.splitLine",
"paint.invert",
"paint.lastColor",
"paint.lastGradient",
"paint.setFill",
"paint.setGradientGeom",
"path.appendAnchor",
"pattern.options",
"pdf.preset.save",
"perspective.grid.define",
"perspective.presets.save",
"print.tiling",
"recolor.apply",
"select.editSaved",
"shape.flare",
"shape.polarGrid",
"shape.rectangularGrid",
"stroke.setAdvanced",
"swatch.edit",
"swatch.library.load",
"text.setFormat",
"text.setRangeStyle",
"text.setStyle",
"text.tabs.clear",
"text.tabs.get",
"text.tabs.set",
"transparency.disableOpacityMask",
"transparency.enableOpacityMask",
"transparency.linkOpacityMask",
"transparency.set",
"transparency.setOpacityMask",
"transparency.unlinkOpacityMask",
"type.pathOptions",
"webExport.presets.save",
"webExport.settings",
];
/// 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(", ")),
};
let key = shown_key(key);
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(())
}
/// `given`, the options object at `within` (`""` for the params themselves, `options.`,
/// `formats[0].`), has only `keys`, and only their own keys in the PDF and EPS object options
/// (`trees`). `why_not` says why a key that is not near any of `keys` is refused, and where the
/// keys are listed.
fn check_option_keys(id: &str, within: &str, given: &serde_json::Map<String, Value>, keys: &[&str], why_not: &str, trees: &Value) -> Result<()> {
for (key, value) in given {
if !keys.contains(&key.as_str()) {
let near = nearest(key, keys);
let key = shown_key(key);
return Err(bad(
id,
match near {
Some(near) => format!("unknown parameter `{within}{key}`; did you mean `{within}{near}`?"),
None => format!("unknown parameter `{within}{key}`; {why_not}"),
},
));
}
if let (Some(Value::Object(schema)), Value::Object(value)) = (trees.get(key), value) {
check_option_tree(id, &format!("{within}{key}."), value, schema)?;
}
}
Ok(())
}
/// Why an export option that no format reads is refused.
const NO_FORMAT: &str = "no format takes it (see document.formats)";
/// Why a save option that no save format reads is refused.
const NO_SAVE_FORMAT: &str = "no save format takes it (see file.formatOptions)";
/// The format ids Export for Screens writes: the writable formats, those exporter plug-ins add
/// included, but those it doesn't write.
pub fn screen_formats() -> Vec<&'static str> {
use super::fileio::{FORMATS, SCREEN_UNWRITTEN_FORMATS, plugin_formats};
let added = plugin_formats();
FORMATS.iter().chain(added.iter().copied()).filter(|f| f.write && !SCREEN_UNWRITTEN_FORMATS.contains(&f.id)).map(|f| f.id).collect()
}
/// The keys a format row of Export for Screens, and the settings of a format, take: the row's own
/// (`format`, `scale`…), `includeArtboardFill` and `richBlack`, which the encoders read from a
/// row's options (`with_artboard_fills`, the render options), `params`, an exporter plug-in's own,
/// and a key some format reads, as `document.export` takes them.
fn screen_option_keys() -> Vec<&'static str> {
use super::fileio::{FORMATS, SCREEN_ROW_KEYS};
let mut keys: Vec<&str> = SCREEN_ROW_KEYS.to_vec();
keys.extend(["includeArtboardFill", "richBlack", "params"]);
keys.extend(FORMATS.iter().filter(|f| f.write).flat_map(|f| f.options).map(|o| o.name));
keys
}
/// A format row of Export for Screens, or the settings of a format, without the keys the export
/// refuses in it ([`check_screen_rows`]), inside `marks`, `svg` and the other object options too:
/// a saved row or preset that holds a key that is no longer read loads without it.
pub fn drop_unread_row_options(row: &mut serde_json::Map<String, Value>) {
fn retain_tree(o: &mut serde_json::Map<String, Value>, schema: &serde_json::Map<String, Value>) {
o.retain(|k, _| schema.contains_key(k));
for (k, v) in o.iter_mut() {
if let (Some(Value::Object(inner)), Value::Object(v)) = (schema.get(k), v) {
retain_tree(v, inner);
}
}
}
let keys = screen_option_keys();
row.retain(|k, _| keys.contains(&k.as_str()));
let trees = screen_option_trees();
for (k, v) in row.iter_mut() {
if let (Some(Value::Object(schema)), Value::Object(v)) = (trees.get(k), v) {
retain_tree(v, schema);
}
}
}
/// [`option_trees`] of Export for Screens: its remembered settings mark PDF security whose
/// passwords were left out with `security.passwordRequired`, which the export reads.
fn screen_option_trees() -> Value {
let mut trees = option_trees();
if let Some(security) = trees.get_mut("security").and_then(Value::as_object_mut) {
security.insert("passwordRequired".into(), Value::Null);
}
trees
}
/// The object options an export or save checks key by key: PDF's `marks`, `bleed` and the others,
/// the flattener's, and SVG's `svg` with the artboard keys it may carry.
fn option_trees() -> Value {
let mut trees = pdf_option_tree();
if let Some(tree) = trees.as_object_mut() {
tree.insert("flattener".into(), serde_json::to_value(super::flatten::FlattenOptions::default()).unwrap_or_default());
let mut svg = serde_json::to_value(vectorcraft_svg::ExportOptions::default()).unwrap_or_default();
if let Some(svg) = svg.as_object_mut() {
svg.extend(super::fileio::ARTBOARD_PARAMS.iter().chain(&["useArtboards"]).map(|k| (k.to_string(), Value::Null)));
}
tree.insert("svg".into(), svg);
}
trees
}
/// The format rows (`formats`) and the per-format `settings` of Export for Screens params `p`, at
/// `within` (`settings.` in `document.exportPresets.save`). A row, and the settings of a format,
/// take [`screen_option_keys`]; `settings` takes the formats Export for Screens writes.
fn check_screen_rows(id: &str, within: &str, p: &serde_json::Map<String, Value>) -> Result<()> {
let keys = screen_option_keys();
let trees = &screen_option_trees();
if let Some(Value::Array(rows)) = p.get("formats") {
for (i, row) in rows.iter().enumerate() {
if let Value::Object(row) = row {
check_option_keys(id, &format!("{within}formats[{i}]."), row, &keys, NO_FORMAT, trees)?;
}
}
}
if let Some(Value::Object(settings)) = p.get("settings") {
let formats = screen_formats();
for (format, options) in settings {
if !formats.contains(&format.as_str()) {
let why = match nearest(format, &formats) {
Some(near) => format!("did you mean `{within}settings.{near}`?"),
None => format!("`{within}settings` takes: {}", formats.join(", ")),
};
let format = shown_key(format);
return Err(bad(id, format!("unknown parameter `{within}settings.{format}`; {why}")));
}
if let Value::Object(options) = options {
check_option_keys(id, &format!("{within}settings.{format}."), options, &keys, NO_FORMAT, trees)?;
}
}
}
Ok(())
}
/// The options of an export or save command `id`. `document.export`, `document.exportSelection`
/// and `document.serialize` take a key that some format reads (one object can carry several
/// formats' options; `document.formats` lists them). `document.exportPdf`, `document.pdfSettings`
/// and `pdf.preset.save` take a key that PDF reads, `document.exportEps` one that EPS reads and
/// `document.exportDxf` one that DXF reads. `document.save`, `file.saveAs`, `file.saveCopy` and
/// `file.saveAsTemplate` take, beside the path and in `options`, a key that some save format
/// reads. `document.exportForScreens` takes the keys it reads, and checks its format rows and
/// settings ([`check_screen_rows`]), as `document.exportPresets.save` does in its `settings` and
/// `assets.export` and `assets.settings.set` do in theirs.
/// Where a command takes `marks`, `bleed`, `flattener`, `svg` or another object option of PDF,
/// EPS or SVG, only the object's own keys pass ([`option_trees`]). Saved presets are not checked:
/// one with a key that is no longer read keeps loading.
fn check_export_options(id: &str, params: &Value) -> Result<()> {
use super::fileio::{FORMATS, SAVE_FORMATS, SCREEN_PARAM_KEYS, TEMPLATE_FORMATS, dxf, eps, pdf};
let Value::Object(given) = params else { return Ok(()) };
match (id, given.get("settings")) {
("document.exportForScreens", _) => {
if let Some(key) = given.keys().find(|k| !SCREEN_PARAM_KEYS.contains(&k.as_str())) {
return Err(unknown(id, key, &SCREEN_PARAM_KEYS));
}
return check_screen_rows(id, "", given);
}
("document.exportPresets.save", Some(Value::Object(settings))) => {
let why = format!("`settings` takes: {}", SCREEN_PARAM_KEYS.join(", "));
check_option_keys(id, "settings.", settings, &SCREEN_PARAM_KEYS, &why, &Value::Null)?;
return check_screen_rows(id, "settings.", settings);
}
// Export Assets exports with, and stores, Export for Screens' format rows and settings.
("assets.export" | "assets.settings.set", _) => return check_screen_rows(id, "", given),
_ => {}
}
let mut keys: Vec<&str> = vec!["path", "format", "acknowledgeLoss"];
// A save's `options` and the keys they take.
let mut inner: Option<(&serde_json::Map<String, Value>, Vec<&str>)> = None;
let why_not = match id {
"document.save" | "file.saveAs" | "file.saveCopy" | "file.saveAsTemplate" => {
let saved = FORMATS.iter().filter(|f| SAVE_FORMATS.contains(&f.id) || TEMPLATE_FORMATS.contains(&f.id));
let options: Vec<&str> = saved.flat_map(|f| f.options).map(|o| o.name).collect();
if let Some(Value::Object(o)) = given.get("options") {
inner = Some((o, options.clone()));
}
keys.extend(["options", "modified"]);
keys.extend(options);
NO_SAVE_FORMAT
}
"document.export" | "document.exportSelection" | "document.serialize" => {
// `richBlack` is Appearance of Black's setting for a raster export, and `params` an
// exporter plug-in's own.
keys.extend(["includeArtboardFill", "selectedOnly", "richBlack", "params"]);
keys.extend(FORMATS.iter().filter(|f| f.write).flat_map(|f| f.options).map(|o| o.name));
NO_FORMAT
}
"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 (see document.formats)"
}
"document.exportEps" => {
keys.push("includeArtboardFill");
keys.extend(eps::OPTIONS.iter().map(|o| o.name));
"EPS doesn't take it (see document.formats)"
}
"document.exportDxf" => {
keys.extend(dxf::OPTIONS.iter().map(|o| o.name));
"DXF doesn't take it (see document.formats)"
}
_ => return Ok(()),
};
let trees = option_trees();
if let Some((o, options)) = inner {
check_option_keys(id, "options.", o, &options, NO_SAVE_FORMAT, &trees)?;
}
check_option_keys(id, "", given, &keys, why_not, &trees)
}
/// [`check_keys`] for an engine command, the steps of a `command.batch` or an `action.batch` included,
/// [`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)
}
/// [`check_params`] for a step of a batch when `in_batch`: batches can't nest, so the steps of a
/// `command.batch` or `action.batch` step aren't checked (running it reports that), however deep they go.
fn check_params_in(id: &str, params: &Value, in_batch: bool) -> Result<()> {
let Some(spec) = find_command(id) else { return Ok(()) };
if let ("effect.apply" | "appearance.addEffect", Some(effect), Some(given)) =
(id, params.get("effect").or_else(|| params.get("id")).and_then(Value::as_str), params.get("params"))
{
check_effect_params(id, effect, given)?;
}
check_export_options(id, params)?;
if UNCHECKED.contains(&id) {
return Ok(());
}
check_keys(id, spec.params, params)?;
if in_batch {
crate::schemas::check(spec, params, false)?;
}
if matches!(id, "command.batch" | "action.batch") && !in_batch {
for step in params.get("commands").and_then(Value::as_array).into_iter().flatten() {
if let Some(step_id) = step.get("command").and_then(Value::as_str) {
check_params_in(step_id, step.get("params").unwrap_or(&Value::Null), true)?;
}
}
}
Ok(())
}
/// The command id a misspelled `id`, which names no command, most likely meant: the nearest one,
/// when it is near (as [`check_keys`] matches a misspelled key).
pub(crate) fn nearest_command(id: &str) -> Option<&'static str> {
let ids: Vec<&'static str> = command_specs().iter().map(|c| c.id).collect();
nearest(id, &ids)
}
/// The error for `id`, which names no command: with the nearest command id, when one is near.
pub(crate) fn unknown_command(id: &str) -> crate::EngineError {
match nearest_command(id) {
Some(near) => crate::EngineError::UnknownCommandNear(shown_key(id), near.to_string()),
None => crate::EngineError::UnknownCommand(shown_key(id)),
}
}
impl crate::Session {
/// [`check_params`], and [`check_effect_params`] for the effects `effect.setParams` edits in
/// this session's document (not as a step of a `command.batch`: what it edits is only there
/// once the steps before it ran).
pub fn check_params(&self, id: &str, params: &Value) -> Result<()> {
check_params(id, params)?;
if let ("effect.setParams", Some(given)) = (id, params.get("params")) {
for effect in super::effectcmd::set_params_effects(self, params) {
check_effect_params(id, &effect, given)?;
}
}
Ok(())
}
}