From 0e4d3f687138f22d28d2586e9885676e8dfd2bca Mon Sep 17 00:00:00 2001 From: bflatastic Date: Mon, 5 Oct 2026 17:18:13 +0700 Subject: [PATCH] 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 --- ROADMAP.md | 4 +- crates/engine/src/cmd/fileio/mod.rs | 3 + crates/engine/src/cmd/fileio/pdf.rs | 8 +- crates/engine/src/cmd/fileio/tests_pdf.rs | 2 +- .../engine/src/cmd/fileio/tests_pdflayers.rs | 45 +++ crates/pdf/src/encrypt.rs | 63 ++-- crates/pdf/src/export.rs | 27 ++ crates/pdf/src/forms.rs | 287 ++++++++++++++++++ crates/pdf/src/lib.rs | 5 + crates/pdf/src/output.rs | 4 +- crates/pdf/src/patch.rs | 7 +- crates/pdf/src/settings.rs | 14 +- crates/pdf/src/tests_layers.rs | 217 +++++++++++++ crates/pdf/src/tests_settings.rs | 3 +- crates/ui-egui/src/dialogs/save_pdf.rs | 23 +- docs/mcp.md | 3 + 16 files changed, 679 insertions(+), 36 deletions(-) create mode 100644 crates/engine/src/cmd/fileio/tests_pdflayers.rs create mode 100644 crates/pdf/src/forms.rs create mode 100644 crates/pdf/src/tests_layers.rs diff --git a/ROADMAP.md b/ROADMAP.md index 5018b128..f1f8be2a 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -140,7 +140,7 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist | M1 | Selection, transform, layers, MCP | ✅ mostly done (transform reference point snaps to anchors/centres; rotated persistent bbox pending) | 4–6 | | M2 | Drawing tools + smart guides | ✅ mostly done (Flare, Reshape, Live Corners widget dragging landed; Shaper, Pen modifier nuances) | 15–20 | | M3 | Paint & appearance (swatches, color, gradient, stroke, appearance, transparency, styles) | ✅ done (M3.7–M3.98): swatches, groups, libraries, tints, Lab spots and Registration; Color Picker, Color panel, Color Guide, Color Themes, Edit Colors and Recolor Artwork; gradients that follow every transform, the annotator, linked stops, freeform gradients, focal points, gradients on strokes and Expand; stroke geometry, arrowheads, dashes fitted to corners, stroke on type, width profiles and the Width tool, Scale Strokes and preview bounds; Appearance targeting, container appearance, target circles, Expand Appearance; knockout, isolation, blend accuracy, CMYK blending, overprint, Attributes, masks and clipping, Flatten Transparency; graphic styles, links and libraries. Left: freeform and mixed spot/process gradients export as stops or process colours to SVG/PDF; flattener presets not yet used by PDF/print; confirm the Scale Strokes & Effects default | 4–8 | -| M4 | Files (native, SVG, PDF, raster, Export for Screens, clipboard interop) | ✅ mostly done (M4.14–M4.98 on 2026-10-05): Save As/Save a Copy/Revert/templates, Data Recovery and background save, SVG import and export fidelity (units, text, symbols, filters, SVGZ, Preserve Editing, SVG Options), PDF import (colours, layers, masks, editable text, security) and export (presets, marks, bleed, ICC and output intent, raster effects, subset fonts, PDF/X-1a, X-3 and X-4), Place and Links, Package, EPS and DXF in and out, EMF/WMF, TIFF/BMP/Targa/PSD/GIF/PNG-8 export, Print and print presets, slices, Save for Web, Asset Export, clipboard flavours, File Info. Left: DWG (use DXF), PSD placement as layers, polish | 6–10 | +| M4 | Files (native, SVG, PDF, raster, Export for Screens, clipboard interop) | ✅ mostly done (M4.14–M4.98 on 2026-10-05): Save As/Save a Copy/Revert/templates, Data Recovery and background save, SVG import and export fidelity (units, text, symbols, filters, SVGZ, Preserve Editing, SVG Options), PDF import (colours, layers, masks, editable text, security) and export (presets, marks, bleed, ICC and output intent, raster effects, subset fonts, PDF/X-1a, X-3 and X-4, PDF layers), Place and Links, Package, EPS and DXF in and out, EMF/WMF, TIFF/BMP/Targa/PSD/GIF/PNG-8 export, Print and print presets, slices, Save for Web, Asset Export, clipboard flavours, File Info. Left: DWG (use DXF), PSD placement as layers, polish | 6–10 | | M5 | Performance | 🟡 background render + caches + MT done; `vectorcraft-cli bench` and `vectorcraft-cli perf` (budget suite); file format v2 opens 3× faster (50k paths: 722 → 244 ms); raster effects (glows, shadows, blur, feather) no longer force the whole frame single-threaded (filtered offscreen per effect, verified equal to the single-threaded reference); effect-heavy demos need a clean-machine benchmark; dirty-region rendering, GPU backend spike pending | 15–25 | | M6 | Path operations (Pathfinder, Shape Builder, offset…) | ✅ mostly done (Boolean precision on almost-horizontal edges fixed and the property tests made deterministic; Shape Builder edge erase, large-offset bug open) | 3–6 | | M7 | Type (point/area/path, editing, styles, OpenType, threading, glyphs) | 🟡 Character/Paragraph Styles, Area Type Options, threaded text, Fit Headline, Glyphs, OpenType panel, Find Font, Text Wrap (offset, invert, both sides of an object; follows edits), Type on a Path effects, tab stops + Tabs panel done; tab leaders, spell check, vertical type pending | 45–60 | @@ -185,7 +185,7 @@ about 1–1.5 agent-hours each, so the other rows (estimated on the older scale) | Layers, artboards, document setup | 5 | 80% | Layers panel options depth, artboard presets/rearrange polish (Document Setup and New Document are done) | 5–8 | | View & navigation | 3 | 70% | New View/Edit Views, multiple windows/arrange, Snap to Pixel/Glyph (print tiling is done) | 9–14 | | Guides, grids, smart guides, snapping, rulers | 3 | 75% | global/video rulers, smart-guide preference depth | 4–8 | -| File formats | 6 | 85% | DWG (no open spec: DXF instead), PSD placement as layers, the remaining fidelity polish; EPS, DXF, EMF/WMF, TIFF, BMP, Targa, PSD export, SVGZ, PDF security, PDF/X and presets are done | 6–10 | +| File formats | 6 | 85% | DWG (no open spec: DXF instead), PSD placement as layers, the remaining fidelity polish; EPS, DXF, EMF/WMF, TIFF, BMP, Targa, PSD export, SVGZ, PDF security, PDF/X, PDF layers and presets are done | 6–10 | | Export for Screens, Asset Export, slices, Save for Web | 3 | 85% | polish only (all four are done) | 2–4 | | Print, colour management, separations, flattener | 3 | 78% | flattener presets in PDF export, print preview fidelity, `/OP` in composite print (Print dialog, presets, PostScript, marks, separations, Print Tiling, print overprint/flattener/bitmap options and printer profile are done) | 2–4 | | Automation | 3 | 60% | Variables (data merge), scripting surface, batch | 8–12 | diff --git a/crates/engine/src/cmd/fileio/mod.rs b/crates/engine/src/cmd/fileio/mod.rs index 3ac30626..23817f16 100644 --- a/crates/engine/src/cmd/fileio/mod.rs +++ b/crates/engine/src/cmd/fileio/mod.rs @@ -908,3 +908,6 @@ mod tests_psd; #[cfg(test)] mod tests_epsimport; + +#[cfg(test)] +mod tests_pdflayers; diff --git a/crates/engine/src/cmd/fileio/pdf.rs b/crates/engine/src/cmd/fileio/pdf.rs index 24704209..b3f9222e 100644 --- a/crates/engine/src/cmd/fileio/pdf.rs +++ b/crates/engine/src/cmd/fileio/pdf.rs @@ -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 { @@ -85,7 +91,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 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 ), diff --git a/crates/engine/src/cmd/fileio/tests_pdf.rs b/crates/engine/src/cmd/fileio/tests_pdf.rs index 8214db20..1e5a62ae 100644 --- a/crates/engine/src/cmd/fileio/tests_pdf.rs +++ b/crates/engine/src/cmd/fileio/tests_pdf.rs @@ -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. diff --git a/crates/engine/src/cmd/fileio/tests_pdflayers.rs b/crates/engine/src/cmd/fileio/tests_pdflayers.rs new file mode 100644 index 00000000..1b781de8 --- /dev/null +++ b/crates/engine/src/cmd/fileio/tests_pdflayers.rs @@ -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 { + vectorcraft_format::base64_decode(v["dataBase64"].as_str().expect("dataBase64")).unwrap() +} + +fn warnings(v: &Value) -> Vec { + 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)); +} diff --git a/crates/pdf/src/encrypt.rs b/crates/pdf/src/encrypt.rs index de58326d..ef8fd25a 100644 --- a/crates/pdf/src/encrypt.rs +++ b/crates/pdf/src/encrypt.rs @@ -421,7 +421,7 @@ fn owner_key(owner: &[u8], r: u8, n: usize) -> Vec { // ---------- PDF syntax ---------- -fn is_white(b: u8) -> bool { +pub(crate) fn is_white(b: u8) -> bool { matches!(b, 0 | 9 | 10 | 12 | 13 | 32) } @@ -430,7 +430,7 @@ fn is_regular(b: u8) -> bool { } #[derive(Debug)] -enum Tok<'a> { +pub(crate) enum Tok<'a> { DictOpen, DictClose, ArrayOpen, @@ -442,14 +442,14 @@ enum Tok<'a> { } /// A token with its byte span. -type Spanned<'a> = (Tok<'a>, usize, usize); +pub(crate) type Spanned<'a> = (Tok<'a>, usize, usize); /// Deepest nesting of arrays and dictionaries read. const MAX_DEPTH: usize = 64; /// A parsed object, with what encryption needs: the strings and dictionaries with their spans. #[derive(Debug)] -enum Obj<'a> { +pub(crate) enum Obj<'a> { /// `start` is where `<<` is, `end` just after `>>`. Dict { entries: Vec<(&'a [u8], Obj<'a>)>, @@ -474,14 +474,14 @@ enum Obj<'a> { } impl<'a> Obj<'a> { - fn get(&self, key: &[u8]) -> Option<&Obj<'a>> { + pub(crate) fn get(&self, key: &[u8]) -> Option<&Obj<'a>> { match self { Self::Dict { entries, .. } => entries.iter().find(|(k, _)| *k == key).map(|(_, v)| v), _ => None, } } - fn name(&self, key: &[u8]) -> Option<&'a [u8]> { + pub(crate) fn name(&self, key: &[u8]) -> Option<&'a [u8]> { match self.get(key)? { Self::Name(n) => Some(n), _ => None, @@ -524,18 +524,18 @@ impl<'a> Obj<'a> { } } -fn int(w: &[u8]) -> Option { +pub(crate) fn int(w: &[u8]) -> Option { std::str::from_utf8(w).ok()?.parse().ok() } #[derive(Clone)] -struct Lexer<'a> { +pub(crate) struct Lexer<'a> { buf: &'a [u8], pos: usize, } impl<'a> Lexer<'a> { - fn at(buf: &'a [u8], pos: usize) -> Self { + pub(crate) fn at(buf: &'a [u8], pos: usize) -> Self { Self { buf, pos } } @@ -557,7 +557,7 @@ impl<'a> Lexer<'a> { } } - fn next(&mut self) -> Option> { + pub(crate) fn next(&mut self) -> Option> { self.skip_space(); let start = self.pos; let b = self.peek_byte(0)?; @@ -689,7 +689,7 @@ impl<'a> Lexer<'a> { } /// One object (a reference `n g R` reads as one). - fn object(&mut self, depth: usize) -> Option> { + pub(crate) fn object(&mut self, depth: usize) -> Option> { if depth > MAX_DEPTH { return None; } @@ -748,7 +748,7 @@ impl<'a> Lexer<'a> { } /// The header `num generation obj` of an indirect object. - fn header(&mut self) -> Option<(u32, u16)> { + pub(crate) fn header(&mut self) -> Option<(u32, u16)> { let (Some((Tok::Word(n), ..)), Some((Tok::Word(g), ..)), Some((Tok::Word(b"obj"), ..))) = (self.next(), self.next(), self.next()) else { return None; }; @@ -820,14 +820,29 @@ fn object_edits(pdf: &[u8], off: usize, h: &Handler, catalog: bool, rng: &mut En { edits.push((start + 2, start + 2, AES256_EXTENSION.to_vec())); } - let Some((Tok::Word(b"stream"), _, keyword_end)) = lx.next() else { return Ok(()) }; + let Some(((len_start, len_end), (data, data_end))) = stream_spans(pdf, &mut lx, &obj).map_err(failed)? else { return Ok(()) }; let ty = obj.name(b"Type"); if matches!(ty, Some(b"XRef" | b"ObjStm")) { return Err(failed("cross-reference and object streams aren't supported")); } - let Some(Obj::Int { value, start: len_start, end: len_end }) = obj.get(b"Length") else { - return Err(failed("a stream's length isn't a number")); - }; + if ty == Some(&b"Metadata"[..]) && !h.encrypt_metadata { + return Ok(()); + } + let bytes = pdf.get(data..data_end).ok_or_else(|| failed("a stream runs past the end"))?; + let enc = h.encrypt(num, generation, bytes, rng)?; + edits.push((len_start, len_end, enc.len().to_string().into_bytes())); + edits.push((data, data_end, enc)); + Ok(()) +} + +/// The spans of a stream's length value and of its data. +pub(crate) type StreamSpans = ((usize, usize), (usize, usize)); + +/// Where the object whose dictionary `dict` the lexer `lx` has just read keeps its stream data: +/// its [`StreamSpans`]; `None` when it isn't a stream. +pub(crate) fn stream_spans(pdf: &[u8], lx: &mut Lexer<'_>, dict: &Obj<'_>) -> Result, &'static str> { + let Some((Tok::Word(b"stream"), _, keyword_end)) = lx.next() else { return Ok(None) }; + let Some(Obj::Int { value, start, end }) = dict.get(b"Length") else { return Err("a stream's length isn't a number") }; // The data starts after the end of line that follows `stream`. let mut data = keyword_end; if pdf.get(data) == Some(&b'\r') { @@ -836,18 +851,14 @@ fn object_edits(pdf: &[u8], off: usize, h: &Handler, catalog: bool, rng: &mut En if pdf.get(data) == Some(&b'\n') { data += 1; } - let data_end = usize::try_from(*value).ok().and_then(|n| data.checked_add(n)).ok_or_else(|| failed("bad stream length"))?; - let bytes = pdf.get(data..data_end).ok_or_else(|| failed("a stream runs past the end"))?; + let data_end = usize::try_from(*value).ok().and_then(|n| data.checked_add(n)).ok_or("bad stream length")?; + if data_end > pdf.len() { + return Err("a stream runs past the end"); + } if !matches!(Lexer::at(pdf, data_end).next(), Some((Tok::Word(b"endstream"), ..))) { - return Err(failed("a stream's length is wrong")); + return Err("a stream's length is wrong"); } - if ty == Some(&b"Metadata"[..]) && !h.encrypt_metadata { - return Ok(()); - } - let enc = h.encrypt(num, generation, bytes, rng)?; - edits.push((*len_start, *len_end, enc.len().to_string().into_bytes())); - edits.push((data, data_end, enc)); - Ok(()) + Ok(Some(((*start, *end), (data, data_end)))) } /// Encrypt `pdf` (as the export writes it: one cross-reference table) as `set`'s Security diff --git a/crates/pdf/src/export.rs b/crates/pdf/src/export.rs index ae51f3c8..9f460e26 100644 --- a/crates/pdf/src/export.rs +++ b/crates/pdf/src/export.rs @@ -23,6 +23,7 @@ use vectorcraft_geom::{Affine, BezPath, FillRule, Rect}; use vectorcraft_text::TextLayout; use vectorcraft_text::embed::Embedding; +use crate::forms::Mark; use crate::lab_spot::{find, rfind}; use crate::marks::PageBoxes; use crate::output::ColorOut; @@ -71,6 +72,11 @@ pub fn export_with_report(doc: &Document, opts: &PdfOptions) -> Result Result { /// The fonts real text is written in, by face id, with their units per em; `None` for faces /// that can't be embedded. fonts: HashMap>, + /// Top-level layers are drawn as forms marked for their optional content groups + /// ([`crate::forms`]). + pub layers: bool, + /// A top-level layer's form is being drawn. + in_layer: bool, } impl<'a> Exporter<'a> { @@ -457,6 +470,8 @@ impl<'a> Exporter<'a> { convert: true, outline_text: set.advanced.outline_text, fonts: HashMap::new(), + layers: false, + in_layer: false, } } @@ -849,6 +864,18 @@ impl Exporter<'_> { } fn node(&mut self, s: &mut Surface, n: &Node, page: Rect, force: bool) { + if self.layers + && !self.in_layer + && let NodeKind::Layer { template: false, printable, .. } = n.kind + && (printable || self.non_printing) + && let Some(i) = self.doc.layers.iter().position(|l| std::ptr::eq(&**l, n)) + { + // A PDF layer: hidden layers are written too (their group is off). + self.in_layer = true; + crate::forms::form(s, Mark::Layer(i), |s| self.node(s, n, page, true)); + self.in_layer = false; + return; + } if !force && !n.visible { return; } diff --git a/crates/pdf/src/forms.rs b/crates/pdf/src/forms.rs new file mode 100644 index 00000000..65609fc3 --- /dev/null +++ b/crates/pdf/src/forms.rs @@ -0,0 +1,287 @@ +//! What the PDF writer has no operators for — optional content (PDF layers) — drawn as form +//! XObjects of their own (without a transparency group, so they draw as if in place), each +//! starting with a mark: an empty clip whose first point says what the form is. Marked forms also +//! stay apart (the writer shares identical forms). [`finish`] completes them in the written file: +//! a layer's form is drawn as the layer's optional content (`/OC … BDC`). + +use std::collections::HashMap; +use std::io::Read; + +use krilla::geom::PathBuilder; +use krilla::graphic::Graphic; +use krilla::surface::Surface; +use vectorcraft_doc::{Document, NodeKind}; + +use crate::encrypt::{Lexer, Obj, StreamSpans, Tok, int, stream_spans}; +use crate::output::{deflate, text_string}; +use crate::patch::{Patch, Xref}; +use crate::{PdfError, PdfSettings, Standard}; + +/// What a marked form is. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum Mark { + /// The art of top-level layer `i` (`Document::layers`). + Layer(usize), +} + +/// The x coordinate of a layer mark's first point (its y is the layer index). +const LAYER_X: i32 = -32000; + +impl Mark { + fn at(self) -> (f32, f32) { + match self { + // Exact in `f32` up to 2^24 layers. + Self::Layer(i) => (LAYER_X as f32, i as f32), + } + } +} + +/// Draw `draw` as a form of its own, marked `mark`. +pub(crate) fn form(s: &mut Surface, mark: Mark, draw: impl FnOnce(&mut Surface)) { + let mut sb = s.stream_builder(); + let mut fs = sb.surface(); + let (x, y) = mark.at(); + let mut pb = PathBuilder::new(); + pb.move_to(x, y); + pb.line_to(x + 1.0, y); + pb.line_to(x + 1.0, y + 1.0); + pb.close(); + // A form's drawing space is its own: the mark's points are written as they are. + if let Some(p) = pb.finish() { + fs.push_clip_path(&p, &krilla::paint::FillRule::NonZero); + fs.pop(); + } + draw(&mut fs); + fs.finish(); + let stream = sb.finish(); + s.draw_graphic(Graphic::new(stream, false)); +} + +// ---------- finishing the written file ---------- + +/// The prefix of the resource names of the layers' optional content groups (then the index). +const LAYER_OC: &str = "VCoc"; +/// The most bytes of a form's content read to find its mark. +const HEAD: u64 = 64; + +fn bad(why: &str) -> PdfError { + PdfError::Write(format!("can't write PDF layers: {why}")) +} + +/// An object of the written file with a dictionary, and its stream's [`StreamSpans`]. +struct Object<'a> { + dict: Obj<'a>, + stream: Option, +} + +impl Object<'_> { + /// The stream's data, decoded (Flate or none), at most `limit` bytes. + fn data(&self, pdf: &[u8], limit: u64) -> Option> { + let (_, (start, end)) = self.stream?; + let data = pdf.get(start..end)?; + let mut out = vec![]; + match self.dict.get(b"Filter") { + None => out.extend_from_slice(data.get(..usize::try_from(limit).unwrap_or(usize::MAX).min(data.len()))?), + Some(Obj::Name(b"FlateDecode")) => { + flate2::read::ZlibDecoder::new(data).take(limit).read_to_end(&mut out).ok()?; + } + Some(_) => return None, + } + Some(out) + } + + /// Replace the stream's data with `content` (compressed as before). + fn rewrite(&self, patch: &mut Patch, content: &[u8]) -> Result<(), PdfError> { + let ((len_start, len_end), (start, end)) = self.stream.ok_or_else(|| bad("a content stream isn't a stream"))?; + let data = if self.dict.get(b"Filter").is_some() { deflate(content).map_err(|e| bad(&e.to_string()))? } else { content.to_vec() }; + patch.replace(len_start, len_end, data.len().to_string().into_bytes()); + patch.replace(start, end, data); + Ok(()) + } + + /// The mark of a marked form. + fn mark(&self, pdf: &[u8]) -> Option { + if self.dict.name(b"Subtype") != Some(b"Form") { + return None; + } + let head = self.data(pdf, HEAD)?; + let mut lx = Lexer::at(&head, 0); + let mut word = || match lx.next() { + Some((Tok::Word(w), ..)) => Some(w), + _ => None, + }; + let (Some(b"q"), Some(x), Some(y), Some(b"m")) = (word(), word().and_then(int), word().and_then(int), word()) else { return None }; + match x { + _ if x == i64::from(LAYER_X) => usize::try_from(y).ok().map(Mark::Layer), + _ => None, + } + } +} + +/// The objects of the written file that have a dictionary, by number. +fn objects<'a>(pdf: &'a [u8], xref: &Xref) -> HashMap> { + (1..xref.count()) + .filter_map(|n| { + let n = u32::try_from(n).ok()?; + let mut lx = Lexer::at(pdf, xref.offset(pdf, n)?); + (lx.header()?.0 == n).then_some(())?; + let dict = lx.object(0)?; + let Obj::Dict { .. } = dict else { return None }; + let stream = stream_spans(pdf, &mut lx, &dict).ok().flatten(); + Some((n, Object { dict, stream })) + }) + .collect() +} + +/// The resource dictionary of the content of `o` (a page or a form). +fn resources<'o>(objects: &'o HashMap>, o: &'o Object<'_>) -> Option<&'o Obj<'o>> { + match o.dict.get(b"Resources")? { + Obj::Ref(n, _) => objects.get(n).map(|r| &r.dict), + d => Some(d), + } +} + +/// Add `entries` (`/VCoc0 12 0 R`) to the `category` (`Properties`) of resource dictionary `res`. +fn add_resources(patch: &mut Patch, res: &Obj<'_>, category: &str, entries: &str) -> Result<(), PdfError> { + match (res.get(category.as_bytes()), res) { + (Some(Obj::Dict { start, .. }), _) => patch.replace(start + 2, start + 2, entries.as_bytes().to_vec()), + (None, Obj::Dict { start, .. }) => patch.replace(start + 2, start + 2, format!("/{category}<<{entries}>>").into_bytes()), + _ => return Err(bad("unexpected resources")), + } + Ok(()) +} + +/// `content` with each `Do` of a layer's form (`forms`: its resource name → the layer) marked as +/// that layer's optional content, and the layers marked; `None` when there is none. +fn mark_layers(content: &[u8], forms: &HashMap<&[u8], usize>) -> Option<(Vec, Vec)> { + let mut lx = Lexer::at(content, 0); + let mut name = None; + let mut spans = vec![]; + while let Some((tok, start, end)) = lx.next() { + name = match tok { + Tok::Name(n) => forms.get(n).map(|layer| (start, *layer)), + Tok::Word(b"Do") => { + spans.extend(name.map(|(name, layer)| (name, end, layer))); + None + } + // Inline image data isn't PDF syntax (the writer writes none). + Tok::Word(b"ID") => break, + _ => None, + }; + } + if spans.is_empty() { + return None; + } + let mut out = Vec::with_capacity(content.len() + spans.len() * 24); + let mut at = 0; + for (start, end, layer) in &spans { + out.extend_from_slice(content.get(at..*start)?); + out.extend_from_slice(format!("/OC /{LAYER_OC}{layer} BDC ").as_bytes()); + out.extend_from_slice(content.get(*start..*end)?); + out.extend_from_slice(b" EMC"); + at = *end; + } + out.extend_from_slice(content.get(at..)?); + let mut layers: Vec = spans.iter().map(|s| s.2).collect(); + layers.sort_unstable(); + layers.dedup(); + Some((out, layers)) +} + +/// Finish the marked forms of `pdf` (as [`crate::export`] writes `doc` with `set`): with +/// `layers`, each top-level layer (but template layers) becomes an optional content group — +/// named as the layer, off when it is hidden, not printed (`/PrintState /OFF`) when its Print +/// option is off, locked when it is locked — and the uses of its forms its optional content. +pub(crate) fn finish(pdf: Vec, doc: &Document, set: &PdfSettings, layers: bool) -> Result, PdfError> { + if !layers { + return Ok(pdf); + } + let xref = Xref::read(&pdf).ok_or_else(|| bad("the written PDF has no cross-reference table"))?; + let objects = objects(&pdf, &xref); + let marks: HashMap = objects.iter().filter_map(|(n, o)| Some((*n, o.mark(&pdf)?))).collect(); + let mut patch = Patch::new(&xref); + write_layers(&pdf, &xref, &objects, &marks, &mut patch, doc, set)?; + let pdf = patch.apply(&pdf, &xref)?; + // The file still is what its standard says. + crate::pdfx::verify(&pdf, set.standard)?; + Ok(pdf) +} + +/// The optional content groups of `doc`'s top-level layers, the uses of their forms marked as +/// their content, and the catalog's `/OCProperties`. +fn write_layers( + pdf: &[u8], + xref: &Xref, + objects: &HashMap>, + marks: &HashMap, + patch: &mut Patch, + doc: &Document, + set: &PdfSettings, +) -> Result<(), PdfError> { + // One group per layer, in paint order (bottom first). + let mut groups: HashMap = HashMap::new(); + let (mut all, mut off, mut locked) = (vec![], vec![], vec![]); + for (i, l) in doc.layers.iter().enumerate() { + let NodeKind::Layer { template: false, printable, .. } = l.kind else { continue }; + let name = l.name.clone().unwrap_or_else(|| format!("Layer {}", i + 1)); + let on = |b: bool| if b { "ON" } else { "OFF" }; + let g = patch.add_object( + format!("<>/Print<>>>>>", text_string(&name), on(l.visible), on(printable)) + .into_bytes(), + ); + groups.insert(i, g); + all.push(g); + if !l.visible { + off.push(g); + } + if l.locked { + locked.push(g); + } + } + // Every content stream drawing layers' forms: the pages' and the forms'. + for o in objects.values() { + let content = match o.dict.get(b"Contents") { + Some(Obj::Ref(c, _)) if o.dict.name(b"Type") == Some(b"Page") => objects.get(c), + _ if o.dict.name(b"Subtype") == Some(b"Form") => Some(o), + _ => None, + }; + let (Some(content), Some(res)) = (content, resources(objects, o)) else { continue }; + let Some(Obj::Dict { entries, .. }) = res.get(b"XObject") else { continue }; + let forms: HashMap<&[u8], usize> = entries + .iter() + .filter_map(|(name, x)| { + let Obj::Ref(n, _) = x else { return None }; + match marks.get(n) { + Some(Mark::Layer(i)) if groups.contains_key(i) => Some((*name, *i)), + _ => None, + } + }) + .collect(); + if forms.is_empty() { + continue; + } + let data = content.data(pdf, u64::MAX).ok_or_else(|| bad("a content stream can't be read"))?; + let Some((new, marked)) = mark_layers(&data, &forms) else { continue }; + content.rewrite(patch, &new)?; + let entries: String = marked.iter().filter_map(|i| Some(format!("/{LAYER_OC}{i} {} 0 R", groups.get(i)?))).collect(); + add_resources(patch, res, "Properties", &entries)?; + } + let refs = |v: &[u32]| v.iter().map(|g| format!("{g} 0 R")).collect::>().join(" "); + // Layers panels list the top layer first. + let order: Vec = all.iter().rev().copied().collect(); + let mut config = format!("/Name(Layers)/Order[{}]", refs(&order)); + if !off.is_empty() { + config.push_str(&format!("/OFF[{}]", refs(&off))); + } + if !locked.is_empty() { + config.push_str(&format!("/Locked[{}]", refs(&locked))); + } + // Viewers apply the print states when printing (the standards allow no automatic states). + if set.standard == Standard::None { + config.push_str(&format!("/AS[<>]", refs(&all))); + } + let root = xref.trailer_ref(pdf, b"/Root").and_then(|n| objects.get(&n)); + let Some(Object { dict: Obj::Dict { start, .. }, .. }) = root else { return Err(bad("the written PDF has no catalog")) }; + patch.replace(start + 2, start + 2, format!("/OCProperties<>>>", refs(&all)).into_bytes()); + Ok(()) +} diff --git a/crates/pdf/src/lib.rs b/crates/pdf/src/lib.rs index cc985dd4..4e47fa29 100644 --- a/crates/pdf/src/lib.rs +++ b/crates/pdf/src/lib.rs @@ -10,6 +10,8 @@ //! text in embedded subset fonts (Advanced). Colours are converted, tagged with ICC profiles and //! given an output intent as the Output settings say. Hidden objects, //! guides and template layers are skipped, and so are non-printing layers unless asked for. +//! Top-level layers can be PDF layers (optional content groups with their visibility, print +//! state and lock). //! Each page is its artboard (the trim box) grown by the bleed (the bleed box) and by the //! printer's marks around it (the media box), drawn in Registration. [`PdfSettings`] is the Save PDF //! dialog's model (standard, compatibility, General, Compression, Marks and Bleeds, Output, @@ -38,6 +40,7 @@ mod editing; mod encrypt; mod export; +mod forms; mod images; mod import; mod import_color; @@ -217,6 +220,8 @@ mod tests_import_layers; #[cfg(test)] mod tests_import_options; #[cfg(test)] +mod tests_layers; +#[cfg(test)] mod tests_marks; #[cfg(test)] mod tests_output; diff --git a/crates/pdf/src/output.rs b/crates/pdf/src/output.rs index abcc6f41..0a7b40a7 100644 --- a/crates/pdf/src/output.rs +++ b/crates/pdf/src/output.rs @@ -386,7 +386,7 @@ fn catalog(set: &PdfSettings, cmyk_profile: &str) -> Option { /// `s` as a PDF text string: a literal string when it is printable ASCII, else UTF-16BE (with its /// byte order mark) in hex. -fn text_string(s: &str) -> String { +pub(crate) fn text_string(s: &str) -> String { if s.bytes().all(|b| (b' '..=b'~').contains(&b)) { let mut out = String::with_capacity(s.len() + 2); out.push('('); @@ -408,7 +408,7 @@ fn text_string(s: &str) -> String { } } -fn deflate(data: &[u8]) -> std::io::Result> { +pub(crate) fn deflate(data: &[u8]) -> std::io::Result> { use std::io::Write; let mut e = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::default()); e.write_all(data)?; diff --git a/crates/pdf/src/patch.rs b/crates/pdf/src/patch.rs index 61a0d360..09b58014 100644 --- a/crates/pdf/src/patch.rs +++ b/crates/pdf/src/patch.rs @@ -48,8 +48,13 @@ impl Xref { Some(Self { at, entries, count, trailer: (start, end) }) } + /// Number of entries (objects `0..count`). + pub fn count(&self) -> usize { + self.count + } + /// Where in-use object `n` starts. - fn offset(&self, pdf: &[u8], n: u32) -> Option { + pub fn offset(&self, pdf: &[u8], n: u32) -> Option { let e = self.entries + 20 * usize::try_from(n).ok().filter(|n| *n < self.count)?; let entry = pdf.get(e..e + 20)?; (entry.get(17) == Some(&b'n')).then_some(())?; diff --git a/crates/pdf/src/settings.rs b/crates/pdf/src/settings.rs index 82b76b80..956b05ae 100644 --- a/crates/pdf/src/settings.rs +++ b/crates/pdf/src/settings.rs @@ -103,6 +103,13 @@ choice! { } default Pdf17 } +impl Compatibility { + /// The version has PDF layers (optional content, PDF 1.5). + pub fn has_layers(self) -> bool { + matches!(self, Self::Pdf15 | Self::Pdf16 | Self::Pdf17 | Self::Pdf20) + } +} + choice! { /// How images above the threshold resolution are resampled. Downsample { @@ -437,6 +444,11 @@ impl PdfSettings { within("advanced.fontSubsetPercent", self.advanced.font_subset_percent, 0.0, 100.0, "%") } + /// Top-level layers are written as PDF layers: asked for, at a version that has them. + pub fn writes_layers(&self) -> bool { + self.create_layers && self.compatibility.has_layers() + } + /// Forget the passwords (presets never store them). pub fn clear_passwords(&mut self) { self.security.open_password.clear(); @@ -450,7 +462,7 @@ impl PdfSettings { [ (self.thumbnails, "page thumbnails are not embedded yet"), (self.fast_web_view, "fast web view (a linearised file) is not written yet"), - (self.create_layers, "PDF layers are not written yet: every layer is plain page content"), + (self.create_layers && !self.compatibility.has_layers(), "PDF layers need PDF 1.5 or later: every layer is plain page content"), ( !self.advanced.outline_text && self.advanced.font_subset_percent < 100.0, "fonts are embedded as subsets of the characters used: a subset threshold below 100% is not applied", diff --git a/crates/pdf/src/tests_layers.rs b/crates/pdf/src/tests_layers.rs new file mode 100644 index 00000000..f683eea2 --- /dev/null +++ b/crates/pdf/src/tests_layers.rs @@ -0,0 +1,217 @@ +//! Save PDF › Create PDF layers: an optional content group per top-level layer, with its +//! visibility, print state and lock. + +use hayro_syntax::object::{Array, Dict, Name, ObjectIdentifier}; +use serde_json::{Value, json}; +use vectorcraft_color::{Color, Paint}; +use vectorcraft_doc::{Appearance, Document, LayerColor, Node, NodeKind}; +use vectorcraft_geom::{Rect, shapes}; + +use crate::*; + +const RED: Color = Color::Rgb { r: 1.0, g: 0.0, b: 0.0 }; +const GREEN: Color = Color::Rgb { r: 0.0, g: 1.0, b: 0.0 }; +const BLUE: Color = Color::Rgb { r: 0.0, g: 0.0, b: 1.0 }; + +fn rect(d: &mut Document, x: f64, c: Color) -> Node { + Node::path(d.alloc_id(), shapes::rectangle(Rect::new(x, 10.0, x + 30.0, 40.0)), Appearance::basic(Paint::solid(c), Paint::None, 0.0)) +} + +/// Layers, bottom first: "Back" (red), "Hidden" (green; hidden and locked), "Notes" (blue; not +/// printed) and a template layer (grey). +fn layered() -> Document { + let mut d = Document::new(200.0, 100.0); + d.layers.clear(); + for (i, (name, c)) in [("Back", RED), ("Hidden", GREEN), ("Notes", BLUE), ("Template", Color::gray(0.5))].into_iter().enumerate() { + let art = rect(&mut d, 10.0 + 40.0 * i as f64, c); + let mut l = Node::layer(d.alloc_id(), name, LayerColor::Preset(i as u8)); + (l.visible, l.locked) = (name != "Hidden", name == "Hidden"); + if let NodeKind::Layer { children, printable, template, .. } = &mut l.kind { + children.push(art.into()); + *printable = name != "Notes"; + *template = name == "Template"; + } + d.layers.push(l.into()); + } + d +} + +fn export(d: &Document, v: Value) -> ExportReport { + let settings: PdfSettings = serde_json::from_value(v).unwrap(); + export_with_report(d, &PdfOptions { settings, ..Default::default() }).unwrap() +} + +/// The groups of `refs` (an array of the catalog's optional content properties) by name. +fn names(xref: &hayro_syntax::xref::XRef, refs: Option>) -> Vec { + let ids = refs.map(|a| a.raw_iter().filter_map(|i| i.as_obj_ref()).map(ObjectIdentifier::from).collect::>()).unwrap_or_default(); + ids.into_iter() + .map(|id| { + let g = xref.get::>(id).unwrap(); + String::from_utf8(g.get::>(b"Name").unwrap().as_bytes().to_vec()).unwrap() + }) + .collect() +} + +/// The catalog's `/OCProperties` of `bytes` (opened with `password`), if any. +fn oc_properties(bytes: &[u8], password: Option<&str>, f: impl FnOnce(Option>, &hayro_syntax::xref::XRef)) { + let file = crate::pages::open(bytes, password).unwrap(); + let xref = file.xref(); + let catalog = xref.get::>(xref.root_id()).unwrap(); + f(catalog.get::>(b"OCProperties"), xref); +} + +/// The layers of an import: name, visible, printable, locked and fill colours of their art. +fn layers(d: &Document) -> Vec<(String, bool, bool, bool, Vec)> { + d.layers + .iter() + .map(|l| { + let printable = matches!(l.kind, NodeKind::Layer { printable: true, .. }); + let colors = l.children().unwrap().iter().filter_map(|n| n.appearance.fill().and_then(|f| f.paint.color())).collect(); + (l.name.clone().unwrap_or_default(), l.visible, printable, l.locked, colors) + }) + .collect() +} + +#[test] +fn create_layers_writes_a_group_per_layer_with_its_states() { + let d = layered(); + let r = export(&d, json!({"createLayers": true, "compression": {"compressText": false}})); + assert!(r.warnings.is_empty(), "{:?}", r.warnings); + oc_properties(&r.bytes, None, |props, xref| { + let props = props.expect("/OCProperties"); + // Template layers aren't exported; the groups are in paint order, listed top first. + assert_eq!(names(xref, props.get(b"OCGs")), ["Back", "Hidden", "Notes"]); + let config = props.get::>(b"D").unwrap(); + assert_eq!(names(xref, config.get(b"Order")), ["Notes", "Hidden", "Back"]); + assert_eq!(names(xref, config.get(b"OFF")), ["Hidden"], "hidden layers are off"); + assert_eq!(names(xref, config.get(b"Locked")), ["Hidden"]); + assert!(config.get::>(b"Name").is_some()); + let auto = config.get::>(b"AS").unwrap().iter::>().next().unwrap(); + assert_eq!(auto.get::>(b"Event").unwrap().as_ref(), b"Print"); + let print_state = |name: &str| { + let ids = + props.get::>(b"OCGs").unwrap().raw_iter().filter_map(|i| i.as_obj_ref()).map(ObjectIdentifier::from).collect::>(); + let g = ids + .into_iter() + .map(|id| xref.get::>(id).unwrap()) + .find(|g| g.get::>(b"Name").unwrap().as_bytes() == name.as_bytes()); + let usage = g.unwrap().get::>(b"Usage").unwrap(); + usage.get::>(b"Print").unwrap().get::>(b"PrintState").unwrap().as_ref().to_vec() + }; + assert_eq!(print_state("Notes"), b"OFF", "non-printing layers aren't printed"); + assert_eq!(print_state("Back"), b"ON"); + }); + // Each layer's art is its group's optional content. + let text = String::from_utf8_lossy(&r.bytes); + assert_eq!(text.matches(" BDC ").count(), 3, "{text}"); + assert!(text.contains("/OC /VCoc0 BDC /x") && text.contains("/Properties<<")); +} + +#[test] +fn pdf_layers_import_back_as_layers() { + let d = layered(); + for password in [None, Some("secret")] { + let mut set = json!({"createLayers": true}); + if let Some(p) = password { + set["security"] = json!({"openPassword": p}); + } + let bytes = export(&d, set).bytes; + let opts = ImportOptions { password: password.map(str::to_string), ..Default::default() }; + let back = import_with_report(&bytes, &opts).unwrap().document; + assert_eq!( + layers(&back), + [ + ("Back".to_string(), true, true, false, vec![RED]), + ("Hidden".to_string(), false, true, true, vec![GREEN]), + ("Notes".to_string(), true, false, false, vec![BLUE]), + ], + "password {password:?}" + ); + } +} + +#[test] +fn identical_layers_stay_apart() { + let mut d = Document::new(200.0, 100.0); + d.layers.clear(); + for name in ["One", "Two"] { + let art = rect(&mut d, 10.0, RED); + let mut l = Node::layer(d.alloc_id(), name, LayerColor::Preset(0)); + if let NodeKind::Layer { children, .. } = &mut l.kind { + children.push(art.into()); + } + d.layers.push(l.into()); + } + let back = import(&export(&d, json!({"createLayers": true})).bytes).unwrap(); + let got: Vec<(String, usize)> = back.layers.iter().map(|l| (l.name.clone().unwrap_or_default(), l.children().unwrap().len())).collect(); + assert_eq!(got, [("One".to_string(), 1), ("Two".to_string(), 1)]); +} + +#[test] +fn without_pdf_layers_the_layers_are_plain_page_content() { + let mut d = layered(); + for (v, warning) in [ + (json!({}), None), + (json!({"createLayers": true, "compatibility": "1.4"}), Some("1.5")), + (json!({"createLayers": true, "compatibility": "1.5"}), None), + ] { + let r = export(&d, v.clone()); + let mut has = false; + oc_properties(&r.bytes, None, |props, _| has = props.is_some()); + assert_eq!(has, v["createLayers"] == json!(true) && warning.is_none(), "{v}"); + match warning { + Some(w) => assert!(r.warnings.iter().any(|x| x.contains(w)), "{v}: {:?}", r.warnings), + None => assert!(r.warnings.is_empty(), "{v}: {:?}", r.warnings), + } + } + // A knockout page group draws objects, not layers. + d.page_knockout = true; + let r = export(&d, json!({"createLayers": true})); + oc_properties(&r.bytes, None, |props, _| assert!(props.is_none())); + assert!(r.warnings.iter().any(|w| w.contains("knockout page group")), "{:?}", r.warnings); +} + +#[test] +fn pdf_a_layers_have_no_automatic_states() { + let r = export(&layered(), json!({"standard": "pdfA2b", "createLayers": true})); + oc_properties(&r.bytes, None, |props, _| { + let config = props.unwrap().get::>(b"D").unwrap(); + assert!(config.get::>(b"AS").is_none() && config.get::>(b"Name").is_some()); + }); +} + +#[test] +fn pdf_x4_keeps_pdf_layers_and_x1a_refuses_them() { + let d = layered(); + let r = export(&d, json!({"standard": "pdfX4", "compatibility": "1.6", "createLayers": true})); + assert!(r.bytes.starts_with(b"%PDF-1.6")); + oc_properties(&r.bytes, None, |props, xref| { + let props = props.expect("/OCProperties"); + assert_eq!(names(xref, props.get(b"OCGs")), ["Back", "Hidden", "Notes"]); + assert!(props.get::>(b"D").unwrap().get::>(b"AS").is_none()); + }); + let set: PdfSettings = serde_json::from_value(json!({"standard": "pdfX1a", "compatibility": "1.4", "createLayers": true})).unwrap(); + assert!(matches!(export_with_report(&d, &PdfOptions { settings: set, ..Default::default() }), Err(PdfError::BadSetting(_)))); +} + +/// The painted paths of `d`: their bounds' corner and colour, in order. +fn leaves(d: &Document) -> Vec<(i32, i32, Option<[u8; 4]>)> { + let mut out = vec![]; + d.walk(|n| { + if let Some(b) = n.path_data().and_then(|p| p.bounds()) { + let paint = n.appearance.fill().map(|f| f.paint.clone()).or_else(|| n.appearance.stroke().map(|s| s.paint.clone())); + out.push((b.x0.round() as i32, b.y0.round() as i32, paint.and_then(|p| p.color()).map(|c| c.to_rgba8(1.0)))); + } + }); + out.sort_by_key(|l| (l.0, l.1)); + out +} + +#[test] +fn layered_art_imports_as_it_was_drawn() { + let mut d = layered(); + d.layers = d.layers.iter().map(|l| Node { visible: true, ..(**l).clone() }.into()).collect(); + let plain = leaves(&import(&export(&d, json!({"includeNonPrinting": true})).bytes).unwrap()); + assert_eq!(plain.len(), 3); + assert_eq!(leaves(&import(&export(&d, json!({"createLayers": true})).bytes).unwrap()), plain, "the marks draw nothing"); +} diff --git a/crates/pdf/src/tests_settings.rs b/crates/pdf/src/tests_settings.rs index 577eac03..1ddcf9ac 100644 --- a/crates/pdf/src/tests_settings.rs +++ b/crates/pdf/src/tests_settings.rs @@ -78,7 +78,7 @@ fn options_not_applied_yet_come_back_as_warnings() { for (v, word) in [ (json!({"thumbnails": true}), "thumbnails"), (json!({"fastWebView": true}), "fast web view"), - (json!({"createLayers": true}), "layers"), + (json!({"createLayers": true, "compatibility": "1.4"}), "PDF 1.5"), (json!({"advanced": {"outlineText": false, "fontSubsetPercent": 35}}), "subset"), (json!({"output": {"outputIntent": "No Such Press"}}), "without embedding"), (json!({"output": {"registry": "http://example.com"}}), "condition identifier"), @@ -101,6 +101,7 @@ fn options_not_applied_yet_come_back_as_warnings() { json!({"bleed": {"useDocument": true, "top": 9}}), json!({"bleed": {"top": 9}, "marks": {"trim": true, "registration": true, "colorBars": true, "pageInfo": true}}), json!({"includeNonPrinting": true}), + json!({"createLayers": true}), json!({"viewAfterSaving": true}), json!({"compression": {"compressText": false}}), json!({"compression": {"color": {"compression": "jpeg"}, "mono": {"compression": "ccittG4"}}}), diff --git a/crates/ui-egui/src/dialogs/save_pdf.rs b/crates/ui-egui/src/dialogs/save_pdf.rs index e4780456..5beff550 100644 --- a/crates/ui-egui/src/dialogs/save_pdf.rs +++ b/crates/ui-egui/src/dialogs/save_pdf.rs @@ -440,7 +440,9 @@ fn general(ui: &mut egui::Ui, d: &mut Dialog, editor: bool) { flag(ui, d, "thumbnails", "Embed page thumbnails", true); flag(ui, d, "fastWebView", "Optimize for fast web view", true); flag(ui, d, "viewAfterSaving", "View PDF after saving", true); - flag(ui, d, "createLayers", "Create PDF layers from top-level layers", standard.allows_layers()); + // PDF layers need PDF 1.5, and PDF/X-1a and PDF/X-3 have none. + let layers = standard.allows_layers() && choice::(d, "compatibility").unwrap_or_default().has_layers(); + flag(ui, d, "createLayers", "Create PDF layers from top-level layers", layers); flag(ui, d, "includeNonPrinting", "Include non-printing layers", true); if editor { heading(ui, "Description"); @@ -975,4 +977,23 @@ mod tests { standard_chosen(d, Standard::PdfX4); assert_eq!((get(d, "compatibility"), get(d, "createLayers")), (&json!("1.6"), &json!(true))); } + + #[test] + fn create_layers_writes_pdf_layers() { + let (mut app, written, _) = app(); + app.run("layer.new", json!({"name": "Notes"})).unwrap(); + app.run("shape.ellipse", json!({"x": 60, "y": 40, "width": 20, "height": 20})).unwrap(); + app.run("ui.savePdfDialog", json!({"path": "/tmp/layers.pdf", "createLayers": true})).unwrap(); + // The option is drawn greyed at PDF 1.4 (no PDF layers there), then live again. + for c in ["1.4", "1.7"] { + set_field(&mut app, "compatibility", json!(c)); + frame(&mut app); + } + let r = super::super::confirm(&mut app).unwrap(); + assert_eq!(r["warnings"], json!([])); + let back = vectorcraft_pdf::import(&written.borrow()[0].1).unwrap(); + let names: Vec<_> = back.layers.iter().filter_map(|l| l.name.clone()).collect(); + // The empty pages of the other artboards come in as page layers. + assert_eq!(names[..2], ["Layer 1", "Notes"]); + } } diff --git a/docs/mcp.md b/docs/mcp.md index eba00620..6340ffb7 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -223,6 +223,9 @@ permissions; either password opens it (`document.open {password}`), and a passwo Pattern fills and strokes are written as their tiles clipped to the area they paint (a stroke's outline, with its dashes, caps, profile, arrowheads and alignment), and freeform gradients as an image of their colour field at the document's raster effects resolution, clipped the same way. +`createLayers` writes each top-level layer (template layers are left out) as a PDF layer, an optional content group +named as the layer: hidden layers are off, non-printing ones have `/PrintState /OFF` and locked ones are locked. It needs +PDF 1.5 or later (at 1.4 it warns), and the file reopens with those layers. Images follow the `compression` settings of their kind (`color`, `gray`, or `mono` for black-and-white images): above `abovePpi` as placed they are resampled (`downsample`: `average`, `subsample` or `bicubic`) to `ppi`, and compressed with `zip`, `jpeg` (at `quality`; images with transparency stay lossless) or `auto` (JPEGs stay JPEG, the others