use super::definitions::{INSPECT_KEYS, SAVE_FILE_OPTIONS, tool_definitions}; use super::handlers::{ Args, absolute_path, add_text, answer_dialog, apply_paint, draw_path, draw_shape, exec, image_result, max_side, need_ui, req_str, screenshot, select_ids, transform, }; use super::{ARTBOARD_PARAMS, Backend, Map, ToolResult, Value, format, json, save_reads_option, writable_format}; /// The `menu` argument of the command listings. pub(super) const MENU_FILTER: &str = "Case-insensitive substring of the menu path, its levels joined with \" > \" (e.g. \"Object\", \"Object > Path\")"; /// The `brief` argument of the command listings. pub(super) const BRIEF: &str = "Leave out each command's params description (the bulk of the catalogue); run command_list with the id as filter to read it"; /// The `max_side` argument of the window captures. pub(super) const MAX_SIDE_WINDOW: &str = "Window capture only: longest side of the returned image in pixels; a larger capture is scaled down to it (default: the full capture)"; /// The `traceViews` argument of the artboard renders. pub(super) const TRACE_VIEWS: &str = "Draw Image Trace objects in the view set with imageTrace.setView (outlines, the source image…), as the app's window does; by default they draw their tracing result, as exports do"; /// `traceViews`: false when absent. pub(super) fn trace_views(a: &Args) -> Result { match a.get("traceViews") { None => Ok(false), Some(v) => v.as_bool().ok_or_else(|| "`traceViews` must be a boolean".into()), } } /// The command catalogue narrowed by the listing tools' arguments: `filter` (or `query`) on the /// id, label and menu path, `menu` on the menu path, `enabledOnly`, and `brief` (no `params` /// text: the full catalogue is too large for many clients, #2173). fn filter_commands(all: Value, a: &Args) -> Value { let text = |k: &str| a.get(k).and_then(Value::as_str).map(str::to_lowercase).filter(|s| !s.is_empty()); let needle = text("filter").or_else(|| text("query")); let in_menu = text("menu"); let enabled_only = a.get("enabledOnly").and_then(Value::as_bool).unwrap_or(false); let brief = a.get("brief").and_then(Value::as_bool).unwrap_or(false); let Value::Array(list) = all else { return all }; let menu_path = |c: &Value, sep: &str| { c.get("menu") .and_then(Value::as_array) .map(|m| m.iter().filter_map(Value::as_str).collect::>().join(sep)) .unwrap_or_default() .to_lowercase() }; let hit = |c: &Value| { if enabled_only && c.get("enabled").and_then(Value::as_bool) == Some(false) { return false; } if let Some(m) = &in_menu && !menu_path(c, " > ").contains(m.as_str()) { return false; } let Some(n) = &needle else { return true }; let field = |k: &str| c.get(k).and_then(Value::as_str).unwrap_or("").to_lowercase(); field("id").contains(n.as_str()) || field("label").contains(n.as_str()) || menu_path(c, " ").contains(n.as_str()) }; Value::Array( list.into_iter() .filter(hit) .map(|mut c| { if brief && let Some(o) = c.as_object_mut() { o.remove("params"); } c }) .collect(), ) } fn dispatch(b: &mut dyn Backend, name: &str, a: &Args) -> Result { let j = |v: Value| Ok(ToolResult::json(&v)); match name { "command_list" => { let all = b.call("engine.commands", json!({}))?; let mut filters = a.clone(); if let Some(v) = filters.remove("enabled_only") { filters.insert("enabledOnly".into(), v); } j(filter_commands(all, &filters)) } "command_batch" => command_batch(b, a), "render_preview" => render_preview(b, a), "ui_screenshot" => { let mut args = a.clone(); args.insert("window".into(), json!(true)); screenshot(b, &args) } "list_commands" => { let all = b.call("engine.commands", json!({}))?; let list = filter_commands(all, a); j(json!({"count": list.as_array().map_or(0, Vec::len), "commands": list})) } "command_run" | "run_command" | "invoke_menu" => { let cmd = req_str(a, if name == "command_run" { "id" } else { "command" })?; let params = match a.get("params") { None | Some(Value::Null) => json!({}), Some(v @ Value::Object(_)) => v.clone(), Some(_) => return Err("`params` must be an object".into()), }; let method = if name == "invoke_menu" && b.has_ui() { "ui.menu.invoke" } else { "engine.execute" }; j(b.call(method, json!({"command": cmd, "params": params}))?) } "doc_inspect" | "inspect_document" => { // Only the slicing and scope options reach the command, which validates them. let slice: Map = a.iter().filter(|(k, _)| INSPECT_KEYS.contains(&k.as_str())).map(|(k, v)| (k.clone(), v.clone())).collect(); j(b.call("document.inspect", Value::Object(slice))?) } "ui_inspect" | "inspect_ui" => { need_ui(b, name)?; j(b.call("ui.inspect", json!({}))?) } "select_tool" => j(b.call("ui.tool.select", json!({"tool": req_str(a, "tool")?}))?), "pointer_gesture" => { let events = a.get("events").and_then(Value::as_array).filter(|e| !e.is_empty()).ok_or("`events` must be a non-empty array")?; for (i, e) in events.iter().enumerate() { let kind = e.get("kind").and_then(Value::as_str).unwrap_or(""); if crate::headless::pointer_kind(kind).is_none() { return Err(format!("event {i}: unknown kind `{kind}` (down, drag, up, move, doubleclick)")); } if !e.get("x").is_some_and(Value::is_number) || !e.get("y").is_some_and(Value::is_number) { return Err(format!("event {i}: numeric `x` and `y` required")); } } if let Some(t) = a.get("tool").and_then(Value::as_str) { b.call("ui.tool.select", json!({"tool": t}))?; } let events: Vec = events .iter() .map(|e| { let mut e = e.clone(); e["space"] = json!("doc"); e }) .collect(); let mut p = json!({"events": events}); if let Some(m) = a.get("mods") { p["mods"] = m.clone(); } j(b.call("ui.pointer", p)?) } "draw_path" => j(draw_path(b, a)?), "draw_shape" => j(draw_shape(b, a)?), "set_paint" => { if !["fill", "stroke", "strokeWidth"].iter().any(|k| a.contains_key(*k)) { return Err("give at least one of `fill`, `stroke`, `strokeWidth`".into()); } let (applied, objects) = apply_paint(b, a, a.get("ids").cloned())?; let mut reply = json!({"applied": applied, "objects": objects}); if objects == 0 { reply["note"] = json!( "no object was painted (nothing selected, or `ids` is empty or holds no object this paint applies to): only the paint for new art changed" ); } j(reply) } "press_key" => { let mut p = json!({"key": req_str(a, "key")?}); if let Some(Value::Object(m)) = a.get("mods") { for (k, v) in m { p[k.as_str()] = v.clone(); } } j(b.call("ui.key", p)?) } "type_text" => { need_ui(b, name)?; j(b.call("ui.text", json!({"text": req_str(a, "text")?}))?) } "answer_dialog" => { need_ui(b, name)?; j(answer_dialog(b, a)?) } "open_panel" => { need_ui(b, name)?; j(b.call("ui.set", json!({"panel": req_str(a, "panel")?}))?) } "screenshot" => screenshot(b, a), "open_file" => { let path = absolute_path(b, req_str(a, "path")?)?; j(b.call("app.open", json!({"path": path}))?) } "save_file" => { let mut p = json!({"path": a.get("path").and_then(Value::as_str).map(|p| absolute_path(b, p)).transpose()?}); for k in SAVE_FILE_OPTIONS { if let Some(v) = a.get(k).filter(|v| !v.is_null()) { p[k] = v.clone(); } } // The app writes the file before it replies, even with Background Save on, so a failed // save is this call's error. if b.has_ui() { p["wait"] = json!(true); } let mut r = b.call("app.save", p)?; let notes = save_file_warnings(a, r.get("format").and_then(Value::as_str)); if let Some(o) = r.as_object_mut().filter(|_| !notes.is_empty()) { let mut all = o.get("warnings").and_then(Value::as_array).cloned().unwrap_or_default(); all.extend(notes.into_iter().map(Value::from)); o.insert("warnings".into(), Value::Array(all)); } j(r) } "export" => { // One params object for every backend; the named arguments win over `options` (a named // artboard or range replaces every artboard choice in `options`). let mut params = a.get("options").and_then(Value::as_object).cloned().unwrap_or_default(); if ["artboard", "range"].iter().any(|k| a.get(*k).is_some_and(|v| !v.is_null())) { for k in ARTBOARD_PARAMS { params.remove(k); } } for k in named_export_params(a) { if let Some(v) = a.get(*k).filter(|v| !v.is_null()) { params.insert((*k).into(), v.clone()); } } if let Some(path) = params.get("path").and_then(Value::as_str) { let path = absolute_path(b, path)?; params.insert("path".into(), json!(path)); } let cmd = if a.get("selection").and_then(Value::as_bool) == Some(true) { "document.exportSelection" } else { "document.export" }; j(exec(b, cmd, Value::Object(params))?) } "add_text" => j(add_text(b, a)?), "apply_effect" => { let Some(effect) = a.get("effect").and_then(Value::as_str) else { let all = exec(b, "effect.list", json!({}))?; return j(json!({"catalog": all.get("catalog")})); }; let mut p = json!({"effect": effect, "params": a.get("params").cloned().unwrap_or(json!({}))}); if let Some(ids) = a.get("ids") { p["ids"] = ids.clone(); } j(exec(b, "effect.apply", p)?) } "pathfinder" => { select_ids(b, a)?; j(exec(b, &format!("object.pathfinder.{}", req_str(a, "operation")?), json!({}))?) } "transform" => j(transform(b, a)?), "create_graph" => { let mut p = Value::Object(a.clone()); if p.get("type").is_none() { p["type"] = json!("column"); } j(exec(b, "graph.create", p)?) } "text_wrap" => { select_ids(b, a)?; if a.get("release").and_then(Value::as_bool) == Some(true) { return j(exec(b, "object.textWrap.release", json!({}))?); } let mut p = json!({}); for k in ["offset", "invert"] { if let Some(v) = a.get(k) { p[k] = v.clone(); } } j(exec(b, "object.textWrap.make", p)?) } "undo" => j(exec(b, "edit.undo", json!({}))?), "redo" => j(exec(b, "edit.redo", json!({}))?), other => Err(format!("unknown tool `{other}` (see tools/list)")), } } /// The named `export` arguments passed on to `document.export`. `scale` is raster pixels per /// point, so only a raster format gets it: DXF's own (drawing units per unit, given in `options`) /// would resize the drawing and leave its units unset. fn named_export_params(a: &Args) -> &'static [&'static str] { let format = writable_format(a.get("format").and_then(Value::as_str), a.get("path").and_then(Value::as_str)); if format.is_ok_and(|f| f.raster) { &["path", "format", "scale", "artboard", "range", "outlineText"] } else { &["path", "format", "artboard", "range", "outlineText"] } } /// What `save_file` with arguments `a` adds to the save's `warnings`, for a file written in format /// `written` (none when nothing was written yet): each key in `options` that the format doesn't /// read. The save itself warns about `compress`, `version` and `preview` given at the top level. fn save_file_warnings(a: &Args, written: Option<&str>) -> Vec { let Some(f) = written.and_then(format) else { return vec![] }; let nested = a.get("options").and_then(Value::as_object); nested .into_iter() .flatten() .filter(|(key, v)| !v.is_null() && !save_reads_option(f, key)) .map(|(key, _)| format!("`options.{key}` was not used: {} files don't read it", f.label)) .collect() } /// Run one tool. Failures (unknown tool, bad arguments, command errors) come back as an /// `isError` result so the model can read and correct them. pub fn call_tool(b: &mut dyn Backend, name: &str, args: &Value) -> ToolResult { let empty = Map::new(); let a = match args { Value::Object(o) => o, Value::Null => &empty, _ => return ToolResult::error("tool arguments must be a JSON object"), }; match vectorcraft_engine::guard::catch_panic(|| dispatch(b, name, a)) { Ok(Ok(r)) => r, Ok(Err(e)) => ToolResult::error(format!("{name}: {e}")), Err(e) => ToolResult::error(format!("internal error in `{name}`: {e} (please report this bug)")), } } /// Most steps one `command_batch` may hold, and most bytes its kept step results may total. /// Batches run sequentially and every result is kept for later steps' `"$N.path"` references, so /// without caps one batch could do unbounded work and pile up an unbounded reply (#1727). pub(crate) const MAX_COMMAND_BATCH_STEPS: usize = 256; pub(crate) const MAX_COMMAND_BATCH_BYTES: usize = 8 * 1024 * 1024; /// Refuses an argument the tool's schema doesn't list, as `tools/call` does (#713). pub(crate) fn check_tool_arguments(name: &str, args: &Args) -> Result<(), String> { let defs = tool_definitions(); let props = defs.iter().find(|t| t.get("name").and_then(Value::as_str) == Some(name)).and_then(|t| t.get("inputSchema")?.get("properties")?.as_object()); match props.and_then(|p| args.keys().find(|k| !p.contains_key(*k))) { Some(key) => Err(format!("unknown argument `{key}` for `{name}`")), None => Ok(()), } } fn command_batch(b: &mut dyn Backend, a: &Args) -> Result { let steps = a.get("steps").and_then(Value::as_array).ok_or("`steps` must be an array")?; if steps.len() > MAX_COMMAND_BATCH_STEPS { return Err(format!("`steps` holds {} steps; at most {MAX_COMMAND_BATCH_STEPS}", steps.len())); } let stop = match a.get("stop_on_error") { None => true, Some(v) => v.as_bool().ok_or("`stop_on_error` must be a boolean")?, }; let atomic = match a.get("atomic") { None => false, Some(v) => v.as_bool().ok_or("`atomic` must be a boolean")?, }; if atomic { let label = a.get("label").cloned().unwrap_or(Value::Null); b.call("engine.execute", json!({"command":"edit.undoGroup.begin","params":{"label":label}}))?; } else if a.get("label").is_some() { return Err("`label` names the undo step of an atomic batch: give atomic:true too".into()); } let (mut completed, mut failed, mut results) = (0, 0, Vec::new()); // Each step's result (null for a failed one), for later steps' `"$N.path"` references. let mut values: Vec = vec![]; // The kept results all go back in the reply, so their total size is capped: one batch can't // pile up an unbounded answer (#1727). let mut kept_bytes = 0usize; // The objects the drawing steps made (#2113). let mut created: Vec = vec![]; for step in steps { let result = vectorcraft_engine::guard::catch_panic(|| { let args = step.as_object().ok_or("each step must be an object")?; if args.keys().any(|k| k != "id" && k != "params") { return Err("unknown step argument (expected id, params)".into()); } let id = req_str(args, "id")?; let params = match args.get("params") { None | Some(Value::Null) => json!({}), Some(v @ Value::Object(_)) => vectorcraft_engine::steps::resolve(v, &values)?, Some(_) => return Err("`params` must be an object".into()), }; match id { // The drawing tools, with the arguments `tools/call` takes (#2113). "draw_path" | "draw_shape" => { let args = params.as_object().cloned().unwrap_or_default(); check_tool_arguments(id, &args)?; let made = if id == "draw_path" { draw_path(b, &args)? } else { draw_shape(b, &args)? }; if let Some(new) = made.get("id").filter(|v| v.is_u64()) { created.push(new.clone()); } Ok(made) } _ => b.call("engine.execute", json!({"command":id,"params":params})), } }) .unwrap_or_else(|e| Err(format!("internal error: {e}"))); match result { Ok(v) => { let entry = json!({"ok": true, "result": v}); let size = entry.to_string().len(); if kept_bytes.saturating_add(size) > MAX_COMMAND_BATCH_BYTES { // The results already kept stay; this step ran, and its result is replaced by // the reason the batch stops (saying it ran, so nobody runs it again). The loop breaks either way — carrying on would defeat the cap — // and the atomic group below still closes, as for any stopped batch. failed += 1; values.push(Value::Null); results.push(json!({"ok": false, "error": format!("this step ran, but the batch's results exceed {MAX_COMMAND_BATCH_BYTES} bytes: its result is dropped and the batch stops here")})); break; } kept_bytes += size; completed += 1; values.push(entry["result"].clone()); results.push(entry); } Err(e) => { failed += 1; values.push(Value::Null); results.push(json!({"ok":false,"error":e})); if stop { break; } } } } // The steps that ran stay, as one undo step. if atomic { b.call("engine.execute", json!({"command":"edit.undoGroup.end","params":{}}))?; } let mut r = ToolResult::json(&json!({"completed":completed,"failed":failed,"results":results,"created":created})); r.is_error = failed > 0; Ok(r) } fn render_preview(b: &mut dyn Backend, a: &Args) -> Result { let max = max_side(a)?.unwrap_or(1024); let index = match a.get("artboard") { None => 0, Some(v) => v.as_u64().ok_or("`artboard` must be a 0-based artboard index")?, }; let trace_views = trace_views(a)?; let doc = b.call("document.inspect", json!({"depth":0}))?; let board = doc.get("artboards").and_then(Value::as_array).and_then(|v| v.get(usize::try_from(index).ok()?)).ok_or(format!("no artboard {index}"))?; let width = board.get("width").and_then(Value::as_f64).ok_or("artboard has no width")?; let height = board.get("height").and_then(Value::as_f64).ok_or("artboard has no height")?; if !width.is_finite() || !height.is_finite() || width <= 0.0 || height <= 0.0 { return Err("invalid artboard dimensions".into()); } // The headless renderer clamps scale at 0.01. Bound its allocation even for // a huge artboard, then downsize to the requested dimensions. let scale = (f64::from(max) / width.max(height)).clamp(0.01, 1.0); if width.max(height) * scale > 4096.0 { return Err("artboard is too large for a bounded preview".into()); } let mut p = json!({"scale":scale, "artboard": index}); if trace_views { p["traceViews"] = json!(true); } let r = b.call("ui.render", p)?; let encoded = r.get("pngBase64").and_then(Value::as_str).ok_or("renderer returned no image")?; let png = vectorcraft_format::base64_decode(encoded).ok_or("renderer returned bad base64")?; let img = image::load_from_memory_with_format(&png, image::ImageFormat::Png).map_err(|e| format!("read preview: {e}"))?; let img = if img.width() > max || img.height() > max { img.thumbnail(max, max) } else { img }; let mut out = std::io::Cursor::new(Vec::new()); img.write_to(&mut out, image::ImageFormat::Png).map_err(|e| format!("encode preview: {e}"))?; Ok(image_result(out.get_ref(), json!({"width":img.width(),"height":img.height()}))) }