Files
craft-libs/crates/print/src/sheets.rs
T
Dylan M. Taylor 4f85efb2b9 security: bound print dialog name resolution, PPD labels, lpstat merge and inherited geometry
- craft-ui shown_names resolves label collisions with hash-map counts and a worklist, so a
  crafted cascade is linear instead of cubic (CodeRabbit CWE-407, ui/src/printing.rs:32)
- parse_ppd caps group, option and choice labels at 256 characters before each option copies
  its group (CodeRabbit CWE-400, print/src/lib.rs:232)
- printers_parsed tracks seen destinations in a HashSet (CodeRabbit CWE-407, lib.rs:417)
- sheets::Reader::rect checks an inline array's length before reading it and borrows direct
  objects instead of deep-cloning the inherited MediaBox/CropBox/Rotate for every page
  (CodeRabbit CWE-400, print/src/sheets.rs:774)
2026-10-22 21:49:46 -04:00

1217 lines
47 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! The sheets of a print-ready PDF, read leniently, and the incremental update that gives them
//! one paper shape. Windows prints a job with one PrintTicket, so with one paper size and
//! orientation, and draws each page at the size the PDF gives it. A job whose sheets differ is
//! brought to one shape here, by appending new versions of the pages that need it: `/Rotate`
//! turns a sheet of the other orientation a quarter turn, and for shrink-to-fit a page gets the
//! paper's size with its content scaled down (never up) and centred. The original bytes are
//! kept as they are, so a job that needs nothing is the caller's PDF unchanged.
//!
//! Only what that takes is read: the cross-reference sections (tables and streams, object
//! streams, FlateDecode with PNG predictors), the page tree and each page's boxes and rotation.
//! Every length, count and depth is bounded, and anything unreadable is an [`Error`], never a
//! panic: the caller's PDF is input like any other.
use std::borrow::Cow;
use std::collections::{HashMap, HashSet};
use std::sync::Arc;
use crate::Error;
/// Nesting of arrays and dictionaries, and of the page tree.
const MAX_DEPTH: usize = 64;
const MAX_PAGES: usize = 100_000;
/// The most a cross-reference or object stream may inflate to.
const MAX_STREAM: usize = 16 << 20;
// A short compressed file can otherwise expand into millions of hash-map entries.
// These are job-wide budgets, including older xref sections and cached object streams.
const MAX_RECORDS: usize = 500_000;
const MAX_DECODED: usize = 32 << 20;
const MAX_NODES: usize = 250_000;
const MAX_TOKENS: usize = 16 << 20;
const MAX_OBJECT_NODES: usize = 32_000;
const MAX_OBJECT_TOKENS: usize = 1 << 20;
const MAX_SECTIONS: usize = 256;
/// A page without a MediaBox (invalid, but readers accept it) is US Letter.
const DEFAULT_MEDIA: [f64; 4] = [0.0, 0.0, 612.0, 792.0];
/// Sheet sizes this close, in points (0.18 mm), are the same size.
pub(crate) const SAME: f64 = 0.5;
fn unreadable(detail: &str) -> Error {
Error::Spool(format!("the print-ready PDF could not be read for printing: {detail}"))
}
#[derive(Clone, Debug, PartialEq)]
enum Obj {
Null,
Bool(bool),
/// A number as written, so it is written back the same.
Num(Vec<u8>),
/// A name, `#xx` escapes decoded.
Name(Vec<u8>),
/// A string as written, delimiters included (still encrypted, if the file is).
Str(Vec<u8>),
Array(Vec<Obj>),
Dict(Dict),
Ref(u32, u16),
}
type Dict = Vec<(Vec<u8>, Obj)>;
fn get<'d>(dict: &'d Dict, key: &[u8]) -> Option<&'d Obj> {
dict.iter().find(|(k, _)| k == key).map(|(_, v)| v)
}
impl Obj {
fn num(&self) -> Option<f64> {
match self {
Obj::Num(raw) => std::str::from_utf8(raw).ok()?.parse::<f64>().ok().filter(|v| v.is_finite()),
_ => None,
}
}
fn int(&self) -> Option<i64> {
match self {
Obj::Num(raw) => std::str::from_utf8(raw).ok()?.parse::<i64>().ok(),
_ => None,
}
}
fn name(&self) -> Option<&[u8]> {
match self {
Obj::Name(n) => Some(n),
_ => None,
}
}
fn dict(&self) -> Option<&Dict> {
match self {
Obj::Dict(d) => Some(d),
_ => None,
}
}
fn array(&self) -> Option<&[Obj]> {
match self {
Obj::Array(a) => Some(a),
_ => None,
}
}
}
fn is_white(b: u8) -> bool {
matches!(b, 0 | 9 | 10 | 12 | 13 | 32)
}
fn is_delim(b: u8) -> bool {
matches!(b, b'(' | b')' | b'<' | b'>' | b'[' | b']' | b'{' | b'}' | b'/' | b'%')
}
fn is_regular(b: u8) -> bool {
!is_white(b) && !is_delim(b)
}
struct Lexer<'a> {
s: &'a [u8],
pos: usize,
nodes: usize,
tokens: usize,
max_nodes: usize,
max_tokens: usize,
exhausted: bool,
}
impl<'a> Lexer<'a> {
fn new(s: &'a [u8], pos: usize) -> Self {
Lexer { s, pos, nodes: 0, tokens: 0, max_nodes: MAX_OBJECT_NODES, max_tokens: MAX_OBJECT_TOKENS, exhausted: false }
}
fn bounded(s: &'a [u8], pos: usize, budget: &Budget) -> Self {
let mut lx = Self::new(s, pos);
lx.max_nodes = MAX_OBJECT_NODES.min(MAX_NODES.saturating_sub(budget.nodes));
lx.max_tokens = MAX_OBJECT_TOKENS.min(MAX_TOKENS.saturating_sub(budget.tokens));
lx
}
fn peek(&self) -> Option<u8> {
self.s.get(self.pos).copied()
}
fn rest(&self) -> &'a [u8] {
self.s.get(self.pos..).unwrap_or(&[])
}
fn skip_ws(&mut self) {
while let Some(b) = self.peek() {
if is_white(b) {
self.pos += 1;
} else if b == b'%' {
while let Some(b) = self.peek() {
if b == b'\n' || b == b'\r' {
break;
}
self.pos += 1;
}
} else {
break;
}
}
}
/// A run of regular characters (a number, keyword or name body).
fn word(&mut self) -> &'a [u8] {
let start = self.pos;
while self.peek().is_some_and(is_regular) {
self.pos += 1;
}
self.s.get(start..self.pos).unwrap_or(&[])
}
/// The keyword `kw` as a whole word, consumed only when it is there.
fn keyword(&mut self, kw: &[u8]) -> bool {
self.skip_ws();
let rest = self.rest();
if rest.starts_with(kw) && rest.get(kw.len()).is_none_or(|b| !is_regular(*b)) {
self.pos += kw.len();
true
} else {
false
}
}
/// A non-negative integer, consumed only when it is one.
fn unsigned(&mut self) -> Option<u64> {
self.skip_ws();
let start = self.pos;
let word = self.word();
match std::str::from_utf8(word).ok().filter(|w| w.bytes().all(|b| b.is_ascii_digit())).and_then(|w| w.parse().ok()) {
Some(v) => Some(v),
None => {
self.pos = start;
None
}
}
}
fn object(&mut self, depth: usize) -> Option<Obj> {
self.nodes = self.nodes.checked_add(1)?;
if self.nodes > self.max_nodes {
self.exhausted = true;
return None;
}
if depth > MAX_DEPTH {
return None;
}
self.skip_ws();
match self.peek()? {
b'/' => {
self.pos += 1;
Some(Obj::Name(self.name()?))
}
b'(' => self.literal().map(Obj::Str),
b'<' if self.s.get(self.pos + 1) == Some(&b'<') => {
self.pos += 2;
self.dict_body(depth).map(Obj::Dict)
}
b'<' => self.hex().map(Obj::Str),
b'[' => {
self.pos += 1;
let mut items = Vec::new();
loop {
self.skip_ws();
if self.peek()? == b']' {
self.pos += 1;
return Some(Obj::Array(items));
}
items.push(self.object(depth + 1)?);
}
}
b'+' | b'-' | b'.' | b'0'..=b'9' => self.number_or_ref(),
_ => match self.word() {
b"true" => Some(Obj::Bool(true)),
b"false" => Some(Obj::Bool(false)),
b"null" => Some(Obj::Null),
_ => None,
},
}
}
fn dict_body(&mut self, depth: usize) -> Option<Dict> {
// A dictionary's last occurrence of a key wins, including its position. Scanning
// all earlier keys for each insertion makes a small hostile dictionary quadratic.
// Keep bounded slots while reading, then compact them once in final occurrence order.
let mut positions = HashMap::new();
let mut items = Vec::new();
loop {
self.skip_ws();
if self.rest().starts_with(b">>") {
self.pos += 2;
return Some(items.into_iter().flatten().collect());
}
if self.peek()? != b'/' {
return None;
}
self.pos += 1;
let key = self.name()?;
let value = self.object(depth + 1)?;
if let Some(previous) = positions.insert(key.clone(), items.len()) {
*items.get_mut(previous)? = None;
}
items.push(Some((key, value)));
}
}
fn name(&mut self) -> Option<Vec<u8>> {
let raw = self.word();
self.tokens = self.tokens.checked_add(raw.len())?;
if self.tokens > self.max_tokens {
self.exhausted = true;
return None;
}
let mut out = Vec::with_capacity(raw.len());
let mut i = 0;
while let Some(&b) = raw.get(i) {
let hex = raw.get(i + 1..i + 3).and_then(|h| std::str::from_utf8(h).ok()).and_then(|h| u8::from_str_radix(h, 16).ok());
match (b, hex) {
(b'#', Some(v)) => {
out.push(v);
i += 3;
}
_ => {
out.push(b);
i += 1;
}
}
}
Some(out)
}
fn token(&mut self, start: usize, end: usize) -> Option<Vec<u8>> {
let raw = self.s.get(start..end)?;
self.tokens = self.tokens.checked_add(raw.len())?;
if self.tokens > self.max_tokens {
self.exhausted = true;
return None;
}
Some(raw.to_vec())
}
/// A literal string, kept as written: balanced parentheses, `\` escaping the next byte.
fn literal(&mut self) -> Option<Vec<u8>> {
let start = self.pos;
let mut depth = 0usize;
loop {
let b = self.peek()?;
self.pos += 1;
match b {
b'\\' => self.pos += 1,
b'(' => depth += 1,
b')' => {
depth = depth.saturating_sub(1);
if depth == 0 {
return self.token(start, self.pos);
}
}
_ => {}
}
}
}
fn hex(&mut self) -> Option<Vec<u8>> {
let start = self.pos;
let end = self.rest().iter().position(|&b| b == b'>')?;
self.pos += end + 1;
self.token(start, self.pos)
}
/// A number, or `num gen R`.
fn number_or_ref(&mut self) -> Option<Obj> {
let word = self.word();
if word.is_empty() || !word.iter().all(|b| matches!(b, b'+' | b'-' | b'.' | b'0'..=b'9')) || !word.iter().any(u8::is_ascii_digit) {
return None;
}
if word.iter().all(u8::is_ascii_digit) {
let after = self.pos;
if let (Some(num), Some(generation)) = (std::str::from_utf8(word).ok().and_then(|w| w.parse::<u32>().ok()), self.unsigned())
&& let Ok(generation) = u16::try_from(generation)
&& self.keyword(b"R")
{
return Some(Obj::Ref(num, generation));
}
self.pos = after;
}
self.tokens = self.tokens.checked_add(word.len())?;
if self.tokens > self.max_tokens {
self.exhausted = true;
return None;
}
Some(Obj::Num(word.to_vec()))
}
}
/// An object written back: the value it was read as.
fn write_obj(out: &mut Vec<u8>, obj: &Obj) {
match obj {
Obj::Null => out.extend_from_slice(b"null"),
Obj::Bool(b) => out.extend_from_slice(if *b { b"true" } else { b"false" }),
Obj::Num(raw) | Obj::Str(raw) => out.extend_from_slice(raw),
Obj::Name(name) => write_name(out, name),
Obj::Array(items) => {
out.push(b'[');
for (i, item) in items.iter().enumerate() {
if i > 0 {
out.push(b' ');
}
write_obj(out, item);
}
out.push(b']');
}
Obj::Dict(dict) => write_dict(out, dict),
Obj::Ref(num, generation) => out.extend_from_slice(format!("{num} {generation} R").as_bytes()),
}
}
fn write_name(out: &mut Vec<u8>, name: &[u8]) {
out.push(b'/');
for &b in name {
if (0x21..=0x7e).contains(&b) && !is_delim(b) && b != b'#' {
out.push(b);
} else {
out.extend_from_slice(format!("#{b:02X}").as_bytes());
}
}
}
fn write_dict(out: &mut Vec<u8>, dict: &Dict) {
out.extend_from_slice(b"<<");
for (key, value) in dict {
write_name(out, key);
out.push(b' ');
write_obj(out, value);
}
out.extend_from_slice(b">>");
}
/// A number for a content stream or a box: at most four decimals, no exponent.
fn number(v: f64) -> String {
if !v.is_finite() {
return "0".into();
}
let s = format!("{v:.4}");
let s = s.trim_end_matches('0').trim_end_matches('.');
if s.is_empty() || s == "-" || s == "-0" { "0".into() } else { s.to_string() }
}
fn num_obj(v: f64) -> Obj {
Obj::Num(number(v).into_bytes())
}
/// Where an object is: at an offset, or the `index`th object of an object stream.
#[derive(Clone, Copy, Debug)]
enum Entry {
Free,
At(usize),
In(u32, u32),
}
/// An object stream, inflated: each object's number and offset within `data`.
struct ObjStm {
data: Vec<u8>,
objects: Vec<(u32, usize)>,
}
#[derive(Default)]
struct Budget {
records: usize,
decoded: usize,
nodes: usize,
tokens: usize,
exhausted: bool,
}
impl Budget {
fn charge(&mut self, records: usize, decoded: usize, nodes: usize, tokens: usize) -> Option<()> {
if self.exhausted {
return None;
}
let fits = self.records.checked_add(records).is_some_and(|n| n <= MAX_RECORDS)
&& self.decoded.checked_add(decoded).is_some_and(|n| n <= MAX_DECODED)
&& self.nodes.checked_add(nodes).is_some_and(|n| n <= MAX_NODES)
&& self.tokens.checked_add(tokens).is_some_and(|n| n <= MAX_TOKENS);
if !fits {
self.exhausted = true;
return None;
}
self.records += records;
self.decoded += decoded;
self.nodes += nodes;
self.tokens += tokens;
Some(())
}
fn parsed(&mut self, lx: &Lexer<'_>) -> Option<()> {
if lx.exhausted {
self.exhausted = true;
return None;
}
self.charge(0, 0, lx.nodes, lx.tokens)
}
}
struct Reader<'a> {
data: &'a [u8],
entries: HashMap<u32, Entry>,
objstms: HashMap<u32, Option<ObjStm>>,
budget: Budget,
}
/// An indirect object read at an offset: its number, value and stream data.
struct Indirect<'a> {
num: u32,
obj: Obj,
stream: Option<&'a [u8]>,
}
fn indirect<'a>(data: &'a [u8], offset: usize, budget: &mut Budget) -> Option<Indirect<'a>> {
let mut lx = Lexer::bounded(data, offset, budget);
let num = u32::try_from(lx.unsigned()?).ok()?;
lx.unsigned()?;
if !lx.keyword(b"obj") {
return None;
}
let obj = lx.object(0);
budget.parsed(&lx)?;
let obj = obj?;
let mut stream = None;
if let Obj::Dict(dict) = &obj
&& lx.keyword(b"stream")
{
// The data starts after the end of line following `stream` (CR LF or LF; a lone CR is
// tolerated).
match lx.rest() {
[b'\r', b'\n', ..] => lx.pos += 2,
[b'\n' | b'\r', ..] => lx.pos += 1,
_ => {}
}
let start = lx.pos;
let by_length = get(dict, b"Length")
.and_then(Obj::int)
.and_then(|l| usize::try_from(l).ok())
.and_then(|l| start.checked_add(l))
.filter(|&end| Lexer::new(data, end).keyword(b"endstream"));
let end = match by_length {
Some(end) => end,
// An indirect or wrong /Length: the data runs to `endstream`, less its end of line.
None => {
let rest = data.get(start..)?;
let at = rest.windows(9).position(|w| w == b"endstream")?;
let mut end = start + at;
if data.get(end.wrapping_sub(1)) == Some(&b'\n') {
end -= 1;
}
if data.get(end.wrapping_sub(1)) == Some(&b'\r') {
end -= 1;
}
end.max(start)
}
};
stream = data.get(start..end);
}
Some(Indirect { num, obj, stream })
}
/// A stream's data with its filters undone: FlateDecode (with PNG predictors), or none.
fn decode(dict: &Dict, raw: &[u8], budget: &mut Budget) -> Option<Vec<u8>> {
if budget.exhausted {
return None;
}
let limit = MAX_STREAM.min(MAX_DECODED.saturating_sub(budget.decoded));
if limit == 0 {
budget.exhausted = true;
return None;
}
let filters: Vec<&[u8]> = match get(dict, b"Filter") {
None => Vec::new(),
Some(Obj::Name(n)) => vec![n.as_slice()],
Some(Obj::Array(a)) => a.iter().map(Obj::name).collect::<Option<_>>()?,
Some(_) => return None,
};
let parms = match get(dict, b"DecodeParms") {
Some(Obj::Dict(d)) => Some(d),
Some(Obj::Array(a)) => a.first().and_then(Obj::dict),
_ => None,
};
let data = match filters.as_slice() {
[] => {
if raw.len() > limit {
budget.exhausted = true;
return None;
}
Some(raw.to_vec())
}
[b"FlateDecode" | b"Fl"] => {
let inflated = match miniz_oxide::inflate::decompress_to_vec_zlib_with_limit(raw, limit) {
Ok(v) => v,
Err(e) if e.status == miniz_oxide::inflate::TINFLStatus::HasMoreOutput => {
budget.exhausted = true;
return None;
}
// A damaged stream: what inflated before the damage, as other readers do.
Err(e) if !e.output.is_empty() => e.output,
Err(_) => match miniz_oxide::inflate::decompress_to_vec_with_limit(raw, limit) {
Ok(v) => v,
Err(e) => {
budget.exhausted |= e.status == miniz_oxide::inflate::TINFLStatus::HasMoreOutput;
return None;
}
},
};
unpredict(&inflated, parms, limit)
}
_ => None,
}?;
budget.charge(0, data.len(), 0, 0)?;
Some(data)
}
/// Undo a PNG predictor (`/Predictor` 10–15), which cross-reference streams use.
fn unpredict(data: &[u8], parms: Option<&Dict>, limit: usize) -> Option<Vec<u8>> {
let int = |key: &[u8], default: i64| parms.and_then(|p| get(p, key)).and_then(Obj::int).unwrap_or(default);
let predictor = int(b"Predictor", 1);
if predictor < 10 {
return (predictor == 1).then(|| data.to_vec());
}
let colors = usize::try_from(int(b"Colors", 1)).ok().filter(|c| (1..=32).contains(c))?;
let bits = usize::try_from(int(b"BitsPerComponent", 8)).ok().filter(|b| [1, 2, 4, 8, 16].contains(b))?;
let columns = usize::try_from(int(b"Columns", 1)).ok().filter(|c| (1..=1 << 20).contains(c))?;
let bpp = (colors * bits).div_ceil(8).max(1);
let row = (columns * colors * bits).div_ceil(8);
if row > limit || !data.len().is_multiple_of(row + 1) || data.len() / (row + 1) * row > limit {
return None;
}
let mut out: Vec<u8> = Vec::with_capacity(data.len());
let mut prev = vec![0u8; row];
for chunk in data.chunks(row + 1) {
let (&kind, line) = chunk.split_first()?;
let mut cur = line.to_vec();
cur.resize(row, 0);
for i in 0..row {
let a = if i >= bpp { cur.get(i - bpp).copied().unwrap_or(0) } else { 0 };
let b = prev.get(i).copied().unwrap_or(0);
let c = if i >= bpp { prev.get(i - bpp).copied().unwrap_or(0) } else { 0 };
let add = match kind {
0 => 0,
1 => a,
2 => b,
3 => ((u16::from(a) + u16::from(b)) / 2) as u8,
4 => {
let p = i16::from(a) + i16::from(b) - i16::from(c);
let (pa, pb, pc) = ((p - i16::from(a)).abs(), (p - i16::from(b)).abs(), (p - i16::from(c)).abs());
if pa <= pb && pa <= pc {
a
} else if pb <= pc {
b
} else {
c
}
}
_ => return None,
};
if let Some(v) = cur.get_mut(i) {
*v = v.wrapping_add(add);
}
}
out.extend_from_slice(&cur);
prev = cur;
}
Some(out)
}
/// One cross-reference section and its trailer (for a stream, its dictionary).
fn xref_section(data: &[u8], offset: usize, entries: &mut HashMap<u32, Entry>, budget: &mut Budget) -> Option<(Dict, bool)> {
let mut lx = Lexer::bounded(data, offset, budget);
if !lx.keyword(b"xref") {
let x = indirect(data, offset, budget)?;
let dict = x.obj.dict()?.clone();
xref_stream(&dict, x.stream?, entries, budget)?;
return Some((dict, true));
}
let mut table = Vec::new();
let trailer = loop {
if lx.keyword(b"trailer") {
let trailer = lx.object(0);
budget.parsed(&lx)?;
break trailer?.dict()?.clone();
}
let start = u32::try_from(lx.unsigned()?).ok()?;
let count = u32::try_from(lx.unsigned()?).ok()?;
budget.charge(usize::try_from(count).ok()?, 0, 0, 0)?;
start.checked_add(count.saturating_sub(1))?;
for i in 0..count {
let offset = usize::try_from(lx.unsigned()?).ok()?;
lx.unsigned()?;
lx.skip_ws();
let kind = lx.word();
let Some(num) = start.checked_add(i) else { break };
table.push((num, if kind == b"n" { Entry::At(offset) } else { Entry::Free }));
}
};
// A hybrid file's stream lists the objects its table leaves out (ISO 32000-1, 7.5.8.4).
if let Some(at) = get(&trailer, b"XRefStm").and_then(Obj::int).and_then(|v| usize::try_from(v).ok())
&& let Some(x) = indirect(data, at, budget)
&& let (Some(dict), Some(stream)) = (x.obj.dict(), x.stream)
{
xref_stream(dict, stream, entries, budget)?;
}
for (num, entry) in table {
entries.entry(num).or_insert(entry);
}
Some((trailer, false))
}
fn xref_stream(dict: &Dict, raw: &[u8], entries: &mut HashMap<u32, Entry>, budget: &mut Budget) -> Option<()> {
if get(dict, b"Type").and_then(Obj::name) != Some(b"XRef") {
return None;
}
let widths: Vec<usize> =
get(dict, b"W")?.array()?.iter().map(|w| w.int().and_then(|w| usize::try_from(w).ok()).filter(|w| *w <= 8)).collect::<Option<_>>()?;
let [w0, w1, w2] = widths.as_slice() else { return None };
let entry_len = w0 + w1 + w2;
if entry_len == 0 {
return None;
}
let size = get(dict, b"Size").and_then(Obj::int).unwrap_or(0);
let index: Vec<i64> = match get(dict, b"Index").and_then(Obj::array) {
Some(a) => a.iter().map(Obj::int).collect::<Option<_>>()?,
None => vec![0, size],
};
if !index.len().is_multiple_of(2) {
return None;
}
let mut count = 0usize;
for &[start, n] in index.as_chunks::<2>().0 {
let (start, n) = (u32::try_from(start).ok()?, u32::try_from(n).ok()?);
start.checked_add(n.saturating_sub(1))?;
count = count.checked_add(usize::try_from(n).ok()?)?;
}
// Check counts before inflating or expanding the xref map, including duplicate records.
budget.charge(count, 0, 0, 0)?;
let data = decode(dict, raw, budget)?;
let field = |bytes: &[u8]| bytes.iter().fold(0u64, |v, &b| v.wrapping_shl(8) | u64::from(b));
let mut records = data.chunks_exact(entry_len);
for &[start, count] in index.as_chunks::<2>().0 {
let (Ok(start), Ok(count)) = (u32::try_from(start), u32::try_from(count)) else { return None };
for i in 0..count {
let Some(record) = records.next() else { return Some(()) };
let (kind, rest) = record.split_at_checked(*w0)?;
let (second, third) = rest.split_at_checked(*w1)?;
let kind = if *w0 == 0 { 1 } else { field(kind) };
let (second, third) = (field(second), field(third));
let entry = match kind {
0 => Entry::Free,
1 => Entry::At(usize::try_from(second).ok()?),
2 => Entry::In(u32::try_from(second).ok()?, u32::try_from(third).ok()?),
_ => continue,
};
let Some(num) = start.checked_add(i) else { break };
entries.entry(num).or_insert(entry);
}
}
Some(())
}
/// The offset `startxref` names.
fn startxref(data: &[u8]) -> Option<usize> {
let at = data.windows(9).rposition(|w| w == b"startxref")?;
usize::try_from(Lexer::new(data, at + 9).unsigned()?).ok()
}
impl Reader<'_> {
fn object(&mut self, num: u32) -> Option<Obj> {
// Geometry fallback must not resume parsing after any job-wide budget is spent.
if self.budget.exhausted {
return None;
}
match *self.entries.get(&num)? {
Entry::Free => None,
Entry::At(offset) => indirect(self.data, offset, &mut self.budget).filter(|x| x.num == num).map(|x| x.obj),
Entry::In(stm, index) => {
if !self.objstms.contains_key(&stm) {
let loaded = self.load_objstm(stm);
self.objstms.insert(stm, loaded);
}
let objstm = self.objstms.get(&stm)?.as_ref()?;
let &(found, offset) = objstm.objects.get(usize::try_from(index).ok()?)?;
if found != num {
return None;
}
let mut lx = Lexer::bounded(&objstm.data, offset, &self.budget);
let obj = lx.object(0);
self.budget.parsed(&lx)?;
obj
}
}
}
fn load_objstm(&mut self, stm: u32) -> Option<ObjStm> {
let Entry::At(offset) = *self.entries.get(&stm)? else { return None };
let x = indirect(self.data, offset, &mut self.budget).filter(|x| x.num == stm)?;
let dict = x.obj.dict()?;
if get(dict, b"Type").and_then(Obj::name) != Some(b"ObjStm") {
return None;
}
let n = get(dict, b"N").and_then(Obj::int).and_then(|n| usize::try_from(n).ok())?;
self.budget.charge(n, 0, 0, 0)?;
let data = decode(dict, x.stream?, &mut self.budget)?;
let first = get(dict, b"First").and_then(Obj::int).and_then(|f| usize::try_from(f).ok())?;
let mut lx = Lexer::new(&data, 0);
let mut objects = Vec::new();
for _ in 0..n {
let (Some(num), Some(at)) = (lx.unsigned(), lx.unsigned()) else { break };
let (Ok(num), Some(at)) = (u32::try_from(num), usize::try_from(at).ok().and_then(|a| a.checked_add(first))) else { break };
objects.push((num, at));
}
Some(ObjStm { data, objects })
}
/// The object a value names, when it is a reference (a few levels at most).
fn resolve(&mut self, obj: &Obj) -> Option<Obj> {
let mut current = obj.clone();
for _ in 0..8 {
match current {
Obj::Ref(num, _) => current = self.object(num)?,
other => return Some(other),
}
}
None
}
/// Like [`resolve`](Self::resolve), but a direct object is borrowed, not copied. Inherited
/// page attributes are read once per page, so copying a large inline value would repeat that
/// work for every page outside the budget.
fn resolve_borrowed<'o>(&mut self, obj: &'o Obj) -> Option<Cow<'o, Obj>> {
match obj {
Obj::Ref(..) => self.resolve(obj).map(Cow::Owned),
direct => Some(Cow::Borrowed(direct)),
}
}
fn rect(&mut self, obj: Option<&Obj>) -> Option<[f64; 4]> {
let obj = self.resolve_borrowed(obj?)?;
let items = obj.array()?;
// The shape is checked before reading any element.
if items.len() != 4 {
return None;
}
let mut v = [0.0; 4];
for (slot, item) in v.iter_mut().zip(items) {
*slot = self.resolve_borrowed(item)?.num()?;
}
let [x0, y0, x1, y1] = v;
Some([x0.min(x1), y0.min(y1), x0.max(x1), y0.max(y1)])
}
}
/// One sheet of the print-ready PDF.
#[derive(Clone, Debug, PartialEq)]
pub(crate) struct Sheet {
/// The page object (number, generation); `None` for a page written inside its parent.
obj: Option<(u32, u16)>,
dict: Dict,
/// The visible box (CropBox within MediaBox), in the page's own unrotated space.
pub(crate) bounds: [f64; 4],
/// `/Rotate`, 0, 90, 180 or 270.
pub(crate) rotate: u32,
/// Annotation flags match the renderer's visible printing behavior.
print_annotations_safe: bool,
}
impl Sheet {
/// Width and height as the sheet is shown and printed (rotation applied), in points.
pub(crate) fn size(&self) -> (f64, f64) {
let [x0, y0, x1, y1] = self.bounds;
if self.rotate % 180 == 90 { (y1 - y0, x1 - x0) } else { (x1 - x0, y1 - y0) }
}
}
/// A print-ready PDF's sheets, and what an update to it needs.
pub(crate) struct Sheets<'a> {
data: &'a [u8],
pub(crate) sheets: Vec<Sheet>,
trailer: Dict,
/// The newest cross-reference section is a stream (so is the update's).
xref_stream: bool,
startxref: usize,
/// The first unused object number.
size: u32,
}
/// What [`read`] inherits down the page tree.
#[derive(Clone, Default)]
struct Inherited {
media: Option<Arc<Obj>>,
crop: Option<Arc<Obj>>,
rotate: Option<Arc<Obj>>,
}
/// Read the sheets of a print-ready PDF.
pub(crate) fn read(data: &[u8]) -> Result<Sheets<'_>, Error> {
let start = startxref(data).ok_or_else(|| unreadable("no cross-reference table"))?;
let mut entries = HashMap::new();
let mut budget = Budget::default();
let mut trailer = None;
let mut xref_stream = false;
let mut seen = HashSet::new();
let mut next = Some(start);
while let Some(offset) = next {
if !seen.insert(offset) || seen.len() > MAX_SECTIONS {
break;
}
let Some((section, is_stream)) = xref_section(data, offset, &mut entries, &mut budget) else {
// An older section that can't be read only loses objects the newer ones replaced.
if budget.exhausted || trailer.is_none() {
return Err(unreadable("the cross-reference table is damaged"));
}
break;
};
next = get(&section, b"Prev").and_then(Obj::int).and_then(|v| usize::try_from(v).ok());
if trailer.is_none() {
xref_stream = is_stream;
trailer = Some(section);
}
}
let trailer = trailer.ok_or_else(|| unreadable("no trailer"))?;
let size = entries
.keys()
.max()
.map_or(0, |m| m.saturating_add(1))
.max(get(&trailer, b"Size").and_then(Obj::int).and_then(|s| u32::try_from(s).ok()).unwrap_or(0));
let mut reader = Reader { data, entries, objstms: HashMap::new(), budget };
let root = get(&trailer, b"Root").cloned().ok_or_else(|| unreadable("no document catalog"))?;
let catalog = reader.resolve(&root).and_then(|c| c.dict().cloned()).ok_or_else(|| unreadable("no document catalog"))?;
let pages_root = get(&catalog, b"Pages").cloned().ok_or_else(|| unreadable("no page tree"))?;
let mut sheets = Vec::new();
let mut visited = HashSet::new();
// Depth-first in page order: kids are pushed last to first.
let mut stack = vec![(pages_root, Inherited::default(), 0usize)];
while let Some((node, inherited, depth)) = stack.pop() {
if depth > MAX_DEPTH || sheets.len() >= MAX_PAGES {
return Err(unreadable("the page tree is too deep or too large"));
}
let obj = match &node {
Obj::Ref(num, generation) => {
if !visited.insert(*num) {
return Err(unreadable("the page tree loops"));
}
Some((*num, *generation))
}
_ => None,
};
let mut dict = reader.resolve(&node).and_then(|d| d.dict().cloned()).ok_or_else(|| unreadable("a page is missing"))?;
let here = Inherited {
media: get(&dict, b"MediaBox").cloned().map(Arc::new).or(inherited.media),
crop: get(&dict, b"CropBox").cloned().map(Arc::new).or(inherited.crop),
rotate: get(&dict, b"Rotate").cloned().map(Arc::new).or(inherited.rotate),
};
let kids = get(&dict, b"Kids").cloned().and_then(|k| reader.resolve(&k));
let is_node = get(&dict, b"Type").and_then(Obj::name) == Some(b"Pages") || (get(&dict, b"Type").is_none() && kids.is_some());
if is_node {
let kids = kids.and_then(|k| k.array().map(<[Obj]>::to_vec)).unwrap_or_default();
if stack.len().checked_add(kids.len()).is_none_or(|n| n > MAX_PAGES) {
return Err(unreadable("the page tree is too large"));
}
stack.extend(kids.into_iter().rev().map(|kid| (kid, here.clone(), depth + 1)));
continue;
}
// Windows.Data.Pdf ignores nondefault UserUnit. Refuse it before submitting a
// job rather than silently printing the wrong physical dimensions.
if let Some(unit) = get(&dict, b"UserUnit") {
let unit = reader.resolve(unit).and_then(|u| u.num());
if unit != Some(1.0) {
return Err(unreadable("nondefault or invalid UserUnit is not supported"));
}
}
if let Some(contents) = get(&dict, b"Contents").cloned() {
let resolved = reader.resolve(&contents).ok_or_else(|| unreadable("page content is missing"))?;
// An indirect array can live in an object stream. Keep only its stream
// references in the wrapper; an array reference there renders blank.
if let Obj::Array(items) = resolved {
if !items.iter().all(|item| matches!(item, Obj::Ref(..))) {
return Err(unreadable("a content array must contain stream references"));
}
dict.retain(|(key, _)| key != b"Contents");
dict.push((b"Contents".to_vec(), Obj::Array(items)));
} else if resolved == Obj::Null {
dict.retain(|(key, _)| key != b"Contents");
} else if !matches!(contents, Obj::Ref(..)) || !matches!(resolved, Obj::Dict(_)) {
return Err(unreadable("page content must be a stream or an array of streams"));
}
}
let media = reader.rect(here.media.as_deref()).filter(|r| r[2] > r[0] && r[3] > r[1]).unwrap_or(DEFAULT_MEDIA);
let bounds = reader
.rect(here.crop.as_deref())
.map(|c| [c[0].max(media[0]), c[1].max(media[1]), c[2].min(media[2]), c[3].min(media[3])])
.filter(|c| c[2] > c[0] && c[3] > c[1])
.unwrap_or(media);
let rotate = here.rotate.as_ref().and_then(|r| reader.resolve_borrowed(r).and_then(|r| r.int())).unwrap_or(0).rem_euclid(360);
// Not a multiple of 90 is invalid; such a page is shown unrotated.
let rotate = if rotate % 90 == 0 { u32::try_from(rotate).unwrap_or(0) } else { 0 };
// Windows renders viewer visibility, not annotation print visibility. Only a plain
// visible printable annotation is safe; flatten other flags before native submission.
let print_annotations_safe = match get(&dict, b"Annots") {
None | Some(Obj::Null) => true,
Some(annotations) => match reader.resolve(annotations) {
Some(Obj::Null) => true,
Some(Obj::Array(items)) => items.iter().all(|item| {
reader
.resolve(item)
.and_then(|a| a.dict().cloned())
.is_some_and(|annotation| get(&annotation, b"F").and_then(|f| reader.resolve(f)).and_then(|f| f.int()) == Some(4))
}),
_ => false,
},
};
sheets.push(Sheet { obj, dict, bounds, rotate, print_annotations_safe });
}
if reader.budget.exhausted {
return Err(unreadable("the parser memory budget was exceeded"));
}
if sheets.is_empty() {
return Err(Error::Spool("the print-ready PDF has no pages".into()));
}
Ok(Sheets { data, sheets, trailer, xref_stream, startxref: start, size })
}
/// How sheets of another size than the paper are printed.
#[derive(Clone, Copy, Debug, PartialEq)]
pub(crate) enum Fit {
/// Every sheet must be the paper's size (either way round).
Exact,
/// Sheets are shrunk (never enlarged) to fit inside `margin` points of the paper's edges, and
/// centred on it.
Shrink { margin: f64 },
}
/// A page's new version: its rotation, and for a page brought to the paper's size, the new
/// geometry.
#[derive(Clone, Debug, PartialEq)]
pub(crate) struct Edit {
pub(crate) sheet: usize,
pub(crate) rotate: u32,
pub(crate) frame: Option<Frame>,
}
/// A page reframed onto the paper: its new MediaBox (unrotated space), the matrix placing the
/// old content on it, and the old visible box, which stays the clip.
#[derive(Clone, Debug, PartialEq)]
pub(crate) struct Frame {
pub(crate) media: (f64, f64),
pub(crate) matrix: [f64; 6],
pub(crate) clip: [f64; 4],
}
fn close(a: (f64, f64), b: (f64, f64)) -> bool {
(a.0 - b.0).abs() <= SAME && (a.1 - b.1).abs() <= SAME
}
/// The edits that put every sheet on `paper` (width and height as printed, in points; its
/// orientation is the job's). A sheet of the other orientation turns a quarter: a landscape sheet
/// in a portrait job anticlockwise, as drivers put a landscape job on the paper, a portrait sheet
/// in a landscape job clockwise. `Exact` refuses a sheet of another size; `Shrink` reframes it.
pub(crate) fn plan(sheets: &[Sheet], paper: (f64, f64), fit: Fit) -> Result<Vec<Edit>, Error> {
let (pw, ph) = paper;
if !(pw.is_finite() && ph.is_finite() && pw > 0.0 && ph > 0.0) {
return Err(Error::Spool("the printer reported a paper size that is not usable".into()));
}
let mut edits = Vec::new();
for (i, sheet) in sheets.iter().enumerate() {
let (w, h) = sheet.size();
let turn = if pw > ph + SAME && h > w + SAME {
90
} else if ph > pw + SAME && w > h + SAME {
270
} else {
0
};
let rotate = (sheet.rotate + turn) % 360;
let (w, h) = if turn == 0 { (w, h) } else { (h, w) };
let frame = match fit {
Fit::Exact if close((w, h), paper) => None,
Fit::Exact => {
return Err(Error::Spool(format!(
"Windows prints one paper size per job: sheet {} is {:.2} × {:.2} in, the job's paper {:.2} × {:.2} in; print the sheets of each size separately",
i + 1,
w / 72.0,
h / 72.0,
pw / 72.0,
ph / 72.0
)));
}
Fit::Shrink { margin } => {
let margin = if margin.is_finite() && margin > 0.0 && 2.0 * margin < pw.min(ph) { margin } else { 0.0 };
let scale = ((pw - 2.0 * margin) / w).min((ph - 2.0 * margin) / h).min(1.0);
if scale >= 1.0 && close((w, h), paper) {
None
} else {
let [x0, y0, x1, y1] = sheet.bounds;
let (cw, ch) = (x1 - x0, y1 - y0);
// The paper in the page's unrotated space.
let (uw, uh) = if rotate % 180 == 90 { (ph, pw) } else { (pw, ph) };
let tx = (uw - scale * cw) / 2.0 - scale * x0;
let ty = (uh - scale * ch) / 2.0 - scale * y0;
Some(Frame { media: (uw, uh), matrix: [scale, 0.0, 0.0, scale, tx, ty], clip: [x0, y0, cw, ch] })
}
}
};
if turn != 0 || frame.is_some() {
edits.push(Edit { sheet: i, rotate, frame });
}
}
Ok(edits)
}
/// Boxes a reframed page drops: they were in the old geometry.
const OLD_GEOMETRY: [&[u8]; 6] = [b"MediaBox", b"CropBox", b"BleedBox", b"TrimBox", b"ArtBox", b"Rotate"];
impl Sheets<'_> {
/// Refuse annotation print/view visibility the Windows renderer cannot preserve.
pub(crate) fn check_print_annotations(&self) -> Result<(), Error> {
if self.sheets.iter().all(|s| s.print_annotations_safe) {
Ok(())
} else {
Err(Error::Spool("flatten annotation print visibility into page content before native Windows printing; nothing was sent".into()))
}
}
/// The PDF with `edits` appended as an incremental update (ISO 32000-1, 7.5.6): the
/// original bytes, then the new versions of the edited pages, the content streams a
/// reframed page wraps its own in, and a cross-reference section of the same kind as the
/// newest one, chained to it with `/Prev`.
pub(crate) fn update(&self, edits: &[Edit]) -> Result<Vec<u8>, Error> {
let refuse = |why: &str| Error::Spool(format!("the print-ready PDF can't be fitted to the paper: {why}"));
let mut out = self.data.to_vec();
if !out.ends_with(b"\n") {
out.push(b'\n');
}
let mut next = self.size;
// Each new object's number, generation and offset.
let mut written: Vec<(u32, u16, usize)> = Vec::new();
fn new_object(out: &mut Vec<u8>, num: u32, generation: u16, written: &mut Vec<(u32, u16, usize)>) {
written.push((num, generation, out.len()));
out.extend_from_slice(format!("{num} {generation} obj\n").as_bytes());
}
for edit in edits {
let sheet = self.sheets.get(edit.sheet).ok_or_else(|| refuse("no such sheet"))?;
let (num, generation) = sheet.obj.ok_or_else(|| refuse("a page is not an object of its own"))?;
let mut dict = sheet.dict.clone();
dict.retain(|(k, _)| k != b"Rotate");
if let Some(frame) = &edit.frame {
if get(&self.trailer, b"Encrypt").is_some() {
return Err(refuse("it is encrypted"));
}
// Annotation appearances live outside /Contents. Transforming only content
// would leave them at the old coordinates; deleting them loses printed marks.
// Require callers to flatten before fitting until appearances can be reframed.
if let Some(annotations) = get(&dict, b"Annots")
&& !matches!(annotations, Obj::Null)
&& !matches!(annotations, Obj::Array(items) if items.is_empty())
{
return Err(refuse("flatten page annotations before using shrink-to-fit"));
}
let [a, b, c, d, e, f] = frame.matrix;
let [x, y, w, h] = frame.clip;
let prefix = format!(
"q {} {} {} {} {} {} cm {} {} {} {} re W n\n",
number(a),
number(b),
number(c),
number(d),
number(e),
number(f),
number(x),
number(y),
number(w),
number(h)
);
let mut contents = Vec::new();
for part in [prefix.as_bytes(), b"\nQ\n"] {
let stream = next;
next = next.checked_add(1).ok_or_else(|| refuse("too many objects"))?;
new_object(&mut out, stream, 0, &mut written);
out.extend_from_slice(format!("<</Length {}>>\nstream\n", part.len()).as_bytes());
out.extend_from_slice(part);
out.extend_from_slice(b"\nendstream\nendobj\n");
contents.push(Obj::Ref(stream, 0));
}
// The page's own content, in order, between the two.
let own = match get(&dict, b"Contents") {
Some(Obj::Array(items)) => items.clone(),
Some(other) => vec![other.clone()],
None => Vec::new(),
};
contents.splice(1..1, own);
dict.retain(|(k, _)| !OLD_GEOMETRY.contains(&k.as_slice()) && k != b"Contents" && k != b"Annots");
// CropBox too: a parent's would otherwise be inherited, in the old geometry.
let media = Obj::Array(vec![num_obj(0.0), num_obj(0.0), num_obj(frame.media.0), num_obj(frame.media.1)]);
dict.push((b"MediaBox".to_vec(), media.clone()));
dict.push((b"CropBox".to_vec(), media));
dict.push((b"Contents".to_vec(), Obj::Array(contents)));
}
dict.push((b"Rotate".to_vec(), Obj::Num(edit.rotate.to_string().into_bytes())));
new_object(&mut out, num, generation, &mut written);
write_dict(&mut out, &dict);
out.extend_from_slice(b"\nendobj\n");
}
if written.is_empty() {
return Ok(self.data.to_vec());
}
written.sort_by_key(|(num, _, _)| *num);
let mut trailer: Dict = Vec::new();
for key in [&b"Root"[..], b"Info", b"ID", b"Encrypt"] {
if let Some(v) = get(&self.trailer, key) {
trailer.push((key.to_vec(), v.clone()));
}
}
trailer.push((b"Prev".to_vec(), Obj::Num(self.startxref.to_string().into_bytes())));
if self.xref_stream {
let xref = next;
next = next.checked_add(1).ok_or_else(|| refuse("too many objects"))?;
let at = out.len();
written.push((xref, 0, at));
let width = (usize::BITS - at.leading_zeros()).div_ceil(8).max(1) as usize;
let mut data = Vec::new();
let mut index = Vec::new();
for (num, generation, offset) in &written {
index.extend([Obj::Num(num.to_string().into_bytes()), Obj::Num(b"1".to_vec())]);
data.push(1u8);
data.extend_from_slice(offset.to_be_bytes().get(std::mem::size_of::<usize>() - width..).unwrap_or(&[]));
data.extend_from_slice(&generation.to_be_bytes());
}
trailer.extend([
(b"Type".to_vec(), Obj::Name(b"XRef".to_vec())),
(b"Size".to_vec(), Obj::Num(next.to_string().into_bytes())),
(b"W".to_vec(), Obj::Array(vec![Obj::Num(b"1".to_vec()), Obj::Num(width.to_string().into_bytes()), Obj::Num(b"2".to_vec())])),
(b"Index".to_vec(), Obj::Array(index)),
(b"Length".to_vec(), Obj::Num(data.len().to_string().into_bytes())),
]);
out.extend_from_slice(format!("{xref} 0 obj\n").as_bytes());
write_dict(&mut out, &trailer);
out.extend_from_slice(b"\nstream\n");
out.extend_from_slice(&data);
out.extend_from_slice(format!("\nendstream\nendobj\nstartxref\n{at}\n%%EOF\n").as_bytes());
} else {
let at = out.len();
out.extend_from_slice(b"xref\n");
for (num, generation, offset) in &written {
out.extend_from_slice(format!("{num} 1\n{offset:010} {generation:05} n\r\n").as_bytes());
}
trailer.push((b"Size".to_vec(), Obj::Num(next.to_string().into_bytes())));
out.extend_from_slice(b"trailer\n");
write_dict(&mut out, &trailer);
out.extend_from_slice(format!("\nstartxref\n{at}\n%%EOF\n").as_bytes());
}
Ok(out)
}
}
#[cfg(test)]
#[path = "tests/pdf_bounds.rs"]
mod tests;