CLI, MCP: the raster scale no longer resizes DXF exports

`vectorcraft-cli run/convert --scale` and the MCP export tool's `scale`
are raster pixels per point, but they were passed to every export, and
DXF reads `scale` as drawing units per unit: a 2x PNG next to a DXF
doubled the drawing and left it unitless ($INSUNITS 0). They now go
only to raster formats. DXF's own scale stays reachable through the
format options.
This commit is contained in:
MortenHusted
2026-10-18 23:50:57 +07:00
committed by bflatastic
parent 88494319da
commit eba9abc21e
4 changed files with 66 additions and 8 deletions
+17 -4
View File
@@ -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}))?;
+22
View File
@@ -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#"<svg xmlns="http://www.w3.org/2000/svg" width="100" height="50"><rect x="10" y="10" width="30" height="20"/></svg>"#)
.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();
+11
View File
@@ -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();
+16 -4
View File
@@ -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<ToolResult, Str
params.remove(k);
}
}
for k in ["path", "format", "scale", "artboard", "range", "outlineText"] {
if let Some(v) = a.get(k).filter(|v| !v.is_null()) {
params.insert(k.into(), v.clone());
for k in named_export_params(a) {
if let Some(v) = a.get(*k).filter(|v| !v.is_null()) {
params.insert((*k).into(), v.clone());
}
}
let cmd = if a.get("selection").and_then(Value::as_bool) == Some(true) { "document.exportSelection" } else { "document.export" };
@@ -1054,6 +1054,18 @@ fn dispatch(b: &mut dyn Backend, name: &str, a: &Args) -> Result<ToolResult, Str
}
}
/// The named `export` arguments passed on to `document.export`. `scale` is raster pixels per
/// point, so only a raster format gets it: DXF's own (drawing units per unit, given in `options`)
/// would resize the drawing and leave its units unset.
fn named_export_params(a: &Args) -> &'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 {