diff --git a/apps/vectorcraft-cli/src/main.rs b/apps/vectorcraft-cli/src/main.rs index b7334586c..2d8df4592 100644 --- a/apps/vectorcraft-cli/src/main.rs +++ b/apps/vectorcraft-cli/src/main.rs @@ -2,9 +2,9 @@ //! //! ```text //! vectorcraft-cli mcp [--connect 127.0.0.1:7979 | --headless] [--automation-read-root DIR]... [--automation-write-root DIR]... -//! vectorcraft-cli run [--in FILE] [--cmd id [--params '{json}'] | --commands FILE|-]... [--export out.svg]... [--scale 2] +//! vectorcraft-cli run [--in FILE] [--cmd id [--params '{json}'] | --commands FILE|-]... [--export out.svg]... [--scale 2] [--ppi 300] //! vectorcraft-cli commands -//! vectorcraft-cli convert IN OUT [--scale 2] [--artboard 0 | --range 1-3,5] [--outline-text] [--decimals N] [--replace-lossy] [--editing-data] [--preserve-editing] [--ai-version 7|pdf] [--hidden-objects layer|omit|visible] [--transparent-images flatten|omit] +//! vectorcraft-cli convert IN OUT [--scale 2] [--ppi 300] [--artboard 0 | --range 1-3,5] [--outline-text] [--decimals N] [--replace-lossy] [--editing-data] [--preserve-editing] [--ai-version 7|pdf] [--hidden-objects layer|omit|visible] [--transparent-images flatten|omit] //! vectorcraft-cli info FILE //! vectorcraft-cli bench FILE [--size 2880x1800] [--iters 5] //! vectorcraft-cli perf [--paths 50000] [--deep] @@ -81,11 +81,12 @@ USAGE: allowed, and a kind left out grants none of its access. They imply --headless: a running app enforces its own (vectorcraft --control PORT --automation-read-root DIR …). - vectorcraft-cli run [--in FILE] [--cmd ID [--params JSON] | --commands FILE|-]... [--export FILE]... [--scale N] + vectorcraft-cli run [--in FILE] [--cmd ID [--params JSON] | --commands FILE|-]... [--export FILE]... [--scale N] [--ppi 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). --scale sizes raster exports - (pixels per point, default 1); other formats leave it out. Prints one JSON result per step. + is a template, written as file.saveAsTemplate writes it). Raster exports use the document's + raster effects resolution unless --scale (pixels per point) or --ppi (pixels per inch, wins + over --scale) is given; other formats leave them out. Prints one JSON result per step. --commands reads steps from FILE, or from standard input for -, where it stands among the --cmd and --export steps: a JSON array of {\"command\": ID, \"params\": {...}} (or \"id\" for \"command\"), or one such object per line (JSON Lines), at most 64 MiB and 100000 steps. @@ -94,10 +95,10 @@ USAGE: 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] [--preserve-editing] [--ai-version 7|pdf] [--hidden-objects layer|omit|visible] [--transparent-images flatten|omit] + vectorcraft-cli convert IN OUT [--scale N] [--ppi N] [--artboard I | --range R] [--outline-text] [--decimals N] [--replace-lossy] [--editing-data] [--preserve-editing] [--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). --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 + formats). Raster resolution: --ppi, else 72 times --scale, else the document's raster effects + resolution; other formats leave them 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). @@ -279,13 +280,15 @@ fn commands() -> Result<(), String> { } fn convert(args: &[String]) -> Result<(), String> { - let valued = ["--scale", "-s", "--artboard", "-a", "--range", "-r", "--decimals", "--ai-version", "--hidden-objects", "--transparent-images"]; + let valued = + ["--scale", "-s", "--ppi", "--artboard", "-a", "--range", "-r", "--decimals", "--ai-version", "--hidden-objects", "--transparent-images"]; if help_requested(args, &valued) { return help(); } let mut files = vec![]; let (mut scale, mut artboard, mut range, mut outline_text, mut replace_lossy, mut editing_data, mut preserve_editing) = - (1.0f64, None::, None::, false, false, false, false); + (None::, None::, None::, false, false, false, false); + let mut ppi = None::; let mut decimals = None::; // Legacy `.ai` options, passed on as the export's own (`aiVersion`, `hiddenObjects`, // `transparentImages`); the engine checks their values. @@ -293,7 +296,15 @@ fn convert(args: &[String]) -> Result<(), String> { let mut it = args.iter(); while let Some(a) = it.next() { match a.as_str() { - "--scale" | "-s" => scale = it.next().and_then(|v| v.parse().ok()).ok_or("--scale needs a number")?, + "--scale" | "-s" => scale = Some(it.next().and_then(|v| v.parse().ok()).ok_or("--scale needs a number")?), + "--ppi" => { + ppi = Some( + it.next() + .and_then(|v| v.parse().ok()) + .filter(|n: &f64| n.is_finite() && *n > 0.0) + .ok_or("--ppi needs a positive finite number")?, + ); + } "--artboard" | "-a" => artboard = Some(it.next().and_then(|v| v.parse().ok()).ok_or("--artboard needs an index")?), "--range" | "-r" => range = Some(it.next().cloned().ok_or("--range needs artboards such as 1-3,5")?), "--outline-text" => outline_text = true, @@ -326,7 +337,7 @@ fn convert(args: &[String]) -> Result<(), String> { // A conversion keeps hidden layers and objects, written hidden, where the format can (SVG, // PSD). 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); + let mut params = with_pixel_scale(params, &output, scale, ppi); if let Some(decimals) = decimals { params["decimals"] = json!(decimals); } @@ -372,12 +383,17 @@ 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. +/// The export `params` for `path`, with explicit raster resolution options only for raster formats. /// 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 { +fn with_pixel_scale(mut params: Value, path: &str, scale: Option, ppi: Option) -> Value { if vectorcraft_engine::cmd::fileio::writable_format(None, Some(path)).is_ok_and(|f| f.raster) { - params["scale"] = json!(scale); + if let Some(scale) = scale { + params["scale"] = json!(scale); + } + if let Some(ppi) = ppi { + params["ppi"] = json!(ppi); + } } params } @@ -485,7 +501,7 @@ fn command_step(v: Value) -> Result { } fn run(args: &[String]) -> Result<(), String> { - let valued = ["--in", "-i", "--cmd", "-c", "--params", "-p", "--commands", "--export", "-o", "--scale", "-s"]; + let valued = ["--in", "-i", "--cmd", "-c", "--params", "-p", "--commands", "--export", "-o", "--scale", "-s", "--ppi"]; if help_requested(args, &valued) { return help(); } @@ -494,7 +510,8 @@ fn run(args: &[String]) -> Result<(), String> { // The step of the `--cmd` just given, which a `--params` sets. let mut params_for: Option = None; let mut stdin_read = false; - let mut scale = 1.0; + let mut scale = None::; + let mut ppi = None::; let mut it = args.iter(); while let Some(a) = it.next() { let mut val = |what: &str| it.next().cloned().ok_or_else(|| format!("{a} needs {what}")); @@ -531,7 +548,16 @@ fn run(args: &[String]) -> Result<(), String> { } "--scale" | "-s" => { let raw = val("a number")?; - scale = raw.parse::().map_err(|_| format!("--scale {raw}: not a number"))?; + scale = Some(raw.parse::().map_err(|_| format!("--scale {raw}: not a number"))?); + } + "--ppi" => { + let raw = val("a number")?; + ppi = Some( + raw.parse::() + .ok() + .filter(|n| n.is_finite() && *n > 0.0) + .ok_or_else(|| format!("--ppi {raw}: needs a positive finite number"))?, + ); } other => return Err(format!("unknown run option `{other}`")), } @@ -576,7 +602,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", with_pixel_scale(json!({"path": path}), &path, scale)) + h.call("app.export", with_pixel_scale(json!({"path": path}), &path, scale, ppi)) }; let r = r.map_err(|e| format!("step {n}: export {path}: {e}"))?; emit(json!({"step": "export", "result": r}))?; diff --git a/apps/vectorcraft-cli/tests/it/fileio.rs b/apps/vectorcraft-cli/tests/it/fileio.rs index 1423b78b4..c60357b5f 100644 --- a/apps/vectorcraft-cli/tests/it/fileio.rs +++ b/apps/vectorcraft-cli/tests/it/fileio.rs @@ -105,6 +105,38 @@ fn images_open_at_their_pixel_size() { assert_eq!(&std::fs::read(&png).unwrap()[16..24], [0, 0, 0, 6, 0, 0, 0, 4], "IHDR 6×4"); } +#[test] +fn raster_exports_use_document_resolution_unless_overridden() { + let source = tmp("resolution.vectorcraft"); + ok(&["run", "--cmd", "file.new", "--params", r#"{"width":72,"height":36,"rasterEffectsPpi":300}"#, "--export", &source]); + for (flags, width, height) in [(vec![], 300u32, 150u32), (vec!["--scale", "1"], 72, 36), (vec!["--ppi", "150", "--scale", "2"], 150, 75)] { + for mode in ["run", "convert"] { + for format in ["png", "psd"] { + let path = tmp(&format!("resolution-{mode}-{width}.{format}")); + let mut args = if mode == "run" { vec!["run", "--in", &source, "--export", &path] } else { vec!["convert", &source, &path] }; + args.extend_from_slice(&flags); + ok(&args); + let bytes = std::fs::read(&path).unwrap(); + let dimensions = if format == "png" { &bytes[16..24] } else { &bytes[14..22] }; + let expected = + if format == "png" { [width.to_be_bytes(), height.to_be_bytes()] } else { [height.to_be_bytes(), width.to_be_bytes()] }; + assert_eq!(dimensions, expected.concat(), "{args:?}"); + } + } + } +} + +#[test] +fn raster_export_rejects_invalid_ppi() { + for ppi in ["0", "-150", "NaN", "inf", "high"] { + for args in [vec!["run", "--export", "unused.png", "--ppi", ppi], vec!["convert", "unused.svg", "unused.png", "--ppi", ppi]] { + let out = Command::new(BIN).args(&args).output().unwrap(); + assert!(!out.status.success(), "{args:?}"); + assert!(String::from_utf8_lossy(&out.stderr).contains("positive finite number"), "{args:?}: {out:?}"); + } + } +} + #[test] fn help_lists_readable_formats() { let out = ok(&["--help"]); diff --git a/crates/engine/src/cmd/fileio/commands.rs b/crates/engine/src/cmd/fileio/commands.rs index 5b734c5a2..b1a422dea 100644 --- a/crates/engine/src/cmd/fileio/commands.rs +++ b/crates/engine/src/cmd/fileio/commands.rs @@ -36,7 +36,7 @@ pub fn specs() -> Vec { "Export Document", [], None, - "{path?, format?: svg|svgz|pdf|ai|png|jpg|webp|gif|png8|txt|dxf|eps|emf|wmf|tiff|bmp|tga|psd|vectorcraft|template (default: from the path's extension, else png; png8 writes an indexed .png; ait, a .ait template, is written by file.saveAsTemplate; an exporter plug-in's format is plugin., which an extension only it lists picks too), params?: {…} (an exporter plug-in's parameters; missing ones take their defaults, see plugin.info), selectedOnly?: false (the selected objects alone, in their layers), includeArtboardFill?: true (raster, PDF, SVG and EPS draw each exported artboard's background color behind its art, a transparent background too; false leaves it out), artboard?: 0, artboards?: [i…], rect?: [x, y, width, height] (raster formats: that area of the page in points, y down, instead of an artboard; not with artboard, artboards, range or useArtboards), range?: \"1-3, 5\" | \"all\" (1-based; PDF and .ai write one page per artboard, default all; SVG writes one file per artboard, {stem}-{artboard}.svg; raster formats write one artboard), useArtboards?: true (raster: one file per chosen artboard, default all, {stem}-{artboard}.{ext}; pdf/ai: every page) | false (pdf/ai/raster: the bounds of the visible art; SVG has it as an SVG option), ppi?: 72 (raster formats: pixels per inch, stored in the file; wins over scale), scale?: 1 (pixels per point), background?: transparent|white|black|\"#rrggbb\" (jpg: white when transparent; #rrggbbaa and #rgba are refused here and in matte), antiAlias?: none|art (default)|type (text snapped to pixels), interlaced?: false (png, Adam7), embedIcc?: true (png: an sRGB chunk marks the colours as sRGB), quality?: 90 (jpg: 0–100), colorModel?: rgb|cmyk|gray (jpg), method?: baseline|optimized|progressive, scans?: 3 (3–5, progressive), embedIcc?: true, imageMap?: none|client|server (an HTML or NCSA map of the objects with a URL, written as .html / .map), colors?: 256 (gif/png8: 2–256), reduction?: perceptual|selective (default)|adaptive|web|blackWhite|gray, dither?: none|diffusion (default)|pattern|noise, ditherAmount?: 100, transparency?: true, matte?: white|\"#rrggbb\"|none, interlaced?, lossless?: true (webp: lossy WebP isn't available yet: written lossless, with a warning), encoding?: utf8|utf16 (txt: the stories in stacking order, back to front, a thread once; utf16 with a byte order mark), lineEndings?: lf|crlf, selectionOnly?: false, svg?: {styling, outlineText, images, objectIds, decimals, minify, responsive, useArtboards, preserveEditing, metadata, fewerTspans, hiddenLayers, encoding, profile, embedFonts} (see document.formats), …the SVG options given flat instead, …the PDF options of document.exportPdf, …the DXF options of document.exportDxf (useArtboards: one drawing per artboard), …the EPS options of document.exportEps (useArtboards: one file per artboard, {stem}_{artboard}.eps, after a central {stem}.eps of the whole document when there are two or more and art shows; else the visible art), …emf and wmf write one picture of the artboard (useArtboards: true one file per chosen artboard, false the bounds of the visible art; EMF keeps curves, clipping, transparent images and gradients as images clipped to their shape; WMF flattens curves into polygons behind a placeable header; what a format leaves out comes back in warnings), colorModel?: rgb|cmyk|gray (tiff: rgb keeps transparency as an alpha channel), lzw?: true, byteOrder?: little|big, embedIcc?: true, colorModel?: rgb|gray (bmp), depth?: 24 (bmp: 1 black and white, 4|8 a palette by reduction and dither, 16, 24, 32 with alpha), fileFormat?: windows|os2 (1, 4, 8 or 24 bits), rle?: false (4 and 8 bits, windows), flipRows?: false (top-down rows), depth?: 24 (tga: 16 one-bit alpha, 24, 32 with alpha), colorModel?: rgb|cmyk|gray (psd), layers?: true (each top-level layer a pixel layer with its opacity and blend mode; a background colour is a Background layer; false: one flat image, on white), maxEditability?: false (layers and sublayers become groups, each object a layer named as in the Layers panel, text by its text; clipping, masked or knockout layers stay one layer), hiddenLayers?: false (hidden layers and objects written hidden instead of left out), embedIcc?: true (at most 30000 pixels a side; formats without alpha are flattened on white)} → {path, format, bytes, warnings, files?: [path…] (several), linked?: [path…] (linked images, image maps)}; no path → {dataBase64, format, bytes, warnings, files?: [{name, dataBase64}], linked?: [{name, dataBase64}]}. Never changes the document's path", + "{path?, format?: svg|svgz|pdf|ai|png|jpg|webp|gif|png8|txt|dxf|eps|emf|wmf|tiff|bmp|tga|psd|vectorcraft|template (default: from the path's extension, else png; png8 writes an indexed .png; ait, a .ait template, is written by file.saveAsTemplate; an exporter plug-in's format is plugin., which an extension only it lists picks too), params?: {…} (an exporter plug-in's parameters; missing ones take their defaults, see plugin.info), selectedOnly?: false (the selected objects alone, in their layers), includeArtboardFill?: true (raster, PDF, SVG and EPS draw each exported artboard's background color behind its art, a transparent background too; false leaves it out), artboard?: 0, artboards?: [i…], rect?: [x, y, width, height] (raster formats: that area of the page in points, y down, instead of an artboard; not with artboard, artboards, range or useArtboards), range?: \"1-3, 5\" | \"all\" (1-based; PDF and .ai write one page per artboard, default all; SVG writes one file per artboard, {stem}-{artboard}.svg; raster formats write one artboard), useArtboards?: true (raster: one file per chosen artboard, default all, {stem}-{artboard}.{ext}; pdf/ai: every page) | false (pdf/ai/raster: the bounds of the visible art; SVG has it as an SVG option), ppi?: number (raster formats: pixels per inch, stored in the file; wins over scale; without either, the document's raster effects resolution), scale?: number (pixels per point), background?: transparent|white|black|\"#rrggbb\" (jpg: white when transparent; #rrggbbaa and #rgba are refused here and in matte), antiAlias?: none|art (default)|type (text snapped to pixels), interlaced?: false (png, Adam7), embedIcc?: true (png: an sRGB chunk marks the colours as sRGB), quality?: 90 (jpg: 0–100), colorModel?: rgb|cmyk|gray (jpg), method?: baseline|optimized|progressive, scans?: 3 (3–5, progressive), embedIcc?: true, imageMap?: none|client|server (an HTML or NCSA map of the objects with a URL, written as .html / .map), colors?: 256 (gif/png8: 2–256), reduction?: perceptual|selective (default)|adaptive|web|blackWhite|gray, dither?: none|diffusion (default)|pattern|noise, ditherAmount?: 100, transparency?: true, matte?: white|\"#rrggbb\"|none, interlaced?, lossless?: true (webp: lossy WebP isn't available yet: written lossless, with a warning), encoding?: utf8|utf16 (txt: the stories in stacking order, back to front, a thread once; utf16 with a byte order mark), lineEndings?: lf|crlf, selectionOnly?: false, svg?: {styling, outlineText, images, objectIds, decimals, minify, responsive, useArtboards, preserveEditing, metadata, fewerTspans, hiddenLayers, encoding, profile, embedFonts} (see document.formats), …the SVG options given flat instead, …the PDF options of document.exportPdf, …the DXF options of document.exportDxf (useArtboards: one drawing per artboard), …the EPS options of document.exportEps (useArtboards: one file per artboard, {stem}_{artboard}.eps, after a central {stem}.eps of the whole document when there are two or more and art shows; else the visible art), …emf and wmf write one picture of the artboard (useArtboards: true one file per chosen artboard, false the bounds of the visible art; EMF keeps curves, clipping, transparent images and gradients as images clipped to their shape; WMF flattens curves into polygons behind a placeable header; what a format leaves out comes back in warnings), colorModel?: rgb|cmyk|gray (tiff: rgb keeps transparency as an alpha channel), lzw?: true, byteOrder?: little|big, embedIcc?: true, colorModel?: rgb|gray (bmp), depth?: 24 (bmp: 1 black and white, 4|8 a palette by reduction and dither, 16, 24, 32 with alpha), fileFormat?: windows|os2 (1, 4, 8 or 24 bits), rle?: false (4 and 8 bits, windows), flipRows?: false (top-down rows), depth?: 24 (tga: 16 one-bit alpha, 24, 32 with alpha), colorModel?: rgb|cmyk|gray (psd), layers?: true (each top-level layer a pixel layer with its opacity and blend mode; a background colour is a Background layer; false: one flat image, on white), maxEditability?: false (layers and sublayers become groups, each object a layer named as in the Layers panel, text by its text; clipping, masked or knockout layers stay one layer), hiddenLayers?: false (hidden layers and objects written hidden instead of left out), embedIcc?: true (at most 30000 pixels a side; formats without alpha are flattened on white)} → {path, format, bytes, warnings, files?: [path…] (several), linked?: [path…] (linked images, image maps)}; no path → {dataBase64, format, bytes, warnings, files?: [{name, dataBase64}], linked?: [{name, dataBase64}]}. Never changes the document's path", has_doc, export::export ), @@ -45,7 +45,7 @@ pub fn specs() -> Vec { "Export Selection…", ["File"], None, - "{path?, format?: png|jpg|webp|svg|svgz|pdf|ai|gif|png8|tiff|bmp|tga|psd|txt|dxf|eps|emf|wmf|vectorcraft|template (default: from the extension, else png), scale?: 1, …the format's options} the selected objects cropped to their bounds (template layers left out) → {path, format, bytes, warnings, bounds} (no path → {dataBase64, format, bytes, warnings, bounds}); warnings say what the format approximated or left out, as for document.export", + "{path?, format?: png|jpg|webp|svg|svgz|pdf|ai|gif|png8|tiff|bmp|tga|psd|txt|dxf|eps|emf|wmf|vectorcraft|template (default: from the extension, else png), ppi?: number (raster formats: pixels per inch, stored in the file; wins over scale; without either, the document's raster effects resolution), scale?: number (pixels per point), …the format's options} the selected objects cropped to their bounds (template layers left out) → {path, format, bytes, warnings, bounds} (no path → {dataBase64, format, bytes, warnings, bounds}); warnings say what the format approximated or left out, as for document.export", has_selection, export::export_selection ), diff --git a/crates/engine/src/cmd/fileio/encode.rs b/crates/engine/src/cmd/fileio/encode.rs index b125e48a8..1108d3e2c 100644 --- a/crates/engine/src/cmd/fileio/encode.rs +++ b/crates/engine/src/cmd/fileio/encode.rs @@ -202,13 +202,14 @@ impl RasterOptions { } } - /// The render and encoder settings: `ppi`, else 72 × `scale`, within 0.01–64 pixels per point; + /// The render and encoder settings: `ppi`, else 72 × `scale`, else the document's resolution, + /// within 0.01–64 pixels per point; /// `background`, else `page` (the document's background). - fn settings(&self, page: Option<[u8; 3]>) -> Result { + fn settings(&self, page: Option<[u8; 3]>, document_ppi: f64) -> Result { if let Some(ppi) = self.ppi.filter(|p| !(p.is_finite() && *p > 0.0)) { return Err(bad(C, format!("ppi must be a positive number, not {ppi}"))); } - let ppi = self.ppi.unwrap_or(72.0 * self.scale.unwrap_or(1.0)); + let ppi = self.ppi.unwrap_or_else(|| self.scale.map_or(document_ppi, |scale| 72.0 * scale)); let color_model = parse(self.color_model.as_deref(), jpeg::ColorModel::from_id, "colorModel", "rgb, cmyk or gray")?; let embed_icc = self.embed_icc.unwrap_or(true); Ok(RasterExportOptions { @@ -544,7 +545,7 @@ fn encode_files(doc: &Document, f: &Format, p: &Value) -> Result { // New Document → Background Contents: White makes the export opaque, unless `background` // says otherwise (JPEG has no alpha: white either way). let page = (doc.setup.background == vectorcraft_doc::Background::White).then_some([255; 3]); - let mut settings = o.settings(page)?; + let mut settings = o.settings(page, doc.raster_ppi())?; let size = o.pixel_size()?; let format = match f.id { "png" => RasterFormat::Png, diff --git a/crates/engine/src/cmd/fileio/formats.rs b/crates/engine/src/cmd/fileio/formats.rs index 09e8de829..33ecfa8ab 100644 --- a/crates/engine/src/cmd/fileio/formats.rs +++ b/crates/engine/src/cmd/fileio/formats.rs @@ -76,7 +76,12 @@ pub(super) const RANGE: FormatOption = FormatOption { description: "1-based artboards such as \"1-3, 5\", or \"all\" (wins over artboards and artboard)", }; -const SCALE: FormatOption = FormatOption { name: "scale", ty: "number", default: "1", description: "pixels per point (0.01–64)" }; +const SCALE: FormatOption = FormatOption { + name: "scale", + ty: "number", + default: "null", + description: "pixels per point (0.01–64); without ppi or scale, uses the document's raster effects resolution", +}; const WIDTH: FormatOption = FormatOption { name: "width", @@ -112,8 +117,8 @@ const RECT: FormatOption = FormatOption { const PPI: FormatOption = FormatOption { name: "ppi", ty: "number", - default: "72", - description: "resolution in pixels per inch (72 = one pixel per point; 150, 300…), stored in the file except in GIF, WebP and Targa; wins over scale", + default: "null", + description: "resolution in pixels per inch (72 = one pixel per point; 150, 300…), stored in the file except in GIF, WebP and Targa; wins over scale; without either, uses the document's raster effects resolution", }; const BACKGROUND: FormatOption = FormatOption { diff --git a/crates/engine/src/cmd/fileio/tests.rs b/crates/engine/src/cmd/fileio/tests.rs index 69158c04b..fca92d73f 100644 --- a/crates/engine/src/cmd/fileio/tests.rs +++ b/crates/engine/src/cmd/fileio/tests.rs @@ -459,7 +459,7 @@ fn formats_query_lists_readers_writers_and_options() { ); assert!(ids("readable").contains(&"tiff".to_string()) && ids("readable").contains(&"ait".to_string())); let png = r["formats"].as_array().unwrap().iter().find(|f| f["id"] == "png").unwrap(); - assert_eq!(png["options"]["scale"]["default"], 1); + assert_eq!(png["options"]["scale"]["default"], Value::Null); assert_eq!(r["openExtensions"].as_array().unwrap().len(), OPEN_EXTS.len()); } diff --git a/crates/engine/src/cmd/fileio/tests_raster.rs b/crates/engine/src/cmd/fileio/tests_raster.rs index 0c0184446..80001476a 100644 --- a/crates/engine/src/cmd/fileio/tests_raster.rs +++ b/crates/engine/src/cmd/fileio/tests_raster.rs @@ -32,6 +32,44 @@ fn png_chunk<'a>(file: &'a [u8], ty: &[u8; 4]) -> Option<&'a [u8]> { None } +#[test] +fn raster_export_defaults_to_document_resolution() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 72, "height": 36, "rasterEffectsPpi": 300})).unwrap(); + assert_eq!(s.doc().unwrap().doc.raster_effects_ppi, 300.0); + for format in ["png", "psd"] { + for (options, size, ppi) in [ + (json!({}), (300, 150), 300.0), + (json!({"scale": 1}), (72, 36), 72.0), + (json!({"scale": 2}), (144, 72), 144.0), + (json!({"ppi": 150, "scale": 2}), (150, 75), 150.0), + ] { + let mut params = options; + params["format"] = json!(format); + let bytes = b64(&s.execute("document.export", ¶ms).unwrap()); + assert_eq!(super::ppi::resolution(&bytes), Some((ppi, ppi)), "{params}"); + let image = super::load::raster_image(&bytes).unwrap(); + assert_eq!((image.width, image.height), size, "{params}"); + } + } +} + +#[test] +fn raster_export_default_uses_the_sanitised_document_resolution() { + // A stored resolution that isn't finite or is outside 1–2400 ppi exports at the resolution the + // document reports (72 when not finite, else clamped), not at a zero, huge or failed size. + for (stored, (width, height), size) in [(f64::NAN, (72, 36), (72, 36)), (0.0, (144, 72), (2, 1)), (1e9, (36, 18), (1200, 600))] { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": width, "height": height})).unwrap(); + std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc).raster_effects_ppi = stored; + for format in ["png", "psd"] { + let bytes = b64(&s.execute("document.export", &json!({"format": format})).unwrap()); + let image = super::load::raster_image(&bytes).unwrap(); + assert_eq!((image.width, image.height), size, "{format} with a stored {stored} ppi"); + } + } +} + #[test] fn png_options_resolution_background_interlace_and_anti_aliasing() { let mut s = session(40.0, 30.0, 1); @@ -183,7 +221,7 @@ fn formats_list_the_raster_options() { assert!(opts("png").get(k).is_some(), "png lists {k}"); } assert_eq!(opts("png")["antiAlias"]["default"], "art"); - assert_eq!(opts("png")["ppi"]["default"], 72); + assert_eq!(opts("png")["ppi"]["default"], Value::Null); assert!(opts("jpg").get("quality").is_some() && opts("jpg").get("interlaced").is_none()); assert!(opts("webp").get("antiAlias").is_some() && opts("svg").get("antiAlias").is_none()); } diff --git a/crates/mcp/src/tests/exportas.rs b/crates/mcp/src/tests/exportas.rs index 679a3f554..0d9dc5c49 100644 --- a/crates/mcp/src/tests/exportas.rs +++ b/crates/mcp/src/tests/exportas.rs @@ -16,6 +16,24 @@ fn three_boards() -> Headless { h } +#[test] +fn raster_export_uses_document_resolution_unless_overridden() { + let mut h = Headless::new(); + h.call("engine.execute", json!({"command": "file.new", "params": {"width": 72, "height": 36, "rasterEffectsPpi": 300}})).unwrap(); + for (args, size) in [ + (json!({}), (300, 150)), + (json!({"scale": 1}), (72, 36)), + (json!({"options": {"scale": 2}}), (144, 72)), + (json!({"scale": 2, "options": {"ppi": 150}}), (150, 75)), + ] { + let mut params = args.clone(); + params["format"] = json!("png"); + let r = text(&call_tool(&mut h, "export", ¶ms)); + let bytes = vectorcraft_format::base64_decode(r["dataBase64"].as_str().unwrap()).unwrap(); + assert_eq!(image::load_from_memory(&bytes).unwrap().to_rgba8().dimensions(), size, "{args}"); + } +} + #[test] fn export_with_a_range_writes_one_file_per_artboard() { let mut h = three_boards(); diff --git a/crates/mcp/src/tools/definitions.rs b/crates/mcp/src/tools/definitions.rs index c889c2237..105dca346 100644 --- a/crates/mcp/src/tools/definitions.rs +++ b/crates/mcp/src/tools/definitions.rs @@ -391,7 +391,7 @@ pub fn tool_definitions() -> Vec { json!({ "format": {"type": "string", "enum": FORMATS.iter().filter(|f| f.write).map(|f| f.id).collect::>(), "description": "Default: from the path's extension"}, "path": string("Destination file (omit to get dataBase64)"), - "scale": num("Raster pixels per point (default 1)"), + "scale": num("Raster pixels per point (without scale or options.ppi, uses the document's raster effects resolution; options.ppi wins)"), "artboard": {"type": "integer", "minimum": 0, "description": "0-based artboard (default: the first; PDF and .ai default: all)"}, "range": string("1-based artboards such as \"1-3, 5\" (PDF and .ai: one page each; SVG and SVGZ: one file each; EPS: one file each, after a central file of the whole document when there are two or more and art shows; rasters, DXF, EMF and WMF: one, or one file each with options.useArtboards: true)"), "selection": {"type": "boolean", "description": "Export only the selection, cropped to it"}, diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index b2ee69583..b9db1bece 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -188,6 +188,7 @@ the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPri | Date | Change | Summary | |---|---|---| | 2026-11-05 | minor | PDF import: the stops of an axial or radial shading in a DeviceN space of one spot ink and process plates link to the ink's swatch and keep the plates (`GradientStop.plates` in the native format); PDF and EPS export write them in a DeviceN space again (#3235) | +| 2026-11-04 | minor | Raster/PSD export uses document resolution unless ppi or scale is supplied; CLI run/convert accept --ppi. Synthetic engine, CLI and MCP regressions cover dimensions and override precedence; scores and hours unchanged. | | 2026-11-01 | minor | Native format: a symbol's Symbol Type (`dynamic`) and a dynamic symbol instance's appearance changes to objects of its art (`symbol_overrides`) round-trip; both are left out when unset, so other documents save unchanged (#4938). `.ai` reading and writing of dynamic symbols remains. | | 2026-11-01 | minor | Exports draw each artboard's background color behind its art (#4937): PNG, JPEG, GIF, TIFF, WebP, BMP, Targa and PSD (a layer of its own; on a transparent background too), PDF, SVG and EPS pages, a `.ai` file's PDF pages and PostScript and PDF print; `includeArtboardFill: false` leaves it out. Files with editing data (Preserve Editing, `.ai`, EPS, native) keep it as the artboard's color and reopen without extra art. Not read from the reference app's own `.ai` data, nor written to it. DXF, EMF and WMF leave it out | | 2026-10-31 | minor | PDF import finds an installed font whose PDF name holds hyphens (`Noto-Sans-Bold` is Noto Sans, Bold: the longest part before a hyphen that names an installed family) and a composite font by its CIDFont's name rather than its base name, which ends in the CMap's (`Name-Identity-H`, [ISO 32000-1 §9.7.6.1](https://opensource.adobe.com/dc-acrobat-sdk-docs/pdfstandards/PDF32000_2008.pdf)); a missing font is split only before a style part (`Helvetica-BoldOblique`), so it is reported as `MS PGothic`, not `MS`; scores unchanged (estimated) | diff --git a/docs/mcp.md b/docs/mcp.md index df2529a18..18dccfcd2 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -709,6 +709,11 @@ SVG writes one file per artboard of a `range`); `useArtboards: false` exports th its effects paint, the effects of groups, type and images included. `artboard.fitToArt` measures the art the same way. +Raster exports default to the document's raster effects resolution. An explicit `scale` sets pixels per +point (72 times that value in ppi); `options.ppi` wins over either scale option. CLI `run --export` and +`convert` follow the same rule and accept `--ppi N` (which wins over `--scale N`). Documents without a +saved higher resolution still use 72 ppi; use `--ppi 300` for print output from those documents. + Raster exports take more options in `options`: `ppi` (72, 150, 300…; stored in the file, except in GIF, WebP and Targa), `background` (`transparent`, `white`, `black` or `"#rrggbb"`; `#rrggbbaa` and `#rgba` are refused), `antiAlias` (`none`, `art`, `type`: text snapped to pixels) and, for PNG, `interlaced`. `width` or `height` sets the size of each file in pixels: each artboard diff --git a/docs/roadmap.md b/docs/roadmap.md index 5c3103ef5..8f9d762d5 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -32,6 +32,7 @@ M7 type: Touch Type moves a right-to-left character along its line and up from i | 2026-11-05 | minor | Layers panel: a layer whose Layer Options › Print is off shows its name italic, as the reference application shows non-printing layers, and keeps it while the row is dragged (#5739); scores and estimates unchanged. | | 2026-11-04 | minor | M7: Find Font's Replace With Font lists family and style rows with a search, and the list and the document's font list scroll on their own (#4972); scores and estimates unchanged. | | 2026-11-04 | minor | Selection Tools add-on: Select by Artboards selects the objects overlapping, fully inside or fully outside several chosen artboards, from a dialog that lists, searches and multi-selects the artboards and fits one in the window on double-click (#5525, #5548, #5549, a VectorCraft addition); scores and estimates unchanged. | +| 2026-11-04 | minor | M4: raster/PSD exports default to document resolution; CLI run/convert expose --ppi, matching the engine and MCP precedence. Scores and hours unchanged. | | 2026-11-05 | minor | Panels: a floating panel window hides under a modal dialog and drops out of the top level under an open menu, so neither the Colour Picker or Recolor Artwork nor the menu bar is covered, and it comes back at its palette level after (#5238); scores and estimates unchanged. | | 2026-11-04 | minor | New Document: the units chosen in Preset Details belong to the chosen preset; Recent and Saved cards keep the units their document or preset was made in, and the built-in tabs still follow the chosen units (#2078). Scores and estimates unchanged. | | 2026-11-03 | minor | M1: Enclosing Lasso provides whole-object enclosure selection as an add-on with no default shortcut or Basic toolbar slot; estimates unchanged. | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index cbf855fe1..bb9e7962b 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -35,6 +35,7 @@ Artboard Options forwards the active Artboard tool's Scale Artwork with Artboard | 2026-11-05 | minor | Gradient panel: Delete Stop sits at the right end of the ramp, so it shows whenever the ramp does instead of under the stop fields, where a short panel hid it (#5403); scores and estimates unchanged | | 2026-11-04 | minor | Type core: a Type-range Copy or Cut publishes styled art alongside plain text, so canvas Paste keeps font family, size and color after editing ends (#5636); scores and estimates unchanged. | | 2026-11-04 | minor | Selection Tools add-on: Select by Artboards selects the objects overlapping, fully inside or fully outside several chosen artboards, from a dialog that lists, searches and multi-selects the artboards and fits one in the window on double-click (#5525, #5548, #5549, a VectorCraft addition); scores and estimates unchanged. | +| 2026-11-04 | minor | Raster/PSD defaults honor document resolution; CLI run/convert accept --ppi, matching MCP options.ppi precedence. Synthetic regressions added; scores and hours unchanged. | | 2026-11-05 | minor | Floating panels keep out of the main window's way: hidden under a modal dialog, and out of the top level while a menu is open (#5238); scores and estimates unchanged. | | 2026-11-04 | minor | New Document: the units chosen in Preset Details belong to the chosen preset, as the public page lists the pane's options for the selected preset; the chosen card shows them, every other Recent and Saved card keeps the units its document or preset was made in, and the built-in tabs still follow the chosen units (#2078). Scores and estimates unchanged. | | 2026-11-04 | minor | Document Tools add-on (a VectorCraft addition): Batch Trace Folder under Object › Plug-ins (`addon.document.batchTraceFolder`, #4921); evidence added; scores unchanged. |