From d9891d9b2ced4939dc3ac2a493512ac197bc05b2 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 21 Oct 2026 04:09:05 -0700 Subject: [PATCH 01/26] CLI: --help after a subcommand prints the usage `vectorcraft-cli mcp --help` stopped with "unknown mcp option `--help`", and `run`, `convert`, `info`, `bench` and `perf` treated `--help` and `-h` as an unknown option or a file name, while `vectorcraft-cli help` printed the usage. `--help` or `-h` after a subcommand now prints the same usage and exits successfully, before the subcommand parses a value or reads a file, so `run --commands missing.json --help` prints it too. As the value of an option it stays that option's value: `run --cmd -h` and `convert IN --range -h` still read `-h` as before. The error for `--connect` with an automation root also had a run of 21 spaces in the middle of its text; it now has one. --- apps/vectorcraft-cli/src/main.rs | 47 ++++++++++++++++++--- apps/vectorcraft-cli/src/perf.rs | 3 ++ apps/vectorcraft-cli/tests/it/cli.rs | 63 +++++++++++++++++++++++++++- 3 files changed, 107 insertions(+), 6 deletions(-) diff --git a/apps/vectorcraft-cli/src/main.rs b/apps/vectorcraft-cli/src/main.rs index 10a859ac5..9f915f6fc 100644 --- a/apps/vectorcraft-cli/src/main.rs +++ b/apps/vectorcraft-cli/src/main.rs @@ -134,6 +134,29 @@ fn usage() -> String { format!("{USAGE}\nReadable formats: .{}\nWritable formats: .{}\n", OPEN_EXTS.join(", ."), export_extensions().join(", .")) } +/// Does a subcommand's `args` ask for the usage? `--help` or `-h` counts where an option goes, not as +/// the value of one of the `valued` options before it, so `run --cmd -h` keeps `-h` for `--cmd`. The +/// subcommands check this before they parse or read anything else. +fn help_requested(args: &[String], valued: &[&str]) -> bool { + let mut it = args.iter(); + while let Some(a) = it.next() { + match a.as_str() { + "--help" | "-h" => return true, + a if valued.contains(&a) => { + it.next(); + } + _ => {} + } + } + false +} + +/// Prints the usage, for `help`, `--help` and `-h`. +fn help() -> Result<(), String> { + out!("{}", usage()); + Ok(()) +} + fn main() -> ExitCode { // Before any font lookup: the fonts font services load (#579). #[cfg(all(windows, not(target_vendor = "win7")))] @@ -143,6 +166,9 @@ fn main() -> ExitCode { prefs_dir::install_fonts(); let args: Vec = std::env::args().skip(1).collect(); let r = match args.first().map(String::as_str) { + // `commands` and `info` take no options, so `--help` or `-h` anywhere after them prints the + // usage. The other subcommands check for it themselves. + Some("commands" | "info") if help_requested(&args[1..], &[]) => help(), Some("mcp") => mcp(&args[1..]), Some("run") => run(&args[1..]), Some("commands") => commands(), @@ -150,10 +176,7 @@ fn main() -> ExitCode { Some("info") => info(&args[1..]), Some("bench") => bench(&args[1..]), Some("perf") => perf::run(&args[1..]), - Some("-h" | "--help" | "help") | None => { - out!("{}", usage()); - Ok(()) - } + Some("-h" | "--help" | "help") | None => help(), Some("-V" | "--version") => { outln!("vectorcraft-cli {}", env!("CARGO_PKG_VERSION")); Ok(()) @@ -180,6 +203,9 @@ fn with_stores(mut h: Headless) -> Headless { } fn mcp(args: &[String]) -> Result<(), String> { + if help_requested(args, &["--connect", "--automation-read-root", "--automation-write-root"]) { + return help(); + } let mut connect: Option = None; let mut headless = false; let (mut read_roots, mut write_roots): (Vec, Vec) = (vec![], vec![]); @@ -207,7 +233,7 @@ fn mcp(args: &[String]) -> Result<(), String> { let write: Vec<&Path> = write_roots.iter().map(Path::new).collect(); let roots = AutomationRoots::new(&read, &write)?; if roots.is_some() && connect.is_some() { - return Err("--automation-read-root and --automation-write-root confine the headless server; a running app enforces its own roots: start it with `vectorcraft --control --automation-read-root --automation-write-root `" + return Err("--automation-read-root and --automation-write-root confine the headless server; a running app enforces its own roots: start it with `vectorcraft --control --automation-read-root --automation-write-root `" .into()); } let backend: Box = if let Some(roots) = roots { @@ -243,6 +269,10 @@ 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"]; + 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) = (1.0f64, None::, None::, false, false, false); @@ -422,6 +452,10 @@ 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"]; + if help_requested(args, &valued) { + return help(); + } let mut input: Option = None; let mut steps: Vec = vec![]; // The step of the `--cmd` just given, which a `--params` sets. @@ -522,6 +556,9 @@ fn run(args: &[String]) -> Result<(), String> { } fn bench(args: &[String]) -> Result<(), String> { + if help_requested(args, &["--size", "--iters"]) { + return help(); + } let mut file = None; let (mut w, mut h, mut iters) = (2880u32, 1800u32, 5u32); let mut it = args.iter(); diff --git a/apps/vectorcraft-cli/src/perf.rs b/apps/vectorcraft-cli/src/perf.rs index d760cfefe..8fe3d23b7 100644 --- a/apps/vectorcraft-cli/src/perf.rs +++ b/apps/vectorcraft-cli/src/perf.rs @@ -341,6 +341,9 @@ fn deep(n: usize) -> Result<(), String> { } pub fn run(args: &[String]) -> Result<(), String> { + if crate::help_requested(args, &["--paths"]) { + return crate::help(); + } let mut n = 50_000usize; let mut want_deep = false; let mut it = args.iter(); diff --git a/apps/vectorcraft-cli/tests/it/cli.rs b/apps/vectorcraft-cli/tests/it/cli.rs index e06444b39..c45c5d867 100644 --- a/apps/vectorcraft-cli/tests/it/cli.rs +++ b/apps/vectorcraft-cli/tests/it/cli.rs @@ -201,6 +201,64 @@ fn run_refuses_malformed_commands_naming_the_step() { assert_eq!(printed, 2); } +/// `--help` and `-h` right after a subcommand print the usage that `vectorcraft-cli help` prints. +#[test] +fn help_after_a_subcommand_prints_the_usage() { + let usage = Command::new(BIN).arg("help").output().unwrap(); + assert!(usage.status.success() && String::from_utf8_lossy(&usage.stdout).contains("USAGE:")); + for sub in ["mcp", "run", "commands", "convert", "info", "bench", "perf"] { + for flag in ["--help", "-h"] { + let out = Command::new(BIN).args([sub, flag]).stdin(Stdio::null()).output().unwrap(); + assert!(out.status.success(), "{sub} {flag}: {}", String::from_utf8_lossy(&out.stderr)); + assert!(out.stdout == usage.stdout, "{sub} {flag}: {}", String::from_utf8_lossy(&out.stdout)); + } + } +} + +/// `--help` and `-h` after other arguments print the usage too, before the subcommand does anything +/// with them. +#[test] +fn help_later_in_the_line_prints_the_usage() { + let usage = Command::new(BIN).arg("help").output().unwrap(); + let mut wrong = vec![]; + for args in [ + &["mcp", "--headless", "--help"][..], + &["mcp", "--headless", "-h"], + &["mcp", "--connect", "127.0.0.1:1", "--help"], + &["run", "--cmd", "file.new", "--help"], + &["run", "--in", "missing.svg", "-h"], + &["run", "--commands", "missing.json", "--help"], + &["run", "--cmd", "file.new", "--params", "not json", "-h"], + &["commands", "x", "-h"], + &["convert", "missing.svg", "out.png", "--help"], + &["convert", "missing.svg", "--scale", "2", "-h"], + &["info", "missing.svg", "--help"], + &["bench", "missing.svg", "-h"], + &["bench", "--size", "10x10", "--help"], + &["bench", "--size", "bad", "-h"], + &["perf", "--paths", "10", "--help"], + &["perf", "--paths", "many", "-h"], + &["convert", "--decimals", "many", "--help"], + ] { + let out = Command::new(BIN).args(args).stdin(Stdio::null()).output().unwrap(); + if !out.status.success() || out.stdout != usage.stdout { + wrong.push(format!("{args:?}: {}{}", String::from_utf8_lossy(&out.stderr), String::from_utf8_lossy(&out.stdout))); + } + } + assert!(wrong.is_empty(), "{}", wrong.join("\n")); +} + +/// Further along the line, `-h` is the value of the option before it. +#[test] +fn help_as_an_option_value_goes_to_the_option() { + let out = Command::new(BIN).args(["run", "--cmd", "-h"]).output().unwrap(); + let err = String::from_utf8_lossy(&out.stderr); + assert!(!out.status.success() && err.contains("vectorcraft-cli: step 0 (-h):") && out.stdout.is_empty(), "{err}"); + let out = Command::new(BIN).args(["convert", "in.svg", "--range", "-h"]).output().unwrap(); + let err = String::from_utf8_lossy(&out.stderr); + assert!(!out.status.success() && err.contains("convert needs IN and OUT") && out.stdout.is_empty(), "{err}"); +} + #[test] fn mcp_headless_over_stdio() { let mut child = Command::new(BIN).args(["mcp", "--headless"]).stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::null()).spawn().unwrap(); @@ -319,7 +377,10 @@ fn automation_roots(base: &std::path::Path) { fails(&["mcp", "--automation-read-root", &dir("missing")], "cannot open the automation read root"); fails(&["mcp", "--automation-read-root", &dir("in"), "--automation-read-root", &dir("missing")], "cannot open the automation read root"); fails(&["mcp", "--automation-write-root"], "needs a folder"); - fails(&["mcp", "--connect", "127.0.0.1:1", &format!("--automation-write-root={}", dir("out"))], "a running app enforces its own roots"); + fails( + &["mcp", "--connect", "127.0.0.1:1", &format!("--automation-write-root={}", dir("out"))], + "a running app enforces its own roots: start it with `vectorcraft --control ", + ); let mut child = Command::new(BIN) .args(["mcp", "--automation-read-root", &dir("in"), &format!("--automation-write-root={}", dir("out"))]) From df49ed25f1c42b17ecfefcfbdb168b19ee288cc0 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 24 Oct 2026 03:46:55 -0700 Subject: [PATCH 02/26] EPS: the help text says no central file is written when no art shows With Use Artboards and two or more artboards, a document with no visible art gets only the artboard files. The useArtboards and range descriptions, the document.export and document.exportEps params text, the control protocol and the parity line now say so. --- crates/engine/src/cmd/fileio/eps.rs | 10 +++++----- crates/engine/src/cmd/fileio/mod.rs | 2 +- crates/mcp/src/tools.rs | 2 +- crates/ui-egui/src/dialogs/export_as.rs | 4 ++-- docs/control-protocol.md | 2 +- docs/target-app-parity.md | 2 +- 6 files changed, 11 insertions(+), 11 deletions(-) diff --git a/crates/engine/src/cmd/fileio/eps.rs b/crates/engine/src/cmd/fileio/eps.rs index 5a5489863..b0b1f037f 100644 --- a/crates/engine/src/cmd/fileio/eps.rs +++ b/crates/engine/src/cmd/fileio/eps.rs @@ -1,7 +1,7 @@ //! EPS export for every EPS path (`document.export {format: eps}`, `document.exportEps`, Export As //! → EPS Options): the options parsed from the params, transparency flattened with a flattener //! preset, one file of the art's bounds or one per artboard (with a central file of the whole -//! document when there are several), the preview and thumbnail rendered, +//! document when there are several and art shows), the preview and thumbnail rendered, //! and the native document embedded for reopening. use std::borrow::Cow; @@ -29,7 +29,7 @@ pub fn specs() -> Vec { "Export EPS", [], None, - "{path?, level?: 2|3 (default; PostScript language level: 3 writes smooth gradients and masks transparent image pixels out, 2 steps gradients and writes those pixels white), previewFormat?: none|tiffBw|tiffColor (default: a TIFF preview other apps show), transparentPreview?: true (else on white), overprints?: preserve (default)|discard, flattenerPreset?: \"medium\" (default; high, low or a saved preset: flattener.presets.list), flattener?: {the options of object.flattenTransparency over the preset}, embedFonts?: true (type is always written as outlines, so no fonts are needed), includeLinkedFiles?: false (the embedded document keeps linked images whole, not just their previews), thumbnails?: true (a PNG thumbnail in the file), cmykPostScript?: true (RGB documents written in CMYK), compatibleGradients?: false (stepped gradients at level 3 too), selectedOnly?: false, useArtboards?: true (one file per chosen artboard, artboards?/range?, {stem}_{artboard}.eps, the bounding box the artboard; with two or more, also the central file {stem}.eps first: the whole document, the bounding box the visible art) | false (default: one file, the bounding box the visible art, y up from the first artboard's bottom-left corner), format?: \"eps\", artboard?: i | artboards?: [i…] | range?: \"1-3, 5\" (with useArtboards: the artboards written)} → {path, bytes, warnings, files?}; no path → {dataBase64, …}. Transparency is flattened with the preset (EPS has none); the native document rides along in comments, for reopening the file as it was. Same as document.export {format: eps}", + "{path?, level?: 2|3 (default; PostScript language level: 3 writes smooth gradients and masks transparent image pixels out, 2 steps gradients and writes those pixels white), previewFormat?: none|tiffBw|tiffColor (default: a TIFF preview other apps show), transparentPreview?: true (else on white), overprints?: preserve (default)|discard, flattenerPreset?: \"medium\" (default; high, low or a saved preset: flattener.presets.list), flattener?: {the options of object.flattenTransparency over the preset}, embedFonts?: true (type is always written as outlines, so no fonts are needed), includeLinkedFiles?: false (the embedded document keeps linked images whole, not just their previews), thumbnails?: true (a PNG thumbnail in the file), cmykPostScript?: true (RGB documents written in CMYK), compatibleGradients?: false (stepped gradients at level 3 too), selectedOnly?: false, useArtboards?: true (one file per chosen artboard, artboards?/range?, {stem}_{artboard}.eps, the bounding box the artboard; with two or more, also the central file {stem}.eps first: the whole document, the bounding box the visible art; none when no art shows) | false (default: one file, the bounding box the visible art, y up from the first artboard's bottom-left corner), format?: \"eps\", artboard?: i | artboards?: [i…] | range?: \"1-3, 5\" (with useArtboards: the artboards written)} → {path, bytes, warnings, files?}; no path → {dataBase64, …}. Transparency is flattened with the preset (EPS has none); the native document rides along in comments, for reopening the file as it was. Same as document.export {format: eps}", has_doc, export_eps )] @@ -44,7 +44,7 @@ pub(super) const OPTIONS: &[FormatOption] = &[ name: "useArtboards", ty: "boolean", default: "null", - description: "true: one file per chosen artboard (default all), _.eps, and with two or more a central .eps of the whole document; false or absent: one file of the visible art (naming artboards also uses them)", + description: "true: one file per chosen artboard (default all), _.eps, and with two or more a central .eps of the whole document (none when no art shows); false or absent: one file of the visible art (naming artboards also uses them)", }, FormatOption { name: "level", ty: "integer", default: "3", description: "PostScript language level, 2 or 3" }, FormatOption { @@ -257,8 +257,8 @@ fn native(doc: &Document, board: Option, include_linked: bool) -> Vec } /// Encode `doc` as EPS: one file of the visible art, or with Use Artboards one per chosen -/// artboard (after a central file of the whole document when there are several). Transparency is -/// flattened first (once for all of them); the previews show `doc` itself. +/// artboard (after a central file of the whole document when there are several and art shows). +/// Transparency is flattened first (once for all of them); the previews show `doc` itself. pub(super) fn encode(doc: &Document, p: &Value) -> Result { let set = parse(p)?; let original = doc; diff --git a/crates/engine/src/cmd/fileio/mod.rs b/crates/engine/src/cmd/fileio/mod.rs index 6debeb84f..9dc7d2b8c 100644 --- a/crates/engine/src/cmd/fileio/mod.rs +++ b/crates/engine/src/cmd/fileio/mod.rs @@ -128,7 +128,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), 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), 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; 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), 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), 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 ), diff --git a/crates/mcp/src/tools.rs b/crates/mcp/src/tools.rs index 6fa01e6b4..c1f004be6 100644 --- a/crates/mcp/src/tools.rs +++ b/crates/mcp/src/tools.rs @@ -377,7 +377,7 @@ pub fn tool_definitions() -> Vec { "path": string("Destination file (omit to get dataBase64)"), "scale": num("Raster pixels per point (default 1)"), "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; rasters, DXF, EMF and WMF: one, or one file each with options.useArtboards: true)"), + "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"}, "outlineText": {"type": "boolean", "description": "SVG/SVGZ: true writes text as outlines (viewable without the fonts). DXF takes it too (type outlined anyway unless preserve: editability). PDF uses options.advanced.outlineText instead (default false: live text)"}, "options": {"type": "object", "description": "More format options, passed through (see run_command document.formats), e.g. {\"quality\": 80} for jpg or {\"advanced\": {\"outlineText\": true}} for PDF text as outlines (default false: live text)"}, diff --git a/crates/ui-egui/src/dialogs/export_as.rs b/crates/ui-egui/src/dialogs/export_as.rs index 855cd357f..ee918ecf4 100644 --- a/crates/ui-egui/src/dialogs/export_as.rs +++ b/crates/ui-egui/src/dialogs/export_as.rs @@ -1,6 +1,6 @@ //! File → Export → Export As…: the format and Use Artboards (All or a range: one file per -//! artboard, after a central file of the whole document for EPS with two or more, or one page -//! each in a PDF); without artboards the export covers the visible art. OK +//! artboard, after a central file of the whole document for EPS with two or more when art shows, +//! or one page each in a PDF); without artboards the export covers the visible art. OK //! picks the file, then the format's options dialog follows (PNG/JPEG/WebP/PNG-8/GIF Options, SVG //! Options, Text Export Options, DXF Options, EPS Options, and Save PDF for artboards as pages), //! else the file is written. DWG is listed, greyed out, with what to use instead. The formats of diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 0ec4cfd87..1c65a7c3b 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -657,7 +657,7 @@ EPS Options: Export As… with the EPS format (`file.exportAs {format: "eps"}`, `range`). `ui.dialog.confirm` checks them (a bad value or an unknown preset keeps the dialog open), remembers them for the next EPS Options and writes the file(s), asking for a path when there is none. With Use Artboards and two or more artboards, it writes a central file of the whole document under the chosen name and one `{stem}_{artboard}.eps` per -artboard. +artboard. When no art shows, it writes only the artboard files. Data Recovery: at launch, copies left behind by a crash open the `recovery` dialog (never copies another running instance keeps, so instances started side by side for screenshots don't get it). Fields: `copies` (`[{file, title, diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index bc1fe1fdd..cc0e648dc 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -915,7 +915,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - EPS and DXF in and out, EMF/WMF in and out; PNG (with PNG-8), JPEG, WebP, GIF, TIFF, BMP, Targa and layered PSD export; grayscale TIFFs with an alpha channel or other extra channels place, relink and open, the alpha as the image's transparency (#1466 in part; CCITT Group 3 and planar files with extra channels are not read); PSD and PSB files open and place, linked or embedded, as their merged image in every colour mode and depth (#918). EPS import runs a PostScript Level 3 interpreter written from the PostScript Language Reference: resource categories and `resourceforall`, local and global VM (`currentglobal`/`setglobal`/`gcheck`), `clipsave`/`cliprestore`, subarrays and substrings that share storage, the standard dictionaries in `systemdict`, `dictstack`, `internaldict`, executable filters and strings, `load` by any key, `//name` immediate names, `token`, `execform`, user paths, `cshow`, `rootfont`, `cachestatus`, colour rendering and halftone queries, file `status` and data read with `flushfile`, and `pdfmark` marks dropped (Discord feedback, #466, #469, #474). Masked images (`ImageType 3` in its three interleavings) and reusable streams are read, and an image's several data sources are read in turn, as the reference defines (procedures that read one file get their own rows: such images came in banded, #498). `restore` puts dictionaries back as they were at the `save`, so programs that count their saves in a dictionary read to their end (part of #505). Tiling patterns come in as pattern swatches, uncoloured ones too; Type 3 fonts' glyphs as their outlines, spaced by `ashow`/`widthshow`/`xshow`/`kshow`; mesh and function-based shadings (types 1, 4–7) as gradient meshes; sampled functions (type 0) and Flate/LZW predictors are read. It is tested with programs written from the specification in the ways common generators use these features; Inkscape/cairo EPS files open as vectors. When a file's PostScript still can't be read, it opens as its TIFF preview, its Windows metafile preview (as vectors) or its EPSI preview, and the warning (in the status bar, `document.open`/`app.open`/MCP `open_file` replies and `vectorcraft-cli info`) names the error, the operator that raised it and the procedures it ran in. Legacy PostScript `.ai` files (Illustrator 3–8, written with the prolog that defines their operators) keep their groups and nested groups (`u` … `U`), between and inside clipping groups; EPS and EMF/WMF import nest clips (and groups) at most 128 deep, so a hostile file can't exhaust the stack. DXF TEXT justified Fit or Aligned spans its two points (stretched, or scaled as a whole). PDF export writes type as real, searchable text in embedded subset fonts by default, as Illustrator's PDF presets do (Convert Text to Outlines is an option; printer's marks stay shapes), so text-heavy PDFs stay small (#733). DXF export writes each fill as its outlines followed by its hatch, so laser and cutter software, which skips hatches, gets filled art and type too (#546). - The Open and Place dialogs list PostScript `.ps` files and `.epsf` files. A `.ps` file opens and places through the EPS reader (its first page), also when its header is only `%!`, as the `ps` format, which exports don't write (Print to PostScript does); an `.epsf` file opens and places as EPS. Copied in a file manager, these files paste as EPS, and `clipboard.importEps` also reads PostScript whose header is only `%!` (`crates/engine/src/cmd/fileio/tests_epsimport.rs`). `document.pdfInfo` reads an EPS, a `.ps` file or a `.ai` in PostScript form as one page the size of its bounding box (a US Letter page without one), and its `thumbnail: 1` is a PNG of what that page prints (`crates/engine/src/cmd/fileio/tests_pdfimport.rs`). - - EPS export with Use Artboards writes a central EPS file of the whole document, bounded by its visible art, beside the file of each artboard when it writes two or more artboards (`crates/engine/src/cmd/fileio/tests_eps.rs`). + - EPS export with Use Artboards writes a central EPS file of the whole document, bounded by its visible art, beside the file of each artboard when it writes two or more artboards; a document with no visible art gets only the artboard files (`crates/engine/src/cmd/fileio/tests_eps.rs`). - Targa files (`.tga`, `.vda`, `.icb`, `.vst`) open, place linked or embedded, relink and update at their pixel size: color-mapped, true-color and black-and-white, raw or run-length encoded, in any of the four row orders. The image descriptor's attribute bits and the extension area's Attributes Type decide the transparency: a file with no attribute bits, or with attribute data the Attributes Type calls undefined, opens opaque; with one bit, a 16-bit true-color pixel takes that bit as its alpha, and a color-mapped pixel takes the bit of the 16-bit color map entry it indexes; premultiplied colors are divided by their alpha. Open and Place recognize a Targa file by its extension, and `clipboard.importImage` and Glass's Load Texture… by its header; a Targa file copied in a file manager pastes as a bitmap (`crates/engine/src/cmd/fileio/tests_tiffbmp.rs`, `crates/engine/src/tests_links.rs`). - EPS and DXF in and out, EMF/WMF in and out; PNG (with PNG-8), JPEG, WebP, GIF, TIFF, BMP, Targa and layered PSD export; grayscale TIFFs with an alpha channel or other extra channels place, relink and open, the alpha as the image's transparency (#1466 in part); PSD and PSB files open and place, linked or embedded, as their merged image in every colour mode and depth (#918). EPS import runs a PostScript Level 3 interpreter written from the PostScript Language Reference: resource categories and `resourceforall`, local and global VM (`currentglobal`/`setglobal`/`gcheck`), `clipsave`/`cliprestore`, subarrays and substrings that share storage, the standard dictionaries in `systemdict`, `dictstack`, `internaldict`, executable filters and strings, `load` by any key, `//name` immediate names, `token`, `execform`, user paths, `cshow`, `rootfont`, `cachestatus`, colour rendering and halftone queries, file `status` and data read with `flushfile`, and `pdfmark` marks dropped (Discord feedback, #466, #469, #474). Masked images (`ImageType 3` in its three interleavings) and reusable streams are read, and an image's several data sources are read in turn, as the reference defines (procedures that read one file get their own rows: such images came in banded, #498). `restore` puts dictionaries back as they were at the `save`, so programs that count their saves in a dictionary read to their end (part of #505). Tiling patterns come in as pattern swatches, uncoloured ones too; Type 3 fonts' glyphs as their outlines, spaced by `ashow`/`widthshow`/`xshow`/`kshow`; mesh and function-based shadings (types 1, 4–7) as gradient meshes; sampled functions (type 0) and Flate/LZW predictors are read. It is tested with programs written from the specification in the ways common generators use these features; Inkscape/cairo EPS files open as vectors. When a file's PostScript still can't be read, it opens as its TIFF preview, its Windows metafile preview (as vectors) or its EPSI preview, and the warning (in the status bar, `document.open`/`app.open`/MCP `open_file` replies and `vectorcraft-cli info`) names the error, the operator that raised it and the procedures it ran in. Legacy PostScript `.ai` files (Illustrator 3–8, written with the prolog that defines their operators) keep their groups and nested groups (`u` … `U`), between and inside clipping groups; EPS and EMF/WMF import nest clips (and groups) at most 128 deep, so a hostile file can't exhaust the stack. DXF TEXT justified Fit or Aligned spans its two points (stretched, or scaled as a whole). PDF export writes type as real, searchable text in embedded subset fonts by default, as Illustrator's PDF presets do (Convert Text to Outlines is an option; printer's marks stay shapes), so text-heavy PDFs stay small (#733). DXF export writes each fill as its outlines followed by its hatch, so laser and cutter software, which skips hatches, gets filled art and type too (#546). - TIFFs that store each channel as its own plane (PlanarConfiguration 2) place, relink and open as the same image stored pixel by pixel does, 8 and 16 bits: grayscale with an alpha channel or other extra channels, and CMYK with or without them. A planar CMYK TIFF keeps its ink amounts and is stored as a CMYK TIFF with each pixel's inks together, at the file's resolution. A TIFF whose header claims more strips or tiles than can be counted is refused as too large to draw (#1466 in part; `crates/engine/src/cmd/fileio/tests.rs`). From 7a9ae4324d4643992977966f41221e08924a48e8 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 24 Oct 2026 09:25:13 -0700 Subject: [PATCH 03/26] Threaded text: Rasterize on a frame no longer duplicates its text MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Object › Rasterize replaced a frame of threaded text by an image of it. The thread's re-flow then added the frame's text to the story, as it does for a frame that was deleted, so the other frames held that text a second time while the image drew it. A frame that Rasterize turns into an image now leaves its thread with the text it shows, and the other frames keep the text they hold, as with Create Outlines and Expand. When the thread's last frame is rasterized, the text it has no room for goes after the text of the last frame that stays. A thread left with one frame is no longer a thread, and undo restores the thread. Docs: docs/mcp.md, docs/target-app-parity.md. --- crates/engine/src/cmd/menucmds.rs | 5 +++- crates/engine/src/cmd/threads.rs | 44 +++++++++++++++++++++++++++++++ docs/mcp.md | 4 ++- docs/target-app-parity.md | 1 + 4 files changed, 52 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/cmd/menucmds.rs b/crates/engine/src/cmd/menucmds.rs index c9ff3f455..904e75b4e 100644 --- a/crates/engine/src/cmd/menucmds.rs +++ b/crates/engine/src/cmd/menucmds.rs @@ -94,7 +94,7 @@ pub fn specs() -> Vec { "Rasterize…", ["Object"], None, - "{ppi?, background?: transparent|white|black|\"#rrggbb\", antiAlias?: none|art|type (text snapped to pixels)|bool, padding?|addAround?: pt (0–1000), colorModel?: \"rgb\"|\"cmyk\"|\"grayscale\"|\"bitmap\", clippingMask?: bool (a clip group with the art's outline)} replace the selection with an embedded PNG image; each defaults to document.rasterEffectsSettings → {id (the image, or its clip group), width, height, warning?: \"an image couldn't be decoded and is missing from the picture\" (as raster exports say)}", + "{ppi?, background?: transparent|white|black|\"#rrggbb\", antiAlias?: none|art|type (text snapped to pixels)|bool, padding?|addAround?: pt (0–1000), colorModel?: \"rgb\"|\"cmyk\"|\"grayscale\"|\"bitmap\", clippingMask?: bool (a clip group with the art's outline)} replace the selection with an embedded PNG image; each defaults to document.rasterEffectsSettings; a frame of threaded text that is rasterized leaves its thread with the text it shows → {id (the image, or its clip group), width, height, warning?: \"an image couldn't be decoded and is missing from the picture\" (as raster exports say)}", has_selection, rasterize ), @@ -561,8 +561,11 @@ fn rasterize(s: &mut Session, p: &Value) -> Result { return Err(bad(C, "nothing selected")); } let id = s.edit("Rasterize", |d, sel| { + let threaded = super::threads::frames(d); let node = image.into_node(d); let id = d.wrap(&roots, |_| node)?; + // Rasterized frames of threaded text leave their threads with the text they show. + super::threads::leave_threads(d, &threaded); sel.set([id]); Ok(id) })?; diff --git a/crates/engine/src/cmd/threads.rs b/crates/engine/src/cmd/threads.rs index 3df488404..409081079 100644 --- a/crates/engine/src/cmd/threads.rs +++ b/crates/engine/src/cmd/threads.rs @@ -1127,6 +1127,50 @@ mod tests { assert_eq!([a, b, c].map(|id| text(&s, id)), shown); } + /// Object › Rasterize on frames of threaded text takes them out of their thread with the text + /// they show, which the image draws, and the other frames keep the text they hold. Undo threads + /// the frames again. When the last frame is rasterized, the frame before it takes the text it + /// has no room for. + #[test] + fn rasterized_frames_leave_their_thread_with_their_text() { + for clip in [false, true] { + for out in [&[0][..], &[1], &[2], &[0, 2]] { + let case = format!("clippingMask {clip} {out:?}"); + let (mut s, frames) = three_frames(&TEXT.repeat(4), 200.0); + let shown = frames.map(|id| text(&s, id)); + assert_eq!(shown.concat(), TEXT.repeat(4)); + let ids: Vec = out.iter().map(|k| frames[*k]).collect(); + s.execute("select.set", &json!({"ids": ids})).unwrap(); + s.execute("object.rasterize", &json!({"ppi": 36, "clippingMask": clip})).unwrap(); + let rest: Vec = frames.iter().copied().filter(|id| !ids.contains(id)).collect(); + for id in &ids { + assert!(!is_frame(&s, *id), "{case}: frame {id} is rasterized"); + } + for (id, before) in frames.iter().zip(&shown).filter(|(id, _)| rest.contains(id)) { + assert_eq!(text(&s, *id), *before, "{case}: frame {id} keeps its text"); + } + assert_eq!(threads(&s), if rest.len() >= 2 { vec![rest] } else { vec![] }, "{case}"); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(threads(&s), [frames], "{case}: undo"); + assert_eq!(frames.map(|id| text(&s, id)), shown, "{case}: undo"); + } + } + let (mut s, [a, b, c]) = three_frames(&TEXT.repeat(12), 100.0); + let shown = [a, b, c].map(|id| text(&s, id)); + let laid = { + let Some(t) = text_of(&s.doc().unwrap().doc, NodeId(c)) else { panic!("not area type") }; + vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t) + }; + assert!(laid.overflow, "the last frame has more text than it shows"); + let end = laid.lines.last().map_or(0, |l| l.end); + let hidden = shown[2][end..].trim_start_matches(' '); + s.execute("select.set", &json!({"ids": [c]})).unwrap(); + s.execute("object.rasterize", &json!({"ppi": 36})).unwrap(); + assert_eq!(text(&s, a), shown[0]); + assert_eq!(text(&s, b), format!("{}{hidden}", shown[1]), "the frame before it keeps its text and takes the rest"); + assert_eq!(threads(&s), [[a, b]]); + } + /// Frame `id`'s first line: whether its first paragraph continues from the frame before, where /// its text starts from the frame's left edge, and how many list markers it starts with. fn first_line(s: &Session, id: u64) -> (bool, f64, usize) { diff --git a/docs/mcp.md b/docs/mcp.md index 9ee0e1388..49167e360 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -3267,7 +3267,9 @@ images, symbol instances, live objects, and those in opacity masks, symbols and go in an image under the untouched vector art, other effects replace the object with its image, and a path whose fills or strokes carry effects is written as one piece per fill and stroke, so only the affected ones become images. When an image in the art can't be decoded, `object.rasterize` and `object.cropImage` (without `trim`) make their image -without it, and their results carry the `warning` that Flatten Transparency gives. +without it, and their results carry the `warning` that Flatten Transparency gives. `object.rasterize` takes each frame +of threaded text that it rasterizes out of its thread with the text it shows, as `type.createOutlines` does (see +Threading text from the in and out ports), and the other frames keep the text they hold. ```json {"name":"run_command","arguments":{"command":"document.rasterEffectsSettings","params":{"resolution":"high","background":"white","addAround":36}}} diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 016e7a771..2d2f7c16b 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -765,6 +765,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur An edit copies a thread's story only when it changes one of the thread's frames: with a story of 50,000 characters threaded through 8 frames with a style change at every word, moving another object took 7.8 ms and now takes 0.013 ms, and a frame of a drag took 7.2 ms and now takes 0.012 ms (release build, `threaded_story_edit_timing`). A paragraph that continues from one threaded frame into the next has its first-line indent and bullet or number in the frame where it starts; in the next frame its first line starts where its wrapped lines start. In frames that are no longer threaded, copies of a frame and point type converted from one, the first paragraph has its indent and bullet or number again. Type › Convert To Point Type on a frame of threaded text makes point type of the text that frame holds and takes it out of the thread; the story flows through the other frames without that text, so each character is in the document once. + Object › Rasterize on a frame of threaded text takes it out of the thread with the text it shows, which the image draws, and the other frames keep the text they hold. When the last frame is rasterized, the text it has no room for goes after the text of the last frame that stays. Type › Create Outlines, Object › Expand and Object › Expand Appearance on a frame of threaded text take it out of the thread with the text it shows, which the outlines draw, and the other frames keep the text they hold. When the last frame is outlined, the text it has no room for goes after the text of the last frame that stays. When Expand Appearance keeps the frame as type in a group with its shadow, outer glow or Crop Marks, the type stays in the thread. With Optical Margin Alignment on, or Roman Hanging Punctuation on in the first paragraph, the glyphs at the ends of left-to-right type on a path hang outside its brackets, measured along the path. The type hangs at the start bracket when its line is placed there, hangs at the end bracket when the line is placed there, and hangs at both when it is centered. Glyphs that hang past the end bracket do not make the type overflow, and a hang stops at an open path's end. SVG export writes such type as outlines. The frames of threaded text share one Optical Margin Alignment setting: turning it on or off in one frame sets it in every frame, threading frames gives the thread its first frame's setting, and frames taken out of a thread keep it. From 0dcb52c7cdf2c52d56940b9c8bfde112bd514f83 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Tue, 20 Oct 2026 17:55:07 -0700 Subject: [PATCH 04/26] Export for Screens: takes the saved document's file name In a file name pattern, is documented as the document's name without its extension, but it expanded to the File Info title whenever the document had one, so a saved my-logo.vectorcraft titled "Logo: final version" exported as Logo--final-version__Artboard 1.png. The Full Document file and the .zip took the same title. Export for Screens and the dialog's Example line now take from the document's file, as Save names it (save.rs), and fall back to the title only for a document that has not been saved. --- crates/engine/src/cmd/fileio/screens.rs | 11 ++++--- crates/engine/src/cmd/fileio/tests_screens.rs | 33 +++++++++++++++++++ docs/mcp.md | 5 +-- 3 files changed, 43 insertions(+), 6 deletions(-) diff --git a/crates/engine/src/cmd/fileio/screens.rs b/crates/engine/src/cmd/fileio/screens.rs index 1d9c0303c..aed45c0cd 100644 --- a/crates/engine/src/cmd/fileio/screens.rs +++ b/crates/engine/src/cmd/fileio/screens.rs @@ -382,7 +382,7 @@ struct Naming { } impl Naming { - /// The naming of params `o` for a document titled `title`. + /// The naming of params `o` for a document named `title`: its file's path, or its title before it is saved. fn of(o: &ScreenParams, title: &str) -> Result { let folders = Folders::of(o)?; // A prefix names files, not folders. @@ -669,7 +669,9 @@ pub(crate) fn export(s: &mut Session, p: &Value) -> Result { let o = ScreenParams::of(p)?; let rows = rows_of(s, p, o.preset.as_deref())?; o.one_source()?; - let naming = Naming::of(&o, &s.doc()?.doc.title)?; + // ``: the document's file, as Save names it, or its title before it is saved. + let st = s.doc()?; + let naming = Naming::of(&o, st.path.as_deref().unwrap_or(&st.doc.title))?; let selection = o.selection_mode()?; let selected = if selection.is_some() { selected_artwork(s)? } else { vec![] }; let doc = s.doc()?.doc.clone(); @@ -846,13 +848,14 @@ pub fn first_file(s: &Session, p: &Value) -> Result { let rows = rows_of(s, &p, o.preset.as_deref())?; o.one_source()?; let selection = o.selection_mode()?; - let doc = &s.doc()?.doc; + let st = s.doc()?; + let doc = &st.doc; let mut selected = if selection.is_some() { selected_artwork(s)? } else { vec![] }; // A file per object: objects that draw nothing write no file. if selection == Some(true) { selected = selected.into_iter().find(|id| export_crop(doc, &[*id]).is_some()).into_iter().collect(); } - let naming = Naming::of(&o, &doc.title)?; + let naming = Naming::of(&o, st.path.as_deref().unwrap_or(&doc.title))?; let items = items(doc, &o, selection, &selected, &naming.filename, false, false)?; let (Some(item), Some(r)) = (items.first(), rows.first()) else { return Err(bad(C, "nothing to export")) }; let (sub, file) = naming.file(item, 0, 0, r); diff --git a/crates/engine/src/cmd/fileio/tests_screens.rs b/crates/engine/src/cmd/fileio/tests_screens.rs index 867911681..5634c0da7 100644 --- a/crates/engine/src/cmd/fileio/tests_screens.rs +++ b/crates/engine/src/cmd/fileio/tests_screens.rs @@ -205,6 +205,39 @@ fn the_first_file_is_the_file_the_export_writes_first() { } } +/// A saved document's ``, Full Document file and ZIP are named after its file without the +/// extension, as Save names it, whatever its File Info title says. +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn a_saved_document_exports_under_its_file_name() { + let mut s = session(1); + s.execute("file.info", &json!({"title": "Logo: final version"})).unwrap(); + let dir = std::env::temp_dir().join(format!("vc-screens-filename-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + s.execute("document.save", &json!({"path": dir.join("my-logo.vectorcraft").to_string_lossy()})).unwrap(); + for (p, name) in [ + (json!({"name": "__", "formats": [{"format": "png"}]}), "my-logo__Artboard 1.png"), + (json!({"fullDocument": true, "formats": [{"format": "png"}]}), "my-logo.png"), + ] { + assert_eq!(names(&export(&mut s, p.clone())), [name], "{p}"); + assert_eq!(super::screens::first_file(&s, &p).unwrap(), name, "{p}"); + } + assert_eq!(export(&mut s, json!({"zip": true, "formats": [{"format": "svg"}]}))["name"], "my-logo.zip"); + let _ = std::fs::remove_dir_all(dir); +} + +/// A document that has not been saved has no file: `` is its title. +#[test] +fn an_unsaved_document_exports_under_its_title() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 40, "height": 30, "name": "Poster"})).unwrap(); + s.execute("shape.rectangle", &json!({"x": 5, "y": 5, "width": 20, "height": 20})).unwrap(); + s.execute("file.info", &json!({"title": "Spring poster"})).unwrap(); + let p = json!({"name": "", "formats": [{"format": "png"}]}); + assert_eq!(names(&export(&mut s, p.clone())), ["Spring-poster.png"]); + assert_eq!(super::screens::first_file(&s, &p).unwrap(), "Spring-poster.png"); +} + #[cfg(not(target_arch = "wasm32"))] #[test] fn sub_folders_are_made_in_the_export_folder() { diff --git a/docs/mcp.md b/docs/mcp.md index d7d6f6890..1b0dea3cc 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -3628,8 +3628,9 @@ fallback font…), each note once; the export still writes or returns every file Batch exports (#1935): - **What:** `layers: [id…]` (each layer's art alone) or `selection: "one" | "each"` instead of artboards (`assets`, `fullDocument` as before). -- **Names:** `name` is a pattern for every file: `` (the document), `` (the artboard, asset, layer - or object), ``, `####` (a counter zero-padded to the run of `#`, from `counterStart`; `counterPer: "item"` +- **Names:** `name` is a pattern for every file: `` (the document's file name without its extension, or + its title before it is saved; the Full Document file and the `.zip` use the same name), `` (the artboard, + asset, layer or object), ``, `####` (a counter zero-padded to the run of `#`, from `counterStart`; `counterPer: "item"` or `"file"`), `YYYYMMDD`, `YYYY-MM-DD`, ``, `