From 0721522ad065a2925f3b99c62e9816e38cefdfbf Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 12 Nov 2026 11:45:26 -0800 Subject: [PATCH] PDF export: a page of the art's bounds carries no editing data With Use Artboards off, the Preserve Editing data held a copy of the document whose artboards were replaced by the one around the art, so reopening the PDF lost the document's artboards. document.exportPdf, document.export and the Summary's export in memory now leave the data out of such a PDF and warn, as a PDF of some artboards does. --- crates/engine/src/cmd/fileio/encode.rs | 7 +++++ crates/engine/src/cmd/fileio/pdf.rs | 31 +++++++++++++++++++---- crates/engine/src/cmd/fileio/tests_pdf.rs | 29 +++++++++++++++++++++ docs/mcp.md | 3 ++- 4 files changed, 64 insertions(+), 6 deletions(-) diff --git a/crates/engine/src/cmd/fileio/encode.rs b/crates/engine/src/cmd/fileio/encode.rs index 8ee3da70e5..a343ab7ba1 100644 --- a/crates/engine/src/cmd/fileio/encode.rs +++ b/crates/engine/src/cmd/fileio/encode.rs @@ -529,6 +529,13 @@ fn encode_files(doc: &Document, f: &Format, p: &Value) -> Result { } return encode_files(&single_artboard(doc, rect, "Rect"), f, &q); } + // A PDF of the art's bounds carries no editing data (it would replace the artboards). + if use_artboards == Some(false) + && f.id == "pdf" + && let Some((bytes, warnings)) = super::pdf::encode_art_page(C, doc, p)? + { + return Ok(Encoded { warnings, ..Encoded::one(bytes) }); + } if use_artboards == Some(false) && let Some((art, q)) = art_page(C, doc, p)? { diff --git a/crates/engine/src/cmd/fileio/pdf.rs b/crates/engine/src/cmd/fileio/pdf.rs index 434834ecc5..0c07eeb14f 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), 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", + "{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, Preserve Editing is left out with a warning, 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 ), @@ -280,6 +280,27 @@ fn options_at(cmd: &str, doc: &Document, p: &Value, now: Option) -> Result< pub const EDITING_NEEDS_EVERY_ARTBOARD: &str = "Preserve editing was left out: it needs a PDF of every artboard, in order (reopened, this one would show the others too)"; +/// Why a PDF of the art's bounds (`useArtboards: false`) carries no editing data. +pub const EDITING_NEEDS_THE_ARTBOARDS: &str = + "Preserve editing was left out: it needs a PDF of the artboards (reopened, this one would replace them with one artboard around the art)"; + +/// With `useArtboards: false` in `p`: the PDF of one page, the bounds of `doc`'s visible art +/// ([`super::encode::art_page`]) → (bytes, warnings). It carries no editing data: with Preserve +/// Editing on, the warnings end with [`EDITING_NEEDS_THE_ARTBOARDS`]. `None` when `p` asks for the +/// artboards. +pub(super) fn encode_art_page(cmd: &str, doc: &Document, p: &Value) -> Result, Vec)>> { + let Some((art, mut q)) = super::encode::art_page(cmd, doc, p)? else { return Ok(None) }; + let left_out = settings(cmd, &q)?.preserve_editing; + if left_out && let Some(o) = q.as_object_mut() { + o.insert("preserveEditing".into(), json!(false)); + } + let (bytes, mut warnings) = encode(cmd, &art, &q)?; + if left_out { + warnings.push(EDITING_NEEDS_THE_ARTBOARDS.to_string()); + } + Ok(Some((bytes, warnings))) +} + /// The warning for a PDF/X file that leaves out the document's bleed /// ([`vectorcraft_pdf::PdfSettings::leaves_out_bleed`]). pub const BLEED_LEFT_OUT: &str = "the document sets a bleed that this PDF/X file leaves out: turn on Use document bleed settings"; @@ -481,8 +502,8 @@ fn export_pdf(s: &mut Session, p: &Value) -> Result { } // 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)?, + let (bytes, warnings) = match encode_art_page(C, &doc, &q)? { + Some(encoded) => encoded, None => encode(C, &doc, &q)?, }; write_or_return(str_param(p, "path"), &bytes, json!({ "warnings": warnings })) @@ -521,8 +542,8 @@ fn pdf_settings(s: &mut Session, p: &Value) -> Result { 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, + match encode_art_page(Q, &doc, &q)? { + Some((_, warnings)) => warnings, None => encode(Q, &doc, &q)?.1, } } diff --git a/crates/engine/src/cmd/fileio/tests_pdf.rs b/crates/engine/src/cmd/fileio/tests_pdf.rs index 0fced29f55..91fe9701e7 100644 --- a/crates/engine/src/cmd/fileio/tests_pdf.rs +++ b/crates/engine/src/cmd/fileio/tests_pdf.rs @@ -103,6 +103,35 @@ fn use_artboards_false_writes_one_page_of_the_arts_bounds() { } } +/// A PDF of the art's bounds carries no Preserve Editing data, through `document.exportPdf`, +/// `document.export` and the Summary's export in memory, and says so in a warning: reopened, its +/// editing data would replace the document's artboards with the one around the art. A PDF of the +/// artboards keeps the data, and it reopens with every artboard. With Preserve Editing off, there +/// is no warning. +#[test] +fn a_pdf_of_the_arts_bounds_carries_no_editing_data() { + let mut s = session(3); + let left_out = |v: &Value| warnings(v).iter().any(|w| w == super::pdf::EDITING_NEEDS_THE_ARTBOARDS); + let art = json!({"useArtboards": false}); + for v in [export(&mut s, art.clone()), s.execute("document.export", &as_export(&art)).unwrap()] { + let pdf = b64(&v); + assert!(vectorcraft_pdf::editing(&pdf).is_none(), "{:?}", warnings(&v)); + assert!(left_out(&v), "{:?}", warnings(&v)); + assert_eq!(pages(&pdf), 1); + } + let summary = s.execute("document.pdfSettings", &json!({"includeDocument": true, "useArtboards": false})).unwrap(); + assert!(left_out(&summary), "{summary}"); + let quiet = export(&mut s, json!({"useArtboards": false, "preserveEditing": false})); + assert!(!left_out(&quiet), "{:?}", warnings(&quiet)); + + for v in [export(&mut s, json!({})), export(&mut s, json!({"useArtboards": true}))] { + let editing = vectorcraft_pdf::editing(&b64(&v)).expect("editing data"); + let reopened = vectorcraft_format::load(&editing.data).unwrap(); + assert_eq!(reopened.artboards.len(), 3); + assert!(!left_out(&v), "{:?}", warnings(&v)); + } +} + /// 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. diff --git a/docs/mcp.md b/docs/mcp.md index 568a327cef..fbe5e9ae89 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -885,7 +885,8 @@ or `range`), named `-.`, and returns `{path, files}` (a PDF 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. `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 +`range`. A PDF of that page, from either command, carries no Preserve Editing data and comes back with a warning. +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