mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-29 12:17:14 -06: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 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
87df4a0648
commit
8961824876
@@ -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");
|
||||
}
|
||||
Reference in New Issue
Block a user