mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-22 19:08:39 +00:00
Merge pull request #3630 from Taha-Mahmoodi/feat/3147-cli-commands-file
CLI: run --commands reads the steps from a JSON file or standard input (#3147)
This commit is contained in:
4 files changed
+260
-15
No files matched your search
@@ -2,7 +2,7 @@
|
||||
//!
|
||||
//! ```text
|
||||
//! vectorcraft-cli mcp [--connect 127.0.0.1:7979 | --headless] [--automation-read-root DIR] [--automation-write-root DIR]
|
||||
//! vectorcraft-cli run [--in FILE] [--cmd id [--params '{json}']]... [--export out.svg]... [--scale 2]
|
||||
//! vectorcraft-cli run [--in FILE] [--cmd id [--params '{json}'] | --commands FILE|-]... [--export out.svg]... [--scale 2]
|
||||
//! vectorcraft-cli commands
|
||||
//! vectorcraft-cli convert IN OUT [--scale 2] [--artboard 0 | --range 1-3,5] [--outline-text] [--decimals N] [--replace-lossy] [--editing-data] [--ai-version 7|pdf] [--hidden-objects layer|omit|visible] [--transparent-images flatten|omit]
|
||||
//! vectorcraft-cli info FILE
|
||||
@@ -76,11 +76,15 @@ USAGE:
|
||||
DIR, links followed; a root left out grants none of its access. They imply --headless: a
|
||||
running app enforces its own (vectorcraft --control PORT --automation-read-root DIR …).
|
||||
|
||||
vectorcraft-cli run [--in FILE] [--cmd ID [--params JSON]]... [--export FILE]... [--scale N]
|
||||
vectorcraft-cli run [--in FILE] [--cmd ID [--params JSON] | --commands FILE|-]... [--export FILE]... [--scale N]
|
||||
Headless batch: open FILE (any readable format) or start a new document, run commands in
|
||||
order, export each FILE in the format its extension picks (see Writable formats; a .ait FILE
|
||||
is a template, written as file.saveAsTemplate writes it). --scale sizes raster exports
|
||||
(pixels per point, default 1); other formats leave it out. Prints one JSON result per step.
|
||||
--commands reads steps from FILE, or from standard input for -, where it stands among the
|
||||
--cmd and --export steps: a JSON array of {\"command\": ID, \"params\": {...}} (or \"id\" for
|
||||
\"command\"), or one such object per line (JSON Lines), at most 64 MiB and 100000 steps.
|
||||
Errors name the step, counting from 0 as \"$N\" references do.
|
||||
|
||||
vectorcraft-cli commands
|
||||
Print the command catalogue as JSON.
|
||||
@@ -338,28 +342,127 @@ enum Step {
|
||||
Export(String),
|
||||
}
|
||||
|
||||
/// Most bytes `run --commands` reads from a file or standard input: the steps are untrusted input.
|
||||
const MAX_COMMANDS_BYTES: u64 = 64 * 1024 * 1024;
|
||||
/// Most steps one `run --commands` takes.
|
||||
const MAX_COMMANDS_STEPS: usize = 100_000;
|
||||
|
||||
/// The text of `--commands source`: the file `source`, or standard input for `-`, refused past
|
||||
/// [`MAX_COMMANDS_BYTES`].
|
||||
fn read_commands(source: &str) -> Result<String, String> {
|
||||
use std::io::Read;
|
||||
let mut bytes = vec![];
|
||||
let read = if source == "-" {
|
||||
std::io::stdin().lock().take(MAX_COMMANDS_BYTES + 1).read_to_end(&mut bytes)
|
||||
} else {
|
||||
std::fs::File::open(source).and_then(|f| f.take(MAX_COMMANDS_BYTES + 1).read_to_end(&mut bytes))
|
||||
};
|
||||
read.map_err(|e| format!("--commands {source}: {e}"))?;
|
||||
if bytes.len() as u64 > MAX_COMMANDS_BYTES {
|
||||
return Err(format!("--commands {source}: more than {} MiB of steps", MAX_COMMANDS_BYTES / (1024 * 1024)));
|
||||
}
|
||||
String::from_utf8(bytes).map_err(|_| format!("--commands {source}: not UTF-8 text"))
|
||||
}
|
||||
|
||||
/// The steps of `--commands source` from its `text`: a JSON array of `{"command": ID, "params":
|
||||
/// {…}}` objects (`"id"` may stand for `"command"`, as in MCP's `command_batch`), or one such object
|
||||
/// per line (JSON Lines, blank lines skipped). `first` is the run's index of the first of them
|
||||
/// (the `N` of `"$N"`), which an error names with the entry's place in the text.
|
||||
fn parse_commands(source: &str, text: &str, first: usize) -> Result<Vec<Step>, String> {
|
||||
// Notepad on Windows starts UTF-8 files with a byte order mark.
|
||||
let text = text.strip_prefix('\u{feff}').unwrap_or(text);
|
||||
let too_many = || format!("--commands {source}: more than {MAX_COMMANDS_STEPS} steps");
|
||||
// Each entry with where it is in the text: its line for JSON Lines.
|
||||
let mut entries: Vec<(Option<usize>, Value)> = vec![];
|
||||
if text.trim_start().starts_with('[') {
|
||||
let v: Value = serde_json::from_str(text).map_err(|e| format!("--commands {source}: {e}"))?;
|
||||
let Value::Array(items) = v else { return Err(format!("--commands {source}: not a JSON array")) };
|
||||
if items.len() > MAX_COMMANDS_STEPS {
|
||||
return Err(too_many());
|
||||
}
|
||||
entries.extend(items.into_iter().map(|v| (None, v)));
|
||||
} else {
|
||||
for (n, line) in text.lines().enumerate().filter(|(_, l)| !l.trim().is_empty()) {
|
||||
if entries.len() == MAX_COMMANDS_STEPS {
|
||||
return Err(too_many());
|
||||
}
|
||||
let v = serde_json::from_str(line).map_err(|e| format!("--commands {source}: step {} (line {}): {e}", first + entries.len(), n + 1))?;
|
||||
entries.push((Some(n + 1), v));
|
||||
}
|
||||
}
|
||||
entries
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.map(|(k, (line, v))| {
|
||||
command_step(v).map_err(|e| {
|
||||
let place = line.map_or_else(|| format!("entry {}", k + 1), |l| format!("line {l}"));
|
||||
format!("--commands {source}: step {} ({place}): {e}", first + k)
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The step of one `--commands` entry, `{"command": ID, "params": {…}}`.
|
||||
fn command_step(v: Value) -> Result<Step, String> {
|
||||
let Value::Object(mut o) = v else { return Err("a step must be an object such as {\"command\": \"select.all\", \"params\": {}}".into()) };
|
||||
let id = match (o.remove("command"), o.remove("id")) {
|
||||
(Some(Value::String(id)), None) | (None, Some(Value::String(id))) => id,
|
||||
(Some(_), Some(_)) => return Err("give `command` or `id`, not both".into()),
|
||||
(None, None) => return Err("no `command`".into()),
|
||||
_ => return Err("`command` must be a string, a command id".into()),
|
||||
};
|
||||
let params = match o.remove("params") {
|
||||
None | Some(Value::Null) => json!({}),
|
||||
Some(p @ Value::Object(_)) => p,
|
||||
Some(_) => return Err(format!("`params` of {id} must be a JSON object")),
|
||||
};
|
||||
if let Some(key) = o.keys().next() {
|
||||
return Err(format!("unknown key `{key}`: a step has `command` (or `id`) and `params`"));
|
||||
}
|
||||
Ok(Step::Cmd(id, params))
|
||||
}
|
||||
|
||||
fn run(args: &[String]) -> Result<(), String> {
|
||||
let mut input: Option<String> = None;
|
||||
let mut steps: Vec<Step> = vec![];
|
||||
// The step of the `--cmd` just given, which a `--params` sets.
|
||||
let mut params_for: Option<usize> = None;
|
||||
let mut stdin_read = false;
|
||||
let mut scale = 1.0;
|
||||
let mut it = args.iter();
|
||||
while let Some(a) = it.next() {
|
||||
let mut val = |what: &str| it.next().cloned().ok_or_else(|| format!("{a} needs {what}"));
|
||||
match a.as_str() {
|
||||
"--in" | "-i" => input = Some(val("a file")?),
|
||||
"--cmd" | "-c" => steps.push(Step::Cmd(val("a command id")?, json!({}))),
|
||||
"--cmd" | "-c" => {
|
||||
steps.push(Step::Cmd(val("a command id")?, json!({})));
|
||||
params_for = Some(steps.len() - 1);
|
||||
}
|
||||
"--params" | "-p" => {
|
||||
let raw = val("JSON")?;
|
||||
let p: Value = serde_json::from_str(&raw).map_err(|e| format!("--params {raw}: {e}"))?;
|
||||
if !p.is_object() {
|
||||
return Err(format!("--params must be a JSON object, got {raw}"));
|
||||
}
|
||||
match steps.last_mut() {
|
||||
match params_for.and_then(|i| steps.get_mut(i)) {
|
||||
Some(Step::Cmd(_, params)) => *params = p,
|
||||
_ => return Err("--params must follow a --cmd".into()),
|
||||
}
|
||||
}
|
||||
"--export" | "-o" => steps.push(Step::Export(val("a file")?)),
|
||||
"--commands" => {
|
||||
let source = val("a file, or - for standard input")?;
|
||||
if source == "-" && std::mem::replace(&mut stdin_read, true) {
|
||||
return Err("--commands - is given twice: standard input is read once".into());
|
||||
}
|
||||
let text = read_commands(&source)?;
|
||||
let read = parse_commands(&source, &text, steps.len())?;
|
||||
steps.extend(read);
|
||||
params_for = None;
|
||||
}
|
||||
"--export" | "-o" => {
|
||||
steps.push(Step::Export(val("a file")?));
|
||||
params_for = None;
|
||||
}
|
||||
"--scale" | "-s" => {
|
||||
let raw = val("a number")?;
|
||||
scale = raw.parse::<f64>().map_err(|_| format!("--scale {raw}: not a number"))?;
|
||||
@@ -394,10 +497,12 @@ fn run(args: &[String]) -> Result<(), String> {
|
||||
// Each step's result, for later steps to refer to (`"$1.id"`, see `vectorcraft_engine::steps`).
|
||||
let mut results = vec![];
|
||||
for step in steps {
|
||||
// The step's index, as `"$N"` names its result.
|
||||
let n = results.len();
|
||||
let r = match step {
|
||||
Step::Cmd(id, params) => {
|
||||
let params = vectorcraft_engine::steps::resolve(¶ms, &results).map_err(|e| format!("{id}: {e}"))?;
|
||||
let r = h.call("engine.execute", json!({"command": id, "params": params})).map_err(|e| format!("{id}: {e}"))?;
|
||||
let params = vectorcraft_engine::steps::resolve(¶ms, &results).map_err(|e| format!("step {n} ({id}): {e}"))?;
|
||||
let r = h.call("engine.execute", json!({"command": id, "params": params})).map_err(|e| format!("step {n} ({id}): {e}"))?;
|
||||
emit(json!({"step": "cmd", "command": id, "result": r}))?;
|
||||
r
|
||||
}
|
||||
@@ -407,7 +512,7 @@ fn run(args: &[String]) -> Result<(), String> {
|
||||
} else {
|
||||
h.call("app.export", with_pixel_scale(json!({"path": path}), &path, scale))
|
||||
};
|
||||
let r = r.map_err(|e| format!("export {path}: {e}"))?;
|
||||
let r = r.map_err(|e| format!("step {n}: export {path}: {e}"))?;
|
||||
emit(json!({"step": "export", "result": r}))?;
|
||||
r
|
||||
}
|
||||
|
||||
@@ -84,11 +84,125 @@ fn scale_sizes_raster_exports_only() {
|
||||
fn run_reports_errors() {
|
||||
let out = Command::new(BIN).args(["run", "--cmd", "nope.nothing"]).output().unwrap();
|
||||
assert!(!out.status.success());
|
||||
assert!(String::from_utf8_lossy(&out.stderr).contains("nope.nothing"));
|
||||
let stderr = String::from_utf8_lossy(&out.stderr).to_string();
|
||||
assert!(stderr.contains("step 0 (nope.nothing)"), "{stderr}");
|
||||
let out = Command::new(BIN).args(["run", "--params", "{}"]).output().unwrap();
|
||||
assert!(!out.status.success());
|
||||
}
|
||||
|
||||
/// The JSON lines `run` printed.
|
||||
fn json_lines(stdout: &[u8]) -> Vec<Value> {
|
||||
String::from_utf8_lossy(stdout).lines().map(|l| serde_json::from_str(l).unwrap()).collect()
|
||||
}
|
||||
|
||||
/// #3147: `run --commands FILE` reads its steps from a JSON array (`command` or `id`), runs them
|
||||
/// where the option stands among `--cmd` and `--export`, and they count with those for `"$N"`; a
|
||||
/// placed file's `dataBase64` longer than a Windows command line holds fits.
|
||||
#[test]
|
||||
fn run_reads_steps_from_a_commands_file() {
|
||||
let steps = tmp("steps.json");
|
||||
let svg = tmp("steps.svg");
|
||||
// Base64 of an SVG well past 32,767 characters, the most a Windows command line holds.
|
||||
let padding = "x".repeat(40_000);
|
||||
let placed = format!(
|
||||
r##"<svg xmlns="http://www.w3.org/2000/svg" width="40" height="20"><!-- {padding} --><rect width="40" height="20" fill="#118833"/></svg>"##
|
||||
);
|
||||
let data = vectorcraft_format::base64_encode(placed.as_bytes());
|
||||
assert!(data.len() > 32_767);
|
||||
let file = json!([
|
||||
{"command": "shape.rectangle", "params": {"x": 10, "y": 10, "width": 50, "height": 30}},
|
||||
{"command": "file.place", "params": {"name": "code.svg", "dataBase64": data}},
|
||||
{"id": "object.setProps", "params": {"id": "$2.ids.0", "name": "barcode"}},
|
||||
]);
|
||||
std::fs::write(&steps, serde_json::to_string_pretty(&file).unwrap()).unwrap();
|
||||
let out = Command::new(BIN)
|
||||
.args(["run", "--cmd", "file.new", "--params", r#"{"width":200,"height":100}"#, "--commands", steps.to_str().unwrap()])
|
||||
.args(["--cmd", "document.node", "--params", r#"{"id":"$2.ids.0"}"#, "--export", svg.to_str().unwrap()])
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr));
|
||||
let lines = json_lines(&out.stdout);
|
||||
let ran: Vec<&str> = lines.iter().map(|l| l["command"].as_str().unwrap_or("export")).collect();
|
||||
assert_eq!(ran, ["file.new", "shape.rectangle", "file.place", "object.setProps", "document.node", "export"]);
|
||||
// `$2` in the file and in the `--cmd` after it is the placed art.
|
||||
assert_eq!(lines[4]["result"]["name"], "barcode", "{}", lines[4]);
|
||||
let written = std::fs::read_to_string(&svg).unwrap();
|
||||
assert!(written.contains("118833") && written.contains("barcode"), "{written}");
|
||||
}
|
||||
|
||||
/// `run --commands -` reads JSON Lines from standard input: a byte order mark, blank lines and
|
||||
/// CRLF line ends are fine, and the first step may be `file.new`.
|
||||
#[test]
|
||||
fn run_reads_json_lines_from_standard_input() {
|
||||
let svg = tmp("stdin.svg");
|
||||
let mut child = Command::new(BIN)
|
||||
.args(["run", "--commands", "-", "--export", svg.to_str().unwrap()])
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
.unwrap();
|
||||
let text = "\u{feff}{\"command\":\"file.new\",\"params\":{\"width\":120,\"height\":80}}\n\n{\"command\":\"shape.ellipse\",\"params\":{\"x\":10,\"y\":10,\"width\":40,\"height\":30}}\r\n{\"id\":\"paint.setFill\",\"params\":{\"color\":\"#3366ff\"}}\n";
|
||||
child.stdin.take().unwrap().write_all(text.as_bytes()).unwrap();
|
||||
let out = child.wait_with_output().unwrap();
|
||||
assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr));
|
||||
let lines = json_lines(&out.stdout);
|
||||
assert_eq!(lines.len(), 4);
|
||||
assert_eq!(lines[0]["command"], "file.new");
|
||||
assert!(std::fs::read_to_string(&svg).unwrap().contains("3366ff"));
|
||||
}
|
||||
|
||||
/// Malformed `--commands` input stops the run before any step runs, naming the step (counted from
|
||||
/// 0 with the `--cmd` before it) and the entry or line; input past the size and step limits is
|
||||
/// refused; a step that fails names its number.
|
||||
#[test]
|
||||
fn run_refuses_malformed_commands_naming_the_step() {
|
||||
let fails = |args: &[&str]| {
|
||||
let out = Command::new(BIN).args(args).stdin(Stdio::null()).output().unwrap();
|
||||
assert!(!out.status.success(), "{args:?}");
|
||||
(String::from_utf8_lossy(&out.stderr).to_string(), json_lines(&out.stdout).len())
|
||||
};
|
||||
let bad = |name: &str, text: &[u8], wanted: &[&str]| {
|
||||
let path = tmp(name);
|
||||
std::fs::write(&path, text).unwrap();
|
||||
let (e, printed) = fails(&["run", "--cmd", "file.new", "--commands", path.to_str().unwrap()]);
|
||||
assert!(wanted.iter().all(|w| e.contains(w)), "{name}: {e}");
|
||||
assert_eq!(printed, 0, "{name}: nothing runs");
|
||||
};
|
||||
bad("number.json", b"[1]", &["step 1 (entry 1)", "must be an object"]);
|
||||
bad("both.json", br#"[{"command":"select.all"},{"command":"select.all","id":"select.none"}]"#, &["step 2 (entry 2)", "not both"]);
|
||||
bad("no-command.json", br#"[{"params":{}}]"#, &["step 1 (entry 1)", "no `command`"]);
|
||||
bad("id-number.json", br#"[{"command":3}]"#, &["`command` must be a string"]);
|
||||
bad("params-array.json", br#"[{"command":"select.all","params":[1]}]"#, &["`params` of select.all must be a JSON object"]);
|
||||
bad("typo.json", br#"[{"command":"select.all","param":{}}]"#, &["unknown key `param`"]);
|
||||
bad("broken.json", b"[{\"command\":", &["--commands", "broken.json"]);
|
||||
bad("lines.jsonl", b"{\"command\":\"select.all\"}\n\n{oops}\n", &["step 2 (line 3)"]);
|
||||
bad("line-array.jsonl", b"{\"command\":\"select.all\"}\n[1]\n", &["step 2 (line 2)"]);
|
||||
bad("latin1.json", b"[{\"command\":\"select.all\",\"params\":{\"name\":\"\xe9\"}}]", &["not UTF-8"]);
|
||||
let many = "{\"command\":\"select.all\"}\n".repeat(100_001);
|
||||
bad("many.jsonl", many.as_bytes(), &["more than 100000 steps"]);
|
||||
// Past 64 MiB (a sparse file: nothing is written).
|
||||
let huge = tmp("huge.json");
|
||||
std::fs::File::create(&huge).unwrap().set_len(64 * 1024 * 1024 + 1).unwrap();
|
||||
let (e, _) = fails(&["run", "--commands", huge.to_str().unwrap()]);
|
||||
assert!(e.contains("more than 64 MiB"), "{e}");
|
||||
let (e, _) = fails(&["run", "--commands", tmp("missing.json").to_str().unwrap()]);
|
||||
assert!(e.contains("missing.json"), "{e}");
|
||||
// `--params` sets the `--cmd` just before it, never a step of the file.
|
||||
let ok = tmp("ok.json");
|
||||
std::fs::write(&ok, br#"[{"command":"select.all"}]"#).unwrap();
|
||||
let (e, _) = fails(&["run", "--cmd", "select.all", "--commands", ok.to_str().unwrap(), "--params", "{}"]);
|
||||
assert!(e.contains("--params must follow a --cmd"), "{e}");
|
||||
let (e, _) = fails(&["run", "--commands", "-", "--commands", "-"]);
|
||||
assert!(e.contains("standard input is read once"), "{e}");
|
||||
// A step that fails is named by its number, after the steps before it ran.
|
||||
let failing = tmp("failing.json");
|
||||
std::fs::write(&failing, br#"[{"command":"select.all"},{"command":"nope.nothing"}]"#).unwrap();
|
||||
let (e, printed) = fails(&["run", "--cmd", "file.new", "--commands", failing.to_str().unwrap()]);
|
||||
assert!(e.contains("step 2 (nope.nothing)"), "{e}");
|
||||
assert_eq!(printed, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mcp_headless_over_stdio() {
|
||||
let mut child = Command::new(BIN).args(["mcp", "--headless"]).stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::null()).spawn().unwrap();
|
||||
|
||||
+29
-4
@@ -828,10 +828,35 @@ vectorcraft-cli run --cmd file.new --params '{"width":800,"height":600}' \
|
||||
--export star.vectorcraft
|
||||
```
|
||||
|
||||
`run` prints one JSON line per step (`open`, `cmd`, `export`) and exits non-zero on the first failure. `--params`
|
||||
applies to the `--cmd` just before it. `run` also accepts the host commands `file.open`, `file.save`, `file.export`,
|
||||
`file.exportForScreens` and `tool.select`. `run --in`, `convert` and `info` read every format `document.open` reads
|
||||
(`vectorcraft-cli --help` lists them).
|
||||
`run` prints one JSON line per step (`open`, `cmd`, `export`) and exits non-zero on the first failure, naming the step
|
||||
(counting from 0, as `"$N"` references do) and its command. `--params` applies to the `--cmd` just before it. `run`
|
||||
also accepts the host commands `file.open`, `file.save`, `file.export`, `file.exportForScreens` and `tool.select`.
|
||||
`run --in`, `convert` and `info` read every format `document.open` reads (`vectorcraft-cli --help` lists them).
|
||||
|
||||
### Steps from a file or standard input
|
||||
|
||||
A command line has a length limit (32,767 characters on Windows), which long parameters such as a placed file's
|
||||
`dataBase64` or a long list of ids soon reach (#3147). `--commands FILE` reads the steps from a file instead, and
|
||||
`--commands -` from standard input:
|
||||
|
||||
```sh
|
||||
vectorcraft-cli run --in sheet.vectorcraft --commands steps.json --export out.pdf
|
||||
```
|
||||
|
||||
```json
|
||||
[
|
||||
{"command": "file.place", "params": {"name": "code.svg", "dataBase64": "PHN2ZyB4bWxucz0i…", "rect": [20, 40, 16, 57]}},
|
||||
{"command": "object.setProps", "params": {"id": "$0.ids.0", "name": "barcode"}}
|
||||
]
|
||||
```
|
||||
|
||||
The text is a JSON array of steps, or one step per line (JSON Lines; blank lines are skipped). A step is an object
|
||||
with `command`, the command id (or `id`, as `command_batch` names it), and optional `params`, a JSON object; any
|
||||
other key is refused. The steps run where `--commands` stands among the `--cmd` and `--export` options, in order,
|
||||
and count with them for `"$N"`, so a file can refer to the ids an earlier `--cmd` made and a later `--cmd` to the
|
||||
file's results. `--commands` may be given more than once (standard input once). The text is untrusted input: it
|
||||
must be UTF-8 (a byte order mark is skipped), at most 64 MiB and 100,000 steps, and a malformed step stops the run
|
||||
before anything runs, with the step's number and its entry (`entry 3`) or line (`line 3`) in the message.
|
||||
|
||||
### What a headless session remembers
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Parity with Adobe Illustrator
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-22 · **Change:** minor (G2: Effect › Sketch › Conté Crayon (Foreground Level, Background Level, Texture, Scaling, Relief, Light Direction, Invert; a black crayon in the dark areas and a white one in the light areas on textured mid-grey paper; ranges and defaults unverified VectorCraft choices); measured Photoshop-style filters 57/57, the Effect Gallery remains (4–6 h); Affinity import AF.5: master pages, tables, paragraph formats, shadows, glows, outlines, overlays and embedded documents; Recolor Artwork Edit chips scroll within the available screen height (#3416); small palettes retain their tab height; scores and estimates unchanged; #3515: recent-font groups in font pickers; PDF/X-1a and PDF/X-3 flattening keeps a CMYK document's inks where it rasterizes, #2160; Blend panel Type and Ramp control step easing with a separate color Shift (#2539); Blend Options retains spacing and orientation; polynomial curves are original approximation; UI chrome: panels dock into any dock group or the icon column by dragging (UI.6), workspaces keep their layout changes (UI.7), dock groups collapse to their tabs by a double-click (UI.8); scores and hours unchanged; Shape Builder Gap Detection shares Live Paint’s spatial gap finder and temporary merge bridges (#2526); Custom is capped at 72 pt in the existing options dialog; Recolor model selection is available through the engine and MCP only (#2520); the dialog retains its existing controls; filter plug-ins read and set effect records and stroke joins, caps and miter limits, G17; the Print dialog's Media list has the Japanese papers: JIS B4 257 × 364 mm, JIS B5 182 × 257 mm and JIS B6 beside the ISO B4/B5, which keep their ISO 216 sizes, and はがき, 往復はがき, 封筒 長形3号/長形4号/角形2号/角形3号 and 四六判; a paper is named by its JSON id or its dialog label, any case and spacing; imported PDF type that is letter-spaced keeps its word spaces and no others; Fractional Widths: Character panel menu, per type object, on by default; System Layout remains a panel stub; scores unchanged; G4: manual Tate-chu-yoko; G4: No Break; scores unchanged; the selection tools take a clipping path on its outline, G1; Polygon and Star exact-size placement remembers the chosen count for later drags and dialog openings; Cancel preserves the previous count; Live trace result bounds and selection highlights exclude the retained bitmap except in source-image views; CSS swatch libraries retain fractional gradient stop positions; scores and hours unchanged; live-effect plug-ins given their object's fills and strokes and the item they sit on, G17; plug-in parameters: labels, units, groups, help and conditions in their dialogs, text and number-list parameters, up to 256 of them, G17; exporter plug-ins: plug-in file formats in Export As and document.export, G17; G2: Effect › Artistic › Smudge Stick, Sponge and Watercolor; Sketch › Bas Relief, Charcoal, Photocopy and Stamp, #2533; Photoshop filters 45/57, 12 remaining (8–14 h); interactive Mirror Repeat axis; bounding-box handles take priority over the axis body while the center and end handles retain their move and rotation actions; Libraries panel: the list scrolls, items and groups reorder by dragging, items drag into and out of groups (`library.moveItem` before/after, `library.moveGroup`); scores unchanged; PDF invisible text opens as type with no fill or stroke and is written back invisible, #2675; status-readout sample widths cached by language, document unit and pixels per point; status messages and readouts use stable responsive allocations sized for localized labels and ordinary coordinates; cached canvas dimensions match the renderer limit before cache-key creation, view construction and worker submission; cached canvas reprojection preserves scale during resize, rotation and DPI changes; interactive Mirror Repeat axis; bounding-box handles take priority over the axis body while the center and end handles retain their move and rotation actions; 3D lighting: one to ten colored lights, smooth colored Inflate, legacy white-light appearance preserved; surface batching and restored seam minimum; scores and estimates unchanged; Blend spine Anchor Point conversions, selection/stacking priority and Shift regression coverage (#2544); scores and hours unchanged; Artboard Options' X and Y with a reference point, #1876; Image Trace: Auto Color keeps rare but distinct colours; Image Trace: large images trace with a warning; anchor limit raised to 8,000,000; Image Trace: Raster::new returns an error for a buffer of the wrong length; M12 maximal-disc graph pruning and junction merging measured; M12 crop EDT and Guo–Hall thinning measured; M12 centre-lines baseline measured; scores and hours unchanged; installed plug-ins stay installed after a restart, #2541; Group Selection picks and moves one path of a compound path, #2441 in part; the Move dialog oLine truncated
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-22 · **Change:** minor (#3147: `vectorcraft-cli run --commands FILE` (or `-`) reads the steps from a JSON file or standard input (an array or JSON Lines, at most 64 MiB and 100,000 steps), among `--cmd` and `--export` in order; errors name the step. Scores and estimates unchanged; G2: Effect › Sketch › Conté Crayon (Foreground Level, Background Level, Texture, Scaling, Relief, Light Direction, Invert; a black crayon in the dark areas and a white one in the light areas on textured mid-grey paper; ranges and defaults unverified VectorCraft choices); measured Photoshop-style filters 57/57, the Effect Gallery remains (4–6 h); Affinity import AF.5: master pages, tables, paragraph formats, shadows, glows, outlines, overlays and embedded documents; Recolor Artwork Edit chips scroll within the available screen height (#3416); small palettes retain their tab height; scores and estimates unchanged; #3515: recent-font groups in font pickers; PDF/X-1a and PDF/X-3 flattening keeps a CMYK document's inks where it rasterizes, #2160; Blend panel Type and Ramp control step easing with a separate color Shift (#2539); Blend Options retains spacing and orientation; polynomial curves are original approximation; UI chrome: panels dock into any dock group or the icon column by dragging (UI.6), workspaces keep their layout changes (UI.7), dock groups collapse to their tabs by a double-click (UI.8); scores and hours unchanged; Shape Builder Gap Detection shares Live Paint’s spatial gap finder and temporary merge bridges (#2526); Custom is capped at 72 pt in the existing options dialog; Recolor model selection is available through the engine and MCP only (#2520); the dialog retains its existing controls; filter plug-ins read and set effect records and stroke joins, caps and miter limits, G17; the Print dialog's Media list has the Japanese papers: JIS B4 257 × 364 mm, JIS B5 182 × 257 mm and JIS B6 beside the ISO B4/B5, which keep their ISO 216 sizes, and はがき, 往復はがき, 封筒 長形3号/長形4号/角形2号/角形3号 and 四六判; a paper is named by its JSON id or its dialog label, any case and spacing; imported PDF type that is letter-spaced keeps its word spaces and no others; Fractional Widths: Character panel menu, per type object, on by default; System Layout remains a panel stub; scores unchanged; G4: manual Tate-chu-yoko; G4: No Break; scores unchanged; the selection tools take a clipping path on its outline, G1; Polygon and Star exact-size placement remembers the chosen count for later drags and dialog openings; Cancel preserves the previous count; Live trace result bounds and selection highlights exclude the retained bitmap except in source-image views; CSS swatch libraries retain fractional gradient stop positions; scores and hours unchanged; live-effect plug-ins given their object's fills and strokes and the item they sit on, G17; plug-in parameters: labels, units, groups, help and conditions in their dialogs, text and number-list parameters, up to 256 of them, G17; exporter plug-ins: plug-in file formats in Export As and document.export, G17; G2: Effect › Artistic › Smudge Stick, Sponge and Watercolor; Sketch › Bas Relief, Charcoal, Photocopy and Stamp, #2533; Photoshop filters 45/57, 12 remaining (8–14 h); interactive Mirror Repeat axis; bounding-box handles take priority over the axis body while the center and end handles retain their move and rotation actions; Libraries panel: the list scrolls, items and groups reorder by dragging, items drag into and out of groups (`library.moveItem` before/after, `library.moveGroup`); scores unchanged; PDF invisible text opens as type with no fill or stroke and is written back invisible, #2675; status-readout sample widths cached by language, document unit and pixels per point; status messages and readouts use stable responsive allocations sized for localized labels and ordinary coordinates; cached canvas dimensions match the renderer limit before cache-key creation, view construction and worker submission; cached canvas reprojection preserves scale during resize, rotation and DPI changes; interactive Mirror Repeat axis; bounding-box handles take priority over the axis body while the center and end handles retain their move and rotation actions; 3D lighting: one to ten colored lights, smooth colored Inflate, legacy white-light appearance preserved; surface batching and restored seam minimum; scores and estimates unchanged; Blend spine Anchor Point conversions, selection/stacking priority and Shift regression coverage (#2544); scores and hours unchanged; Artboard Options' X and Y with a reference point, #1876; Image Trace: Auto Color keeps rare but distinct colours; Image Trace: large images trace with a warning; anchor limit raised to 8,000,000; Image Trace: Raster::new returns an error for a buffer of the wrong length; M12 maximal-disc graph pruning and junction merging measured; M12 Line truncated
|
||||
|
||||
## Feature submission evidence
|
||||
|
||||
@@ -497,7 +497,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
|
||||
- The control server writes each reply through a 64 KiB buffer. On Windows 11 it sends the 17 MB `document.inspect` reply of a document with 50,000 objects on one layer to a client over loopback in 0.09 s instead of 34 s.
|
||||
- MCP server with 25 tools (drawing, text, effects, Pathfinder, transforms, graphs, text wrap, export, screenshots, any command), attached to the running app or headless. Tool descriptions and replies agree with the engine: format lists come from its format table, `save_file` says it keeps the document's own format, `inspect_document` reports the paint type's characters show, and selection exports report the encoder's warnings. `--automation-read-root` / `--automation-write-root` (on `vectorcraft-cli mcp` and on `vectorcraft --control`) confine the files commands read and write to chosen folders, links followed, with the flags PhotoCraft takes (#832). A long export can be cancelled (`notifications/cancelled` stops it at the next page or group and writes nothing), reports page progress to a client that sent a progress token, and does not make other requests wait: engine-free ones answer at once, the rest are refused as busy after two seconds (#2165, #2171, #2172).
|
||||
- MCP protocol: five prompts (`prompts/list` / `prompts/get`: poster, icon set, recolour, trace-and-style, export set), `completion/complete` for prompt arguments and template variables read from the live catalogues (formats, effect ids, command ids, trace presets, swatch and object ids), four resource templates (`vectorcraft://object/{id}`, `//command/{id}`, `//effect/{id}`, `//swatch/{name}`) so an agent reads one thing instead of the whole document, and `logging/setLevel` with `notifications/message`. `window.panel` ids are case-insensitive (display labels work too), `vectorcraft://object/{id}` reads through `document.node {summary: true}` (the same reply bytes from one lookup instead of summarizing the whole document: a leaf read on a 2,000-object document drops from ~20 ms to ~0.06 ms), large containers slice with `document.node {depth?, childLimit?}` (truncated levels report `childCount`), `document.inspect` and the `inspect_document` tool slice the same way so a depth-0 read is the layer skeleton (~9.7 KB instead of ~2 MB on a 2,000-object document), `document.find` locates nodes by name/kind/text with ancestor paths, and the CLI sweeps the protocol surface over stdio against the compiled binary. `docs/mcp.md` has a Protocol section covering the capability table and what is deliberately absent (subscriptions, progress, cancellation, sampling, elicitation, Streamable HTTP — all of which need the transport to interleave reads).
|
||||
- Headless CLI (`vectorcraft-cli run`, `convert`, `info`, `bench`, `perf`, `mcp`). A `run` step, like a `command_batch` step over MCP, can use an earlier step's result (`"$1.id"`, #785).
|
||||
- Headless CLI (`vectorcraft-cli run`, `convert`, `info`, `bench`, `perf`, `mcp`). A `run` step, like a `command_batch` step over MCP, can use an earlier step's result (`"$1.id"`, #785); `run --commands FILE` (or `-` for standard input) reads the steps from a JSON array or JSON Lines, past the Windows command-line limit (#3147, `apps/vectorcraft-cli/tests/cli.rs` `run_reads_*`, `run_refuses_malformed_commands_naming_the_step`).
|
||||
- File › Scripts › Check Script… reads a script written for Illustrator (`.jsx`, `.jsxinc` or a compiled `.jsxbin`) without running any of it and reports what it needs (the document model, files, dialogs, menu commands, features only Illustrator has), what it would do outside the document, the menu commands and document-model members it uses, its directives and included files, and the problems that would stop it loading (`script.check`, which also reads `.js` files); File › Open and drag-and-drop show the report for `.jsx`, `.jsxinc` and `.jsxbin` files.
|
||||
- **UI:** Illustrator 2026 layout restyled to measured values:
|
||||
- Medium Dark theme, categorized and Advanced toolbars (a tool group's flyout opens on a right press, a long press or a press on its corner triangle), 35 pt document tabs, 33 pt panel tabs.
|
||||
@@ -981,6 +981,7 @@ separate. Scores and hours unchanged. [Behavior and command details](development
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-22 | minor | #3147: `vectorcraft-cli run --commands FILE` (or `-`) reads the steps from a JSON file or standard input (an array or JSON Lines, at most 64 MiB and 100,000 steps), among `--cmd` and `--export` in order; errors name the step. Scores and estimates unchanged. |
|
||||
| 2026-10-22 | minor | G2: Effect › Sketch › Conté Crayon (Foreground Level, Background Level, Texture, Scaling, Relief, Light Direction, Invert; a black crayon in the dark areas and a white one in the light areas on textured mid-grey paper; ranges and defaults unverified VectorCraft choices); measured Photoshop-style filters 57/57, the Effect Gallery remains (4–6 h); area score unchanged (49%, estimated). |
|
||||
| 2026-10-22 | minor | File formats: Affinity import gains master pages, tables, paragraph formats, text scale, shadows, glows, outlines (also on stroked shapes), overlays, underlines, strikethrough and editable embedded documents, strokes styled None left out, CMYK pixel layers through the document's CMYK profile, text placed as Affinity places it (AF.5, from storytold/designcraft#291); synthetic, corpus and local-thumbnail tests; score and hours unchanged (estimated). Hostile files stay bounded: one budget for the whole read (layers, pixels, characters, table cells, embedded documents included), a work budget for overlays, capped master page copies, objects past `MAX_COORD` left out; the import fuzzer and limit tests cover tables, paragraphs, effects, overlays, master pages, CMYK pixels and embedded documents. |
|
||||
| 2026-10-22 | minor | Recolor Artwork Edit chips scroll within the available screen height (#3416); small palettes retain their tab height; scores and estimates unchanged. |
|
||||
|
||||
Reference in new issue
Block a user