Merge pull request #124 from storytold/pr/m4-pdf-thumbs-linearize-13

M4.86–M4.87: PDF thumbnails, fast web view and PDF 1.3 (M4 done)
This commit is contained in:
BFlat authored and GitHub committed 2026-10-05 18:06:10 +07:00
commit cd8973a520
27 files changed
+1854 -161

No files matched your search

+2 -2
View File
@@ -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.
+5 -5
View File
@@ -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).
- 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), 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 |
+7 -11
View File
@@ -135,11 +135,7 @@ fn choice<T>(field: &str, v: Option<String>, read: impl Fn(&str) -> Option<T>, 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<FlattenOptions> {
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<Params> {
@@ -176,7 +172,7 @@ fn parse(p: &Value) -> Result<Settings> {
/// `saved` it names written out, as the encoder needs it.
pub fn resolve(p: &Value, saved: &[super::super::FlattenerPreset]) -> std::result::Result<Value, String> {
let run = || -> Result<Value> {
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<Option<Value>> {
fn expanded(cmd: &str, p: &Value, saved: &[super::super::FlattenerPreset]) -> Result<Option<Value>> {
match str_param(p, "flattenerPreset") {
Some(name) if FlattenOptions::preset(name).is_none() => {
let o = flattener(&params(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<Value> {
}
/// `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<Cow<'a, Value>> {
Ok(match expanded(&p, &s.prefs.flattener_presets)? {
pub(super) fn with_flattener<'a>(s: &Session, cmd: &str, p: Cow<'a, Value>) -> Result<Cow<'a, Value>> {
Ok(match expanded(cmd, &p, &s.prefs.flattener_presets)? {
Some(q) => Cow::Owned(q),
None => p,
})
+1 -2
View File
@@ -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<Cow<'a, Value>> {
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
+6
View File
@@ -911,3 +911,9 @@ mod tests_epsimport;
#[cfg(test)]
mod tests_pdflayers;
#[cfg(test)]
mod tests_pdfwebview;
#[cfg(test)]
mod tests_pdf13;
+107 -22
View File
@@ -3,11 +3,12 @@
//! over them, the warnings that come back, and `document.pdfSettings` (the dialog's Summary).
use std::borrow::Cow;
use std::sync::Arc;
use serde::Deserialize;
use serde_json::{Value, json};
use vectorcraft_doc::Document;
use vectorcraft_pdf::{PdfError, PdfOptions, PdfPreset, PdfSettings, Standard};
use vectorcraft_doc::{Document, Node, NodeKind};
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::*;
@@ -37,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",
@@ -68,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",
@@ -82,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<CommandSpec> {
@@ -91,7 +109,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Export PDF",
[],
None,
"{path?, preset?: \"VectorCraft Default\" (built-in or saved: pdf.preset.list), artboard? | artboards?: [i…] | range?: \"1-3, 5\" (1-based; default all, one page each), standard?: none|pdfA2b|pdfX1a (CMYK, grey and spot colours only: colours and images converted to output.destination or the CMYK output intent, untagged; transparency flattened with the High Resolution flattener preset; PDF 1.3)|pdfX3 (colours ICC-tagged; transparency flattened; PDF 1.3)|pdfX4 (transparency and layers kept; colours ICC-tagged; PDF 1.6 at most) (PDF/X: a /GTS_PDFX output intent, by default the CMYK profile in effect, embedded; TrimBox, Trapped, GTS_PDFXVersion, title and dates; fonts embedded or outlined), compatibility?: 1.4|1.5|1.6|1.7 (default)|2.0 (choosing a standard sets the latest it allows: 1.7 for pdfA2b, 1.6 for pdfX4, 1.4 for pdfX1a and pdfX3, written as PDF 1.3), preserveEditing? (on in VectorCraft Default: the native document as an embedded file, which document.open restores; choosing a standard turns it off, a standard refuses it), thumbnails?, fastWebView?, viewAfterSaving? (the app opens the written file), createLayers? (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 c Line truncated
has_doc,
export_pdf
),
@@ -197,10 +215,11 @@ pub fn settings(cmd: &str, p: &Value) -> Result<PdfSettings> {
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<Cow<'a, Value>> {
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();
@@ -208,10 +227,11 @@ pub fn expand_preset<'a>(s: &Session, cmd: &str, p: &'a Value) -> Result<Cow<'a,
if let Some(o) = q.as_object_mut() {
o.remove("preset");
}
Ok(Cow::Owned(q))
Cow::Owned(q)
}
_ => 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).
@@ -236,6 +256,9 @@ pub fn encode(cmd: &str, doc: &Document, p: &Value) -> Result<(Vec<u8>, Vec<Stri
/// native document with its save options, whose pages may be left blank).
pub(super) fn encode_carrying(cmd: &str, doc: &Document, p: &Value, native: impl FnOnce() -> Result<Vec<u8>>) -> Result<(Vec<u8>, Vec<String>)> {
let mut opts = options(cmd, doc, p)?;
if opts.settings.thumbnails {
opts.thumbnails = thumbnails(doc, &opts).map_err(|e| pdf_error(cmd, e))?;
}
let mut warnings = vec![];
if opts.settings.preserve_editing {
if opts.artboards.as_ref().is_none_or(|v| v.iter().copied().eq(0..doc.artboards.len())) {
@@ -245,30 +268,92 @@ 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<Option<Document>> {
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<Option<Document>> {
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)
}
/// The pages `opts` export of `doc` drawn on white, [`THUMBNAIL_SIZE`] pixels on their long side:
/// the thumbnails Embed Page Thumbnails embeds. Layers the pages leave out (non-printing ones,
/// unless asked for) are left out of them too.
fn thumbnails(doc: &Document, opts: &PdfOptions) -> std::result::Result<Vec<Thumbnail>, PdfError> {
let areas = vectorcraft_pdf::page_areas(doc, opts)?;
let set = &opts.settings;
let printed = if set.include_non_printing || set.create_layers { None } else { without_non_printing(&doc.layers) };
let shown = printed.map(|layers| {
let mut d = doc.clone();
d.layers = layers;
d
});
let doc = shown.as_ref().unwrap_or(doc);
let mut r = vectorcraft_render::Renderer::new();
Ok(areas
.into_iter()
.map(|a| {
let img = r.render_region(doc, a, f64::from(THUMBNAIL_SIZE) / a.width().max(a.height()).max(1.0), true);
// Drawn on white: every pixel is opaque.
let rgb = img.pixels.as_chunks::<4>().0.iter().flat_map(|p| [p[0], p[1], p[2]]).collect();
Thumbnail { width: img.width, height: img.height, rgb }
})
.collect())
}
/// `layers` with the non-printing layers among them (at any depth) hidden; `None` when there are
/// none.
fn without_non_printing(layers: &[Arc<Node>]) -> Option<Vec<Arc<Node>>> {
let mut out: Option<Vec<Arc<Node>>> = None;
for (i, l) in layers.iter().enumerate() {
let NodeKind::Layer { printable, children, .. } = &l.kind else { continue };
let new = if !printable {
l.visible.then(|| {
let mut n = (**l).clone();
n.visible = false;
Arc::new(n)
})
} else {
without_non_printing(children).map(|c| {
let mut n = (**l).clone();
if let NodeKind::Layer { children, .. } = &mut n.kind {
*children = c;
}
Arc::new(n)
})
};
if let Some(slot) = new.and_then(|new| Some((out.get_or_insert_with(|| layers.to_vec()).get_mut(i)?, new))) {
*slot.0 = slot.1;
}
}
out
}
fn export_pdf(s: &mut Session, p: &Value) -> Result<Value> {
let expanded = expand_preset(s, C, p)?;
let p = &*expanded;
+8 -4
View File
@@ -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"}),
@@ -105,9 +107,11 @@ fn pdf_a_refuses_pdf_2() {
#[test]
fn options_not_applied_yet_and_document_features_warn() {
let mut s = session(1);
let w = warnings(&export(&mut s, json!({"thumbnails": true, "fastWebView": true, "marks": {"trim": true}})));
assert_eq!(w.len(), 2, "{w:?}");
assert!(w.iter().any(|w| w.contains("thumbnails")) && w.iter().any(|w| w.contains("fast web view")), "printer's marks are drawn: no warning");
// Printer's marks are drawn, thumbnails embedded and the file linearised: nothing to report.
let v = export(&mut s, json!({"thumbnails": true, "fastWebView": true, "marks": {"trim": true}}));
assert_eq!(warnings(&v), Vec::<String>::new());
let text = String::from_utf8_lossy(&b64(&v)).into_owned();
assert!(text.contains("/Thumb ") && text.contains("/Linearized 1"));
// A pattern stroke is written as its tiles clipped to the stroke: nothing to report.
let tile = s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap()["id"].as_u64().unwrap();
s.execute("select.set", &json!({"ids": [tile]})).unwrap();
+155
View File
@@ -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<u8>, Vec<String>) {
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("<pdf:PDFVersion>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()
);
}
@@ -0,0 +1,98 @@
//! `document.exportPdf {thumbnails, fastWebView}`: each page's thumbnail is the page drawn small
//! (its bleed and marks too, without the layers the page leaves out), and the file is linearised.
use std::io::Read;
use serde_json::{Value, json};
use super::*;
fn export(s: &mut Session, p: Value) -> (Vec<u8>, Vec<Value>) {
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().clone())
}
fn find(hay: &[u8], needle: &[u8], from: usize) -> Option<usize> {
hay.get(from..)?.windows(needle.len()).position(|w| w == needle).map(|i| i + from)
}
/// The page thumbnails of `pdf`, in file order: (width, height, RGB pixels).
fn thumbnails(pdf: &[u8]) -> Vec<(u32, u32, Vec<u8>)> {
let mut out = vec![];
let mut from = 0;
while let Some(at) = find(pdf, b"<</Width ", from) {
from = at + 1;
let head = String::from_utf8_lossy(&pdf[at..find(pdf, b">>", at).unwrap()]).into_owned();
if !head.contains("/ColorSpace/DeviceRGB/BitsPerComponent 8/Filter/FlateDecode/Length ") {
continue;
}
let num = |key: &str| -> usize {
let rest = &head[head.find(key).unwrap() + key.len()..];
rest[..rest.find(|c: char| !c.is_ascii_digit()).unwrap_or(rest.len())].parse().unwrap()
};
let data = find(pdf, b"stream\n", at).unwrap() + 7;
let mut rgb = vec![];
flate2::read::ZlibDecoder::new(&pdf[data..data + num("/Length ")]).read_to_end(&mut rgb).unwrap();
out.push((num("/Width ") as u32, num("/Height ") as u32, rgb));
}
out
}
#[test]
fn thumbnails_show_the_pages() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 200, "height": 100, "artboards": 2})).unwrap();
let second = s.doc().unwrap().doc.artboards[1].rect;
// A red first page, a green second one.
s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 200, "height": 100})).unwrap();
s.execute("paint.setFill", &json!({"color": "#ff0000"})).unwrap();
s.execute("shape.rectangle", &json!({"x": second.x0, "y": second.y0, "width": 200, "height": 100})).unwrap();
s.execute("paint.setFill", &json!({"color": "#00ff00"})).unwrap();
// A non-printing layer covering the first page in blue.
let notes = s.execute("layer.new", &json!({})).unwrap()["id"].clone();
s.execute("layer.setProps", &json!({"id": notes, "printable": false})).unwrap();
s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 100, "height": 100})).unwrap();
s.execute("paint.setFill", &json!({"color": "#0000ff"})).unwrap();
let (pdf, warnings) = export(&mut s, json!({"thumbnails": true}));
assert_eq!(warnings, Vec::<Value>::new());
let thumbs = thumbnails(&pdf);
assert_eq!(thumbs.len(), 2, "one a page");
for ((w, h, rgb), colour) in thumbs.iter().zip([[255, 0, 0], [0, 255, 0]]) {
assert_eq!((*w, *h), (vectorcraft_pdf::THUMBNAIL_SIZE, 53), "the page's shape, 106 px long");
// The middle of the left half: the page's colour (the non-printing layer is left out).
let at = ((*h / 2 * w + w / 4) * 3) as usize;
assert_eq!(rgb[at..at + 3], colour);
}
// Non-printing layers included: the thumbnail shows them too.
let (pdf, _) = export(&mut s, json!({"thumbnails": true, "includeNonPrinting": true}));
let (w, h, rgb) = &thumbnails(&pdf)[0];
let at = ((*h / 2 * w + w / 4) * 3) as usize;
assert_eq!(rgb[at..at + 3], [0, 0, 255]);
// With the bleed the page grows, and so does its thumbnail.
let (pdf, _) = export(&mut s, json!({"thumbnails": true, "bleed": {"top": 50, "bottom": 50, "left": 0, "right": 0}}));
assert_eq!(thumbnails(&pdf).iter().map(|t| (t.0, t.1)).collect::<Vec<_>>(), [(106, 106), (106, 106)]);
// Off: none.
assert!(thumbnails(&export(&mut s, json!({})).0).is_empty());
}
#[test]
fn fast_web_view_through_every_pdf_path() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 100, "height": 80, "artboards": 3})).unwrap();
s.execute("shape.ellipse", &json!({"x": 10, "y": 10, "width": 40, "height": 30})).unwrap();
let linearised = |pdf: &[u8]| String::from_utf8_lossy(&pdf[..200.min(pdf.len())]).contains("/Linearized 1");
let (pdf, warnings) = export(&mut s, json!({"fastWebView": true, "thumbnails": true}));
assert!(linearised(&pdf) && warnings.is_empty(), "{warnings:?}");
// Reopened: the pages, and the document the editing data carries.
s.execute("document.close", &json!({"force": true})).ok();
let v = s.execute("document.open", &json!({"dataBase64": vectorcraft_format::base64_encode(&pdf), "name": "web.pdf"})).unwrap();
assert!(v.is_object());
assert_eq!(s.doc().unwrap().doc.artboards.len(), 3);
assert!(s.doc().unwrap().doc.layers[0].children().unwrap().iter().any(|n| n.path_data().is_some()), "the ellipse is back");
// document.export takes the same options; without them the file isn't linearised.
let v = s.execute("document.export", &json!({"format": "pdf", "fastWebView": true, "range": "2"})).unwrap();
assert!(linearised(&vectorcraft_format::base64_decode(v["dataBase64"].as_str().unwrap()).unwrap()));
assert!(!linearised(&export(&mut s, json!({})).0));
}
+1 -1
View File
@@ -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.
+10
View File
@@ -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<Self, String> {
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<Self, String> {
+1 -1
View File
@@ -62,7 +62,7 @@ fn saved_presets_apply_wherever_a_preset_is_named() {
// document.exportPdf, document.export, serialize and pdfSettings take it.
let v = s.execute("document.exportPdf", &json!({"preset": "web"})).unwrap();
assert!(pdf(&v).starts_with(b"%PDF-1.5"), "the preset's compatibility");
assert!(v["warnings"].as_array().unwrap().iter().any(|w| w.as_str().unwrap().contains("fast web view")), "{v}");
assert!(String::from_utf8_lossy(&pdf(&v)).contains("/Linearized 1"), "the preset's fast web view: {v}");
let v = s.execute("document.export", &json!({"format": "pdf", "preset": "Web"})).unwrap();
assert!(pdf(&v).starts_with(b"%PDF-1.5"));
let v = s.execute("document.serialize", &json!({"format": "pdf", "preset": "Web", "compatibility": "1.6"})).unwrap();
+66 -29
View File
@@ -3,10 +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.
//! 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.
@@ -22,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";
@@ -32,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,
@@ -43,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,
@@ -138,7 +143,7 @@ impl PdfSettings {
pub(crate) fn warnings(set: &PdfSettings) -> Vec<String> {
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
@@ -335,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();
@@ -361,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!("<</Filter/Standard/V 2/R 3/Length 128/P {p}/O{}/U{}>>", 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!(
"<</Filter/Standard/V 4/R 4/Length 128/CF<</StdCF<</AuthEvent/DocOpen/CFM/{cfm}/Length 16>>>>/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!("<</Filter/Standard/V 1/R 2/Length 40/P {p}/O{}/U{}>>", hex(&o), hex(&u)),
3 => format!("<</Filter/Standard/V 2/R 3/Length 128/P {p}/O{}/U{}>>", hex(&o), hex(&u)),
_ => {
let cfm = if cipher == Encryption::Aes128 { "AESV2" } else { "V2" };
format!(
"<</Filter/Standard/V 4/R 4/Length 128/CF<</StdCF<</AuthEvent/DocOpen/CFM/{cfm}/Length 16>>>>/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<Vec<u8>, 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<Vec<u8>, 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),
}
@@ -488,7 +505,7 @@ impl<'a> Obj<'a> {
}
}
fn int(&self, key: &[u8]) -> Option<i64> {
pub(crate) fn int(&self, key: &[u8]) -> Option<i64> {
match self.get(key)? {
Self::Int { value, .. } => Some(*value),
_ => None,
@@ -861,12 +878,32 @@ pub(crate) fn stream_spans(pdf: &[u8], lx: &mut Lexer<'_>, dict: &Obj<'_>) -> Re
Ok(Some(((*start, *end), (data, data_end))))
}
/// What encrypts the streams of a file [`protect_keeping`] encrypted, for those written after it
/// (the hint stream of a linearised file).
pub(crate) struct Cipher {
h: Handler,
rng: Entropy,
}
impl Cipher {
/// `data`, the stream of object `num` (generation 0), encrypted.
pub(crate) fn stream(&mut self, num: u32, data: &[u8]) -> Result<Vec<u8>, PdfError> {
self.h.encrypt(num, 0, data, &mut self.rng)
}
}
/// Encrypt `pdf` (as the export writes it: one cross-reference table) as `set`'s Security
/// section says; without a password it comes back as it is.
pub(crate) fn protect(pdf: Vec<u8>, set: &PdfSettings) -> Result<Vec<u8>, PdfError> {
protect_keeping(pdf, set).map(|(pdf, _)| pdf)
}
/// [`protect`], keeping what encrypted the file (`None` when it isn't encrypted). The objects keep
/// their order and numbers; the encryption dictionary comes after them, numbered last.
pub(crate) fn protect_keeping(pdf: Vec<u8>, set: &PdfSettings) -> Result<(Vec<u8>, Option<Cipher>), PdfError> {
let sec = &set.security;
if !sec.protected() {
return Ok(pdf);
return Ok((pdf, None));
}
let xref = xref_offset(&pdf).ok_or_else(|| failed("no cross-reference table"))?;
let (mut objects, trailer) = xref_table(&pdf, xref)?;
@@ -944,7 +981,7 @@ pub(crate) fn protect(pdf: Vec<u8>, set: &PdfSettings) -> Result<Vec<u8>, PdfErr
out.extend_from_slice(format!("/ID[{}{}]", hex(&id), hex(&id)).as_bytes());
}
out.extend_from_slice(format!("/Encrypt {size} 0 R>>\nstartxref\n{table}\n%%EOF").as_bytes());
Ok(out)
Ok((out, Some(Cipher { h, rng })))
}
// ---------- reading ----------
+34 -25
View File
@@ -42,19 +42,7 @@ pub fn export_with_report(doc: &Document, opts: &PdfOptions) -> Result<ExportRep
// Live geometry effects export as their result; raster effects are reported below.
let baked = vectorcraft_effects::bake_document(doc);
let doc = baked.as_ref().unwrap_or(doc);
if doc.artboards.is_empty() {
return Err(PdfError::NoArtboards);
}
let indices: Vec<usize> = match &opts.artboards {
Some(v) => v.clone(),
None => (0..doc.artboards.len()).collect(),
};
if indices.is_empty() {
return Err(PdfError::NoArtboards);
}
if let Some(bad) = indices.iter().find(|i| **i >= doc.artboards.len()) {
return Err(PdfError::BadArtboard(*bad));
}
let pages = pages(doc, opts)?;
let title = crate::pdfx::title(set.standard, opts.title.clone().unwrap_or_else(|| doc.title.clone()));
let created_at = opts.created.or_else(vectorcraft_doc::metadata::now_unix);
@@ -78,12 +66,9 @@ pub fn export_with_report(doc: &Document, opts: &PdfOptions) -> Result<ExportRep
warnings.push("PDF layers can't be written with a knockout page group: every layer is plain page content".into());
}
ex.overprint = set.advanced.overprint == crate::Overprint::Preserve;
// Marks and Bleeds: each page is its artboard (the trim box) grown by the bleed, and by the
// printer's marks around that.
let bleed = set.bleed_of(doc);
let marks = set.marks.printer_marks();
for i in indices {
let boxes = PageBoxes::new(doc.artboards[i].rect, bleed, &marks);
for (i, boxes) in pages {
let (m, r) = (boxes.media, boxes.bleed);
let info = if marks.page_info { crate::marks::page_info(doc, &title, i, created_at) } else { String::new() };
let art = crate::marks::art(doc, &marks, &boxes, bleed, &info);
@@ -108,13 +93,33 @@ pub fn export_with_report(doc: &Document, opts: &PdfOptions) -> Result<ExportRep
w.absorb(ex);
let (bytes, more) = w.finish()?;
let bytes = crate::forms::finish(bytes, doc, set, layers, overprinted)?;
let bytes = if native.is_some() { crate::editing::seal(bytes)? } else { bytes };
let bytes = crate::encrypt::protect(bytes, set)?;
warnings.extend(more);
let bytes = crate::post::finish(bytes, opts, native.is_some(), &mut warnings)?;
warnings.dedup();
Ok(ExportReport { bytes, warnings })
}
/// The artboards `opts` exports, in page order, with the boxes of their pages: Marks and Bleeds
/// make each page its artboard (the trim box) grown by the bleed, and by the printer's marks
/// around that.
fn pages(doc: &Document, opts: &PdfOptions) -> Result<Vec<(usize, PageBoxes)>, PdfError> {
let indices: Vec<usize> = match &opts.artboards {
Some(v) => v.clone(),
None => (0..doc.artboards.len()).collect(),
};
if doc.artboards.is_empty() || indices.is_empty() {
return Err(PdfError::NoArtboards);
}
let (bleed, marks) = (opts.settings.bleed_of(doc), opts.settings.marks.printer_marks());
indices.into_iter().map(|i| Ok((i, PageBoxes::new(doc.artboards.get(i).ok_or(PdfError::BadArtboard(i))?.rect, bleed, &marks)))).collect()
}
/// What each page of `doc` exported with `opts` shows, in document space (its media box: the
/// artboard with the bleed and the printer's marks), in page order.
pub fn page_areas(doc: &Document, opts: &PdfOptions) -> Result<Vec<Rect>, PdfError> {
Ok(pages(doc, opts)?.into_iter().map(|(_, b)| b.media).collect())
}
/// A PDF being written, shared by export and print: the file's configuration and metadata, then
/// its pages ([`Self::page`]), each drawn by an [`Exporter`].
pub(crate) struct Writer {
@@ -127,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<String>,
/// 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`]),
@@ -166,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,
@@ -207,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.
@@ -236,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();
@@ -306,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))
}
}
+16 -2
View File
@@ -16,7 +16,10 @@
//! 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,
//! 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()`]).
//! 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). 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),
@@ -49,20 +52,24 @@ mod import_scan;
mod import_shading;
mod import_text;
mod lab_spot;
mod linearize;
mod marks;
mod output;
mod pages;
mod patch;
mod pdfx;
mod post;
mod presets;
mod print;
mod settings;
mod syntax;
pub use editing::{EDITING_FILE, Editing, LEGACY_EDITING_FILE, editing, editing_with};
pub use encrypt::Encryption;
pub use export::{export, export_with_report};
pub use export::{export, export_with_report, page_areas};
pub use import::{import, import_with_report};
pub use pages::{PageInfo, PdfInfo, info, is_postscript};
pub use post::{THUMBNAIL_SIZE, Thumbnail};
pub use presets::*;
pub use print::*;
pub use settings::*;
@@ -81,6 +88,9 @@ pub struct PdfOptions {
pub created: Option<i64>,
/// The native document (`.vectorcraft` bytes) Preserve Editing embeds.
pub native: Option<Vec<u8>>,
/// The pages drawn small, in page order: what Embed Page Thumbnails embeds (the writer
/// doesn't draw; [`page_areas`] says what each page shows).
pub thumbnails: Vec<Thumbnail>,
}
impl PdfOptions {
@@ -226,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;
@@ -243,3 +255,5 @@ mod tests_stroke;
mod tests_strokeout;
#[cfg(test)]
mod tests_textstroke;
#[cfg(test)]
mod tests_webview;
+532
View File
@@ -0,0 +1,532 @@
//! Save PDF › Optimize for fast web view: a linearised file (ISO 32000-1 Annex F), whose first page
//! shows while the rest of it downloads.
//!
//! [`prepare`] writes the export's file (one cross-reference table) again with its objects in
//! linearised order and numbering. First, with the highest numbers: the linearization dictionary,
//! the catalog with what opening the document needs, the hint stream and the first page (its page
//! object, then every object it uses, numbered on from it). Then, numbered from 1: each other page
//! with the objects only it uses, the objects several pages share, and the rest (the page tree,
//! document information, metadata, editing data, thumbnails). Encryption runs on that file: it
//! keeps the order and the numbers and adds its dictionary as the last object. [`write`] then lays
//! the file out: the linearization dictionary, the first page's cross-reference table and trailer,
//! the catalog part (with the encryption dictionary), the hint stream (the page offset and shared
//! object hint tables, encrypted like any stream), the first page, the other pages, the shared
//! objects, the rest and the main cross-reference table.
use std::collections::{HashMap, HashSet, VecDeque};
use crate::PdfError;
use crate::encrypt::Cipher;
use crate::encrypt::{Lexer, Obj};
use crate::patch::Xref;
use crate::syntax::{Indirect, indirect, ref_spans};
fn failed(why: &str) -> PdfError {
PdfError::Write(format!("can't linearise the PDF: {why}"))
}
/// Catalog entries whose objects opening the document needs: they go with the catalog.
const OPEN_KEYS: [&[u8]; 5] = [b"ViewerPreferences", b"Threads", b"OpenAction", b"AcroForm", b"OCProperties"];
/// Keys whose objects aren't part of what a page uses: its parent (the page tree) and its
/// thumbnail (shown without the page).
const NOT_USED: [&[u8]; 2] = [b"Parent", b"Thumb"];
/// Deepest page tree read.
const MAX_TREE_DEPTH: usize = 64;
/// Width of the numbers [`write`] fills in once the layout is known (offsets below 10 GB).
const WIDTH: usize = 10;
/// The linearised order of a [`prepare`]d file's objects, by part (object numbers).
pub(crate) struct Plan {
/// The linearization dictionary: the first object of the first page's cross-reference table.
first: u32,
hint: u32,
/// The catalog and the objects opening the document needs.
open: Vec<u32>,
/// The first page: its page object, then every object it uses.
first_page: Vec<u32>,
/// Each other page: its page object, then the objects only it uses.
pages: Vec<Vec<u32>>,
/// The objects several pages (not the first) use.
shared: Vec<u32>,
/// The rest.
other: Vec<u32>,
/// For each page but the first, the shared object hint table entries of the objects it uses
/// from the first page and from [`Self::shared`].
shared_ids: Vec<Vec<u32>>,
}
/// A written file (one cross-reference table, as the export writes it) read for rewriting.
pub(crate) struct Parsed<'a> {
pub xref: Xref,
pub objects: HashMap<u32, Indirect<'a>>,
/// The objects' numbers and offsets, in file order.
pub order: Vec<(u32, usize)>,
pub trailer: Obj<'a>,
/// The catalog.
pub root: u32,
/// The page objects, in order.
pub pages: Vec<u32>,
/// The page tree's nodes.
pub nodes: HashSet<u32>,
}
/// Read `pdf` → its objects, catalog and pages, or why it can't be.
pub(crate) fn parse(pdf: &[u8]) -> Result<Parsed<'_>, &'static str> {
let xref = Xref::read(pdf).ok_or("no cross-reference table")?;
let mut order = xref.objects(pdf);
order.sort_by_key(|e| e.1);
let mut objects = HashMap::with_capacity(order.len());
for &(n, off) in &order {
let o = indirect(pdf, off).filter(|o| o.num == n && o.generation == 0).ok_or("an object can't be read")?;
objects.insert(n, o);
}
let trailer = Lexer::at(pdf, xref.trailer_at()).object(0).ok_or("bad trailer")?;
let Some(&Obj::Ref(root, _)) = trailer.get(b"Root") else { return Err("no catalog") };
let catalog = &objects.get(&root).ok_or("no catalog")?.value;
let Some(&Obj::Ref(tree, _)) = catalog.get(b"Pages") else { return Err("no pages") };
let (mut pages, mut nodes) = (vec![], HashSet::new());
page_tree(&objects, tree, 0, &mut pages, &mut nodes)?;
if pages.is_empty() {
return Err("no pages");
}
Ok(Parsed { xref, objects, order, trailer, root, pages, nodes })
}
/// The pages under page tree node `n`, in order, and the tree's nodes.
pub(crate) fn page_tree(
objects: &HashMap<u32, Indirect<'_>>,
n: u32,
depth: usize,
pages: &mut Vec<u32>,
nodes: &mut HashSet<u32>,
) -> Result<(), &'static str> {
let node = objects.get(&n).ok_or("a page is missing")?;
match node.value.name(b"Type") {
Some(b"Page") if !pages.contains(&n) => pages.push(n),
Some(b"Pages") if depth < MAX_TREE_DEPTH && nodes.insert(n) => {
let Some(Obj::Array(kids)) = node.value.get(b"Kids") else { return Err("a page tree node has no kids") };
for kid in kids {
let Obj::Ref(k, _) = kid else { return Err("a page tree kid isn't a reference") };
page_tree(objects, *k, depth + 1, pages, nodes)?;
}
}
_ => return Err("bad page tree"),
}
Ok(())
}
/// The objects reached from `starts` (themselves included), breadth first, not entering those
/// `stop` names.
fn reach(objects: &HashMap<u32, Indirect<'_>>, starts: &[u32], stop: impl Fn(u32) -> bool) -> Vec<u32> {
let mut seen = HashSet::new();
let mut queue: VecDeque<u32> = starts.iter().copied().filter(|n| objects.contains_key(n) && seen.insert(*n)).collect();
let mut out = vec![];
let mut links = vec![];
while let Some(n) = queue.pop_front() {
out.push(n);
links.clear();
if let Some(o) = objects.get(&n) {
o.value.refs(&NOT_USED, &mut links);
}
for &m in &links {
if objects.contains_key(&m) && !stop(m) && seen.insert(m) {
queue.push_back(m);
}
}
}
out
}
/// `pdf` (as the export writes it: one cross-reference table, no encryption) with its objects in
/// linearised order and numbering, a placeholder linearization dictionary and hint stream, and the
/// plan [`write`] lays it out with.
pub(crate) fn prepare(pdf: &[u8]) -> Result<(Vec<u8>, Plan), PdfError> {
let Parsed { objects, order: entries, trailer, root, pages, nodes, .. } = parse(pdf).map_err(failed)?;
if trailer.get(b"Encrypt").is_some() || trailer.get(b"Prev").is_some() {
return Err(failed("the file was updated or encrypted already"));
}
let catalog = &objects.get(&root).ok_or_else(|| failed("no catalog"))?.value;
let Some(&first_page_object) = pages.first() else { return Err(failed("no pages")) };
let page_set: HashSet<u32> = pages.iter().copied().collect();
let stop = |n: u32| n == root || page_set.contains(&n) || nodes.contains(&n);
// The catalog and what opening the document needs.
let mut starts = vec![];
for key in OPEN_KEYS {
if let Some(v) = catalog.get(key) {
v.refs(&NOT_USED, &mut starts);
}
}
starts.retain(|n| !stop(*n));
let open: Vec<u32> = std::iter::once(root).chain(reach(&objects, &starts, stop)).collect();
let mut taken: HashSet<u32> = open.iter().copied().collect();
// What each page uses (pages don't enter other pages).
let uses: Vec<Vec<u32>> = pages.iter().map(|&p| reach(&objects, &[p], |n| n != p && stop(n))).collect();
let first_page: Vec<u32> = uses.first().map_or(&[][..], Vec::as_slice).iter().copied().filter(|n| !taken.contains(n)).collect();
taken.extend(&first_page);
let rest = uses.get(1..).unwrap_or_default();
let mut users: HashMap<u32, usize> = HashMap::new();
for n in rest.iter().flatten().filter(|n| !taken.contains(n)) {
*users.entry(*n).or_default() += 1;
}
let own: Vec<Vec<u32>> = rest.iter().map(|u| u.iter().copied().filter(|n| users.get(n) == Some(&1)).collect()).collect();
let mut shared = vec![];
for &n in rest.iter().flatten() {
if users.get(&n).is_some_and(|c| *c > 1) && !shared.contains(&n) {
shared.push(n);
}
}
taken.extend(own.iter().flatten().chain(&shared));
let other: Vec<u32> = entries.iter().map(|e| e.0).filter(|n| !taken.contains(n)).collect();
if own.iter().any(|p| p.first().is_none_or(|n| !page_set.contains(n))) || first_page.first() != Some(&first_page_object) {
return Err(failed("a page object is used by something else"));
}
// Their shared object hint table entries: the first page's objects, then the shared ones.
let index: HashMap<u32, u32> = first_page.iter().chain(&shared).enumerate().map(|(i, n)| (*n, i as u32)).collect();
let shared_ids: Vec<Vec<u32>> =
rest.iter().map(|u| u.iter().filter(|n| users.get(n) != Some(&1)).filter_map(|n| index.get(n).copied()).collect()).collect();
// Numbers: the second part from 1, then the first part.
let mut numbers = Numbers { next: 1, of: HashMap::with_capacity(objects.len()) };
let own_new: Vec<Vec<u32>> = own.iter().map(|p| numbers.assign(p)).collect();
let shared_new = numbers.assign(&shared);
let other_new = numbers.assign(&other);
let first = numbers.fresh();
let open_new = numbers.assign(&open);
let hint = numbers.fresh();
let first_page_new = numbers.assign(&first_page);
let (size, number) = (numbers.next, numbers.of);
if number.len() != objects.len() {
return Err(failed("objects were left out"));
}
// The file in that order.
let head = entries.first().and_then(|e| pdf.get(..e.1)).ok_or_else(|| failed("no objects"))?;
let mut out = Vec::with_capacity(pdf.len() + 256);
out.extend_from_slice(head);
let mut offsets = vec![0usize; size as usize];
let mut put = |num: u32, body: &[u8], out: &mut Vec<u8>| {
if let Some(slot) = offsets.get_mut(num as usize) {
*slot = out.len();
}
out.extend_from_slice(format!("{num} 0 obj\n").as_bytes());
out.extend_from_slice(body);
out.extend_from_slice(b"\nendobj\n");
};
let body = |n: u32| -> Result<Vec<u8>, PdfError> {
let o = objects.get(&n).ok_or_else(|| failed("an object is missing"))?;
// Up to `endobj`, without the white space before it.
let end = o.endobj - pdf.get(..o.endobj).map_or(0, |b| b.iter().rev().take_while(|c| c.is_ascii_whitespace()).count());
renumber(pdf, o.value_span.0, end.max(o.value_span.1), o.value_span.1, &number)
};
put(first, b"<</Linearized 1>>", &mut out);
for (&n, &new) in open.iter().zip(&open_new) {
put(new, &body(n)?, &mut out);
}
put(hint, b"<</Length 0>>\nstream\n\nendstream", &mut out);
let order = first_page.iter().zip(&first_page_new).chain(own.iter().flatten().zip(own_new.iter().flatten()));
for (&n, &new) in order.chain(shared.iter().zip(&shared_new)).chain(other.iter().zip(&other_new)) {
put(new, &body(n)?, &mut out);
}
let table = out.len();
out.extend_from_slice(format!("xref\n0 {size}\n0000000000 65535 f\r\n").as_bytes());
for off in offsets.iter().skip(1) {
out.extend_from_slice(format!("{off:010} 00000 n\r\n").as_bytes());
}
let Obj::Dict { start, end, .. } = &trailer else { return Err(failed("bad trailer")) };
let Some(Obj::Int { start: size_start, end: size_end, .. }) = trailer.get(b"Size") else { return Err(failed("the trailer has no size")) };
let mut dict = renumber(pdf, *start, *size_start, *size_start, &number)?;
dict.extend_from_slice(size.to_string().as_bytes());
dict.extend(renumber(pdf, *size_end, *end, *end, &number)?);
out.extend_from_slice(b"trailer\n");
out.extend(dict);
out.extend_from_slice(format!("\nstartxref\n{table}\n%%EOF\n").as_bytes());
let plan = Plan { first, hint, open: open_new, first_page: first_page_new, pages: own_new, shared: shared_new, other: other_new, shared_ids };
Ok((out, plan))
}
/// New object numbers, given in turn.
struct Numbers {
next: u32,
/// Old number → new.
of: HashMap<u32, u32>,
}
impl Numbers {
/// A number for a new object.
fn fresh(&mut self) -> u32 {
self.next += 1;
self.next - 1
}
/// Numbers for the objects `list` names, in its order.
fn assign(&mut self, list: &[u32]) -> Vec<u32> {
list.iter()
.map(|n| {
let new = self.fresh();
self.of.insert(*n, new);
new
})
.collect()
}
}
/// `pdf[start..end]` with the references in `start..refs_end` renumbered as `number`
/// says (references to objects that aren't there become `null`).
fn renumber(pdf: &[u8], start: usize, end: usize, refs_end: usize, number: &HashMap<u32, u32>) -> Result<Vec<u8>, PdfError> {
let mut out = Vec::with_capacity(end.saturating_sub(start) + 16);
let mut at = start;
for (s, e, n) in ref_spans(pdf, start, refs_end) {
out.extend_from_slice(pdf.get(at..s).ok_or_else(|| failed("bad reference"))?);
match number.get(&n) {
Some(new) => out.extend_from_slice(format!("{new} 0 R").as_bytes()),
None => out.extend_from_slice(b"null"),
}
at = e;
}
out.extend_from_slice(pdf.get(at..end).ok_or_else(|| failed("bad object"))?);
Ok(out)
}
/// Bits written most significant first; [`Self::align`] pads to a byte.
#[derive(Default)]
struct Bits {
out: Vec<u8>,
byte: u8,
used: u32,
}
impl Bits {
fn put(&mut self, value: u64, bits: u32) {
for i in (0..bits).rev() {
self.byte = self.byte << 1 | u8::from(value >> i & 1 == 1);
self.used += 1;
if self.used == 8 {
self.out.push(self.byte);
(self.byte, self.used) = (0, 0);
}
}
}
fn align(&mut self) {
if self.used > 0 {
self.put(0, 8 - self.used);
}
}
/// One item of every entry, then the padding to a byte (each item of a hint table starts on
/// one).
fn column(&mut self, values: impl IntoIterator<Item = u64>, bits: u32) {
for v in values {
self.put(v, bits);
}
self.align();
}
}
/// Bits needed to write `v`.
fn bits(v: u64) -> u32 {
64 - v.leading_zeros()
}
/// A 32-bit hint table value.
fn u32_of(v: usize) -> Result<u64, PdfError> {
u32::try_from(v).map(u64::from).map_err(|_| failed("the file is too large"))
}
/// The page offset and shared object hint tables → (the hint stream's data, where the shared
/// object table starts), from where each object starts and how long it is, the hint stream left
/// out.
fn hint_tables(plan: &Plan, at: &HashMap<u32, (usize, usize)>) -> Result<(Vec<u8>, usize), PdfError> {
let len = |n: &u32| at.get(n).map_or(0, |a| a.1);
let pages: Vec<&[u32]> = std::iter::once(plan.first_page.as_slice()).chain(plan.pages.iter().map(Vec::as_slice)).collect();
let counts: Vec<u64> = pages.iter().map(|p| p.len() as u64).collect();
let lengths = pages.iter().map(|p| u32_of(p.iter().map(len).sum())).collect::<Result<Vec<u64>, _>>()?;
let nshared: Vec<u64> = std::iter::once(0).chain(plan.shared_ids.iter().map(|s| s.len() as u64)).collect();
let span = |v: &[u64]| {
let (min, max) = (v.iter().copied().min().unwrap_or(0), v.iter().copied().max().unwrap_or(0));
(min, bits(max - min))
};
let ((min_count, count_bits), (min_len, len_bits)) = (span(&counts), span(&lengths));
let entries = plan.first_page.len() + plan.shared.len();
let id_bits = bits(entries.saturating_sub(1) as u64);
let first_at = plan.first_page.first().and_then(|n| at.get(n)).map_or(0, |a| a.0);
let mut b = Bits::default();
// The page offset hint table: its header, then each item for every page.
b.put(min_count, 32);
b.put(u32_of(first_at)?, 32);
b.put(u64::from(count_bits), 16);
b.put(min_len, 32);
b.put(u64::from(len_bits), 16);
// Content streams' offsets and lengths: the page's.
b.put(0, 32);
b.put(0, 16);
b.put(min_len, 32);
b.put(u64::from(len_bits), 16);
b.put(u64::from(bits(nshared.iter().copied().max().unwrap_or(0))), 16);
b.put(u64::from(id_bits), 16);
// No fractional positions of shared objects.
b.put(0, 16);
b.put(1, 16);
b.column(counts.iter().map(|c| c - min_count), count_bits);
b.column(lengths.iter().map(|l| l - min_len), len_bits);
b.column(nshared.iter().copied(), bits(nshared.iter().copied().max().unwrap_or(0)));
b.column(plan.shared_ids.iter().flatten().map(|i| u64::from(*i)), id_bits);
b.column(lengths.iter().map(|l| l - min_len), len_bits);
// The shared object hint table: one object a group, the first page's objects first.
let shared_at = b.out.len();
let groups = plan.first_page.iter().chain(&plan.shared).map(|n| u32_of(len(n))).collect::<Result<Vec<u64>, _>>()?;
let (min_group, group_bits) = span(&groups);
let first_shared = plan.shared.first().and_then(|n| Some((*n, at.get(n)?.0))).unwrap_or((0, 0));
b.put(u64::from(first_shared.0), 32);
b.put(u32_of(first_shared.1)?, 32);
b.put(plan.first_page.len() as u64, 32);
b.put(entries as u64, 32);
b.put(0, 16);
b.put(min_group, 32);
b.put(u64::from(group_bits), 16);
b.column(groups.iter().map(|g| g - min_group), group_bits);
// No MD5 signatures.
b.column(groups.iter().map(|_| 0), 1);
Ok((b.out, shared_at))
}
/// A number padded with spaces to [`WIDTH`].
fn padded(v: usize) -> String {
format!("{v:<WIDTH$}")
}
/// Lay out a [`prepare`]d file (encrypted after it, or not) as `plan` says, the hint stream
/// encrypted with `cipher` when the file is.
pub(crate) fn write(pdf: &[u8], plan: &Plan, cipher: Option<&mut Cipher>) -> Result<Vec<u8>, PdfError> {
let xref = Xref::read(pdf).ok_or_else(|| failed("no cross-reference table"))?;
let mut entries = xref.objects(pdf);
entries.sort_by_key(|e| e.1);
// Each object's bytes run to where the next one starts.
let ends = entries.iter().skip(1).map(|e| e.1).chain(std::iter::once(xref.at()));
let bytes: HashMap<u32, &[u8]> = entries.iter().zip(ends).filter_map(|(&(n, off), end)| Some((n, pdf.get(off..end)?))).collect();
let head = entries.first().and_then(|e| pdf.get(..e.1)).ok_or_else(|| failed("no objects"))?;
let trailer = Lexer::at(pdf, xref.trailer_at()).object(0).ok_or_else(|| failed("bad trailer"))?;
let (Obj::Dict { start, end, .. }, Some(Obj::Int { value: size, .. })) = (&trailer, trailer.get(b"Size")) else {
return Err(failed("bad trailer"));
};
let size = u32::try_from(*size).map_err(|_| failed("bad trailer"))?;
let encrypt = match trailer.get(b"Encrypt") {
Some(Obj::Ref(n, _)) => Some(*n),
_ => None,
};
// The first part: the linearization dictionary, the catalog part with the encryption
// dictionary, the hint stream and the first page, which the first page's table covers.
let open: Vec<u32> = plan.open.iter().copied().chain(encrypt).collect();
let mut first_part: Vec<u32> = [plan.first, plan.hint].into_iter().chain(open.iter().copied()).chain(plan.first_page.iter().copied()).collect();
first_part.sort_unstable();
let rest: Vec<u32> = plan.pages.iter().flatten().chain(&plan.shared).chain(&plan.other).copied().collect();
let complete = (1..size).all(|n| bytes.contains_key(&n));
if !complete || !first_part.iter().copied().eq(plan.first..size) || rest.len() + 1 != plan.first as usize {
return Err(failed("the objects don't match the plan"));
}
let len = |n: &u32| bytes.get(n).map_or(0, |b| b.len());
// The layout without the hint stream (where its tables place the objects).
let lin_len = linearization(plan, [0; 5]).len();
let dict = pdf.get(*start..end.saturating_sub(2)).ok_or_else(|| failed("bad trailer"))?;
let first_table = |prev: usize, at: &dyn Fn(u32) -> usize| {
let mut t = format!("xref\n{} {}\n", plan.first, size - plan.first).into_bytes();
for n in plan.first..size {
t.extend_from_slice(format!("{:010} 00000 n\r\n", at(n)).as_bytes());
}
t.extend_from_slice(b"trailer\n");
t.extend_from_slice(dict);
t.extend_from_slice(format!("/Prev {}>>\nstartxref\n0\n%%EOF\n", padded(prev)).as_bytes());
t
};
let table_len = first_table(0, &|_| 0).len();
let mut at: HashMap<u32, (usize, usize)> = HashMap::with_capacity(bytes.len() + 2);
let mut pos = head.len() + lin_len + table_len;
let mut place = |list: &mut dyn Iterator<Item = &u32>, pos: &mut usize| {
for n in list {
at.insert(*n, (*pos, len(n)));
*pos += len(n);
}
};
place(&mut open.iter(), &mut pos);
let hint_at = pos;
place(&mut plan.first_page.iter(), &mut pos);
let first_end = pos;
place(&mut rest.iter(), &mut pos);
let main_at = pos;
// The hint stream, and every offset after it moved by its length.
let (tables, shared_at) = hint_tables(plan, &at)?;
let data = match cipher {
Some(c) => c.stream(plan.hint, &tables)?,
None => tables,
};
let mut hint = format!("{} 0 obj\n<</Length {}/S {shared_at}>>\nstream\n", plan.hint, data.len()).into_bytes();
hint.extend_from_slice(&data);
hint.extend_from_slice(b"\nendstream\nendobj\n");
let shift = |off: usize| if off >= hint_at { off + hint.len() } else { off };
let offset = |n: u32| match n {
n if n == plan.first => head.len(),
n if n == plan.hint => hint_at,
n => at.get(&n).map_or(0, |a| shift(a.0)),
};
let main = {
let mut t = format!("xref\n0 {}\n0000000000 65535 f\r\n", plan.first).into_bytes();
for n in 1..plan.first {
t.extend_from_slice(format!("{:010} 00000 n\r\n", offset(n)).as_bytes());
}
t
};
let main_at = shift(main_at);
let entries_at = main_at + format!("xref\n0 {}\n", plan.first).len();
let tail = format!("trailer\n<</Size {}>>\nstartxref\n{}\n%%EOF\n", plan.first, head.len() + lin_len);
let total = main_at + main.len() + tail.len();
if total >= 10usize.pow(WIDTH as u32) {
return Err(failed("the file is too large"));
}
let lin = linearization(plan, [total, hint_at, hint.len(), shift(first_end), entries_at - 1]);
let table = first_table(main_at, &offset);
if lin.len() != lin_len || table.len() != table_len {
return Err(failed("the layout moved"));
}
let mut out = Vec::with_capacity(total);
out.extend_from_slice(head);
out.extend_from_slice(&lin);
out.extend_from_slice(&table);
let copy = |list: &mut dyn Iterator<Item = &u32>, out: &mut Vec<u8>| {
for n in list {
out.extend_from_slice(bytes.get(n).copied().unwrap_or_default());
}
};
copy(&mut open.iter(), &mut out);
out.extend_from_slice(&hint);
copy(&mut plan.first_page.iter(), &mut out);
copy(&mut rest.iter(), &mut out);
out.extend_from_slice(&main);
out.extend_from_slice(tail.as_bytes());
if out.len() != total {
return Err(failed("the layout moved"));
}
Ok(out)
}
/// The linearization dictionary object, its numbers ([file length, hint stream offset and length,
/// end of the first page, offset of the white space before the main table's first entry]) padded
/// to a fixed width.
fn linearization(plan: &Plan, [l, h, h_len, e, t]: [usize; 5]) -> Vec<u8> {
let o = plan.first_page.first().copied().unwrap_or_default();
let n = 1 + plan.pages.len();
format!(
"{} 0 obj\n<</Linearized 1/L {}/H[{} {}]/O {o}/E {}/N {n}/T {}>>\nendobj\n",
plan.first,
padded(l),
padded(h),
padded(h_len),
padded(e),
padded(t)
)
.into_bytes()
}
+15
View File
@@ -61,6 +61,21 @@ impl Xref {
number(entry, 0).map(|(off, _)| off)
}
/// The in-use objects: number and offset, in number order.
pub fn objects(&self, pdf: &[u8]) -> Vec<(u32, usize)> {
(1..u32::try_from(self.count).unwrap_or(u32::MAX)).filter_map(|n| Some((n, self.offset(pdf, n)?))).collect()
}
/// Where the table (`xref`) starts.
pub fn at(&self) -> usize {
self.at
}
/// Where the trailer dictionary starts (its `<<`).
pub fn trailer_at(&self) -> usize {
self.trailer.0
}
/// The object the trailer's `key` (`b"/Root"`) refers to.
pub fn trailer_ref(&self, pdf: &[u8], key: &[u8]) -> Option<u32> {
let dict = pdf.get(self.trailer.0..self.trailer.1)?;
+24 -22
View File
@@ -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<u8>)> = vec![];
if standard.flattens()
&& let Some(v) = find(xml, b"<pdf:PDFVersion>1.4<", 0)
{
let digit = v + b"<pdf:PDFVersion>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"</rdf:Description>").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<u8>, standard: Standard) -> Result<Vec<u8>, 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<u8>, standard: Standard, pdf13: bool) -> Result<Vec<u8>, 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) =
+114
View File
@@ -0,0 +1,114 @@
//! 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. 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};
/// Longest side of a page thumbnail, in pixels.
pub const THUMBNAIL_SIZE: u32 = 106;
/// A page thumbnail: 8-bit RGB pixels, row by row, at most [`THUMBNAIL_SIZE`] pixels a side.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct Thumbnail {
pub width: u32,
pub height: u32,
pub rgb: Vec<u8>,
}
impl Thumbnail {
/// The image XObject of a page's `/Thumb` entry; `gray`: in DeviceGray (for PDF/X files, whose
/// device colours must suit the printing condition, an RGB thumbnail wouldn't).
fn object(&self, gray: bool) -> Result<Vec<u8>, PdfError> {
let (w, h) = (self.width, self.height);
let sized = (1..=THUMBNAIL_SIZE).contains(&w) && (1..=THUMBNAIL_SIZE).contains(&h);
if !sized || self.rgb.len() as u64 != u64::from(w) * u64::from(h) * 3 {
return Err(PdfError::BadSetting(format!("a page thumbnail must be RGB pixels, 1 to {THUMBNAIL_SIZE} a side (got {w} × {h})")));
}
let (space, pixels) = if gray {
// Rec. 601 luma.
let luma = |p: &[u8; 3]| ((299 * u32::from(p[0]) + 587 * u32::from(p[1]) + 114 * u32::from(p[2]) + 500) / 1000) as u8;
("DeviceGray", Cow::Owned(self.rgb.as_chunks::<3>().0.iter().map(luma).collect()))
} else {
("DeviceRGB", Cow::Borrowed(self.rgb.as_slice()))
};
let data = crate::output::deflate(&pixels).map_err(|e| PdfError::Write(format!("page thumbnail: {e}")))?;
let mut out = format!("<</Width {w}/Height {h}/ColorSpace/{space}/BitsPerComponent 8/Filter/FlateDecode/Length {}>>\nstream\n", data.len())
.into_bytes();
out.extend_from_slice(&data);
out.extend_from_slice(b"\nendstream");
Ok(out)
}
}
/// `pdf` with `thumbs` as the thumbnails of its pages, in page order (in grey when `gray`).
fn thumbnails(pdf: Vec<u8>, thumbs: &[Thumbnail], gray: bool) -> Result<Vec<u8>, PdfError> {
let failed = |why: &str| PdfError::Write(format!("can't add page thumbnails: {why}"));
let Parsed { xref, pages, .. } = linearize::parse(&pdf).map_err(failed)?;
let mut patch = Patch::new(&xref);
for (page, thumb) in pages.iter().zip(thumbs) {
let (dict, _) = xref.dict(&pdf, *page).ok_or_else(|| failed("a page can't be read"))?;
let n = patch.add_object(thumb.object(gray)?);
patch.replace(dict, dict, format!("/Thumb {n} 0 R").into_bytes());
}
patch.apply(&pdf, &xref)
}
/// Linearise `pdf`, encrypting it on the way as `set` says.
fn linearised(pdf: &[u8], set: &PdfSettings) -> Result<Vec<u8>, PdfError> {
let (prepared, plan) = linearize::prepare(pdf)?;
let (encrypted, mut cipher) = crate::encrypt::protect_keeping(prepared, set)?;
linearize::write(&encrypted, &plan, cipher.as_mut())
}
/// The file the export wrote (`pdf`), finished as `opts` say; `editing`: it carries the editing
/// data, to seal once the pages are final. What can't be done comes back in `warnings`.
pub(crate) fn finish(pdf: Vec<u8>, opts: &PdfOptions, editing: bool, warnings: &mut Vec<String>) -> Result<Vec<u8>, PdfError> {
let set = &opts.settings;
let pdf = match (set.thumbnails, opts.thumbnails.is_empty()) {
(true, false) => thumbnails(pdf, &opts.thumbnails, set.standard.is_pdfx())?,
(true, true) => {
warnings.push("page thumbnails need the pages drawn, which weren't given: none are embedded".into());
pdf
}
(false, _) => pdf,
};
let pdf = if editing { crate::editing::seal(pdf)? } else { pdf };
if !set.fast_web_view {
return crate::encrypt::protect(pdf, set);
}
match linearised(&pdf, set) {
Ok(out) => Ok(out),
Err(e) => {
warnings.push(format!("the file isn't optimized for fast web view: {e}"));
crate::encrypt::protect(pdf, set)
}
}
}
/// 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<usize> {
const VERSION: &[u8] = b"<pdf:PDFVersion>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)
}
+2 -2
View File
@@ -80,12 +80,12 @@ pub fn builtin_presets() -> Vec<PdfPreset> {
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",
+19 -9
View File
@@ -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();
@@ -460,8 +472,6 @@ impl PdfSettings {
let d = Self::default();
let s = &self.security;
[
(self.thumbnails, "page thumbnails are not embedded yet"),
(self.fast_web_view, "fast web view (a linearised file) is not written yet"),
(self.create_layers && !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,
+76
View File
@@ -0,0 +1,76 @@
//! A written file's indirect objects read for rewriting it (the linearisation and the page
//! thumbnails), on the lexer of [`crate::encrypt`]: where each object's parts lie, what it refers
//! to, and the references to renumber.
use crate::encrypt::{Lexer, Obj, Tok, int, stream_spans};
impl Obj<'_> {
/// Every object this one refers to, in file order, leaving out the values of the dictionary
/// keys in `skip` (`Parent`).
pub(crate) fn refs(&self, skip: &[&[u8]], out: &mut Vec<u32>) {
match self {
Self::Ref(n, _) => out.push(*n),
Self::Dict { entries, .. } => entries.iter().filter(|(k, _)| !skip.contains(k)).for_each(|(_, v)| v.refs(skip, out)),
Self::Array(items) => items.iter().for_each(|v| v.refs(skip, out)),
_ => {}
}
}
}
/// An indirect object of a file, with where its parts lie.
pub(crate) struct Indirect<'a> {
pub num: u32,
pub generation: u16,
pub value: Obj<'a>,
/// Where the value (a stream's dictionary) starts and ends.
pub value_span: (usize, usize),
/// Where `endobj` starts.
pub endobj: usize,
}
/// The indirect object at `off` of `pdf` (a stream's length must be direct); `None` when it can't
/// be read.
pub(crate) fn indirect(pdf: &[u8], off: usize) -> Option<Indirect<'_>> {
let mut lx = Lexer::at(pdf, off);
let (num, generation) = lx.header()?;
let start = lx.clone().next()?.1;
let value = lx.object(0)?;
// The value ends before the white space ahead of the next token (`stream` or `endobj`).
let (next, next_at, _) = lx.clone().next()?;
let end = next_at - pdf.get(..next_at)?.iter().rev().take_while(|b| b.is_ascii_whitespace()).count();
let endobj = match next {
Tok::Word(b"endobj") => next_at,
_ => {
let (_, (_, data_end)) = stream_spans(pdf, &mut lx, &value).ok()??;
let mut lx = Lexer::at(pdf, data_end);
let (Tok::Word(b"endstream"), ..) = lx.next()? else { return None };
let (Tok::Word(b"endobj"), at, _) = lx.next()? else { return None };
at
}
};
Some(Indirect { num, generation, value, value_span: (start, end.max(start)), endobj })
}
/// The references (`n g R`) in `pdf[start..end]` (an object's value: no stream data), outside
/// strings: span and object number.
pub(crate) fn ref_spans(pdf: &[u8], start: usize, end: usize) -> Vec<(usize, usize, u32)> {
let mut out = vec![];
let mut lx = Lexer::at(pdf, start);
// The last two tokens when they are numbers: (start, value).
let mut last: [Option<(usize, i64)>; 2] = [None, None];
while let Some((tok, s, e)) = lx.next().filter(|t| t.1 < end) {
match tok {
Tok::Word(b"R") => {
if let [Some((at, n)), Some(_)] = last
&& let Ok(n) = u32::try_from(n)
{
out.push((at, e, n));
}
last = [None, None];
}
Tok::Word(w) => last = [last[1], int(w).map(|v| (s, v))],
_ => last = [None, None],
}
}
out
}
+79
View File
@@ -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::<PdfSettings>(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("<pdf:PDFVersion>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"));
}
+4 -4
View File
@@ -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::<PdfSettings>(bad.clone()).is_err(), "{bad}");
}
}
@@ -76,8 +76,6 @@ fn checks_refuse_what_the_writer_cant_honour() {
fn options_not_applied_yet_come_back_as_warnings() {
assert!(PdfSettings::default().warnings().is_empty());
for (v, word) in [
(json!({"thumbnails": true}), "thumbnails"),
(json!({"fastWebView": true}), "fast web view"),
(json!({"createLayers": true, "compatibility": "1.4"}), "PDF 1.5"),
(json!({"advanced": {"outlineText": false, "fontSubsetPercent": 35}}), "subset"),
(json!({"output": {"outputIntent": "No Such Press"}}), "without embedding"),
@@ -103,6 +101,7 @@ fn options_not_applied_yet_come_back_as_warnings() {
json!({"includeNonPrinting": true}),
json!({"createLayers": true}),
json!({"viewAfterSaving": true}),
json!({"thumbnails": true, "fastWebView": true}),
json!({"compression": {"compressText": false}}),
json!({"compression": {"color": {"compression": "jpeg"}, "mono": {"compression": "ccittG4"}}}),
] {
@@ -111,6 +110,7 @@ fn options_not_applied_yet_come_back_as_warnings() {
let r =
export_with_report(&doc(), &PdfOptions { settings: settings(json!({"thumbnails": true, "marks": {"trim": true}})), ..Default::default() })
.unwrap();
assert_eq!(r.warnings.len(), 1, "{:?}", r.warnings);
// The writer embeds the thumbnails it is given: without them, the one warning says so.
assert!(r.warnings.len() == 1 && r.warnings[0].contains("thumbnails need the pages drawn"), "{:?}", r.warnings);
assert!(r.bytes.starts_with(b"%PDF-1.7"));
}
+395
View File
@@ -0,0 +1,395 @@
//! General › Embed page thumbnails and Optimize for fast web view: a `/Thumb` image on every page,
//! and a linearised file whose hint tables say where each page's objects are (checked here as a
//! linearisation checker does), also when encrypted; with both off the export is unchanged.
use std::collections::{HashMap, HashSet};
use std::io::{Cursor, Read};
use image::{DynamicImage, ImageFormat};
use serde_json::json;
use vectorcraft_color::{Color, Paint};
use vectorcraft_doc::{Appearance, Artboard, Document, ImageBlob, ImageObject, Node, NodeKind};
use vectorcraft_geom::{Affine, Rect, shapes};
use crate::encrypt::{Lexer, Obj};
use crate::syntax::{Indirect, indirect};
use crate::*;
/// `n` artboards side by side, each with a rectangle; `image` places the same image on every page
/// but the first (shared objects), and on the first too when `on_first`.
fn doc(n: usize, image: bool, on_first: bool) -> Document {
let mut d = Document::new(100.0, 100.0);
for i in 1..n {
let x = 200.0 * i as f64;
d.artboards.push(Artboard {
id: i as u32 + 1,
name: format!("A{i}"),
rect: Rect::new(x, 0.0, x + 100.0, 100.0),
show_center_mark: false,
show_cross_hairs: false,
});
}
let mut png = vec![];
DynamicImage::from(image::RgbImage::from_fn(8, 8, |x, y| image::Rgb([(x * 30) as u8, (y * 30) as u8, 90])))
.write_to(&mut Cursor::new(&mut png), ImageFormat::Png)
.unwrap();
d.images.insert("img".into(), ImageBlob::new("image/png", png));
let layer = d.default_layer().unwrap();
for i in 0..n {
let x = 200.0 * i as f64;
let id = d.alloc_id();
let fill = Paint::solid(Color::rgb(0.2, 0.1 * i as f32, 0.8));
d.insert(
Some(layer),
0,
Node::path(id, shapes::rectangle(Rect::new(x + 10.0, 10.0, x + 50.0, 50.0)), Appearance::basic(fill, Paint::None, 0.0)),
)
.unwrap();
if image && (i > 0 || on_first) {
let xf = Affine::translate((x + 60.0, 60.0)) * Affine::scale(4.0);
let img = ImageObject { key: "img".into(), width: 8, height: 8, xf, link: None, placement: Default::default() };
let id = d.alloc_id();
d.insert(Some(layer), 0, Node::new(id, NodeKind::Image(img))).unwrap();
}
}
d
}
fn options(settings: serde_json::Value) -> PdfOptions {
PdfOptions { settings: serde_json::from_value(settings).unwrap(), created: Some(0), ..Default::default() }
}
/// A thumbnail of `w` × `h` pixels of one grey.
fn thumb(w: u32, h: u32, grey: u8) -> Thumbnail {
Thumbnail { width: w, height: h, rgb: vec![grey; (w * h * 3) as usize] }
}
/// The objects of a (possibly linearised) file, from its cross-reference tables: number → offset.
fn offsets(pdf: &[u8], tables: &[usize]) -> HashMap<u32, usize> {
let mut out = HashMap::new();
for &at in tables {
let mut lx = Lexer::at(pdf, at);
assert!(matches!(lx.next(), Some((crate::encrypt::Tok::Word(b"xref"), ..))), "a table at {at}");
let word = |lx: &mut Lexer| match lx.next() {
Some((crate::encrypt::Tok::Word(w), ..)) => std::str::from_utf8(w).unwrap().to_string(),
t => panic!("{t:?}"),
};
let (first, count): (u32, u32) = (word(&mut lx).parse().unwrap(), word(&mut lx).parse().unwrap());
for n in first..first + count {
let (off, _, kind) = (word(&mut lx), word(&mut lx), word(&mut lx));
if kind == "n" {
out.insert(n, off.parse().unwrap());
}
}
}
out
}
fn int(o: &Obj, key: &[u8]) -> usize {
usize::try_from(o.int(key).unwrap_or_else(|| panic!("{}", String::from_utf8_lossy(key)))).unwrap()
}
fn reference(o: &Obj, key: &[u8]) -> u32 {
match o.get(key) {
Some(Obj::Ref(n, _)) => *n,
_ => panic!("{} is no reference", String::from_utf8_lossy(key)),
}
}
/// Bits read most significant first.
struct BitReader<'a> {
data: &'a [u8],
at: usize,
}
impl BitReader<'_> {
fn get(&mut self, bits: u64) -> u64 {
(0..bits).fold(0, |v, _| {
let bit = self.data[self.at / 8] >> (7 - self.at % 8) & 1;
self.at += 1;
v << 1 | u64::from(bit)
})
}
fn align(&mut self) {
self.at = self.at.div_ceil(8) * 8;
}
/// One item for each of `n` entries, then the padding.
fn column(&mut self, n: usize, bits: u64) -> Vec<u64> {
let v = (0..n).map(|_| self.get(bits)).collect();
self.align();
v
}
}
/// What a linearisation check finds in `pdf`.
struct Linearised {
pages: Vec<u32>,
/// The objects of the first page's cross-reference table.
first_part: HashSet<u32>,
encrypted: bool,
}
/// Check `pdf` is linearised as ISO 32000-1 Annex F says, as a linearisation checker does: the
/// linearization dictionary, both cross-reference tables and their trailers, and (unless the file
/// is encrypted) the hint tables against where every page's objects are and what each page uses.
fn check_linearised(pdf: &[u8]) -> Linearised {
// The linearization dictionary is the first object.
let first_obj = crate::lab_spot::find(pdf, b" 0 obj", 0).unwrap();
let lin_at = pdf[..first_obj].iter().rposition(|b| *b == b'\n').unwrap() + 1;
let lin = indirect(pdf, lin_at).unwrap();
assert_eq!(lin.value.int(b"Linearized"), Some(1));
assert_eq!(int(&lin.value, b"L"), pdf.len(), "/L is the file's length");
// The first page's table follows it, its trailer points at the main table (/Prev), and the
// file's last startxref at it.
let first_table = lin.endobj + 7;
assert!(pdf[first_table..].starts_with(b"xref"));
let last = crate::lab_spot::rfind(pdf, b"startxref").unwrap();
assert_eq!(std::str::from_utf8(&pdf[last + 9..]).unwrap().split_whitespace().next().unwrap().parse::<usize>().unwrap(), first_table);
let trailer_at = crate::lab_spot::find(pdf, b"trailer", first_table).unwrap() + 7;
let trailer = Lexer::at(pdf, trailer_at).object(0).unwrap();
let main = int(&trailer, b"Prev");
let size = int(&trailer, b"Size") as u32;
let first_part: HashSet<u32> = offsets(pdf, &[first_table]).into_keys().collect();
let all = offsets(pdf, &[first_table, main]);
assert_eq!(all.len() as u32, size - 1, "the tables cover every object");
let first = lin.num;
assert!(first_part.iter().all(|n| (first..size).contains(n)) && first_part.len() as u32 == size - first);
// /T: the white space before the main table's first entry.
let entries = main + format!("xref\n0 {first}\n").len();
assert!(pdf[main..].starts_with(format!("xref\n0 {first}\n").as_bytes()), "the main table covers 0..{first}");
assert_eq!(int(&lin.value, b"T"), entries - 1);
let main_trailer = Lexer::at(pdf, crate::lab_spot::find(pdf, b"trailer", main).unwrap() + 7).object(0).unwrap();
assert_eq!(int(&main_trailer, b"Size") as u32, first);
// Every object where its table says, read as the file's objects.
let objects: HashMap<u32, Indirect> = all.iter().map(|(n, off)| (*n, indirect(pdf, *off).unwrap())).collect();
assert!(objects.iter().all(|(n, o)| o.num == *n));
// Each object's span: to the next object, or to the table after it.
let mut starts: Vec<usize> = all.values().copied().chain([first_table, main]).collect();
starts.sort_unstable();
let span = |n: u32| {
let at = all[&n];
starts[starts.partition_point(|s| *s <= at)] - at
};
let root = &objects[&reference(&trailer, b"Root")];
let mut pages = vec![];
let mut nodes = HashSet::new();
crate::linearize::page_tree(&objects, reference(&root.value, b"Pages"), 0, &mut pages, &mut nodes).unwrap();
assert_eq!(int(&lin.value, b"N"), pages.len());
assert_eq!(int(&lin.value, b"O") as u32, pages[0], "/O is the first page");
// The hint stream: /H gives where it is and how long.
let Some(Obj::Array(h)) = lin.value.get(b"H") else { panic!("no /H") };
let [Obj::Int { value: h_at, .. }, Obj::Int { value: h_len, .. }] = &h[..] else { panic!("bad /H") };
let (h_at, h_len) = (*h_at as usize, *h_len as usize);
let hint = indirect(pdf, h_at).unwrap();
assert_eq!(h_len, span(hint.num));
// Offsets in the hint tables leave the hint stream out.
let adjusted = |n: u32| if all[&n] > h_at { all[&n] - h_len } else { all[&n] };
// /E: the end of the first page (the encryption dictionary is numbered after it).
let encrypt = trailer.get(b"Encrypt").map(|_| reference(&trailer, b"Encrypt"));
let encrypted = encrypt.is_some();
let first_page_objects = (pages[0]..).take_while(|n| first_part.contains(n) && Some(*n) != encrypt).count() as u32;
let first_end = all[&(pages[0] + first_page_objects - 1)] + span(pages[0] + first_page_objects - 1);
assert_eq!(int(&lin.value, b"E"), first_end, "/E ends the first page");
if encrypted {
return Linearised { pages, first_part, encrypted };
}
let data_at = crate::lab_spot::find(pdf, b"stream", hint.value_span.1).unwrap() + 7;
let data = &pdf[data_at..data_at + int(&hint.value, b"Length")];
let mut r = BitReader { data, at: 0 };
// The page offset hint table.
let (min_objects, first_page_at, objects_bits) = (r.get(32), r.get(32) as usize, r.get(16));
let (min_len, len_bits) = (r.get(32), r.get(16));
let (_content_at, content_at_bits, _content_len, content_len_bits) = (r.get(32), r.get(16), r.get(32), r.get(16));
let (nshared_bits, id_bits, numerator_bits, _denominator) = (r.get(16), r.get(16), r.get(16), r.get(16));
assert_eq!(first_page_at, adjusted(pages[0]), "the first page's location");
let n = pages.len();
let counts: Vec<u64> = r.column(n, objects_bits).iter().map(|c| c + min_objects).collect();
let lengths: Vec<u64> = r.column(n, len_bits).iter().map(|l| l + min_len).collect();
let nshared = r.column(n, nshared_bits);
let ids: Vec<Vec<u64>> = nshared.iter().map(|k| (0..*k).map(|_| r.get(id_bits)).collect()).collect();
r.align();
r.column(nshared.iter().sum::<u64>() as usize, numerator_bits);
r.column(n, content_at_bits);
r.column(n, content_len_bits);
assert_eq!(counts[0] as u32, first_page_objects, "the first page's object count");
assert_eq!(nshared[0], 0, "the first page lists no shared objects");
let mut at = first_page_at;
for (i, &page) in pages.iter().enumerate() {
// A page's objects are numbered on from its page object, and lie together from it.
assert_eq!(adjusted(page), at, "page {i} starts where the pages before it end");
let len: usize = (page..page + counts[i] as u32).map(span).sum();
assert_eq!(len as u64, lengths[i], "page {i}'s length");
at += len;
}
// The shared object hint table.
let s = int(&hint.value, b"S");
let mut r = BitReader { data, at: 8 * s };
let (first_shared, first_shared_at, nfirst, ntotal) = (r.get(32) as u32, r.get(32) as usize, r.get(32), r.get(32));
let (group_bits, min_group, group_len_bits) = (r.get(16), r.get(32), r.get(16));
assert_eq!(group_bits, 0, "one object a group");
assert_eq!(nfirst as u32, first_page_objects);
let shared_object = |id: u64| if id < nfirst { pages[0] + id as u32 } else { first_shared + (id - nfirst) as u32 };
if ntotal > nfirst {
assert_eq!(first_shared_at, adjusted(first_shared), "the shared objects' location");
}
let groups = r.column(ntotal as usize, group_len_bits);
for (id, g) in groups.iter().enumerate() {
assert_eq!((g + min_group) as usize, span(shared_object(id as u64)), "shared object {id}'s length");
}
assert!(r.column(ntotal as usize, 1).iter().all(|s| *s == 0), "no signatures");
// What each page uses lies in its own objects, the first part or its shared objects.
let page_set: HashSet<u32> = pages.iter().copied().collect();
for (i, &page) in pages.iter().enumerate() {
let own: HashSet<u32> = (page..page + counts[i] as u32).collect();
let listed: HashSet<u32> = ids[i].iter().map(|id| shared_object(*id)).collect();
let (mut seen, mut queue) = (HashSet::from([page]), vec![page]);
while let Some(o) = queue.pop() {
assert!(
own.contains(&o) || first_part.contains(&o) && (i == 0 || o < pages[0] || listed.contains(&o)) || listed.contains(&o),
"page {i} uses object {o}, which isn't among its objects or listed"
);
let mut links = vec![];
objects[&o].value.refs(&[b"Parent", b"Thumb"], &mut links);
queue.extend(links.into_iter().filter(|m| !page_set.contains(m) && !nodes.contains(m) && seen.insert(*m)));
}
assert!(listed.iter().all(|o| seen.contains(o)), "page {i} lists only objects it uses");
}
Linearised { pages, first_part, encrypted }
}
#[test]
fn thumbnails_are_embedded_on_every_page() {
let d = doc(3, false, false);
let mut o = options(json!({"thumbnails": true}));
o.thumbnails = vec![thumb(106, 106, 10), thumb(53, 106, 20), thumb(106, 1, 30)];
let r = export_with_report(&d, &o).unwrap();
assert!(r.warnings.is_empty(), "{:?}", r.warnings);
let parsed = crate::linearize::parse(&r.bytes).unwrap();
for (page, t) in parsed.pages.iter().zip(&o.thumbnails) {
let thumb = &parsed.objects[&reference(&parsed.objects[page].value, b"Thumb")];
assert_eq!((int(&thumb.value, b"Width"), int(&thumb.value, b"Height")), (t.width as usize, t.height as usize));
assert_eq!(thumb.value.name(b"ColorSpace"), Some(&b"DeviceRGB"[..]));
let at = crate::lab_spot::find(&r.bytes, b"stream", thumb.value_span.1).unwrap() + 7;
let mut rgb = vec![];
flate2::read::ZlibDecoder::new(&r.bytes[at..at + int(&thumb.value, b"Length")]).read_to_end(&mut rgb).unwrap();
assert_eq!(rgb, t.rgb, "the pixels as given");
}
// The file reads back: three pages, the thumbnails aside.
assert_eq!(import(&r.bytes).unwrap().artboards.len(), 3);
// Asked for without the pages drawn: none, with a warning. Thumbnails too large are refused.
let r = export_with_report(&d, &options(json!({"thumbnails": true}))).unwrap();
assert!(r.warnings.iter().any(|w| w.contains("thumbnails")) && crate::lab_spot::find(&r.bytes, b"/Thumb", 0).is_none());
o.thumbnails[1] = thumb(107, 20, 0);
assert!(matches!(export(&d, &o), Err(PdfError::BadSetting(_))));
}
#[test]
fn fast_web_view_writes_a_linearised_file_whose_hints_check() {
for (pages, image, on_first) in [(1, false, false), (2, false, false), (3, true, false), (4, true, true)] {
let d = doc(pages, image, on_first);
let mut o = options(json!({"fastWebView": true, "compression": {"compressText": pages % 2 == 0}}));
let plain = export(&d, &options(json!({"compression": {"compressText": pages % 2 == 0}}))).unwrap();
let r = export_with_report(&d, &o).unwrap();
assert!(r.warnings.is_empty(), "{:?}", r.warnings);
assert!(r.bytes.starts_with(b"%PDF-1.7"));
let l = check_linearised(&r.bytes);
assert_eq!(l.pages.len(), pages);
assert!(l.first_part.contains(&l.pages[0]) && !l.encrypted);
// The same pages: it reads back as the plain file does.
let (a, b) = (import(&r.bytes).unwrap(), import(&plain).unwrap());
assert_eq!(a.artboards.len(), b.artboards.len());
assert_eq!(format!("{:?}", a.layers), format!("{:?}", b.layers), "{pages} pages");
// With thumbnails and the editing data too (both after the pages).
o.settings.thumbnails = true;
o.settings.preserve_editing = true;
o.native = Some(br#"{"pages":1}"#.to_vec());
o.thumbnails = (0..pages).map(|i| thumb(20, 20, i as u8)).collect();
let r = export_with_report(&d, &o).unwrap();
assert!(r.warnings.is_empty(), "{:?}", r.warnings);
check_linearised(&r.bytes);
let editing = editing(&r.bytes).unwrap();
assert!(editing.intact, "the editing data's seal holds");
assert_eq!(editing.data, br#"{"pages":1}"#);
}
}
#[test]
fn encrypted_files_stay_linearised() {
let d = doc(3, true, true);
for c in ["1.4", "1.5", "1.6", "1.7", "2.0"] {
let o = options(json!({"fastWebView": true, "compatibility": c, "security": {"openPassword": "open", "plaintextMetadata": c != "1.4"}}));
let bytes = export(&d, &o).unwrap();
let l = check_linearised(&bytes);
assert!(l.encrypted && l.pages.len() == 3, "{c}");
// The encryption dictionary is in the first part (opening needs it).
let trailer_at = crate::lab_spot::find(&bytes, b"trailer", 0).unwrap() + 7;
let trailer = Lexer::at(&bytes, trailer_at).object(0).unwrap();
assert!(l.first_part.contains(&reference(&trailer, b"Encrypt")), "{c}");
assert!(matches!(import(&bytes), Err(PdfError::NeedsPassword)), "{c}");
let opened = import_with_report(&bytes, &ImportOptions { password: Some("open".into()), ..Default::default() }).unwrap();
assert_eq!(opened.document.artboards.len(), 3, "{c}");
}
}
#[test]
fn with_both_off_the_file_is_unchanged() {
let d = doc(2, true, false);
let off = options(json!({"thumbnails": false, "fastWebView": false}));
let plain = export(&d, &options(json!({}))).unwrap();
assert_eq!(export(&d, &off).unwrap(), plain, "the default export");
// Finishing leaves the written file as it is.
let mut warnings = vec![];
assert_eq!(crate::post::finish(plain.clone(), &off, false, &mut warnings).unwrap(), plain);
assert!(warnings.is_empty());
// Thumbnails given but not asked for are left out.
let given = PdfOptions { thumbnails: vec![thumb(10, 10, 0); 2], ..off };
assert_eq!(export(&d, &given).unwrap(), plain);
}
#[test]
fn pdf_x_files_take_thumbnails_and_stay_linearised() {
let d = doc(3, true, false);
for standard in [Standard::PdfX1a, Standard::PdfX3, Standard::PdfX4] {
let mut o = options(json!({"thumbnails": true, "fastWebView": true}));
(o.settings.standard, o.settings.compatibility) = (standard, standard.version());
o.thumbnails = vec![thumb(30, 30, 200); 3];
let r = export_with_report(&d, &o).unwrap();
let header = if standard == Standard::PdfX4 { "%PDF-1.6" } else { "%PDF-1.3" };
assert!(r.bytes.starts_with(header.as_bytes()), "{standard:?}");
assert_eq!(check_linearised(&r.bytes).pages.len(), 3, "{standard:?}");
// Still the standard: grey thumbnails (no RGB in PDF/X-1a), nothing it forbids.
crate::pdfx::verify(&r.bytes, standard).unwrap();
assert_eq!(
String::from_utf8_lossy(&r.bytes).matches("/ColorSpace/DeviceGray/BitsPerComponent 8/Filter/FlateDecode").count(),
3,
"{standard:?}"
);
}
}
#[test]
fn pdf_layers_stay_with_a_linearised_file() {
let mut d = doc(3, true, true);
let l = d.add_layer(Some("Notes"));
let id = d.alloc_id();
let n = Node::path(id, shapes::rectangle(Rect::new(220.0, 20.0, 260.0, 60.0)), Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0));
d.insert(Some(l), 0, n).unwrap();
let r = export_with_report(&d, &options(json!({"fastWebView": true, "createLayers": true}))).unwrap();
assert!(r.warnings.is_empty(), "{:?}", r.warnings);
let l = check_linearised(&r.bytes);
// The optional content groups open the document: they come first, with the catalog.
let text = String::from_utf8_lossy(&r.bytes);
let at = text.find("/OCGs[").unwrap() + 6;
let ocgs: Vec<u32> = text[at..at + text[at..].find(']').unwrap()].split(" 0 R").filter_map(|n| n.trim().parse().ok()).collect();
assert!(ocgs.len() == 2 && ocgs.iter().all(|n| l.first_part.contains(n)), "{ocgs:?}");
assert_eq!(l.pages.len(), 3);
let back = import(&r.bytes).unwrap();
assert!(back.layers.len() >= 2, "the layers come back");
}
+61 -12
View File
@@ -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::<Compatibility>(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::<Overprint>(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::<Compatibility>(d, "compatibility") == Some(Compatibility::Pdf13)
|| choice::<Standard>(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", &current, &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::<Compatibility>(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()));
}
@@ -813,12 +839,16 @@ mod tests {
frame(&mut app);
assert!(app.ui.dialog.is_some(), "{s} closed the dialog");
}
// The Summary lists what changed and the warnings for options not applied yet.
// The Summary lists what changed and the warnings for options not applied yet (permissions
// without a password; thumbnails are embedded).
set_field(&mut app, "thumbnails", json!(true));
set_field(&mut app, "security.printing", json!("low"));
let p = params(app.ui.dialog.as_ref().unwrap());
let v = summary_of(&mut app, &egui::Context::default(), &p).unwrap();
assert_eq!(v["changed"][0]["option"], "thumbnails");
assert!(v["warnings"][0].as_str().unwrap().contains("thumbnails"));
let changed: Vec<&str> = v["changed"].as_array().unwrap().iter().map(|c| c["option"].as_str().unwrap()).collect();
assert_eq!(changed, ["security.printing", "thumbnails"]);
let warnings = v["warnings"].as_array().unwrap();
assert!(warnings.len() == 1 && warnings[0].as_str().unwrap().contains("permissions"), "{warnings:?}");
}
#[test]
@@ -849,9 +879,9 @@ mod tests {
fn warnings_reach_the_status_and_errors_keep_the_dialog_open() {
let (mut app, written, opened) = app();
app.run("ui.savePdfDialog", json!({"path": "/tmp/w.pdf"})).unwrap();
set_field(&mut app, "thumbnails", json!(true));
set_field(&mut app, "security.printing", json!("low"));
super::super::confirm(&mut app).unwrap();
assert!(app.ui.status.contains("1 note(s)") && app.ui.status.contains("thumbnails"), "{}", app.ui.status);
assert!(app.ui.status.contains("1 note(s)") && app.ui.status.contains("permissions"), "{}", app.ui.status);
assert!(opened.borrow().is_empty(), "not opened unless asked");
app.run("ui.savePdfDialog", json!({"path": "/tmp/bad.pdf"})).unwrap();
set_field(&mut app, "__allArtboards", json!(false));
@@ -962,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);
@@ -996,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"));
}
}
+16 -8
View File
@@ -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.
@@ -231,6 +231,14 @@ Images follow the `compression` settings of their kind (`color`, `gray`, or `mon
with `zip`, `jpeg` (at `quality`; images with transparency stay lossless) or `auto` (JPEGs stay JPEG, the others
lossless). `none`, `jpeg2000`, CCITT and `runLength` are written as ZIP, with a warning when an image needs them.
`document.pdfSettings` lists the options that differ from the preset and the warnings without writing a file.
`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)
@@ -1582,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}}}}