mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-14 19:26:56 -04:00
M4.86: PDF page thumbnails and fast web view (linearised files)
Embed Page Thumbnails gives every page a /Thumb image: the page drawn on white, 106 px on its long side, without the layers the page leaves out (the engine draws them; vectorcraft_pdf::page_areas says what each page shows; PDF/X files get grey ones). Optimize for Fast Web View writes a linearised file: the objects are put in linearised order and numbering, encrypted when a password asks (the encryption dictionary goes with the catalog, PDF layers' groups too), then laid out with the linearization dictionary, both cross-reference tables and the page offset and shared object hint tables (the hint stream encrypted like any other). Their "not applied yet" warnings are gone. Reading the objects back uses the lexer of encrypt.rs (syntax.rs adds what rewriting needs). Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
87df4a0648
commit
8961824876
18 files changed
+1356
-44
No files matched your search
+2
-2
@@ -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).
|
||||
- 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.
|
||||
@@ -140,7 +140,7 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist
|
||||
| M1 | Selection, transform, layers, MCP | ✅ mostly done (transform reference point snaps to anchors/centres; rotated persistent bbox pending) | 4–6 |
|
||||
| M2 | Drawing tools + smart guides | ✅ mostly done (Flare, Reshape, Live Corners widget dragging landed; Shaper, Pen modifier nuances) | 15–20 |
|
||||
| M3 | Paint & appearance (swatches, color, gradient, stroke, appearance, transparency, styles) | ✅ done (M3.7–M3.98): swatches, groups, libraries, tints, Lab spots and Registration; Color Picker, Color panel, Color Guide, Color Themes, Edit Colors and Recolor Artwork; gradients that follow every transform, the annotator, linked stops, freeform gradients, focal points, gradients on strokes and Expand; stroke geometry, arrowheads, dashes fitted to corners, stroke on type, width profiles and the Width tool, Scale Strokes and preview bounds; Appearance targeting, container appearance, target circles, Expand Appearance; knockout, isolation, blend accuracy, CMYK blending, overprint, Attributes, masks and clipping, Flatten Transparency; graphic styles, links and libraries. Left: freeform and mixed spot/process gradients export as stops or process colours to SVG/PDF; flattener presets not yet used by PDF/print; confirm the Scale Strokes & Effects default | 4–8 |
|
||||
| M4 | Files (native, SVG, PDF, raster, Export for Screens, clipboard interop) | ✅ mostly done (M4.14–M4.98 on 2026-10-05): Save As/Save a Copy/Revert/templates, Data Recovery and background save, SVG import and export fidelity (units, text, symbols, filters, SVGZ, Preserve Editing, SVG Options), PDF import (colours, layers, masks, editable text, security) and export (presets, marks, bleed, ICC and output intent, raster effects, subset fonts, PDF/X-1a, X-3 and X-4, 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 |
|
||||
| M4 | Files (native, SVG, PDF, raster, Export for Screens, clipboard interop) | ✅ mostly done (M4.14–M4.98 on 2026-10-05): Save As/Save a Copy/Revert/templates, Data Recovery and background save, SVG import and export fidelity (units, text, symbols, filters, SVGZ, Preserve Editing, SVG Options), PDF import (colours, layers, masks, editable text, security) and export (presets, marks, bleed, ICC and output intent, raster effects, subset fonts, PDF/X-1a, X-3 and X-4, PDF layers, overprint, page thumbnails, fast web view), Place and Links, Package, EPS and DXF in and out, EMF/WMF, TIFF/BMP/Targa/PSD/GIF/PNG-8 export, Print and print presets, slices, Save for Web, Asset Export, clipboard flavours, File Info. Left: DWG (use DXF), PSD placement as layers, polish | 6–10 |
|
||||
| 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 |
|
||||
|
||||
@@ -911,3 +911,6 @@ mod tests_epsimport;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests_pdflayers;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests_pdfwebview;
|
||||
@@ -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::{PdfError, PdfOptions, PdfPreset, PdfSettings, Standard, THUMBNAIL_SIZE, Thumbnail};
|
||||
|
||||
use super::super::flatten::{FlattenOptions, flatten_document, see_through_images};
|
||||
use super::super::*;
|
||||
@@ -91,7 +92,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 the High Resolution flattener preset; PDF 1.3)|pdfX3 (colours ICC-tagged; transparency flattened; PDF 1.3)|pdfX4 (transparency and layers kept; colours ICC-tagged; PDF 1.6 at most) (PDF/X: a /GTS_PDFX output intent, by default the CMYK profile in effect, embedded; TrimBox, Trapped, GTS_PDFXVersion, title and dates; fonts embedded or outlined), compatibility?: 1.4|1.5|1.6|1.7 (default)|2.0 (choosing a standard sets the latest it allows: 1.7 for pdfA2b, 1.6 for pdfX4, 1.4 for pdfX1a and pdfX3, written as PDF 1.3), preserveEditing? (on in VectorCraft Default: the native document as an embedded file, which document.open restores; choosing a standard turns it off, a standard refuses it), thumbnails? (each page drawn as a /Thumb image, 106 px on its long side), fastWebView? (a linearised file: the first page shows while the rest downloads; it stays linearised when encrypted), viewAfterSaving? (the app opens the written file), createLayers? (each top-level layer but template layers as a PDF layer, an optional content group: hidden layers off, non-printing ones /PrintState /OFF, locked ones locked; needs 1.5 or later; they reopen as layers; choosing pdfX1a or pdfX3 turns it off; they refuse it), includeNonPrinting? (keep layers whose Print option is off; left out by default unless createLayers), compression?: {color?, gray?: {downsample: none|average|subsample|bicubic, ppi: 300, abovePpi: 450, compression: none|zip|jpeg|jpeg2000|auto, quality: minimum|low|medium|high|maximum}, mono?: {downsample, ppi: 1200, abovePpi: 1800, compression: none|ccittG3|ccittG4|zip|runLength} (black-and-white images), compressText?: true} (images above abovePpi are resampled to ppi; auto keeps JPEGs JPEG, the others lossless; JPEG needs opaque images; none, jpeg2000, CCITT and runLength are written as ZIP with a warning), marks?: {trim, registration, colorBars, pageInfo, kind: roman|japanese, weight: 0.25 (trim marks and targets), offset: 6 (pt from the artboard, at least the bleed)} (printer's marks in [Registration], every plate: trim marks, registration targets mid-side, CMYK/spot/black-tint colour bars on top, page information (title, artboard, date UTC) below), bleed?: {useDocument (the document's bleed, document.setup), top, bottom, left, right} (pt; art in the bleed is kept). Each page: TrimBox = artboard, BleedBox = artboard + bleed, MediaBox = that + the marks' room (art clipped to the BleedBox), output?: {conversion: none (default)|destination (every colour into the destination profile's model, CMYK or RGB, also colours of that model in another profile)|preserveNumbers (only colours of the other model and Lab; those in the destination's model keep their numbers) (grey stays grey; images are converted too, printer's marks are not), destination: profile name (edit.colorSettings lists them; default: the document's profile for its colour mode), profiles: none (device colours)|all|destination|taggedSource (documents assigned a profile: edit.assignProfile) (ICC-based colours: CMYK with the destination or document CMYK profile, RGB as sRGB, grey with the sRGB tone curve; PDF/A always), outputIntent: profile name (embedded as the catalog's /GTS_PDFX output intent; a name that isn't a profile is only named, which pdfX1a and pdfX3 allow only with a registry and pdfX4 refuses; pdfX1a needs a CMYK one), outputCondition, outputConditionId (default: outputIntent), registry, trapped (/Trapped /True; /False when an output intent is written)} (PDF/A files keep their own output intent), advanced?: {fontSubsetPercent: 100 (below 100 is accepted with a warning: fonts are always embedded as subsets), outlineText: true (false: type as real, selectable and searchable text in embedded subset fonts with a ToUnicode map; fonts whose licence forbids embedding, or allows only whole fonts, stay outlines with a warning), overprint: preserve (overprinting fills and strokes set /OP /op true, /OPM 1; white ones knock out with document.setup discardWhiteOverprint)|discard (they knock out)}, security?: {openPassword, permissionsPassword, printing: none|low|high, changes: none|pages|forms|comments|any, copy, screenReader, plaintextMetadata}} → {path, bytes, warnings}; no path → {dataBase64, bytes, warnings}. The options apply over the preset (null keeps its value); options accepted but not applied yet come back as warnings; a standard with a PDF version it doesn't allow and a password with a standard are refused, and so is a PDF/X file that would still break its standard (transparency the flattener left, RGB in pdfX1a, a font not embedded). document.export {format: pdf} takes the same options",
|
||||
has_doc,
|
||||
export_pdf
|
||||
),
|
||||
@@ -236,6 +237,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())) {
|
||||
@@ -269,6 +273,59 @@ fn flatten_for(doc: &Document, set: &PdfSettings) -> Result<Option<Document>> {
|
||||
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;
|
||||
|
||||
@@ -105,9 +105,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();
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -6,7 +6,9 @@
|
||||
//! 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.
|
||||
//! 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.
|
||||
@@ -488,7 +490,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 +863,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 +966,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 ----------
|
||||
|
||||
+24
-19
@@ -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 {
|
||||
|
||||
+13
-2
@@ -16,7 +16,9 @@
|
||||
//! 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).
|
||||
//! - [`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 +51,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 +87,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 {
|
||||
@@ -243,3 +252,5 @@ mod tests_stroke;
|
||||
mod tests_strokeout;
|
||||
#[cfg(test)]
|
||||
mod tests_textstroke;
|
||||
#[cfg(test)]
|
||||
mod tests_webview;
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)?;
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
//! 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.
|
||||
|
||||
use std::borrow::Cow;
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -460,8 +460,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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -813,12 +813,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 +853,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));
|
||||
|
||||
@@ -231,6 +231,9 @@ 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.
|
||||
|
||||
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)
|
||||
|
||||
Reference in new issue
Block a user