//! The command registry. Ids follow Illustrator's menu structure. pub(crate) mod appearance; mod assets; mod attributes; mod brushsym; mod buildcmds; pub mod clipboard; mod colorcmds; pub mod colormgmt; pub mod colortheme; mod create; mod css; mod cut; pub(crate) mod distortcmds; mod docinfo; mod docmenu; pub(crate) mod docsetup; mod draw2; mod edit; mod effectcmd; pub mod expand; pub(crate) mod fileinfo; pub mod fileio; pub mod findfiles; pub mod flatten; pub mod fontfiles; mod fonts; pub(crate) mod freeform; pub(crate) mod gradient; pub(crate) mod graph; mod halftone; pub mod help; pub(crate) mod inline; mod layer; mod layerpanel; pub mod library; pub mod linkfiles; pub mod links; mod live; pub(crate) mod maskedit; pub(crate) mod menucmds; pub(crate) mod newart; pub mod newdoc; mod object; mod opacitymask; mod overprint; mod package; mod paint; mod panelcmds; pub mod params; mod path; mod pathops; mod patterncmds; pub mod pdfcmds; pub mod perspgrid; pub(crate) mod place; pub mod plugin; pub mod prefscmds; pub mod print; pub(crate) mod printadvanced; pub mod printpresets; pub mod printtiling; pub mod rasterfx; mod recolor; pub mod recovery; pub mod scripts; mod select; pub(crate) mod shaper; pub(crate) mod slices; mod stroke; mod style; pub mod stylelib; mod swatch; pub mod swatchlib; pub(crate) mod tabs; mod textedit; pub mod textstyles; pub(crate) mod textwrap; pub(crate) mod threads; pub(crate) mod typecmd; mod typemenu; mod variablecmds; pub(crate) mod views; pub mod wand; pub mod webexport; mod xform; use serde::Serialize; use serde_json::Value; use vectorcraft_color::Color; use vectorcraft_doc::NodeId; use vectorcraft_geom::{Affine, Point}; use crate::{EngineError, Result, Session}; pub type Run = fn(&mut Session, &Value) -> Result; pub type Enabled = fn(&Session) -> std::result::Result<(), String>; /// Metadata + implementation for one command. pub struct CommandSpec { pub id: &'static str, pub label: &'static str, /// Menu placement, e.g. `["Object", "Arrange"]`. Empty = not in menus. pub menu: &'static [&'static str], /// Default shortcut (`Cmd+Shift+]`), mapped per platform by the UI. pub shortcut: Option<&'static str>, /// Human/agent-readable parameter description. pub params: &'static str, pub enabled: Enabled, pub run: Run, /// Record in the journal (false for queries and selection-only helpers). pub journal: bool, } /// Serializable command metadata. #[derive(Clone, Debug, Serialize)] pub struct CommandInfo { pub id: &'static str, pub label: &'static str, pub menu: Vec<&'static str>, pub shortcut: Option<&'static str>, pub params: &'static str, pub enabled: bool, #[serde(skip_serializing_if = "Option::is_none")] pub disabled_reason: Option, } impl CommandSpec { pub fn info(&self, s: &Session) -> CommandInfo { let e = (self.enabled)(s); CommandInfo { id: self.id, label: self.label, menu: self.menu.to_vec(), shortcut: self.shortcut, params: self.params, enabled: e.is_ok(), disabled_reason: e.err(), } } } // ---------- enablement predicates ---------- pub fn always(_: &Session) -> std::result::Result<(), String> { Ok(()) } pub fn has_doc(s: &Session) -> std::result::Result<(), String> { s.active().map(|_| ()).ok_or_else(|| "no document open".into()) } pub fn has_selection(s: &Session) -> std::result::Result<(), String> { let st = s.active().ok_or("no document open")?; if st.selection.is_empty() { Err("nothing selected".into()) } else { Ok(()) } } /// Objects are selected, or the Artboard tool is chosen (Copy and Cut take its artboard). pub fn has_selection_or_artboard_tool(s: &Session) -> std::result::Result<(), String> { if s.tool_id() == "artboard" { has_doc(s) } else { has_selection(s) } } /// Objects or ruler guides are selected (what Delete and the arrow keys act on). pub fn has_selection_or_guides(s: &Session) -> std::result::Result<(), String> { let st = s.active().ok_or("no document open")?; if st.selection.has_objects_or_guides() { Ok(()) } else { Err("nothing selected".into()) } } pub fn has_anchors(s: &Session) -> std::result::Result<(), String> { let st = s.active().ok_or("no document open")?; if st.selection.anchors.values().all(|a| a.is_empty()) { Err("no anchor points selected".into()) } else { Ok(()) } } pub fn has_multi(s: &Session) -> std::result::Result<(), String> { let st = s.active().ok_or("no document open")?; if st.selection.len() < 2 { Err("select at least two objects".into()) } else { Ok(()) } } pub fn can_undo(s: &Session) -> std::result::Result<(), String> { s.active().filter(|d| !d.history.undo.is_empty()).map(|_| ()).ok_or_else(|| "nothing to undo".into()) } pub fn can_redo(s: &Session) -> std::result::Result<(), String> { s.active().filter(|d| !d.history.redo.is_empty()).map(|_| ()).ok_or_else(|| "nothing to redo".into()) } pub fn has_clipboard(s: &Session) -> std::result::Result<(), String> { has_doc(s)?; if s.clipboard.is_empty() { Err("clipboard is empty".into()) } else { Ok(()) } } macro_rules! cmd { ($id:literal, $label:literal, [$($m:literal),*], $sc:expr, $params:literal, $en:expr, $run:expr) => { $crate::cmd::CommandSpec { id: $id, label: $label, menu: &[$($m),*], shortcut: $sc, params: $params, enabled: $en, run: $run, journal: true } }; (query $id:literal, $label:literal, [$($m:literal),*], $sc:expr, $params:literal, $en:expr, $run:expr) => { $crate::cmd::CommandSpec { id: $id, label: $label, menu: &[$($m),*], shortcut: $sc, params: $params, enabled: $en, run: $run, journal: false } }; } pub(crate) use cmd; pub fn command_specs() -> &'static [CommandSpec] { static SPECS: std::sync::OnceLock> = std::sync::OnceLock::new(); SPECS.get_or_init(|| { let mut v = Vec::new(); v.extend(edit::specs()); v.extend(clipboard::specs()); v.extend(recolor::specs()); v.extend(fileio::specs()); v.extend(create::specs()); v.extend(object::specs()); v.extend(path::specs()); v.extend(select::specs()); v.extend(wand::specs()); v.extend(paint::specs()); // Split out of `paint` (M3.8); kept next to it so lists in registry order (the command // palette, `engine.commands`) show them where they always were. v.extend(stroke::specs()); v.extend(appearance::specs()); v.extend(style::specs()); v.extend(swatch::specs()); v.extend(gradient::specs()); v.extend(opacitymask::specs()); v.extend(layer::specs()); v.extend(layerpanel::specs()); v.extend(draw2::specs()); v.extend(xform::specs()); v.extend(effectcmd::specs()); v.extend(pathops::specs()); v.extend(typecmd::specs()); v.extend(menucmds::specs()); v.extend(live::specs()); v.extend(colorcmds::specs()); v.extend(colormgmt::specs()); v.extend(typemenu::specs()); v.extend(textedit::specs()); v.extend(inline::specs()); v.extend(textstyles::specs()); v.extend(fonts::specs()); v.extend(fontfiles::specs()); v.extend(help::specs()); v.extend(threads::specs()); v.extend(textwrap::specs()); v.extend(graph::specs()); v.extend(rasterfx::specs()); v.extend(views::specs()); v.extend(maskedit::specs()); v.extend(tabs::specs()); v.extend(docmenu::specs()); v.extend(variablecmds::specs()); v.extend(docinfo::specs()); v.extend(panelcmds::specs()); v.extend(buildcmds::specs()); v.extend(shaper::specs()); v.extend(brushsym::specs()); v.extend(patterncmds::specs()); v.extend(prefscmds::specs()); v.extend(distortcmds::specs()); v.extend(paint::proxy_specs()); v.extend(overprint::specs()); v.extend(swatchlib::specs()); v.extend(freeform::specs()); v.extend(flatten::specs()); v.extend(stylelib::specs()); v.extend(library::specs()); v.extend(expand::specs()); v.extend(attributes::specs()); v.extend(newart::specs()); v.extend(colortheme::specs()); v.extend(fileio::pdf::specs()); v.extend(docsetup::specs()); v.extend(newdoc::specs()); v.extend(place::specs()); v.extend(links::specs()); v.extend(linkfiles::specs()); v.extend(pdfcmds::specs()); v.extend(fileio::dxf::specs()); v.extend(fileio::eps::specs()); v.extend(package::specs()); v.extend(slices::specs()); v.extend(print::specs()); v.extend(recovery::specs()); v.extend(fileio::dxfimport::specs()); v.extend(webexport::specs()); v.extend(assets::specs()); v.extend(css::specs()); v.extend(printpresets::specs()); v.extend(printtiling::specs()); v.extend(plugin::specs()); v.extend(scripts::specs()); v.extend(cut::specs()); v.extend(halftone::specs()); v.extend(perspgrid::specs()); v }) } pub fn find_command(id: &str) -> Option<&'static CommandSpec> { command_specs().iter().find(|c| c.id == id) } // ---------- param helpers ---------- pub(crate) fn bad(cmd: &str, msg: impl Into) -> EngineError { EngineError::BadParams { cmd: cmd.into(), msg: msg.into() } } pub(crate) fn f64_or(p: &Value, key: &str, default: f64) -> f64 { p.get(key).and_then(Value::as_f64).unwrap_or(default) } pub(crate) fn f64_req(p: &Value, key: &str, cmd: &str) -> Result { p.get(key).and_then(Value::as_f64).ok_or_else(|| bad(cmd, format!("missing number `{key}`"))) } pub(crate) fn bool_or(p: &Value, key: &str, default: bool) -> bool { p.get(key).and_then(Value::as_bool).unwrap_or(default) } pub(crate) fn str_param<'a>(p: &'a Value, key: &str) -> Option<&'a str> { p.get(key).and_then(Value::as_str) } pub(crate) fn id_param(p: &Value, key: &str) -> Option { p.get(key).and_then(Value::as_u64).map(NodeId) } pub(crate) fn ids_param(p: &Value, key: &str) -> Option> { p.get(key).and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_u64).map(NodeId).collect()) } /// Validate all explicit targets before a command changes the document or selection. pub(crate) fn checked_ids_param(s: &Session, p: &Value, key: &str, command: &str) -> Result> { let values = p.get(key).and_then(Value::as_array).ok_or_else(|| bad(command, format!("{key} must be an array of non-negative integer object ids")))?; values.iter().map(|value| checked_id(s, value, command)).collect() } /// The objects a command acts on: `ids` when given (every one checked), else the selected objects. pub(crate) fn checked_ids_or_selection(s: &Session, p: &Value, command: &str) -> Result> { if p.get("ids").is_some() { checked_ids_param(s, p, "ids", command) } else { edit::selected_roots(s) } } pub(crate) fn checked_id(s: &Session, value: &Value, command: &str) -> Result { let id = value.as_u64().map(NodeId).ok_or_else(|| bad(command, format!("invalid object id {value}: expected a non-negative integer")))?; if s.doc()?.doc.node(id).is_none() { return Err(bad(command, format!("no such object id {value}"))); } Ok(id) } /// `v` as exactly `N` finite numbers (`[x, y]`, `[x, y, width, height]`…): `None` when it isn't an /// array of that length or an entry isn't a number, so nothing malformed is left out or padded. pub(crate) fn finite_numbers(v: &Value) -> Option<[f64; N]> { let a = v.as_array().filter(|a| a.len() == N)?; let mut out = [0.0; N]; for (o, n) in out.iter_mut().zip(a) { *o = n.as_f64().filter(|n| n.is_finite())?; } Some(out) } /// `v` as a list of point indices: `None` unless it is an array of non-negative integers, so no /// malformed index is dropped or wrapped round. pub(crate) fn point_indices(v: &Value) -> Option> { v.as_array()?.iter().map(|i| usize::try_from(i.as_u64()?).ok()).collect() } pub(crate) fn point_param(p: &Value, key: &str) -> Option { let a = p.get(key)?.as_array()?; Some(Point::new(a.first()?.as_f64()?, a.get(1)?.as_f64()?)) } /// `[a, b, c, d, e, f]` affine coefficients. pub fn matrix_param(p: &Value, key: &str) -> Option { let a = p.get(key)?.as_array()?; if a.len() != 6 { return None; } let mut c = [0.0; 6]; for (i, v) in a.iter().enumerate() { c[i] = v.as_f64()?; } Some(Affine::new(c)) } /// A colour from `"#rrggbb"`, a colour key (`"cmyk 0 100 100 0"`, `"lab 55 60 40"`), `[r,g,b]` (0..1), /// `{"c":..,"m":..,"y":..,"k":..}` (percent when any component is above 1, else 0..1), `{"gray":..}` or `{"l":..,"a":..,"b":..}` (CIE Lab: L 0..100, a and b about /// −128..127). pub fn color_value(v: &Value) -> Option { match v { // A hex, or a colour key ("cmyk 0 100 100 0", as Recolor Artwork lists colours). Value::String(s) => Color::from_hex(s).or_else(|| vectorcraft_color::recolor::ColorKey::parse(s).map(|k| k.color())), Value::Array(a) if a.len() >= 3 => Some(Color::rgb(a[0].as_f64()? as f32, a[1].as_f64()? as f32, a[2].as_f64()? as f32)), Value::Object(o) => { if let (Some(c), Some(m), Some(y), Some(k)) = (o.get("c"), o.get("m"), o.get("y"), o.get("k")) { // One scale for the whole colour: percent when any component is above 1, else 0..1. // Deciding per component read {c: 18, k: 0.79} as K 79 %. let v = [c.as_f64()?, m.as_f64()?, y.as_f64()?, k.as_f64()?]; let scale = if v.iter().any(|x| *x > 1.0) { 100.0 } else { 1.0 }; let f = |x: f64| (x / scale) as f32; return Some(Color::cmyk(f(v[0]), f(v[1]), f(v[2]), f(v[3]))); } if let Some(g) = o.get("gray") { let g = g.as_f64()?; return Some(Color::gray(if g > 1.0 { g / 100.0 } else { g } as f32)); } if let (Some(l), Some(a), Some(b), None) = (o.get("l"), o.get("a"), o.get("b"), o.get("model")) { return Some(Color::lab(l.as_f64()? as f32, a.as_f64()? as f32, b.as_f64()? as f32)); } serde_json::from_value(v.clone()).ok() } _ => None, } } /// Objects a command targets: explicit `ids` param or the selection. pub(crate) fn targets(s: &Session, p: &Value) -> Result> { // Objects given are used as given: a value that isn't an object id fails rather than the // command acting on the selection instead (#785). let object_id = |v: &Value| v.as_u64().map(NodeId).ok_or_else(|| EngineError::Other(format!("object ids are non-negative integers, not {v}"))); match p.get("ids").filter(|v| !v.is_null()) { Some(Value::Array(a)) => return a.iter().map(object_id).collect(), Some(v) => return Err(EngineError::Other(format!("`ids` must be an array of object ids, not {v}"))), None => {} } match p.get("id").filter(|v| !v.is_null()) { Some(v @ Value::Number(_)) => Ok(vec![object_id(v)?]), // A step reference a batch couldn't resolve (other strings are other ids, such as an // effect's). Some(Value::String(r)) if r.starts_with('$') => { Err(EngineError::Other(format!("`id` is the step reference {r:?}, which only `run` and `command_batch` resolve"))) } _ => Ok(s.doc()?.selection.objects.clone()), } } pub(crate) fn ok() -> Result { Ok(Value::Null) } /// Leaves whose appearance a paint command changes: groups and layers expand to their contents, /// and compound paths own their children's appearance. pub(crate) fn leaf_targets(s: &Session, ids: &[NodeId]) -> Result> { use vectorcraft_doc::NodeKind; let d = &s.doc()?.doc; let mut out = vec![]; for id in ids { let Some(n) = d.node(*id) else { continue }; match &n.kind { NodeKind::Group { .. } | NodeKind::Layer { .. } => n.walk(&mut |c| { if !c.is_container() || matches!(c.kind, NodeKind::Compound { .. }) { out.push(c.id) } }), _ => out.push(*id), } } let comp: Vec = out.iter().filter(|id| matches!(d.node(**id).map(|n| &n.kind), Some(NodeKind::Compound { .. }))).copied().collect(); out.retain(|id| !comp.iter().any(|c| d.parent_of(*id) == Some(*c))); Ok(out) } /// [`leaf_targets`] of a command's [`targets`] (`ids`, `id` or the selection). pub(crate) fn paint_targets(s: &Session, p: &Value) -> Result> { leaf_targets(s, &targets(s, p)?) } /// `base` if `taken` doesn't claim it, else the first free "base 2", "base 3", … pub(crate) fn unique_name(base: &str, taken: impl Fn(&str) -> bool) -> String { if !taken(base) { return base.to_string(); } (2..).map(|i| format!("{base} {i}")).find(|n| !taken(n)).unwrap_or_else(|| base.to_string()) } pub(crate) use edit::{settle_interaction, typing_in_progress}; pub(crate) use vectorcraft_doc::range::parse_range; /// A date param in Unix seconds that defaults to the clock: `None` when absent (now, see /// [`clock_date`]), `Some(None)` for null (no date), else `Some(Some(seconds))`. pub(crate) fn date_param(p: &Value, key: &str, cmd: &str) -> Result>> { match p.get(key) { None => Ok(None), Some(Value::Null) => Ok(Some(None)), Some(v) => v.as_i64().map(|t| Some(Some(t))).ok_or_else(|| bad(cmd, format!("{key} must be Unix seconds (an integer) or null"))), } } /// The date a command stamps: `given` (see [`date_param`]), else now (none on the web, which has /// no clock). It joins the running command's journal entry as `key`, so a replay of the journal /// gives the same date whenever it runs (an action leaves it out: [`crate::Session::journal_for_action`]). pub(crate) fn clock_date(s: &mut Session, key: &str, given: Option>) -> Option { let t = given.unwrap_or_else(vectorcraft_doc::metadata::now_unix); s.note_journal(key, serde_json::json!(t)); t } /// The Transparency panel's state ([`Session::transparency_info`]). pub use opacitymask::TransparencyInfo; /// Eyedropper Options (`Prefs::eyedropper`). pub use xform::{AppearanceAttrs, EyedropperAttrs, EyedropperOptions, FillAttrs, StrokeAttrs}; /// The Attributes panel's state ([`Session::attributes_info`]). pub use overprint::AttributesInfo; /// Object → Flatten Transparency settings (`object.flattenTransparency`). pub use flatten::FlattenOptions; /// A built-in or saved flattener preset ([`crate::Prefs::flattener_presets`]). pub use flatten::FlattenerPreset; /// The shapes a group selected alone gives the Pathfinder panel. pub use pathops::shape_count; /// Command ids kept only so older scripts keep working: `(alias, the command it duplicates)`. /// They run as before but the command palette leaves them out (it lists the command once). pub const ALIASES: &[(&str, &str)] = &[("object.convertDocumentColorMode", "file.documentColorMode")]; /// Is `id` an [`ALIASES`] entry? pub fn is_alias(id: &str) -> bool { ALIASES.iter().any(|(a, _)| *a == id) }