diff --git a/ROADMAP.md b/ROADMAP.md index a927a0d..1388366 100644 --- a/ROADMAP.md +++ b/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 | diff --git a/apps/designcraft-cli/src/main.rs b/apps/designcraft-cli/src/main.rs index 03564f4..2c3bd5d 100644 --- a/apps/designcraft-cli/src/main.rs +++ b/apps/designcraft-cli/src/main.rs @@ -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 = 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 = 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 = 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 = None; + let mut setup: Vec<(String, Value)> = Vec::new(); + let mut connect: Option = None; + let mut save: Option = None; + let mut export: Vec = 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 = 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 = 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 = None; + let mut rest: Vec = 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 { 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(()) +} diff --git a/apps/designcraft-cli/tests/cli.rs b/apps/designcraft-cli/tests/cli.rs new file mode 100644 index 0000000..aa118de --- /dev/null +++ b/apps/designcraft-cli/tests/cli.rs @@ -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"))); +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index fd7cdd6..38c2a4a 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -9,6 +9,7 @@ pub mod cmd; pub mod links; pub mod sample; +pub mod script; mod tooling; use std::sync::Arc; diff --git a/crates/engine/src/script.rs b/crates/engine/src/script.rs new file mode 100644 index 0000000..a447cb6 --- /dev/null +++ b/crates/engine/src/script.rs @@ -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, 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::(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 { + 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 { + 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::().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::()) { + (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 { + 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::>()?), + Value::Object(o) => Value::Object(o.iter().map(|(k, v)| Ok((k.clone(), resolve(v, results)?))).collect::>()?), + v => v.clone(), + }) +} + +/// Outcome of running a script. +#[derive(Clone, Debug, PartialEq)] +pub struct Report { + pub results: Vec, + /// 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) -> 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); + } +} diff --git a/crates/mcp/src/tests.rs b/crates/mcp/src/tests.rs index 63ab741..2804d13 100644 --- a/crates/mcp/src/tests.rs +++ b/crates/mcp/src/tests.rs @@ -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); +} diff --git a/crates/mcp/src/tools.rs b/crates/mcp/src/tools.rs index 8f40750..dc2604c 100644 --- a/crates/mcp/src/tools.rs +++ b/crates/mcp/src/tools.rs @@ -114,15 +114,21 @@ pub fn tool_definitions() -> Vec { "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 { - 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). diff --git a/docs/agents.md b/docs/agents.md new file mode 100644 index 0000000..0d87c8f --- /dev/null +++ b/docs/agents.md @@ -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. diff --git a/docs/mcp.md b/docs/mcp.md index d9185d2..228fc4e 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -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) | diff --git a/docs/parity.md b/docs/parity.md new file mode 100644 index 0000000..76e60ae --- /dev/null +++ b/docs/parity.md @@ -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. + + +**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 | diff --git a/xtask/src/main.rs b/xtask/src/main.rs index 3994206..83c7829 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -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(()) diff --git a/xtask/src/parity.rs b/xtask/src/parity.rs new file mode 100644 index 0000000..d0dfcf6 --- /dev/null +++ b/xtask/src/parity.rs @@ -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 = ""; +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::() / 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::() / 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 `` 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: .")); + } +}