diff --git a/README.md b/README.md index fbd85cd9..08e3dd98 100644 --- a/README.md +++ b/README.md @@ -172,7 +172,7 @@ milestones, and honest time-to-parity estimates. **Where we are (2026-10-05):** roughly 68–74% of Illustrator's features exist and work, and about 40–55% of "a power user can't tell the difference". Everyday vector illustration is close to usable: drawing and path tools, Pathfinder and Shape Builder, paint, gradients, appearance and transparency, type with styles and threading, and -files (SVG, PDF and PDF-compatible `.ai`, EPS, DXF, raster formats, Print, Package). The scores are +files (SVG, PDF and PDF-compatible `.ai` with PDF/X, EPS, DXF, EMF/WMF, raster formats and PSD, Print, Package). The scores are self-assessed, so the [honest assessment](ROADMAP.md#honest-assessment-2026-10-05) explains how far to trust them. **What's missing:** @@ -187,7 +187,7 @@ self-assessed, so the [honest assessment](ROADMAP.md#honest-assessment-2026-10-0 advanced type, then hardening and packaging for 1.0. The prioritized list is in [Where we're lacking](ROADMAP.md#where-were-lacking-in-priority-order). -**Workspace:** `crates/{geom, color, doc, pathops, text, effects, trace, brush, render, svg, pdf, format, tools, engine, ui-egui, mcp, testkit}` +**Workspace:** `crates/{geom, color, doc, pathops, text, effects, trace, brush, render, svg, pdf, eps, cad, metafile, format, tools, engine, ui-egui, mcp, testkit}` and `apps/{vectorcraft, vectorcraft-cli, vectorcraft-web}`. The egui frontend is its own crate, so the UI can be swapped without touching the engine. diff --git a/ROADMAP.md b/ROADMAP.md index 6a6007b6..7c39a1ba 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -126,7 +126,7 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist - **Colour, type and file workflows:** Recolor Artwork (dialog with harmonies), Edit Colors, Find & Replace, Change Case, Smart Punctuation, Guides, Lock/Hide Above, Transform Each, Rasterize. - **Files (M4):** - `.vectorcraft` (lossless JSON, compressed, atomic saves, save down to v1), Save As with a format chooser, Save a Copy, Revert, templates, Data Recovery after a crash, background save and export. - - SVG/SVGZ in and out with SVG Options, Preserve Editing, symbols, filters, rich text and physical units; PDF and PDF-compatible `.ai` in and out (layers, masks, editable text, spot colours, presets, security, marks and bleed, ICC output intents, raster effects, subset fonts, PDF/X, PDF layers, overprint, page thumbnails, fast web view). + - SVG/SVGZ in and out with SVG Options, Preserve Editing, symbols, filters, rich text and physical units; PDF and PDF-compatible `.ai` in and out (layers, masks, editable text, spot colours, presets, security, marks and bleed, ICC output intents, raster effects, subset fonts, PDF/X, PDF layers, overprint, page thumbnails, fast web view, PDF 1.3). - EPS and DXF in and out, EMF/WMF in and out; PNG (with PNG-8), JPEG, WebP, GIF, TIFF, BMP, Targa and layered PSD export. - Place and the Links panel, Package, File Info, Print with print presets, PostScript output and print tiling, slices and Save for Web, Export for Screens and Asset Export, CSS Properties, PNG/PDF/SVG/text clipboard flavours. - **Performance:** 20k shapes + 1k texts render in 27 ms per full-retina frame (7.8 ms zoomed), 7× faster than the first version. The UI thread never blocks. The web build is 7.1 MB gzipped. @@ -139,8 +139,8 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist | M0 | Skeleton + vertical slice | ✅ done | — | | 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, PDF layers, overprint, page thumbnails, fast web view), 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 | +| 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; confirm the Scale Strokes & Effects default | 4–8 | +| M4 | Files (native, SVG, PDF, raster, Export for Screens, clipboard interop) | ✅ 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, overprint, page thumbnails, fast web view, PDF 1.3), 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, overprint read back from PDF, polish | 3–6 | | 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 | @@ -174,7 +174,7 @@ about 1–1.5 agent-hours each, so the other rows (estimated on the older scale) | Path operations, Pathfinder, Shape Builder, Live Paint | 5 | 85% | Live Paint gap options, Shape Builder edge cases | 3–6 | | Colour, swatches, gradients, patterns, mesh, recolor | 7 | 96% | freeform and mixed spot/process gradients in SVG/PDF (exported as stops or process colours), pattern fills in Expand | 2–4 | | Strokes, brushes, width profiles | 5 | 88% | brush options depth, brush libraries (generated in code) | 6–10 | -| Appearance, transparency, graphic styles, masks | 5 | 97% | flattener presets in PDF/EPS/print, raster effect reach in preview bounds | 2–4 | +| Appearance, transparency, graphic styles, masks | 5 | 98% | raster effect reach in preview bounds (flattener presets apply in PDF, EPS and print) | 1–2 | | Live vector effects | 5 | 85% | Outline Object, Pathfinder Hard/Soft Mix and Trap, SVG Filters | 6–10 | | Raster effects (Effect Gallery, Document Raster Effects Settings) | 4 | 20% | ~55 Photoshop-style filters (Artistic, Brush Strokes, Distort, Pixelate, Sketch, Stylize, Texture, Video) and the Effect Gallery; Document Raster Effects Settings and raster effects in PDF are done | 28–42 | | 3D and Materials | 4 | 0% | Extrude & Bevel, Revolve, Inflate, Rotate, lighting, materials (software renderer) | 50–80 | @@ -187,7 +187,7 @@ about 1–1.5 agent-hours each, so the other rows (estimated on the older scale) | 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, 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 | 80% | flattener presets in PDF export, print preview fidelity (overprint in PDF and composite print, Print dialog, presets, PostScript, marks, separations, Print Tiling, print overprint/flattener/bitmap options and printer profile are done) | 1–3 | +| Print, colour management, separations, flattener | 3 | 80% | print preview fidelity (flattener presets in PDF and print, overprint in PDF and composite print, Print dialog, presets, PostScript, marks, separations, Print Tiling, print overprint/flattener/bitmap options and printer profile are done) | 1–2 | | Automation | 3 | 60% | Variables (data merge), scripting surface, batch | 8–12 | | UI chrome (panels, contextual Properties, workspaces, prefs) | 6 | 78% | Variables, SVG Interactivity, Properties per context, Consolidate All Windows (Links, Asset Export, CSS Properties, Attributes are done) | 15–25 | | Libraries, Links, Package | 2 | 50% | a local Libraries panel (no cloud by design); Links and Package are done | 5–8 | diff --git a/crates/engine/src/cmd/fileio/eps.rs b/crates/engine/src/cmd/fileio/eps.rs index cac1500e..31953251 100644 --- a/crates/engine/src/cmd/fileio/eps.rs +++ b/crates/engine/src/cmd/fileio/eps.rs @@ -135,11 +135,7 @@ fn choice(field: &str, v: Option, read: impl Fn(&str) -> Option, d /// The flattener options `p` asks for (`flattenerPreset` and `flattener` over it), built-in /// presets and `saved` ones; discarded overprints are dropped from the flattened art too. fn flattener(p: &Params, saved: &[super::super::FlattenerPreset]) -> Result { - let mut q = json!({ "options": p.flattener.clone().unwrap_or(Value::Null) }); - if let Some(name) = &p.flattener_preset { - q["preset"] = json!(name); - } - FlattenOptions::from_params_with(&q, saved).map_err(|e| bad(C, format!("EPS flattener: {e}"))) + FlattenOptions::for_export(p.flattener_preset.as_deref(), p.flattener.as_ref(), saved).map_err(|e| bad(C, format!("EPS flattener: {e}"))) } fn params(p: &Value) -> Result { @@ -176,7 +172,7 @@ fn parse(p: &Value) -> Result { /// `saved` it names written out, as the encoder needs it. pub fn resolve(p: &Value, saved: &[super::super::FlattenerPreset]) -> std::result::Result { let run = || -> Result { - let q = expanded(p, saved)?.unwrap_or_else(|| p.clone()); + let q = expanded(C, p, saved)?.unwrap_or_else(|| p.clone()); parse(&q)?; Ok(q) }; @@ -185,10 +181,10 @@ pub fn resolve(p: &Value, saved: &[super::super::FlattenerPreset]) -> std::resul /// `p` with a saved flattener preset it names written out as `flattener` options, for the /// encoder, which knows only the built-in presets; `None` when `p` names none. -fn expanded(p: &Value, saved: &[super::super::FlattenerPreset]) -> Result> { +fn expanded(cmd: &str, p: &Value, saved: &[super::super::FlattenerPreset]) -> Result> { match str_param(p, "flattenerPreset") { - Some(name) if FlattenOptions::preset(name).is_none() => { - let o = flattener(¶ms(p)?, saved)?; + Some(name) if !name.trim().is_empty() && FlattenOptions::preset(name).is_none() => { + let o = FlattenOptions::for_export(Some(name), p.get("flattener"), saved).map_err(|e| bad(cmd, format!("flattener: {e}")))?; let mut q = p.clone(); q["flattener"] = serde_json::to_value(o).map_err(|e| EngineError::Other(e.to_string()))?; if let Some(m) = q.as_object_mut() { @@ -299,8 +295,8 @@ fn export_eps(s: &mut Session, p: &Value) -> Result { } /// `p` with a saved flattener preset of the session's written out (see [`expanded`]). -pub(super) fn with_flattener<'a>(s: &Session, p: Cow<'a, Value>) -> Result> { - Ok(match expanded(&p, &s.prefs.flattener_presets)? { +pub(super) fn with_flattener<'a>(s: &Session, cmd: &str, p: Cow<'a, Value>) -> Result> { + Ok(match expanded(cmd, &p, &s.prefs.flattener_presets)? { Some(q) => Cow::Owned(q), None => p, }) diff --git a/crates/engine/src/cmd/fileio/export.rs b/crates/engine/src/cmd/fileio/export.rs index b794d075..79fd3aa0 100644 --- a/crates/engine/src/cmd/fileio/export.rs +++ b/crates/engine/src/cmd/fileio/export.rs @@ -104,8 +104,7 @@ pub(super) fn without_artboards(p: &Value) -> Value { /// `p` with the saved presets it names (PDF, flattener) written out as options, for the encoders, /// which know only the built-in ones. fn expand_presets<'a>(s: &Session, cmd: &str, p: &'a Value) -> Result> { - let pdf = super::pdf::expand_preset(s, cmd, p)?; - super::eps::with_flattener(s, pdf) + super::pdf::expand_preset(s, cmd, p) } /// The document an export of `f` writes: the active one (see [`export_source`]), or for text diff --git a/crates/engine/src/cmd/fileio/mod.rs b/crates/engine/src/cmd/fileio/mod.rs index 695fcfd2..067f2197 100644 --- a/crates/engine/src/cmd/fileio/mod.rs +++ b/crates/engine/src/cmd/fileio/mod.rs @@ -914,3 +914,6 @@ mod tests_pdflayers; #[cfg(test)] mod tests_pdfwebview; + +#[cfg(test)] +mod tests_pdf13; diff --git a/crates/engine/src/cmd/fileio/pdf.rs b/crates/engine/src/cmd/fileio/pdf.rs index 154d8acb..d87da6e9 100644 --- a/crates/engine/src/cmd/fileio/pdf.rs +++ b/crates/engine/src/cmd/fileio/pdf.rs @@ -8,7 +8,7 @@ use std::sync::Arc; use serde::Deserialize; use serde_json::{Value, json}; use vectorcraft_doc::{Document, Node, NodeKind}; -use vectorcraft_pdf::{PdfError, PdfOptions, PdfPreset, PdfSettings, Standard, THUMBNAIL_SIZE, Thumbnail}; +use vectorcraft_pdf::{Overprint, PdfError, PdfOptions, PdfPreset, PdfSettings, Standard, THUMBNAIL_SIZE, Thumbnail}; use super::super::flatten::{FlattenOptions, flatten_document, see_through_images}; use super::super::*; @@ -38,7 +38,12 @@ pub(super) const OPTIONS: &[FormatOption] = &[ default: "\"none\"", description: "none | pdfA2b | pdfX1a | pdfX3 | pdfX4 (choosing one sets the compatibility it allows; see document.exportPdf)", }, - FormatOption { name: "compatibility", ty: "string", default: "\"1.7\"", description: "PDF version: 1.4 | 1.5 | 1.6 | 1.7 | 2.0" }, + FormatOption { + name: "compatibility", + ty: "string", + default: "\"1.7\"", + description: "PDF version: 1.3 (transparency flattened) | 1.4 | 1.5 | 1.6 | 1.7 | 2.0", + }, FormatOption { name: "preserveEditing", ty: "boolean", @@ -69,7 +74,7 @@ pub(super) const OPTIONS: &[FormatOption] = &[ name: "security", ty: "object", default: "null", - description: "passwords and permissions (the file is encrypted: RC4 128-bit at 1.4–1.5, AES-128 at 1.6, AES-256 at 1.7/2.0)", + description: "passwords and permissions (the file is encrypted: RC4 40-bit at 1.3, RC4 128-bit at 1.4–1.5, AES-128 at 1.6, AES-256 at 1.7/2.0)", }, FormatOption { name: "includeNonPrinting", @@ -83,6 +88,18 @@ pub(super) const OPTIONS: &[FormatOption] = &[ default: "false", description: "top-level layers as PDF layers (optional content groups with their visibility, print state and lock; PDF 1.5 or later)", }, + FormatOption { + name: "flattenerPreset", + ty: "string", + default: "\"\"", + description: "the flattener preset PDF 1.3 files (PDF/X-1a and PDF/X-3 too) flatten transparency with: empty (High Resolution), high, medium, low or a saved one", + }, + FormatOption { + name: "flattener", + ty: "object", + default: "null", + description: "flattener options over flattenerPreset (as object.flattenTransparency)", + }, ]; pub fn specs() -> Vec { @@ -92,7 +109,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? (each page drawn as a /Thumb image, 106 px on its long side), fastWebView? (a linearised file: the first page shows while the rest downloads; it stays linearised when encrypted), viewAfterSaving? (the app opens the written file), createLayers? (each 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 (overprinting fills and strokes set /OP /op true, /OPM 1; white ones knock out with document.setup discardWhiteOverprint)|discard (they knock out)}, security?: {openPassword, permissionsPassword, printing: none|low|high, changes: none|pages|forms|comments|any, copy, screenReader, plaintextMetadata}} → {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 flattenerPreset; PDF 1.3)|pdfX3 (colours ICC-tagged; transparency flattened; PDF 1.3)|pdfX4 (transparency and layers kept; colours ICC-tagged; PDF 1.6 at most) (PDF/X: a /GTS_PDFX output intent, by default the CMYK profile in effect, embedded; TrimBox, Trapped, GTS_PDFXVersion, title and dates; fonts embedded or outlined), compatibility?: 1.3 (no transparency: it is flattened with flattenerPreset; images keep no see-through pixels)|1.4|1.5|1.6|1.7 (default)|2.0 (choosing a standard sets its version: 1.7 for pdfA2b, 1.6 for pdfX4, 1.3 for pdfX1a and pdfX3, which write 1.4 as 1.3 too; pdfA2b refuses 1.3), preserveEditing? (on in VectorCraft Default: the native document as an embedded file, which document.open restores; choosing a standard turns it off, a standard refuses it), thumbnails? (each page drawn as a /Thumb image, 106 px on its long side), fastWebView? (a linearised file: the first page shows while the rest downloads; it stays linearised when encrypted), viewAfterSaving? (the app opens the written file), createLayers? (each 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), flattenerPreset?: \"\" (High Resolution) | high | medium | low | a saved preset (flattener.presets.list): what PDF 1.3 files flatten transparency with, flattener?: {object.flattenTransparency options over it}, compression?: {color?, gray?: {downsample: none|average|subsample|bicubic, ppi: 300, abovePpi: 450, compression: none|zip|jpeg|jpeg2000|auto, quality: minimum|low|medium|high|maximum}, mono?: {downsample, ppi: 1200, abovePpi: 1800, compression: none|ccittG3|ccittG4|zip|runLength} (black-and-white images), compressText?: true} (images above abovePpi are resampled to ppi; auto keeps JPEGs JPEG, the others lossless; JPEG needs opaque images; none, jpeg2000, CCITT and runLength are written as ZIP with a warning), marks?: {trim, registration, colorBars, pageInfo, kind: roman|japanese, weight: 0.25 (trim marks and targets), offset: 6 (pt from the artboard, at least the bleed)} (printer's marks in [Registration], every plate: trim marks, registration targets mid-side, CMYK/spot/black-tint colour bars on top, page information (title, artboard, date UTC) below), bleed?: {useDocument (the document's bleed, document.setup), top, bottom, left, right} (pt; art in the bleed is kept). 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 (overprinting fills and strokes set /OP /op true, /OPM 1; white ones knock out with document.setup discardWhiteOverprint)|discard (they knock out)}, security?: {openPassword, permissionsPassword, printing: none|low|high, changes: none|pages|forms|comments|any, copy, screenReader, plaintextMetadata}} → {path, bytes, warnings}; no path → {dataBase64, bytes, warnings}. The options apply over the preset (null keeps its value); options accepted but not applied yet come back as warnings; a standard with a PDF version it doesn't allow and a password with a standard are refused, and so is a PDF/X file that would still break its standard (transparency the flattener left, RGB in pdfX1a, a font not embedded) or a PDF 1.3 file with transparency left. document.export {format: pdf} takes the same options", has_doc, export_pdf ), @@ -198,10 +215,11 @@ pub fn settings(cmd: &str, p: &Value) -> Result { settings_with(cmd, p, &[]) } -/// `p` with a saved preset it names written out as options, for the encoders, which know only the -/// built-in presets. Params naming no preset, or a built-in one, come back as they are. +/// `p` with the saved presets it names (a PDF preset, a flattener preset: see +/// [`super::eps::with_flattener`]) written out as options, for the encoders, which know only the +/// built-in presets. Params naming none come back as they are. pub fn expand_preset<'a>(s: &Session, cmd: &str, p: &'a Value) -> Result> { - match str_param(p, "preset") { + let expanded = match str_param(p, "preset") { Some(name) if vectorcraft_pdf::builtin_preset(name).is_none() => { let set = settings_with(cmd, p, &s.prefs.pdf_presets)?; let mut q = p.clone(); @@ -209,10 +227,11 @@ pub fn expand_preset<'a>(s: &Session, cmd: &str, p: &'a Value) -> Result Ok(Cow::Borrowed(p)), - } + _ => Cow::Borrowed(p), + }; + super::eps::with_flattener(s, cmd, expanded) } /// The full export options for `doc`: settings plus the artboards `p` picks (default all). @@ -249,27 +268,36 @@ pub(super) fn encode_carrying(cmd: &str, doc: &Document, p: &Value, native: impl warnings.push(EDITING_NEEDS_EVERY_ARTBOARD.to_string()); } } - let flat = flatten_for(doc, &opts.settings)?; + let set = &opts.settings; + let flat = flatten_for(cmd, doc, set, p)?; if flat.is_some() { - warnings.push(format!( - "transparency was flattened with the High Resolution flattener preset: {} files have none", - opts.settings.standard.label() - )); + let kind = if set.standard.flattens() { set.standard.label() } else { set.compatibility.label() }; + warnings.push(format!("transparency is flattened into opaque art and images ({kind} files have none): see the flattener preset")); } let r = super::super::rasterfx::export_pdf_with_report(flat.as_ref().unwrap_or(doc), &opts).map_err(|e| pdf_error(cmd, e))?; warnings.extend(r.warnings); Ok((r.bytes, warnings)) } -/// `doc` with its transparency flattened for a standard whose files have none (PDF/X-1a, PDF/X-3), -/// with the High Resolution flattener preset; images with see-through pixels count as -/// transparency. `None` when nothing changes (or the standard keeps transparency). -fn flatten_for(doc: &Document, set: &PdfSettings) -> Result> { - if !set.standard.flattens() { +/// The flattener preset of PDF 1.3 files whose `flattenerPreset` is empty. +pub const DEFAULT_FLATTENER: &str = "High Resolution"; + +/// `doc` with its transparency flattened for a PDF 1.3 file (asked for, or PDF/X-1a and PDF/X-3, +/// whose files have none), with `flattenerPreset` (default High Resolution) and `p`'s `flattener` +/// options over it; images with see-through pixels count as transparency, rasterized areas are +/// clipped to their regions, and discarded overprints are dropped. `None` when nothing changes (or +/// the file keeps transparency). +fn flatten_for(cmd: &str, doc: &Document, set: &PdfSettings, p: &Value) -> Result> { + if !set.pdf13() { return Ok(None); } - let mut o = FlattenOptions::preset("high").unwrap_or_default(); + let name = Some(set.flattener_preset.trim()).filter(|n| !n.is_empty()).unwrap_or(DEFAULT_FLATTENER); + let mut o = FlattenOptions::for_export(Some(name), p.get("flattener"), &[]).map_err(|e| bad(cmd, format!("PDF flattener: {e}")))?; + o.preserve_overprints &= set.advanced.overprint == Overprint::Preserve; o.no_transparency = Some(see_through_images(doc)); + // Without soft masks a rasterized area can't be a rectangle with see-through pixels around + // its art: it is clipped to its regions (opaque inside them). + o.clip_complex_regions = true; flatten_document(doc, &o) } diff --git a/crates/engine/src/cmd/fileio/tests_pdf.rs b/crates/engine/src/cmd/fileio/tests_pdf.rs index 7cfad879..60823c2c 100644 --- a/crates/engine/src/cmd/fileio/tests_pdf.rs +++ b/crates/engine/src/cmd/fileio/tests_pdf.rs @@ -60,7 +60,9 @@ fn export_pdf_writes_the_compatibility_header_and_the_range() { fn bad_options_are_refused() { let mut s = session(2); for p in [ - json!({"compatibility": "1.3"}), + json!({"compatibility": "1.2"}), + json!({"compatibility": "1.3", "standard": "pdfA2b"}), + json!({"compatibility": "1.3", "flattenerPreset": "Nope"}), json!({"compatibility": 1.5}), json!({"range": "1-3"}), json!({"range": "0"}), diff --git a/crates/engine/src/cmd/fileio/tests_pdf13.rs b/crates/engine/src/cmd/fileio/tests_pdf13.rs new file mode 100644 index 00000000..53c1bf9a --- /dev/null +++ b/crates/engine/src/cmd/fileio/tests_pdf13.rs @@ -0,0 +1,155 @@ +//! `document.exportPdf {compatibility: "1.3", flattenerPreset?, flattener?}`: a PDF 1.3 file has +//! no transparency (no soft masks, blend modes, constant opacity or transparency groups): it is +//! flattened with the chosen preset, and reads back looking like the canvas. + +use serde_json::{Value, json}; +use vectorcraft_geom::Rect; +use vectorcraft_render::{Rendered, Renderer}; + +use super::*; + +/// A document with overlapping transparency: a half-opaque rectangle, a Multiply ellipse, a +/// gradient fading out, a drop shadow and a PNG with see-through pixels, in an isolated page. +fn session() -> Session { + let mut s = Session::new(); + let run = |s: &mut Session, id: &str, p: Value| s.execute(id, &p).unwrap_or_else(|e| panic!("{id} {p}: {e}")); + run(&mut s, "file.new", json!({"width": 200, "height": 160})); + run(&mut s, "shape.rectangle", json!({"x": 10, "y": 10, "width": 90, "height": 70})); + run(&mut s, "paint.setFill", json!({"color": "#2060e0"})); + run(&mut s, "shape.rectangle", json!({"x": 50, "y": 40, "width": 90, "height": 70})); + run(&mut s, "paint.setFill", json!({"color": "#f02010"})); + run(&mut s, "transparency.set", json!({"opacity": 50})); + run(&mut s, "shape.ellipse", json!({"x": 90, "y": 20, "width": 80, "height": 80})); + run(&mut s, "paint.setFill", json!({"color": "#20c040"})); + run(&mut s, "transparency.set", json!({"blendMode": "multiply"})); + let shadowed = run(&mut s, "shape.rectangle", json!({"x": 20, "y": 100, "width": 50, "height": 40}))["id"].clone(); + run(&mut s, "paint.setFill", json!({"color": "#e0c020"})); + run(&mut s, "effect.apply", json!({"effect": "stylize.dropShadow", "ids": [shadowed]})); + let mut png = vec![]; + image::RgbaImage::from_fn(8, 8, |x, _| image::Rgba([0, 120, 255, if x < 4 { 0 } else { 255 }])) + .write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png) + .unwrap(); + run(&mut s, "file.place", json!({"name": "dots.png", "dataBase64": vectorcraft_format::base64_encode(&png), "at": [150, 120]})); + s +} + +fn export(s: &mut Session, p: Value) -> (Vec, Vec) { + let v = s.execute("document.exportPdf", &p).unwrap_or_else(|e| panic!("{p}: {e}")); + let bytes = vectorcraft_format::base64_decode(v["dataBase64"].as_str().unwrap()).unwrap(); + (bytes, v["warnings"].as_array().unwrap().iter().map(|w| w.as_str().unwrap().to_string()).collect()) +} + +/// The transparency `pdf` has, in its dictionaries. +fn transparency(pdf: &[u8]) -> Vec<&'static str> { + let text = String::from_utf8_lossy(pdf); + let mut found = vec![]; + for (what, there) in [ + ("soft masks", text.contains("/SMask") && !text.contains("/SMask/None")), + ("blend modes", text.contains("/BM") && !text.contains("/BM/Normal")), + ("transparency groups", text.contains("/S/Transparency")), + ("opacity", text.contains("/ca 0.") || text.contains("/CA 0.")), + ] { + if there { + found.push(what); + } + } + found +} + +fn render(d: &vectorcraft_doc::Document) -> Rendered { + Renderer::new().render_region(d, Rect::new(0.0, 0.0, 200.0, 160.0), 1.0, true) +} + +/// Mean difference of the colour channels of two renders, as a share of full scale. +fn mean_diff(a: &Rendered, b: &Rendered) -> f64 { + let sum: u64 = a.pixels.iter().zip(&b.pixels).map(|(x, y)| x.abs_diff(*y) as u64).sum(); + sum as f64 / a.pixels.len() as f64 / 255.0 +} + +#[test] +fn pdf_1_3_flattens_all_transparency_and_looks_the_same() { + let mut s = session(); + let before = s.doc().unwrap().doc.clone(); + // The same document at PDF 1.4 has transparency. + let (pdf, _) = export(&mut s, json!({"compatibility": "1.4", "compression": {"compressText": false}, "preserveEditing": false})); + assert!(transparency(&pdf).len() >= 3, "{:?}", transparency(&pdf)); + let (pdf, warnings) = export(&mut s, json!({"compatibility": "1.3", "compression": {"compressText": false}, "preserveEditing": false})); + assert!(pdf.starts_with(b"%PDF-1.3")); + assert!(String::from_utf8_lossy(&pdf).contains("1.3<"), "the metadata says 1.3 too"); + assert_eq!(transparency(&pdf), Vec::<&str>::new()); + assert!(warnings.iter().any(|w| w.contains("flattened") && w.contains("PDF 1.3")), "{warnings:?}"); + // It reads back looking like the canvas. + let canvas = render(&before); + let diff = mean_diff(&canvas, &render(&vectorcraft_pdf::import(&pdf).unwrap())); + assert!(diff < 0.02, "read back {:.2}% off", diff * 100.0); + // The document itself is untouched. + assert_eq!(s.doc().unwrap().doc, before); + // The writer refuses a document that isn't flat; a flat one with isolated page blending gets + // no page group. + let opts = vectorcraft_pdf::PdfOptions { settings: serde_json::from_value(json!({"compatibility": "1.3"})).unwrap(), ..Default::default() }; + let e = vectorcraft_pdf::export(&before, &opts).unwrap_err(); + assert!(matches!(&e, vectorcraft_pdf::PdfError::Unsupported(m) if m.contains("PDF 1.3")), "{e}"); + let mut flat = vectorcraft_doc::Document::new(100.0, 100.0); + flat.page_isolate = true; + let l = flat.default_layer().unwrap(); + let id = flat.alloc_id(); + let rect = vectorcraft_geom::shapes::rectangle(Rect::new(10.0, 10.0, 50.0, 50.0)); + let paint = vectorcraft_color::Paint::solid(vectorcraft_color::Color::BLACK); + flat.insert(Some(l), 0, vectorcraft_doc::Node::path(id, rect, vectorcraft_doc::Appearance::basic(paint, vectorcraft_color::Paint::None, 0.0))) + .unwrap(); + assert!(transparency(&vectorcraft_pdf::export(&flat, &opts).unwrap()).is_empty()); +} + +#[test] +fn the_flattener_preset_and_options_apply() { + let mut s = session(); + let images = |pdf: &[u8]| String::from_utf8_lossy(pdf).matches("/Subtype/Image").count(); + let p = |extra: Value| { + let mut p = json!({"compatibility": "1.3", "preserveEditing": false}); + super::pdf::merge(&mut p, &extra); + p + }; + let (high, _) = export(&mut s, p(json!({}))); + let (default, _) = export(&mut s, p(json!({"flattenerPreset": "High Resolution"}))); + assert_eq!(high.len(), default.len(), "the default is High Resolution"); + let (low, _) = export(&mut s, p(json!({"flattenerPreset": "low"}))); + assert!(low.len() < high.len(), "lower resolution rasters"); + // All raster: one image a flattened group at most, and still flat. + let (raster, _) = export(&mut s, p(json!({"flattener": {"balance": 0}}))); + assert!(images(&raster) >= 1 && transparency(&raster).is_empty()); + // A saved preset by name, through every PDF path; an unknown one is refused. + s.execute("flattener.presets.save", &json!({"name": "All Raster", "preset": "high", "balance": 0})).unwrap(); + let (saved, _) = export(&mut s, p(json!({"flattenerPreset": "All Raster"}))); + assert_eq!(saved.len(), raster.len()); + let v = s + .execute("document.export", &json!({"format": "pdf", "compatibility": "1.3", "flattenerPreset": "All Raster", "preserveEditing": false})) + .unwrap(); + assert_eq!(vectorcraft_format::base64_decode(v["dataBase64"].as_str().unwrap()).unwrap().len(), raster.len()); + s.execute("pdf.preset.save", &json!({"name": "Old Press", "compatibility": "1.3", "flattenerPreset": "All Raster", "preserveEditing": false})) + .unwrap(); + assert_eq!(export(&mut s, json!({"preset": "Old Press"})).0.len(), raster.len(), "a PDF preset keeps its flattener preset"); + assert!(s.execute("document.exportPdf", &p(json!({"flattenerPreset": "Nope"}))).is_err()); + // Other versions keep their transparency, whatever the preset. + let (kept, warnings) = export(&mut s, json!({"flattenerPreset": "low", "compression": {"compressText": false}})); + assert!(!transparency(&kept).is_empty() && !warnings.iter().any(|w| w.contains("flattened"))); +} + +#[test] +fn pdf_1_3_files_are_encrypted_with_40_bit_rc4_and_can_be_linearised() { + let mut s = session(); + let (pdf, warnings) = export( + &mut s, + json!({"compatibility": "1.3", "fastWebView": true, "thumbnails": true, "security": {"openPassword": "open", "permissionsPassword": "owner", "plaintextMetadata": false}}), + ); + assert!(pdf.starts_with(b"%PDF-1.3") && warnings.iter().all(|w| !w.contains("metadata")), "{warnings:?}"); + let text = String::from_utf8_lossy(&pdf); + assert!(text.contains("/V 1/R 2/Length 40") && text.contains("/Linearized 1") && text.contains("/Thumb ")); + for password in ["open", "owner"] { + s.execute("document.close", &json!({"force": true})).ok(); + s.execute("document.open", &json!({"dataBase64": vectorcraft_format::base64_encode(&pdf), "name": "old.pdf", "password": password})).unwrap(); + assert_eq!(s.doc().unwrap().doc.artboards.len(), 1, "{password}"); + } + assert!( + s.execute("document.open", &json!({"dataBase64": vectorcraft_format::base64_encode(&pdf), "name": "old.pdf", "password": "nope"})).is_err() + ); +} diff --git a/crates/engine/src/cmd/fileio/tests_pdfx.rs b/crates/engine/src/cmd/fileio/tests_pdfx.rs index bf0ab29b..ff43cdd1 100644 --- a/crates/engine/src/cmd/fileio/tests_pdfx.rs +++ b/crates/engine/src/cmd/fileio/tests_pdfx.rs @@ -67,7 +67,7 @@ fn choosing_a_pdfx_standard_sets_its_version_and_turns_off_what_it_forbids() { let v = settings(&mut s, json!({"standard": "pdfX4"})); assert_eq!((&v["compatibility"], &v["preserveEditing"]), (&json!("1.6"), &json!(false))); let v = settings(&mut s, json!({"standard": "pdfX1a", "createLayers": null, "preset": "Press Quality"})); - assert_eq!((&v["compatibility"], &v["createLayers"]), (&json!("1.4"), &json!(false))); + assert_eq!((&v["compatibility"], &v["createLayers"]), (&json!("1.3"), &json!(false))); let v = settings(&mut s, json!({"standard": "pdfX4", "compatibility": "1.5", "createLayers": true})); assert_eq!((&v["compatibility"], &v["createLayers"]), (&json!("1.5"), &json!(true))); // What is asked for explicitly is checked, not changed. diff --git a/crates/engine/src/cmd/flatten.rs b/crates/engine/src/cmd/flatten.rs index 6396c77a..7661793c 100644 --- a/crates/engine/src/cmd/flatten.rs +++ b/crates/engine/src/cmd/flatten.rs @@ -204,6 +204,16 @@ impl FlattenOptions { Self::from_params_with(p, &[]) } + /// The options an export flattens with: preset `name` (built-in or one of `saved`; default + /// medium) with the export's `flattener` `options` over it. + pub fn for_export(name: Option<&str>, options: Option<&Value>, saved: &[FlattenerPreset]) -> std::result::Result { + let mut q = json!({ "options": options.cloned().unwrap_or(Value::Null) }); + if let Some(name) = name { + q["preset"] = json!(name); + } + Self::from_params_with(&q, saved) + } + /// The options `p` asks for: `preset` (built-in or one of `saved`; default medium) adjusted by /// the option keys given at the top level or in `options`. pub fn from_params_with(p: &Value, saved: &[FlattenerPreset]) -> std::result::Result { diff --git a/crates/pdf/src/encrypt.rs b/crates/pdf/src/encrypt.rs index c7abf10f..173f1de5 100644 --- a/crates/pdf/src/encrypt.rs +++ b/crates/pdf/src/encrypt.rs @@ -3,12 +3,12 @@ //! //! The export writes a plain file; [`protect`] then encrypts every string and stream in it (the //! XMP metadata stays readable with Plaintext Metadata) and adds the encryption dictionary. The -//! algorithm follows the compatibility ([`Encryption`]): 128-bit RC4 for PDF 1.4 (revision 3) and -//! 1.5 (revision 4), 128-bit AES for 1.6 (revision 4) and 256-bit AES for 1.7 and 2.0 -//! (revision 6). The random file key, salts and initialisation vectors come from a SHA-256 stream -//! seeded with the process's random hash keys, the time and the file itself. A linearised file is -//! encrypted between its two steps ([`protect_keeping`]): what encrypted it then encrypts the hint -//! stream written last ([`Cipher`]). +//! algorithm follows the compatibility ([`Encryption`]): 40-bit RC4 for PDF 1.3 (revision 2), +//! 128-bit RC4 for PDF 1.4 (revision 3) and 1.5 (revision 4), 128-bit AES for 1.6 (revision 4) and +//! 256-bit AES for 1.7 and 2.0 (revision 6). The random file key, salts and initialisation vectors +//! come from a SHA-256 stream seeded with the process's random hash keys, the time and the file +//! itself. A linearised file is encrypted between its two steps ([`protect_keeping`]): what +//! encrypted it then encrypts the hint stream written last ([`Cipher`]). //! //! Reading: [`user_password`] turns the permissions (owner) password of a file encrypted with RC4 //! or 128-bit AES into its open (user) password, so either password opens it. @@ -24,6 +24,7 @@ use crate::{Changes, Compatibility, PdfError, PdfSettings, Printing, SecuritySet choice! { /// The encryption algorithm, which follows the PDF version (Compatibility). Encryption { + Rc440 = "rc440", "40-bit RC4"; Rc4 = "rc4", "128-bit RC4"; Aes128 = "aes128", "128-bit AES"; Aes256 = "aes256", "256-bit AES"; @@ -34,6 +35,7 @@ impl Encryption { /// The algorithm a file of PDF version `c` is encrypted with. pub fn for_compatibility(c: Compatibility) -> Self { match c { + Compatibility::Pdf13 => Self::Rc440, Compatibility::Pdf14 | Compatibility::Pdf15 => Self::Rc4, Compatibility::Pdf16 => Self::Aes128, Compatibility::Pdf17 | Compatibility::Pdf20 => Self::Aes256, @@ -45,6 +47,7 @@ impl Encryption { /// encrypted), 4 (crypt filters: RC4 or AES-128) or 6 (AES-256). fn revision(c: Compatibility) -> u8 { match c { + Compatibility::Pdf13 => 2, Compatibility::Pdf14 => 3, Compatibility::Pdf15 | Compatibility::Pdf16 => 4, Compatibility::Pdf17 | Compatibility::Pdf20 => 6, @@ -140,7 +143,7 @@ impl PdfSettings { pub(crate) fn warnings(set: &PdfSettings) -> Vec { let s = &set.security; let mut out = vec![]; - if s.protected() && s.plaintext_metadata && revision(set.compatibility) == 3 { + if s.protected() && s.plaintext_metadata && revision(set.compatibility) <= 3 { out.push(format!("{} encryption covers the metadata too: plaintext metadata needs PDF 1.5 or later", set.compatibility.label())); } out @@ -337,7 +340,7 @@ impl Handler { // Without a permissions password the open password grants every permission anyway. let owner = if sec.permissions_password.is_empty() { user } else { sec.permissions_password.as_bytes() }; let p = sec.permission_bits(); - let encrypt_metadata = r == 3 || !sec.plaintext_metadata; + let encrypt_metadata = r <= 3 || !sec.plaintext_metadata; let meta = if encrypt_metadata { "" } else { "/EncryptMetadata false" }; if r == 6 { let key: [u8; 32] = rng.bytes(); @@ -363,47 +366,59 @@ impl Handler { ); return Ok(Self { key: key.to_vec(), cipher, encrypt_metadata, dict }); } - // O (algorithm 3), the file key (algorithm 2) and U (algorithm 5), for a 16-byte key. - let owner_key = owner_key(owner, r, 16); + // O (algorithm 3), the file key (algorithm 2) and U (algorithms 4 and 5): a 5-byte key and + // single RC4 passes for revision 2, a 16-byte key for revisions 3 and 4. + let n = if r == 2 { 5 } else { 16 }; + let owner_key = owner_key(owner, r, n); let mut o = rc4(&owner_key, &padded(user)); - for i in 1..=19 { + for i in (1..=19).filter(|_| r >= 3) { o = rc4(&xored(&owner_key, i), &o); } let no_meta: &[u8] = if encrypt_metadata { &[] } else { &[0xFF; 4] }; let mut key = md5(&[&padded(user), &o, &p.to_le_bytes(), id, no_meta]); - for _ in 0..50 { + for _ in (0..50).filter(|_| r >= 3) { key = md5(&[&key]); } - let mut u = rc4(&key, &md5(&[&PAD, id])); - for i in 1..=19 { - u = rc4(&xored(&key, i), &u); - } - u.extend_from_slice(&rng.bytes::<16>()); - let dict = if r == 3 { - format!("<>", hex(&o), hex(&u)) + let key = key.get(..n).unwrap_or(&key).to_vec(); + let u = if r == 2 { + rc4(&key, &PAD) } else { - let cfm = if cipher == Encryption::Aes128 { "AESV2" } else { "V2" }; - format!( - "<>>>/StmF/StdCF/StrF/StdCF/P {p}/O{}/U{}{meta}>>", - hex(&o), - hex(&u) - ) + let mut u = rc4(&key, &md5(&[&PAD, id])); + for i in 1..=19 { + u = rc4(&xored(&key, i), &u); + } + u.extend_from_slice(&rng.bytes::<16>()); + u }; - Ok(Self { key: key.to_vec(), cipher, encrypt_metadata, dict }) + let dict = match r { + 2 => format!("<>", hex(&o), hex(&u)), + 3 => format!("<>", hex(&o), hex(&u)), + _ => { + let cfm = if cipher == Encryption::Aes128 { "AESV2" } else { "V2" }; + format!( + "<>>>/StmF/StdCF/StrF/StdCF/P {p}/O{}/U{}{meta}>>", + hex(&o), + hex(&u) + ) + } + }; + Ok(Self { key, cipher, encrypt_metadata, dict }) } /// `data` of object `num generation` encrypted. fn encrypt(&self, num: u32, generation: u16, data: &[u8], rng: &mut Entropy) -> Result, PdfError> { + // The object's key: the file key's length plus 5 bytes, at most 16 (algorithm 1). let object_key = || { let salt: &[u8] = if self.cipher == Encryption::Aes128 { b"sAlT" } else { &[] }; - md5(&[&self.key, &num.to_le_bytes()[..3], &generation.to_le_bytes(), salt]) + let h = md5(&[&self.key, &num.to_le_bytes()[..3], &generation.to_le_bytes(), salt]); + h.get(..(self.key.len() + 5).min(16)).unwrap_or(&h).to_vec() }; let aes = |key: &[u8], rng: &mut Entropy| -> Result, PdfError> { let iv: [u8; 16] = rng.bytes(); Ok([&iv[..], &Aes::new(key)?.cbc(iv, data, true)].concat()) }; match self.cipher { - Encryption::Rc4 => Ok(rc4(&object_key(), data)), + Encryption::Rc440 | Encryption::Rc4 => Ok(rc4(&object_key(), data)), Encryption::Aes128 => aes(&object_key(), rng), Encryption::Aes256 => aes(&self.key, rng), } diff --git a/crates/pdf/src/export.rs b/crates/pdf/src/export.rs index 1a6536e9..955270e9 100644 --- a/crates/pdf/src/export.rs +++ b/crates/pdf/src/export.rs @@ -132,6 +132,8 @@ pub(crate) struct Writer { /// Spot colours written with a Lab alternate, gathered from the exporters ([`Self::absorb`]). lab_spots: Vec<(String, vectorcraft_color::cms::Lab)>, warnings: Vec, + /// A PDF 1.3 file ([`crate::PdfSettings::pdf13`]): flat, without page groups. + pdf13: bool, } /// One page as [`Writer::page`] draws it: the art placed in a drawing space ([`Self::place`]), @@ -171,7 +173,8 @@ impl Writer { warnings.push(format!("the CMYK profile {} can't be embedded: CMYK colours are written untagged", out.cmyk_profile())); } let version = match set.compatibility { - Compatibility::Pdf14 => PdfVersion::Pdf14, + // PDF 1.3 files are written with the PDF 1.4 settings: flat, they use nothing newer. + Compatibility::Pdf13 | Compatibility::Pdf14 => PdfVersion::Pdf14, Compatibility::Pdf15 => PdfVersion::Pdf15, Compatibility::Pdf16 => PdfVersion::Pdf16, Compatibility::Pdf17 => PdfVersion::Pdf17, @@ -212,7 +215,7 @@ impl Writer { meta = meta.creation_date(t); } pdf.set_metadata(meta); - Ok(Self { pdf, cmyk: out.blends_cmyk(cmyk), out: Arc::new(out), lab_spots: vec![], warnings }) + Ok(Self { pdf, cmyk: out.blends_cmyk(cmyk), out: Arc::new(out), lab_spots: vec![], warnings, pdf13: set.pdf13() }) } /// Draw one page: the art of `ex`'s document placed as `sheet` says, then its marks. @@ -241,10 +244,11 @@ impl Writer { s.push_clip_path(clip, &krilla::paint::FillRule::NonZero); } // Page Isolated Blending / Page Knockout Group: the page content is one group (the PDF - // writer has no page group attributes). In CMYK, transparency at the top of a page is put - // in a non-isolated group of its own, whose blending space `cmyk_blending` rewrites. + // writer has no page group attributes; a flat PDF 1.3 file has none). In CMYK, transparency + // at the top of a page is put in a non-isolated group of its own, whose blending space + // `cmyk_blending` rewrites. let doc = ex.doc; - let page_group = doc.page_isolate || doc.page_knockout; + let page_group = !self.pdf13 && (doc.page_isolate || doc.page_knockout); let cmyk_page_group = self.cmyk && !page_group && ex.shows_transparency(); if page_group { s.push_isolated(); @@ -311,7 +315,7 @@ impl Writer { let bytes = if lab { crate::lab_spot::lab_alternates(bytes, &self.lab_spots) } else { bytes }; let (bytes, more) = self.out.write_catalog(bytes)?; self.warnings.extend(more); - Ok((crate::pdfx::finish(bytes, self.out.standard)?, self.warnings)) + Ok((crate::pdfx::finish(bytes, self.out.standard, self.pdf13)?, self.warnings)) } } diff --git a/crates/pdf/src/lib.rs b/crates/pdf/src/lib.rs index 15ccc450..11b72369 100644 --- a/crates/pdf/src/lib.rs +++ b/crates/pdf/src/lib.rs @@ -18,7 +18,8 @@ //! Advanced, Security); options the writer doesn't apply yet come back as warnings. With Preserve //! Editing the native document rides along as an embedded file ([`editing()`]). Each page can //! carry a thumbnail ([`Thumbnail`], drawn by the caller), and Fast Web View writes a linearised -//! file (still linearised when encrypted). +//! file (still linearised when encrypted). A PDF 1.3 file ([`PdfSettings::pdf13`]) must be flat: +//! the app flattens the document first, and the writer refuses transparency that is left. //! - [`import`] reads PDF (and PDF-compatible `.ai`) pages with `hayro-interpret` into a //! [`Document`]: one artboard per page, and a layer per page or per optional content group //! (with its visibility, print state and lock; art that is off comes in as a hidden layer), @@ -235,6 +236,8 @@ mod tests_marks; #[cfg(test)] mod tests_output; #[cfg(test)] +mod tests_pdf13; +#[cfg(test)] mod tests_pdfx; #[cfg(test)] mod tests_presets; diff --git a/crates/pdf/src/pdfx.rs b/crates/pdf/src/pdfx.rs index 72c39c8d..116d1cf0 100644 --- a/crates/pdf/src/pdfx.rs +++ b/crates/pdf/src/pdfx.rs @@ -12,8 +12,8 @@ //! document information, a title and dates, embedded fonts (or text as outlines), no editing //! data and no encryption (both refused by the settings checks). //! -//! PDF 1.3 files are written with the PDF 1.4 settings (nothing above 1.3 is used once the art is -//! flat) and their header and metadata say 1.3. The written file is checked ([`verify`]): what the +//! PDF/X-1a and PDF/X-3 files are PDF 1.3 files ([`crate::PdfSettings::pdf13`]): flat, written with +//! the PDF 1.4 settings, their header and metadata saying 1.3. The written file is checked ([`verify`]): what the //! standard forbids that is still there refuses the export, rather than a file claiming a //! standard it breaks. @@ -121,7 +121,7 @@ pub(crate) fn info_entries(standard: Standard) -> String { /// Edit the XMP metadata of a PDF/X file (the catalog's `/Metadata` stream; `root` is the /// catalog dictionary's contents) into `patch`: PDF/X-4 adds the standard, Trapped and a version -/// id; PDF 1.3 files say so. Files without metadata are left as they are. +/// id (PDF 1.3 files say so in [`finish`]). Files without metadata are left as they are. pub(crate) fn xmp(pdf: &[u8], xref: &Xref, root: (usize, usize), patch: &mut Patch, standard: Standard, trapped: bool) -> Result<(), PdfError> { if !standard.is_pdfx() { return Ok(()); @@ -137,12 +137,6 @@ pub(crate) fn xmp(pdf: &[u8], xref: &Xref, root: (usize, usize), patch: &mut Pat let data = data + obj.get(data..).map_or(0, |r| r.iter().take(2).take_while(|b| matches!(b, b'\r' | b'\n')).count()); let xml = data.checked_add(length).and_then(|e| obj.get(data..e)).ok_or_else(bad)?; let mut edits: Vec<(usize, usize, Vec)> = vec![]; - if standard.flattens() - && let Some(v) = find(xml, b"1.4<", 0) - { - let digit = v + b"1.".len(); - edits.push((digit, digit + 1, b"3".to_vec())); - } if let Some((version, _)) = standard.pdfx_ids().filter(|_| standard == Standard::PdfX4) { // Into the writer's description, whose namespaces declare the `pdf` and `xmpMM` prefixes. let close = crate::lab_spot::rfind(xml, b"").ok_or_else(bad)?; @@ -163,18 +157,30 @@ pub(crate) fn xmp(pdf: &[u8], xref: &Xref, root: (usize, usize), patch: &mut Pat Ok(()) } -/// The finished file of `standard`: its header version (PDF 1.3 for PDF/X-1a and PDF/X-3), then -/// checked ([`verify`]). -pub(crate) fn finish(mut pdf: Vec, standard: Standard) -> Result, PdfError> { - if standard.flattens() - && let Some(v) = pdf.get_mut(..8).filter(|h| h.starts_with(b"%PDF-1.4")) - { - v.copy_from_slice(b"%PDF-1.3"); +/// The finished file of `standard`: a PDF 1.3 file (`pdf13`: PDF/X-1a, PDF/X-3, or asked for) says +/// so ([`crate::post::declare_pdf13`]) and must be flat, then the file is checked against its +/// standard ([`verify`]). +pub(crate) fn finish(mut pdf: Vec, standard: Standard, pdf13: bool) -> Result, PdfError> { + if pdf13 { + crate::post::declare_pdf13(&mut pdf); + if !standard.flattens() { + flat(&without_streams(&pdf), "PDF 1.3")?; + } } verify(&pdf, standard)?; Ok(pdf) } +/// Refuse a file (`objects`, without its stream data) with transparency: `kind` files have none. +fn flat(objects: &[u8], kind: &str) -> Result<(), PdfError> { + match transparency(objects) { + Some(what) => Err(PdfError::Unsupported(format!( + "{kind} files have no transparency, and the file would have {what}: flatten it first (object.flattenTransparency)" + ))), + None => Ok(()), + } +} + /// Refuse a written file that breaks `standard`: transparency in PDF/X-1a and PDF/X-3 (soft /// masks, transparency groups, constant opacity, blend modes), colour spaces other than CMYK, /// grey and spot inks in PDF/X-1a, and fonts that aren't embedded. @@ -184,12 +190,8 @@ pub(crate) fn verify(pdf: &[u8], standard: Standard) -> Result<(), PdfError> { } let objects = without_streams(pdf); let std = standard.label(); - if standard.flattens() - && let Some(what) = transparency(&objects) - { - return Err(PdfError::Unsupported(format!( - "{std} files have no transparency, and the file would have {what}: flatten it first (object.flattenTransparency)" - ))); + if standard.flattens() { + flat(&objects, std)?; } if standard.cmyk_only() && let Some(space) = diff --git a/crates/pdf/src/post.rs b/crates/pdf/src/post.rs index a29f63fa..c7c2dc5b 100644 --- a/crates/pdf/src/post.rs +++ b/crates/pdf/src/post.rs @@ -1,10 +1,12 @@ //! What the export does to the file once it is written, in this order: the pages' thumbnails //! (General › Embed page thumbnails), the seal of the editing data, fast web view (a linearised //! file, [`crate::linearize`]) and encryption ([`crate::encrypt`]), which a linearised file goes -//! through between its two steps so that it stays linearised. +//! through between its two steps so that it stays linearised. Also here: what makes a file say it +//! is PDF 1.3 ([`declare_pdf13`]), for PDF 1.3 and PDF/X-1a and PDF/X-3 files. use std::borrow::Cow; +use crate::encrypt::Obj; use crate::linearize::{self, Parsed}; use crate::patch::Patch; use crate::{PdfError, PdfOptions, PdfSettings}; @@ -89,3 +91,24 @@ pub(crate) fn finish(pdf: Vec, opts: &PdfOptions, editing: bool, warnings: & } } } + +/// Make a file written with the PDF 1.4 settings say it is PDF 1.3: its header and the version +/// its XMP metadata gives, rewritten in place. +pub(crate) fn declare_pdf13(pdf: &mut [u8]) { + if let Some(v) = pdf.get_mut(..8).filter(|h| h.starts_with(b"%PDF-1.4")) { + v.copy_from_slice(b"%PDF-1.3"); + } + if let Some(digit) = metadata_version(pdf).and_then(|at| pdf.get_mut(at)) { + *digit = b'3'; + } +} + +/// Where the minor version digit is in the `1.4` of the XMP metadata stream of `pdf`'s catalog. +fn metadata_version(pdf: &[u8]) -> Option { + const VERSION: &[u8] = b"1.4<"; + let p = linearize::parse(pdf).ok()?; + let Some(&Obj::Ref(n, _)) = p.objects.get(&p.root)?.value.get(b"Metadata") else { return None }; + let o = p.objects.get(&n)?; + let at = crate::lab_spot::find(pdf.get(..o.endobj)?, VERSION, o.value_span.1)?; + Some(at + VERSION.len() - 2) +} diff --git a/crates/pdf/src/presets.rs b/crates/pdf/src/presets.rs index 36f80f38..cf7ae0fe 100644 --- a/crates/pdf/src/presets.rs +++ b/crates/pdf/src/presets.rs @@ -80,12 +80,12 @@ pub fn builtin_presets() -> Vec { preset( "PDF/X-1a:2001", "Print exchange with CMYK and spot colours only: transparency flattened, fonts embedded.", - pdf_x(Standard::PdfX1a, Compatibility::Pdf14), + pdf_x(Standard::PdfX1a, Compatibility::Pdf13), ), preset( "PDF/X-3:2002", "Print exchange allowing colour-managed colours: transparency flattened, fonts embedded.", - pdf_x(Standard::PdfX3, Compatibility::Pdf14), + pdf_x(Standard::PdfX3, Compatibility::Pdf13), ), preset( "PDF/X-4:2010", diff --git a/crates/pdf/src/settings.rs b/crates/pdf/src/settings.rs index 1eb13ea3..b01a3036 100644 --- a/crates/pdf/src/settings.rs +++ b/crates/pdf/src/settings.rs @@ -67,26 +67,25 @@ choice! { PdfX4 = "pdfX4", "PDF/X-4:2010"; } default None } - impl Standard { /// A file of this standard can declare PDF version `c`: PDF/A-2 is based on PDF 1.7 (so it - /// can't be PDF 2.0), PDF/X-4 on PDF 1.6, and PDF/X-1a and PDF/X-3 on PDF 1.3, which the - /// writer writes as 1.4 (the file says 1.3, see [`crate::pdfx`]). + /// can't be PDF 2.0, nor PDF 1.3, which has no transparency), PDF/X-4 on PDF 1.6, and PDF/X-1a + /// and PDF/X-3 on PDF 1.3 (PDF 1.4 is accepted too, and written as 1.3). pub fn allows(self, c: Compatibility) -> bool { use Compatibility::*; match self { Self::None => true, - Self::PdfA2b => c != Pdf20, - Self::PdfX1a | Self::PdfX3 => c == Pdf14, + Self::PdfA2b => !matches!(c, Pdf13 | Pdf20), + Self::PdfX1a | Self::PdfX3 => matches!(c, Pdf13 | Pdf14), Self::PdfX4 => matches!(c, Pdf14 | Pdf15 | Pdf16), } } - /// The latest PDF version a file of this standard can declare (what choosing it sets). + /// The PDF version a file of this standard is (what choosing it sets). pub fn version(self) -> Compatibility { match self { Self::None | Self::PdfA2b => Compatibility::Pdf17, - Self::PdfX1a | Self::PdfX3 => Compatibility::Pdf14, + Self::PdfX1a | Self::PdfX3 => Compatibility::Pdf13, Self::PdfX4 => Compatibility::Pdf16, } } @@ -95,6 +94,8 @@ impl Standard { choice! { /// The PDF version written in the file header. Compatibility { + /// No transparency: it is flattened (see [`PdfSettings::pdf13`]). + Pdf13 = "1.3", "PDF 1.3"; Pdf14 = "1.4", "PDF 1.4"; Pdf15 = "1.5", "PDF 1.5"; Pdf16 = "1.6", "PDF 1.6"; @@ -236,6 +237,10 @@ pub struct PdfSettings { pub output: OutputSettings, pub advanced: AdvancedSettings, pub security: SecuritySettings, + /// The transparency flattener preset PDF 1.3 files (PDF/X-1a and PDF/X-3 ones too) are + /// flattened with: a built-in or saved preset's name; empty, High Resolution. The app flattens + /// the document before writing; the writer doesn't read it. + pub flattener_preset: String, } /// Resampling and compression of one kind of image. @@ -449,6 +454,13 @@ impl PdfSettings { self.create_layers && self.compatibility.has_layers() } + /// The file is PDF 1.3 (asked for, or a PDF/X-1a or PDF/X-3 file): it has no transparency (the + /// app flattens it, see [`Self::flattener_preset`]), is written with the PDF 1.4 settings, and + /// its header and metadata say 1.3. + pub fn pdf13(&self) -> bool { + self.compatibility == Compatibility::Pdf13 || self.standard.flattens() + } + /// Forget the passwords (presets never store them). pub fn clear_passwords(&mut self) { self.security.open_password.clear(); diff --git a/crates/pdf/src/tests_pdf13.rs b/crates/pdf/src/tests_pdf13.rs new file mode 100644 index 00000000..4df56f3f --- /dev/null +++ b/crates/pdf/src/tests_pdf13.rs @@ -0,0 +1,79 @@ +//! Compatibility PDF 1.3: which standards allow it, the header and metadata that say so, the +//! writer refusing transparency (the app flattens it first) and 40-bit RC4 encryption. + +use serde_json::json; +use vectorcraft_color::{BlendMode, Color, Paint}; +use vectorcraft_doc::{Appearance, Document, Node}; +use vectorcraft_geom::{Rect, shapes}; + +use crate::*; + +/// A document with a black rectangle; `opacity` below 1 makes it transparent. +fn doc(opacity: f32) -> Document { + let mut d = Document::new(100.0, 100.0); + let l = d.default_layer().unwrap(); + let mut n = Node::path( + d.alloc_id(), + shapes::rectangle(Rect::new(10.0, 10.0, 50.0, 50.0)), + Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0), + ); + n.opacity = opacity; + d.insert(Some(l), 0, n).unwrap(); + d +} + +fn options(v: serde_json::Value) -> PdfOptions { + PdfOptions { settings: serde_json::from_value(v).unwrap(), created: Some(0), ..PdfOptions::uncompressed() } +} + +#[test] +fn which_standards_allow_pdf_1_3() { + use Compatibility::*; + assert!(Standard::None.allows(Pdf13) && Standard::PdfX1a.allows(Pdf13) && Standard::PdfX3.allows(Pdf13)); + assert!(!Standard::PdfA2b.allows(Pdf13) && !Standard::PdfX4.allows(Pdf13)); + // PDF/X-1a and PDF/X-3 files are PDF 1.3; PDF 1.4 is still accepted, and written as 1.3. + assert_eq!((Standard::PdfX1a.version(), Standard::PdfX3.version()), (Pdf13, Pdf13)); + assert!(Standard::PdfX1a.allows(Pdf14)); + let s = |v| serde_json::from_value::(v).unwrap(); + assert!(s(json!({"compatibility": "1.3"})).pdf13() && s(json!({"standard": "pdfX3", "compatibility": "1.4"})).pdf13()); + assert!(!s(json!({"compatibility": "1.4"})).pdf13() && !s(json!({"standard": "pdfX4"})).pdf13()); + assert!(matches!(s(json!({"standard": "pdfA2b", "compatibility": "1.3"})).check(), Err(PdfError::BadSetting(m)) if m.contains("PDF 1.3"))); + assert_eq!(Compatibility::IDS.first(), Some(&"1.3"), "first in the dropdown"); + assert_eq!(Encryption::for_compatibility(Pdf13), Encryption::Rc440); +} + +#[test] +fn pdf_1_3_files_say_so_and_have_no_transparency() { + let mut d = doc(1.0); + // Isolated page blending is a transparency group: a flat file has none. + d.page_isolate = true; + let r = export_with_report(&d, &options(json!({"compatibility": "1.3"}))).unwrap(); + let text = String::from_utf8_lossy(&r.bytes); + assert!(r.bytes.starts_with(b"%PDF-1.3") && text.contains("1.3<"), "the header and the metadata"); + assert!(!text.contains("/Transparency") && !text.contains("/SMask")); + assert!(r.warnings.is_empty(), "{:?}", r.warnings); + // The same at 1.4 has its page group. + assert!(String::from_utf8_lossy(&export(&d, &options(json!({"compatibility": "1.4"}))).unwrap()).contains("/S/Transparency")); + // Transparency the app didn't flatten is refused. + let e = export(&doc(0.5), &options(json!({"compatibility": "1.3"}))).unwrap_err(); + assert!(matches!(&e, PdfError::Unsupported(m) if m.contains("PDF 1.3 files have no transparency")), "{e}"); + let mut d = doc(1.0); + if let Some(n) = d.layers[0].children().and_then(|c| c.first()).map(|n| n.id) { + d.node_mut(n).unwrap().blend = BlendMode::Multiply; + } + assert!(matches!(export(&d, &options(json!({"compatibility": "1.3"}))), Err(PdfError::Unsupported(m)) if m.contains("PDF 1.3"))); +} + +#[test] +fn pdf_1_3_files_are_encrypted_with_40_bit_rc4() { + let o = options(json!({"compatibility": "1.3", "security": {"openPassword": "open", "permissionsPassword": "owner", "plaintextMetadata": true}})); + let r = export_with_report(&doc(1.0), &o).unwrap(); + assert!(String::from_utf8_lossy(&r.bytes).contains("/Filter/Standard/V 1/R 2/Length 40/P ")); + assert!(r.warnings.iter().any(|w| w.contains("PDF 1.3 encryption covers the metadata too")), "{:?}", r.warnings); + assert!(matches!(import(&r.bytes), Err(PdfError::NeedsPassword))); + for password in ["open", "owner"] { + let opened = import_with_report(&r.bytes, &ImportOptions { password: Some(password.into()), ..Default::default() }).unwrap(); + assert_eq!(opened.document.layers[0].children().map_or(0, |c| c.len()), 1, "{password}"); + } + assert_eq!(crate::encrypt::user_password(&r.bytes, "owner").as_deref(), Some("open")); +} diff --git a/crates/pdf/src/tests_settings.rs b/crates/pdf/src/tests_settings.rs index 4f68f274..6397b177 100644 --- a/crates/pdf/src/tests_settings.rs +++ b/crates/pdf/src/tests_settings.rs @@ -36,7 +36,7 @@ fn json_is_camel_case_and_partial_objects_keep_defaults() { assert_eq!(s.compression.gray.downsample, Downsample::Bicubic); assert_eq!(s.compression.gray.ppi, 300.0); assert!(s.compression.compress_text); - for bad in [json!({"compatibility": "1.3"}), json!({"standard": "pdfX9"}), json!({"marks": {"trim": "yes"}})] { + for bad in [json!({"compatibility": "1.2"}), json!({"standard": "pdfX9"}), json!({"marks": {"trim": "yes"}})] { assert!(serde_json::from_value::(bad.clone()).is_err(), "{bad}"); } } diff --git a/crates/ui-egui/src/dialogs/save_pdf.rs b/crates/ui-egui/src/dialogs/save_pdf.rs index 63abd454..b9d5ced5 100644 --- a/crates/ui-egui/src/dialogs/save_pdf.rs +++ b/crates/ui-egui/src/dialogs/save_pdf.rs @@ -13,6 +13,7 @@ use std::sync::LazyLock; use serde_json::{Map, Value, json}; use vectorcraft_doc::Unit; +use vectorcraft_engine::cmd::FlattenOptions; use vectorcraft_engine::cmd::fileio::{SaveMode, pdf}; use vectorcraft_pdf::{ Changes, Choice, ColorConversion, Compatibility, Downsample, Encryption, ImageCodec, JpegQuality, MarkKind, MonoCodec, Overprint, PdfSettings, @@ -331,7 +332,7 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { "Compression" => compression(ui, d), "Marks and Bleeds" => marks_and_bleeds(app, ui, d), "Output" => output(ui, d), - "Advanced" => advanced(ui, d), + "Advanced" => advanced(app, ui, d), "Security" => security(ui, d), "Summary" => summary(app, ui, d), _ => general(ui, d, editor), @@ -369,8 +370,9 @@ pub(super) fn section_list(ui: &mut egui::Ui, d: &mut Dialog, sections: &[&str], } } -/// What choosing `standard` changes: the compatibility, to the latest version it allows, and off -/// what files of a standard can't have (editing data, passwords; layers in PDF/X-1a and PDF/X-3). +/// What choosing `standard` changes: the compatibility, to its version unless it allows the one +/// chosen, and off what files of a standard can't have (editing data, passwords; layers in PDF/X-1a +/// and PDF/X-3). fn standard_chosen(d: &mut Dialog, standard: Standard) { if choice::(d, "compatibility").is_some_and(|c| !standard.allows(c)) { set(d, "compatibility", json!(standard.version().id())); @@ -604,7 +606,7 @@ fn output(ui: &mut egui::Ui, d: &mut Dialog) { } } -fn advanced(ui: &mut egui::Ui, d: &mut Dialog) { +fn advanced(app: &VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) { heading(ui, "Fonts"); let outline = get(d, "advanced.outlineText").as_bool() == Some(true); row(ui, "Subset fonts below:", |ui| { @@ -612,10 +614,28 @@ fn advanced(ui: &mut egui::Ui, d: &mut Dialog) { ui.label("of characters used"); }); flag(ui, d, "advanced.outlineText", "Convert text to outlines", true); - heading(ui, "Overprint"); + heading(ui, "Overprint and Transparency Flattener"); row(ui, "Overprint:", |ui| { pick::(ui, d, "advanced.overprint", 130.0, |_| true); }); + // PDF 1.3 files (PDF/X-1a and PDF/X-3 ones too) have no transparency: it is flattened. + let flat = choice::(d, "compatibility") == Some(Compatibility::Pdf13) + || choice::(d, "standard").is_some_and(Standard::flattens); + row(ui, "Flattener preset:", |ui| { + let presets = app.session.flattener_presets(); + let names: Vec<&str> = presets.iter().map(|p| p.name.as_str()).collect(); + let name = get(d, "flattenerPreset").as_str().map(str::trim).filter(|n| !n.is_empty()).unwrap_or(pdf::DEFAULT_FLATTENER); + // A built-in preset named by id (`high`) shows as its name. + let current = FlattenOptions::preset_label(&name.to_ascii_lowercase()).unwrap_or(name).to_string(); + ui.add_enabled_ui(flat, |ui| { + if let Some(n) = widgets::dropdown(ui, "flattenerPreset", ¤t, &names, 200.0).and_then(|i| names.get(i)) { + set(d, "flattenerPreset", json!(n)); + } + }); + }); + if !flat { + note(ui, "PDF 1.3 files have no transparency: the preset flattens it."); + } } /// The password fields (never stored in presets). @@ -670,7 +690,13 @@ fn security(ui: &mut egui::Ui, d: &mut Dialog) { flag(ui, d, "security.screenReader", reader, restrict); } let compatibility = choice::(d, "compatibility").unwrap_or_default(); - flag(ui, d, "security.plaintextMetadata", "Enable plaintext metadata", (open || restrict) && compatibility != Compatibility::Pdf14); + flag( + ui, + d, + "security.plaintextMetadata", + "Enable plaintext metadata", + (open || restrict) && !matches!(compatibility, Compatibility::Pdf13 | Compatibility::Pdf14), + ); ui.add_space(6.0); note(ui, &format!("Encryption: {} (set by Compatibility)", Encryption::for_compatibility(compatibility).label())); } @@ -966,7 +992,7 @@ mod tests { let d = app.ui.dialog.as_mut().unwrap(); set(d, "standard", json!(Standard::PdfX1a.id())); standard_chosen(d, Standard::PdfX1a); - assert_eq!((get(d, "compatibility"), get(d, "createLayers"), get(d, "preserveEditing")), (&json!("1.4"), &json!(false), &json!(false))); + assert_eq!((get(d, "compatibility"), get(d, "createLayers"), get(d, "preserveEditing")), (&json!("1.3"), &json!(false), &json!(false))); for s in SECTIONS { set_field(&mut app, "__section", json!(s)); frame(&mut app); @@ -1000,4 +1026,23 @@ mod tests { // The empty pages of the other artboards come in as page layers. assert_eq!(names[..2], ["Layer 1", "Notes"]); } + + #[test] + fn pdf_1_3_takes_a_flattener_preset_in_advanced() { + let (mut app, written, _) = app(); + app.run("ui.savePdfDialog", json!({"path": "/tmp/old.pdf", "compatibility": "1.3"})).unwrap(); + for s in ["Advanced", "Security"] { + set_field(&mut app, "__section", json!(s)); + frame(&mut app); + } + set_field(&mut app, "flattenerPreset", json!("Low Resolution")); + let p = params(app.ui.dialog.as_ref().unwrap()); + assert_eq!((p["compatibility"].as_str(), p["flattenerPreset"].as_str()), (Some("1.3"), Some("Low Resolution"))); + super::super::confirm(&mut app).unwrap(); + assert!(written.borrow()[0].1.starts_with(b"%PDF-1.3")); + // A preset keeps the flattener preset. + app.run("pdf.preset.save", json!({"name": "Old", "compatibility": "1.3", "flattenerPreset": "Low Resolution"})).unwrap(); + let saved = app.session.prefs.pdf_presets.iter().find(|p| p.name == "Old").unwrap(); + assert_eq!((saved.settings.compatibility, saved.settings.flattener_preset.as_str()), (Compatibility::Pdf13, "Low Resolution")); + } } diff --git a/docs/mcp.md b/docs/mcp.md index 2ef4aa38..89040d50 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -216,10 +216,10 @@ PDF files take the Save PDF dialog's options: `preset`, `standard`, `compatibili `compression`, `marks`, `bleed`, `output`, `advanced` and `security` sections (`list_commands` with filter `exportPdf` documents every field). `document.exportPdf` and `export` (format `pdf`, the options in `options`) return `warnings`: options accepted but not applied yet, and features approximated or left out. A standard with a PDF version it doesn't -allow is refused (PDF/A-2b at 2.0, PDF/X-4 above 1.6, PDF/X-1a and PDF/X-3 at anything but 1.4); choosing a standard -without giving `compatibility` sets the latest it allows. -A `security` password encrypts the file (RC4 128-bit at PDF 1.4–1.5, AES-128 at 1.6, AES-256 at 1.7 and 2.0) with its -permissions; either password opens it (`document.open {password}`), and a password with PDF/A or PDF/X is refused. +allow is refused (PDF/A-2b at 1.3 or 2.0, PDF/X-4 above 1.6, PDF/X-1a and PDF/X-3 above 1.4); choosing a +standard without giving `compatibility` sets its version (PDF 1.3 for PDF/X-1a and PDF/X-3). +A `security` password encrypts the file (RC4 40-bit at PDF 1.3, RC4 128-bit at 1.4–1.5, AES-128 at 1.6, AES-256 at +1.7 and 2.0) with its permissions; either password opens it (`document.open {password}`), and a password with PDF/A or PDF/X is refused. 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. @@ -234,6 +234,11 @@ lossless). `none`, `jpeg2000`, CCITT and `runLength` are written as ZIP, with a `thumbnails: true` embeds each page drawn small (106 px on its long side, without the layers the page leaves out) as its `/Thumb` image. `fastWebView: true` writes a linearised file (the linearization dictionary first, then the first page with hint tables saying where every other page's objects are), which stays linearised when a password encrypts it. +`compatibility: "1.3"` writes a PDF 1.3 file, which has no transparency: a copy of the document is flattened first +with `flattenerPreset` (empty: High Resolution; `high`, `medium`, `low` or a saved preset, `flattener.presets.list`) +and `flattener` options over it (as `object.flattenTransparency` takes them); images with see-through pixels count as +transparency and rasterized areas are clipped to their regions. A file that would still have transparency fails the +export. PDF/X-1a and PDF/X-3 files are PDF 1.3 files too. Opening a PDF (or `.ai`) imports every page as an artboard and layer; `document.open` takes `pages` ("2-3, 5", 1-based), `cropTo` (`bounding` (the art's bounds), `art`, `crop` (default), `trim`, `bleed`, `media`: the box each artboard gets) @@ -1585,10 +1590,10 @@ layers are refused. - **pdfX4** (PDF/X-4:2010, PDF 1.6 at most): transparency and layers are kept, colours are tagged, and the XMP metadata names the standard. -Flattening uses the High Resolution flattener preset on a copy (the document is untouched; a warning says so), and -images with see-through pixels count as transparency. The written file is checked against its standard: what it still -breaks (transparency, RGB in PDF/X-1a, a font not embedded) fails the export instead of writing a file that only -claims the standard. +Flattening uses `flattenerPreset` (default High Resolution, see PDF 1.3 above) on a copy (the document is untouched; a +warning says so), and images with see-through pixels count as transparency. The written file is checked against +its standard: what it still breaks (transparency, RGB in PDF/X-1a, a font not embedded) fails the export instead of +writing a file that only claims the standard. ```json {"name":"run_command","arguments":{"command":"document.exportPdf","params":{"path":"/tmp/press.pdf","preset":"PDF/X-1a:2001","output":{"outputIntent":"VectorCraft Generic CMYK (SWOP-like)","outputCondition":"Coated","trapped":false}}}}