mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-25 10:39:01 -06:00
Each leaf page gets its own copy of every attribute it inherits, so a large direct /Resources on the root of a page tree with thousands of leaves was copied thousands of times: a compressed file of a few kilobytes could need tens of gigabytes. The walk weighs each inherited value once where it is declared and charges every copy to a 1 GiB budget; past it the walk is cut off, as an over-large tree already is.
407 lines
18 KiB
Rust
407 lines
18 KiB
Rust
//! pdfcraft-model — typed views over the PDF object graph (L2). See the README.
|
|
|
|
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
|
|
|
use pdfcraft_cos::{Dict, Document, ObjRef, Object};
|
|
|
|
/// Attributes a page inherits from its ancestors (ISO 32000-2 §7.7.3.4).
|
|
pub const INHERITABLE: [&[u8]; 4] = [b"Resources", b"MediaBox", b"CropBox", b"Rotate"];
|
|
|
|
/// The largest `/UserUnit` honoured, as in Acrobat (a larger value counts as this one).
|
|
pub const MAX_USER_UNIT: f64 = 75_000.0;
|
|
|
|
/// A leaf page and its effective (inherited) attributes.
|
|
#[derive(Clone, Debug, PartialEq)]
|
|
pub struct Page {
|
|
pub obj: ObjRef,
|
|
/// The page dictionary with inherited attributes filled in.
|
|
pub dict: Dict,
|
|
}
|
|
|
|
fn rect(doc: &Document, o: Option<&Object>) -> Option<[f64; 4]> {
|
|
let o = doc.resolve(o?);
|
|
let v: Vec<f64> = o.as_array()?.iter().filter_map(|x| doc.resolve(x).as_f64()).collect();
|
|
(v.len() == 4 && v.iter().all(|x| x.is_finite())).then(|| [v[0].min(v[2]), v[1].min(v[3]), v[0].max(v[2]), v[1].max(v[3])])
|
|
}
|
|
|
|
impl Page {
|
|
/// The visible region (crop box clipped to the media box), in user space.
|
|
pub fn crop(&self, doc: &Document) -> [f64; 4] {
|
|
let media = rect(doc, self.dict.get(b"MediaBox")).unwrap_or([0.0, 0.0, 612.0, 792.0]);
|
|
match rect(doc, self.dict.get(b"CropBox")) {
|
|
Some(c) => {
|
|
let r = [c[0].max(media[0]), c[1].max(media[1]), c[2].min(media[2]), c[3].min(media[3])];
|
|
if r[2] > r[0] && r[3] > r[1] { r } else { media }
|
|
}
|
|
None => media,
|
|
}
|
|
}
|
|
|
|
/// Clockwise rotation when displayed: 0, 90, 180 or 270. A `/Rotate` that isn't a multiple
|
|
/// of 90 (ISO 32000-2 §7.7.3.3 requires one) counts as 0, as the renderer draws that page.
|
|
pub fn rotation(&self, doc: &Document) -> i64 {
|
|
let r = self.dict.get(b"Rotate").and_then(|o| doc.resolve(o).as_int()).unwrap_or(0).rem_euclid(360);
|
|
if r % 90 == 0 { r } else { 0 }
|
|
}
|
|
|
|
/// The size of the page's user-space unit in points (`/UserUnit`; it isn't inherited), read
|
|
/// as Acrobat reads it: a number from 1 up, at most [`MAX_USER_UNIT`]; anything else is 1.
|
|
pub fn user_unit(&self, doc: &Document) -> f64 {
|
|
self.dict.get(b"UserUnit").and_then(|o| doc.resolve(o).as_f64()).filter(|u| *u >= 1.0).map_or(1.0, |u| u.min(MAX_USER_UNIT))
|
|
}
|
|
|
|
/// The displayed size (width, height) in points, after `/UserUnit` and `/Rotate`.
|
|
pub fn display_size(&self, doc: &Document) -> (f64, f64) {
|
|
let c = self.crop(doc);
|
|
let u = self.user_unit(doc);
|
|
let (w, h) = ((c[2] - c[0]) * u, (c[3] - c[1]) * u);
|
|
if self.rotation(doc) % 180 == 0 { (w, h) } else { (h, w) }
|
|
}
|
|
|
|
/// The matrix `[a b c d e f]` from display space (points, origin at the bottom-left of the
|
|
/// page as shown, y up, after `/UserUnit` and `/Rotate`) to user space.
|
|
pub fn view_matrix(&self, doc: &Document) -> [f64; 6] {
|
|
let m = view_matrix_for(self.rotation(doc), self.crop(doc));
|
|
let u = self.user_unit(doc);
|
|
if u == 1.0 { m } else { [m[0] / u, m[1] / u, m[2] / u, m[3] / u, m[4], m[5]] }
|
|
}
|
|
}
|
|
|
|
/// [`Page::view_matrix`] for a region `crop` (`[x0, y0, x1, y1]`) shown turned clockwise by
|
|
/// `rotation` (0, 90, 180 or 270): the matrix from the turned region's own display space
|
|
/// (origin at its bottom-left, y up) to the region's space. For a rectangle in user space it
|
|
/// puts an upright picture into that rectangle as the page displays it.
|
|
pub fn view_matrix_for(rotation: i64, crop: [f64; 4]) -> [f64; 6] {
|
|
let [x0, y0, x1, y1] = crop;
|
|
match rotation {
|
|
90 => [0.0, 1.0, -1.0, 0.0, x1, y0],
|
|
180 => [-1.0, 0.0, 0.0, -1.0, x1, y1],
|
|
270 => [0.0, -1.0, 1.0, 0.0, x0, y1],
|
|
_ => [1.0, 0.0, 0.0, 1.0, x0, y0],
|
|
}
|
|
}
|
|
|
|
/// Leaf pages in document order, with inherited attributes resolved. Cycles and absurdly deep
|
|
/// trees are cut off rather than followed, as is a tree whose inherited values would take more
|
|
/// than [`MAX_INHERITED_COPIES`] bytes to copy into its leaves.
|
|
pub fn pages(doc: &Document) -> Vec<Page> {
|
|
PageTree::new(doc).collect()
|
|
}
|
|
|
|
/// Approximate bytes of inherited values copied into leaf dictionaries by one page walk.
|
|
pub const MAX_INHERITED_COPIES: usize = 1 << 30;
|
|
|
|
/// Approximate heap bytes of `o`: one [`Object`] per node plus string, name and key bytes.
|
|
/// Values nested deeper than the parser allows weigh as too large.
|
|
fn weight(o: &Object, depth: usize) -> usize {
|
|
if depth > 256 {
|
|
return usize::MAX;
|
|
}
|
|
let own = std::mem::size_of::<Object>();
|
|
match o {
|
|
Object::String(s) => own.saturating_add(s.bytes.len()),
|
|
Object::Name(n) => own.saturating_add(n.len()),
|
|
Object::Array(a) => a.iter().fold(own, |n, v| n.saturating_add(weight(v, depth + 1))),
|
|
Object::Dict(d) => d.iter().fold(own, |n, (k, v)| n.saturating_add(k.len()).saturating_add(weight(v, depth + 1))),
|
|
Object::Stream(s) => s.dict.iter().fold(own, |n, (k, v)| n.saturating_add(k.len()).saturating_add(weight(v, depth + 1))),
|
|
_ => own,
|
|
}
|
|
}
|
|
|
|
// Retain the owner of each inherited value rather than deep-cloning direct Resources on
|
|
// every edge. Only the final Page dictionaries need owned copies of those values. Each owner
|
|
// carries the value's weight, measured once where the value is declared.
|
|
type Inherited = [Option<(std::sync::Arc<Object>, usize)>; 4];
|
|
|
|
struct Children {
|
|
owner: std::sync::Arc<Object>,
|
|
next: usize,
|
|
inherited: Inherited,
|
|
}
|
|
|
|
impl Children {
|
|
fn kids(&self) -> Option<&Vec<Object>> {
|
|
match &*self.owner {
|
|
Object::Dict(d) => d.get(b"Kids")?.as_array(),
|
|
other => other.as_array(),
|
|
}
|
|
}
|
|
}
|
|
|
|
struct PageTree<'a> {
|
|
doc: &'a Document,
|
|
seen: std::collections::HashSet<ObjRef>,
|
|
// One cursor per ancestor, never one entry per child. This also preserves the first
|
|
// depth-first occurrence's attributes when malformed trees share a child.
|
|
pending: Vec<Children>,
|
|
// Weight left for copying inherited values into leaves. A large direct value on an
|
|
// ancestor of many leaves would otherwise be copied once per leaf.
|
|
budget: usize,
|
|
}
|
|
|
|
impl<'a> PageTree<'a> {
|
|
fn new(doc: &'a Document) -> Self {
|
|
Self::with_budget(doc, MAX_INHERITED_COPIES)
|
|
}
|
|
|
|
fn with_budget(doc: &'a Document, budget: usize) -> Self {
|
|
let top = doc.root().and_then(|r| doc.get(r).as_dict().and_then(|d| d.reference(b"Pages")));
|
|
let pending = top
|
|
.into_iter()
|
|
.map(|r| Children { owner: std::sync::Arc::new(Object::Array(vec![Object::Ref(r)])), next: 0, inherited: Default::default() })
|
|
.collect();
|
|
Self { doc, seen: Default::default(), pending, budget }
|
|
}
|
|
}
|
|
|
|
impl Iterator for PageTree<'_> {
|
|
type Item = Page;
|
|
|
|
fn next(&mut self) -> Option<Page> {
|
|
while let Some(parent) = self.pending.last_mut() {
|
|
let Some(child) = parent.kids().and_then(|kids| kids.get(parent.next)) else {
|
|
self.pending.pop();
|
|
continue;
|
|
};
|
|
let node = child.as_ref();
|
|
// A successful get means next is strictly below the allocated array length.
|
|
parent.next += 1;
|
|
let Some(node) = node else { continue };
|
|
if self.seen.len() >= 1_000_000 {
|
|
self.pending.clear();
|
|
return None;
|
|
}
|
|
if !self.seen.insert(node) {
|
|
continue;
|
|
}
|
|
let obj = self.doc.get(node);
|
|
let Some(d) = obj.as_dict() else { continue };
|
|
let mut inherited = parent.inherited.clone();
|
|
for (key, owner) in INHERITABLE.iter().zip(&mut inherited) {
|
|
if let Some(value) = d.get(key) {
|
|
*owner = Some((obj.clone(), weight(value, 0)));
|
|
}
|
|
}
|
|
let is_pages = d.name(b"Type") == Some(b"Pages") || (d.contains(b"Kids") && d.name(b"Type") != Some(b"Page"));
|
|
if is_pages {
|
|
let owner = match d.get(b"Kids") {
|
|
Some(Object::Ref(r)) => self.doc.get(*r),
|
|
Some(Object::Array(_)) => obj.clone(),
|
|
_ => continue,
|
|
};
|
|
self.pending.push(Children { owner, next: 0, inherited });
|
|
} else {
|
|
let mut dict = d.clone();
|
|
for (key, owner) in INHERITABLE.iter().zip(&inherited) {
|
|
if !dict.contains(key)
|
|
&& let Some((owner, weight)) = owner
|
|
&& let Some(value) = owner.as_dict().and_then(|d| d.get(key))
|
|
{
|
|
// Past the budget the walk is cut off, like a tree too large to follow.
|
|
let Some(left) = self.budget.checked_sub(*weight) else {
|
|
self.pending.clear();
|
|
return None;
|
|
};
|
|
self.budget = left;
|
|
dict.set(key.to_vec(), value.clone());
|
|
}
|
|
}
|
|
return Some(Page { obj: node, dict });
|
|
}
|
|
}
|
|
None
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use std::sync::Arc;
|
|
|
|
use super::*;
|
|
|
|
fn doc(rotate: i64) -> Document {
|
|
doc_with(rotate, "", "")
|
|
}
|
|
|
|
/// `pages` and `page` add entries to the page tree node and to the page.
|
|
fn doc_with(rotate: i64, pages: &str, page: &str) -> Document {
|
|
let objs = [
|
|
"<< /Type /Catalog /Pages 2 0 R >>".to_string(),
|
|
format!("<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] /Rotate {rotate} /Resources << /Font << >> >> {pages} >>"),
|
|
format!("<< /Type /Page /Parent 2 0 R /CropBox [10 20 210 320] {page} >>"),
|
|
];
|
|
let mut out = b"%PDF-1.7\n".to_vec();
|
|
let mut offs = Vec::new();
|
|
for (i, o) in objs.iter().enumerate() {
|
|
offs.push(out.len());
|
|
out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes());
|
|
}
|
|
let x = out.len();
|
|
out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
|
|
for o in offs {
|
|
out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
|
|
}
|
|
out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{x}\n%%EOF\n", objs.len() + 1).as_bytes());
|
|
Document::open(Arc::new(out)).unwrap()
|
|
}
|
|
|
|
#[test]
|
|
fn inherited_copies_stop_at_a_budget() {
|
|
let mut d = doc(0);
|
|
let root = ObjRef { num: 2, generation: 0 };
|
|
let leaf = d.get(ObjRef { num: 3, generation: 0 }).as_dict().unwrap().clone();
|
|
let kids: Vec<_> = (0..200).map(|_| Object::Ref(d.add(leaf.clone()))).collect();
|
|
let mut resources = Dict::new();
|
|
resources.set(b"Payload".to_vec(), Object::Array(vec![Object::Int(42); 8192]));
|
|
let resources = Object::Dict(resources);
|
|
let each = weight(&resources, 0);
|
|
d.update_dict(root, |dict| {
|
|
dict.set(b"Kids".to_vec(), Object::Array(kids));
|
|
dict.set(b"Resources".to_vec(), resources);
|
|
})
|
|
.unwrap();
|
|
let budget = 1 << 20;
|
|
let pages: Vec<Page> = PageTree::with_budget(&d, budget).collect();
|
|
let copied: usize = pages.iter().filter_map(|p| p.dict.get(b"Resources")).map(|r| weight(r, 0)).sum();
|
|
assert!(copied <= budget, "{copied} bytes of inherited values copied into {} pages", pages.len());
|
|
assert_eq!(pages.len(), budget / each);
|
|
assert_eq!(super::pages(&d).len(), 200);
|
|
}
|
|
|
|
#[test]
|
|
fn repeated_children_do_not_queue_resource_copies() {
|
|
let mut d = doc(0);
|
|
let root = ObjRef { num: 2, generation: 0 };
|
|
let leaf = ObjRef { num: 3, generation: 0 };
|
|
d.update_dict(root, |dict| {
|
|
// Modest fixture: the assertion measures retained work, not an OOM or timing.
|
|
dict.set(b"Kids".to_vec(), Object::Array(vec![Object::Ref(leaf); 128]));
|
|
let mut resources = Dict::new();
|
|
resources.set(b"Payload".to_vec(), Object::Array(vec![Object::Int(42); 1024]));
|
|
dict.set(b"Resources".to_vec(), Object::Dict(resources));
|
|
})
|
|
.unwrap();
|
|
let mut walk = PageTree::new(&d);
|
|
let p = walk.next().unwrap();
|
|
assert_eq!(p.obj, leaf);
|
|
assert_eq!(p.dict.get(b"Resources").unwrap().as_dict().unwrap().get(b"Payload").unwrap().as_array().unwrap().len(), 1024);
|
|
assert!(walk.pending.len() <= 2, "duplicate edges retained {} pending entries", walk.pending.len());
|
|
assert!(walk.next().is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn shared_children_keep_depth_first_order_and_nearest_attributes() {
|
|
let mut d = doc(0);
|
|
let root = ObjRef { num: 2, generation: 0 };
|
|
let leaf = ObjRef { num: 3, generation: 0 };
|
|
let mut second = d.get(leaf).as_dict().unwrap().clone();
|
|
second.set(b"Rotate".to_vec(), Object::Int(180));
|
|
let second = d.add(second);
|
|
let mut branch = Dict::new();
|
|
branch.set(b"Type".to_vec(), Object::name("Pages"));
|
|
branch.set(b"Rotate".to_vec(), Object::Int(90));
|
|
// A cycle, a shared child, an invalid entry, and an indirect Kids array.
|
|
let kids = d.add(Object::Array(vec![Object::Ref(root), Object::Null, Object::Ref(leaf), Object::Ref(second)]));
|
|
branch.set(b"Kids".to_vec(), Object::Ref(kids));
|
|
let branch = d.add(branch);
|
|
d.update_dict(root, |dict| {
|
|
dict.set(b"Kids".to_vec(), Object::Array(vec![Object::Ref(branch), Object::Ref(leaf), Object::Ref(second)]));
|
|
})
|
|
.unwrap();
|
|
let pages = pages(&d);
|
|
assert_eq!(pages.iter().map(|p| p.obj).collect::<Vec<_>>(), vec![leaf, second]);
|
|
assert_eq!(pages[0].rotation(&d), 90, "first depth-first occurrence supplies inheritance");
|
|
assert_eq!(pages[1].rotation(&d), 180, "a leaf's own attribute wins");
|
|
assert!(pages.iter().all(|p| p.dict.contains(b"Resources")));
|
|
}
|
|
|
|
#[test]
|
|
fn wide_page_tree_retains_only_ancestor_cursors() {
|
|
let mut d = doc(0);
|
|
let root = ObjRef { num: 2, generation: 0 };
|
|
let leaf = d.get(ObjRef { num: 3, generation: 0 }).as_dict().unwrap().clone();
|
|
let kids: Vec<Object> = (0..64).map(|_| Object::Ref(d.add(leaf.clone()))).collect();
|
|
d.update_dict(root, |dict| dict.set(b"Kids".to_vec(), Object::Array(kids.clone()))).unwrap();
|
|
let mut walk = PageTree::new(&d);
|
|
for kid in kids {
|
|
assert_eq!(walk.next().unwrap().obj, kid.as_ref().unwrap());
|
|
assert!(walk.pending.len() <= 2);
|
|
}
|
|
assert!(walk.next().is_none());
|
|
}
|
|
|
|
fn apply(m: [f64; 6], x: f64, y: f64) -> [f64; 2] {
|
|
[m[0] * x + m[2] * y + m[4], m[1] * x + m[3] * y + m[5]]
|
|
}
|
|
|
|
#[test]
|
|
fn inherited_attributes_and_display_geometry() {
|
|
for rotate in [0, 90, 180, 270, 450] {
|
|
let d = doc(rotate);
|
|
let p = &pages(&d)[0];
|
|
assert!(p.dict.contains(b"Resources") && p.dict.contains(b"MediaBox"));
|
|
assert_eq!(p.crop(&d), [10.0, 20.0, 210.0, 320.0]);
|
|
let (w, h) = p.display_size(&d);
|
|
let m = p.view_matrix(&d);
|
|
// The displayed page's corners map onto the crop box's corners.
|
|
let corners: Vec<[f64; 2]> = [(0.0, 0.0), (w, 0.0), (0.0, h), (w, h)].iter().map(|(x, y)| apply(m, *x, *y)).collect();
|
|
for c in &corners {
|
|
assert!([10.0, 210.0].contains(&c[0]) && [20.0, 320.0].contains(&c[1]), "{rotate}: {c:?}");
|
|
}
|
|
// The displayed top-left corner is where a reader sees it.
|
|
let tl = apply(m, 0.0, h);
|
|
let expect = match rotate % 360 {
|
|
0 => [10.0, 320.0],
|
|
90 => [10.0, 20.0],
|
|
180 => [210.0, 20.0],
|
|
_ => [210.0, 320.0],
|
|
};
|
|
assert_eq!(tl, expect, "rotation {rotate}");
|
|
}
|
|
}
|
|
|
|
/// A `/Rotate` that isn't a quarter turn counts as 0, as the renderer draws the page
|
|
/// upright, so display space matches what's on screen.
|
|
#[test]
|
|
fn rotate_off_a_quarter_turn_counts_as_upright() {
|
|
for rotate in [135, 30, -45, 359] {
|
|
let d = doc(rotate);
|
|
let p = &pages(&d)[0];
|
|
assert_eq!(p.rotation(&d), 0, "{rotate}");
|
|
assert_eq!(p.display_size(&d), (200.0, 300.0), "{rotate}");
|
|
assert_eq!(p.view_matrix(&d), [1.0, 0.0, 0.0, 1.0, 10.0, 20.0], "{rotate}");
|
|
}
|
|
let d = doc(-90);
|
|
assert_eq!(pages(&d)[0].rotation(&d), 270);
|
|
}
|
|
|
|
/// Display space is in points: `/UserUnit 2` doubles it. As in Acrobat, the value isn't
|
|
/// inherited, values below 1 or not numbers count as 1, and values over 75,000 as 75,000.
|
|
#[test]
|
|
fn user_unit_scales_display_space() {
|
|
for rotate in [0, 90, 180, 270] {
|
|
let d = doc_with(rotate, "", "/UserUnit 2");
|
|
let p = &pages(&d)[0];
|
|
let (w, h) = p.display_size(&d);
|
|
assert_eq!((w, h), if rotate % 180 == 0 { (400.0, 600.0) } else { (600.0, 400.0) }, "{rotate}");
|
|
let plain = doc(rotate);
|
|
let unscaled = pages(&plain)[0].view_matrix(&plain);
|
|
for (x, y) in [(0.0, 0.0), (w, 0.0), (0.0, h), (w, h), (37.0, 81.0)] {
|
|
assert_eq!(apply(p.view_matrix(&d), x, y), apply(unscaled, x / 2.0, y / 2.0), "{rotate}: ({x}, {y})");
|
|
}
|
|
}
|
|
for (pages_attrs, page_attrs, unit) in [
|
|
("/UserUnit 2", "", 1.0),
|
|
("", "/UserUnit 1.5", 1.5),
|
|
("", "/UserUnit 0.5", 1.0),
|
|
("", "/UserUnit 0", 1.0),
|
|
("", "/UserUnit -2", 1.0),
|
|
("", "/UserUnit /Two", 1.0),
|
|
("", "/UserUnit 100000", MAX_USER_UNIT),
|
|
] {
|
|
let d = doc_with(0, pages_attrs, page_attrs);
|
|
assert_eq!(pages(&d)[0].user_unit(&d), unit, "{pages_attrs} {page_attrs}");
|
|
}
|
|
}
|
|
}
|