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:
parent
d7e9f2a47a
commit
0e4d3f6871
|
|
@ -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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| Automation | 3 | 60% | Variables (data merge), scripting surface, batch | 8–12 |
|
||||||
|
|
|
||||||
|
|
@ -908,3 +908,6 @@ mod tests_psd;
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests_epsimport;
|
mod tests_epsimport;
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests_pdflayers;
|
||||||
|
|
|
||||||
|
|
@ -76,6 +76,12 @@ pub(super) const OPTIONS: &[FormatOption] = &[
|
||||||
default: "false",
|
default: "false",
|
||||||
description: "keep the layers whose Print option is off (left out otherwise, unless createLayers)",
|
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> {
|
pub fn specs() -> Vec<CommandSpec> {
|
||||||
|
|
@ -85,7 +91,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||||
"Export PDF",
|
"Export PDF",
|
||||||
[],
|
[],
|
||||||
None,
|
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,
|
has_doc,
|
||||||
export_pdf
|
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();
|
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!(changed, ["compatibility", "compression.compressText", "createLayers", "security.copy"], "key order");
|
||||||
assert_eq!(v["changed"][0]["value"], "1.5");
|
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!(!v.to_string().contains("Password"), "passwords never come back");
|
||||||
assert!(s.execute("document.pdfSettings", &json!({"compatibility": "9"})).is_err());
|
assert!(s.execute("document.pdfSettings", &json!({"compatibility": "9"})).is_err());
|
||||||
// With the document: the in-memory export adds the document's own warnings.
|
// 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));
|
||||||
|
}
|
||||||
|
|
@ -421,7 +421,7 @@ fn owner_key(owner: &[u8], r: u8, n: usize) -> Vec<u8> {
|
||||||
|
|
||||||
// ---------- PDF syntax ----------
|
// ---------- PDF syntax ----------
|
||||||
|
|
||||||
fn is_white(b: u8) -> bool {
|
pub(crate) fn is_white(b: u8) -> bool {
|
||||||
matches!(b, 0 | 9 | 10 | 12 | 13 | 32)
|
matches!(b, 0 | 9 | 10 | 12 | 13 | 32)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -430,7 +430,7 @@ fn is_regular(b: u8) -> bool {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
enum Tok<'a> {
|
pub(crate) enum Tok<'a> {
|
||||||
DictOpen,
|
DictOpen,
|
||||||
DictClose,
|
DictClose,
|
||||||
ArrayOpen,
|
ArrayOpen,
|
||||||
|
|
@ -442,14 +442,14 @@ enum Tok<'a> {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A token with its byte span.
|
/// 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.
|
/// Deepest nesting of arrays and dictionaries read.
|
||||||
const MAX_DEPTH: usize = 64;
|
const MAX_DEPTH: usize = 64;
|
||||||
|
|
||||||
/// A parsed object, with what encryption needs: the strings and dictionaries with their spans.
|
/// A parsed object, with what encryption needs: the strings and dictionaries with their spans.
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
enum Obj<'a> {
|
pub(crate) enum Obj<'a> {
|
||||||
/// `start` is where `<<` is, `end` just after `>>`.
|
/// `start` is where `<<` is, `end` just after `>>`.
|
||||||
Dict {
|
Dict {
|
||||||
entries: Vec<(&'a [u8], Obj<'a>)>,
|
entries: Vec<(&'a [u8], Obj<'a>)>,
|
||||||
|
|
@ -474,14 +474,14 @@ enum Obj<'a> {
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<'a> 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 {
|
match self {
|
||||||
Self::Dict { entries, .. } => entries.iter().find(|(k, _)| *k == key).map(|(_, v)| v),
|
Self::Dict { entries, .. } => entries.iter().find(|(k, _)| *k == key).map(|(_, v)| v),
|
||||||
_ => None,
|
_ => None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn name(&self, key: &[u8]) -> Option<&'a [u8]> {
|
pub(crate) fn name(&self, key: &[u8]) -> Option<&'a [u8]> {
|
||||||
match self.get(key)? {
|
match self.get(key)? {
|
||||||
Self::Name(n) => Some(n),
|
Self::Name(n) => Some(n),
|
||||||
_ => None,
|
_ => None,
|
||||||
|
|
@ -524,18 +524,18 @@ impl<'a> Obj<'a> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn int(w: &[u8]) -> Option<i64> {
|
pub(crate) fn int(w: &[u8]) -> Option<i64> {
|
||||||
std::str::from_utf8(w).ok()?.parse().ok()
|
std::str::from_utf8(w).ok()?.parse().ok()
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
struct Lexer<'a> {
|
pub(crate) struct Lexer<'a> {
|
||||||
buf: &'a [u8],
|
buf: &'a [u8],
|
||||||
pos: usize,
|
pos: usize,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<'a> Lexer<'a> {
|
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 }
|
Self { buf, pos }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -557,7 +557,7 @@ impl<'a> Lexer<'a> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn next(&mut self) -> Option<Spanned<'a>> {
|
pub(crate) fn next(&mut self) -> Option<Spanned<'a>> {
|
||||||
self.skip_space();
|
self.skip_space();
|
||||||
let start = self.pos;
|
let start = self.pos;
|
||||||
let b = self.peek_byte(0)?;
|
let b = self.peek_byte(0)?;
|
||||||
|
|
@ -689,7 +689,7 @@ impl<'a> Lexer<'a> {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One object (a reference `n g R` reads as one).
|
/// One object (a reference `n g R` reads as one).
|
||||||
fn object(&mut self, depth: usize) -> Option<Obj<'a>> {
|
pub(crate) fn object(&mut self, depth: usize) -> Option<Obj<'a>> {
|
||||||
if depth > MAX_DEPTH {
|
if depth > MAX_DEPTH {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
|
|
@ -748,7 +748,7 @@ impl<'a> Lexer<'a> {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The header `num generation obj` of an indirect object.
|
/// 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 {
|
let (Some((Tok::Word(n), ..)), Some((Tok::Word(g), ..)), Some((Tok::Word(b"obj"), ..))) = (self.next(), self.next(), self.next()) else {
|
||||||
return None;
|
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()));
|
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");
|
let ty = obj.name(b"Type");
|
||||||
if matches!(ty, Some(b"XRef" | b"ObjStm")) {
|
if matches!(ty, Some(b"XRef" | b"ObjStm")) {
|
||||||
return Err(failed("cross-reference and object streams aren't supported"));
|
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 {
|
if ty == Some(&b"Metadata"[..]) && !h.encrypt_metadata {
|
||||||
return Err(failed("a stream's length isn't a number"));
|
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<Option<StreamSpans>, &'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`.
|
// The data starts after the end of line that follows `stream`.
|
||||||
let mut data = keyword_end;
|
let mut data = keyword_end;
|
||||||
if pdf.get(data) == Some(&b'\r') {
|
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') {
|
if pdf.get(data) == Some(&b'\n') {
|
||||||
data += 1;
|
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 data_end = usize::try_from(*value).ok().and_then(|n| data.checked_add(n)).ok_or("bad stream length")?;
|
||||||
let bytes = pdf.get(data..data_end).ok_or_else(|| failed("a stream runs past the end"))?;
|
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"), ..))) {
|
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 {
|
Ok(Some(((*start, *end), (data, data_end))))
|
||||||
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(())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Encrypt `pdf` (as the export writes it: one cross-reference table) as `set`'s Security
|
/// Encrypt `pdf` (as the export writes it: one cross-reference table) as `set`'s Security
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,7 @@ use vectorcraft_geom::{Affine, BezPath, FillRule, Rect};
|
||||||
use vectorcraft_text::TextLayout;
|
use vectorcraft_text::TextLayout;
|
||||||
use vectorcraft_text::embed::Embedding;
|
use vectorcraft_text::embed::Embedding;
|
||||||
|
|
||||||
|
use crate::forms::Mark;
|
||||||
use crate::lab_spot::{find, rfind};
|
use crate::lab_spot::{find, rfind};
|
||||||
use crate::marks::PageBoxes;
|
use crate::marks::PageBoxes;
|
||||||
use crate::output::ColorOut;
|
use crate::output::ColorOut;
|
||||||
|
|
@ -71,6 +72,11 @@ pub fn export_with_report(doc: &Document, opts: &PdfOptions) -> Result<ExportRep
|
||||||
let mut ex = Exporter::new(doc, set);
|
let mut ex = Exporter::new(doc, set);
|
||||||
ex.non_printing = set.include_non_printing || set.create_layers;
|
ex.non_printing = set.include_non_printing || set.create_layers;
|
||||||
ex.out = w.out.clone();
|
ex.out = w.out.clone();
|
||||||
|
// A knockout page group draws the layers' objects one by one, not layer by layer.
|
||||||
|
ex.layers = set.writes_layers() && !doc.page_knockout;
|
||||||
|
if set.writes_layers() && doc.page_knockout {
|
||||||
|
warnings.push("PDF layers can't be written with a knockout page group: every layer is plain page content".into());
|
||||||
|
}
|
||||||
// Marks and Bleeds: each page is its artboard (the trim box) grown by the bleed, and by the
|
// Marks and Bleeds: each page is its artboard (the trim box) grown by the bleed, and by the
|
||||||
// printer's marks around that.
|
// printer's marks around that.
|
||||||
let bleed = set.bleed_of(doc);
|
let bleed = set.bleed_of(doc);
|
||||||
|
|
@ -97,8 +103,10 @@ pub fn export_with_report(doc: &Document, opts: &PdfOptions) -> Result<ExportRep
|
||||||
};
|
};
|
||||||
w.page(&mut ex, &sheet).map_err(|_| PdfError::BadArtboard(i))?;
|
w.page(&mut ex, &sheet).map_err(|_| PdfError::BadArtboard(i))?;
|
||||||
}
|
}
|
||||||
|
let layers = ex.layers;
|
||||||
w.absorb(ex);
|
w.absorb(ex);
|
||||||
let (bytes, more) = w.finish()?;
|
let (bytes, more) = w.finish()?;
|
||||||
|
let bytes = crate::forms::finish(bytes, doc, set, layers)?;
|
||||||
let bytes = if native.is_some() { crate::editing::seal(bytes)? } else { bytes };
|
let bytes = if native.is_some() { crate::editing::seal(bytes)? } else { bytes };
|
||||||
let bytes = crate::encrypt::protect(bytes, set)?;
|
let bytes = crate::encrypt::protect(bytes, set)?;
|
||||||
warnings.extend(more);
|
warnings.extend(more);
|
||||||
|
|
@ -435,6 +443,11 @@ pub(crate) struct Exporter<'a> {
|
||||||
/// The fonts real text is written in, by face id, with their units per em; `None` for faces
|
/// The fonts real text is written in, by face id, with their units per em; `None` for faces
|
||||||
/// that can't be embedded.
|
/// that can't be embedded.
|
||||||
fonts: HashMap<u32, Option<(krilla::text::Font, f64)>>,
|
fonts: HashMap<u32, Option<(krilla::text::Font, f64)>>,
|
||||||
|
/// 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> {
|
impl<'a> Exporter<'a> {
|
||||||
|
|
@ -457,6 +470,8 @@ impl<'a> Exporter<'a> {
|
||||||
convert: true,
|
convert: true,
|
||||||
outline_text: set.advanced.outline_text,
|
outline_text: set.advanced.outline_text,
|
||||||
fonts: HashMap::new(),
|
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) {
|
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 {
|
if !force && !n.visible {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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<StreamSpans>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Object<'_> {
|
||||||
|
/// The stream's data, decoded (Flate or none), at most `limit` bytes.
|
||||||
|
fn data(&self, pdf: &[u8], limit: u64) -> Option<Vec<u8>> {
|
||||||
|
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<Mark> {
|
||||||
|
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<u32, Object<'a>> {
|
||||||
|
(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<u32, Object<'_>>, 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<u8>, Vec<usize>)> {
|
||||||
|
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<usize> = 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<u8>, doc: &Document, set: &PdfSettings, layers: bool) -> Result<Vec<u8>, 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<u32, Mark> = 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<u32, Object<'_>>,
|
||||||
|
marks: &HashMap<u32, Mark>,
|
||||||
|
patch: &mut Patch,
|
||||||
|
doc: &Document,
|
||||||
|
set: &PdfSettings,
|
||||||
|
) -> Result<(), PdfError> {
|
||||||
|
// One group per layer, in paint order (bottom first).
|
||||||
|
let mut groups: HashMap<usize, u32> = 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!("<</Type/OCG/Name{}/Usage<</View<</ViewState/{}>>/Print<</PrintState/{}>>>>>>", 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::<Vec<_>>().join(" ");
|
||||||
|
// Layers panels list the top layer first.
|
||||||
|
let order: Vec<u32> = 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[<</Event/Print/OCGs[{}]/Category[/Print]>>]", 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<</OCGs[{}]/D<<{config}>>>>", refs(&all)).into_bytes());
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
@ -10,6 +10,8 @@
|
||||||
//! text in embedded subset fonts (Advanced). Colours are converted, tagged with ICC profiles and
|
//! 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,
|
//! 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.
|
//! 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
|
//! 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
|
//! 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,
|
//! dialog's model (standard, compatibility, General, Compression, Marks and Bleeds, Output,
|
||||||
|
|
@ -38,6 +40,7 @@
|
||||||
mod editing;
|
mod editing;
|
||||||
mod encrypt;
|
mod encrypt;
|
||||||
mod export;
|
mod export;
|
||||||
|
mod forms;
|
||||||
mod images;
|
mod images;
|
||||||
mod import;
|
mod import;
|
||||||
mod import_color;
|
mod import_color;
|
||||||
|
|
@ -217,6 +220,8 @@ mod tests_import_layers;
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests_import_options;
|
mod tests_import_options;
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
mod tests_layers;
|
||||||
|
#[cfg(test)]
|
||||||
mod tests_marks;
|
mod tests_marks;
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests_output;
|
mod tests_output;
|
||||||
|
|
|
||||||
|
|
@ -386,7 +386,7 @@ fn catalog(set: &PdfSettings, cmyk_profile: &str) -> Option<Catalog> {
|
||||||
|
|
||||||
/// `s` as a PDF text string: a literal string when it is printable ASCII, else UTF-16BE (with its
|
/// `s` as a PDF text string: a literal string when it is printable ASCII, else UTF-16BE (with its
|
||||||
/// byte order mark) in hex.
|
/// 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)) {
|
if s.bytes().all(|b| (b' '..=b'~').contains(&b)) {
|
||||||
let mut out = String::with_capacity(s.len() + 2);
|
let mut out = String::with_capacity(s.len() + 2);
|
||||||
out.push('(');
|
out.push('(');
|
||||||
|
|
@ -408,7 +408,7 @@ fn text_string(s: &str) -> String {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn deflate(data: &[u8]) -> std::io::Result<Vec<u8>> {
|
pub(crate) fn deflate(data: &[u8]) -> std::io::Result<Vec<u8>> {
|
||||||
use std::io::Write;
|
use std::io::Write;
|
||||||
let mut e = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::default());
|
let mut e = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::default());
|
||||||
e.write_all(data)?;
|
e.write_all(data)?;
|
||||||
|
|
|
||||||
|
|
@ -48,8 +48,13 @@ impl Xref {
|
||||||
Some(Self { at, entries, count, trailer: (start, end) })
|
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.
|
/// Where in-use object `n` starts.
|
||||||
fn offset(&self, pdf: &[u8], n: u32) -> Option<usize> {
|
pub fn offset(&self, pdf: &[u8], n: u32) -> Option<usize> {
|
||||||
let e = self.entries + 20 * usize::try_from(n).ok().filter(|n| *n < self.count)?;
|
let e = self.entries + 20 * usize::try_from(n).ok().filter(|n| *n < self.count)?;
|
||||||
let entry = pdf.get(e..e + 20)?;
|
let entry = pdf.get(e..e + 20)?;
|
||||||
(entry.get(17) == Some(&b'n')).then_some(())?;
|
(entry.get(17) == Some(&b'n')).then_some(())?;
|
||||||
|
|
|
||||||
|
|
@ -103,6 +103,13 @@ choice! {
|
||||||
} default Pdf17
|
} 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! {
|
choice! {
|
||||||
/// How images above the threshold resolution are resampled.
|
/// How images above the threshold resolution are resampled.
|
||||||
Downsample {
|
Downsample {
|
||||||
|
|
@ -437,6 +444,11 @@ impl PdfSettings {
|
||||||
within("advanced.fontSubsetPercent", self.advanced.font_subset_percent, 0.0, 100.0, "%")
|
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).
|
/// Forget the passwords (presets never store them).
|
||||||
pub fn clear_passwords(&mut self) {
|
pub fn clear_passwords(&mut self) {
|
||||||
self.security.open_password.clear();
|
self.security.open_password.clear();
|
||||||
|
|
@ -450,7 +462,7 @@ impl PdfSettings {
|
||||||
[
|
[
|
||||||
(self.thumbnails, "page thumbnails are not embedded yet"),
|
(self.thumbnails, "page thumbnails are not embedded yet"),
|
||||||
(self.fast_web_view, "fast web view (a linearised file) is not written 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,
|
!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",
|
"fonts are embedded as subsets of the characters used: a subset threshold below 100% is not applied",
|
||||||
|
|
|
||||||
|
|
@ -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<Array<'_>>) -> Vec<String> {
|
||||||
|
let ids = refs.map(|a| a.raw_iter().filter_map(|i| i.as_obj_ref()).map(ObjectIdentifier::from).collect::<Vec<_>>()).unwrap_or_default();
|
||||||
|
ids.into_iter()
|
||||||
|
.map(|id| {
|
||||||
|
let g = xref.get::<Dict<'_>>(id).unwrap();
|
||||||
|
String::from_utf8(g.get::<hayro_syntax::object::String<'_>>(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<Dict<'_>>, &hayro_syntax::xref::XRef)) {
|
||||||
|
let file = crate::pages::open(bytes, password).unwrap();
|
||||||
|
let xref = file.xref();
|
||||||
|
let catalog = xref.get::<Dict<'_>>(xref.root_id()).unwrap();
|
||||||
|
f(catalog.get::<Dict<'_>>(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<Color>)> {
|
||||||
|
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::<Dict<'_>>(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::<hayro_syntax::object::String<'_>>(b"Name").is_some());
|
||||||
|
let auto = config.get::<Array<'_>>(b"AS").unwrap().iter::<Dict<'_>>().next().unwrap();
|
||||||
|
assert_eq!(auto.get::<Name<'_>>(b"Event").unwrap().as_ref(), b"Print");
|
||||||
|
let print_state = |name: &str| {
|
||||||
|
let ids =
|
||||||
|
props.get::<Array<'_>>(b"OCGs").unwrap().raw_iter().filter_map(|i| i.as_obj_ref()).map(ObjectIdentifier::from).collect::<Vec<_>>();
|
||||||
|
let g = ids
|
||||||
|
.into_iter()
|
||||||
|
.map(|id| xref.get::<Dict<'_>>(id).unwrap())
|
||||||
|
.find(|g| g.get::<hayro_syntax::object::String<'_>>(b"Name").unwrap().as_bytes() == name.as_bytes());
|
||||||
|
let usage = g.unwrap().get::<Dict<'_>>(b"Usage").unwrap();
|
||||||
|
usage.get::<Dict<'_>>(b"Print").unwrap().get::<Name<'_>>(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::<Dict<'_>>(b"D").unwrap();
|
||||||
|
assert!(config.get::<Array<'_>>(b"AS").is_none() && config.get::<hayro_syntax::object::String<'_>>(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::<Dict<'_>>(b"D").unwrap().get::<Array<'_>>(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");
|
||||||
|
}
|
||||||
|
|
@ -78,7 +78,7 @@ fn options_not_applied_yet_come_back_as_warnings() {
|
||||||
for (v, word) in [
|
for (v, word) in [
|
||||||
(json!({"thumbnails": true}), "thumbnails"),
|
(json!({"thumbnails": true}), "thumbnails"),
|
||||||
(json!({"fastWebView": true}), "fast web view"),
|
(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!({"advanced": {"outlineText": false, "fontSubsetPercent": 35}}), "subset"),
|
||||||
(json!({"output": {"outputIntent": "No Such Press"}}), "without embedding"),
|
(json!({"output": {"outputIntent": "No Such Press"}}), "without embedding"),
|
||||||
(json!({"output": {"registry": "http://example.com"}}), "condition identifier"),
|
(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": {"useDocument": true, "top": 9}}),
|
||||||
json!({"bleed": {"top": 9}, "marks": {"trim": true, "registration": true, "colorBars": true, "pageInfo": true}}),
|
json!({"bleed": {"top": 9}, "marks": {"trim": true, "registration": true, "colorBars": true, "pageInfo": true}}),
|
||||||
json!({"includeNonPrinting": true}),
|
json!({"includeNonPrinting": true}),
|
||||||
|
json!({"createLayers": true}),
|
||||||
json!({"viewAfterSaving": true}),
|
json!({"viewAfterSaving": true}),
|
||||||
json!({"compression": {"compressText": false}}),
|
json!({"compression": {"compressText": false}}),
|
||||||
json!({"compression": {"color": {"compression": "jpeg"}, "mono": {"compression": "ccittG4"}}}),
|
json!({"compression": {"color": {"compression": "jpeg"}, "mono": {"compression": "ccittG4"}}}),
|
||||||
|
|
|
||||||
|
|
@ -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, "thumbnails", "Embed page thumbnails", true);
|
||||||
flag(ui, d, "fastWebView", "Optimize for fast web view", true);
|
flag(ui, d, "fastWebView", "Optimize for fast web view", true);
|
||||||
flag(ui, d, "viewAfterSaving", "View PDF after saving", 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::<Compatibility>(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);
|
flag(ui, d, "includeNonPrinting", "Include non-printing layers", true);
|
||||||
if editor {
|
if editor {
|
||||||
heading(ui, "Description");
|
heading(ui, "Description");
|
||||||
|
|
@ -975,4 +977,23 @@ mod tests {
|
||||||
standard_chosen(d, Standard::PdfX4);
|
standard_chosen(d, Standard::PdfX4);
|
||||||
assert_eq!((get(d, "compatibility"), get(d, "createLayers")), (&json!("1.6"), &json!(true)));
|
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"]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
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
|
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.
|
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
|
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
|
`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
|
with `zip`, `jpeg` (at `quality`; images with transparency stay lossless) or `auto` (JPEGs stay JPEG, the others
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue