Agents: command scripts with result references (CLI, MCP, app); parity estimate
- engine::script: parse scripts (lines, JSON lines, JSON arrays), resolve "$N.path" /
"${N.path}" references to earlier results, run with a failure report (+ tests)
- CLI: script [FILE|-] (headless or --connect to the running app; --save/--export,
--keep-going), app COMMAND [JSON] / --method, describe ID, commands FILTER, $N references in
run --cmd (+ integration tests)
- MCP batch: references and a script-text alternative (+ test)
- docs/agents.md; docs/parity.md scored over 275 catalogue features with cargo xtask parity;
ROADMAP: parity ~63% breadth / ~55% overall, ~600 agent-hours remaining
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
ded7c7ad04
commit
7145107896
27
ROADMAP.md
27
ROADMAP.md
|
|
@ -35,6 +35,33 @@ DesignCraft aims at full Adobe InDesign parity — and to be better: faster, ope
|
|||
|
||||
**Next (in order):** books, footnotes in EPUB / span columns / split footnotes · table/cell styles, text rotation in cells · PDF/X-4 output intent, tagged PDF · UI for Data Merge/spelling/hyperlinks.
|
||||
|
||||
## How far from full parity (estimate, 2026-10-02)
|
||||
|
||||
**Breadth: ~63% weighted** (P0 core 86%, P1 44%, P2 15%) over the 275 features of the InDesign catalogue, scored
|
||||
row by row in [docs/parity.md](docs/parity.md) (`cargo xtask parity` recomputes it). Many features scored done still
|
||||
lack some of InDesign's options or dialog details, so **overall parity including depth is about 55%**.
|
||||
|
||||
**Remaining work: about 600 wall-clock hours of a single Claude Opus 5.5 agent** (±30%), or roughly 150–200 hours with
|
||||
four agents in parallel on separate crates:
|
||||
|
||||
| Work | Estimate |
|
||||
|---|---|
|
||||
| Open P0 (23: autosave/recovery, Preferences, Color panel/picker, Gradient tool, Place PDF/SVG, DOCX/RTF import, PDF/X-4, parent overrides UI, …) | 35 h |
|
||||
| Open P1 (66: type on a path, nested/GREP styles, table/cell styles, pathfinder, glyphs panel, package, print, variable fonts, …) | 165 h |
|
||||
| Open P2 (68: liquid/alternate layouts, books, buttons & forms, XML, conditional text, HTML, tagged PDF, …) | 205 h |
|
||||
| Depth and pixel fidelity of every dialog, panel and menu against InDesign 2026 | 130 h |
|
||||
| Performance (incremental composition, GPU raster) and interchange hardening (IDML/PDF corpus) | 85 h |
|
||||
|
||||
Basis: this session landed 12 arcs (footnotes, cross-references, index, anchored objects, guides, Links, Find Font,
|
||||
clipboard, menus, damage repaint, agent scripting) in about 13 hours on a heavily loaded machine — roughly 1–3 hours per
|
||||
catalogue row depending on size.
|
||||
|
||||
## Agents: CLI and MCP
|
||||
|
||||
Every command is reachable from `designcraft-cli` (`run`, `script` with `$N.path` result references, `app` for the
|
||||
running window, `describe`, `commands`), from MCP (`designcraft-cli mcp`, tools including `execute` and `batch` with the
|
||||
same references), and from the app's JSON control channel. See [docs/agents.md](docs/agents.md).
|
||||
|
||||
## Milestones
|
||||
|
||||
| # | Milestone | State |
|
||||
|
|
|
|||
|
|
@ -2,7 +2,12 @@
|
|||
//!
|
||||
//! ```text
|
||||
//! designcraft-cli run [--in FILE.designcraft|FILE.idml | --sample] [--cmd ID[=JSON]]... [--page N] [--scale S] [--export OUT.png|.jpg|.pdf|.designcraft|.idml|.epub] [--pdf-options JSON] [--all-pages DIR]
|
||||
//! designcraft-cli commands # list every command (JSON)
|
||||
//! designcraft-cli commands [FILTER] # list commands (JSON), optionally only ids/labels/menus containing FILTER
|
||||
//! designcraft-cli describe ID # one command: label, menu, shortcut, parameters
|
||||
//! designcraft-cli script [FILE|-] [--in FILE | --sample] [--connect PORT] [--save OUT] [--export OUT] [--keep-going]
|
||||
//! # run a command script (crates/engine/src/script.rs): `$N.path` references
|
||||
//! designcraft-cli app [--port PORT] COMMAND [JSON] # run a command in the running app (designcraft --control PORT)
|
||||
//! designcraft-cli app [--port PORT] --method METHOD [JSON] # any control-channel method (ui.screenshot, ui.render, …)
|
||||
//! designcraft-cli mcp [--connect PORT] [--sample] # MCP server over stdio (docs/mcp.md)
|
||||
//! designcraft-cli perf [--pages N] [--frames N] [--chars N] [--images N] [--runs N] [--strict] # budgets on a synthetic stress document
|
||||
//! designcraft-cli bench FILE [--runs N] # the same measurements on one document
|
||||
|
|
@ -21,9 +26,20 @@ fn main() -> ExitCode {
|
|||
Some("run") => report(run(&args[1..])),
|
||||
Some("commands") => {
|
||||
let s = Session::new();
|
||||
println!("{}", serde_json::to_string_pretty(&s.commands()).unwrap_or_default());
|
||||
let filter = args.get(1).map(|f| f.to_lowercase());
|
||||
let list: Vec<Value> = serde_json::to_value(s.commands())
|
||||
.ok()
|
||||
.and_then(|v| v.as_array().cloned())
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.filter(|c| filter.as_ref().is_none_or(|f| c.to_string().to_lowercase().contains(f.as_str())))
|
||||
.collect();
|
||||
println!("{}", serde_json::to_string_pretty(&list).unwrap_or_default());
|
||||
ExitCode::SUCCESS
|
||||
}
|
||||
Some("describe") => report(describe(args.get(1).map(String::as_str))),
|
||||
Some("script") => report(script(&args[1..])),
|
||||
Some("app") => report(app(&args[1..])),
|
||||
Some("mcp") => report(mcp(&args[1..])),
|
||||
Some("perf") => report(perf::perf(&args[1..])),
|
||||
Some("bench") => report(perf::bench(&args[1..])),
|
||||
|
|
@ -34,7 +50,7 @@ fn main() -> ExitCode {
|
|||
}
|
||||
_ => {
|
||||
eprintln!(
|
||||
"usage: designcraft-cli run [--in FILE | --sample] [--cmd ID[=JSON]]... [--page N] [--scale S] [--pdf-options JSON] [--export OUT] [--all-pages DIR]\n designcraft-cli commands\n designcraft-cli mcp [--connect PORT] [--sample]\n designcraft-cli perf [--pages N] [--runs N] [--strict]\n designcraft-cli bench FILE [--runs N]\n designcraft-cli links"
|
||||
"usage: designcraft-cli run [--in FILE | --sample] [--cmd ID[=JSON]]... [--page N] [--scale S] [--pdf-options JSON] [--export OUT] [--all-pages DIR]\n designcraft-cli commands [FILTER]\n designcraft-cli describe COMMAND\n designcraft-cli script [FILE|-] [--in FILE | --sample] [--connect PORT] [--save OUT] [--export OUT] [--keep-going]\n designcraft-cli app [--port PORT] COMMAND [JSON] | --method METHOD [JSON]\n designcraft-cli mcp [--connect PORT] [--sample]\n designcraft-cli perf [--pages N] [--runs N] [--strict]\n designcraft-cli bench FILE [--runs N]\n designcraft-cli links"
|
||||
);
|
||||
eprintln!(
|
||||
"\nCommunity: {} · {} · {}",
|
||||
|
|
@ -100,6 +116,8 @@ fn run(args: &[String]) -> Result<(), String> {
|
|||
let mut it = args.iter();
|
||||
let mut opened = false;
|
||||
let mut pdf_opts = json!({});
|
||||
// Earlier --cmd results, for `$N.path` references.
|
||||
let mut results: Vec<Value> = Vec::new();
|
||||
while let Some(a) = it.next() {
|
||||
let mut val = || it.next().cloned().ok_or_else(|| format!("{a} needs a value"));
|
||||
match a.as_str() {
|
||||
|
|
@ -120,7 +138,9 @@ fn run(args: &[String]) -> Result<(), String> {
|
|||
let c = val()?;
|
||||
let (id, p) = c.split_once('=').unwrap_or((&c, "{}"));
|
||||
let p: Value = serde_json::from_str(p).map_err(|e| format!("--cmd {id}: {e}"))?;
|
||||
let p = designcraft_engine::script::resolve(&p, &results).map_err(|e| format!("--cmd {id}: {e}"))?;
|
||||
let r = s.execute(id, &p).map_err(|e| e.to_string())?;
|
||||
results.push(r.clone());
|
||||
if !r.is_null() {
|
||||
println!("{}", serde_json::to_string(&r).unwrap_or_default());
|
||||
}
|
||||
|
|
@ -183,3 +203,147 @@ fn export(s: &mut Session, out: &str, page: usize, scale: f64) -> Result<(), Str
|
|||
eprintln!("wrote {out} ({}×{}, {:.1} ms, {} glyphs)", img.width, img.height, t.elapsed().as_secs_f64() * 1000.0, r.stats.glyphs);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `describe ID`: one command's documentation.
|
||||
fn describe(id: Option<&str>) -> Result<(), String> {
|
||||
let id = id.ok_or("usage: designcraft-cli describe COMMAND")?;
|
||||
let s = Session::new();
|
||||
let all = serde_json::to_value(s.commands()).map_err(|e| e.to_string())?;
|
||||
let c = all.as_array().and_then(|a| a.iter().find(|c| c["id"] == id)).ok_or_else(|| {
|
||||
let near: Vec<String> = all
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(|c| c["id"].as_str())
|
||||
.filter(|c| c.split('.').next() == id.split('.').next())
|
||||
.map(str::to_string)
|
||||
.collect();
|
||||
format!("no command `{id}`{}", if near.is_empty() { String::new() } else { format!(" (similar: {})", near.join(", ")) })
|
||||
})?;
|
||||
// Enablement depends on a live document and selection: not meaningful here.
|
||||
let mut c = c.clone();
|
||||
if let Some(o) = c.as_object_mut() {
|
||||
o.remove("enabled");
|
||||
o.remove("disabled_reason");
|
||||
}
|
||||
println!("{}", serde_json::to_string_pretty(&c).unwrap_or_default());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `script`: run a command script headless (or in the running app with `--connect`).
|
||||
fn script(args: &[String]) -> Result<(), String> {
|
||||
use designcraft_mcp::{Backend, Headless, Remote, control_addr};
|
||||
let mut file: Option<String> = None;
|
||||
let mut setup: Vec<(String, Value)> = Vec::new();
|
||||
let mut connect: Option<String> = None;
|
||||
let mut save: Option<String> = None;
|
||||
let mut export: Vec<String> = Vec::new();
|
||||
let mut keep_going = false;
|
||||
let mut it = args.iter();
|
||||
while let Some(a) = it.next() {
|
||||
let mut val = || it.next().cloned().ok_or_else(|| format!("{a} needs a value"));
|
||||
match a.as_str() {
|
||||
"--in" => {
|
||||
let p = val()?;
|
||||
let cmd = if p.to_lowercase().ends_with(".idml") { "file.openIdml" } else { "file.open" };
|
||||
setup.push((cmd.into(), json!({"path": p})));
|
||||
}
|
||||
"--sample" => setup.push(("file.newSample".into(), json!({}))),
|
||||
"--connect" => connect = Some(val()?),
|
||||
"--save" => save = Some(val()?),
|
||||
"--export" => export.push(val()?),
|
||||
"--keep-going" => keep_going = true,
|
||||
f if !f.starts_with("--") && file.is_none() => file = Some(f.to_string()),
|
||||
other => return Err(format!("unknown script option `{other}`")),
|
||||
}
|
||||
}
|
||||
let text = match file.as_deref() {
|
||||
None | Some("-") => {
|
||||
let mut t = String::new();
|
||||
std::io::Read::read_to_string(&mut std::io::stdin(), &mut t).map_err(|e| e.to_string())?;
|
||||
t
|
||||
}
|
||||
Some(f) => std::fs::read_to_string(f).map_err(|e| format!("{f}: {e}"))?,
|
||||
};
|
||||
let steps = designcraft_engine::script::parse(&text)?;
|
||||
let mut backend: Box<dyn Backend> = match &connect {
|
||||
Some(c) => {
|
||||
let addr = control_addr(c);
|
||||
Box::new(
|
||||
Remote::connect(&addr)
|
||||
.map_err(|e| format!("cannot connect to the DesignCraft app at {addr}: {e} (start it with `designcraft --control PORT`)"))?,
|
||||
)
|
||||
}
|
||||
None => Box::new(Headless::with_document()),
|
||||
};
|
||||
let b = &mut *backend;
|
||||
let exec = |b: &mut dyn Backend, id: &str, p: Value| b.call("engine.execute", json!({"command": id, "params": p}));
|
||||
for (c, p) in &setup {
|
||||
exec(b, c, p.clone())?;
|
||||
}
|
||||
let report = if keep_going {
|
||||
// Run every step; failures become {"error": …} results.
|
||||
let mut results: Vec<Value> = Vec::new();
|
||||
for st in &steps {
|
||||
let r = designcraft_engine::script::resolve(&st.params, &results).and_then(|p| exec(b, &st.command, p));
|
||||
results.push(r.unwrap_or_else(|e| json!({"error": e, "command": st.command})));
|
||||
}
|
||||
designcraft_engine::script::Report { results, failed: None }
|
||||
} else {
|
||||
designcraft_engine::script::run(&steps, |id, p| exec(b, id, p))
|
||||
};
|
||||
if report.failed.is_none() {
|
||||
if let Some(out) = &save {
|
||||
exec(b, "file.saveAs", json!({"path": out}))?;
|
||||
}
|
||||
for out in &export {
|
||||
let lower = out.to_lowercase();
|
||||
let p = json!({"path": out});
|
||||
if lower.ends_with(".pdf") {
|
||||
exec(b, "file.exportPdf", p)?;
|
||||
} else if lower.ends_with(".idml") {
|
||||
exec(b, "file.exportIdml", p)?;
|
||||
} else if lower.ends_with(".epub") {
|
||||
exec(b, "file.exportEpub", p)?;
|
||||
} else {
|
||||
// PNG / JPEG of the first page.
|
||||
b.call("app.export", p)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
println!("{}", serde_json::to_string_pretty(&report.to_json()).unwrap_or_default());
|
||||
match report.failed {
|
||||
Some((i, c, e)) => Err(format!("step {i} ({c}) failed: {e}")),
|
||||
None => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
/// `app`: one command or control-channel method in the running app.
|
||||
fn app(args: &[String]) -> Result<(), String> {
|
||||
use designcraft_mcp::{Backend, Remote, control_addr};
|
||||
let mut port = "7979".to_string();
|
||||
let mut method: Option<String> = None;
|
||||
let mut rest: Vec<String> = Vec::new();
|
||||
let mut it = args.iter();
|
||||
while let Some(a) = it.next() {
|
||||
match a.as_str() {
|
||||
"--port" | "--connect" => port = it.next().cloned().ok_or("--port needs a value")?,
|
||||
"--method" => method = Some(it.next().cloned().ok_or("--method needs a value")?),
|
||||
_ => rest.push(a.clone()),
|
||||
}
|
||||
}
|
||||
let addr = control_addr(&port);
|
||||
let mut r = Remote::connect(&addr)
|
||||
.map_err(|e| format!("cannot connect to the DesignCraft app at {addr}: {e} (start it with `designcraft --control {port}`)"))?;
|
||||
let json_arg =
|
||||
|s: Option<&String>| -> Result<Value, String> { s.map_or(Ok(json!({})), |t| serde_json::from_str(t).map_err(|e| format!("bad JSON: {e}"))) };
|
||||
let out = match method {
|
||||
Some(m) => r.call(&m, json_arg(rest.first())?)?,
|
||||
None => {
|
||||
let cmd = rest.first().ok_or("usage: designcraft-cli app [--port PORT] COMMAND [JSON]")?;
|
||||
r.call("engine.execute", json!({"command": cmd, "params": json_arg(rest.get(1))?}))?
|
||||
}
|
||||
};
|
||||
println!("{}", serde_json::to_string_pretty(&out).unwrap_or_default());
|
||||
Ok(())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,68 @@
|
|||
//! The agent-facing command line: scripts with references, describe, filtered command lists.
|
||||
|
||||
use std::io::Write;
|
||||
use std::process::{Command, Stdio};
|
||||
|
||||
fn cli() -> Command {
|
||||
Command::new(env!("CARGO_BIN_EXE_designcraft-cli"))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn script_from_stdin_with_references_saves_and_exports() {
|
||||
let dir = std::env::temp_dir().join(format!("dc-cli-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let (doc, png) = (dir.join("a.designcraft"), dir.join("a.png"));
|
||||
let mut child = cli()
|
||||
.args(["script", "-", "--save", doc.to_str().unwrap(), "--export", png.to_str().unwrap()])
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.spawn()
|
||||
.unwrap();
|
||||
child
|
||||
.stdin
|
||||
.take()
|
||||
.unwrap()
|
||||
.write_all(
|
||||
b"# comment\nframe.create {\"rect\": [72, 72, 300, 200], \"content\": \"text\", \"text\": \"Hi\"}\nstory.get {\"story\": \"$0.story\"}\n",
|
||||
)
|
||||
.unwrap();
|
||||
let out = child.wait_with_output().unwrap();
|
||||
assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr));
|
||||
let v: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap();
|
||||
assert_eq!(v["completed"], 2);
|
||||
assert_eq!(v["results"][1]["text"], "Hi");
|
||||
assert!(doc.exists() && png.exists());
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failing_script_exits_nonzero_with_the_step() {
|
||||
let mut child = cli().args(["script", "-"]).stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::piped()).spawn().unwrap();
|
||||
child.stdin.take().unwrap().write_all(b"document.inspect\nno.such.command\n").unwrap();
|
||||
let out = child.wait_with_output().unwrap();
|
||||
assert!(!out.status.success());
|
||||
let v: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap();
|
||||
assert_eq!(v["failedIndex"], 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_cmd_references_and_describe() {
|
||||
let out = cli()
|
||||
.args([
|
||||
"run",
|
||||
"--cmd",
|
||||
r#"frame.create={"rect":[72,72,300,200],"content":"text","text":"Hello"}"#,
|
||||
"--cmd",
|
||||
r#"story.get={"story":"$0.story"}"#,
|
||||
])
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr));
|
||||
assert!(String::from_utf8_lossy(&out.stdout).contains("\"text\":\"Hello\""));
|
||||
let out = cli().args(["describe", "footnote.insert"]).output().unwrap();
|
||||
let v: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap();
|
||||
assert_eq!(v["label"], "Insert Footnote");
|
||||
let out = cli().args(["commands", "footnote."]).output().unwrap();
|
||||
let v: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap();
|
||||
assert!(v.as_array().unwrap().iter().all(|c| c.to_string().contains("footnote")));
|
||||
}
|
||||
|
|
@ -9,6 +9,7 @@
|
|||
pub mod cmd;
|
||||
pub mod links;
|
||||
pub mod sample;
|
||||
pub mod script;
|
||||
mod tooling;
|
||||
|
||||
use std::sync::Arc;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,207 @@
|
|||
//! Command scripts for agents and shells: a list of `{command, params}` steps where later steps
|
||||
//! can use earlier results.
|
||||
//!
|
||||
//! **References.** A string parameter that is exactly `"$N.path"` (or `"$last.path"`, `"$N"`)
|
||||
//! becomes the JSON value at `path` in the result of step `N` (0-based): after
|
||||
//! `frame.create → {"id": 8, "story": 9}`, `{"story": "$0.story"}` passes `9`. Paths are dotted
|
||||
//! keys and array indices (`$2.ids.0`). Inside longer strings `${N.path}` is replaced by the
|
||||
//! value's text (`"Frame ${0.id}"`).
|
||||
//!
|
||||
//! **Script text** ([`parse`]): a JSON array of steps, JSON lines (`{"command": …, "params": …}`),
|
||||
//! or compact lines `command.id {json}` / `command.id={json}` / `command.id`; blank lines and
|
||||
//! lines starting with `#` or `//` are ignored.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
/// One step of a script.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Step {
|
||||
pub command: String,
|
||||
pub params: Value,
|
||||
}
|
||||
|
||||
/// Parse script text (see the module docs).
|
||||
pub fn parse(text: &str) -> Result<Vec<Step>, String> {
|
||||
let t = text.trim();
|
||||
if t.starts_with('[') {
|
||||
let v: Value = serde_json::from_str(t).map_err(|e| format!("script: {e}"))?;
|
||||
return v
|
||||
.as_array()
|
||||
.ok_or("script: expected an array")?
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, s)| step_of(s).map_err(|e| format!("step {i}: {e}")))
|
||||
.collect();
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
for (n, line) in t.lines().enumerate() {
|
||||
let l = line.trim();
|
||||
if l.is_empty() || l.starts_with('#') || l.starts_with("//") {
|
||||
continue;
|
||||
}
|
||||
let step = if l.starts_with('{') {
|
||||
serde_json::from_str::<Value>(l).map_err(|e| e.to_string()).and_then(|v| step_of(&v))
|
||||
} else {
|
||||
// `id {json}`, `id={json}` or `id`.
|
||||
let split = l.find([' ', '=', '\t']).unwrap_or(l.len());
|
||||
let (id, rest) = l.split_at(split);
|
||||
let rest = rest.trim_start_matches(['=', ' ', '\t']).trim();
|
||||
let params = if rest.is_empty() { Ok(json!({})) } else { serde_json::from_str(rest).map_err(|e| e.to_string()) };
|
||||
params.map(|params| Step { command: id.to_string(), params })
|
||||
};
|
||||
out.push(step.map_err(|e| format!("line {}: {e}", n + 1))?);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn step_of(v: &Value) -> Result<Step, String> {
|
||||
let command = v.get("command").and_then(Value::as_str).filter(|s| !s.is_empty()).ok_or("missing `command`")?.to_string();
|
||||
let params = v.get("params").cloned().filter(|p| !p.is_null()).unwrap_or(json!({}));
|
||||
if !params.is_object() {
|
||||
return Err("`params` must be an object".into());
|
||||
}
|
||||
Ok(Step { command, params })
|
||||
}
|
||||
|
||||
/// The value a reference (`N.path`, `last.path`) points at.
|
||||
fn lookup(r: &str, results: &[Value]) -> Result<Value, String> {
|
||||
let mut parts = r.split('.');
|
||||
let head = parts.next().unwrap_or("");
|
||||
let idx = if head == "last" {
|
||||
results.len().checked_sub(1).ok_or("`$last` before any step")?
|
||||
} else {
|
||||
head.parse::<usize>().map_err(|_| format!("bad reference `${r}`"))?
|
||||
};
|
||||
let mut v = results.get(idx).ok_or_else(|| format!("`${r}`: step {idx} hasn't run (only {} have)", results.len()))?.clone();
|
||||
for p in parts {
|
||||
v = match (&v, p.parse::<usize>()) {
|
||||
(Value::Array(a), Ok(i)) => a.get(i).cloned(),
|
||||
(Value::Object(o), _) => o.get(p).cloned(),
|
||||
_ => None,
|
||||
}
|
||||
.ok_or_else(|| format!("`${r}`: no `{p}` in {}", short(&v)))?;
|
||||
}
|
||||
Ok(v)
|
||||
}
|
||||
|
||||
fn short(v: &Value) -> String {
|
||||
let s = v.to_string();
|
||||
if s.len() > 120 { format!("{}…", &s[..s.floor_char_boundary(120)]) } else { s }
|
||||
}
|
||||
|
||||
/// Replace references in `params` with values from earlier `results`.
|
||||
pub fn resolve(params: &Value, results: &[Value]) -> Result<Value, String> {
|
||||
Ok(match params {
|
||||
Value::String(s) => {
|
||||
if let Some(r) = s.strip_prefix('$').filter(|r| !r.is_empty() && !r.contains(['{', ' '])) {
|
||||
return lookup(r, results);
|
||||
}
|
||||
if !s.contains("${") {
|
||||
return Ok(params.clone());
|
||||
}
|
||||
let mut out = String::new();
|
||||
let mut rest = s.as_str();
|
||||
while let Some(i) = rest.find("${") {
|
||||
out.push_str(&rest[..i]);
|
||||
let end = rest[i..].find('}').ok_or_else(|| format!("unclosed `${{` in {s:?}"))? + i;
|
||||
let v = lookup(&rest[i + 2..end], results)?;
|
||||
match v {
|
||||
Value::String(t) => out.push_str(&t),
|
||||
other => out.push_str(&other.to_string()),
|
||||
}
|
||||
rest = &rest[end + 1..];
|
||||
}
|
||||
out.push_str(rest);
|
||||
Value::String(out)
|
||||
}
|
||||
Value::Array(a) => Value::Array(a.iter().map(|v| resolve(v, results)).collect::<Result<_, _>>()?),
|
||||
Value::Object(o) => Value::Object(o.iter().map(|(k, v)| Ok((k.clone(), resolve(v, results)?))).collect::<Result<_, String>>()?),
|
||||
v => v.clone(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Outcome of running a script.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Report {
|
||||
pub results: Vec<Value>,
|
||||
/// The failing step (index, command, message); later steps didn't run.
|
||||
pub failed: Option<(usize, String, String)>,
|
||||
}
|
||||
|
||||
impl Report {
|
||||
pub fn to_json(&self) -> Value {
|
||||
let mut v = json!({"completed": self.results.len(), "results": self.results});
|
||||
if let Some((i, c, e)) = &self.failed {
|
||||
v["failedIndex"] = json!(i);
|
||||
v["failedCommand"] = json!(c);
|
||||
v["error"] = json!(e);
|
||||
}
|
||||
v
|
||||
}
|
||||
}
|
||||
|
||||
/// Run `steps` through `exec`, resolving references; stops at the first error.
|
||||
pub fn run(steps: &[Step], mut exec: impl FnMut(&str, Value) -> Result<Value, String>) -> Report {
|
||||
let mut results = Vec::with_capacity(steps.len());
|
||||
for (i, s) in steps.iter().enumerate() {
|
||||
let r = resolve(&s.params, &results).and_then(|p| exec(&s.command, p));
|
||||
match r {
|
||||
Ok(v) => results.push(v),
|
||||
Err(e) => return Report { results, failed: Some((i, s.command.clone(), e)) },
|
||||
}
|
||||
}
|
||||
Report { results, failed: None }
|
||||
}
|
||||
|
||||
impl crate::Session {
|
||||
/// Run a script on this session (see [`crate::script`]).
|
||||
pub fn run_script(&mut self, steps: &[Step]) -> Report {
|
||||
run(steps, |c, p| self.execute(c, &p).map_err(|e| e.to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parses_all_forms() {
|
||||
let a = parse(r#"[{"command": "file.new"}, {"command": "frame.create", "params": {"rect": [0, 0, 10, 10]}}]"#).unwrap();
|
||||
let b = parse("# comment\nfile.new\n\nframe.create {\"rect\": [0, 0, 10, 10]}\n").unwrap();
|
||||
let c = parse("{\"command\": \"file.new\"}\nframe.create={\"rect\": [0, 0, 10, 10]}").unwrap();
|
||||
assert_eq!(a, b);
|
||||
assert_eq!(a, c);
|
||||
assert!(parse("frame.create {bad").unwrap_err().contains("line 1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolves_references() {
|
||||
let results = vec![json!({"id": 8, "story": 9, "ids": [4, 5]}), json!({"name": "A"})];
|
||||
let p = json!({"story": "$0.story", "id": "$0.ids.1", "label": "Frame ${0.id} of ${last.name}", "keep": "$", "list": ["$1.name"]});
|
||||
let r = resolve(&p, &results).unwrap();
|
||||
assert_eq!(r, json!({"story": 9, "id": 5, "label": "Frame 8 of A", "keep": "$", "list": ["A"]}));
|
||||
assert!(resolve(&json!({"x": "$5.id"}), &results).unwrap_err().contains("hasn't run"));
|
||||
assert!(resolve(&json!({"x": "$0.nope"}), &results).unwrap_err().contains("no `nope`"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runs_on_a_session_with_references() {
|
||||
let mut s = crate::Session::new();
|
||||
let steps = parse(
|
||||
"file.new {\"pages\": 2}\n\
|
||||
frame.create {\"rect\": [72, 72, 300, 200], \"content\": \"text\", \"text\": \"Hello\"}\n\
|
||||
text.select {\"story\": \"$1.story\", \"anchor\": 5, \"focus\": 5}\n\
|
||||
text.insert {\"text\": \" world\"}\n\
|
||||
story.get {\"story\": \"$1.story\"}",
|
||||
)
|
||||
.unwrap();
|
||||
let r = s.run_script(&steps);
|
||||
assert!(r.failed.is_none(), "{r:?}");
|
||||
assert_eq!(r.results[4]["text"], "Hello world");
|
||||
// Failure reports the step and keeps earlier results.
|
||||
let r = s.run_script(&parse("document.inspect\nno.such.command\ndocument.inspect").unwrap());
|
||||
assert_eq!(r.results.len(), 1);
|
||||
assert_eq!(r.failed.as_ref().map(|f| (f.0, f.1.as_str())), Some((1, "no.such.command")));
|
||||
assert_eq!(r.to_json()["failedIndex"], 1);
|
||||
}
|
||||
}
|
||||
|
|
@ -391,3 +391,28 @@ fn control_addr_accepts_port_or_address() {
|
|||
assert_eq!(control_addr("localhost:8000"), "localhost:8000");
|
||||
assert!(Remote::connect("127.0.0.1:1").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_steps_use_earlier_results() {
|
||||
let mut s = server();
|
||||
let v = ok(
|
||||
&mut s,
|
||||
"batch",
|
||||
json!({"commands": [
|
||||
{"command": "frame.create", "params": {"rect": [72, 72, 300, 200], "content": "text", "text": "Hello"}},
|
||||
{"command": "text.select", "params": {"story": "$0.story", "anchor": 5, "focus": 5}},
|
||||
{"command": "footnote.insert", "params": {"text": "A note for frame ${0.id}."}},
|
||||
{"command": "footnote.list", "params": {"story": "$0.story"}},
|
||||
]}),
|
||||
);
|
||||
assert_eq!(v["completed"], 4);
|
||||
let id = v["results"][0]["id"].as_u64().unwrap();
|
||||
assert_eq!(v["results"][3][0]["text"], format!("A note for frame {id}."));
|
||||
// The same as script text.
|
||||
let v = ok(
|
||||
&mut s,
|
||||
"batch",
|
||||
json!({"script": "# one per line\nframe.create {\"rect\": [0, 0, 50, 50]}\nobject.rename {\"ids\": [\"$0.id\"], \"name\": \"Box\"}"}),
|
||||
);
|
||||
assert_eq!(v["completed"], 2);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -114,15 +114,21 @@ pub fn tool_definitions() -> Vec<Value> {
|
|||
"batch",
|
||||
"Execute commands",
|
||||
"Run several commands in order, stopping at the first error. Returns each result; on failure also the failing index \
|
||||
and message (earlier commands stay applied; use edit.undo to revert them).",
|
||||
and message (earlier commands stay applied; use edit.undo to revert them). Later steps can use earlier results: a \
|
||||
parameter string \"$N.path\" is replaced by that value of step N's result (0-based; \"$last.path\" for the previous \
|
||||
step), e.g. after frame.create → {\"id\": 8, \"story\": 9}, {\"story\": \"$0.story\"} passes 9; \"${N.path}\" \
|
||||
interpolates into longer strings. Give `commands`, or `script` text (one `command.id {json}` per line).",
|
||||
obj(
|
||||
json!({"commands": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"description": "Commands to run in order",
|
||||
"items": obj(json!({"command": string("Command id"), "params": params_schema()}), &["command"]),
|
||||
}}),
|
||||
&["commands"],
|
||||
json!({
|
||||
"commands": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"description": "Commands to run in order",
|
||||
"items": obj(json!({"command": string("Command id"), "params": params_schema()}), &["command"]),
|
||||
},
|
||||
"script": string("Alternative to `commands`: lines of `command.id {json params}` (# comments allowed)"),
|
||||
}),
|
||||
&[],
|
||||
),
|
||||
false,
|
||||
),
|
||||
|
|
@ -433,22 +439,18 @@ fn filter_commands(all: Value, a: &Args) -> Value {
|
|||
}
|
||||
|
||||
fn batch(b: &mut dyn Backend, a: &Args) -> Result<ToolResult, String> {
|
||||
let cmds = a.get("commands").and_then(Value::as_array).filter(|c| !c.is_empty()).ok_or("`commands` must be a non-empty array")?;
|
||||
let mut results = vec![];
|
||||
for (i, c) in cmds.iter().enumerate() {
|
||||
let step = (|| {
|
||||
let id = c.get("command").and_then(Value::as_str).filter(|s| !s.is_empty()).ok_or("missing `command`")?;
|
||||
exec(b, id, command_params(c.get("params"))?)
|
||||
})();
|
||||
match step {
|
||||
Ok(r) => results.push(r),
|
||||
Err(e) => {
|
||||
let v = json!({"completed": i, "results": results, "failedIndex": i, "failedCommand": c.get("command"), "error": e});
|
||||
return Ok(ToolResult { is_error: true, ..ToolResult::json(&v) });
|
||||
}
|
||||
}
|
||||
use designcraft_engine::script;
|
||||
let steps = match (a.get("commands").and_then(Value::as_array), a.get("script").and_then(Value::as_str)) {
|
||||
(Some(c), _) if !c.is_empty() => script::parse(&Value::Array(c.clone()).to_string())?,
|
||||
(_, Some(t)) => script::parse(t)?,
|
||||
_ => return Err("give a non-empty `commands` array or `script` text".into()),
|
||||
};
|
||||
if steps.is_empty() {
|
||||
return Err("the script has no steps".into());
|
||||
}
|
||||
Ok(ToolResult::json(&json!({"completed": results.len(), "results": results})))
|
||||
let report = script::run(&steps, |id, p| exec(b, id, p));
|
||||
let v = report.to_json();
|
||||
Ok(if report.failed.is_some() { ToolResult { is_error: true, ..ToolResult::json(&v) } } else { ToolResult::json(&v) })
|
||||
}
|
||||
|
||||
/// The story id from `story` or the text frame `frame` (else the selection).
|
||||
|
|
|
|||
|
|
@ -0,0 +1,53 @@
|
|||
# Driving DesignCraft from agents and scripts
|
||||
|
||||
Every user-visible action in DesignCraft is a command (`crates/engine/src/cmd`), and every command is reachable
|
||||
three ways — pick whichever fits the agent:
|
||||
|
||||
| Surface | Use it when | Entry point |
|
||||
|---|---|---|
|
||||
| **CLI** | One-shot jobs, shell pipelines, CI: build or edit a document and export it | `designcraft-cli run`, `script`, `app`, `describe`, `commands` |
|
||||
| **MCP** | Claude and other MCP clients; images of pages and the window come back as content | `designcraft-cli mcp` ([mcp.md](mcp.md)) |
|
||||
| **Control channel** | Your own client driving the running app (JSON lines over TCP) | `designcraft --control 7979` ([control-protocol.md](control-protocol.md)) |
|
||||
|
||||
## Discover commands
|
||||
|
||||
```sh
|
||||
designcraft-cli commands footnote # every command whose id/label/menu mentions "footnote"
|
||||
designcraft-cli describe xref.insert # label, menu path, shortcut and parameters of one command
|
||||
```
|
||||
|
||||
Parameters are documented on each command (`params`), e.g. `{anchor? | paragraph?: text to find | story? + para?, format?}`.
|
||||
|
||||
## Scripts: several commands, later ones using earlier results
|
||||
|
||||
A script is one command per line (`command.id {json params}`; `#` comments), JSON lines, or a JSON array of
|
||||
`{command, params}`. A parameter string `"$N.path"` becomes that value of step N's result (0-based, `$last` for the
|
||||
previous step); `"${N.path}"` interpolates inside longer strings.
|
||||
|
||||
```sh
|
||||
cat > page.dcs <<'DCS'
|
||||
frame.create {"rect": [72, 72, 540, 300], "content": "text", "text": "Results of the survey"}
|
||||
text.select {"story": "$0.story", "anchor": 0, "focus": 0}
|
||||
footnote.insert {"text": "Survey run in 2026."}
|
||||
frame.create {"rect": [72, 320, 540, 500], "content": "text", "text": "See ."}
|
||||
text.select {"story": "$3.story", "anchor": 4, "focus": 4}
|
||||
xref.insert {"paragraph": "Results", "format": "Paragraph Text & Page Number"}
|
||||
DCS
|
||||
designcraft-cli script page.dcs --save page.designcraft --export page.pdf --export page.png
|
||||
designcraft-cli script page.dcs --connect 7979 # the same steps, live in the running app
|
||||
```
|
||||
|
||||
The result is JSON: `{"completed": n, "results": [...]}`, plus `failedIndex` / `failedCommand` / `error` when a step
|
||||
fails (the exit status is non-zero; `--keep-going` records errors and continues). `run --cmd ID=JSON` accepts the
|
||||
same references, and MCP's `batch` tool takes `commands` or `script` text with them.
|
||||
|
||||
## The running app
|
||||
|
||||
```sh
|
||||
designcraft --sample --control 7979 &
|
||||
designcraft-cli app type.changeCase '{"case": "title"}' # any command
|
||||
designcraft-cli app --method ui.screenshot '{"path": "/tmp/w.png"}' # any control-channel method
|
||||
```
|
||||
|
||||
`ui.menu.invoke {command}` without params acts like choosing the menu item: a command labelled "…" opens its
|
||||
dialog, which `ui.dialog.set` / `ui.dialog.confirm` fill in and close like a user would.
|
||||
|
|
@ -49,7 +49,7 @@ see `ui_inspect` → `canvasRect`).
|
|||
|---|---|
|
||||
| `list_commands` | Every command (id, label, menu path, shortcut, params, enabled), with `filter` / `enabledOnly` |
|
||||
| `execute` | Run any command: `{command, params}` |
|
||||
| `batch` | Run `commands: [{command, params}]` in order, stopping at the first error (reports `failedIndex`) |
|
||||
| `batch` | Run `commands: [{command, params}]` (or `script` text, one `command.id {json}` per line) in order, stopping at the first error (reports `failedIndex`); `"$N.path"` parameters use earlier results ([agents.md](agents.md)) |
|
||||
| `inspect_document` | Pages, spreads, items (ids, bounds, fill, story), stories (overset), layers, styles, swatches, selection |
|
||||
| `get_story` | Text, frames, paragraphs, lines, overset of a story (`story` or text `frame`) |
|
||||
| `set_story_text` | Replace a story's text (`\n` = new paragraph) |
|
||||
|
|
|
|||
|
|
@ -0,0 +1,318 @@
|
|||
# Feature parity with InDesign
|
||||
|
||||
The summary is generated by `cargo xtask parity` from the table at the bottom of this file (edit a row, re-run).
|
||||
|
||||
Each row of the InDesign feature catalogue is scored **D** (done), **P** (partial: works but lacks options or UI) or **M** (missing), weighted by priority (**P0** core = 3, **P1** = 2, **P2** = 1). Out-of-scope rows (Adobe cloud services, plug-ins, Typekit, Flash) are excluded.
|
||||
|
||||
<!-- SUMMARY -->
|
||||
**Weighted breadth parity: 63%** over 275 features.
|
||||
|
||||
| Priority | Features | Done | Partial | Missing | Parity |
|
||||
|---|---|---|---|---|---|
|
||||
| P0 | 102 | 79 | 18 | 5 | 86% |
|
||||
| P1 | 97 | 31 | 24 | 42 | 44% |
|
||||
| P2 | 76 | 8 | 7 | 61 | 15% |
|
||||
|
||||
| Area | Parity |
|
||||
|---|---|
|
||||
| Application shell & workspace | 57% |
|
||||
| Documents, pages, spreads | 66% |
|
||||
| Layers | 74% |
|
||||
| Frames, shapes & paths | 70% |
|
||||
| Transform | 56% |
|
||||
| Fill, stroke, colour | 46% |
|
||||
| Effects & transparency | 50% |
|
||||
| Placing & links | 49% |
|
||||
| Type & text frames | 77% |
|
||||
| Typography | 76% |
|
||||
| Styles | 55% |
|
||||
| Tables | 73% |
|
||||
| Long documents | 58% |
|
||||
| Interactivity & digital | 30% |
|
||||
| Output & production | 48% |
|
||||
| XML & automation | 39% |
|
||||
| View & navigation | 62% |
|
||||
| Undo, history, saving | 67% |
|
||||
| Accessibility | 20% |
|
||||
|
||||
Open P0 items: Dockable panels, Recovery / autosave, Preferences, Document Setup, Parent item overrides, Polygon settings, Pen, add/delete anchor, convert direction, Paste Into / Paste in Place, Scale strokes / content options, Dimensions Include Stroke Weight, Color panel, Color Picker dialog, Gradient panel & tool, Stroke panel, Raster formats, Vector/page formats (PDF/AI/SVG/EPS place), Text import (TXT/DOCX/RTF), Display performance, OpenType features, Export to PDF (print), Display performance, Revert, Save / Save As / Save a Copy.
|
||||
|
||||
## Rows
|
||||
|
||||
| Area | Feature | Priority | Status |
|
||||
|---|---|---|---|
|
||||
| Application shell & workspace | Start/Home screen | P0 | D |
|
||||
| Application shell & workspace | Multiple documents in tabs | P0 | D |
|
||||
| Application shell & workspace | New Window for same document | P1 | M |
|
||||
| Application shell & workspace | Dockable panels | P0 | P |
|
||||
| Application shell & workspace | Workspaces | P1 | P |
|
||||
| Application shell & workspace | Control panel | P0 | D |
|
||||
| Application shell & workspace | Properties panel | P0 | D |
|
||||
| Application shell & workspace | Contextual task bar | P2 | D |
|
||||
| Application shell & workspace | Themes | P0 | D |
|
||||
| Application shell & workspace | UI scaling | P1 | M |
|
||||
| Application shell & workspace | Keyboard shortcut editor | P1 | M |
|
||||
| Application shell & workspace | Menu customization | P2 | M |
|
||||
| Application shell & workspace | Show Full Menus | P2 | M |
|
||||
| Application shell & workspace | Recovery / autosave | P0 | M |
|
||||
| Application shell & workspace | Preferences | P0 | P |
|
||||
| Application shell & workspace | Localization | P2 | M |
|
||||
| Application shell & workspace | Scripting (own API: commands, scripts, MCP) | P2 | D |
|
||||
| Documents, pages, spreads | New Document dialog | P0 | D |
|
||||
| Documents, pages, spreads | Document Setup | P0 | P |
|
||||
| Documents, pages, spreads | Page sizes | P0 | D |
|
||||
| Documents, pages, spreads | Facing pages / spreads | P0 | D |
|
||||
| Documents, pages, spreads | Pages per spread up to 10 | P1 | M |
|
||||
| Documents, pages, spreads | Insert/move/duplicate/delete pages | P0 | D |
|
||||
| Documents, pages, spreads | Per-page size (Page tool) | P1 | M |
|
||||
| Documents, pages, spreads | Margins and Columns per page | P0 | D |
|
||||
| Documents, pages, spreads | Spread rotation (view) | P2 | M |
|
||||
| Documents, pages, spreads | Pasteboard | P0 | D |
|
||||
| Documents, pages, spreads | Sections & numbering | P0 | D |
|
||||
| Documents, pages, spreads | Page numbering display | P1 | P |
|
||||
| Documents, pages, spreads | Smart Text Reflow | P1 | M |
|
||||
| Documents, pages, spreads | Primary text frame | P1 | P |
|
||||
| Documents, pages, spreads | Parent pages | P0 | D |
|
||||
| Documents, pages, spreads | Parent item overrides | P0 | P |
|
||||
| Documents, pages, spreads | Page transitions | P2 | M |
|
||||
| Documents, pages, spreads | Spread view rotation | P2 | M |
|
||||
| Documents, pages, spreads | Layout Adjustment | P2 | M |
|
||||
| Documents, pages, spreads | Adjust Layout | P2 | M |
|
||||
| Documents, pages, spreads | Liquid layout | P2 | M |
|
||||
| Documents, pages, spreads | Alternate layouts | P2 | M |
|
||||
| Documents, pages, spreads | Document grid & baseline grid | P0 | D |
|
||||
| Documents, pages, spreads | Ruler guides | P0 | D |
|
||||
| Documents, pages, spreads | Guides on layers | P1 | M |
|
||||
| Documents, pages, spreads | Liquid guides | P2 | M |
|
||||
| Documents, pages, spreads | Smart guides | P0 | D |
|
||||
| Documents, pages, spreads | Snap to guides / grid / baseline | P0 | D |
|
||||
| Layers | Layers panel | P0 | D |
|
||||
| Layers | Layer options | P0 | D |
|
||||
| Layers | Move items between layers | P0 | D |
|
||||
| Layers | Merge layers, delete unused | P1 | M |
|
||||
| Layers | Hide/Lock others | P1 | M |
|
||||
| Layers | Page item names in Layers panel | P1 | D |
|
||||
| Layers | Layer colour drives selection colour | P0 | D |
|
||||
| Layers | Object Layer Options for placed files | P2 | M |
|
||||
| Frames, shapes & paths | Rectangle/Ellipse/Polygon frames | P0 | D |
|
||||
| Frames, shapes & paths | Rectangle/Ellipse/Polygon shapes | P0 | D |
|
||||
| Frames, shapes & paths | Polygon settings | P0 | P |
|
||||
| Frames, shapes & paths | Line tool | P0 | D |
|
||||
| Frames, shapes & paths | Pen, add/delete anchor, convert direction | P0 | P |
|
||||
| Frames, shapes & paths | Pencil, Smooth, Erase | P1 | M |
|
||||
| Frames, shapes & paths | Scissors | P1 | M |
|
||||
| Frames, shapes & paths | Content type | P0 | D |
|
||||
| Frames, shapes & paths | Convert Shape | P1 | P |
|
||||
| Frames, shapes & paths | Corner Options | P0 | D |
|
||||
| Frames, shapes & paths | Pathfinder | P1 | M |
|
||||
| Frames, shapes & paths | Compound paths | P1 | M |
|
||||
| Frames, shapes & paths | Text outlines | P1 | M |
|
||||
| Frames, shapes & paths | Clipping paths | P2 | M |
|
||||
| Frames, shapes & paths | Frame fitting | P0 | D |
|
||||
| Frames, shapes & paths | Frame Fitting Options | P1 | P |
|
||||
| Frames, shapes & paths | Content grabber, select content/container | P0 | D |
|
||||
| Frames, shapes & paths | Auto-fit | P1 | P |
|
||||
| Frames, shapes & paths | Step and Repeat | P1 | D |
|
||||
| Frames, shapes & paths | Gridify while drawing | P2 | M |
|
||||
| Frames, shapes & paths | Gap tool | P2 | M |
|
||||
| Frames, shapes & paths | Live Distribute | P2 | M |
|
||||
| Frames, shapes & paths | Generate QR Code | P2 | M |
|
||||
| Frames, shapes & paths | Placeholder text | P1 | D |
|
||||
| Frames, shapes & paths | Arrange | P0 | D |
|
||||
| Frames, shapes & paths | Group/Ungroup | P0 | D |
|
||||
| Frames, shapes & paths | Lock / Unlock All on Spread | P0 | D |
|
||||
| Frames, shapes & paths | Hide / Show All on Spread | P1 | D |
|
||||
| Frames, shapes & paths | Select submenu | P1 | P |
|
||||
| Frames, shapes & paths | Select All / Deselect All | P0 | D |
|
||||
| Frames, shapes & paths | Duplicate | P0 | D |
|
||||
| Frames, shapes & paths | Paste Into / Paste in Place | P0 | P |
|
||||
| Frames, shapes & paths | Paste Without Formatting | P0 | D |
|
||||
| Frames, shapes & paths | Non-printing attribute | P0 | D |
|
||||
| Frames, shapes & paths | Overprint fill/stroke/gap | P1 | P |
|
||||
| Transform | Transform panel / Control | P0 | D |
|
||||
| Transform | Free Transform tool | P0 | D |
|
||||
| Transform | Rotate, Scale, Shear tools + dialogs | P0 | D |
|
||||
| Transform | Transform Again family | P1 | M |
|
||||
| Transform | Clear Transformations | P1 | M |
|
||||
| Transform | Transformations are Totals pref | P1 | M |
|
||||
| Transform | Scale strokes / content options | P0 | M |
|
||||
| Transform | Align & Distribute | P0 | D |
|
||||
| Transform | Dimensions Include Stroke Weight | P0 | M |
|
||||
| Transform | Rotate 90 / Flip buttons | P0 | D |
|
||||
| Fill, stroke, colour | Swatches panel | P0 | D |
|
||||
| Fill, stroke, colour | Color panel | P0 | P |
|
||||
| Fill, stroke, colour | Color Picker dialog | P0 | M |
|
||||
| Fill, stroke, colour | Gradient panel & tool | P0 | P |
|
||||
| Fill, stroke, colour | Gradient Feather tool | P1 | M |
|
||||
| Fill, stroke, colour | Tints | P0 | D |
|
||||
| Fill, stroke, colour | Special swatches | P0 | D |
|
||||
| Fill, stroke, colour | Spot colours | P1 | D |
|
||||
| Fill, stroke, colour | Ink Manager | P2 | M |
|
||||
| Fill, stroke, colour | Load/Save Swatches (.ase) | P1 | M |
|
||||
| Fill, stroke, colour | Color groups | P2 | M |
|
||||
| Fill, stroke, colour | Unnamed colour to swatch | P1 | M |
|
||||
| Fill, stroke, colour | Stroke panel | P0 | P |
|
||||
| Fill, stroke, colour | Stroke styles | P1 | P |
|
||||
| Fill, stroke, colour | Color management | P1 | P |
|
||||
| Fill, stroke, colour | Overprint Preview | P1 | M |
|
||||
| Fill, stroke, colour | Separations Preview | P2 | M |
|
||||
| Fill, stroke, colour | Eyedropper | P1 | D |
|
||||
| Fill, stroke, colour | Appearance of Black prefs | P1 | M |
|
||||
| Effects & transparency | Opacity & blend modes | P0 | D |
|
||||
| Effects & transparency | Isolate Blending / Knockout Group | P2 | M |
|
||||
| Effects & transparency | Drop Shadow | P1 | D |
|
||||
| Effects & transparency | Inner Shadow, Outer Glow, Inner Glow, Bevel, Satin | P2 | P |
|
||||
| Effects & transparency | Basic/Directional/Gradient Feather | P2 | P |
|
||||
| Effects & transparency | Global Light | P2 | M |
|
||||
| Effects & transparency | Transparency blend space | P1 | M |
|
||||
| Effects & transparency | Transparency flattener | P2 | M |
|
||||
| Placing & links | Place | P0 | D |
|
||||
| Placing & links | Place into frame / by click or drag | P0 | D |
|
||||
| Placing & links | Raster formats | P0 | P |
|
||||
| Placing & links | Vector/page formats (PDF/AI/SVG/EPS place) | P0 | M |
|
||||
| Placing & links | Placing INDD/IDML pages | P2 | M |
|
||||
| Placing & links | Text import (TXT/DOCX/RTF) | P0 | P |
|
||||
| Placing & links | Links panel | P0 | D |
|
||||
| Placing & links | Effective/actual PPI | P0 | D |
|
||||
| Placing & links | Relink to folder / extension | P1 | M |
|
||||
| Placing & links | Copy Links To / Package | P1 | M |
|
||||
| Placing & links | Captions | P2 | M |
|
||||
| Placing & links | Image import options | P1 | M |
|
||||
| Placing & links | Linked text/stories | P2 | M |
|
||||
| Placing & links | Display performance | P0 | P |
|
||||
| Placing & links | Video/audio | P2 | M |
|
||||
| Type & text frames | Text frames | P0 | D |
|
||||
| Type & text frames | Threading | P0 | D |
|
||||
| Type & text frames | Overset detection & indicator | P0 | D |
|
||||
| Type & text frames | Text Frame Options | P0 | D |
|
||||
| Type & text frames | Type on a path | P1 | M |
|
||||
| Type & text frames | Vertical type | P2 | M |
|
||||
| Type & text frames | Story Editor | P1 | D |
|
||||
| Type & text frames | Track Changes | P2 | M |
|
||||
| Type & text frames | Notes | P2 | M |
|
||||
| Type & text frames | Find/Change | P0 | D |
|
||||
| Type & text frames | Find Font | P0 | D |
|
||||
| Type & text frames | Spell checking | P1 | D |
|
||||
| Type & text frames | Hyphenation dictionaries | P0 | D |
|
||||
| Type & text frames | Glyphs panel | P1 | M |
|
||||
| Type & text frames | Insert special chars / white space / breaks | P0 | D |
|
||||
| Type & text frames | Text variables | P1 | D |
|
||||
| Type & text frames | Conditional text | P2 | M |
|
||||
| Type & text frames | Fill with placeholder text | P1 | D |
|
||||
| Type & text frames | Create Outlines | P1 | M |
|
||||
| Type & text frames | Change Case | P0 | D |
|
||||
| Type & text frames | Show Hidden Characters | P0 | D |
|
||||
| Type & text frames | Font menu with preview, favourites | P1 | P |
|
||||
| Type & text frames | Missing fonts handling | P0 | D |
|
||||
| Type & text frames | Footnotes | P1 | D |
|
||||
| Type & text frames | Endnotes | P2 | M |
|
||||
| Type & text frames | Cross-references | P2 | D |
|
||||
| Type & text frames | Text anchors & hyperlinks | P1 | D |
|
||||
| Type & text frames | Data merge | P2 | D |
|
||||
| Type & text frames | Smart quotes | P0 | D |
|
||||
| Type & text frames | Autocorrect / Dynamic spelling | P2 | P |
|
||||
| Type & text frames | Drag & drop text editing | P1 | M |
|
||||
| Type & text frames | Paste text with/without formatting | P0 | D |
|
||||
| Type & text frames | Math expressions | P2 | M |
|
||||
| Type & text frames | Right-to-left / World-Ready composer | P2 | M |
|
||||
| Typography | Character attributes | P0 | D |
|
||||
| Typography | Underline/Strikethrough options | P1 | M |
|
||||
| Typography | OpenType features | P0 | P |
|
||||
| Typography | Variable fonts | P1 | M |
|
||||
| Typography | Paragraph attributes | P0 | D |
|
||||
| Typography | Paragraph Composer (total-fit) | P0 | D |
|
||||
| Typography | Single-line Composer | P0 | D |
|
||||
| Typography | Tabs, leaders, right-indent tab | P0 | D |
|
||||
| Typography | Nested / line / GREP styles | P1 | M |
|
||||
| Typography | Paragraph rules | P1 | D |
|
||||
| Typography | Paragraph borders & shading | P1 | P |
|
||||
| Typography | Balance ragged lines | P1 | D |
|
||||
| Typography | Optical margin alignment | P1 | D |
|
||||
| Typography | Baseline grid | P0 | D |
|
||||
| Typography | Highlight H&J / keep violations | P1 | P |
|
||||
| Typography | Glyph scaling, letter/word spacing | P0 | D |
|
||||
| Typography | Auto leading | P0 | D |
|
||||
| Typography | Text wrap | P0 | D |
|
||||
| Typography | Anchored objects | P1 | P |
|
||||
| Typography | Hyphenation exceptions | P1 | P |
|
||||
| Typography | Language support | P1 | P |
|
||||
| Styles | Paragraph styles | P0 | D |
|
||||
| Styles | Character styles | P0 | D |
|
||||
| Styles | Object styles | P1 | D |
|
||||
| Styles | Table & cell styles | P1 | M |
|
||||
| Styles | Style groups | P1 | M |
|
||||
| Styles | Quick Apply | P1 | P |
|
||||
| Styles | Break link to style | P1 | M |
|
||||
| Styles | Style mapping on import | P1 | P |
|
||||
| Styles | Style export tagging | P2 | P |
|
||||
| Tables | Create / Insert Table | P1 | D |
|
||||
| Tables | Convert Text to Table / Table to Text | P1 | D |
|
||||
| Tables | Rows/columns insert/delete/resize/distribute | P1 | D |
|
||||
| Tables | Merge/Unmerge/Split cells | P1 | P |
|
||||
| Tables | Header/footer rows | P1 | D |
|
||||
| Tables | Table options | P1 | D |
|
||||
| Tables | Cell options | P1 | D |
|
||||
| Tables | Graphic cells | P2 | M |
|
||||
| Tables | Table styles & cell styles | P1 | M |
|
||||
| Tables | Table panel | P1 | D |
|
||||
| Tables | Overset cells | P1 | D |
|
||||
| Tables | Tables spanning frames | P1 | D |
|
||||
| Tables | Sort table rows | P2 | M |
|
||||
| Tables | Excel import | P2 | M |
|
||||
| Tables | Drag to move rows/columns | P2 | M |
|
||||
| Long documents | Table of contents | P1 | D |
|
||||
| Long documents | Index | P2 | D |
|
||||
| Long documents | Books | P2 | M |
|
||||
| Long documents | Numbered lists across stories | P1 | M |
|
||||
| Long documents | Cross-references | P2 | D |
|
||||
| Long documents | Running headers via variables | P1 | D |
|
||||
| Long documents | Footnotes/endnotes | P1 | P |
|
||||
| Long documents | Captions | P2 | M |
|
||||
| Interactivity & digital | Hyperlinks panel | P1 | P |
|
||||
| Interactivity & digital | Bookmarks | P1 | P |
|
||||
| Interactivity & digital | Buttons and Forms | P2 | M |
|
||||
| Interactivity & digital | Object States | P2 | M |
|
||||
| Interactivity & digital | Articles panel | P2 | M |
|
||||
| Interactivity & digital | Object Export Options | P2 | M |
|
||||
| Interactivity & digital | EPUB export | P2 | D |
|
||||
| Interactivity & digital | HTML export | P2 | M |
|
||||
| Output & production | Export to PDF (print) | P0 | P |
|
||||
| Output & production | Interactive PDF | P2 | P |
|
||||
| Output & production | Print dialog | P1 | M |
|
||||
| Output & production | Print booklet | P2 | M |
|
||||
| Output & production | Package | P1 | M |
|
||||
| Output & production | Preflight | P1 | D |
|
||||
| Output & production | Export JPEG/PNG | P1 | D |
|
||||
| Output & production | Export IDML | P0 | D |
|
||||
| Output & production | Export EPUB/HTML | P2 | P |
|
||||
| Output & production | Export RTF/TXT/Tagged text | P2 | M |
|
||||
| Output & production | Export XML | P2 | M |
|
||||
| Output & production | Separations / Flattener / Ink limit preview | P2 | M |
|
||||
| XML & automation | Tags panel, Structure | P2 | M |
|
||||
| XML & automation | Import/Export XML | P2 | M |
|
||||
| XML & automation | Data Merge | P2 | D |
|
||||
| XML & automation | Scripts panel | P2 | P |
|
||||
| XML & automation | Script Label | P2 | M |
|
||||
| XML & automation | Content Collector/Placer | P2 | M |
|
||||
| XML & automation | Snippets | P1 | D |
|
||||
| XML & automation | Object Library | P2 | M |
|
||||
| View & navigation | Zoom commands | P0 | D |
|
||||
| View & navigation | Power Zoom | P2 | M |
|
||||
| View & navigation | Animated/scrubby zoom | P1 | M |
|
||||
| View & navigation | Screen modes | P0 | D |
|
||||
| View & navigation | Overprint preview | P1 | M |
|
||||
| View & navigation | Rulers, guides, grids toggles | P0 | D |
|
||||
| View & navigation | Show/hide frame edges, threads, hidden chars | P0 | D |
|
||||
| View & navigation | Structure / tag markers | P2 | M |
|
||||
| View & navigation | Display performance | P0 | P |
|
||||
| View & navigation | Navigate pages | P0 | D |
|
||||
| View & navigation | Split window | P2 | M |
|
||||
| View & navigation | Rotate spread view | P2 | M |
|
||||
| View & navigation | Find/zoom to object | P1 | P |
|
||||
| Undo, history, saving | Unlimited undo/redo | P0 | D |
|
||||
| Undo, history, saving | Revert | P0 | P |
|
||||
| Undo, history, saving | Save / Save As / Save a Copy | P0 | P |
|
||||
| Accessibility | Tagged PDF export | P2 | M |
|
||||
| Accessibility | Alt text | P2 | M |
|
||||
| Accessibility | Keyboard navigation of UI | P1 | P |
|
||||
| Accessibility | High contrast | P2 | M |
|
||||
|
|
@ -5,6 +5,7 @@
|
|||
|
||||
mod assets;
|
||||
mod layers;
|
||||
mod parity;
|
||||
mod stats;
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
|
@ -21,6 +22,7 @@ commands:
|
|||
corpus [--download]
|
||||
show where test corpora live; --download fetches PngSuite into corpus/pngsuite
|
||||
stats [--exact] count tests and lines per crate (--exact: ask the test harness via `-- --list`)
|
||||
parity recompute the InDesign feature-parity summary in docs/parity.md
|
||||
";
|
||||
|
||||
fn main() -> ExitCode {
|
||||
|
|
@ -33,6 +35,7 @@ fn main() -> ExitCode {
|
|||
Some("ci") => cmd_ci(),
|
||||
Some("corpus") => cmd_corpus(rest.contains(&"--download")),
|
||||
Some("stats") => stats::run(&root(), rest.contains(&"--exact")),
|
||||
Some("parity") => parity::run(&root()),
|
||||
Some("-h" | "--help" | "help") | None => {
|
||||
print!("{USAGE}");
|
||||
Ok(())
|
||||
|
|
|
|||
|
|
@ -0,0 +1,99 @@
|
|||
//! `cargo xtask parity`: recompute the feature-parity summary in docs/parity.md from its row
|
||||
//! table (each InDesign feature scored D / P / M, weighted by priority).
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
const START: &str = "<!-- SUMMARY -->";
|
||||
const ROWS: &str = "\n## Rows";
|
||||
|
||||
/// (area, feature, priority 0–2, value 0 / 0.5 / 1) for each table row.
|
||||
pub fn rows(text: &str) -> Vec<(String, String, u8, f64)> {
|
||||
let mut out = Vec::new();
|
||||
for l in text.lines() {
|
||||
let cells: Vec<&str> = l.trim().trim_matches('|').split('|').map(str::trim).collect();
|
||||
if cells.len() != 4 {
|
||||
continue;
|
||||
}
|
||||
let pri = match cells[2] {
|
||||
"P0" => 0,
|
||||
"P1" => 1,
|
||||
"P2" => 2,
|
||||
_ => continue,
|
||||
};
|
||||
let v = match cells[3] {
|
||||
"D" => 1.0,
|
||||
"P" => 0.5,
|
||||
"M" => 0.0,
|
||||
_ => continue,
|
||||
};
|
||||
out.push((cells[0].to_string(), cells[1].to_string(), pri, v));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn weight(p: u8) -> f64 {
|
||||
[3.0, 2.0, 1.0][p as usize]
|
||||
}
|
||||
|
||||
pub fn summary(rows: &[(String, String, u8, f64)]) -> String {
|
||||
let pct = |rs: &[&(String, String, u8, f64)]| {
|
||||
let tot: f64 = rs.iter().map(|r| weight(r.2)).sum();
|
||||
if tot == 0.0 { 0.0 } else { 100.0 * rs.iter().map(|r| weight(r.2) * r.3).sum::<f64>() / tot }
|
||||
};
|
||||
let all: Vec<_> = rows.iter().collect();
|
||||
let mut s = format!(
|
||||
"**Weighted breadth parity: {:.0}%** over {} features.\n\n| Priority | Features | Done | Partial | Missing | Parity |\n|---|---|---|---|---|---|\n",
|
||||
pct(&all),
|
||||
rows.len()
|
||||
);
|
||||
for p in 0..3u8 {
|
||||
let rr: Vec<_> = rows.iter().filter(|r| r.2 == p).collect();
|
||||
let n = |v: f64| rr.iter().filter(|r| r.3 == v).count();
|
||||
let unweighted = if rr.is_empty() { 0.0 } else { 100.0 * rr.iter().map(|r| r.3).sum::<f64>() / rr.len() as f64 };
|
||||
s += &format!("| P{p} | {} | {} | {} | {} | {unweighted:.0}% |\n", rr.len(), n(1.0), n(0.5), n(0.0));
|
||||
}
|
||||
s += "\n| Area | Parity |\n|---|---|\n";
|
||||
let mut areas: Vec<&str> = Vec::new();
|
||||
for r in rows {
|
||||
if !areas.contains(&r.0.as_str()) {
|
||||
areas.push(&r.0);
|
||||
}
|
||||
}
|
||||
for a in areas {
|
||||
let rr: Vec<_> = rows.iter().filter(|r| r.0 == a).collect();
|
||||
s += &format!("| {a} | {:.0}% |\n", pct(&rr));
|
||||
}
|
||||
let open: Vec<&str> = rows.iter().filter(|r| r.2 == 0 && r.3 < 1.0).map(|r| r.1.as_str()).collect();
|
||||
s += &format!("\nOpen P0 items: {}.\n", open.join(", "));
|
||||
s
|
||||
}
|
||||
|
||||
pub fn run(root: &Path) -> Result<(), String> {
|
||||
let path = root.join("docs/parity.md");
|
||||
let text = std::fs::read_to_string(&path).map_err(|e| format!("{}: {e}", path.display()))?;
|
||||
let (Some(a), Some(b)) = (text.find(START), text.find(ROWS)) else {
|
||||
return Err("docs/parity.md needs a `<!-- SUMMARY -->` marker before `## Rows`".into());
|
||||
};
|
||||
let rows = rows(&text[b..]);
|
||||
let s = summary(&rows);
|
||||
let new = format!("{}{START}\n{s}{}", &text[..a], &text[b..]);
|
||||
std::fs::write(&path, new).map_err(|e| e.to_string())?;
|
||||
print!("{s}");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn scores_rows() {
|
||||
let t = "| Area | Feature | Priority | Status |\n|---|---|---|---|\n| A | x | P0 | D |\n| A | y | P2 | M |\n| B | z | P1 | P |\n";
|
||||
let r = rows(t);
|
||||
assert_eq!(r.len(), 3);
|
||||
let s = summary(&r);
|
||||
// (3·1 + 1·0 + 2·0.5) / 6 = 67%
|
||||
assert!(s.contains("**Weighted breadth parity: 67%**"), "{s}");
|
||||
assert!(s.contains("Open P0 items: ."));
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue