//! 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 its //! default, 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, 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 + '_ { 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> { 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, Vec) = (a.chars().map(|c| c.to_ascii_lowercase()).collect(), b.chars().map(|c| c.to_ascii_lowercase()).collect()); let (mut before, mut prev): (Vec, Vec) = (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 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"))), _ => {} } } // An SVG filter's XML is one drawable 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) } /// 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::(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 { 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, schema: &serde_json::Map) -> 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, 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) { fn retain_tree(o: &mut serde_json::Map, schema: &serde_json::Map) { 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) -> 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, 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(()) } }