diff --git a/README.md b/README.md index 41af2b45..c6c08faf 100644 --- a/README.md +++ b/README.md @@ -87,7 +87,8 @@ by its own renderer. Try it yourself with `File → New → Sample Document`, or Line breaks are identical on screen and in PDF. - **Fast.** Multithreaded SIMD rendering (vello_cpu), copy-on-write documents with O(1) undo snapshots, and cached composition. -- **Open.** A documented native format, IDML import and export, PNG export, and PDF on the roadmap. +- **Open.** A documented native format, IDML import and export, and PNG, PDF (PDF/X-4, PDF/A-2b) + and EPUB export. No subscription, no licence server, no telemetry. - **Agent-native.** Every menu item, tool gesture, panel control and dialog can be driven over a JSON control channel and an **MCP server**, so Claude and other agents can lay out and edit diff --git a/apps/designcraft-cli/src/main.rs b/apps/designcraft-cli/src/main.rs index 9e9eba25..a564befc 100644 --- a/apps/designcraft-cli/src/main.rs +++ b/apps/designcraft-cli/src/main.rs @@ -1,7 +1,8 @@ //! Headless DesignCraft. //! //! ```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 run [--in FILE.designcraft|FILE.idml | --sample] [--cmd ID[=JSON]]... [--page N] [--scale S] [--pdf-options JSON] [--export OUT.png|.jpg|.pdf|.designcraft|.idml|.epub] [--all-pages DIR] +//! # --page, --scale and --pdf-options apply to the exports that follow them //! 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] @@ -77,7 +78,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 [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\n designcraft-cli --version" + "usage: designcraft-cli run [--in FILE | --sample] [--cmd ID[=JSON]]... [--page N] [--scale S] [--pdf-options JSON] [--export OUT] [--all-pages DIR]\n (--page, --scale and --pdf-options apply to the exports that follow them)\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\n designcraft-cli --version" ); eprintln!( "\nCommunity: {} · {} · {}", @@ -136,7 +137,36 @@ fn mcp(args: &[String]) -> Result<(), String> { Server::new(backend).serve(stdin.lock(), stdout.lock()).map_err(|e| e.to_string()) } +/// The first `--page`, `--scale` or `--pdf-options` with no `--export` or `--all-pages` after it. +/// They apply to the exports that follow them, so one after the last export would do nothing. +fn option_after_last_export(args: &[String]) -> Option<&str> { + let mut unused = None; + let mut it = args.iter(); + while let Some(a) = it.next() { + match a.as_str() { + "--page" | "--scale" | "--pdf-options" => { + unused = unused.or(Some(a.as_str())); + it.next(); + } + "--export" | "--all-pages" => { + unused = None; + it.next(); + } + "--in" | "--cmd" => { + it.next(); + } + _ => {} + } + } + unused +} + fn run(args: &[String]) -> Result<(), String> { + if let Some(opt) = option_after_last_export(args) { + return Err(format!( + "{opt} has no --export or --all-pages after it; options apply to the exports that follow them, so put it before its --export" + )); + } let mut s = Session::new(); let mut page = 0usize; let mut scale = 1.0f64; diff --git a/apps/designcraft-cli/tests/cli.rs b/apps/designcraft-cli/tests/cli.rs index 75f6d90b..8c2b0f14 100644 --- a/apps/designcraft-cli/tests/cli.rs +++ b/apps/designcraft-cli/tests/cli.rs @@ -80,3 +80,25 @@ fn closed_stdout_ends_quietly() { let err = String::from_utf8_lossy(&out.stderr); assert!(out.status.success() && !err.contains("panicked"), "{:?}: {err}", out.status); } + +/// `--scale`, `--page` and `--pdf-options` apply to the exports after them; one with no export +/// after it was silently ignored. +#[test] +fn run_export_options_come_before_their_export() { + let dir = std::env::temp_dir().join(format!("dc-cli-order-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let (a, b) = (dir.join("a.png"), dir.join("b.png")); + let (a, b) = (a.to_str().unwrap(), b.to_str().unwrap()); + for (opt, val) in [("--scale", "0.5"), ("--page", "1"), ("--pdf-options", r#"{"pages":"1"}"#)] { + let out = cli().args(["run", "--sample", "--export", a, opt, val]).output().unwrap(); + let err = String::from_utf8_lossy(&out.stderr); + assert!(!out.status.success(), "{opt} after the last --export: {err}"); + assert!(err.contains(opt) && err.contains("before"), "{err}"); + } + assert!(!std::path::Path::new(a).exists(), "nothing is exported when the arguments are wrong"); + let out = cli().args(["run", "--sample", "--scale", "0.5", "--export", a, "--scale", "0.25", "--export", b]).output().unwrap(); + let err = String::from_utf8_lossy(&out.stderr); + assert!(out.status.success(), "{err}"); + assert!(err.contains("wrote") && std::path::Path::new(a).exists() && std::path::Path::new(b).exists(), "{err}"); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/crates/mcp/src/tools.rs b/crates/mcp/src/tools.rs index 0ad99622..802f615d 100644 --- a/crates/mcp/src/tools.rs +++ b/crates/mcp/src/tools.rs @@ -106,7 +106,8 @@ pub fn tool_definitions() -> Vec { "Run any DesignCraft command by id with JSON params (ids and params from list_commands). Examples: \ {\"command\":\"frame.create\",\"params\":{\"rect\":[36,36,576,300],\"content\":\"text\",\"text\":\"Hello\"}} → {id, story}; \ {\"command\":\"type.char\",\"params\":{\"size\":24}}; {\"command\":\"edit.undo\"}. Coordinates are points in spread space. \ - In the desktop app UI-only commands (view.*, window.*, app.*) work too.", + In the desktop app the UI-only commands (view.*, window.*, help.*, edit.dynamicSpelling, and app.* except app.links, \ + which works everywhere) work too.", obj(json!({"command": string("Command id, e.g. frame.create"), "params": params_schema()}), &["command"]), false, ), @@ -137,16 +138,17 @@ pub fn tool_definitions() -> Vec { "inspect_document", "Inspect document", "Summary of the active document: settings, pages (index, name, bounds, margins, columns), spreads with items (id, kind, \ - bounds, fill, stroke, story), stories (id, frames, length, overset, preview), layers, paragraph/character styles, \ - swatches, selection, active tool.", + bounds, fill, stroke, story), stories (id, frames, length in UTF-8 bytes, overset, preview), layers, \ + paragraph/character styles, swatches, selection, active tool.", empty(), true, ), tool( "get_story", "Get story", - "Full text of a story plus its frames, paragraph count, line count and overset position. Identify it by `story` id or \ - by a text `frame` id (default: the selection).", + "Full text of a story plus its length, frames, paragraph count, line count and overset position (length and positions \ + are UTF-8 byte offsets, the unit text.select takes). Identify it by `story` id or by a text `frame` id (default: the \ + selection).", obj(json!({"story": int("Story id"), "frame": int("Text frame id")}), &[]), true, ), diff --git a/docs/agents.md b/docs/agents.md index 4571b279..0dd23f06 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -41,6 +41,10 @@ The result is JSON: `{"completed": n, "results": [...]}`, plus `failedIndex` / ` 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. +`run` works through its arguments in order. `--page`, `--scale` and `--pdf-options` apply to the `--export` / +`--all-pages` that follow them, so one run can write several sizes +(`run --sample --scale 0.5 --export small.png --scale 2 --export large.png`); one with no export after it is an error. + ## Data merge `data.source.select {path, sheet?}` links a CSV/TSV/`.xlsx` file to the document (`data.source.update` re-reads it, diff --git a/docs/mcp.md b/docs/mcp.md index 2181fef9..f53c5676 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -31,7 +31,7 @@ Other MCP clients use the same command line, e.g. in a JSON config: | Engine | In-process `designcraft_engine::Session`, starts with an empty Letter document (`--sample` opens the sample magazine) | The app's session, through its loopback control channel ([control-protocol.md](control-protocol.md)) | | Rendering | `designcraft-render` (CPU) | The app's renderer | | Window tools (`screenshot`, `click`, `drag`, `menu_list`, `ui_inspect`, `ui_set`, `dialog_*`) | Return an error explaining how to connect | Work | -| UI-only commands (`view.*`, `window.*`, `app.*`) via `execute` | Error | Work | +| UI-only commands via `execute` (`view.*`, `window.*`, `help.*`, `edit.dynamicSpelling`, and `app.*` except `app.links`) | Error | Work | In connected mode every tool maps to a control-channel method (`engine.execute`, `ui.render`, `ui.pointer`, …), so what the agent does shows up live in the window. The connection is re-established once if the app restarts. @@ -43,6 +43,14 @@ spread the right page starts at x = page width. `inspect_document` lists page bo `render_page` / `export_png` are 0-based. `pointer` events in connected mode may use `"space": "screen"` (egui points, see `ui_inspect` → `canvasRect`). +## Text offsets + +Positions in a story's text are **UTF-8 byte offsets**, not character counts: `anchor` / `focus` of `text.select`, +`start` / `end` of `story.replaceRange` and of `find.find` matches, the `pos` that `text.placeCaret`, `text.insert` +and `text.move` return, a story's `length` in `get_story` and `inspect_document`, and its `overset` position in +`get_story`. A character outside ASCII takes 2 to 4 bytes (é 2, — and は 3), so the story `はただ商店` has length 15 +and its second character starts at 3. An offset inside a character snaps back to the start of that character. + ## Tools | Tool | What it does |