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:
Brandon Thomas 2026-10-02 08:06:42 -04:00
parent ded7c7ad04
commit 7145107896
12 changed files with 994 additions and 27 deletions

View File

@ -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 |

View File

@ -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(())
}

View File

@ -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")));
}

View File

@ -9,6 +9,7 @@
pub mod cmd;
pub mod links;
pub mod sample;
pub mod script;
mod tooling;
use std::sync::Arc;

207
crates/engine/src/script.rs Normal file
View File

@ -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);
}
}

View File

@ -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);
}

View File

@ -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).

53
docs/agents.md Normal file
View File

@ -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.

View File

@ -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) |

318
docs/parity.md Normal file
View File

@ -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 |

View File

@ -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(())

99
xtask/src/parity.rs Normal file
View File

@ -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: ."));
}
}