diff --git a/crates/engine/src/cmd/fileio/commands.rs b/crates/engine/src/cmd/fileio/commands.rs index b1a422dea..6988f943a 100644 --- a/crates/engine/src/cmd/fileio/commands.rs +++ b/crates/engine/src/cmd/fileio/commands.rs @@ -27,7 +27,7 @@ pub fn specs() -> Vec { "Serialize Document", [], None, - "{format?: vectorcraft (default)|template|svg|svgz|pdf|ai|png|jpg|webp|gif|png8|txt|dxf|eps|emf|wmf|tiff|bmp|tga|psd (ait, a .ait template, is written by file.saveAsTemplate), …the format's options (see document.formats; SVG ones also as svg: {…}), selectedOnly?: false (the selected objects alone, in their layers)} → {text, warnings} for svg (plus dataBase64, the file, when its encoding isn't UTF-8), else {dataBase64, warnings}; an SVG of several artboards also gives files: [{name, text}], linked images linked: [{name, dataBase64}]", + "{format?: vectorcraft (default)|template|svg|svgz|pdf|ai|png|jpg|webp|gif|png8|txt|dxf|eps|emf|wmf|tiff|bmp|tga|psd (ait, a .ait template, is written by file.saveAsTemplate), …the format's options (see document.formats; SVG ones also as svg: {…}), selectedOnly?: false (the selected objects alone, in their layers)} → {text, warnings} for svg (plus dataBase64, the file, when its encoding isn't UTF-8), else {dataBase64, warnings}; an SVG of several artboards also gives files: [{name, text}], linked images linked: [{name, dataBase64}]. A key no format takes, and an unknown key inside a PDF or EPS option's object, are refused; a key only another format takes is ignored", has_doc, export::serialize ), @@ -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?: 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", + "{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}]}. A key no format takes, and an unknown key inside a PDF or EPS option's object, are refused; a key only another format takes is ignored. 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), 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", + "{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. A key no format takes, and an unknown key inside a PDF or EPS option's object, are refused; a key only another format takes is ignored", has_selection, export::export_selection ), diff --git a/crates/engine/src/cmd/fileio/dxf.rs b/crates/engine/src/cmd/fileio/dxf.rs index 4320cbb9c..c1f09b18f 100644 --- a/crates/engine/src/cmd/fileio/dxf.rs +++ b/crates/engine/src/cmd/fileio/dxf.rs @@ -21,14 +21,14 @@ pub fn specs() -> Vec { "Export DXF", [], None, - "{path?, version?: R12|R13|R14|2000|2004|2007|2010|2013|2018 (default), unit?: mm (default; any unit name: pt, in, cm…), scale?: 1 (drawing units per unit), scaleLineweights?: false, colors?: 8|16|256|true (default; true colour needs 2004+, else 256), rasterFormat?: png (default)|jpeg (placed images, written next to the file), preserve?: appearance (default: type as outlines; strokes a CAD line can't draw as filled outlines; brush art)|editability (type as text; every stroke a line with its lineweight and dashes), alterPaths?: false (strokes a CAD line can't draw as their filled outlines, with editability too; also strokes wider than 2.11 mm, and every stroke in R13 and R14), outlineText?: false, selectedOnly?: false (the selected objects, in their layers), artboard?: 0 (the origin is its bottom-left corner), useArtboards?: true (one drawing per chosen artboard, artboards?/range?, holding the art over it: {stem}-{artboard}.dxf) | false (the origin at the art's bounds)} → {path, bytes, warnings, files?, linked?: [image files]}; no path → {dataBase64, …, linked?: [{name, dataBase64}]}. Layers become DXF layers (hidden: off; locked; non-printing: not plotted); paths become polylines and splines, fills their outlines (which laser and cutter software reads) and solid hatches. Same as document.export {format: dxf}", + "{path?, version?: R12|R13|R14|2000|2004|2007|2010|2013|2018 (default), unit?: mm (default; any unit name: pt, in, cm…), scale?: 1 (drawing units per unit), scaleLineweights?: false, colors?: 8|16|256|true (default; true colour needs 2004+, else 256), rasterFormat?: png (default)|jpeg (placed images, written next to the file), preserve?: appearance (default: type as outlines; strokes a CAD line can't draw as filled outlines; brush art)|editability (type as text; every stroke a line with its lineweight and dashes), alterPaths?: false (strokes a CAD line can't draw as their filled outlines, with editability too; also strokes wider than 2.11 mm, and every stroke in R13 and R14), outlineText?: false, selectedOnly?: false (the selected objects, in their layers), artboard?: 0 (the origin is its bottom-left corner), useArtboards?: true (one drawing per chosen artboard, artboards?/range?, holding the art over it: {stem}-{artboard}.dxf) | false (the origin at the art's bounds)} → {path, bytes, warnings, files?, linked?: [image files]}; no path → {dataBase64, …, linked?: [{name, dataBase64}]}. Layers become DXF layers (hidden: off; locked; non-printing: not plotted); paths become polylines and splines, fills their outlines (which laser and cutter software reads) and solid hatches. Same as document.export {format: dxf}. A key DXF doesn't take is refused", has_doc, export_dxf )] } /// The DXF options `document.formats` lists. -pub(super) const OPTIONS: &[FormatOption] = &[ +pub(crate) const OPTIONS: &[FormatOption] = &[ super::ARTBOARD, super::ARTBOARDS, super::RANGE, diff --git a/crates/engine/src/cmd/fileio/eps.rs b/crates/engine/src/cmd/fileio/eps.rs index f90dacfc7..ac3332699 100644 --- a/crates/engine/src/cmd/fileio/eps.rs +++ b/crates/engine/src/cmd/fileio/eps.rs @@ -32,14 +32,14 @@ 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; none when no art shows) | false (one file, the bounding box the visible art, y up from the first artboard's bottom-left corner, even when artboards are named; the default when none are named), format?: \"eps\", artboard?: i | artboards?: [i…] | range?: \"1-3, 5\" (the artboards written; naming them without useArtboards works as useArtboards: true)} → {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 (one file, the bounding box the visible art, y up from the first artboard's bottom-left corner, even when artboards are named; the default when none are named), format?: \"eps\", artboard?: i | artboards?: [i…] | range?: \"1-3, 5\" (the artboards written; naming them without useArtboards works as useArtboards: true)} → {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}. A key EPS doesn't take, and an unknown key inside flattener, are refused", has_doc, export_eps )] } /// The EPS options `document.formats` lists. -pub(super) const OPTIONS: &[FormatOption] = &[ +pub(crate) const OPTIONS: &[FormatOption] = &[ super::ARTBOARD, super::ARTBOARDS, super::RANGE, diff --git a/crates/engine/src/cmd/fileio/pdf.rs b/crates/engine/src/cmd/fileio/pdf.rs index 812b8ac97..7f2d31d01 100644 --- a/crates/engine/src/cmd/fileio/pdf.rs +++ b/crates/engine/src/cmd/fileio/pdf.rs @@ -21,7 +21,7 @@ const C: &str = "document.exportPdf"; pub use vectorcraft_pdf::DEFAULT_PRESET; /// The PDF options `document.formats` lists; `document.exportPdf` documents every field. -pub(super) const OPTIONS: &[FormatOption] = &[ +pub(crate) const OPTIONS: &[FormatOption] = &[ super::ARTBOARD, super::ARTBOARDS, super::RANGE, @@ -50,6 +50,19 @@ pub(super) const OPTIONS: &[FormatOption] = &[ default: "true", description: "embed the native document so VectorCraft reopens the PDF editable (the default preset's choice)", }, + FormatOption { name: "thumbnails", ty: "boolean", default: "false", description: "each page drawn as a /Thumb image, 106 px on its long side" }, + FormatOption { + name: "fastWebView", + ty: "boolean", + default: "false", + description: "a linearized file: the first page shows while the rest downloads", + }, + FormatOption { + name: "viewAfterSaving", + ty: "boolean", + default: "false", + description: "in the desktop app's document.exportPdf, the written file opens in the system viewer", + }, FormatOption { name: "compression", ty: "object", default: "null", description: "{color, gray, mono, compressText} (see document.exportPdf)" }, FormatOption { name: "marks", ty: "object", default: "null", description: "printer's marks (see document.exportPdf)" }, FormatOption { @@ -109,7 +122,7 @@ pub fn specs() -> Vec { "Export PDF", [], None, - "{path?, preset?: \"VectorCraft Default\" (built-in or saved: pdf.preset.list), artboard? | artboards?: [i…] | range?: \"1-3, 5\" (1-based; default all, one page each), standard?: none|pdfA2b|pdfX1a (CMYK, grey and spot colours only: colours and images converted to output.destination or the CMYK output intent, untagged; transparency flattened with flattenerPreset; PDF 1.3)|pdfX3 (colours ICC-tagged; transparency flattened; PDF 1.3)|pdfX4 (transparency and layers kept; colours ICC-tagged; PDF 1.6 at most) (PDF/X: a /GTS_PDFX output intent, by default the CMYK profile in effect, embedded; TrimBox, Trapped, GTS_PDFXVersion, title and dates; fonts embedded or outlined), compatibility?: 1.3 (no transparency: it is flattened with flattenerPreset; images keep no see-through pixels)|1.4|1.5|1.6|1.7 (default)|2.0 (choosing a standard sets its version: 1.7 for pdfA2b, 1.6 for pdfX4, 1.3 for pdfX1a and pdfX3, which write 1.4 as 1.3 too; pdfA2b refuses 1.3), preserveEditing? (on in VectorCraft Default: the native document as an embedded file, which document.open restores; choosing a standard turns it off, a standard refuses it), thumbnails? (each page drawn as a /Thumb image, 106 px on its long side), fastWebView? (a linearised file: the first page shows while the rest downloads; it stays linearised when encrypted), viewAfterSaving? (the app opens the written file), createLayers? (each layer and sublayer but template layers as a PDF layer, an optional content group listed under its layer's: hidden layers off, non-printing ones /PrintState /OFF, locked ones locked; needs 1.5 or later; they reopen as layers; choosing pdfX1a or pdfX3 turns it off; they refuse it), includeNonPrinting? (keep layers whose Print option is off; left out by default unless createLayers), flattenerPreset?: \"\" (High Resolution) | high | medium | low | a saved preset (flattener.presets.list): what PDF 1.3 files flatten transparency with, flattener?: {object.flattenTransparency options over it}, compression?: {color?, gray?: {downsample: none|average|subsample|bicubic, ppi: 300, abovePpi: 450, compression: none|zip|jpeg|jpeg2000|auto, quality: minimum|low|medium|high|maximum}, mono?: {downsample, ppi: 1200, abovePpi: 1800, compression: none|ccittG3|ccittG4|zip|runLength} (black-and-white images), compressText?: true} (images above abovePpi are resampled to ppi; auto keeps JPEGs JPEG, the others lossless; JPEG needs opaque images; none, jpeg2000, CCITT and runLength are written as ZIP with a warning), marks?: {trim, registration, colorBars, pageInfo, kind: roman|japanese, weight: 0.25 (trim marks and targets), offset: 6 (pt from the artboard, at least the bleed)} (printer's marks in [Registration], every plate: trim marks, registration targets mid-side, CMYK/spot/black-tint colour bars on top, page information (title, artboard, date UTC) below), bleed?: {useDocument (the document's bleed, document.setup), top, bottom, left, right} (pt; art in the bleed is kept). Each page: TrimBox = artboard, BleedBox = artboard + bleed, MediaBox = that + the marks' room (art clipped to the BleedBox), output?: {conversion: none (default)|destination (every colour into the destination profile's model, CMYK or RGB, also colours of that model in another profile)|preserveNumbers (only colours of the other model and Lab; those in the destination's model keep their numbers) (grey stays grey; images are converted too, printer's marks are not), destination: profile name (edit.colorSettings lists them; default: the document's profile for its colour mode), profiles: none (device colours)|all|destination|taggedSource (documents assigned a profile: edit.assignProfile) (ICC-based colours: CMYK with the destination or document CMYK profile, RGB as sRGB, grey with the sRGB tone curve; PDF/A, pdfX3 and pdfX4 always, whatever profiles says), outputIntent: profile name (embedded as the catalog's /GTS_PDFX output intent; a name that isn't a profile is only named, which pdfX1a and pdfX3 allow only with a registry and pdfX4 refuses; pdfX1a needs a CMYK one), outputCondition, outputConditionId (default: outputIntent), registry, trapped (/Trapped /True; /False when an output intent is written)} (PDF/A files keep their own output intent), advanced?: {fontSubsetPercent: 100 (below 100 is accepted with a warning: fonts are always embedded as subsets), outlineText: false (type as real, selectable and searchable text in embedded subset fonts with a ToUnicode map; fonts whose licence forbids embedding, or allows only whole fonts, stay outlines with a warning; true: every glyph as an outline), overprint: preserve (overprinting fills and strokes set /OP /op true, /OPM 1; white ones knock out with document.setup discardWhiteOverprint)|discard (they knock out)}, security?: {openPassword, permissionsPassword, printing: none|low|high, changes: none|pages|forms|comments|any, copy, screenReader, plaintextMetadata}, acknowledgeLoss?: bool (write over the file the document was read from although reading it left things out), format?: \"pdf\"} → {path, bytes, warnings}; no path → {dataBase64, bytes, warnings}. The options apply over the preset (null keeps its value); options accepted but not applied yet come back as warnings; a standard with a PDF version it doesn't allow and a password with a standard are refused, and so is a PDF/X file that would still break its standard (transparency the flattener left, RGB in pdfX1a, a font not embedded) or a PDF 1.3 file with transparency left. document.export {format: pdf} takes the same options", + "{path?, preset?: \"VectorCraft Default\" (built-in or saved: pdf.preset.list), artboard? | artboards?: [i…] | range?: \"1-3, 5\" (1-based; default all, one page each), standard?: none|pdfA2b|pdfX1a (CMYK, grey and spot colours only: colours and images converted to output.destination or the CMYK output intent, untagged; transparency flattened with flattenerPreset; PDF 1.3)|pdfX3 (colours ICC-tagged; transparency flattened; PDF 1.3)|pdfX4 (transparency and layers kept; colours ICC-tagged; PDF 1.6 at most) (PDF/X: a /GTS_PDFX output intent, by default the CMYK profile in effect, embedded; TrimBox, Trapped, GTS_PDFXVersion, title and dates; fonts embedded or outlined), compatibility?: 1.3 (no transparency: it is flattened with flattenerPreset; images keep no see-through pixels)|1.4|1.5|1.6|1.7 (default)|2.0 (choosing a standard sets its version: 1.7 for pdfA2b, 1.6 for pdfX4, 1.3 for pdfX1a and pdfX3, which write 1.4 as 1.3 too; pdfA2b refuses 1.3), preserveEditing? (on in VectorCraft Default: the native document as an embedded file, which document.open restores; choosing a standard turns it off, a standard refuses it), thumbnails? (each page drawn as a /Thumb image, 106 px on its long side), fastWebView? (a linearised file: the first page shows while the rest downloads; it stays linearised when encrypted), viewAfterSaving? (the app opens the written file), createLayers? (each layer and sublayer but template layers as a PDF layer, an optional content group listed under its layer's: hidden layers off, non-printing ones /PrintState /OFF, locked ones locked; needs 1.5 or later; they reopen as layers; choosing pdfX1a or pdfX3 turns it off; they refuse it), includeNonPrinting? (keep layers whose Print option is off; left out by default unless createLayers), flattenerPreset?: \"\" (High Resolution) | high | medium | low | a saved preset (flattener.presets.list): what PDF 1.3 files flatten transparency with, flattener?: {object.flattenTransparency options over it}, compression?: {color?, gray?: {downsample: none|average|subsample|bicubic, ppi: 300, abovePpi: 450, compression: none|zip|jpeg|jpeg2000|auto, quality: minimum|low|medium|high|maximum}, mono?: {downsample, ppi: 1200, abovePpi: 1800, compression: none|ccittG3|ccittG4|zip|runLength} (black-and-white images), compressText?: true} (images above abovePpi are resampled to ppi; auto keeps JPEGs JPEG, the others lossless; JPEG needs opaque images; none, jpeg2000, CCITT and runLength are written as ZIP with a warning), marks?: {trim, registration, colorBars, pageInfo, kind: roman|japanese, weight: 0.25 (trim marks and targets), offset: 6 (pt from the artboard, at least the bleed)} (printer's marks in [Registration], every plate: trim marks, registration targets mid-side, CMYK/spot/black-tint colour bars on top, page information (title, artboard, date UTC) below), bleed?: {useDocument (the document's bleed, document.setup), top, bottom, left, right} (pt; art in the bleed is kept). Each page: TrimBox = artboard, BleedBox = artboard + bleed, MediaBox = that + the marks' room (art clipped to the BleedBox), output?: {conversion: none (default)|destination (every colour into the destination profile's model, CMYK or RGB, also colours of that model in another profile)|preserveNumbers (only colours of the other model and Lab; those in the destination's model keep their numbers) (grey stays grey; images are converted too, printer's marks are not), destination: profile name (edit.colorSettings lists them; default: the document's profile for its colour mode), profiles: none (device colours)|all|destination|taggedSource (documents assigned a profile: edit.assignProfile) (ICC-based colours: CMYK with the destination or document CMYK profile, RGB as sRGB, grey with the sRGB tone curve; PDF/A, pdfX3 and pdfX4 always, whatever profiles says), outputIntent: profile name (embedded as the catalog's /GTS_PDFX output intent; a name that isn't a profile is only named, which pdfX1a and pdfX3 allow only with a registry and pdfX4 refuses; pdfX1a needs a CMYK one), outputCondition, outputConditionId (default: outputIntent), registry, trapped (/Trapped /True; /False when an output intent is written)} (PDF/A files keep their own output intent), advanced?: {fontSubsetPercent: 100 (below 100 is accepted with a warning: fonts are always embedded as subsets), outlineText: false (type as real, selectable and searchable text in embedded subset fonts with a ToUnicode map; fonts whose licence forbids embedding, or allows only whole fonts, stay outlines with a warning; true: every glyph as an outline), overprint: preserve (overprinting fills and strokes set /OP /op true, /OPM 1; white ones knock out with document.setup discardWhiteOverprint)|discard (they knock out)}, security?: {openPassword, permissionsPassword, printing: none|low|high, changes: none|pages|forms|comments|any, copy, screenReader, plaintextMetadata}, acknowledgeLoss?: bool (write over the file the document was read from although reading it left things out), format?: \"pdf\"} → {path, bytes, warnings}; no path → {dataBase64, bytes, warnings}. The options apply over the preset (null keeps its value); options accepted but not applied yet come back as warnings; a standard with a PDF version it doesn't allow and a password with a standard are refused, and so is a PDF/X file that would still break its standard (transparency the flattener left, RGB in pdfX1a, a font not embedded) or a PDF 1.3 file with transparency left. document.export {format: pdf} takes the same options. A key PDF doesn't take, and an unknown key inside one of the option objects, are refused", has_doc, export_pdf ), @@ -118,7 +131,7 @@ pub fn specs() -> Vec { "PDF Settings", [], None, - "{preset?, includeDocument?: false, …options of document.exportPdf} → {settings (the preset with the options applied), presets: [name…], changed: [{option: \"compression.compressText\", value}] (what differs from the preset), warnings}; includeDocument also exports the active document in memory and adds its warnings (knockout groups approximated, effects left out…)", + "{preset?, includeDocument?: false, …options of document.exportPdf} → {settings (the preset with the options applied), presets: [name…], changed: [{option: \"compression.compressText\", value}] (what differs from the preset), warnings}; includeDocument also exports the active document in memory and adds its warnings (knockout groups approximated, effects left out…). A key PDF doesn't take, and an unknown key inside one of the option objects, are refused", always, pdf_settings ), diff --git a/crates/engine/src/cmd/params.rs b/crates/engine/src/cmd/params.rs index 1ca9bf233..412c620bb 100644 --- a/crates/engine/src/cmd/params.rs +++ b/crates/engine/src/cmd/params.rs @@ -6,7 +6,9 @@ //! by `|`; a `?` marks an optional one, and a `:`, `=` or `(` starts what the entry says about it. An entry that starts //! with `…` stands for keys listed elsewhere: those of each command it names after `as` or `of` //! (`…settings as file.new`), or, naming none, any key (the options of the format an export picks). Objects inside a value aren't -//! described key by key, so only the top level is checked. +//! described key by key, so only the top level is checked, except in an export's options: those +//! are checked against the options of the formats the command can write, in `marks`, `bleed` and +//! the other objects too ([`check_export_options`]). //! //! Callers outside the app (the control channel, MCP, the CLI) are answered with a parameter error //! for a key the description doesn't name ([`check_params`]): a misspelt `widht` no longer runs the @@ -473,9 +475,100 @@ pub const UNCHECKED: &[&str] = &[ "webExport.settings", ]; -/// [`check_keys`] for an engine command, the steps of a `command.batch` included, and -/// [`check_effect_params`] for the effect `effect.apply` adds. A command the engine doesn't have -/// passes: running it reports that. +/// The object options of a PDF export, key by key: the Save PDF settings as a preset stores them, +/// with the passwords it leaves out. +fn pdf_option_tree() -> Value { + let mut tree = serde_json::to_value(vectorcraft_pdf::PdfSettings::default()).unwrap_or_default(); + if let Some(security) = tree.get_mut("security").and_then(Value::as_object_mut) { + security.extend(["openPassword", "permissionsPassword"].map(|k| (k.to_string(), Value::Null))); + } + tree +} + +/// `given`, the object at `within` (`marks.`), has only keys of `schema`, the objects inside it too. +fn check_option_tree(id: &str, within: &str, given: &serde_json::Map, schema: &serde_json::Map) -> Result<()> { + for (key, value) in given { + match (schema.get(key), value) { + (None, _) => { + let keys: Vec<&str> = schema.keys().map(String::as_str).collect(); + let why = match nearest(key, &keys) { + Some(near) => format!("did you mean `{within}{near}`?"), + None => format!("`{}` takes: {}", within.trim_end_matches('.'), keys.join(", ")), + }; + return Err(bad(id, format!("unknown parameter `{within}{key}`; {why}"))); + } + (Some(Value::Object(inner)), Value::Object(value)) => check_option_tree(id, &format!("{within}{key}."), value, inner)?, + _ => {} + } + } + Ok(()) +} + +/// The options of an export command `id`. `document.export`, `document.exportSelection` and +/// `document.serialize` take a key that some format reads (one object can carry several formats' +/// options; `document.formats` lists them). `document.exportPdf`, `document.pdfSettings` and +/// `pdf.preset.save` take a key that PDF reads, `document.exportEps` one that EPS reads and +/// `document.exportDxf` one that DXF reads. Where a command takes `marks`, `bleed`, `flattener` or +/// another object option of PDF or EPS, only the object's own keys pass. An SVG export's `svg` +/// object is checked when it is read. Saved presets are not checked: one with a key that is no +/// longer read keeps loading. +fn check_export_options(id: &str, params: &Value) -> Result<()> { + use super::fileio::{FORMATS, dxf, eps, pdf}; + let Value::Object(given) = params else { return Ok(()) }; + let mut keys: Vec<&str> = vec!["path", "format", "acknowledgeLoss"]; + let why_not = match id { + "document.export" | "document.exportSelection" | "document.serialize" => { + // `richBlack` is Appearance of Black's setting for a raster export, and `params` an + // exporter plug-in's own. + keys.extend(["includeArtboardFill", "selectedOnly", "richBlack", "params"]); + keys.extend(FORMATS.iter().filter(|f| f.write).flat_map(|f| f.options).map(|o| o.name)); + "no format takes it" + } + "document.exportPdf" | "document.pdfSettings" | "pdf.preset.save" => { + keys.push("includeArtboardFill"); + keys.extend(pdf::OPTIONS.iter().map(|o| o.name)); + match id { + "document.pdfSettings" => keys.push("includeDocument"), + "pdf.preset.save" => keys.extend(["name", "newName", "description"]), + _ => {} + } + "PDF doesn't take it" + } + "document.exportEps" => { + keys.push("includeArtboardFill"); + keys.extend(eps::OPTIONS.iter().map(|o| o.name)); + "EPS doesn't take it" + } + "document.exportDxf" => { + keys.extend(dxf::OPTIONS.iter().map(|o| o.name)); + "DXF doesn't take it" + } + _ => return Ok(()), + }; + let mut trees = pdf_option_tree(); + if let Some(tree) = trees.as_object_mut() { + tree.insert("flattener".into(), serde_json::to_value(super::flatten::FlattenOptions::default()).unwrap_or_default()); + } + for (key, value) in given { + if !keys.contains(&key.as_str()) { + return Err(bad( + id, + match nearest(key, &keys) { + Some(near) => format!("unknown parameter `{key}`; did you mean `{near}`?"), + None => format!("unknown parameter `{key}`; {why_not} (see document.formats)"), + }, + )); + } + if let (Some(Value::Object(schema)), Value::Object(value)) = (trees.get(key), value) { + check_option_tree(id, &format!("{key}."), value, schema)?; + } + } + Ok(()) +} + +/// [`check_keys`] for an engine command, the steps of a `command.batch` included, +/// [`check_effect_params`] for the effect `effect.apply` adds and [`check_export_options`] for an +/// export. A command the engine doesn't have passes: running it reports that. pub fn check_params(id: &str, params: &Value) -> Result<()> { let Some(spec) = find_command(id) else { return Ok(()) }; if let ("effect.apply" | "appearance.addEffect", Some(effect), Some(given)) = @@ -483,6 +576,7 @@ pub fn check_params(id: &str, params: &Value) -> Result<()> { { check_effect_params(id, effect, given)?; } + check_export_options(id, params)?; if UNCHECKED.contains(&id) { return Ok(()); } diff --git a/crates/engine/src/cmd/pdfcmds.rs b/crates/engine/src/cmd/pdfcmds.rs index b5a8778fe..35478cdd1 100644 --- a/crates/engine/src/cmd/pdfcmds.rs +++ b/crates/engine/src/cmd/pdfcmds.rs @@ -46,7 +46,7 @@ pub fn specs() -> Vec { "Save PDF Preset", [], None, - "{name?: (default: a new \"PDF Preset N\"), newName?: rename it, description?, preset?: the preset to start from (default: the saved preset `name`, else VectorCraft Default), …options of document.exportPdf} create or change a saved preset (built-in presets are read-only; passwords are never stored) → {name, created, settings}", + "{name?: (default: a new \"PDF Preset N\"), newName?: rename it, description?, preset?: the preset to start from (default: the saved preset `name`, else VectorCraft Default), …options of document.exportPdf} create or change a saved preset (built-in presets are read-only; passwords are never stored) → {name, created, settings}. A key PDF doesn't take, and an unknown key inside one of the option objects, are refused", always, save ), diff --git a/crates/engine/src/tests/params.rs b/crates/engine/src/tests/params.rs index 2eba6c81a..275d75044 100644 --- a/crates/engine/src/tests/params.rs +++ b/crates/engine/src/tests/params.rs @@ -64,7 +64,7 @@ fn an_unknown_key_is_named_with_the_nearest_accepted_one() { ("file.new", json!(null)), // Keys listed by reference, and any key where the description says so. ("file.newPresets.save", json!({"name": "a", "width": 300})), - ("document.export", json!({"format": "png", "anyFormatOption": 1})), + ("document.export", json!({"format": "png", "styling": "style"})), // Not a command: running it says so. ("no.such", json!({"x": 1})), ] { @@ -74,6 +74,109 @@ fn an_unknown_key_is_named_with_the_nearest_accepted_one() { assert!(check_keys("x", "{a, …as file.new}", &json!({"b": 1})).is_err()); } +/// An export's options: a key no format reads is refused, at the top level and in the PDF objects, +/// under each command that takes them. +#[test] +fn export_options_refuse_a_key_no_format_reads() { + for id in ["document.export", "document.exportSelection", "document.serialize"] { + assert_eq!( + refusal(id, json!({"format": "pdf", "standard": "pdfX4", "noSuchOption": true})), + "unknown parameter `noSuchOption`; no format takes it (see document.formats)" + ); + assert_eq!(refusal(id, json!({"format": "pdf", "standardd": "pdfX4"})), "unknown parameter `standardd`; did you mean `standard`?"); + assert_eq!( + refusal(id, json!({"format": "pdf", "options": {"standard": "pdfX4"}})), + "unknown parameter `options`; no format takes it (see document.formats)" + ); + } + // A command that writes one format refuses another format's option, and says which format. + for (id, why) in [ + ("document.exportPdf", "PDF doesn't take it"), + ("document.pdfSettings", "PDF doesn't take it"), + ("pdf.preset.save", "PDF doesn't take it"), + ("document.exportEps", "EPS doesn't take it"), + ("document.exportDxf", "DXF doesn't take it"), + ] { + for key in ["noSuchOption", "quality"] { + assert_eq!(refusal(id, json!({key: 1})), format!("unknown parameter `{key}`; {why} (see document.formats)")); + } + } + assert_eq!( + refusal("document.exportEps", json!({"flattener": {"balanse": 50}})), + "unknown parameter `flattener.balanse`; did you mean `flattener.balance`?" + ); + assert!(refusal("document.exportDxf", json!({"flattener": {}})).contains("DXF doesn't take it")); + assert!(refusal("document.exportEps", json!({"marks": {}})).contains("EPS doesn't take it")); + for id in ["document.pdfSettings", "pdf.preset.save"] { + assert!(refusal(id, json!({"marks": {"useDocumentBleed": true}})).starts_with("unknown parameter `marks.useDocumentBleed`;"), "{id}"); + } + for (given, message) in [ + (json!({"marks": {"useDocumentBleed": true}}), "unknown parameter `marks.useDocumentBleed`; `marks` takes:"), + (json!({"bleed": {"useDocumen": true}}), "unknown parameter `bleed.useDocumen`; did you mean `bleed.useDocument`?"), + (json!({"output": {"intent": "x"}}), "unknown parameter `output.intent`;"), + (json!({"advanced": {"outlineTxt": true}}), "unknown parameter `advanced.outlineTxt`; did you mean `advanced.outlineText`?"), + (json!({"security": {"openPasswrd": "x"}}), "unknown parameter `security.openPasswrd`; did you mean `security.openPassword`?"), + (json!({"compression": {"color": {"dpi": 300}}}), "unknown parameter `compression.color.dpi`;"), + (json!({"flattener": {"balanse": 50}}), "unknown parameter `flattener.balanse`; did you mean `flattener.balance`?"), + ] { + for id in ["document.export", "document.exportPdf"] { + let got = refusal(id, given.clone()); + assert!(got.starts_with(message), "{id} {given}: {got}"); + } + } +} + +/// The check passes each key `document.formats` lists, each key a command's own description names, +/// and each field of the PDF settings and the flattener options. A key an encoder reads that none +/// of these names is outside this test. +#[test] +fn export_options_pass_every_listed_key() { + use crate::cmd::fileio::{FORMATS, dxf, eps, pdf::OPTIONS}; + for f in FORMATS.iter().filter(|f| f.write) { + for o in f.options { + let params = json!({"format": f.id, o.name: null}); + assert!(check_params("document.export", ¶ms).is_ok(), "{params}"); + } + } + for (id, options) in [("document.exportEps", eps::OPTIONS), ("document.exportDxf", dxf::OPTIONS), ("document.exportPdf", OPTIONS)] { + for o in options { + assert!(check_params(id, &json!({o.name: null})).is_ok(), "{id} {}", o.name); + } + } + for id in [ + "document.export", + "document.exportSelection", + "document.serialize", + "document.exportPdf", + "document.pdfSettings", + "pdf.preset.save", + "document.exportEps", + "document.exportDxf", + ] { + // An export reads these two when it writes over the file a lossy document came from. The + // PDF settings and preset commands take them through document.exportPdf's description. + check_params(id, &json!({"path": "a", "acknowledgeLoss": true})).unwrap_or_else(|e| panic!("{id}: {e}")); + let keys = doc_keys(find_command(id).unwrap().params).unwrap().keys; + let refused: Vec<&str> = keys.into_iter().filter(|key| check_params(id, &json!({*key: null})).is_err()).collect(); + // `document.exportPdf`'s description explains the page boxes as `BleedBox = …` between its + // keys, which reads as two more keys. + let prose = if id == "document.exportPdf" { vec!["BleedBox", "MediaBox"] } else { vec![] }; + assert_eq!(refused, prose, "{id} names and refuses these"); + } + let settings = serde_json::to_value(vectorcraft_pdf::PdfSettings::default()).unwrap(); + for key in settings.as_object().unwrap().keys() { + assert!(OPTIONS.iter().any(|o| o.name == key), "document.formats doesn't list the PDF option `{key}`"); + } + let mut every = settings; + every["flattener"] = serde_json::to_value(crate::cmd::flatten::FlattenOptions::default()).unwrap(); + every["security"]["openPassword"] = json!("a"); + every["security"]["permissionsPassword"] = json!("b"); + every["preset"] = json!("High Quality Print"); + for id in ["document.export", "document.exportPdf", "document.pdfSettings", "pdf.preset.save"] { + check_params(id, &every).unwrap_or_else(|e| panic!("{id}: {e}")); + } +} + /// `stroke.set` names every key it reads (its own, `item`, and `ids`/`id`/`target`), so a /// misnamed one is refused instead of answering `objects: 1` with nothing changed. #[test] diff --git a/crates/engine/tests/it/param_keys.rs b/crates/engine/tests/it/param_keys.rs index 3da393ca8..89a27ba81 100644 --- a/crates/engine/tests/it/param_keys.rs +++ b/crates/engine/tests/it/param_keys.rs @@ -21,6 +21,22 @@ fn a_journal_with_a_key_no_longer_read_still_replays() { assert_eq!((st.doc.artboards[0].rect.width(), st.doc.layers[0].children().unwrap().len()), (300.0, 1)); } +/// `Session::execute` still runs an export with an option that is no longer read, and a preset +/// file that holds one still loads and exports. Actions play through `execute`; this test does not +/// play one. +#[test] +fn an_export_step_or_preset_with_a_key_no_longer_read_still_runs() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let old = json!({"format": "pdf", "noSuchOption": true, "marks": {"useDocumentBleed": true}}); + assert!(check_params("document.export", &old).is_err()); + s.execute("document.export", &old).unwrap(); + let file = json!({"format": "vcpdfpresets", "presets": [{"name": "Old", "settings": {"standard": "none", "noLongerRead": 1, "marks": {"retired": true}}}]}); + s.execute("pdf.preset.import", &json!({"data": file.to_string()})).unwrap(); + check_params("document.export", &json!({"format": "pdf", "preset": "Old"})).unwrap(); + s.execute("document.export", &json!({"format": "pdf", "preset": "Old"})).unwrap(); +} + /// So do an effect's own parameters (#1592): a journal or a file with a key the effect doesn't /// take, or a value of another type, replays and opens; the effect reads what it knows, clamped. #[test] diff --git a/crates/mcp/src/headless.rs b/crates/mcp/src/headless.rs index 0a81bb127..59b3f973f 100644 --- a/crates/mcp/src/headless.rs +++ b/crates/mcp/src/headless.rs @@ -315,6 +315,8 @@ impl Headless { /// `app.export {path?, format?, …}`: `document.export` (format defaults to the path's extension). pub fn export(&mut self, p: &Value) -> Result { + // The key check of `document.export` runs before the file is written. + self.session.check_params("document.export", p).map_err(|e| e.to_string())?; let roots = self.roots.clone(); file_access::confine(roots.as_ref(), || self.session.execute("document.export", p)).map_err(|e| e.to_string()) } diff --git a/crates/mcp/src/tests/exportas.rs b/crates/mcp/src/tests/exportas.rs index 0d9dc5c49..8bc958af8 100644 --- a/crates/mcp/src/tests/exportas.rs +++ b/crates/mcp/src/tests/exportas.rs @@ -16,6 +16,34 @@ fn three_boards() -> Headless { h } +/// The tool, `app.export` and `file.export` refuse a key no format takes, at the top level of +/// `options` and inside a PDF object. +#[test] +fn an_option_no_format_takes_is_an_error() { + let mut h = Headless::new(); + h.call("engine.execute", json!({"command": "file.new", "params": {"width": 50, "height": 30}})).unwrap(); + for (options, named) in [ + (json!({"standard": "pdfX4", "noSuchOption": true}), "noSuchOption"), + (json!({"marks": {"useDocumentBleed": true}}), "marks.useDocumentBleed"), + ] { + let r = call_tool(&mut h, "export", &json!({"format": "pdf", "options": options})); + assert!(r.is_error && r.content[0]["text"].as_str().unwrap().contains(named), "{r:?}"); + let mut params = options.clone(); + params["format"] = json!("pdf"); + for method in ["app.export", "file.export"] { + let e = if method == "app.export" { + h.call(method, params.clone()).unwrap_err() + } else { + h.call("engine.execute", json!({"command": method, "params": params})).unwrap_err() + }; + assert!(e.contains(named), "{method}: {e}"); + } + } + // One object can still carry several formats' options. + let r = call_tool(&mut h, "export", &json!({"format": "pdf", "options": {"quality": 80, "styling": "style"}})); + assert!(!r.is_error, "{r:?}"); +} + #[test] fn raster_export_uses_document_resolution_unless_overridden() { let mut h = Headless::new(); diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index fccfbedab..71b1aacdc 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -456,6 +456,12 @@ pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context, req: &ControlReques o.remove("wait"); } let method = req.method.as_str(); + // The key check of `document.export` runs before the file is written. + if method == "app.export" + && let Err(e) = crate::menus::check_params(&app.session, "document.export", &p) + { + return err(e); + } wrap(if wait { crate::background::in_foreground(app, |app| save_or_export(app, method, &p)) } else { save_or_export(app, method, &p) }) } // As the menu's Quit: `{"pending": "saveChanges"}` while a modified document is asked diff --git a/crates/ui-egui/src/tests/automation.rs b/crates/ui-egui/src/tests/automation.rs index 5139d4881..0682b533e 100644 --- a/crates/ui-egui/src/tests/automation.rs +++ b/crates/ui-egui/src/tests/automation.rs @@ -159,6 +159,28 @@ fn shortcut_sets_are_read_only_inside_the_read_root() { let _ = std::fs::remove_dir_all(base); } +/// `app.export` refuses a key no format takes before anything is written, with a path or without. +#[test] +fn control_export_refuses_an_option_no_format_takes() { + let (mut app, tx, base) = confined("export-options"); + let r = request(&mut app, &tx, "app.open", json!({"path": s(base.join("in/a.svg"))})); + assert_eq!(r["ok"], true, "{r}"); + let path = base.join("out/a.pdf"); + let refused = |r: &Value| r["error"].as_str().is_some_and(|e| e.contains("unknown parameter")); + for mut params in [json!({"format": "pdf", "noSuchOption": true}), json!({"format": "pdf", "marks": {"useDocumentBleed": true}})] { + let r = request(&mut app, &tx, "app.export", params.clone()); + assert!(refused(&r), "{r}"); + params["path"] = json!(s(path.clone())); + params["wait"] = json!(true); + let r = request(&mut app, &tx, "app.export", params); + assert!(refused(&r), "{r}"); + assert!(!path.exists()); + } + let r = request(&mut app, &tx, "app.export", json!({"format": "pdf", "path": s(path.clone()), "wait": true})); + assert_eq!(r["ok"], true, "{r}"); + let _ = std::fs::remove_dir_all(base); +} + /// `ui.render` (MCP's `render_preview` and `screenshot` in the app) draws the artboard pixel for /// pixel as `app.export` writes a PNG on white: two rectangles that meet at x 40.3 and a diagonal /// edge. diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 0a9bfd9f0..2ff2a07f8 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -107,7 +107,7 @@ the two can still win that race. | `ui.render` | `{path?, scale?, artboard?, data?, traceViews?}` | render an artboard headlessly (PNG with the pixels of `app.export {format: "png", background: "white"}` at the same scale; `artboard`: its 0-based index, default the first; `traceViews: true`: Image Trace objects draw in their View, as on the canvas, instead of their tracing result): written to `path`, else (or with `data: true` as well) sent back as `pngBase64` | | `ui.render` | `{path?, scale?, artboard?, data?}` | render an artboard headlessly (PNG with the pixels of `app.export {format: "png", background: "white"}` at the same scale; `artboard`: its 0-based index, default the first): written to `path`, else (or with `data: true` as well) sent back as `pngBase64` | | `ui.resize` / `ui.focus` | | | -| `app.open` / `app.save` / `app.export` / `app.quit` | `{path}` / `{path?}` / `{path?, format?, artboard?, range?, scale?, …}` | `app.open` reads every format `document.open` reads (see `document.formats`) and returns its result (`{index, title, format, warnings, …}`; `warnings` say what didn't come in as it was, such as an EPS shown as its preview image and why its PostScript couldn't be read), or null when a dialog asks first or a library loads. `app.export` encodes through the engine's `document.export` (same options; the document keeps its path) and writes `path` through the host; without `path` it returns `{dataBase64, format, bytes}`, as headless mode does. `app.quit`, `file.close` and `file.closeAll` first open a `saveChanges` dialog for each modified document (they return `{"pending": "saveChanges"}`): `ui.dialog.confirm` saves, `ui.dialog.set {field: "discard", value: true}` then confirm discards, `ui.dialog.cancel` cancels the whole close or quit | +| `app.open` / `app.save` / `app.export` / `app.quit` | `{path}` / `{path?}` / `{path?, format?, artboard?, range?, scale?, …}` | `app.open` reads every format `document.open` reads (see `document.formats`) and returns its result (`{index, title, format, warnings, …}`; `warnings` say what didn't come in as it was, such as an EPS shown as its preview image and why its PostScript couldn't be read), or null when a dialog asks first or a library loads. `app.export` encodes through the engine's `document.export` (same options; the document keeps its path) and writes `path` through the host; without `path` it returns `{dataBase64, format, bytes}`, as headless mode does. It refuses a key that no format takes, and an unknown key inside a PDF or EPS option object, before it writes anything (`docs/mcp.md`, Parameter keys). `app.quit`, `file.close` and `file.closeAll` first open a `saveChanges` dialog for each modified document (they return `{"pending": "saveChanges"}`): `ui.dialog.confirm` saves, `ui.dialog.set {field: "discard", value: true}` then confirm discards, `ui.dialog.cancel` cancels the whole close or quit | | `app.export` with `useArtboards` | `{path, useArtboards: true, range? \| artboards?, …}` | Export As: PNG, JPEG and WebP write one file per chosen artboard (default all; SVG does so for a `range`), `-.`, and return `{path, files: [path…]}`; a PDF keeps them as pages of `path`. `useArtboards: false` covers the bounds of the visible art. The menu's Export As… is `file.exportAs`: the `exportAs` dialog (`format`, `useArtboards`, `all`, `range`), then a save dialog and the format's options: `pngOptions` / `jpgOptions` / `webpOptions`, `svgOptions`, or `savePdf` for a PDF with Use Artboards | | `ui.dialog.*` on `gradientStop` | `{field: "color" \| "opacity" \| "location", value}` | double-clicking a stop on the Gradient tool's annotator opens its popover (fields `index`, `x`, `y`, `tab`); set `color` (hex), `opacity` or `location` (percentages) and `ui.dialog.confirm` to apply them to the selected stop. `gradient.selectStop {index}` picks the stop the annotator, the panels and Delete/←/→ (`ui.key`) act on | | `engine.execute` `ui.savePdfDialog` | `{path?, preset?, range?, …document.exportPdf options}` | opens the Save PDF dialog (`file.export.pdf` with no params opens it too). Its fields are the `document.exportPdf` options: set `compatibility` with `ui.dialog.set {field: "compatibility", value: "1.5"}`, a whole section with `{field: "compression", value: {…}}`, the artboards with `__allArtboards: false` and `range`, the visible section with `__section` (General, Compression, Marks and Bleeds, Output, Advanced, Security, Summary). `ui.dialog.confirm` writes `path` (else asks for one), returns `{path, bytes, warnings}` and opens the file when `viewAfterSaving` is set | diff --git a/docs/mcp.md b/docs/mcp.md index 1adcf32b1..e16b78022 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -1134,6 +1134,21 @@ of the format they write (one object can carry several formats' options). A comm (`document.setup`, `text.areaOptions`) says so the same way. The app's own journal isn't checked: an action or journal recorded by an older version replays as it did, except for an effect `color` that the effect can't read. +The export commands limit those keys. `document.export`, `document.exportSelection`, `document.serialize`, `app.export` +and `file.export` refuse a key that no format takes, and ignore a key that only another format takes +(`{"format": "pdf", "quality": 80}` writes the PDF). `document.exportPdf`, `document.pdfSettings` and `pdf.preset.save` +refuse a key that PDF doesn't take, `document.exportEps` one that EPS doesn't take and `document.exportDxf` one that DXF +doesn't take. Each of them refuses an unknown key inside an option object it takes (`marks`, `bleed`, `compression`, +`output`, `advanced`, `security`, `flattener`), naming its path: + +```json +{"id": "document.export", "params": {"format": "pdf", "marks": {"useDocumentBleed": true}}} +→ invalid parameters for `document.export`: unknown parameter `marks.useDocumentBleed`; `marks` takes: … +``` + +A saved PDF preset that holds a key that is no longer read keeps loading. `document.save`, `file.saveAs`, `file.saveCopy` +and `document.exportForScreens` don't apply this check. + An unknown command id is matched the same way as an unknown parameter key: the error names the nearest command id when one is near, ignoring case. A step of `command.batch` or `action.batch` gets the same suggestion in its step error: diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 0af2f0670..2ef553b6e 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -692,6 +692,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - Native macOS menu bar, vector tool cursors, a ⌘K command palette, middle-button panning with any tool. Native menus explicitly end tracking on command selection, outside window presses and Escape; menu updates wait until tracking ends, and a dismissing press never starts a canvas gesture (M14.8). - The errors of the blend spine commands (Move Spine Point, Reshape Spine Segment, Add Spine Point and Delete Spine Point) show in the status bar in the interface language: each has a row in every catalog that translates all status and error messages and in Bulgarian (`blend_spine_param_errors_are_translated`). - The errors of the parameter checks that many commands share show in the status bar in the interface language: a value that is not a number where a number goes (`object.move` with `"dx": "60 mm"`, and `object.rotate`, `object.setBounds` and the other commands that call `check_numbers`), a missing number or index, `ids` that are not a list of object ids, an object id that is not a non-negative integer or names no object, and a date that is not Unix seconds. Each has a row in every catalog that translates all status and error messages, and in Bulgarian, Danish, Indonesian and Dutch (`shared_param_check_errors_are_translated`). + - Export commands run through MCP, the control channel or the CLI refuse a key that no format takes, and a command that writes one format (`document.exportPdf`, `document.exportEps`, `document.exportDxf`) or takes one format's options (`document.pdfSettings`, `pdf.preset.save`) refuses a key that format doesn't take. An unknown key inside `marks`, `bleed`, `compression`, `output`, `advanced`, `security` or `flattener` is refused by its path. - The errors of Enter Isolation Mode (`object.isolate`) for a type object, for hidden or locked art and for a call with no `id` while nothing is selected ("missing id", which other commands that take an `id` also give), and the error of the Layers panel's target circle (`layer.target`) for an object outside the isolated group, show in the status bar in the interface language: each has a row in every catalog that translates all status and error messages, and in Bulgarian, Danish, Indonesian and Dutch. - An EPS or PostScript file that stops on a PostScript error gives the error's name as PostScript spells it (`typecheck`, `rangecheck` and the other names the reader raises, except `undefined`, whose message names the unknown name) followed by what it means, in the warning after opening and in the error when the file can't be opened. The status bar and the open-error dialog show the meaning in the interface language: each meaning has a row in every catalog (`postscript_error_meanings_are_translated`). - The macOS menu bar is built from the in-window menus (one model, `native_menu`) and laid out as a Mac app's: the VectorCraft menu holds About, Settings ▸ (each Preferences page, General… ⌘K), Language ▸, Appearance ▸ (UI brightness), Services, Hide VectorCraft ⌃⌘H (⌘H stays Hide Edges), Hide Others, Show All and Quit; Window gains Minimize ⌃⌘M, Zoom and Bring All to Front, and Help the system's search field. Its key equivalents go back to egui as key presses, so user shortcuts, focused text fields and the Type tool's input method behave as in the window, and nothing fires twice; enabled, check marks and labels update in place. `--in-window-menus` or `VECTORCRAFT_IN_WINDOW_MENUS=1` keeps the menus in the window; `ui.inspect` reports `nativeMenuBar`.