diff --git a/crates/engine/src/cmd/fileio/encode.rs b/crates/engine/src/cmd/fileio/encode.rs index 0f91007515..8ee3da70e5 100644 --- a/crates/engine/src/cmd/fileio/encode.rs +++ b/crates/engine/src/cmd/fileio/encode.rs @@ -321,15 +321,31 @@ pub(crate) fn anti_alias(id: &str) -> std::result::Result { /// The `useArtboards` param of a PDF, AI, DXF or raster export (SVG has its own): `true` writes every /// chosen artboard (default all), `false` the bounds of the visible art instead, absent one -/// artboard (raster, DXF) or the chosen pages (PDF, AI). -fn use_artboards(p: &Value) -> Result> { +/// artboard (raster, DXF) or the chosen pages (PDF, AI). Errors name the command `cmd`. +pub(super) fn use_artboards(cmd: &str, p: &Value) -> Result> { match p.get("useArtboards") { None | Some(Value::Null) => Ok(None), Some(Value::Bool(b)) => Ok(Some(*b)), - Some(v) => Err(bad(C, format!("useArtboards must be true or false, not {v}"))), + Some(v) => Err(bad(cmd, format!("useArtboards must be true or false, not {v}"))), } } +/// With `useArtboards: false` in `p`: `doc` without its opacity-mask or symbol editing layer and +/// with one artboard, the bounds of its visible art, and `p` without the artboard choice. `None` +/// when `useArtboards` is absent, null or true. A document with no visible art is refused. +pub(super) fn art_page(cmd: &str, doc: &Document, p: &Value) -> Result> { + if use_artboards(cmd, p)? != Some(false) { + return Ok(None); + } + let doc = &*doc.without_edit_modes(); + let bounds = vectorcraft_render::encode::art_bounds(doc).ok_or_else(|| bad(cmd, "nothing to export: the document has no visible art"))?; + let mut q = super::export::without_artboards(p); + if let Some(o) = q.as_object_mut() { + o.remove("useArtboards"); + } + Ok(Some((single_artboard(doc, bounds, "Art"), q))) +} + /// The `rect` param of a raster export: `[x, y, width, height]` in points, y down. `None` when /// absent; an error when it is not four finite numbers with a positive size, when the format isn't /// a raster one, or when it comes with an artboard choice (`rect` is the area, so those would @@ -503,7 +519,7 @@ fn encode_files(doc: &Document, f: &Format, p: &Value) -> Result { let doc = &*doc.without_edit_modes(); let n = doc.artboards.len(); // SVG reads its own `useArtboards` (an SVG option). - let use_artboards = if f.raster || matches!(f.id, "pdf" | "ai" | "dxf" | "emf" | "wmf" | "hpgl") { use_artboards(p)? } else { None }; + let use_artboards = if f.raster || matches!(f.id, "pdf" | "ai" | "dxf" | "emf" | "wmf" | "hpgl") { use_artboards(C, p)? } else { None }; // `rect`: that area of the page, as if it were the only artboard (the document is not changed). if let Some(rect) = page_rect(f, p)? { let mut q = super::export::without_artboards(p); @@ -513,13 +529,10 @@ fn encode_files(doc: &Document, f: &Format, p: &Value) -> Result { } return encode_files(&single_artboard(doc, rect, "Rect"), f, &q); } - if use_artboards == Some(false) { - let bounds = vectorcraft_render::encode::art_bounds(doc).ok_or_else(|| bad(C, "nothing to export: the document has no visible art"))?; - let mut q = super::export::without_artboards(p); - if let Some(o) = q.as_object_mut() { - o.remove("useArtboards"); - } - return encode_files(&single_artboard(doc, bounds, "Art"), f, &q); + if use_artboards == Some(false) + && let Some((art, q)) = art_page(C, doc, p)? + { + return encode_files(&art, f, &q); } let bytes = match f.id { "vectorcraft" => super::native::encode(C, f, doc, p)?, diff --git a/crates/engine/src/cmd/fileio/pdf.rs b/crates/engine/src/cmd/fileio/pdf.rs index 1f166797f8..434834ecc5 100644 --- a/crates/engine/src/cmd/fileio/pdf.rs +++ b/crates/engine/src/cmd/fileio/pdf.rs @@ -128,7 +128,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, without the layers the file leaves out; 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; a page with a side longer than 14,400 pt keeps its size with a UserUnit from 1.6 on: 1.5 is then written as 1.6, with 1.5's encryption, and so is 1.4 in pdfA2b files, with a warning; PDF/X files and other 1.3 and 1.4 files write it in points, and 1.3 and 1.4 (pdfX1a and pdfX3 too) refuse a page past 32,767 pt whose size or art needs a real number past 32,767; a page whose short side is 3 pt or longer but would be under 3 units at that UserUnit gets a smaller one that keeps the short side at 3 units; a warning gives each such page's size), longPagesInPoints?: false (true: such a page is written at its size in points, without a UserUnit, at every version, and 1.5 stays 1.5, in files without a standard (PDF/A files keep the UserUnit); apps that keep to the limit cut it to 200 in), 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; a PDF/X file with no bleed on a side where the document's bleed has one comes back with a warning). 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", + "{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), useArtboards?: true (false: one page, the bounds of the visible art, as document.export writes it; artboard, artboards and range are then ignored, and a document with no visible art is refused), 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, without the layers the file leaves out; 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; a page with a side longer than 14,400 pt keeps its size with a UserUnit from 1.6 on: 1.5 is then written as 1.6, with 1.5's encryption, and so is 1.4 in pdfA2b files, with a warning; PDF/X files and other 1.3 and 1.4 files write it in points, and 1.3 and 1.4 (pdfX1a and pdfX3 too) refuse a page past 32,767 pt whose size or art needs a real number past 32,767; a page whose short side is 3 pt or longer but would be under 3 units at that UserUnit gets a smaller one that keeps the short side at 3 units; a warning gives each such page's size), longPagesInPoints?: false (true: such a page is written at its size in points, without a UserUnit, at every version, and 1.5 stays 1.5, in files without a standard (PDF/A files keep the UserUnit); apps that keep to the limit cut it to 200 in), 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; a PDF/X file with no bleed on a side where the document's bleed has one comes back with a warning). 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 ), @@ -137,7 +137,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…). A key PDF doesn't take, and an unknown key inside one of the option objects, are refused", + "{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 (one page of the art's bounds with useArtboards: false) 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 ), @@ -479,7 +479,12 @@ fn export_pdf(s: &mut Session, p: &Value) -> Result { } else { q = json!({ "_displayDocTitle": display_doc_title }); } - let (bytes, warnings) = encode(C, titled.as_ref().unwrap_or(&st.doc), &q)?; + // The opacity-mask or symbol editing layer is left out. + let doc = titled.as_ref().unwrap_or(&st.doc).without_edit_modes(); + let (bytes, warnings) = match super::encode::art_page(C, &doc, &q)? { + Some((art, q)) => encode(C, &art, &q)?, + None => encode(C, &doc, &q)?, + }; write_or_return(str_param(p, "path"), &bytes, json!({ "warnings": warnings })) } @@ -510,9 +515,17 @@ fn pdf_settings(s: &mut Session, p: &Value) -> Result { const Q: &str = "document.pdfSettings"; let saved = &s.prefs.pdf_presets; let set = settings_with(Q, p, saved)?; + super::encode::use_artboards(Q, p)?; let diff = changes(&set, &preset_settings(Q, p, saved)?); let warnings = match s.active() { - Some(st) if bool_or(p, "includeDocument", false) => encode(Q, &st.doc, &*expand_preset(s, Q, p)?)?.1, + Some(st) if bool_or(p, "includeDocument", false) => { + let q = expand_preset(s, Q, p)?; + let doc = st.doc.without_edit_modes(); + match super::encode::art_page(Q, &doc, &q)? { + Some((art, q)) => encode(Q, &art, &q)?.1, + None => encode(Q, &doc, &q)?.1, + } + } _ => set.warnings(), }; let v = serde_json::to_value(&set).map_err(|e| EngineError::Other(e.to_string()))?; diff --git a/crates/engine/src/cmd/fileio/tests_pdf.rs b/crates/engine/src/cmd/fileio/tests_pdf.rs index 6f47307290..0fced29f55 100644 --- a/crates/engine/src/cmd/fileio/tests_pdf.rs +++ b/crates/engine/src/cmd/fileio/tests_pdf.rs @@ -56,6 +56,97 @@ fn export_pdf_writes_the_compatibility_header_and_the_range() { assert!(b64(&v).starts_with(b"%PDF-1.6")); } +/// `useArtboards: false` writes one page, the bounds of the visible art, as `document.export` +/// does, whatever artboards are chosen. Art longer than 200 in keeps its size with +/// `longPagesInPoints`. A document with no visible art and a value that isn't a boolean are +/// refused. +#[test] +fn use_artboards_false_writes_one_page_of_the_arts_bounds() { + let mut s = session(3); + let page = |bytes: &[u8]| { + let d = vectorcraft_pdf::import(bytes).unwrap(); + (d.artboards.len(), d.artboards[0].rect.size()) + }; + let through_export = page(&b64(&s.execute("document.export", &as_export(&json!({"useArtboards": false}))).unwrap())); + assert!(through_export.0 == 1 && through_export.1.width < 50.0 && through_export.1.height < 40.0, "{through_export:?}"); + for p in [ + json!({"useArtboards": false}), + json!({"useArtboards": false, "range": "2"}), + json!({"useArtboards": false, "artboards": [0, 1]}), + json!({"useArtboards": false, "artboard": 2}), + ] { + assert_eq!(page(&b64(&export(&mut s, p.clone()))), through_export, "{p}"); + } + assert_eq!(pages(&b64(&export(&mut s, json!({"useArtboards": true})))), 3); + assert_eq!(pages(&b64(&export(&mut s, json!({"useArtboards": null})))), 3); + + // A 10 m drawing on a small artboard: its page is 10 m long, in points. + s.execute("shape.rectangle", &json!({"x": 0, "y": 200, "width": 28_346.46, "height": 100})).unwrap(); + let v = export(&mut s, json!({"useArtboards": false, "longPagesInPoints": true})); + let pdf = b64(&v); + let (n, size) = page(&pdf); + assert!(n == 1 && size.width > 28_346.0 && size.width < 28_350.0, "{size:?}"); + assert!(!String::from_utf8_lossy(&pdf).contains("/UserUnit")); + assert!(warnings(&v).iter().any(|w| w.contains("written without a UserUnit, in points")), "{v}"); + // The Summary's export in memory writes the same page. + let mut long = |p: Value| warnings(&s.execute("document.pdfSettings", &p).unwrap()).iter().any(|w| w.contains("longer than PDF's page limit")); + assert!(long(json!({"includeDocument": true, "useArtboards": false}))); + assert!(!long(json!({"includeDocument": true})), "the artboards are small"); + let e = s.execute("document.pdfSettings", &json!({"useArtboards": "no"})).unwrap_err(); + assert!(matches!(e, crate::EngineError::BadParams { .. }), "without includeDocument too: {e}"); + + s.execute("select.all", &json!({})).unwrap(); + s.execute("edit.clear", &json!({})).unwrap(); + for p in [json!({"useArtboards": false}), json!({"useArtboards": "no"})] { + let e = s.execute("document.exportPdf", &p).unwrap_err(); + assert!(matches!(e, crate::EngineError::BadParams { .. }), "{p}: {e}"); + } +} + +/// While an opacity mask is edited, a PDF of the art's bounds measures the art without the +/// editing layer, as `document.export` does: a mask shape drawn past the masked object doesn't +/// widen the page. +#[test] +fn a_pdf_of_the_art_leaves_out_the_mask_editing_layer() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 300, "height": 300})).unwrap(); + let obj = s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 200, "height": 200})).unwrap()["id"].clone(); + let mask = s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 100, "height": 200})).unwrap()["id"].clone(); + s.execute("paint.setFill", &json!({"color": "#ffffff", "ids": [mask]})).unwrap(); + s.execute("select.set", &json!({"ids": [obj, mask]})).unwrap(); + s.execute("transparency.makeOpacityMask", &json!({})).unwrap(); + s.execute("transparency.editOpacityMask", &json!({})).unwrap(); + s.execute("shape.ellipse", &json!({"x": 250, "y": 250, "width": 40, "height": 40})).unwrap(); + assert!(s.doc().unwrap().doc.mask_edit.is_some()); + let width = |bytes: &[u8]| vectorcraft_pdf::import(bytes).unwrap().artboards[0].rect.width(); + let through_export = width(&b64(&s.execute("document.export", &as_export(&json!({"useArtboards": false}))).unwrap())); + assert!(through_export < 250.0, "{through_export}"); + assert_eq!(width(&b64(&export(&mut s, json!({"useArtboards": false})))), through_export); +} + +/// While an opacity mask is edited, a PDF of the artboards leaves out the editing layer too, as +/// `document.export` does: from the drawn page and from the Preserve Editing data. +#[test] +fn a_pdf_of_the_artboards_leaves_out_the_mask_editing_layer() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 300, "height": 300})).unwrap(); + let obj = s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 200, "height": 200})).unwrap()["id"].clone(); + let mask = s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 100, "height": 200})).unwrap()["id"].clone(); + s.execute("paint.setFill", &json!({"color": "#ffffff", "ids": [mask]})).unwrap(); + s.execute("select.set", &json!({"ids": [obj, mask]})).unwrap(); + s.execute("transparency.makeOpacityMask", &json!({})).unwrap(); + s.execute("transparency.editOpacityMask", &json!({})).unwrap(); + s.execute("shape.ellipse", &json!({"x": 250, "y": 250, "width": 40, "height": 40})).unwrap(); + assert!(s.doc().unwrap().doc.mask_edit.is_some()); + let nodes = |bytes: &[u8]| vectorcraft_pdf::import(bytes).unwrap().node_count(); + let plain = json!({"preserveEditing": false}); + let through_export = nodes(&b64(&s.execute("document.export", &as_export(&plain)).unwrap())); + assert_eq!(nodes(&b64(&export(&mut s, plain))), through_export, "the drawn page"); + let layers = |v: &Value| vectorcraft_format::load(&vectorcraft_pdf::editing(&b64(v)).unwrap().data).unwrap().layers; + let through_export = layers(&s.execute("document.export", &as_export(&json!({}))).unwrap()); + assert_eq!(layers(&export(&mut s, json!({}))), through_export, "the Preserve Editing data"); +} + #[test] fn bad_options_are_refused() { let mut s = session(2); diff --git a/crates/ui-egui/src/dialogs/export_as.rs b/crates/ui-egui/src/dialogs/export_as.rs index c0ed169e9b..2f14a83d19 100644 --- a/crates/ui-egui/src/dialogs/export_as.rs +++ b/crates/ui-egui/src/dialogs/export_as.rs @@ -2,7 +2,7 @@ //! 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), +//! Options, Text Export Options, DXF Options, EPS Options, and Save PDF), //! else the file is written. DWG is listed, greyed out, with what to use instead. The formats of //! installed exporter plug-ins come after the built-in ones: they write the whole document, and one //! with parameters asks for them before writing (`plugin::open_export`). @@ -169,8 +169,9 @@ fn options_or_write( } Ok(Value::Null) } - // Artboards as pages go on in Save PDF; the art's bounds are written straight away. + // Save PDF writes the artboards as pages, or one page of the art's bounds. "pdf" if use_artboards => save_pdf::open(app, &json!({ "path": path, "range": (!all).then_some(range) })), + "pdf" => save_pdf::open(app, &json!({ "path": path, "useArtboards": false })), "txt" => { text_export::open(app, &path); Ok(Value::Null) diff --git a/crates/ui-egui/src/dialogs/save_pdf.rs b/crates/ui-egui/src/dialogs/save_pdf.rs index e2f21c80cf..8e02bac956 100644 --- a/crates/ui-egui/src/dialogs/save_pdf.rs +++ b/crates/ui-egui/src/dialogs/save_pdf.rs @@ -5,6 +5,8 @@ //! plus `preset`, `range`, `path?` and UI-only `__` keys, so agents fill the dialog with //! `ui.dialog.set`. OK runs `document.exportPdf` through [`io::export_pdf`]; the Summary is //! `document.pdfSettings`. Save Preset… names the settings as a preset (`pdf.preset.save`). +//! Opened from Export As with Use Artboards off (`__artBounds`), the dialog has no Artboards +//! section, and OK writes one page of the visible art's bounds through [`io::export`]. //! //! The same dialog edits a preset (kind `pdfPreset`, from Edit → PDF Presets): a `name` and //! `description` instead of the preset and artboards, and OK saves the preset. @@ -45,6 +47,9 @@ pub(super) const PRESET_SPEC: DialogSpec = DialogSpec { const EDITING: &str = "__editing"; /// Save PDF's field holding the name typed for Save Preset… (present while that row shows). const SAVE_AS: &str = "__savePresetAs"; +/// Save PDF's field that is true when the page is the bounds of the visible art (Export As with +/// Use Artboards off) instead of the artboards. +const ART_BOUNDS: &str = "__artBounds"; pub(super) const SECTIONS: [&str; 7] = ["General", "Compression", "Marks and Bleeds", "Output", "Advanced", "Security", "Summary"]; @@ -76,7 +81,7 @@ pub(super) const LIST_WIDTH: f32 = 150.0; pub(super) const TOP_LABEL_WIDTH: f32 = LIST_WIDTH + 26.0; /// Open the dialog with `params` (`document.exportPdf` options and `path?`) applied over their -/// preset. +/// preset. `useArtboards: false` makes the page the bounds of the visible art. pub fn open(app: &mut VectorcraftApp, params: &Value) -> Result { let mut fields = settings_fields(app, params)?; let s = |k: &str| params.get(k).and_then(Value::as_str); @@ -88,6 +93,9 @@ pub fn open(app: &mut VectorcraftApp, params: &Value) -> Result { if let Some(path) = s("path") { fields.insert("path".into(), json!(path)); } + if params.get("useArtboards") == Some(&json!(false)) { + fields.insert(ART_BOUNDS.into(), json!(true)); + } app.ui.dialog = Some(Dialog { kind: KIND.into(), fields }); Ok(Value::Null) } @@ -117,10 +125,14 @@ fn settings_fields(app: &VectorcraftApp, params: &Value) -> Result Value { let mut p = form::params(d); - if (d.bool("__allArtboards") || d.str("range").trim().is_empty()) - && let Some(o) = p.as_object_mut() - { - o.remove("range"); + let art = d.bool(ART_BOUNDS); + if let Some(o) = p.as_object_mut() { + if art || d.bool("__allArtboards") || d.str("range").trim().is_empty() { + o.remove("range"); + } + if art { + o.insert("useArtboards".into(), json!(false)); + } } p } @@ -137,14 +149,24 @@ fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { } let r = match SaveMode::of(&d.str("__save")) { Some(mode) => io::save_pdf(app, mode, params(d)), + None if d.bool(ART_BOUNDS) => export_art(app, params(d)), None => io::export_pdf(app, params(d)), }; - if r.is_ok() { + // A question the write asked (replace the file the document was opened from?) stays open. + if r.is_ok() && app.ui.dialog.as_ref().is_some_and(|o| o.kind == KIND) { app.ui.dialog = None; } r } +/// A page of the art's bounds (Export As with Use Artboards off): written as Export As writes +/// other formats ([`io::export`]), with the dialog's options and a saved preset written out. +fn export_art(app: &mut VectorcraftApp, mut p: Value) -> Result { + let path = p.as_object_mut().and_then(|o| o.remove("path")).and_then(|v| v.as_str().map(str::to_string)); + let p = pdf::expand_preset(&app.session, "file.exportAs", &p).map_err(|e| e.to_string())?.into_owned(); + io::export(app, Some("pdf"), path, &p) +} + /// The settings as `pdf.preset.save` params (without the artboards and path). fn preset_params(d: &Dialog) -> Value { let mut p = form::params(d); @@ -490,6 +512,10 @@ fn general(ui: &mut egui::Ui, d: &mut Dialog, editor: bool) { } return; } + // A page of the art's bounds has no artboards to choose. + if d.bool(ART_BOUNDS) { + return; + } heading(ui, tl!("Artboards")); let mut all = d.bool("__allArtboards"); ui.horizontal(|ui| { diff --git a/crates/ui-egui/src/dialogs/save_pdf/tests.rs b/crates/ui-egui/src/dialogs/save_pdf/tests.rs index a6f4e9884c..a8129ee3a6 100644 --- a/crates/ui-egui/src/dialogs/save_pdf/tests.rs +++ b/crates/ui-egui/src/dialogs/save_pdf/tests.rs @@ -92,6 +92,72 @@ fn confirm_runs_export_pdf_with_the_dialog_options() { assert!(app.ui.status.starts_with("Saved /tmp/out.pdf"), "{}", app.ui.status); } +/// Opened for a page of the art's bounds (Export As with Use Artboards off), the dialog has no +/// Artboards section, and OK writes one page of the art's bounds with its options, a saved preset +/// and View PDF after Saving included. +#[test] +fn a_page_of_the_arts_bounds_has_no_artboards_section_and_takes_the_options() { + let (mut app, written, opened) = app(); + app.run("pdf.preset.save", json!({"name": "Mine", "compatibility": "1.5"})).unwrap(); + let ctx = egui::Context::default(); + ctx.enable_accesskit(); + theme::install_fonts(&ctx); + let has_range = |app: &mut VectorcraftApp| { + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| super::super::show(app, ui.ctx())); + out.textures_delta.clear(); + let nodes = &out.platform_output.accesskit_update.as_ref().expect("an accessibility tree").nodes; + nodes.iter().any(|(_, n)| n.label() == Some("Range:") && n.role() == egui::accesskit::Role::RadioButton) + }; + app.run("ui.savePdfDialog", json!({"path": "/tmp/artboards.pdf"})).unwrap(); + has_range(&mut app); + assert!(has_range(&mut app), "the artboards' dialog has the section"); + app.run("ui.savePdfDialog", json!({"path": "/tmp/art.pdf", "preset": "Mine", "useArtboards": false})).unwrap(); + assert!(app.ui.dialog.as_ref().unwrap().bool(ART_BOUNDS)); + has_range(&mut app); + assert!(!has_range(&mut app), "no artboards to choose"); + set_field(&mut app, "viewAfterSaving", json!(true)); + let r = super::super::confirm(&mut app).unwrap(); + assert!(app.ui.dialog.is_none(), "closes after saving"); + assert_eq!(r["path"], "/tmp/art.pdf"); + let w = written.borrow(); + assert_eq!(w.len(), 1); + assert!(w[0].1.starts_with(b"%PDF-1.5"), "the saved preset reaches the writer"); + let pdf = vectorcraft_pdf::import(&w[0].1).unwrap(); + assert_eq!(pdf.artboards.len(), 1); + let size = pdf.artboards[0].rect.size(); + assert!((50.0..52.0).contains(&size.width) && (40.0..42.0).contains(&size.height), "the rectangle's bounds with its stroke: {size:?}"); + assert_eq!(opened.borrow().len(), 1, "View PDF after Saving opens the file"); + assert!(opened.borrow()[0].0.ends_with("/tmp/art.pdf")); +} + +/// With Background Export on, a page of the art's bounds is written in the background, as Export +/// As writes its other formats. +#[test] +fn a_page_of_the_arts_bounds_is_written_in_the_background() { + let written = std::sync::Arc::new(std::sync::Mutex::new(Vec::<(String, Vec)>::new())); + let w = written.clone(); + let services = Services { + write_shared: Some(std::sync::Arc::new(move |p: &str, b: &[u8]| { + w.lock().unwrap().push((p.to_string(), b.to_vec())); + Ok(()) + })), + ..Default::default() + }; + let mut app = VectorcraftApp::new(Session::new(), services); + app.session.prefs.background_export = true; + app.run("file.new", json!({"width": 120, "height": 90})).unwrap(); + app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 50, "height": 40})).unwrap(); + app.run("ui.savePdfDialog", json!({"path": "/tmp/bg.pdf", "useArtboards": false})).unwrap(); + let r = super::super::confirm(&mut app).unwrap(); + assert_eq!(r["background"], true, "{r}"); + assert!(app.ui.dialog.is_none()); + crate::background::wait_all(&mut app); + let w = written.lock().unwrap(); + assert_eq!(w.len(), 1); + assert_eq!(w[0].0, "/tmp/bg.pdf"); + assert_eq!(vectorcraft_pdf::import(&w[0].1).unwrap().artboards.len(), 1); +} + #[test] fn warnings_reach_the_status_and_errors_keep_the_dialog_open() { let (mut app, written, opened) = app(); diff --git a/crates/ui-egui/src/dialogs/tests_export.rs b/crates/ui-egui/src/dialogs/tests_export.rs index 93bb5dec75..117e83277c 100644 --- a/crates/ui-egui/src/dialogs/tests_export.rs +++ b/crates/ui-egui/src/dialogs/tests_export.rs @@ -190,7 +190,7 @@ fn an_empty_export_as_range_shows_an_example() { } #[test] -fn svg_continues_in_svg_options_and_pdf_art_is_written_directly() { +fn svg_continues_in_svg_options() { let (mut app, written) = app(2); app.run("file.exportAs", json!({"format": "svg", "useArtboards": true})).unwrap(); confirm(&mut app).unwrap(); @@ -201,12 +201,35 @@ fn svg_continues_in_svg_options_and_pdf_art_is_written_directly() { confirm(&mut app).unwrap(); assert!(app.ui.dialog.is_none()); assert_eq!(names(&written), ["/out/Untitled-1-Artboard-1.svg", "/out/Untitled-1-Artboard-2.svg"]); - // Without artboards: one file of the art's bounds, with no options dialog for a PDF. +} + +/// Without Use Artboards, a PDF continues in Save PDF too, without its artboard choice, and the +/// file is one page of the art's bounds. Its options apply: a drawing 10 m long is written at +/// full size in points with "Write pages longer than 200 in at full size in points". +#[test] +fn pdf_art_continues_in_the_save_pdf_dialog() { + let (mut app, written) = app(2); app.run("file.exportAs", json!({"format": "pdf"})).unwrap(); - let r = confirm(&mut app).unwrap(); - assert_eq!(r["path"], "/out/Untitled-1.pdf"); - let pdf = vectorcraft_pdf::import(&written.borrow()[2].1).unwrap(); + confirm(&mut app).unwrap(); + assert_eq!(kind(&app), Some("savePdf"), "the PDF options follow"); + assert_eq!(app.ui.dialog.as_ref().unwrap().str("path"), "/out/Untitled-1.pdf"); + assert!(written.borrow().is_empty()); + frame(&mut app); + assert_eq!(confirm(&mut app).unwrap()["path"], "/out/Untitled-1.pdf"); + let pdf = vectorcraft_pdf::import(&written.borrow()[0].1).unwrap(); + assert_eq!(pdf.artboards.len(), 1); assert_eq!((pdf.artboards[0].rect.width().round(), pdf.artboards[0].rect.height().round()), (30.0, 20.0), "the ellipse's bounds"); + + app.run("shape.rectangle", json!({"x": 0, "y": 100, "width": 28_346.46, "height": 50})).unwrap(); + app.run("file.exportAs", json!({"format": "pdf"})).unwrap(); + confirm(&mut app).unwrap(); + set(&mut app, "longPagesInPoints", json!(true)); + frame(&mut app); + confirm(&mut app).unwrap(); + let bytes = written.borrow()[1].1.clone(); + assert!(!String::from_utf8_lossy(&bytes).contains("/UserUnit")); + let width = vectorcraft_pdf::import(&bytes).unwrap().artboards[0].rect.width(); + assert!((28_346.0..28_350.0).contains(&width), "{width}"); } #[test] @@ -330,8 +353,17 @@ fn the_enter_that_closed_the_save_panel_does_not_confirm_the_options() { let mut out = ctx.run_ui(egui::RawInput { events, ..Default::default() }, |ui| show(app, ui.ctx())); out.textures_delta.clear(); }; - // Each format whose options follow the panel (PDF's with Use Artboards: Save PDF). - let formats = [("png", false), ("svg", false), ("pdf", true), ("txt", false), ("dxf", false), ("eps", false), ("plugin.test.ui.held", false)]; + // Each format whose options follow the panel (PDF's: Save PDF, with Use Artboards on or off). + let formats = [ + ("png", false), + ("svg", false), + ("pdf", true), + ("pdf", false), + ("txt", false), + ("dxf", false), + ("eps", false), + ("plugin.test.ui.held", false), + ]; for (format, artboards) in formats { let (mut app, written) = app(1); app.run("plugin.install", json!({"dataBase64": vectorcraft_format::base64_encode(&module)})).unwrap(); diff --git a/crates/ui-egui/src/io/export.rs b/crates/ui-egui/src/io/export.rs index 85ef8ffa0c..c1e6d1dae3 100644 --- a/crates/ui-egui/src/io/export.rs +++ b/crates/ui-egui/src/io/export.rs @@ -62,6 +62,9 @@ pub fn export(app: &mut VectorcraftApp, format: Option<&str>, path: Option, path: Option exported_with_notes(&what, warnings.len(), first), None => format!("Exported {what}"), }; + if view && let Some(path) = r["path"].as_str() { + view_file(app, path); + } show_with_notes(app, status, warnings); Ok(r) })?; @@ -192,8 +198,14 @@ pub(crate) fn run_to_file(app: &mut VectorcraftApp, id: &str, ext: &str, mut par /// File → Save as PDF: `document.exportPdf` with `params` (the Save PDF dialog's options), written /// to `path` (else a picked or suggested name) → `{path, bytes, warnings}`. With /// `viewAfterSaving`, the written file opens in the system viewer (not on the web, which -/// downloads it). +/// downloads it). Writing over the file the document was opened from, when opening it left things +/// out, asks first and runs `file.export.pdf` again on OK. pub fn export_pdf(app: &mut VectorcraftApp, params: Value) -> Result { + if let Some(path) = params.get("path").and_then(Value::as_str) + && let Some(r) = ask_before_losing(app, path, "file.export.pdf", ¶ms) + { + return Ok(r); + } let view = params.get("viewAfterSaving").and_then(Value::as_bool).unwrap_or(false); let (path, mut v) = run_to_file(app, "document.exportPdf", "pdf", params)?; if view { diff --git a/crates/ui-egui/src/menus/ui_commands.rs b/crates/ui-egui/src/menus/ui_commands.rs index 390f168659..8510ac1acf 100644 --- a/crates/ui-egui/src/menus/ui_commands.rs +++ b/crates/ui-egui/src/menus/ui_commands.rs @@ -1243,7 +1243,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "ui.savePdfDialog", "Save PDF Dialog", "", - "{path?, preset?, range?, …document.exportPdf options} open the Save PDF dialog with these options (fields = the options, sections are objects; OK writes path, else asks)", + "{path?, preset?, range?, useArtboards?, …document.exportPdf options} open the Save PDF dialog with these options (fields = the options, sections are objects; OK writes path, else asks); useArtboards: false: one page of the visible art's bounds, with no Artboards section", ), ( "file.exportAs", diff --git a/crates/ui-egui/src/tests/save.rs b/crates/ui-egui/src/tests/save.rs index 458511043e..64d4c961be 100644 --- a/crates/ui-egui/src/tests/save.rs +++ b/crates/ui-egui/src/tests/save.rs @@ -283,6 +283,16 @@ fn replacing_a_file_that_opening_left_things_out_of_asks_first() { assert_eq!(app.run("file.exportAs", json!({"path": path, "format": "svg"})).unwrap()["pending"], dialogs::confirm::KIND); app.ui.dialog = None; assert_eq!(std::fs::read(&path).unwrap(), saved); + // So does a PDF written by Save PDF's OK, and the question stays open when Save PDF closes. + assert_eq!(app.run("file.export.pdf", json!({"path": path})).unwrap()["pending"], dialogs::confirm::KIND); + app.ui.dialog = None; + for artboards in [true, false] { + app.run("ui.savePdfDialog", json!({"path": path, "useArtboards": artboards})).unwrap(); + dialogs::confirm(&mut app).unwrap(); + assert_eq!(app.ui.dialog.as_ref().map(|d| d.kind.as_str()), Some(dialogs::confirm::KIND), "useArtboards {artboards}"); + app.ui.dialog = None; + } + assert_eq!(std::fs::read(&path).unwrap(), saved); // Another name doesn't ask. let copy = d.join("b.svg").to_string_lossy().to_string(); assert!(app.run("file.exportAs", json!({"path": copy, "format": "svg"})).unwrap().get("pending").is_none()); diff --git a/docs/control-protocol.md b/docs/control-protocol.md index d1f1fce598..200c8184b9 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -110,7 +110,7 @@ the two can still win that race. | `app.open` / `app.save` / `app.export` / `app.quit` | `{path, importer?, params?}` / `{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. The status bar (`ui.inspect` → `ui.status`) counts the `warnings` and names the first that says reading the file left something out (the notes that make Save ask before writing over it), else the first; with several, hovering it lists them in that order, as many as fit in the window (at most 30), then how many more. After `app.save`, and `app.export` with a `path`, the status bar counts the reply's `warnings` and names the first, and with several, hovering it lists them in the reply's order (a save's notes start with what the format loses). With `importer` (an importer plug-in's format id, `plugin.`) it runs `file.openWith`: the file opens with that importer, also when a built-in format reads its extension, with the importer's parameters `params` (their defaults when omitted; no dialog asks for them). Without `importer`, `params` go to the importer that opens the file, and a built-in format does not read them. `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.save` refuses a key that no save format takes, beside the path or in `options`, the same way. `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 | +| `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). `useArtboards: false` opens it for one page of the visible art's bounds, as Export As with Use Artboards off does: the dialog has no Artboards section and its `__artBounds` field is true. `ui.dialog.confirm` writes `path` (else asks for one), returns `{path, bytes, warnings}` and opens the file when `viewAfterSaving` is set | | `file.save` / `file.saveAs` / `file.saveCopy` / `file.saveAsTemplate` (via `engine.execute`) | `{path?, format?, options?, svg?}` | Save writes the document's own file in its own format (a document opened from or saved as SVG/PDF saves as that again; the result's `warnings` say what the format loses). With `path` the file is written at once (Save As or a Copy to an SVG path without SVG options first opens SVG Options, below). Without one (never saved, a converted older file, Save As, a Copy, a Template) the app shows a save panel with one file type per save format (`vectorcraft`, `template`, `pdf`, `svg`, `svgz`, `ai`; for a Template, `template` and `ait`), then the format's options: SVG opens the `svgOptions` dialog (below; returns `{"pending": "svgOptions", "path"}`), PDF the `savePdf` dialog (its `__save` field names the save command); `ui.dialog.confirm` finishes the save. A name typed in the panel without the file type's extension gets it, and a name the panel returns with the extension of a format the panel does not list has it replaced (in a native save, `card.png` and `card.ait` are saved as `card.vectorcraft`, and `notes.v2`, whose extension names no format, is saved as `notes.v2.vectorcraft`); when a file of that name exists, which the panel did not ask about, the save (and every export or other write through a save panel) answers `cancelled` and opens a `replaceFile` dialog (fields `message`, `detail`, `path`): `ui.dialog.confirm` writes that file, `ui.dialog.cancel` writes nothing. A save or export that writes other files beside that path (a file per artboard, linked images, image maps) checks them before it writes anything: when any of them exists, it writes nothing and opens a `replaceFiles` dialog (fields `message`, `detail` (each file's name when there are several), `files`, `__command`, `__params`; the reply is `{"pending": "replaceFiles"}`, or comes once a Background Save or Export ends): `ui.dialog.confirm` runs the save or export again and replaces them, `ui.dialog.cancel` writes nothing; a dialog open when a background job asks stays open, and the question follows it. A save does not ask about the files the document's last Save or Save As in this session wrote. On the web a `saveOptions` dialog (fields `path`, `format`) first names the file and picks the format. `file.formatOptions {format?}` reads the options a save would use. `file.revert` opens a `confirm` dialog (`{"pending": "confirm"}`); `ui.dialog.confirm` reloads the saved file in the same tab. Location refusals return `READ_ONLY_LOCATION: ` without a fallback panel for automation; see [Save location errors](#save-location-errors). | | SVG Options (`engine.execute` of `file.export.svg`, `file.saveAs`, `file.saveCopy`) | `{}` / `{path?, svg?: {…}}` | `file.export.svg` without params, and Save As or Save a Copy to an `.svg`/`.svgz` path without SVG options, open the `svgOptions` dialog (`ui.dialog.set` its fields: `styling`, `outlineText`, `images`, `objectIds`, `decimals`, `minify`, `responsive`, `fewerTspans`, `metadata`, `preserveEditing`, `useArtboards`, `allArtboards`, `range`, `showCode`, `profile` (`svg11`/`tiny12`), `encoding` (`utf8`/`utf16`/`latin1`), `embedFonts`; `ui.dialog.confirm` exports or saves and remembers the choices). With `svg: {…}` (the `document.formats` SVG options) they run directly. `app.save {path?, svg?}` takes the same options; a document saved as SVG saves as SVG again with the same options | | `engine.execute` `file.place` | `{paths?}` / `{path \| name+dataBase64, …file.place params}` | with no file, picks files and opens the `place` dialog; with `paths`, opens it for them (fields `files`, `link`, `template`, `replace`; `__replace` says whether Replace applies, `__info` describes each file). `ui.dialog.confirm` places one file centered in the view (or in place of the selected object) and loads the place cursor with several (`file.place.queue`). In the desktop app the dialog reads its files off the UI thread: `__info` is empty until they are read, `ui.dialog.confirm` returns `{background: true, status}`, `ui.inspect` lists the files still being read in `placing`, and the file is placed (or the cursor loaded) once it is read. With a file, it runs `file.place` centered in the view, at once. Files dropped on the window with no document open open. On the web, files dropped on the canvas are placed at the drop point (linked; Shift embeds) and files dropped off it (the tab bar) open, as in Illustrator; desktop drops carry no pointer position, so documents (native, SVG, PDF, `.ai`, EPS, DXF, EMF/WMF) open as tabs of their own and pictures and text are placed in the middle of the view. Swatch libraries and color books (`.vcswatches`, `.gpl`, `.ase`, `.acb`), graphic style libraries (`.vcstyles`), Libraries panel files (`.vclibrary`), presets (`.vcflattener`, `.vcpdfpresets`, `.vcprintpresets`, `.vcperspective`, `.vcexportpresets`), plug-ins (`.wasm`) and shortcut sets (`.kys`, in Import Shortcut Set) open wherever they are dropped, as File › Open opens them. With the cursor loaded (`ui.inspect` → `tool: "place"`, `toolOptions: {count, current, name, names}`), `ui.pointer` clicks and drags place files (in the desktop app each file is read off the UI thread and placed once it is read, and `ui.inspect` lists it in `placing` until then) and `ui.key` ←/→/↑/↓ and Escape cycle and discard them | diff --git a/docs/mcp.md b/docs/mcp.md index 62a4212472..568a327cef 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -884,7 +884,9 @@ Export As goes through `options` too: for PNG, JPEG and WebP `useArtboards: true or `range`), named `-.`, and returns `{path, files}` (a PDF keeps them as pages of one file; an SVG writes one file per artboard of a `range`); `useArtboards: false` exports the bounds of the visible art with what its effects paint, the effects of groups, type and images included. `artboard.fitToArt` measures the art the same -way. +way. `document.exportPdf {useArtboards: false}` writes that one page as well, and ignores `artboard`, `artboards` and +`range`. Export As PDF continues in the Save PDF dialog with Use Artboards on or off; with it off, the dialog has no +Artboards section and the page is the bounds of the visible art. Raster exports default to the document's raster effects resolution. An explicit `scale` sets pixels per point (72 times that value in ppi); `options.ppi` wins over either scale option. CLI `run --export` and diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 5ec97e34c1..3d46e13f41 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1513,7 +1513,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur In a `.ai` opened from its editing data, an envelope made with a warp, a mesh or a top object opens as a live envelope of its contents, with its warp style, bend and distortions, its mesh points and handles or its top object's outline, its Envelope Options, its Fidelity and whether its contents are being edited. When Distort Appearance is on and the size the file saves for the contents matches their bounds with strokes, the contents map from that stroked box, until an edit in Edit Contents changes how far the strokes reach; VectorCraft's new envelopes map from the geometric box, as do contents with no width or no height, such as a single straight line. An envelope that can't be read, or that VectorCraft draws differently from the file's drawn art, keeps that drawn art, with a note. An envelope of type opens live, its type laid out from the text document as its contents, where VectorCraft's own envelope of that type draws as the drawn art the file saves (compared as for the other envelopes, seen on white and on black, so white type counts, and only where the layers with it are compared with the page and draw as it, the envelope weighed as the art it draws); elsewhere, and where the type's font isn't available, a text or visibility variable binds the type, or the envelope is in a symbol's or a pattern's art, it keeps its drawn art, with its type hidden and a note that says the type isn't editable (#3179; `crates/eps/src/import/ai/tests.rs`, `crates/eps/src/import/tests_native.rs`). A warp of type whose own warp, VectorCraft's, doesn't draw as its drawn art is tried again as a mesh envelope on the grid the file saves for it: its type stays editable and keeps the file's shape, but the warp's settings (style, bend) are lost, as the document has no place for them on a mesh. Type with strokes in an envelope with Distort Appearance on is also tried mapped from its glyphs grown by as far as its strokes paint along a side (half the weight of a centred stroke), without what miter joins add at corners, where the size the file saves for it doesn't already give that box; the drawn-art check decides which one opens. An envelope's grid reads its way round from all its rows and columns, so a mesh bent far round reads the way it runs. While an envelope's contents are edited, its mesh is kept in its dictionary, and in some files that mesh lies away from the drawn art by a move, the contents with the drawn art (as if the mesh hadn't followed the envelope when it moved): a mesh or top object saved in Edit Contents whose mesh draws the contents as large as the drawn art but elsewhere is tried again, after every other way, with its mesh moved onto that art, and opens there where it then draws as it. The drawn art of these files strokes type in an envelope along its bent outlines at the stroke's own weight, Distort Appearance on or off, and VectorCraft draws it so (#6644): an envelope of stroked type whose grid squeezes or stretches it opens live where it draws as that art. In a `.ai` opened from its editing data, Convert to Shape on a whole path or type object opens as a live Rectangle, Rounded Rectangle or Ellipse effect with its Absolute or Relative size, its corner radius, its place among the object's effects and whether it is hidden. A relative size in the file is added to each side. A relative Ellipse, or a setting VectorCraft doesn't read, keeps the drawn art (`crates/eps/src/import/ai/style/tests.rs`, `crates/eps/src/import/ai/tests.rs`, `crates/eps/src/import/tests_native.rs`). In a `.ai` opened from its editing data, Bas Relief in an art style takes its Detail, Smoothness and Light direction from the style's settings block, and an object other than type with it opens as itself with the live effect. A settings block that VectorCraft can't read (malformed, another effect's, a value out of range or a key it doesn't know) keeps the object's drawn look (part of #637; [raster effects reference](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/apply-filter-effects/raster-effects-reference.html), [Photoshop File Formats Specification](https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/), [scripting the filter gallery](https://community.adobe.com/questions-712/scripting-support-for-the-filter-gallery-1064646); `crates/eps/src/import/ai/style/tests.rs`, `crates/eps/src/import/ai/tests.rs`). - PDF export and `.ai` saves write a page with a side longer than 14,400 pt with a `UserUnit` (PDF 1.6), its longer side divided by 14,400 and rounded up to the next thousandth, so that no side is longer than 14,400 units (ISO 32000-1, Annex C.2); a PDF 1.5 file with such a page is written as PDF 1.6 with PDF 1.5's encryption, PDF/X files and PDF 1.3 and 1.4 files write it in points (in PDF 1.3 and 1.4, a page past 32,767 pt that needs a real number past 32,767 is refused with an error naming it), a page whose short side is 3 pt or longer but would be under 3 units at that `UserUnit` gets a smaller one that keeps the short side at 3 units, and the export's warnings give each such page's size and how it is written (#6382). The Save PDF dialog's "Write pages longer than 200 in at full size in points" (`longPagesInPoints`, off by default) writes such pages in points without a `UserUnit` at every PDF version, in files without a PDF/X or PDF/A standard. + PDF export and `.ai` saves write a page with a side longer than 14,400 pt with a `UserUnit` (PDF 1.6), its longer side divided by 14,400 and rounded up to the next thousandth, so that no side is longer than 14,400 units (ISO 32000-1, Annex C.2); a PDF 1.5 file with such a page is written as PDF 1.6 with PDF 1.5's encryption, PDF/X files and PDF 1.3 and 1.4 files write it in points (in PDF 1.3 and 1.4, a page past 32,767 pt that needs a real number past 32,767 is refused with an error naming it), a page whose short side is 3 pt or longer but would be under 3 units at that `UserUnit` gets a smaller one that keeps the short side at 3 units, and the export's warnings give each such page's size and how it is written (#6382). The Save PDF dialog's "Write pages longer than 200 in at full size in points" (`longPagesInPoints`, off by default) writes such pages in points without a `UserUnit` at every PDF version, in files without a PDF/X or PDF/A standard. Export As PDF with Use Artboards off also opens the Save PDF dialog, without its Artboards section, and writes one page of the visible art's bounds with the dialog's options (`document.exportPdf {useArtboards: false}`; `crates/ui-egui/src/dialogs/tests_export.rs`, `crates/engine/src/cmd/fileio/tests_pdf.rs`). PDF export warns when a PDF/X file has no bleed on a side where the document's bleed has one, and writes the file as asked. A PDF/A-2b export at PDF 1.4 with a page longer than 14,400 pt is written as PDF 1.6 with a `UserUnit` and a warning, whether or not `longPagesInPoints` is on, so its page boundaries stay within PDF/A-2's limit of 14,400 units (ISO 19005-2, 6.1.13) unless the page is too narrow for that (`crates/pdf/src/tests/marks.rs`). PDF/X-1a, PDF/X-3 and PDF 1.3 files keep the art under the hidden part of a picture, gradient, pattern, mesh or raster effect that a clipping mask or clipping layer crops: flattening rasterizes only the part of such art that its clipping path shows, on its own page and on the next one. Layers the file leaves out are left out of the flattening, and transparent art that a clipping path hides entirely is removed (`crates/engine/src/cmd/fileio/tests_pdfx.rs`).