diff --git a/apps/vectorcraft-cli/src/main.rs b/apps/vectorcraft-cli/src/main.rs index e8e2fb358..96cc2d0fb 100644 --- a/apps/vectorcraft-cli/src/main.rs +++ b/apps/vectorcraft-cli/src/main.rs @@ -79,14 +79,16 @@ USAGE: vectorcraft-cli run [--in FILE] [--cmd ID [--params JSON]]... [--export FILE]... [--scale N] Headless batch: open FILE (any readable format) or start a new document, run commands in order, export each FILE in the format its extension picks (see Writable formats; a .ait FILE - is a template, written as file.saveAsTemplate writes it). Prints one JSON result per step. + is a template, written as file.saveAsTemplate writes it). --scale sizes raster exports + (pixels per point, default 1); other formats leave it out. Prints one JSON result per step. vectorcraft-cli commands Print the command catalogue as JSON. vectorcraft-cli convert IN OUT [--scale N] [--artboard I | --range R] [--outline-text] [--decimals N] [--replace-lossy] [--editing-data] [--ai-version 7|pdf] [--hidden-objects layer|omit|visible] [--transparent-images flatten|omit] Open IN (any readable format) and export OUT in the format its extension picks (see Writable - formats). --artboard is 0-based, --range 1-based (\"1-3,5\"); a PDF gets every artboard + formats). --scale sizes a raster OUT (pixels per point, default 1); other formats leave it + out. --artboard is 0-based, --range 1-based (\"1-3,5\"); a PDF gets every artboard unless one of them is given, EPS the bounds of the art, the other formats the first artboard. A .ait OUT is a template of every artboard, written as file.saveAsTemplate writes it (not with --artboard or --range). @@ -278,7 +280,8 @@ fn convert(args: &[String]) -> Result<(), String> { } else { // A conversion keeps hidden layers and objects, written hidden, where the format can (SVG, // PSD). - let mut params = json!({"path": output, "scale": scale, "artboard": artboard, "range": range, "outlineText": outline_text, "hiddenLayers": true, "acknowledgeLoss": replace_lossy}); + let params = json!({"path": output, "artboard": artboard, "range": range, "outlineText": outline_text, "hiddenLayers": true, "acknowledgeLoss": replace_lossy}); + let mut params = with_pixel_scale(params, &output, scale); if let Some(decimals) = decimals { params["decimals"] = json!(decimals); } @@ -302,6 +305,16 @@ fn is_template(path: &str) -> bool { vectorcraft_engine::cmd::fileio::format_for_name(path).is_some_and(|f| f.id == "ait") } +/// The export `params` for `path`, with `--scale` (pixels per point) when its format is raster. +/// DXF has a `scale` of its own (drawing units per unit), which would resize the drawing and leave +/// its units unset. +fn with_pixel_scale(mut params: Value, path: &str, scale: f64) -> Value { + if vectorcraft_engine::cmd::fileio::writable_format(None, Some(path)).is_ok_and(|f| f.raster) { + params["scale"] = json!(scale); + } + params +} + fn info(args: &[String]) -> Result<(), String> { let file = args.first().ok_or("info needs a FILE")?; let mut h = Headless::new(); @@ -391,7 +404,7 @@ fn run(args: &[String]) -> Result<(), String> { let r = if is_template(&path) { h.call("engine.execute", json!({"command": "file.saveAsTemplate", "params": {"path": path, "format": "ait"}})) } else { - h.call("app.export", json!({"path": path, "scale": scale})) + h.call("app.export", with_pixel_scale(json!({"path": path}), &path, scale)) }; let r = r.map_err(|e| format!("export {path}: {e}"))?; emit(json!({"step": "export", "result": r}))?; diff --git a/apps/vectorcraft-cli/tests/cli.rs b/apps/vectorcraft-cli/tests/cli.rs index 32c5e7f09..b376a139f 100644 --- a/apps/vectorcraft-cli/tests/cli.rs +++ b/apps/vectorcraft-cli/tests/cli.rs @@ -58,6 +58,28 @@ fn run_batch_exports() { assert_eq!(last["result"]["artboards"][0]["width"], 200.0); } +#[test] +fn scale_sizes_raster_exports_only() { + // --scale is pixels per point. DXF has a `scale` of its own (drawing units per mm) that would + // resize the drawing and leave its units unset. + let svg = tmp("scale.svg"); + std::fs::write(&svg, r#""#) + .unwrap(); + let svg = svg.to_str().unwrap(); + let (plain, scaled, png) = (tmp("plain.dxf"), tmp("scaled.dxf"), tmp("scaled.png")); + let ok = |args: &[&str]| { + let out = Command::new(BIN).args(args).output().unwrap(); + assert!(out.status.success(), "{args:?}: {}", String::from_utf8_lossy(&out.stderr)); + }; + ok(&["convert", svg, plain.to_str().unwrap()]); + ok(&["convert", svg, scaled.to_str().unwrap(), "--scale", "2"]); + assert!(std::fs::read(&scaled).unwrap() == std::fs::read(&plain).unwrap(), "convert --scale 2 changed the DXF"); + ok(&["run", "--in", svg, "--export", scaled.to_str().unwrap(), "--export", png.to_str().unwrap(), "--scale", "2"]); + assert!(std::fs::read(&scaled).unwrap() == std::fs::read(&plain).unwrap(), "run --scale 2 changed the DXF"); + // 100 pt at scale 2 → 200 px (IHDR width). + assert_eq!(std::fs::read(&png).unwrap()[16..20], 200u32.to_be_bytes()); +} + #[test] fn run_reports_errors() { let out = Command::new(BIN).args(["run", "--cmd", "nope.nothing"]).output().unwrap(); diff --git a/crates/mcp/src/tests_fileio.rs b/crates/mcp/src/tests_fileio.rs index 5b168c6d9..6c150bb28 100644 --- a/crates/mcp/src/tests_fileio.rs +++ b/crates/mcp/src/tests_fileio.rs @@ -132,6 +132,17 @@ fn export_is_the_same_call_in_the_app_and_headless() { assert_eq!(direct["dataBase64"], r["dataBase64"]); } +#[test] +fn the_raster_scale_leaves_other_formats_alone() { + // DXF's own `scale` (drawing units per mm) comes through `options`; the raster one would resize + // the drawing and leave its units unset. + let mut h = two_boards(); + let dxf = |h: &mut Headless, args: Value| text(&call_tool(h, "export", &args))["dataBase64"].clone(); + let plain = dxf(&mut h, json!({"format": "dxf"})); + assert!(dxf(&mut h, json!({"format": "dxf", "scale": 2})) == plain, "the raster scale changed the DXF"); + assert!(dxf(&mut h, json!({"format": "dxf", "options": {"scale": 2}})) != plain, "the DXF scale was dropped"); +} + #[test] fn exporting_native_keeps_the_document_path() { let mut h = two_boards(); diff --git a/crates/mcp/src/tools.rs b/crates/mcp/src/tools.rs index 523693f28..7587d67aa 100644 --- a/crates/mcp/src/tools.rs +++ b/crates/mcp/src/tools.rs @@ -2,7 +2,7 @@ //! [`Backend`]. use serde_json::{Map, Value, json}; -use vectorcraft_engine::cmd::fileio::{ARTBOARD_PARAMS, FORMATS, OPEN_EXTS, SAVE_FORMATS, format}; +use vectorcraft_engine::cmd::fileio::{ARTBOARD_PARAMS, FORMATS, OPEN_EXTS, SAVE_FORMATS, format, writable_format}; use crate::backend::Backend; @@ -1003,9 +1003,9 @@ fn dispatch(b: &mut dyn Backend, name: &str, a: &Args) -> Result Result &'static [&'static str] { + let format = writable_format(a.get("format").and_then(Value::as_str), a.get("path").and_then(Value::as_str)); + if format.is_ok_and(|f| f.raster) { + &["path", "format", "scale", "artboard", "range", "outlineText"] + } else { + &["path", "format", "artboard", "range", "outlineText"] + } +} + /// Run one tool. Failures (unknown tool, bad arguments, command errors) come back as an /// `isError` result so the model can read and correct them. pub fn call_tool(b: &mut dyn Backend, name: &str, args: &Value) -> ToolResult {