mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-26 10:47:11 -06:00
M4.82: PDF export writes top-level layers as PDF layers
Create PDF layers from top-level layers (document.exportPdf createLayers, the Save PDF dialog's General option) now writes an optional content group per top-level layer (template layers are left out): named as the layer, off when the layer is hidden, /PrintState /OFF when its Print option is off, locked when it is locked, listed top first, with a print auto-state (not under a standard). The file reopens with those layers. The PDF writer has no optional content, so each layer is drawn as a form of its own (no transparency group, so it draws as in place) starting with a mark; after writing, the uses of the layers' forms are wrapped in /OC ... BDC/EMC and the catalog gets /OCProperties (crates/pdf/src/forms.rs, through the shared patch writer and the PDF syntax reader of encrypt.rs). PDF 1.4 and a knockout page group write plain page content with a warning; the dialog greys the option at 1.4. The "not written yet" warning is gone. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
d7e9f2a47a
commit
0e4d3f6871
@@ -908,3 +908,6 @@ mod tests_psd;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests_epsimport;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests_pdflayers;
|
||||
|
||||
@@ -76,6 +76,12 @@ pub(super) const OPTIONS: &[FormatOption] = &[
|
||||
default: "false",
|
||||
description: "keep the layers whose Print option is off (left out otherwise, unless createLayers)",
|
||||
},
|
||||
FormatOption {
|
||||
name: "createLayers",
|
||||
ty: "boolean",
|
||||
default: "false",
|
||||
description: "top-level layers as PDF layers (optional content groups with their visibility, print state and lock; PDF 1.5 or later)",
|
||||
},
|
||||
];
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
@@ -85,7 +91,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"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 the High Resolution flattener preset; 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.4|1.5|1.6|1.7 (default)|2.0 (choosing a standard sets the latest it allows: 1.7 for pdfA2b, 1.6 for pdfX4, 1.4 for pdfX1a and pdfX3, written as PDF 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?, fastWebView?, viewAfterSaving? (the app opens the written file), createLayers? (choosing pdfX1a or pdfX3 turns it off; they refuse it), includeNonPrinting? (keep layers whose Print option is off; left out by default unless createLayers), 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 always), 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: true (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), overprint: preserve|discard}, security?: {openPassword, permissionsPassword, printing: none|low|high, changes: none|pages|forms|comments|any, copy, screenReader, plaintextMetadata}} → {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). 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 the High Resolution flattener preset; 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.4|1.5|1.6|1.7 (default)|2.0 (choosing a standard sets the latest it allows: 1.7 for pdfA2b, 1.6 for pdfX4, 1.4 for pdfX1a and pdfX3, written as PDF 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?, fastWebView?, viewAfterSaving? (the app opens the written file), createLayers? (each top-level layer but template layers as a PDF layer, an optional content group: 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), 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 always), 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: true (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), overprint: preserve|discard}, security?: {openPassword, permissionsPassword, printing: none|low|high, changes: none|pages|forms|comments|any, copy, screenReader, plaintextMetadata}} → {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). document.export {format: pdf} takes the same options",
|
||||
has_doc,
|
||||
export_pdf
|
||||
),
|
||||
|
||||
@@ -156,7 +156,7 @@ fn pdf_settings_summarise_changes_and_warnings() {
|
||||
let changed: Vec<&str> = v["changed"].as_array().unwrap().iter().map(|c| c["option"].as_str().unwrap()).collect();
|
||||
assert_eq!(changed, ["compatibility", "compression.compressText", "createLayers", "security.copy"], "key order");
|
||||
assert_eq!(v["changed"][0]["value"], "1.5");
|
||||
assert_eq!(v["warnings"].as_array().unwrap().len(), 2, "layers and permissions: {}", v["warnings"]);
|
||||
assert_eq!(v["warnings"].as_array().unwrap().len(), 1, "permissions (PDF layers are written at 1.5): {}", v["warnings"]);
|
||||
assert!(!v.to_string().contains("Password"), "passwords never come back");
|
||||
assert!(s.execute("document.pdfSettings", &json!({"compatibility": "9"})).is_err());
|
||||
// With the document: the in-memory export adds the document's own warnings.
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
//! `document.exportPdf {createLayers}` writes PDF layers that reopen as layers.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::*;
|
||||
|
||||
fn b64(v: &Value) -> Vec<u8> {
|
||||
vectorcraft_format::base64_decode(v["dataBase64"].as_str().expect("dataBase64")).unwrap()
|
||||
}
|
||||
|
||||
fn warnings(v: &Value) -> Vec<String> {
|
||||
v["warnings"].as_array().expect("warnings").iter().map(|w| w.as_str().unwrap().to_string()).collect()
|
||||
}
|
||||
|
||||
/// A document with a rectangle on "Layer 1" and one on a hidden, non-printing layer "Notes".
|
||||
fn session() -> Session {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 100, "height": 80})).unwrap();
|
||||
s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 20, "height": 20})).unwrap();
|
||||
let notes = s.execute("layer.new", &json!({"name": "Notes"})).unwrap()["id"].as_u64().unwrap();
|
||||
s.execute("shape.rectangle", &json!({"x": 50, "y": 10, "width": 20, "height": 20})).unwrap();
|
||||
s.execute("layer.setProps", &json!({"id": notes, "visible": false, "printable": false})).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn export_pdf_create_layers_writes_layers_that_reopen_as_layers() {
|
||||
let mut s = session();
|
||||
let v = s.execute("document.exportPdf", &json!({"createLayers": true, "preserveEditing": false})).unwrap();
|
||||
assert!(warnings(&v).is_empty(), "{:?}", warnings(&v));
|
||||
let back = vectorcraft_pdf::import(&b64(&v)).unwrap();
|
||||
let layers: Vec<(String, bool, bool)> = back
|
||||
.layers
|
||||
.iter()
|
||||
.map(|l| (l.name.clone().unwrap_or_default(), l.visible, matches!(l.kind, vectorcraft_doc::NodeKind::Layer { printable: true, .. })))
|
||||
.collect();
|
||||
assert_eq!(layers, [("Layer 1".to_string(), true, true), ("Notes".to_string(), false, false)]);
|
||||
// The default preset carries the editing data too.
|
||||
let v = s.execute("document.exportPdf", &json!({"createLayers": true})).unwrap();
|
||||
assert!(warnings(&v).is_empty(), "{:?}", warnings(&v));
|
||||
assert!(vectorcraft_pdf::editing(&b64(&v)).is_some_and(|e| e.intact));
|
||||
// At PDF 1.4 there are no PDF layers.
|
||||
let v = s.execute("document.exportPdf", &json!({"createLayers": true, "compatibility": "1.4"})).unwrap();
|
||||
assert!(warnings(&v).iter().any(|w| w.contains("PDF 1.5")), "{:?}", warnings(&v));
|
||||
}
|
||||
Reference in New Issue
Block a user