Files
vectorcraft/crates/engine/src/cmd/params.rs
T
dotCooCoo 22ce516e2d Appearance: the inline effect editor shows 3D, Warp, Rasterize and channel choices as dropdowns
The Appearance panel's inline effect editor still showed eight choice
parameters as text fields holding the stored value: Revolve's edge,
Extrude & Bevel's bevel, every Warp style's orientation, Rasterize's
Color Model, Background and Anti-aliasing, and the channel of Levels
and Curves. Their dialogs built these lists in their own bodies, so
effect::choices did not hold them. Nothing checked a channel's value:
a typo was stored, and Levels or Curves drew it as RGB.

effect::own_choices now holds these lists, each label as its dialog
passes it to its dropdown or radio buttons. The dialogs read the lists
from it (Rasterize's Anti-aliasing from the AntiAlias list it is built
from), and the inline editor shows each one as a dropdown with the
dialog's translated labels; Rasterize's Color Model reads CMYK or RGB
by the document's mode. effect.apply and effect.setParams refuse a
Levels or Curves channel other than rgb, red, green or blue (r, g, b
and any letter case are read too) with "`channel` must be one of rgb,
red, green, blue", and the document and its undo history stay as they
were.

Docs: docs/mcp.md (effect params), docs/target-app-parity.md.
2026-10-18 12:34:36 +07:00

471 lines
20 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.
//!
//! Callers outside the app (the control channel, MCP, the CLI) are answered with a parameter error
//! for a key the description doesn't name ([`check_params`]): a misspelt `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 its
//! default, with a parameter error ([`check_effect_params`]). [`Session::execute`] itself takes
//! what it is given, so the journal of an older version still replays, except for an effect's
//! `color` that the effect can't read and a Levels or Curves `channel` that the effect doesn't
//! have, which `effect.apply` and `effect.setParams` refuse themselves ([`check_effect_color`],
//! [`check_effect_channel`]).
use serde_json::Value;
use super::{bad, 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.
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.
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
}
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))
}
/// 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 near = keys.iter().map(|k| (distance(key, k), *k)).min()?;
(near.0 <= (key.chars().count() / 3).max(1)).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.
fn unknown(id: &str, key: &str, keys: &[&str]) -> crate::EngineError {
bad(
id,
match nearest(key, keys) {
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 its
/// default's type, 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 effect plug-in resolves its own 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 Ok(());
}
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);
}
}
for (key, v) in given {
if !keys.contains(&key.as_str()) {
return Err(unknown(id, key, &keys));
}
match defaults.and_then(|d| d.get(key)) {
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"))),
_ => {}
}
}
check_effect_color(id, effect, params)?;
check_effect_channel(id, effect, params)
}
/// 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.starts_with(vectorcraft_plugins::effect::PREFIX) {
return Ok(());
}
let takes_color = vectorcraft_render::effects::effect_info(effect).is_some_and(|info| info.defaults.get(key).is_some_and(Value::is_string));
if takes_color && !is_effect_color(v) {
return Err(bad(id, format!("`{key}` must be a #rrggbb color")));
}
Ok(())
}
/// 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.set",
"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",
];
/// [`check_keys`] for an engine command, the steps of a `command.batch` included, and
/// [`check_effect_params`] for the effect `effect.apply` adds. A command the engine doesn't have
/// passes: running it reports that.
pub fn check_params(id: &str, params: &Value) -> 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)?;
}
if UNCHECKED.contains(&id) {
return Ok(());
}
check_keys(id, spec.params, params)?;
if id == "command.batch" {
for step in params.get("commands").and_then(Value::as_array).into_iter().flatten() {
if let (Some(step_id), Some(step_params)) = (step.get("command").and_then(Value::as_str), step.get("params")) {
check_params(step_id, step_params)?;
}
}
}
Ok(())
}
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(())
}
}