mirror of
https://github.com/storytold/designcraft.git
synced 2026-10-24 10:16:59 -06:00
CLI: export options after the last export are an error; MCP and README docs match the code (#127)
* CLI: run rejects an export option with no export after it (#67) --page, --scale and --pdf-options apply to the exports that follow them; one given after the last --export or --all-pages did nothing and said nothing. The usage text and docs/agents.md say so. * MCP: document that text offsets and story lengths are UTF-8 bytes (#68) * MCP: docs name the UI-only commands precisely; app.links works headless (#62) * README: PDF and EPUB export ship (#60)
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -106,7 +106,8 @@ pub fn tool_definitions() -> Vec<Value> {
|
||||
"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<Value> {
|
||||
"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,
|
||||
),
|
||||
|
||||
@@ -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,
|
||||
|
||||
+9
-1
@@ -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 |
|
||||
|
||||
Reference in New Issue
Block a user