Files
pdfcraft/crates/cos/src/document.rs
T
mtlcl410andClaude Opus 5.5 271cb6161f M1: read an xref table numbered from 1 that begins with object 0 (#2676)
Some producers head a classic xref table "1 n" and then write object 0's
free entry first, so every row is one number off and object 1 reads as
free. The renderer coped, but cos did not: printing a scanned invoice
built from cos dropped object 1 (page 1's image) and printed the page
blank. Such a subsection is now read from 0 when its second row locates
an object that is numbered 1, in both xref readers, and the repair is
logged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RUNQSPVXx3dDBabRZHAnjz
2026-10-16 08:35:35 +08:00

2761 lines
130 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.
//! Document: cross-reference data, lazy object loading, and a copy-on-write overlay of edits.
//!
//! `Document` is cheap to clone (the original bytes, xref index and parse caches are shared), so
//! a clone is a snapshot: undo/redo and background jobs hold clones while the UI edits another.
//! Edits never touch the original bytes; the writer appends them (incremental save) or rewrites
//! the file (full save).
use std::collections::{BTreeMap, HashMap, HashSet};
use std::sync::{Arc, Mutex, Weak};
use crate::CosError;
use crate::object::{Dict, ObjRef, Object};
use crate::parser::{Lexer, is_whitespace, parse_indirect, parse_indirect_shared};
thread_local! {
/// The objects being loaded on this thread, innermost last (see `Document::try_get`).
static LOADING: std::cell::RefCell<Vec<u32>> = const { std::cell::RefCell::new(Vec::new()) };
/// Indirect `/Length` values resolved during the current outermost load: a damaged file
/// retries a parse several ways, and each retry would resolve the same lengths again
/// (exponential in the chain of streams whose lengths point at further streams).
static LENGTHS: std::cell::RefCell<HashMap<u32, Option<i64>>> = std::cell::RefCell::new(HashMap::new());
}
/// Marks an object as being loaded on this thread. Loading can re-enter (indirect stream
/// `/Length`, object streams): an object already being loaded is a cycle, and the nesting is
/// bounded as well.
struct LoadGuard;
impl LoadGuard {
const MAX: usize = 32;
fn enter(num: u32) -> Option<Self> {
LOADING.with(|l| {
let mut l = l.borrow_mut();
if l.len() >= Self::MAX || l.contains(&num) {
return None;
}
l.push(num);
Some(LoadGuard)
})
}
}
impl Drop for LoadGuard {
fn drop(&mut self) {
let outermost = LOADING.with(|l| {
let mut l = l.borrow_mut();
l.pop();
l.is_empty()
});
if outermost {
LENGTHS.with(|m| m.borrow_mut().clear());
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum XrefEntry {
Free { next_generation: u16 },
InFile { offset: u64, generation: u16 },
InStream { stream: u32, index: u32 },
}
#[derive(Clone, Copy, Debug)]
struct XrefRange {
start: u32,
count: u32,
offset: usize,
stride: usize,
}
#[derive(Clone, Debug)]
enum XrefSectionData {
Table,
Stream { bytes: Arc<Vec<u8>>, widths: [usize; 3] },
}
#[derive(Clone, Debug)]
struct XrefSection {
data: XrefSectionData,
ranges: Vec<XrefRange>,
row_count: usize,
max_num: u32,
has_entries: bool,
/// A table subsection was read one number lower than it says ([`subsection_start`]).
renumbered: bool,
}
#[derive(Clone, Debug)]
struct XrefRevisionSection {
primary: XrefSection,
hybrid: Option<XrefSection>,
}
#[derive(Clone, Debug)]
enum XrefIndex {
Compact { revisions: Vec<XrefRevisionSection>, max_num: u32, has_entries: bool },
Materialized(Arc<BTreeMap<u32, XrefEntry>>),
}
const MAX_COMPACT_XREF_ROWS: usize = 2_000_000;
const MAX_COMPACT_XREF_BYTES: usize = 64 * 1024 * 1024;
impl XrefSection {
fn has_any_entry(&self) -> bool {
match &self.data {
XrefSectionData::Table => self.has_entries,
XrefSectionData::Stream { bytes, widths } => {
let row_width = widths.iter().sum::<usize>();
if row_width == 0 {
return false;
}
for range in &self.ranges {
let available = bytes.len().saturating_sub(range.offset) / row_width;
for i in 0..available.min(range.count as usize) {
let Some(offset) = i.checked_mul(row_width).and_then(|delta| range.offset.checked_add(delta)) else { continue };
let Some(row) = bytes.get(offset..offset.saturating_add(row_width)) else { continue };
let read_field = |from: usize, width: usize, default: u64| -> Option<u64> {
if width == 0 {
return Some(default);
}
let end = from.checked_add(width)?;
Some(row.get(from..end)?.iter().fold(0u64, |acc, byte| acc.wrapping_shl(8) | u64::from(*byte)))
};
let Some(kind) = read_field(0, widths[0], 1) else { continue };
if kind == 0 || kind == 2 {
return true;
}
if kind == 1 && read_field(widths[0], widths[1], 0).is_some_and(|offset| offset != 0) {
return true;
}
}
}
false
}
}
}
fn retained_bytes(&self) -> usize {
match &self.data {
XrefSectionData::Table => 0,
XrefSectionData::Stream { bytes, .. } => bytes.capacity(),
}
}
fn lookup(&self, num: u32, file: &[u8]) -> Option<XrefEntry> {
let after = self.ranges.partition_point(|range| range.start <= num);
let range = self.ranges.get(after.checked_sub(1)?)?;
let end = u64::from(range.start).checked_add(u64::from(range.count))?;
if u64::from(num) >= end {
return None;
}
let row = usize::try_from(num.checked_sub(range.start)?).ok()?;
let offset = range.offset.checked_add(row.checked_mul(range.stride)?)?;
match &self.data {
XrefSectionData::Table => {
let mut lexer = Lexer::new(file, offset);
lexer.skip_ws();
let object_offset = lexer.token();
lexer.skip_ws();
let generation = lexer.token();
lexer.skip_ws();
let kind = lexer.token();
match kind {
b"n" => {
let object_offset = std::str::from_utf8(object_offset).ok()?.parse::<u64>().ok()?;
if object_offset == 0 {
return None;
}
let generation = std::str::from_utf8(generation).ok()?.parse::<u32>().unwrap_or(0).min(u16::MAX as u32) as u16;
Some(XrefEntry::InFile { offset: object_offset, generation })
}
b"f" => {
let generation = std::str::from_utf8(generation).ok()?.parse::<u32>().unwrap_or(0).min(u16::MAX as u32) as u16;
Some(XrefEntry::Free { next_generation: generation })
}
_ => None,
}
}
XrefSectionData::Stream { bytes, widths } => {
let row_width = widths.iter().sum::<usize>();
let end = offset.checked_add(row_width)?;
let row = bytes.get(offset..end)?;
let field = |from: usize, width: usize, default: u64| -> Option<u64> {
if width == 0 {
return Some(default);
}
let end = from.checked_add(width)?;
Some(row.get(from..end)?.iter().fold(0u64, |acc, byte| acc.wrapping_shl(8) | u64::from(*byte)))
};
let kind = field(0, widths[0], 1)?;
let a = field(widths[0], widths[1], 0)?;
let b = field(widths[0] + widths[1], widths[2], 0)?;
let entry = match kind {
0 => XrefEntry::Free { next_generation: b.min(u16::MAX as u64) as u16 },
1 => XrefEntry::InFile { offset: a, generation: b.min(u16::MAX as u64) as u16 },
2 => XrefEntry::InStream { stream: a.min(u32::MAX as u64) as u32, index: b.min(u32::MAX as u64) as u32 },
_ => return None,
};
(!matches!(entry, XrefEntry::InFile { offset: 0, .. })).then_some(entry)
}
}
}
fn extend_numbers(&self, numbers: &mut Vec<u32>) {
for range in &self.ranges {
let available = match &self.data {
XrefSectionData::Table => range.count as usize,
XrefSectionData::Stream { bytes, widths } => {
let row_width = widths.iter().sum::<usize>();
bytes.len().saturating_sub(range.offset).checked_div(row_width).unwrap_or(0)
}
};
for i in 0..available.min(range.count as usize) {
if let Ok(i) = u32::try_from(i)
&& let Some(num) = range.start.checked_add(i)
{
numbers.push(num);
}
}
}
}
}
impl XrefRevisionSection {
fn lookup(&self, num: u32, file: &[u8]) -> Option<XrefEntry> {
let primary = self.primary.lookup(num, file);
let hybrid = self.hybrid.as_ref().and_then(|section| section.lookup(num, file));
match primary {
None | Some(XrefEntry::Free { .. }) => hybrid.or(primary),
entry => entry,
}
}
fn extend_numbers(&self, numbers: &mut Vec<u32>) {
self.primary.extend_numbers(numbers);
if let Some(hybrid) = &self.hybrid {
hybrid.extend_numbers(numbers);
}
}
}
impl XrefIndex {
fn empty() -> Self {
Self::Materialized(Arc::new(BTreeMap::new()))
}
fn lookup(&self, num: u32, file: &[u8]) -> Option<XrefEntry> {
match self {
Self::Compact { revisions, .. } => revisions.iter().find_map(|revision| revision.lookup(num, file)),
Self::Materialized(entries) => entries.get(&num).copied(),
}
}
fn is_empty(&self) -> bool {
match self {
Self::Compact { has_entries, .. } => !has_entries,
Self::Materialized(entries) => entries.is_empty(),
}
}
/// Drop an entry for object number 4294967295, which no incremental `/Size` can cover.
/// Only a materialized index can hold one: the compact reader requires `max_num < /Size`.
fn remove_exhausted(&mut self) -> bool {
match self {
Self::Compact { .. } => false,
Self::Materialized(entries) => Arc::make_mut(entries).remove(&u32::MAX).is_some(),
}
}
fn max_num(&self) -> u32 {
match self {
Self::Compact { max_num, .. } => *max_num,
Self::Materialized(entries) => entries.keys().next_back().copied().unwrap_or(0),
}
}
fn object_numbers(&self, file: &[u8]) -> Vec<u32> {
match self {
Self::Materialized(entries) => {
entries.iter().filter_map(|(&num, entry)| (!matches!(entry, XrefEntry::Free { .. })).then_some(num)).collect()
}
Self::Compact { revisions, .. } => {
if let [revision] = revisions.as_slice()
&& revision.hybrid.is_none()
{
let mut numbers = Vec::with_capacity(revision.primary.row_count);
revision.primary.extend_numbers(&mut numbers);
numbers.retain(|num| self.lookup(*num, file).is_some_and(|entry| !matches!(entry, XrefEntry::Free { .. })));
return numbers;
}
let mut numbers = Vec::new();
for revision in revisions {
revision.extend_numbers(&mut numbers);
}
numbers.sort_unstable();
numbers.dedup();
numbers.retain(|num| self.lookup(*num, file).is_some_and(|entry| !matches!(entry, XrefEntry::Free { .. })));
numbers
}
}
}
}
/// One cross-reference section of the file (one per save; the first is the original).
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Revision {
/// Offset of the xref section (`startxref` value).
pub xref_offset: u64,
/// `true` if the section is a cross-reference stream.
pub is_stream: bool,
}
#[derive(Clone, Debug)]
struct ObjStm {
/// (object number, byte offset within `data`).
index: Vec<(u32, usize)>,
data: Arc<Vec<u8>>,
}
/// Where an overlay slot came from.
#[derive(Clone, Debug, PartialEq)]
enum Slot {
Set(u16, Arc<Object>),
Freed(u16),
}
/// An immutable input/decode context shared by document clones. Holding this identity
/// retains neither the input bytes nor the parsed document. Weak ownership prevents an
/// expired identity from matching a later allocation at the same address.
#[derive(Clone, Debug)]
pub struct SourceIdentity(Weak<()>);
impl PartialEq for SourceIdentity {
fn eq(&self, other: &Self) -> bool {
self.0.ptr_eq(&other.0)
}
}
impl Eq for SourceIdentity {}
#[derive(Clone)]
pub struct Document {
data: Arc<Vec<u8>>,
source_identity: Arc<()>,
entries: Arc<XrefIndex>,
trailer: Dict,
revisions: Arc<Vec<Revision>>,
/// See [`Self::xref_oddities`].
xref_oddities: Arc<Vec<(u64, u32)>>,
repair_log: Arc<Vec<String>>,
cache: Arc<Mutex<HashMap<u32, Arc<Object>>>>,
objstms: Arc<Mutex<HashMap<u32, Arc<ObjStm>>>>,
stream_limit: usize,
overlay: BTreeMap<u32, Slot>,
next_num: u32,
/// Position of the `%PDF-` header (some files have junk before it).
header_offset: usize,
version: String,
/// The authenticated security handler of an encrypted document.
security: Option<Arc<pdfcraft_crypt::SecurityHandler>>,
/// Object number of the `/Encrypt` dictionary (never encrypted itself).
encrypt_num: Option<u32>,
/// Encryption was added, changed or removed since opening: only a full save can apply it.
encryption_changed: bool,
/// Set by edits that must not leave earlier revisions in the file (redaction).
full_save: bool,
/// The handler and `/Encrypt` object number that saves use, when encryption changed.
out_security: Option<Arc<pdfcraft_crypt::SecurityHandler>>,
out_encrypt_num: Option<u32>,
}
/// A temporary reader for whole-document work. Its caches never populate the editor or
/// undo snapshots. Returned objects survive cache eviction; a stream read from the file is a
/// view into the input bytes (it keeps the whole input alive while held, not a copy).
pub(crate) struct ObjectReader {
pub(crate) document: Document,
decoded_count: usize,
}
impl ObjectReader {
const OBJECTS: usize = 128;
const STREAM_BYTES: usize = 8 * 1024 * 1024;
pub(crate) fn get(&mut self, reference: ObjRef) -> Arc<Object> {
self.try_get(reference).unwrap_or_else(|_| Arc::new(Object::Null))
}
pub(crate) fn try_get(&mut self, reference: ObjRef) -> Result<Arc<Object>, CosError> {
let object = self.document.try_get(reference.num);
// A missed eviction here only costs a re-parse, so never fail the read on a poisoned
// lock. The editing-cache evictors propagate instead; they guard user edits.
if let Ok(mut streams) = self.document.objstms.lock() {
// Only insertion changes this private cache between reads. Most neighboring
// objects reuse a decoded stream: do not sum the whole store for every object.
if streams.len() != self.decoded_count {
let bytes = streams.values().fold(0usize, |bytes, stream| {
bytes
.saturating_add(stream.data.capacity())
.saturating_add(stream.index.capacity().saturating_mul(std::mem::size_of::<(u32, usize)>()))
});
if bytes > Self::STREAM_BYTES || streams.len() >= Self::OBJECTS {
// Retain one oversized active stream so its next object does not require
// decoding it again. That stream still obeys the document's decode cap.
let current = match self.document.xref_entry(reference.num) {
Some(XrefEntry::InStream { stream, .. }) => Some(stream),
_ => None,
};
streams.retain(|num, _| Some(*num) == current);
}
self.decoded_count = streams.len();
}
}
// Parsed objects stay cached (an indirect /Length or a dictionary read again costs a
// lookup, not a parse) until the cache passes its object count or holds more than
// STREAM_BYTES of data of its own. Streams read from the file are views into the
// input bytes, which the document keeps anyway, so they don't count; decrypted
// streams and strings do. Checked after failed loads too: resolving a /Length may
// have cached large objects.
if let Ok(mut cache) = self.document.cache.lock() {
let data = &self.document.data;
if cache.len() >= Self::OBJECTS || cache.values().fold(0usize, |n, o| n.saturating_add(owned_bytes(o, data, 0))) > Self::STREAM_BYTES {
cache.clear();
}
}
object
}
}
/// Bytes `object` holds of its own: string and stream data that is not a view into `data`
/// (the input file), nested a few levels deep (deeper data is rare and still bounded by the
/// reader's object count).
fn owned_bytes(object: &Object, data: &Arc<Vec<u8>>, depth: usize) -> usize {
if depth > 4 {
return 0;
}
match object {
Object::String(s) => s.bytes.len(),
Object::Stream(s) => {
let own = if s.raw.shares(data) { 0 } else { s.raw.len() };
s.dict.iter().fold(own, |n, (_, v)| n.saturating_add(owned_bytes(v, data, depth + 1)))
}
Object::Array(a) => a.iter().fold(0usize, |n, v| n.saturating_add(owned_bytes(v, data, depth + 1))),
Object::Dict(d) => d.iter().fold(0usize, |n, (_, v)| n.saturating_add(owned_bytes(v, data, depth + 1))),
_ => 0,
}
}
impl std::fmt::Debug for Document {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Document").field("bytes", &self.data.len()).field("xref", &self.entries).field("edits", &self.overlay.len()).finish()
}
}
/// How an encrypted document is opened: with a password, or with the file key of a document
/// that has the same encryption (an earlier revision of the same file).
enum Credentials<'a> {
Password(Option<&'a str>),
Handler { handler: &'a Arc<pdfcraft_crypt::SecurityHandler>, id0: Vec<u8> },
}
impl Document {
/// A new document with an empty page tree (for split / extract / combine). It has no
/// original bytes, so saving always writes a full file.
pub fn new_empty() -> Self {
let mut doc = Document {
data: Arc::new(Vec::new()),
source_identity: Arc::new(()),
entries: Arc::new(XrefIndex::empty()),
trailer: Dict::new(),
revisions: Arc::new(Vec::new()),
xref_oddities: Arc::default(),
repair_log: Arc::new(Vec::new()),
cache: Arc::default(),
objstms: Arc::default(),
stream_limit: crate::object::MAX_DECODED,
overlay: BTreeMap::new(),
next_num: 1,
header_offset: 0,
version: "1.7".into(),
security: None,
encrypt_num: None,
encryption_changed: false,
full_save: false,
out_security: None,
out_encrypt_num: None,
};
let mut pages = Dict::new();
pages.set(b"Type".to_vec(), Object::name("Pages"));
pages.set(b"Kids".to_vec(), Object::Array(Vec::new()));
pages.set(b"Count".to_vec(), Object::Int(0));
let pages = doc.add(pages);
let mut catalog = Dict::new();
catalog.set(b"Type".to_vec(), Object::name("Catalog"));
catalog.set(b"Pages".to_vec(), Object::Ref(pages));
let root = doc.add(catalog);
doc.trailer.set(b"Root".to_vec(), Object::Ref(root));
doc
}
/// Parse a document. Damaged cross-reference data is reconstructed; see `repair_log`.
/// Encrypted documents open only if their user password is empty; see `open_with_password`.
pub fn open(data: Arc<Vec<u8>>) -> Result<Self, CosError> {
Self::open_with_password(data, None)
}
/// Parse a document, authenticating encrypted ones with `password` (owner or user; `None`
/// tries the empty password). Fails with `NeedsPassword` / `WrongPassword` as appropriate.
pub fn open_with_password(data: Arc<Vec<u8>>, password: Option<&str>) -> Result<Self, CosError> {
Self::open_with_stream_limit(data, password, crate::object::MAX_DECODED)
}
/// Open with a tighter decompression bound for object and cross-reference streams.
/// Inspection can use this without changing the editing reader's default limit. Such a
/// document is read-only: objects in a larger object stream read as missing, so the
/// writers refuse to save it ([`CosError::ReadOnlyLimit`]).
pub fn open_with_stream_limit(data: Arc<Vec<u8>>, password: Option<&str>, stream_limit: usize) -> Result<Self, CosError> {
Self::open_as(data, Credentials::Password(password), stream_limit)
}
/// Open `data`, an earlier revision of this file (the bytes a signature covers) or another
/// save of it, with this document's credentials: an encrypted one must have the same
/// `/Encrypt` dictionary and `/ID` as this document, whose file key is then reused, so no
/// password is needed or kept; unencrypted data opens as [`open`](Self::open) does. Data
/// encrypted differently fails with `NeedsPassword`.
pub fn open_with_same_credentials(&self, data: Arc<Vec<u8>>) -> Result<Self, CosError> {
let credentials = match &self.security {
Some(handler) => Credentials::Handler { handler, id0: self.first_id() },
None => Credentials::Password(None),
};
Self::open_as(data, credentials, self.stream_limit)
}
fn open_as(data: Arc<Vec<u8>>, credentials: Credentials<'_>, stream_limit: usize) -> Result<Self, CosError> {
// Viewers accept files whose header is missing or damaged as long as the body looks
// like PDF; so do we (a note goes to the repair log).
let header = find(&data, b"%PDF-", 0, 1024);
let headerless = header.is_none();
if headerless && find(&data, b" obj", 0, 4096).is_none() {
return Err(CosError::NotPdf);
}
let header_offset = header.unwrap_or(0);
let version = match header {
Some(h) => String::from_utf8_lossy(&data[(h + 5).min(data.len())..(h + 8).min(data.len())]).trim().to_string(),
None => "1.4".to_string(),
};
let mut doc = Document {
data,
source_identity: Arc::new(()),
entries: Arc::new(XrefIndex::empty()),
trailer: Dict::new(),
revisions: Arc::new(Vec::new()),
xref_oddities: Arc::default(),
repair_log: Arc::new(Vec::new()),
cache: Arc::default(),
objstms: Arc::default(),
stream_limit: stream_limit.min(crate::object::MAX_DECODED),
overlay: BTreeMap::new(),
next_num: 1,
header_offset,
version,
security: None,
encrypt_num: None,
encryption_changed: false,
full_save: false,
out_security: None,
out_encrypt_num: None,
};
let mut log = Vec::new();
if headerless {
log.push("the %PDF- header is missing; reading the file as PDF 1.4".into());
}
let parsed = doc.read_xref_chain(&mut log);
let mut authenticated = false;
let ok = match parsed {
Ok((entries, trailer, revisions, oddities)) => {
doc.entries = Arc::new(entries);
doc.trailer = trailer;
doc.revisions = Arc::new(revisions);
doc.xref_oddities = Arc::new(oddities);
// Authenticate before checking the catalog: it may sit in an encrypted object
// stream, which reads as garbage until the key is known.
if let Some(enc) = doc.trailer.get(b"Encrypt").cloned() {
doc.authenticate(&enc, &credentials)?;
authenticated = true;
}
doc.root_is_catalog()
}
Err(e) => {
log.push(format!("cross-reference data unreadable ({e}); rebuilding from object headers"));
false
}
};
if !ok {
if !doc.entries.is_empty() {
log.push("cross-reference data does not lead to a valid catalog; rebuilding from object headers".into());
}
doc.reconstruct(&mut log)?;
}
doc.repair_log = Arc::new(log);
// Reconstruction may have found a different trailer: (re)authenticate against it.
if (!ok || !authenticated)
&& let Some(enc) = doc.trailer.get(b"Encrypt").cloned()
{
doc.authenticate(&enc, &credentials)?;
}
if Arc::make_mut(&mut doc.entries).remove_exhausted() {
Arc::make_mut(&mut doc.repair_log).push("skipped exhausted xref object number 4294967295".into());
}
let max = doc.entries.max_num();
let size = u32::try_from(doc.trailer.int(b"Size").unwrap_or(0).max(0)).unwrap_or(u32::MAX);
if size == u32::MAX {
Arc::make_mut(&mut doc.repair_log).push("exhausted trailer /Size; new objects use unused object numbers".into());
}
doc.next_num = max.saturating_add(1).max(size).max(1);
Ok(doc)
}
fn authenticate(&mut self, enc: &Object, credentials: &Credentials<'_>) -> Result<(), CosError> {
// Authentication can change how the same input is decoded.
self.source_identity = Arc::new(());
let (num, dict) = match enc {
Object::Ref(r) => (Some(r.num), self.get(*r).as_dict().cloned()),
Object::Dict(d) => (None, Some(d.clone())),
_ => (None, None),
};
let Some(dict) = dict else {
// A dangling /Encrypt: viewers treat the file as unencrypted, and so do we.
let mut log = self.repair_log.as_ref().clone();
log.push("the trailer names an /Encrypt dictionary that does not exist; reading the file as unencrypted".into());
self.repair_log = Arc::new(log);
self.trailer.remove(b"Encrypt");
return Ok(());
};
let params = crate::security::encrypt_dict(self, &dict);
let id0 = self.first_id();
let handler = match credentials {
Credentials::Password(password) => Arc::new(pdfcraft_crypt::SecurityHandler::open(params, &id0, *password).map_err(|e| match e {
pdfcraft_crypt::CryptError::WrongPassword if password.is_none() => CosError::NeedsPassword,
pdfcraft_crypt::CryptError::WrongPassword => CosError::WrongPassword,
other => CosError::Security(other.to_string()),
})?),
// The same encryption as the document whose key this is: that key opens it.
Credentials::Handler { handler, id0: known } if *handler.dict() == params && *known == id0 => Arc::clone(handler),
Credentials::Handler { .. } => return Err(CosError::NeedsPassword),
};
self.security = Some(handler);
self.encrypt_num = num;
// Objects read while locating the catalog were read before decryption was possible.
self.cache = Arc::default();
self.objstms = Arc::default();
Ok(())
}
/// The first file identifier, part of the encryption key (empty when there is none).
fn first_id(&self) -> Vec<u8> {
match self.trailer.get(b"ID") {
Some(Object::Array(a)) => a.first().and_then(|s| s.as_string()).map(|s| s.bytes.clone()).unwrap_or_default(),
_ => Vec::new(),
}
}
/// Lift the restrictions of a document opened with its user password by entering the
/// owner (permissions) password (§7.6.4). Fails with `WrongPassword` for any other
/// password. Does nothing when the document is unencrypted or already opened as owner.
/// Both passwords unlock the same file key, so nothing read so far is decoded again.
pub fn unlock(&mut self, password: &str) -> Result<(), CosError> {
let Some(current) = self.security.as_deref() else { return Ok(()) };
if current.auth() == pdfcraft_crypt::Auth::Owner {
return Ok(());
}
let owner = current.authenticate_owner(&self.first_id(), password).map_err(|e| match e {
pdfcraft_crypt::CryptError::WrongPassword => CosError::WrongPassword,
other => CosError::Security(other.to_string()),
})?;
self.security = Some(Arc::new(owner));
Ok(())
}
/// The security handler, when the document is encrypted.
pub fn security(&self) -> Option<&pdfcraft_crypt::SecurityHandler> {
self.security.as_deref()
}
/// What the opening password allows (`None` for unencrypted documents: everything).
pub fn permissions(&self) -> Option<pdfcraft_crypt::Permissions> {
self.security().map(|s| s.permissions())
}
/// Make the next save a full rewrite: earlier revisions (which still hold what an edit
/// removed) must not stay in the file. Redaction requires this.
pub fn require_full_save(&mut self) {
self.full_save = true;
}
/// The next save must rewrite the whole file (see [`Document::require_full_save`]).
pub fn full_save_required(&self) -> bool {
self.full_save
}
/// Security was set or removed since the document was opened (the next save applies it).
pub fn encryption_changed(&self) -> bool {
self.encryption_changed
}
/// The handler used to write: the new one after `set_encryption` / `remove_encryption`,
/// otherwise the one the document was opened with.
pub(crate) fn output_security(&self) -> (Option<&pdfcraft_crypt::SecurityHandler>, Option<u32>) {
if self.encryption_changed { (self.out_security.as_deref(), self.out_encrypt_num) } else { (self.security.as_deref(), self.encrypt_num) }
}
/// The security the next save writes: protection applied with `set_encryption`, none after
/// `remove_encryption`, otherwise the security the document was opened with.
pub fn output_handler(&self) -> Option<&pdfcraft_crypt::SecurityHandler> {
self.output_security().0
}
/// Protect the document with a password (§7.6.4). Takes effect on the next save, which is
/// always a full rewrite. Returns the handler (authenticated as owner).
pub fn set_encryption(&mut self, params: &pdfcraft_crypt::NewEncryption) -> Result<(), CosError> {
// The file identifier is part of the key; make sure it exists and keep it.
let id0 = match self.trailer.get(b"ID") {
Some(Object::Array(a)) if a.len() == 2 => a[0].as_string().map(|s| s.bytes.clone()).unwrap_or_default(),
_ => {
let mut id = generated_id(&params.seed);
id.truncate(16);
let s = Object::String(crate::PdfString { bytes: id.clone(), hex: true });
self.trailer.set(b"ID".to_vec(), Object::Array(vec![s.clone(), s]));
id
}
};
let h = pdfcraft_crypt::create(params, &id0).map_err(|e| CosError::Security(e.to_string()))?;
let d = h.dict();
let s = |b: &[u8]| Object::String(crate::PdfString { bytes: b.to_vec(), hex: true });
let mut e = Dict::new();
e.set(b"Filter".to_vec(), Object::name("Standard"));
e.set(b"V".to_vec(), Object::Int(d.v));
e.set(b"R".to_vec(), Object::Int(d.r));
e.set(b"Length".to_vec(), Object::Int(d.length_bits));
e.set(b"O".to_vec(), s(&d.o));
e.set(b"U".to_vec(), s(&d.u));
e.set(b"P".to_vec(), Object::Int(i64::from(d.p)));
if d.v >= 4 {
let mut cf = Dict::new();
let mut std_cf = Dict::new();
std_cf.set(b"Type".to_vec(), Object::name("CryptFilter"));
let (cfm, len) = if d.v >= 5 { ("AESV3", 32) } else { ("AESV2", 16) };
std_cf.set(b"CFM".to_vec(), Object::name(cfm));
std_cf.set(b"Length".to_vec(), Object::Int(len));
std_cf.set(b"AuthEvent".to_vec(), Object::name("DocOpen"));
cf.set(b"StdCF".to_vec(), Object::Dict(std_cf));
e.set(b"CF".to_vec(), Object::Dict(cf));
e.set(b"StmF".to_vec(), Object::name("StdCF"));
e.set(b"StrF".to_vec(), Object::name("StdCF"));
if !d.encrypt_metadata {
e.set(b"EncryptMetadata".to_vec(), Object::Bool(false));
}
}
if d.v >= 5 {
e.set(b"OE".to_vec(), s(&d.oe));
e.set(b"UE".to_vec(), s(&d.ue));
e.set(b"Perms".to_vec(), s(&d.perms));
}
if let Some(Object::Ref(old)) = self.trailer.get(b"Encrypt").cloned() {
self.free(old);
}
let r = self.add(e);
self.trailer.set(b"Encrypt".to_vec(), Object::Ref(r));
// Objects are still read with the original handler; saves use the new one.
self.out_security = Some(Arc::new(h));
self.out_encrypt_num = Some(r.num);
self.encryption_changed = true;
Ok(())
}
/// Remove password protection (requires the document to be open — any authenticated
/// password; callers should require the owner password, §7.6.4). Applied by a full save.
pub fn remove_encryption(&mut self) {
if let Some(Object::Ref(r)) = self.trailer.get(b"Encrypt").cloned() {
self.free(r);
}
self.trailer.remove(b"Encrypt");
self.out_security = None;
self.out_encrypt_num = None;
self.encryption_changed = true;
}
/// Decrypt a freshly parsed indirect object if the document is encrypted.
fn decrypted(&self, id: ObjRef, o: Object) -> Object {
match &self.security {
Some(h) if Some(id.num) != self.encrypt_num => crate::security::transform(h, &o, id.num, id.generation, true),
_ => o,
}
}
pub fn bytes(&self) -> &Arc<Vec<u8>> {
&self.data
}
/// Identity of the original bytes, xref and authenticated decode context. Edits and
/// clones preserve it; reopening (including after any save) produces a new identity.
pub fn source_identity(&self) -> SourceIdentity {
SourceIdentity(Arc::downgrade(&self.source_identity))
}
pub fn version(&self) -> &str {
&self.version
}
pub fn trailer(&self) -> &Dict {
&self.trailer
}
pub fn trailer_mut(&mut self) -> &mut Dict {
&mut self.trailer
}
pub fn revisions(&self) -> &[Revision] {
&self.revisions
}
/// Where each revision of the file ends (oldest first): the byte after the `%%EOF` (and its
/// end-of-line) that closes its cross-reference section. `data[..end]` is that revision as it
/// was saved. A linearized file's first-page section belongs to the revision it precedes, so
/// ends only ever increase. Empty for a reconstructed file.
pub fn revision_ends(&self) -> Vec<usize> {
let d = &self.data[..];
let mut ends: Vec<usize> = Vec::with_capacity(self.revisions.len());
for r in self.revisions.iter() {
let from = (r.xref_offset as usize).saturating_add(self.header_offset).min(d.len());
let Some(at) = d[from..].windows(5).position(|w| w == b"%%EOF") else { continue };
let mut end = from + at + 5;
while end < d.len() && matches!(d[end], b'\r' | b'\n') && end - (from + at + 5) < 2 {
end += 1;
}
match ends.last_mut() {
Some(last) if end <= *last => {}
_ => ends.push(end),
}
}
ends
}
pub fn repair_log(&self) -> &[String] {
&self.repair_log
}
/// Add a line to [`Self::repair_log`]: a leniency applied while reading that the user should
/// know about (fidelity: never silent).
pub fn note_repair(&mut self, line: String) {
let mut log = self.repair_log.as_ref().clone();
log.push(line);
self.repair_log = Arc::new(log);
}
/// `true` when there are unsaved edits.
pub fn is_modified(&self) -> bool {
!self.overlay.is_empty()
}
/// Object numbers changed since the document was opened (or last saved).
pub fn modified_objects(&self) -> Vec<u32> {
self.overlay.keys().copied().collect()
}
/// Where object `num` is stored in the file (ignoring unsaved edits).
pub fn xref_entry(&self, num: u32) -> Option<XrefEntry> {
self.entries.lookup(num, &self.data)
}
/// Cross-reference entries this reader did not take at face value, as (byte offset of the
/// section, object number): an in-use entry at offset 0, a second and different entry for
/// one object in the same section, a reserved cross-reference stream entry type, or a
/// hybrid /XRefStm entry that disagrees with the table. The reader sets such an entry aside
/// and keeps what it already had for the object. Another reader, the page renderer among
/// them, may follow it instead, so the two can then show different objects for one number.
/// Code that compares revisions (signature validation) must not call such an object
/// unchanged.
pub fn xref_oddities(&self) -> &[(u64, u32)] {
&self.xref_oddities
}
/// Whether the cross-reference entry of `num` locates that object itself: its `num G obj`
/// header at exactly the recorded offset, or the member with that number at the recorded
/// position of its object stream. When it does not, [`Self::get`] still finds the object by
/// searching, but another reader may resolve the number differently. Ignores unsaved edits.
pub fn xref_entry_is_exact(&self, num: u32) -> bool {
match self.xref_entry(num) {
None | Some(XrefEntry::Free { .. }) => true,
Some(XrefEntry::InFile { offset, .. }) => {
let at = |off: usize| matches!(parse_indirect_shared(&self.data, off, &|_| None), Ok((id, _)) if id.num == num);
// Offsets may count from a header that does not start the file.
usize::try_from(offset).is_ok_and(|off| at(off) || (self.header_offset != 0 && at(off.saturating_add(self.header_offset))))
}
Some(XrefEntry::InStream { stream, index }) => {
self.objstm(stream).is_ok_and(|stm| usize::try_from(index).ok().and_then(|i| stm.index.get(i)).is_some_and(|(n, _)| *n == num))
}
}
}
/// Object `num` has an unsaved edit.
pub fn is_edited(&self, num: u32) -> bool {
self.overlay.contains_key(&num)
}
pub fn root(&self) -> Option<ObjRef> {
self.trailer.reference(b"Root")
}
// ── object access ───────────────────────────────────────────────────────────────────────
/// Fetch an object by reference. Missing objects are `Null` (ISO 32000-2 §7.3.10).
pub fn get(&self, r: ObjRef) -> Arc<Object> {
self.try_get(r.num).unwrap_or_else(|_| Arc::new(Object::Null))
}
/// Follow a reference if `o` is one; otherwise return `o` itself.
pub fn resolve(&self, o: &Object) -> Arc<Object> {
match o {
Object::Ref(r) => self.get(*r),
other => Arc::new(other.clone()),
}
}
/// Resolve and return a dictionary (a stream's dictionary counts).
pub fn dict(&self, o: &Object) -> Option<Dict> {
self.resolve(o).as_dict().cloned()
}
pub fn try_get(&self, num: u32) -> Result<Arc<Object>, CosError> {
match self.overlay.get(&num) {
Some(Slot::Set(_, o)) => return Ok(o.clone()),
Some(Slot::Freed(_)) => return Ok(Arc::new(Object::Null)),
None => {}
}
if let Some(o) = self.cache.lock().map_err(|_| CosError::Poisoned)?.get(&num) {
return Ok(o.clone());
}
// Loading can re-enter (indirect stream `/Length`, object streams); damaged files make
// cycles such as `6 0 obj << /Length 6 0 R >>`. Bound the nesting on every path.
let _guard =
LoadGuard::enter(num).ok_or_else(|| CosError::Syntax { offset: 0, detail: format!("reference cycle while loading object {num}") })?;
let obj = self.load(num, 0)?;
let obj = Arc::new(obj);
// The editing cache is bounded like the scan reader's: re-parsing a file-backed view
// is cheap, and returned objects outlive eviction.
self.cache.lock().map_err(|_| CosError::Poisoned)?.insert(num, obj.clone());
self.evict_editor_cache_if_over_budget(num, &obj)?;
Ok(obj)
}
/// Bounds for the editing cache: roomier than the scan reader's (128 objects / 8 MB)
/// so interactive editing does not thrash, but still capped so touching a large
/// document cannot pin its whole object graph. Only owned (decrypted/copied) bytes
/// count; file-backed views re-parse for free.
///
/// Worst case per document: input bytes + up to `EDITOR_CACHE_OBJECTS` ×
/// `EDITOR_LARGE_OBJECT` (1024 × 256 KB = 256 MB) of small objects between full
/// accounting passes + `EDITOR_OBJSTM_BYTES` (32 MB) + unsaved `overlay` edits.
/// The count cap always holds; byte accounting between passes is approximate.
/// Clones share these caches, so one clone's eviction drops another's hot set;
/// that only costs re-parsing, never correctness. Eviction keeps just the newcomer:
/// file-backed views re-parse cheaply and editor working sets stay far below 1024.
const EDITOR_CACHE_OBJECTS: usize = 1024;
const EDITOR_CACHE_BYTES: usize = 32 * 1024 * 1024;
/// Object streams decode to megabytes each, so fewer are kept than plain objects.
const EDITOR_OBJSTM_OBJECTS: usize = 256;
const EDITOR_OBJSTM_BYTES: usize = 32 * 1024 * 1024;
/// Newcomers above this force a full accounting pass even below the count cap:
/// `EDITOR_CACHE_BYTES / 128`, so 128 large newcomers always trigger a pass before the
/// byte budget can double.
const EDITOR_LARGE_OBJECT: usize = 256 * 1024;
fn evict_editor_cache_if_over_budget(&self, fresh_num: u32, fresh: &Object) -> Result<(), CosError> {
// Fast path: the full fold is O(cache) per insert, so skip it for a small newcomer
// below the count cap. `len` is copied out first; nothing here holds the lock.
let len = self.cache.lock().map_err(|_| CosError::Poisoned)?.len();
if len <= Self::EDITOR_CACHE_OBJECTS && owned_bytes(fresh, &self.data, 0) <= Self::EDITOR_LARGE_OBJECT {
return Ok(());
}
// Walk a snapshot off-lock; re-lock only to evict.
let live: Vec<(u32, Arc<Object>)> = self.cache.lock().map_err(|_| CosError::Poisoned)?.iter().map(|(n, o)| (*n, o.clone())).collect();
let data = &self.data;
let owned: usize = live.iter().fold(0usize, |n, (_, o)| n.saturating_add(owned_bytes(o, data, 0)));
if live.len() <= Self::EDITOR_CACHE_OBJECTS && owned <= Self::EDITOR_CACHE_BYTES {
return Ok(());
}
// Re-check under the final lock: another thread may have inserted or evicted since
// the snapshot. Keep its newcomers, and skip the eviction if back within budget.
let snapshot: std::collections::HashSet<u32> = live.into_iter().map(|(n, _)| n).collect();
let mut cache = self.cache.lock().map_err(|_| CosError::Poisoned)?;
if cache.len() <= Self::EDITOR_CACHE_OBJECTS {
let data = &self.data;
let owned: usize = cache.values().fold(0usize, |n, o| n.saturating_add(owned_bytes(o, data, 0)));
if owned <= Self::EDITOR_CACHE_BYTES {
return Ok(());
}
}
cache.retain(|n, _| *n == fresh_num || !snapshot.contains(n));
Ok(())
}
fn evict_editor_streams_if_over_budget(&self, current: u32) -> Result<(), CosError> {
// The caller inserts `current` first, so retaining it keeps a live entry.
let over = {
let streams = self.objstms.lock().map_err(|_| CosError::Poisoned)?;
let bytes = streams.values().fold(0usize, |bytes, stream| {
bytes
.saturating_add(stream.data.capacity())
.saturating_add(stream.index.capacity().saturating_mul(std::mem::size_of::<(u32, usize)>()))
});
bytes > Self::EDITOR_OBJSTM_BYTES || streams.len() > Self::EDITOR_OBJSTM_OBJECTS
};
if over {
self.objstms.lock().map_err(|_| CosError::Poisoned)?.retain(|n, _| *n == current);
}
Ok(())
}
/// Visit every current object without filling this document's editing cache.
///
/// Full-document searches (for example, standalone timestamps) need to inspect objects
/// that may never be used again. Keep their parse caches private and release them in
/// batches. Returned objects own their data and remain valid after the next batch.
pub fn scan_objects(&self) -> impl Iterator<Item = (ObjRef, Arc<Object>)> + use<> {
let numbers = self.object_numbers();
let mut reader = self.object_reader();
numbers.into_iter().map(move |num| {
let reference = ObjRef::new(num, reader.document.generation(num));
(reference, reader.get(reference))
})
}
/// Like [`Self::scan_objects`], preserving read errors for callers that must distinguish
/// incomplete discovery from an actual null object. Caches remain private and bounded.
pub fn scan_objects_checked(&self) -> impl Iterator<Item = (ObjRef, Result<Arc<Object>, CosError>)> + use<> {
self.scan_objects_subset(self.object_numbers())
}
/// Inspect selected current objects with the same bounded cache as a whole-file scan.
/// Missing/freed numbers yield `Null`; malformed objects return their read error.
pub fn scan_objects_subset(&self, numbers: Vec<u32>) -> impl Iterator<Item = (ObjRef, Result<Arc<Object>, CosError>)> + use<> {
let mut reader = self.object_reader();
numbers.into_iter().map(move |num| {
let reference = ObjRef::new(num, reader.document.generation(num));
(reference, reader.try_get(reference))
})
}
/// Inspect selected objects in the immutable source, ignoring all unsaved edits. This
/// allows a consumer to check an edit's dependencies without retaining an old document.
pub fn scan_original_objects_subset(&self, numbers: Vec<u32>) -> impl Iterator<Item = (ObjRef, Result<Arc<Object>, CosError>)> + use<> {
let mut reader = self.object_reader();
reader.document.overlay.clear();
numbers.into_iter().map(move |num| {
let reference = ObjRef::new(num, reader.document.generation(num));
(reference, reader.try_get(reference))
})
}
/// Opened with a tighter stream limit than editing uses ([`Document::open_with_stream_limit`]).
pub(crate) fn stream_limited(&self) -> bool {
self.stream_limit < crate::object::MAX_DECODED
}
pub(crate) fn object_reader(&self) -> ObjectReader {
let mut document = self.clone();
document.cache = Arc::default();
document.objstms = Arc::default();
ObjectReader { document, decoded_count: 0 }
}
fn load(&self, num: u32, depth: u32) -> Result<Object, CosError> {
if depth > 16 {
return Err(CosError::Syntax { offset: 0, detail: "reference cycle while loading".into() });
}
match self.entries.lookup(num, &self.data) {
None | Some(XrefEntry::Free { .. }) => Ok(Object::Null),
Some(XrefEntry::InFile { offset, .. }) => {
let resolve = |r: ObjRef| {
if let Some(known) = LENGTHS.with(|m| m.borrow().get(&r.num).copied()) {
return known;
}
let length = self.try_get(r.num).ok().and_then(|o| o.as_int());
LENGTHS.with(|m| m.borrow_mut().insert(r.num, length));
length
};
let off = offset as usize;
let at = |off: usize| match parse_indirect_shared(&self.data, off, &resolve) {
Ok((id, o)) if id.num == num => Some(self.decrypted(id, o)),
_ => None,
};
// Offsets relative to a shifted header, or simply wrong: try both fixes.
at(off)
.or_else(|| if self.header_offset == 0 { None } else { at(off.saturating_add(self.header_offset)) })
.or_else(|| self.scan_for(num).map(|(id, o)| self.decrypted(id, o)))
.ok_or(CosError::MissingObject(num))
}
Some(XrefEntry::InStream { stream, index }) => {
let stm = self.objstm(stream)?;
let (n, off) = stm
.index
.get(index as usize)
.copied()
.or_else(|| stm.index.iter().find(|(n, _)| *n == num).copied())
.ok_or(CosError::MissingObject(num))?;
let off =
if n == num { off } else { stm.index.iter().find(|(n, _)| *n == num).map(|(_, o)| *o).ok_or(CosError::MissingObject(num))? };
Lexer::new(&stm.data, off).object()
}
}
}
fn objstm(&self, num: u32) -> Result<Arc<ObjStm>, CosError> {
if let Some(s) = self.objstms.lock().map_err(|_| CosError::Poisoned)?.get(&num) {
return Ok(s.clone());
}
let Object::Stream(s) = &*self.try_get(num)? else { return Err(CosError::MissingObject(num)) };
let n = s.dict.int(b"N").unwrap_or(0).clamp(0, 1_000_000) as usize;
let first = s.dict.int(b"First").unwrap_or(0).max(0) as usize;
let data = s.decoded_within(self.stream_limit)?;
let mut lx = Lexer::new(&data, 0);
let mut index = Vec::with_capacity(n);
for _ in 0..n {
let (Some(Object::Int(onum)), Some(Object::Int(off))) = (lx.object().ok(), lx.object().ok()) else { break };
index.push((onum.max(0) as u32, first + off.max(0) as usize));
}
let stm = Arc::new(ObjStm { index, data: Arc::new(data) });
// Same bound as the object cache: decoded object streams can be megabytes each.
// Insert first so the retained `num` is actually in the map.
self.objstms.lock().map_err(|_| CosError::Poisoned)?.insert(num, stm.clone());
self.evict_editor_streams_if_over_budget(num)?;
Ok(stm)
}
/// Last-resort lookup: scan the file for `num G obj`.
fn scan_for(&self, num: u32) -> Option<(ObjRef, Object)> {
let needle = format!("{num} ");
let data = &self.data;
let mut found = None;
let mut i = 0;
while let Some(p) = find(data, needle.as_bytes(), i, data.len()) {
i = p + 1;
if p > 0 && !is_whitespace(data[p - 1]) {
continue;
}
if let Ok((id, o)) = parse_indirect_shared(data, p, &|_| None)
&& id.num == num
{
found = Some((id, o)); // keep the last (newest) definition
}
}
found
}
// ── editing ─────────────────────────────────────────────────────────────────────────────
/// Stream data still pointing into another document's file would keep that whole file
/// alive for as long as this edit exists: give it a buffer of its own (just its bytes).
fn adopt(&self, obj: Object) -> Object {
match obj {
Object::Stream(mut s) if s.raw.borrows_other_than(&self.data) => {
s.raw = s.raw.detached();
Object::Stream(s)
}
other => other,
}
}
/// Replace an object (keeping its generation).
pub fn set(&mut self, r: ObjRef, obj: impl Into<Object>) {
let obj = self.adopt(obj.into());
self.overlay.insert(r.num, Slot::Set(r.generation, Arc::new(obj)));
if r.num >= self.next_num {
self.next_num = r.num.saturating_add(1);
}
}
/// Add a new indirect object and return its reference.
pub fn add(&mut self, obj: impl Into<Object>) -> ObjRef {
let r = ObjRef::new(self.allocate_num(), 0);
let obj = self.adopt(obj.into());
self.overlay.insert(r.num, Slot::Set(0, Arc::new(obj)));
r
}
/// Find a never-used number, retaining the high-water mark for incremental /Size.
/// Exhausted hints fall back to gaps without replacing source or overlay objects.
fn allocate_num(&mut self) -> u32 {
let mut num = if self.next_num == u32::MAX { 1 } else { self.next_num.max(1) };
while self.entries.lookup(num, &self.data).is_some() || self.overlay.contains_key(&num) {
num = if num == u32::MAX - 1 { 1 } else { num + 1 };
}
self.next_num = self.next_num.max(num.saturating_add(1));
num
}
/// Delete an object (its number becomes free with the next generation).
pub fn free(&mut self, r: ObjRef) {
self.overlay.insert(r.num, Slot::Freed(r.generation.saturating_add(1)));
}
/// Record `current`'s object graph as unsaved edits of this document.
///
/// Used when the working snapshot was restored from an earlier revision and the next
/// incremental save must still append to this file (the last saved revision). Objects
/// that already match are left untouched. Reads go through private readers so neither
/// document's editing cache is filled.
pub fn overlay_changes_from(&mut self, current: &Document) -> Result<(), CosError> {
let saved_nums = self.object_numbers();
let current_nums = current.object_numbers();
let mut freed = Vec::new();
let mut changed = Vec::new();
{
let mut saved_reader = self.object_reader();
let mut current_reader = current.object_reader();
for &num in &saved_nums {
if current_nums.binary_search(&num).is_err() {
freed.push(num);
}
}
for &num in &current_nums {
let generation = current.generation(num);
let same = saved_nums.binary_search(&num).is_ok()
&& self.generation(num) == generation
&& saved_reader.try_get(ObjRef::new(num, 0))? == current_reader.try_get(ObjRef::new(num, 0))?;
if !same {
changed.push(num);
}
}
}
for num in freed {
let generation = self.generation(num);
self.free(ObjRef::new(num, generation));
}
let mut current_reader = current.object_reader();
for num in changed {
let generation = current.generation(num);
let object = current_reader.try_get(ObjRef::new(num, 0))?;
self.set(ObjRef::new(num, generation), (*object).clone());
}
*self.trailer_mut() = current.trailer().clone();
Ok(())
}
/// Edit a dictionary (or stream dictionary) object in place.
pub fn update_dict(&mut self, r: ObjRef, f: impl FnOnce(&mut Dict)) -> Result<(), CosError> {
let mut obj = (*self.get(r)).clone();
let d = obj.as_dict_mut().ok_or(CosError::NotADictionary(r))?;
f(d);
self.set(r, obj);
Ok(())
}
/// Current generation of an object number.
pub fn generation(&self, num: u32) -> u16 {
match self.overlay.get(&num) {
Some(Slot::Set(g, _)) => *g,
Some(Slot::Freed(g)) => *g,
None => match self.entries.lookup(num, &self.data) {
Some(XrefEntry::InFile { generation, .. }) => generation,
Some(XrefEntry::Free { next_generation }) => next_generation,
_ => 0,
},
}
}
pub(crate) fn overlay_entries(&self) -> impl Iterator<Item = (u32, u16, Option<Arc<Object>>)> + '_ {
self.overlay.iter().map(|(n, s)| match s {
Slot::Set(g, o) => (*n, *g, Some(o.clone())),
Slot::Freed(g) => (*n, *g, None),
})
}
pub(crate) fn next_num(&self) -> u32 {
self.next_num
}
/// All object numbers known (file and overlay), excluding freed ones.
pub fn object_numbers(&self) -> Vec<u32> {
let source_numbers = self.entries.object_numbers(&self.data);
let mut numbers = Vec::with_capacity(source_numbers.len().max(self.overlay.len()));
let mut source = source_numbers.into_iter().peekable();
for (&num, slot) in &self.overlay {
while source.peek().is_some_and(|n| *n < num) {
if let Some(n) = source.next() {
numbers.push(n);
}
}
if source.peek().is_some_and(|n| *n == num) {
source.next();
}
if matches!(slot, Slot::Set(..)) {
numbers.push(num);
}
}
numbers.extend(source);
numbers
}
/// Rebase this document on bytes written by the writer (after a save): edits become part of
/// the file and the overlay is cleared, so the next incremental save appends only new edits.
pub fn reopen_after_save(&self, bytes: Arc<Vec<u8>>) -> Result<Self, CosError> {
Document::open(bytes)
}
// ── cross-reference parsing ─────────────────────────────────────────────────────────────
fn root_is_catalog(&self) -> bool {
let Some(r) = self.root() else { return false };
matches!(self.try_get(r.num).as_deref(), Ok(Object::Dict(d)) if d.name(b"Type") == Some(b"Catalog") || d.contains(b"Pages"))
}
#[allow(clippy::type_complexity)]
fn read_xref_chain(&self, log: &mut Vec<String>) -> Result<(XrefIndex, Dict, Vec<Revision>, Vec<(u64, u32)>), CosError> {
let mut compact_log = Vec::new();
if let Ok(Some((index, trailer, revisions))) = self.read_compact_xref_chain(&mut compact_log) {
let size = trailer.int(b"Size").unwrap_or(0).max(0);
if size > 0 && size <= i64::from(u32::MAX) && u64::from(index.max_num()) < size as u64 {
log.extend(compact_log);
// The compact reader declines any section with set-aside entries, so there are none here.
return Ok((index, trailer, revisions, Vec::new()));
}
}
let (entries, trailer, revisions, oddities) = self.read_xref_chain_materialized(log)?;
Ok((XrefIndex::Materialized(Arc::new(entries)), trailer, revisions, oddities))
}
fn read_compact_xref_chain(&self, log: &mut Vec<String>) -> Result<Option<(XrefIndex, Dict, Vec<Revision>)>, CosError> {
let data = &self.data;
let tail_start = data.len().saturating_sub(4096);
let sx = rfind(data, b"startxref", tail_start).ok_or(CosError::Syntax { offset: data.len(), detail: "no startxref".into() })?;
let mut lx = Lexer::new(data, sx + 9);
let first = match lx.object() {
Ok(Object::Int(n)) if n >= 0 => n as u64,
_ => return Err(CosError::Syntax { offset: sx, detail: "bad startxref value".into() }),
};
let mut sections = Vec::new();
let mut retained_rows = 0usize;
let mut retained_bytes = 0usize;
let mut trailer: Option<Dict> = None;
let mut revisions = Vec::new();
let mut visited = HashSet::new();
let mut next = Some(first);
while let Some(off) = next.take() {
if !visited.insert(off) || visited.len() > 10_000 {
log.push(format!("cross-reference chain loops at offset {off}; stopped"));
break;
}
let (primary, sect_trailer, is_stream) = match self.read_compact_section(off as usize) {
Ok(Some(v)) => v,
Ok(None) => return Ok(None),
Err(e) if self.header_offset > 0 => {
log.push(format!("xref at {off} unreadable ({e}); retrying relative to the header"));
match self.read_compact_section(off as usize + self.header_offset)? {
Some(v) => v,
None => return Ok(None),
}
}
// An unreadable older section loses every object only it locates: the
// materialized reader logs it and the file is rebuilt from object headers (#1656).
Err(e) => return Err(e),
};
revisions.push(Revision { xref_offset: off, is_stream });
if primary.renumbered {
log.push(format!("xref subsection at {off} is numbered from 1 but begins with object 0's entry; read from 0"));
}
let Some(rows) = retained_rows.checked_add(primary.row_count).filter(|rows| *rows <= MAX_COMPACT_XREF_ROWS) else { return Ok(None) };
let Some(bytes) = retained_bytes.checked_add(primary.retained_bytes()).filter(|bytes| *bytes <= MAX_COMPACT_XREF_BYTES) else {
return Ok(None);
};
retained_rows = rows;
retained_bytes = bytes;
let hybrid = if let Some(x) = sect_trailer.int(b"XRefStm").filter(|x| *x >= 0) {
match self.read_compact_section(x as usize) {
Ok(Some((section, _, _))) => {
let Some(rows) = retained_rows.checked_add(section.row_count).filter(|rows| *rows <= MAX_COMPACT_XREF_ROWS) else {
return Ok(None);
};
let Some(bytes) = retained_bytes.checked_add(section.retained_bytes()).filter(|bytes| *bytes <= MAX_COMPACT_XREF_BYTES)
else {
return Ok(None);
};
retained_rows = rows;
retained_bytes = bytes;
Some(section)
}
Ok(None) => return Ok(None),
Err(e) => {
log.push(format!("hybrid /XRefStm at {x} unreadable: {e}"));
None
}
}
} else {
None
};
if let Some(hybrid) = &hybrid {
// A hybrid entry that disagrees with the table is set aside by the materialized reader.
let mut numbers = Vec::new();
hybrid.extend_numbers(&mut numbers);
for num in numbers {
let Some(entry) = hybrid.lookup(num, &self.data) else { continue };
if primary.lookup(num, &self.data).is_some_and(|old| !matches!(old, XrefEntry::Free { .. }) && old != entry) {
return Ok(None);
}
}
}
sections.push(XrefRevisionSection { primary, hybrid });
next = sect_trailer.int(b"Prev").filter(|p| *p >= 0).map(|p| p as u64);
if trailer.is_none() {
trailer = Some(sect_trailer);
}
}
revisions.reverse();
let mut trailer = trailer.ok_or(CosError::Syntax { offset: 0, detail: "no trailer".into() })?;
for k in [&b"Prev"[..], b"XRefStm", b"Type", b"W", b"Index", b"Filter", b"DecodeParms", b"Length"] {
trailer.remove(k);
}
let mut max_num = 0;
let mut has_entries = false;
for revision in &sections {
max_num = max_num.max(revision.primary.max_num);
has_entries |= revision.primary.has_entries;
if let Some(hybrid) = &revision.hybrid {
max_num = max_num.max(hybrid.max_num);
has_entries |= hybrid.has_entries;
}
}
Ok(Some((XrefIndex::Compact { revisions: sections, max_num, has_entries }, trailer, revisions)))
}
fn read_compact_section(&self, off: usize) -> Result<Option<(XrefSection, Dict, bool)>, CosError> {
let data = &self.data;
let mut lx = Lexer::new(data, off);
if lx.eat_keyword(b"xref") {
let mut ranges = Vec::new();
let mut layout_ok = true;
let mut max_num = 0;
let mut has_entries = false;
let mut previous_end = None;
let mut row_count = 0usize;
let mut renumbered = false;
loop {
lx.skip_ws();
if lx.eat_keyword(b"trailer") {
let t = lx.object()?;
let d = t.as_dict().cloned().ok_or(CosError::Syntax { offset: lx.pos, detail: "trailer is not a dictionary".into() })?;
if !layout_ok {
return Ok(None);
}
return Ok(Some((XrefSection { data: XrefSectionData::Table, ranges, row_count, max_num, has_entries, renumbered }, d, false)));
}
let (Ok(Object::Int(start)), Ok(Object::Int(count))) = (lx.object(), lx.object()) else {
return Err(CosError::Syntax { offset: lx.pos, detail: "bad xref subsection header".into() });
};
if !valid_xref_range(start, count) || count > 10_000_000 {
return Err(CosError::Syntax { offset: lx.pos, detail: "invalid xref object range".into() });
}
let shifted = subsection_start(data, lx.pos, start, count);
renumbered |= shifted != start;
let start = shifted;
let Some(next_rows) = usize::try_from(count).ok().and_then(|count| row_count.checked_add(count)) else { return Ok(None) };
if next_rows > MAX_COMPACT_XREF_ROWS {
return Ok(None);
}
row_count = next_rows;
let mut range_offset = None;
let mut stride = None;
let mut previous_record = None;
for i in 0..count {
lx.skip_ws();
let record = lx.pos;
if range_offset.is_none() {
range_offset = Some(record);
}
if let Some(previous) = previous_record {
let Some(delta) = record.checked_sub(previous) else {
layout_ok = false;
continue;
};
if stride.is_some_and(|known| known != delta) {
layout_ok = false;
}
stride.get_or_insert(delta);
}
previous_record = Some(record);
let o = lx.token();
lx.skip_ws();
let _generation = lx.token();
lx.skip_ws();
let kind = lx.token();
let num = (start + i) as u32;
let offset = std::str::from_utf8(o).ok().and_then(|s| s.parse::<u64>().ok());
match (kind, offset) {
(b"n", Some(offset)) if offset != 0 => {
has_entries = true;
max_num = max_num.max(num);
}
(b"n", Some(_)) => {
// Set aside by the materialized reader, which records it.
if num != 0 {
return Ok(None);
}
}
(b"f", _) => {
has_entries = true;
max_num = max_num.max(num);
}
_ => return Err(CosError::Syntax { offset: lx.pos, detail: "bad xref entry".into() }),
}
}
if count > 0 {
let range_start = start as u32;
let range_count = count as u32;
let end = u64::from(range_start) + u64::from(range_count);
if previous_end.is_some_and(|previous| u64::from(range_start) < previous) {
layout_ok = false;
}
previous_end = Some(end);
ranges.push(XrefRange {
start: range_start,
count: range_count,
offset: range_offset.unwrap_or(lx.pos),
stride: stride.unwrap_or(1),
});
}
}
}
let (_, obj) = parse_indirect(data, off, &|_| None)?;
let Object::Stream(stream) = obj else { return Err(CosError::Syntax { offset: off, detail: "expected xref table or stream".into() }) };
if stream.dict.name(b"Type") != Some(b"XRef") {
return Err(CosError::Syntax { offset: off, detail: "stream at startxref is not /Type /XRef".into() });
}
let w: Vec<usize> = stream
.dict
.get(b"W")
.and_then(Object::as_array)
.map(|a| a.iter().map(|x| x.as_int().unwrap_or(0).clamp(0, 8) as usize).collect())
.unwrap_or_default();
if w.len() < 3 {
return Err(CosError::Syntax { offset: off, detail: "xref stream /W invalid".into() });
}
let widths = [w[0], w[1], w[2]];
let row = widths.iter().sum::<usize>();
if row == 0 {
return Err(CosError::Syntax { offset: off, detail: "xref stream row width 0".into() });
}
let size = stream.dict.int(b"Size").unwrap_or(0).max(0);
let index: Vec<i64> = match stream.dict.get(b"Index").and_then(Object::as_array) {
Some(a) => a.iter().filter_map(Object::as_int).collect(),
None => vec![0, size],
};
let mut requested_rows = 0usize;
let mut previous_end = None;
for pair in index.chunks(2) {
let [start, count] = pair else { break };
if !valid_xref_range(*start, *count) {
return Err(CosError::Syntax { offset: off, detail: "invalid xref object range".into() });
}
let Ok(count) = usize::try_from(*count) else { return Ok(None) };
let Some(next) = requested_rows.checked_add(count).filter(|rows| *rows <= MAX_COMPACT_XREF_ROWS) else { return Ok(None) };
if count > 0 {
let end = (*start as u64).saturating_add(count as u64);
if previous_end.is_some_and(|previous| (*start as u64) < previous) {
return Ok(None);
}
previous_end = Some(end);
}
requested_rows = next;
}
if requested_rows.checked_mul(row).is_none_or(|bytes| bytes > MAX_COMPACT_XREF_BYTES) {
return Ok(None);
}
let raw = stream.decoded_within(MAX_XREF_OBJECTS.saturating_mul(row).min(self.stream_limit).min(MAX_COMPACT_XREF_BYTES))?;
if raw.capacity() > MAX_COMPACT_XREF_BYTES {
return Ok(None);
}
let mut ranges = Vec::new();
let mut row_start = 0usize;
let available_rows = raw.len() / row;
for pair in index.chunks(2) {
let [start, count] = pair else { break };
let range_start = *start as u32;
let Ok(requested) = usize::try_from(*count) else { return Ok(None) };
let range_count = requested.min(available_rows.saturating_sub(row_start));
if range_count > 0 {
let Ok(range_count_u32) = u32::try_from(range_count) else { return Ok(None) };
let Some(byte_offset) = row_start.checked_mul(row) else { return Ok(None) };
ranges.push(XrefRange { start: range_start, count: range_count_u32, offset: byte_offset, stride: row });
}
let Some(next) = row_start.checked_add(range_count) else { return Ok(None) };
row_start = next;
}
// Rows the materialized reader sets aside (reserved types, offset 0) are left to it.
for range in &ranges {
for i in 0..range.count as usize {
let at = range.offset + i * row;
let Some(r) = raw.get(at..at + row) else { break };
let field = |from: usize, width: usize, default: u64| -> u64 {
if width == 0 {
return default;
}
r[from..from + width].iter().fold(0u64, |acc, byte| acc << 8 | u64::from(*byte))
};
let kind = field(0, widths[0], 1);
let offset = field(widths[0], widths[1], 0);
if kind > 2 || (kind == 1 && offset == 0 && range.start + i as u32 != 0) {
return Ok(None);
}
}
}
let max_num = ranges.iter().filter_map(|range| range.start.checked_add(range.count.saturating_sub(1))).max().unwrap_or(0);
let row_count = row_start;
let section = XrefSection {
data: XrefSectionData::Stream { bytes: Arc::new(raw), widths },
ranges,
row_count,
max_num,
has_entries: false,
renumbered: false,
};
let has_entries = section.has_any_entry();
let section = XrefSection { has_entries, ..section };
Ok(Some((section, stream.dict.clone(), true)))
}
#[allow(clippy::type_complexity)]
fn read_xref_chain_materialized(
&self,
log: &mut Vec<String>,
) -> Result<(BTreeMap<u32, XrefEntry>, Dict, Vec<Revision>, Vec<(u64, u32)>), CosError> {
let data = &self.data;
let tail_start = data.len().saturating_sub(4096);
let sx = rfind(data, b"startxref", tail_start).ok_or(CosError::Syntax { offset: data.len(), detail: "no startxref".into() })?;
let mut lx = Lexer::new(data, sx + 9);
let first = match lx.object() {
Ok(Object::Int(n)) if n >= 0 => n as u64,
_ => return Err(CosError::Syntax { offset: sx, detail: "bad startxref value".into() }),
};
let mut entries = BTreeMap::new();
let mut trailer: Option<Dict> = None;
let mut revisions = Vec::new();
let mut oddities = Vec::new();
let mut visited = HashSet::new();
let mut next = Some(first);
while let Some(off) = next.take() {
if !visited.insert(off) || visited.len() > 10_000 {
log.push(format!("cross-reference chain loops at offset {off}; stopped"));
break;
}
// This section's own entries; older sections only fill what newer ones left out.
let mut sect = BTreeMap::new();
// Object numbers whose entry in this section was set aside ([`Self::xref_oddities`]).
let mut odd = Vec::new();
let (sect_trailer, is_stream) = match self.read_section(off as usize, &mut sect, &mut odd, log) {
Ok(v) => v,
Err(e) if self.header_offset > 0 => {
log.push(format!("xref at {off} unreadable ({e}); retrying relative to the header"));
sect.clear();
odd.clear();
self.read_section(off as usize + self.header_offset, &mut sect, &mut odd, log)?
}
Err(e) => {
// An unreadable older section loses every object only it locates (the page
// tree, often): rebuild from object headers, as readers do (#1656).
if !revisions.is_empty() {
log.push(format!("older cross-reference section at {off} is unreadable: {e}"));
}
return Err(e);
}
};
revisions.push(Revision { xref_offset: off, is_stream });
// Hybrid files (§7.5.8.4): the /XRefStm entries supplement the table and replace the
// free entries it gives objects only a cross-reference stream can locate.
if let Some(x) = sect_trailer.int(b"XRefStm").filter(|x| *x >= 0) {
let mut stm = BTreeMap::new();
match self.read_section(x as usize, &mut stm, &mut odd, log) {
Ok(_) => {
for (num, e) in stm {
match sect.get(&num) {
None | Some(XrefEntry::Free { .. }) => {
sect.insert(num, e);
}
// The table already places the object somewhere else.
Some(old) if *old != e => odd.push(num),
Some(_) => {}
}
}
}
Err(e) => log.push(format!("hybrid /XRefStm at {x} unreadable: {e}")),
}
}
oddities.extend(odd.into_iter().map(|num| (off, num)));
for (num, e) in sect {
entries.entry(num).or_insert(e);
}
next = sect_trailer.int(b"Prev").filter(|p| *p >= 0).map(|p| p as u64);
if trailer.is_none() {
trailer = Some(sect_trailer);
}
}
revisions.reverse();
let mut trailer = trailer.ok_or(CosError::Syntax { offset: 0, detail: "no trailer".into() })?;
for k in [&b"Prev"[..], b"XRefStm", b"Type", b"W", b"Index", b"Filter", b"DecodeParms", b"Length"] {
trailer.remove(k);
}
Ok((entries, trailer, revisions, oddities))
}
/// Read one section at `off`, adding entries not already known (newer sections win). `odd`
/// collects the object numbers whose entry here is set aside ([`Self::xref_oddities`]).
fn read_section(
&self,
off: usize,
entries: &mut BTreeMap<u32, XrefEntry>,
odd: &mut Vec<u32>,
log: &mut Vec<String>,
) -> Result<(Dict, bool), CosError> {
let data = &self.data;
let mut lx = Lexer::new(data, off);
if lx.eat_keyword(b"xref") {
loop {
lx.skip_ws();
if lx.eat_keyword(b"trailer") {
let t = lx.object()?;
let d = t.as_dict().cloned().ok_or(CosError::Syntax { offset: lx.pos, detail: "trailer is not a dictionary".into() })?;
return Ok((d, false));
}
let (Ok(Object::Int(start)), Ok(Object::Int(count))) = (lx.object(), lx.object()) else {
return Err(CosError::Syntax { offset: lx.pos, detail: "bad xref subsection header".into() });
};
if !valid_xref_range(start, count) || count > 10_000_000 {
return Err(CosError::Syntax { offset: lx.pos, detail: "invalid xref object range".into() });
}
let shifted = subsection_start(data, lx.pos, start, count);
if shifted != start {
log.push(format!("xref subsection at {off} is numbered from 1 but begins with object 0's entry; read from 0"));
}
let start = shifted;
for i in 0..count {
lx.skip_ws();
let o = lx.token();
lx.skip_ws();
let g = lx.token();
lx.skip_ws();
let kind = lx.token();
let num = (start + i) as u32;
let offset = std::str::from_utf8(o).ok().and_then(|s| s.parse::<u64>().ok());
let generation = std::str::from_utf8(g).ok().and_then(|s| s.parse::<u32>().ok()).unwrap_or(0).min(u16::MAX as u32) as u16;
let entry = match (kind, offset) {
(b"n", Some(offset)) => XrefEntry::InFile { offset, generation },
(b"f", _) => XrefEntry::Free { next_generation: generation },
_ => return Err(CosError::Syntax { offset: lx.pos, detail: "bad xref entry".into() }),
};
// Offset 0 "in use" entries are a known producer bug: ignore them. (Object 0
// is never a real object, so its entry cannot mislead anyone.)
if matches!(entry, XrefEntry::InFile { offset: 0, .. }) {
if num != 0 {
odd.push(num);
}
continue;
}
if *entries.entry(num).or_insert(entry) != entry {
odd.push(num);
}
}
}
}
// Cross-reference stream.
let (_, obj) = parse_indirect(data, off, &|_| None)?;
let Object::Stream(s) = obj else { return Err(CosError::Syntax { offset: off, detail: "expected xref table or stream".into() }) };
if s.dict.name(b"Type") != Some(b"XRef") {
return Err(CosError::Syntax { offset: off, detail: "stream at startxref is not /Type /XRef".into() });
}
let w: Vec<usize> = s
.dict
.get(b"W")
.and_then(Object::as_array)
.map(|a| a.iter().map(|x| x.as_int().unwrap_or(0).clamp(0, 8) as usize).collect())
.unwrap_or_default();
if w.len() < 3 {
return Err(CosError::Syntax { offset: off, detail: "xref stream /W invalid".into() });
}
let size = s.dict.int(b"Size").unwrap_or(0).max(0);
let index: Vec<i64> = match s.dict.get(b"Index").and_then(Object::as_array) {
Some(a) => a.iter().filter_map(Object::as_int).collect(),
None => vec![0, size],
};
let row = w[0] + w[1] + w[2];
if row == 0 {
return Err(CosError::Syntax { offset: off, detail: "xref stream row width 0".into() });
}
// One row per object, and a document has at most 8,388,607 indirect objects (the classic
// implementation limit): decoding more than that (about 200 MB at the widest rows) is a
// decompression bomb, not cross-reference data. The section is then reconstructed.
let raw = s.decoded_within(MAX_XREF_OBJECTS.saturating_mul(row).min(self.stream_limit))?;
let field = |r: &[u8], from: usize, len: usize, default: u64| -> u64 {
if len == 0 {
return default;
}
r[from..from + len].iter().fold(0u64, |acc, b| acc << 8 | *b as u64)
};
let mut rows = raw.chunks_exact(row);
for pair in index.chunks(2) {
let [start, count] = pair else { break };
if !valid_xref_range(*start, *count) {
return Err(CosError::Syntax { offset: off, detail: "invalid xref object range".into() });
}
for i in 0..*count {
let Some(r) = rows.next() else { break };
let t = field(r, 0, w[0], 1);
let a = field(r, w[0], w[1], 0);
let b = field(r, w[0] + w[1], w[2], 0);
let num = (*start + i) as u32;
let entry = match t {
0 => XrefEntry::Free { next_generation: b.min(u16::MAX as u64) as u16 },
1 => XrefEntry::InFile { offset: a, generation: b.min(u16::MAX as u64) as u16 },
2 => XrefEntry::InStream { stream: a.min(u32::MAX as u64) as u32, index: b.min(u32::MAX as u64) as u32 },
// Reserved types are treated as null references (§7.5.8.3).
_ => {
odd.push(num);
continue;
}
};
if matches!(entry, XrefEntry::InFile { offset: 0, .. }) {
if num != 0 {
odd.push(num);
}
continue;
}
if *entries.entry(num).or_insert(entry) != entry {
odd.push(num);
}
}
}
Ok((s.dict.clone(), true))
}
/// Rebuild the cross-reference index by scanning for `N G obj` headers (§7.5.4 recovery).
fn reconstruct(&mut self, log: &mut Vec<String>) -> Result<(), CosError> {
let data = self.data.clone();
let mut entries: BTreeMap<u32, XrefEntry> = BTreeMap::new();
let mut i = 0;
let mut objstms = Vec::new();
while i < data.len() {
// Object headers start at a line start (or after whitespace).
let at_start = i == 0 || is_whitespace(data[i - 1]) || data[i - 1] == b'>';
if at_start && data[i].is_ascii_digit() {
let mut lx = Lexer::new(&data, i);
let n = lx.token();
lx.skip_ws();
let g = lx.token();
if !n.is_empty() && !g.is_empty() && n.iter().all(u8::is_ascii_digit) && g.iter().all(u8::is_ascii_digit) && lx.eat_keyword(b"obj") {
let num = std::str::from_utf8(n).ok().and_then(|s| s.parse::<u32>().ok());
let generation = std::str::from_utf8(g).ok().and_then(|s| s.parse::<u16>().ok()).unwrap_or(0);
if let Some(num) = num {
entries.insert(num, XrefEntry::InFile { offset: i as u64, generation });
objstms.push(num);
}
i = lx.pos;
continue;
}
}
i += 1;
}
if entries.is_empty() {
return Err(CosError::Syntax { offset: 0, detail: "no objects found".into() });
}
self.entries = Arc::new(XrefIndex::Materialized(Arc::new(entries.clone())));
self.cache.lock().map_err(|_| CosError::Poisoned)?.clear();
// Objects inside object streams.
for num in objstms {
if let Ok(o) = self.try_get(num)
&& let Object::Stream(s) = &*o
&& s.dict.name(b"Type") == Some(b"ObjStm")
&& let Ok(stm) = self.objstm(num)
{
for (idx, (inner, _)) in stm.index.iter().enumerate() {
entries.entry(*inner).or_insert(XrefEntry::InStream { stream: num, index: idx as u32 });
}
}
}
let object_count = entries.len();
self.entries = Arc::new(XrefIndex::Materialized(Arc::new(entries)));
self.cache.lock().map_err(|_| CosError::Poisoned)?.clear();
// Trailer: the last `trailer` dictionary with a usable /Root, else the catalog itself.
let mut trailer = None;
let mut from = 0;
while let Some(p) = find(&data, b"trailer", from, data.len()) {
from = p + 7;
let mut lx = Lexer::new(&data, from);
if let Ok(Object::Dict(d)) = lx.object()
&& d.reference(b"Root").is_some_and(|r| matches!(self.try_get(r.num).as_deref(), Ok(Object::Dict(_))))
{
trailer = Some(d);
}
}
let trailer = match trailer {
Some(t) => t,
None => {
let catalog = self
.entries
.object_numbers(&self.data)
.into_iter()
.find(|n| matches!(self.try_get(*n).as_deref(), Ok(Object::Dict(d)) if d.name(b"Type") == Some(b"Catalog")));
let Some(c) = catalog else { return Err(CosError::Syntax { offset: 0, detail: "no document catalog found".into() }) };
let mut t = Dict::new();
t.set(b"Root".to_vec(), Object::Ref(ObjRef::new(c, self.generation(c))));
t
}
};
let mut trailer = trailer;
for k in [&b"Prev"[..], b"XRefStm"] {
trailer.remove(k);
}
self.trailer = trailer;
self.revisions = Arc::new(Vec::new());
log.push(format!("rebuilt cross-reference index from {object_count} object headers"));
Ok(())
}
}
pub(crate) fn find(hay: &[u8], needle: &[u8], from: usize, to: usize) -> Option<usize> {
let to = to.min(hay.len());
if from >= to || needle.is_empty() || to - from < needle.len() {
return None;
}
hay[from..to].windows(needle.len()).position(|w| w == needle).map(|p| p + from)
}
fn rfind(hay: &[u8], needle: &[u8], from: usize) -> Option<usize> {
if hay.len() < needle.len() {
return None;
}
(from..=hay.len() - needle.len()).rev().find(|&i| &hay[i..i + needle.len()] == needle)
}
/// A file identifier derived from entropy supplied by the caller.
fn generated_id(seed: &[u8; 32]) -> Vec<u8> {
use std::hash::{Hash, Hasher};
let mut out = Vec::new();
for i in 0..2u8 {
let mut h = std::collections::hash_map::DefaultHasher::new();
(seed, i, b"pdfcraft id").hash(&mut h);
out.extend_from_slice(&h.finish().to_be_bytes());
}
out
}
/// Object numbers must fit the representation used by references and the xref map.
/// Validate the whole subsection before adding entries, so neither arithmetic overflow nor
/// truncation can turn a damaged range into entries for unrelated objects.
/// The object number a classic xref subsection really starts at. A common producer bug heads the
/// table `1 n` and then writes object 0's free entry (`0000000000 65535 f`) as its first row, so
/// every row would be read one number too high and object 1 as free. Such a subsection is read
/// from 0 when its second row, read that way, locates an object that is indeed numbered 1. `rows`
/// is the position just after the subsection header.
fn subsection_start(data: &[u8], rows: usize, start: i64, count: i64) -> i64 {
if start != 1 || count < 2 {
return start;
}
let mut lx = Lexer::new(data, rows);
let mut row = || {
lx.skip_ws();
let offset = std::str::from_utf8(lx.token()).ok().and_then(|s| s.parse::<u64>().ok());
lx.skip_ws();
let generation = std::str::from_utf8(lx.token()).ok().and_then(|s| s.parse::<u32>().ok());
lx.skip_ws();
(offset, generation, lx.token() == b"f")
};
let (Some(0), Some(65535), true) = row() else { return start };
let (Some(second), _, false) = row() else { return start };
let Some(at) = usize::try_from(second).ok().filter(|at| *at < data.len()) else { return start };
let mut obj = Lexer::new(data, at);
match (obj.object(), obj.object()) {
(Ok(Object::Int(1)), Ok(Object::Int(_))) if obj.eat_keyword(b"obj") => 0,
_ => start,
}
}
fn valid_xref_range(start: i64, count: i64) -> bool {
u32::try_from(start).is_ok() && count >= 0 && start.checked_add(count).is_some_and(|end| end <= i64::from(u32::MAX) + 1)
}
/// The most indirect objects a document can have (the classic implementation limit, ISO
/// 32000-1 Annex C): it bounds how much data a cross-reference stream can hold.
const MAX_XREF_OBJECTS: usize = 8_388_607;
#[cfg(test)]
mod tests {
use super::*;
/// Build a classic-xref file from object bodies (object i+1 = bodies[i]).
pub(crate) fn build(bodies: &[&str], trailer: &str) -> Vec<u8> {
let mut out = b"%PDF-1.7\n".to_vec();
let mut offsets = Vec::new();
for (i, b) in bodies.iter().enumerate() {
offsets.push(out.len());
out.extend_from_slice(format!("{} 0 obj\n{b}\nendobj\n", i + 1).as_bytes());
}
let xref = out.len();
out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", bodies.len() + 1).as_bytes());
for o in offsets {
out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
}
out.extend_from_slice(format!("trailer\n<< /Size {} {trailer} >>\nstartxref\n{xref}\n%%EOF\n", bodies.len() + 1).as_bytes());
out
}
fn assert_compact_matches_materialized(doc: &Document) {
assert!(matches!(doc.entries.as_ref(), XrefIndex::Compact { .. }), "valid xrefs should not retain a per-entry map");
let (entries, _, revisions, _) = doc.read_xref_chain_materialized(&mut Vec::new()).unwrap();
assert_eq!(revisions.as_slice(), doc.revisions());
let max = entries.keys().next_back().copied().unwrap_or(0);
assert_eq!(doc.entries.max_num(), max);
for num in 0..=max {
assert_eq!(doc.xref_entry(num), entries.get(&num).copied(), "object {num}");
}
let expected: Vec<_> = entries.iter().filter_map(|(&num, entry)| (!matches!(entry, XrefEntry::Free { .. })).then_some(num)).collect();
assert_eq!(doc.object_numbers(), expected);
}
#[test]
fn compact_xref_tables_streams_and_revisions_match_the_materialized_reader() {
let classic = Document::open(Arc::new(build(
&["<< /Type /Catalog /Pages 2 0 R >>", "<< /Type /Pages /Kids [] /Count 0 >>", "<< /Value 9 >>"],
"/Root 1 0 R",
)))
.unwrap();
assert_compact_matches_materialized(&classic);
let mut sparse = b"%PDF-1.7\n".to_vec();
let root_offset = sparse.len();
sparse.extend_from_slice(b"1 0 obj\n<< /Type /Catalog /Pages 5 0 R >>\nendobj\n");
let pages_offset = sparse.len();
sparse.extend_from_slice(b"5 0 obj\n<< /Type /Pages /Kids [] /Count 0 >>\nendobj\n");
let xref = sparse.len();
sparse.extend_from_slice(
format!(
"xref\n0 1\n0000000000 65535 f \n1 1\n{root_offset:010} 00000 n \n5 1\n{pages_offset:010} 00000 n \ntrailer\n<< /Size 6 /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n"
)
.as_bytes(),
);
let sparse = Document::open(Arc::new(sparse)).unwrap();
assert_eq!(sparse.object_numbers(), vec![1, 5]);
assert_compact_matches_materialized(&sparse);
let mut sparse_stream = b"%PDF-1.7\n".to_vec();
let mut offsets = [0u32; 8];
offsets[1] = sparse_stream.len() as u32;
sparse_stream.extend_from_slice(b"1 0 obj\n<< /Type /Catalog /Pages 5 0 R >>\nendobj\n");
offsets[5] = sparse_stream.len() as u32;
sparse_stream.extend_from_slice(b"5 0 obj\n<< /Type /Pages /Kids [] /Count 0 >>\nendobj\n");
offsets[6] = sparse_stream.len() as u32;
sparse_stream.extend_from_slice(b"6 0 obj\n<< /Unused true >>\nendobj\n");
offsets[7] = sparse_stream.len() as u32;
let xref_row = |kind: u8, offset: u32, generation: u16| {
let mut row = Vec::with_capacity(7);
row.push(kind);
row.extend_from_slice(&offset.to_be_bytes());
row.extend_from_slice(&generation.to_be_bytes());
row
};
let mut rows = Vec::new();
rows.extend(xref_row(0, 0, u16::MAX));
rows.extend(xref_row(1, offsets[1], 0));
rows.extend(xref_row(1, offsets[5], 0));
rows.extend(xref_row(1, offsets[6], 0));
rows.extend(xref_row(1, offsets[7], 0));
sparse_stream.extend_from_slice(
format!("7 0 obj\n<< /Type /XRef /Size 8 /Root 1 0 R /W [1 4 2] /Index [0 2 5 3] /Length {} >>\nstream\n", rows.len()).as_bytes(),
);
sparse_stream.extend_from_slice(&rows);
sparse_stream.extend_from_slice(format!("\nendstream\nendobj\nstartxref\n{}\n%%EOF\n", offsets[7]).as_bytes());
let sparse_stream = Document::open(Arc::new(sparse_stream)).unwrap();
assert_eq!(sparse_stream.object_numbers(), vec![1, 5, 6, 7]);
assert_compact_matches_materialized(&sparse_stream);
let stream_bytes = crate::write_full(&classic, &crate::SaveOptions::default()).unwrap();
let stream = Document::open(Arc::new(stream_bytes)).unwrap();
assert!(stream.revisions().last().is_some_and(|revision| revision.is_stream));
assert_compact_matches_materialized(&stream);
let mut edited = classic.clone();
edited.update_dict(ObjRef::new(1, 0), |dict| dict.set(b"Lang".to_vec(), Object::String(crate::PdfString::literal("en")))).unwrap();
edited.free(ObjRef::new(3, 0));
let incremental = crate::write_incremental(&edited, &crate::SaveOptions::default()).unwrap();
let updated = Document::open(Arc::new(incremental)).unwrap();
assert_eq!(updated.revisions().len(), 2);
assert_compact_matches_materialized(&updated);
}
#[test]
fn malformed_size_uses_the_materialized_compatibility_path() {
let mut bytes = build(&["<< /Type /Catalog /Pages 2 0 R >>", "<< /Type /Pages /Kids [] /Count 0 >>", "<< /Value 9 >>"], "/Root 1 0 R");
let marker = b"/Size 4";
let at = bytes.windows(marker.len()).position(|window| window == marker).unwrap();
bytes[at..at + marker.len()].copy_from_slice(b"/Size 2");
let doc = Document::open(Arc::new(bytes)).unwrap();
assert!(matches!(doc.entries.as_ref(), XrefIndex::Materialized(_)));
assert_eq!(doc.next_num, 4);
assert_eq!(doc.object_numbers(), vec![1, 2, 3]);
}
/// Entries the reader sets aside are remembered, so a caller comparing revisions can tell
/// that another reader may resolve the object differently.
#[test]
fn cross_reference_entries_that_are_set_aside_are_recorded() {
let base = build(&["<< /Type /Catalog /Pages 2 0 R >>", "<< /Type /Pages /Kids [] /Count 0 >>", "(three)"], "/Root 1 0 R");
let plain = Document::open(Arc::new(base.clone())).unwrap();
assert!(plain.xref_oddities().is_empty());
assert!((1..=3).all(|n| plain.xref_entry_is_exact(n)));
let prev = String::from_utf8_lossy(&base).rfind("xref\n0 4").unwrap();
let third = plain.xref_entry(3).unwrap();
// A later section: object 3 "in use" at offset 0, object 2 listed twice with different
// offsets, and object 0 at offset 0 (the usual producer slip, which names no object).
let mut bytes = base.clone();
let section = bytes.len();
bytes.extend_from_slice(
format!("xref\n0 1\n0000000000 65535 n \n3 1\n0000000000 00000 n \n2 1\n0000000058 00000 n \n2 1\n0000000009 00000 n \ntrailer\n<< /Size 4 /Root 1 0 R /Prev {prev} >>\nstartxref\n{section}\n%%EOF\n").as_bytes(),
);
let doc = Document::open(Arc::new(bytes)).unwrap();
assert_eq!(doc.xref_oddities(), [(section as u64, 3), (section as u64, 2)]);
// What the reader resolves is what it resolved before: the first entry it kept.
assert_eq!(doc.xref_entry(3), Some(third));
assert_eq!(doc.xref_entry(2), Some(XrefEntry::InFile { offset: 58, generation: 0 }));
}
/// An entry that misses its object still resolves (by searching) but is not exact.
#[test]
fn an_entry_that_misses_its_object_is_found_by_searching_but_is_not_exact() {
let base = build(&["<< /Type /Catalog /Pages 2 0 R >>", "<< /Type /Pages /Kids [] /Count 0 >>", "(three)"], "/Root 1 0 R");
let text = String::from_utf8_lossy(&base).into_owned();
let at = text.find("3 0 obj").unwrap();
let bytes = text.replacen(&format!("{at:010} 00000 n"), &format!("{:010} 00000 n", at + 2), 1).into_bytes();
let doc = Document::open(Arc::new(bytes)).unwrap();
assert_eq!(doc.get(ObjRef { num: 3, generation: 0 }).as_string().map(|s| s.bytes.clone()), Some(b"three".to_vec()));
assert!(!doc.xref_entry_is_exact(3));
assert!(doc.xref_entry_is_exact(1) && doc.xref_entry_is_exact(2));
}
/// A classic table with `/Size` rows and object 0's free entry first, its subsection numbered
/// `1 n` instead of `0 n` (#2676): every row is one number off.
fn misnumbered_table(start: u32) -> Vec<u8> {
let bodies = ["<< /Marker (first object) >>", "<< /Type /Pages /Kids [] /Count 0 >>", "<< /Type /Catalog /Pages 2 0 R >>"];
let mut out = b"%PDF-1.7
"
.to_vec();
let mut offsets = Vec::new();
for (i, body) in bodies.iter().enumerate() {
offsets.push(out.len());
out.extend_from_slice(
format!(
"{} 0 obj
{body}
endobj
",
i + 1
)
.as_bytes(),
);
}
let xref = out.len();
out.extend_from_slice(
format!(
"xref
{start} 4
0000000000 65535 f
"
)
.as_bytes(),
);
for o in offsets {
out.extend_from_slice(
format!(
"{o:010} 00000 n
"
)
.as_bytes(),
);
}
out.extend_from_slice(
format!(
"trailer
<< /Size 4 /Root 3 0 R >>
startxref
{xref}
%%EOF
"
)
.as_bytes(),
);
out
}
#[test]
fn a_table_numbered_from_1_that_begins_with_object_0_is_read_from_0() {
let doc = Document::open(Arc::new(misnumbered_table(1))).unwrap();
let first = doc.get(ObjRef::new(1, 0)).as_dict().cloned().expect("object 1 is not free");
assert!(first.contains(b"Marker"));
assert_eq!(doc.root(), Some(ObjRef::new(3, 0)));
assert!(doc.repair_log().iter().any(|l| l.contains("numbered from 1")), "{:?}", doc.repair_log());
// A correct table reads the same and logs nothing.
let fine = Document::open(Arc::new(misnumbered_table(0))).unwrap();
assert!(fine.get(ObjRef::new(1, 0)).as_dict().is_some_and(|d| d.contains(b"Marker")));
assert!(!fine.repair_log().iter().any(|l| l.contains("numbered from 1")));
}
#[test]
fn a_table_that_really_starts_at_1_keeps_its_numbers() {
// Object 1 deleted for good (generation 65535) in a subsection that does start at 1: its
// second row locates object 2, so nothing is shifted.
let mut bytes = b"%PDF-1.7
"
.to_vec();
let pages = bytes.len();
bytes.extend_from_slice(
b"2 0 obj
<< /Type /Pages /Kids [] /Count 0 >>
endobj
",
);
let catalog = bytes.len();
bytes.extend_from_slice(
b"3 0 obj
<< /Type /Catalog /Pages 2 0 R >>
endobj
",
);
let xref = bytes.len();
bytes.extend_from_slice(
format!(
"xref
0 1
0000000000 65535 f
1 3
0000000000 65535 f
{pages:010} 00000 n
{catalog:010} 00000 n
trailer
<< /Size 4 /Root 3 0 R >>
startxref
{xref}
%%EOF
"
)
.as_bytes(),
);
let doc = Document::open(Arc::new(bytes)).unwrap();
assert!(doc.get(ObjRef::new(2, 0)).as_dict().is_some_and(|d| d.name(b"Type") == Some(b"Pages")));
assert!(doc.get(ObjRef::new(1, 0)).as_dict().is_none(), "object 1 stays free");
assert!(!doc.repair_log().iter().any(|l| l.contains("numbered from 1")));
}
#[test]
fn opening_an_exhausted_classic_xref_repairs_and_allocates() {
// The extra in-use row need not be loaded to exhaust the next object number.
let bytes = b"%PDF-1.7\n1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n2 0 obj\n<< /Type /Pages /Kids [] /Count 0 >>\nendobj\nxref\n0 3\n0000000000 65535 f \n0000000009 00000 n \n0000000058 00000 n \n4294967295 1\n0000000058 00000 n \ntrailer\n<< /Size 3 /Root 1 0 R >>\nstartxref\n110\n%%EOF\n";
assert_eq!(bytes.len(), 266);
let mut doc = Document::open(Arc::new(bytes.to_vec())).unwrap();
assert_eq!(doc.root(), Some(ObjRef::new(1, 0)));
assert!(doc.repair_log().iter().any(|line| line.contains("exhausted xref")));
assert_eq!(doc.add(Object::Null), ObjRef::new(3, 0));
}
#[test]
fn opening_an_exhausted_xref_stream_repairs_and_allocates() {
let mut bytes =
b"%PDF-1.7\n1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n2 0 obj\n<< /Type /Pages /Kids [] /Count 0 >>\nendobj\n".to_vec();
let offset = bytes.len();
bytes.extend_from_slice(b"3 0 obj\n<< /Type /XRef /Size 4 /Root 1 0 R /W [1 4 2] /Index [0 3 4294967295 1] /Length 28 >>\nstream\n");
for (kind, position, generation) in [(0_u8, 0_u32, 65535_u16), (1, 9, 0), (1, 58, 0), (1, 58, 0)] {
bytes.push(kind);
bytes.extend_from_slice(&position.to_be_bytes());
bytes.extend_from_slice(&generation.to_be_bytes());
}
bytes.extend_from_slice(format!("\nendstream\nendobj\nstartxref\n{offset}\n%%EOF\n").as_bytes());
let mut doc = Document::open(Arc::new(bytes)).unwrap();
assert_eq!(doc.root(), Some(ObjRef::new(1, 0)));
assert!(doc.repair_log().iter().any(|line| line.contains("exhausted xref")));
assert_eq!(doc.add(Object::Null), ObjRef::new(4, 0));
}
#[test]
fn exhausted_size_and_high_edits_allocate_without_overwriting_objects() {
let ordinary = build(&["<< /Type /Catalog /Pages 2 0 R >>", "<< /Type /Pages /Kids [] /Count 0 >>"], "/Root 1 0 R");
for size in ["4294967295", "4294967296", "9223372036854775807"] {
let bytes = String::from_utf8(ordinary.clone()).unwrap().replace("/Size 3", &format!("/Size {size}"));
let mut doc = Document::open(Arc::new(bytes.into_bytes())).unwrap();
assert!(doc.repair_log().iter().any(|line| line.contains("exhausted trailer")));
assert_eq!(doc.add(Object::Int(42)), ObjRef::new(3, 0));
doc.set(ObjRef::new(u32::MAX, 0), Object::Int(99));
doc.set(ObjRef::new(u32::MAX - 1, 0), Object::Int(98));
assert_eq!(doc.add(Object::Int(43)), ObjRef::new(4, 0));
assert_eq!(doc.get(ObjRef::new(3, 0)).as_int(), Some(42));
assert_eq!(doc.get(ObjRef::new(u32::MAX, 0)).as_int(), Some(99));
assert_eq!(doc.get(ObjRef::new(1, 0)).as_dict().unwrap().name(b"Type"), Some(b"Catalog".as_slice()));
}
let mut doc = Document::new_empty();
doc.set(ObjRef::new(u32::MAX - 2, 0), Object::Null);
assert_eq!(doc.add(Object::Null), ObjRef::new(u32::MAX - 1, 0));
assert_eq!(doc.add(Object::Null), ObjRef::new(3, 0));
assert_eq!(doc.add(Object::Null), ObjRef::new(4, 0));
}
#[test]
fn exhausted_xref_controls_preserve_ordinary_ignored_and_repaired_entries() {
let ordinary = build(&["<< /Type /Catalog /Pages 2 0 R >>", "<< /Type /Pages /Kids [] /Count 0 >>"], "/Root 1 0 R");
for (extra, repaired) in
[("", false), ("4294967295 1\n0000000000 00000 n \n", false), ("4294967295 2\n0000000058 00000 n \n0000000058 00000 n \n", true)]
{
let bytes = String::from_utf8(ordinary.clone()).unwrap().replacen("trailer\n", &format!("{extra}trailer\n"), 1);
let mut doc = Document::open(Arc::new(bytes.into_bytes())).unwrap();
assert_eq!(doc.root(), Some(ObjRef::new(1, 0)));
assert_eq!(doc.get(ObjRef::new(1, 0)).as_dict().unwrap().name(b"Type"), Some(b"Catalog".as_slice()));
assert_eq!(!doc.repair_log().is_empty(), repaired);
if repaired {
assert!(doc.repair_log().iter().any(|line| line.contains("xref") && line.contains("range")));
}
assert_eq!(doc.add(Object::Null), ObjRef::new(3, 0));
}
}
#[test]
fn invalid_xref_object_ranges_are_reconstructed() {
// Original in-memory PDF: an intact catalog followed by a damaged xref section.
for stream in [false, true] {
for (start, count) in [(i64::MAX, 2), (-1, 2), (4_294_967_296, 1), (4_294_967_295, 2)] {
let mut bytes =
b"%PDF-1.7\n1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n2 0 obj\n<< /Type /Pages /Kids [] /Count 0 >>\nendobj\n".to_vec();
let offset = bytes.len();
if stream {
bytes.extend_from_slice(
format!("3 0 obj\n<< /Type /XRef /Root 1 0 R /Size 4 /W [1 1 1] /Index [{start} {count}] /Length 6 >>\nstream\n").as_bytes(),
);
bytes.extend_from_slice(&[0, 0, 0, 0, 0, 0]);
bytes.extend_from_slice(b"\nendstream\nendobj\n");
} else {
bytes.extend_from_slice(
format!("xref\n{start} {count}\n0000000000 65535 f \n0000000000 65535 f \ntrailer\n<< /Root 1 0 R /Size 3 >>\n").as_bytes(),
);
}
bytes.extend_from_slice(format!("startxref\n{offset}\n%%EOF\n").as_bytes());
let doc = Document::open(Arc::new(bytes)).unwrap();
assert!(
doc.repair_log().iter().any(|line| line.contains("xref") && line.contains("range")),
"stream={stream}, start={start}, count={count}: {:?}",
doc.repair_log()
);
assert_eq!(doc.get(ObjRef::new(1, 0)).as_dict().unwrap().name(b"Type"), Some(b"Catalog".as_slice()));
}
}
}
#[test]
fn objects_only_an_unreadable_older_section_locates_are_found_by_scanning() {
// The newest section lists the catalog only; its /Prev points past the end of the file,
// so the page tree is reachable only by scanning object headers, as readers do (#1656).
let mut bytes = b"%PDF-1.7\n".to_vec();
let mut offsets = Vec::new();
for (num, body) in
[(1, "<< /Type /Catalog /Pages 2 0 R >>"), (2, "<< /Type /Pages /Kids [3 0 R] /Count 1 >>"), (3, "<< /Type /Page /Parent 2 0 R >>")]
{
offsets.push(bytes.len());
bytes.extend_from_slice(format!("{num} 0 obj\n{body}\nendobj\n").as_bytes());
}
let xref = bytes.len();
bytes.extend_from_slice(
format!(
"xref\n0 2\n0000000000 65535 f \n{:010} 00000 n \ntrailer\n<< /Root 1 0 R /Size 4 /Prev 999999 >>\nstartxref\n{xref}\n%%EOF\n",
offsets[0]
)
.as_bytes(),
);
let doc = Document::open(Arc::new(bytes)).unwrap();
assert_eq!(doc.get(ObjRef::new(2, 0)).as_dict().and_then(|d| d.name(b"Type")), Some(b"Pages".as_slice()));
assert_eq!(doc.get(ObjRef::new(3, 0)).as_dict().and_then(|d| d.name(b"Type")), Some(b"Page".as_slice()));
assert!(doc.repair_log().iter().any(|l| l.contains("999999")), "{:?}", doc.repair_log());
}
#[test]
fn self_referential_length_does_not_overflow() {
let bytes = build(
&["<< /Type /Catalog /Pages 2 0 R >>", "<< /Type /Pages /Kids [] /Count 0 >>", "<< /Length 3 0 R >>\nstream\nabc\nendstream"],
"/Root 1 0 R",
);
let doc = Document::open(Arc::new(bytes)).unwrap();
// Either recovered via the endstream search or reported — but never a stack overflow.
let o = doc.get(ObjRef::new(3, 0));
if let Object::Stream(s) = o.as_ref() {
assert_eq!(&s.raw[..], b"abc");
}
}
#[test]
fn mutually_referential_lengths_do_not_overflow() {
let bytes = build(
&[
"<< /Type /Catalog /Pages 2 0 R >>",
"<< /Type /Pages /Kids [] /Count 0 >>",
"<< /Length 4 0 R >>\nstream\nabc\nendstream",
"<< /Length 3 0 R >>\nstream\nxyz\nendstream",
],
"/Root 1 0 R",
);
let doc = Document::open(Arc::new(bytes)).unwrap();
let _ = doc.get(ObjRef::new(3, 0));
let _ = doc.get(ObjRef::new(4, 0));
}
#[test]
fn reads_classic_revision_and_edits_are_copy_on_write() {
let bytes = build(&["<< /Type /Catalog /Pages 2 0 R >>", "<< /Type /Pages /Kids [] /Count 0 >>"], "/Root 1 0 R");
let doc = Document::open(Arc::new(bytes)).unwrap();
assert_eq!(doc.revisions().len(), 1);
assert!(doc.repair_log().is_empty());
let mut edited = doc.clone();
edited.update_dict(ObjRef::new(1, 0), |d| d.set(b"Lang".to_vec(), Object::String(crate::PdfString::literal("en")))).unwrap();
assert!(edited.is_modified() && !doc.is_modified(), "clone is an independent snapshot");
assert!(doc.get(ObjRef::new(1, 0)).as_dict().unwrap().get(b"Lang").is_none());
}
#[test]
fn object_numbers_merge_source_and_overlay_in_order() {
let mut doc = Document::new_empty();
doc.overlay.clear();
doc.entries = Arc::new(XrefIndex::Materialized(Arc::new(BTreeMap::from([
(0, XrefEntry::Free { next_generation: 65535 }),
(2, XrefEntry::InFile { offset: 10, generation: 7 }),
(4, XrefEntry::Free { next_generation: 9 }),
(6, XrefEntry::InStream { stream: 80, index: 1 }),
(8, XrefEntry::InFile { offset: 20, generation: 5 }),
(10, XrefEntry::InFile { offset: 30, generation: 3 }),
(u32::MAX, XrefEntry::InFile { offset: 40, generation: 1 }),
]))));
doc.set(ObjRef::new(1, 2), Object::Int(1)); // before the first live source slot
doc.set(ObjRef::new(2, 8), Object::Int(2)); // override, without duplication
doc.set(ObjRef::new(4, 9), Object::Int(4)); // reuse a source free slot
doc.free(ObjRef::new(6, 0)); // remove a compressed source object
doc.free(ObjRef::new(7, 2)); // free a number absent from the source
doc.free(ObjRef::new(10, 3));
doc.set(ObjRef::new(11, 4), Object::Int(11));
assert_eq!(doc.object_numbers(), vec![1, 2, 4, 8, 11, u32::MAX]);
assert_eq!(doc.generation(2), 8);
assert_eq!(doc.generation(4), 9);
assert_eq!(doc.generation(6), 1);
assert_eq!(doc.generation(8), 5);
assert_eq!(doc.generation(10), 4);
doc.overlay.clear();
assert_eq!(doc.object_numbers(), vec![2, 6, 8, 10, u32::MAX]);
doc.entries = Arc::new(XrefIndex::empty());
assert!(doc.object_numbers().is_empty());
doc.set(ObjRef::new(5, 7), Object::Int(5));
assert_eq!(doc.object_numbers(), vec![5]);
}
#[test]
fn full_object_scans_preserve_edits_without_retaining_the_scan() {
let mut bodies = vec!["<< /Type /Catalog /Pages 2 0 R >>".to_owned(), "<< /Type /Pages /Kids [] /Count 0 >>".to_owned()];
bodies.extend((0..400).map(|i| format!("<< /Value {i} >>")));
let refs: Vec<_> = bodies.iter().map(String::as_str).collect();
let mut doc = Document::open(Arc::new(build(&refs, "/Root 1 0 R"))).unwrap();
doc.set(ObjRef::new(3, 0), Object::Int(900));
doc.free(ObjRef::new(4, 0));
let added = doc.add(Object::Int(901));
let cached = doc.cache.lock().unwrap().len();
let held: Vec<_> = doc.scan_objects().collect();
assert_eq!(held.len(), 402);
assert_eq!(doc.cache.lock().unwrap().len(), cached, "scanning must not populate the editor cache");
assert!(doc.objstms.lock().unwrap().is_empty());
assert!(!held.iter().any(|(r, _)| r.num == 4));
assert_eq!(held.iter().find(|(r, _)| r.num == 3).unwrap().1.as_int(), Some(900));
assert_eq!(held.iter().find(|(r, _)| *r == added).unwrap().1.as_int(), Some(901));
assert_eq!(held.iter().find(|(r, _)| r.num == 400).unwrap().1.as_dict().unwrap().int(b"Value"), Some(397));
// Previously returned objects survive cache eviction and dropping the source.
drop(doc);
assert_eq!(held.iter().find(|(r, _)| r.num == 128).unwrap().1.as_dict().unwrap().int(b"Value"), Some(125));
}
#[test]
fn temporary_reader_keeps_file_views_and_evicts_data_of_its_own() {
// A stream read from the file is a view into the input: caching it costs no copy, so
// even a large one stays cached (its /Length object too) instead of being re-parsed.
let body = "x".repeat(ObjectReader::STREAM_BYTES + 1);
let stream = format!("<< /Length 4 0 R >>\nstream\n{body}\nendstream");
let length = body.len().to_string();
let bytes = build(&["<< /Type /Catalog /Pages 2 0 R >>", "<< /Type /Pages /Kids [] /Count 0 >>", &stream, &length], "/Root 1 0 R");
let doc = Document::open(Arc::new(bytes)).unwrap();
let mut reader = doc.object_reader();
let read = reader.get(ObjRef::new(3, 0));
let Object::Stream(s) = &*read else { panic!("stream") };
assert!(s.raw.shares(&reader.document.data), "no copy of the file's bytes");
assert!(reader.document.cache.lock().unwrap().contains_key(&3), "a view costs nothing to keep");
// Data of its own (decrypted streams, strings) counts: past the budget the cache is
// cleared after the next read, including a failed one.
let owned = Object::Stream(crate::Stream::from_raw(Dict::new(), vec![b'y'; ObjectReader::STREAM_BYTES + 1]));
reader.document.cache.lock().unwrap().insert(3, Arc::new(owned));
assert_eq!(*reader.get(ObjRef::new(99, 0)), Object::Null);
assert!(reader.document.cache.lock().unwrap().is_empty(), "owned data over budget is evicted");
}
#[test]
fn temporary_reader_and_full_save_do_not_fill_snapshot_caches() {
let mut source = Document::new_empty();
let objects: Vec<_> = (0..260)
.map(|i| {
let mut value = vec![b'x'; 48 * 1024];
value[..4].copy_from_slice(&(i as u32).to_le_bytes());
Object::Ref(source.add(Object::String(crate::PdfString::literal(value))))
})
.collect();
source.update_dict(source.root().unwrap(), |d| d.set(b"Extension".to_vec(), Object::Array(objects))).unwrap();
let bytes = Arc::new(crate::write_full(&source, &crate::SaveOptions::default()).unwrap());
let doc = Document::open(bytes).unwrap();
let root = doc.get(doc.root().unwrap());
let references = root.as_dict().unwrap().get(b"Extension").unwrap().as_array().unwrap();
let snapshot = doc.clone();
let cached = doc.cache.lock().unwrap().len();
let streams = doc.objstms.lock().unwrap().len();
let mut reader = doc.object_reader();
let mut held = None;
for (i, reference) in references.iter().enumerate() {
let Object::Ref(reference) = reference else { panic!("indirect object") };
let object = reader.get(*reference);
assert_eq!(&object.as_string().unwrap().bytes[..4], &(i as u32).to_le_bytes());
if i == 0 {
held = Some(object);
}
assert!(reader.document.cache.lock().unwrap().len() < ObjectReader::OBJECTS);
let decoded = reader.document.objstms.lock().unwrap();
assert!(decoded.len() < ObjectReader::OBJECTS);
let bytes: usize = decoded.values().map(|s| s.data.capacity() + s.index.capacity() * std::mem::size_of::<(u32, usize)>()).sum();
assert!(bytes <= ObjectReader::STREAM_BYTES || decoded.len() == 1, "only one oversized stream may remain");
}
drop(reader);
assert_eq!(&held.unwrap().as_string().unwrap().bytes[..4], &0u32.to_le_bytes(), "owned results survive evictions");
for object_streams in [false, true] {
let saved = crate::write_full(&doc, &crate::SaveOptions { object_streams, ..Default::default() }).unwrap();
assert!(Document::open(Arc::new(saved)).unwrap().repair_log().is_empty());
assert_eq!(doc.cache.lock().unwrap().len(), cached, "full saves must not fill the editor cache");
assert_eq!(doc.objstms.lock().unwrap().len(), streams);
assert!(Arc::ptr_eq(&doc.cache, &snapshot.cache), "do not detach or clear caller snapshots");
}
}
#[test]
fn editor_cache_stays_bounded() {
let mut source = Document::new_empty();
let objects: Vec<_> = (0..(Document::EDITOR_CACHE_OBJECTS + 64))
.map(|i| {
let mut value = vec![b'x'; 1024];
value[..4].copy_from_slice(&(i as u32).to_le_bytes());
Object::Ref(source.add(Object::String(crate::PdfString::literal(value))))
})
.collect();
source.update_dict(source.root().unwrap(), |d| d.set(b"Extension".to_vec(), Object::Array(objects))).unwrap();
let bytes = Arc::new(crate::write_full(&source, &crate::SaveOptions::default()).unwrap());
let doc = Document::open(bytes).unwrap();
let root = doc.get(doc.root().unwrap());
let references = root.as_dict().unwrap().get(b"Extension").unwrap().as_array().unwrap().clone();
let mut held = None;
for (i, reference) in references.iter().enumerate() {
let Object::Ref(reference) = reference else { panic!("indirect object") };
let object = doc.get(*reference);
assert_eq!(&object.as_string().unwrap().bytes[..4], &(i as u32).to_le_bytes());
if i == 0 {
held = Some(object);
}
assert!(doc.cache.lock().unwrap().len() <= Document::EDITOR_CACHE_OBJECTS, "editing must not pin the whole object graph");
// Eviction keeps the newcomer.
assert!(doc.cache.lock().unwrap().contains_key(&reference.num), "eviction must keep the newcomer");
}
// Returned objects survive eviction, through both handles of the shared cache.
assert_eq!(&held.unwrap().as_string().unwrap().bytes[..4], &0u32.to_le_bytes());
let snapshot = doc.clone();
let first = references[0].as_ref().unwrap();
assert_eq!(&snapshot.get(first).as_string().unwrap().bytes[..4], &0u32.to_le_bytes());
}
#[test]
fn editor_cache_byte_bound_fires() {
// 40 × 1 MB strings: far below the count cap, above the 32 MB byte budget. Each
// newcomer exceeds EDITOR_LARGE_OBJECT, so every insert takes a full accounting pass.
let mut source = Document::new_empty();
let objects: Vec<_> = (0..40)
.map(|i| {
let mut value = vec![b'y'; 1024 * 1024];
value[..4].copy_from_slice(&(i as u32).to_le_bytes());
Object::Ref(source.add(Object::String(crate::PdfString::literal(value))))
})
.collect();
source.update_dict(source.root().unwrap(), |d| d.set(b"Extension".to_vec(), Object::Array(objects))).unwrap();
let bytes = Arc::new(crate::write_full(&source, &crate::SaveOptions::default()).unwrap());
let doc = Document::open(bytes).unwrap();
let root = doc.get(doc.root().unwrap());
let references = root.as_dict().unwrap().get(b"Extension").unwrap().as_array().unwrap().clone();
let mut held = None;
for (i, reference) in references.iter().enumerate() {
let Object::Ref(reference) = reference else { panic!("indirect object") };
let object = doc.get(*reference);
assert_eq!(&object.as_string().unwrap().bytes[..4], &(i as u32).to_le_bytes());
if i == 0 {
held = Some(object);
}
// Eviction keeps the newcomer.
assert!(doc.cache.lock().unwrap().contains_key(&reference.num), "eviction must keep the newcomer");
}
assert!(doc.cache.lock().unwrap().len() < references.len(), "the byte budget must evict even below the count cap");
let last = references[references.len() - 1].as_ref().unwrap();
assert!(doc.cache.lock().unwrap().contains_key(&last.num), "eviction must keep the newcomer");
assert_eq!(&held.unwrap().as_string().unwrap().bytes[..4], &0u32.to_le_bytes());
}
#[test]
fn a_stream_limited_document_cannot_be_saved() {
let bytes = Arc::new(crate::write_full(&Document::new_empty(), &crate::SaveOptions::default()).unwrap());
let doc = Document::open_with_stream_limit(bytes.clone(), None, 1 << 20).unwrap();
for saved in [crate::write_full(&doc, &crate::SaveOptions::default()), crate::write_incremental(&doc, &crate::SaveOptions::default())] {
assert!(matches!(saved, Err(CosError::ReadOnlyLimit)), "{saved:?}");
}
let doc = Document::open(bytes).unwrap();
assert!(crate::write_full(&doc, &crate::SaveOptions::default()).is_ok());
}
#[test]
fn inspection_stream_limit_applies_before_loading_compressed_objects() {
let mut doc = Document::new_empty();
let large = doc.add(Object::String(crate::PdfString::literal(vec![b'x'; 4096])));
doc.update_dict(doc.root().unwrap(), |catalog| catalog.set(b"Extension".to_vec(), Object::Ref(large))).unwrap();
let bytes = Arc::new(crate::write_full(&doc, &crate::SaveOptions::default()).unwrap());
assert!(Document::open(bytes.clone()).is_ok());
assert!(Document::open_with_stream_limit(bytes, None, 512).is_err());
}
/// A /Prev chain that loops back on itself stops at the loop (and says so).
#[test]
fn looping_revision_chains_stop() {
let mut bytes = build(&["<< /Type /Catalog /Pages 2 0 R >>", "<< /Type /Pages /Kids [] /Count 0 >>"], "/Root 1 0 R");
// An update whose /Prev points at itself.
let at = bytes.len();
bytes.extend_from_slice(
format!("xref\n0 1\n0000000000 65535 f \ntrailer << /Size 3 /Root 1 0 R /Prev {at} >>\nstartxref\n{at}\n%%EOF\n").as_bytes(),
);
let doc = Document::open(Arc::new(bytes)).unwrap();
assert_eq!(doc.root(), Some(ObjRef::new(1, 0)));
assert!(doc.repair_log().iter().any(|l| l.contains("loops")), "{:?}", doc.repair_log());
}
/// A hybrid-reference file: the classic table leaves object 3 out; the `/XRefStm` stream
/// says it lives in object stream 4.
#[test]
fn hybrid_reference_files_read_the_xref_stream_too() {
let mut out = b"%PDF-1.5\n".to_vec();
let mut offs = [0usize; 6];
let mut obj = |out: &mut Vec<u8>, n: usize, body: &[u8]| {
offs[n] = out.len();
out.extend_from_slice(format!("{n} 0 obj\n").as_bytes());
out.extend_from_slice(body);
out.extend_from_slice(b"\nendobj\n");
};
obj(&mut out, 1, b"<< /Type /Catalog /Pages 2 0 R >>");
obj(&mut out, 2, b"<< /Type /Pages /Kids [] /Count 0 /Extra 3 0 R >>");
let inner = b"3 0 << /Hidden (yes) >>";
let mut stm = format!("<< /Type /ObjStm /N 1 /First 4 /Length {} >>\nstream\n", inner.len()).into_bytes();
stm.extend_from_slice(inner);
stm.extend_from_slice(b"\nendstream");
obj(&mut out, 4, &stm);
let data = [2u8, 0, 4, 0];
let mut xs = format!("<< /Type /XRef /Size 6 /W [1 2 1] /Index [3 1] /Length {} >>\nstream\n", data.len()).into_bytes();
xs.extend_from_slice(&data);
xs.extend_from_slice(b"\nendstream");
obj(&mut out, 5, &xs);
let table = out.len();
out.extend_from_slice(b"xref\n0 6\n0000000000 65535 f \n");
for (n, off) in offs.iter().enumerate().skip(1) {
let line = if n == 3 { "0000000000 65535 f \n".to_string() } else { format!("{off:010} 00000 n \n") };
out.extend_from_slice(line.as_bytes());
}
out.extend_from_slice(format!("trailer << /Size 6 /Root 1 0 R /XRefStm {} >>\nstartxref\n{table}\n%%EOF\n", offs[5]).as_bytes());
let doc = Document::open(Arc::new(out)).unwrap();
assert!(doc.repair_log().is_empty(), "{:?}", doc.repair_log());
assert_compact_matches_materialized(&doc);
let hidden = doc.get(ObjRef::new(3, 0));
assert_eq!(hidden.as_dict().and_then(|d| d.get(b"Hidden").and_then(|h| h.as_string().map(|s| s.to_text()))), Some("yes".into()));
}
#[test]
fn revision_ends_split_incremental_updates() {
let mut bytes = build(&["<< /Type /Catalog /Pages 2 0 R >>", "<< /Type /Pages /Kids [] /Count 0 >>"], "/Root 1 0 R");
let first = bytes.len();
let doc = Document::open(Arc::new(bytes.clone())).unwrap();
assert_eq!(doc.revision_ends(), vec![first]);
let mut edited = doc.clone();
edited.update_dict(ObjRef::new(1, 0), |d| d.set(b"Lang".to_vec(), Object::String(crate::PdfString::literal("en")))).unwrap();
bytes = crate::write_incremental(&edited, &crate::SaveOptions::default()).unwrap();
let doc = Document::open(Arc::new(bytes.clone())).unwrap();
let ends = doc.revision_ends();
assert_eq!(ends.len(), 2, "{ends:?}");
assert!(ends[0] >= first && ends[0] < bytes.len() && ends[1] == bytes.len(), "{ends:?} of {}", bytes.len());
// The first revision opens on its own, without the update.
let old = Document::open(Arc::new(bytes[..ends[0]].to_vec())).unwrap();
assert!(old.get(ObjRef::new(1, 0)).as_dict().unwrap().get(b"Lang").is_none());
}
#[test]
fn damaged_xref_is_reconstructed() {
let mut bytes = build(&["<< /Type /Catalog /Pages 2 0 R >>", "<< /Type /Pages /Kids [] /Count 0 >>"], "/Root 1 0 R");
let n = bytes.len();
bytes[n - 12..n - 6].copy_from_slice(b"999999"); // point startxref into nowhere
let doc = Document::open(Arc::new(bytes)).unwrap();
assert!(doc.revisions().is_empty());
assert!(!doc.repair_log().is_empty());
assert_eq!(doc.root(), Some(ObjRef::new(1, 0)));
}
#[test]
fn a_decompression_bomb_in_an_xref_stream_is_refused_and_the_file_reconstructed() {
// 32 MiB of rows behind two FlateDecode filters (a few hundred bytes in the file) for
// /W [1 1 1]: at 3 bytes a row that is more than 8,388,607 objects' worth, so it is not
// decoded (in full: the limit applies while inflating) and the objects are found by
// reconstruction instead.
let bomb = pdfcraft_filters::encode_flate(&pdfcraft_filters::encode_flate(&vec![0u8; 32 << 20]));
let mut bytes = b"%PDF-1.7\n".to_vec();
let o1 = bytes.len();
bytes.extend_from_slice(b"1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n");
let o2 = bytes.len();
bytes.extend_from_slice(b"2 0 obj\n<< /Type /Pages /Kids [] /Count 0 >>\nendobj\n");
let x = bytes.len();
bytes.extend_from_slice(
format!("3 0 obj\n<< /Type /XRef /Size 4 /W [1 1 1] /Root 1 0 R /Filter [/FlateDecode /FlateDecode] /Length {} >>\nstream\n", bomb.len())
.as_bytes(),
);
bytes.extend_from_slice(&bomb);
bytes.extend_from_slice(format!("\nendstream\nendobj\nstartxref\n{x}\n%%EOF\n").as_bytes());
assert!(o1 < o2 && o2 < x);
let doc = Document::open(Arc::new(bytes)).unwrap();
assert_eq!(doc.root(), Some(ObjRef::new(1, 0)));
assert!(doc.repair_log().iter().any(|l| l.contains("limit")), "{:?}", doc.repair_log());
}
#[test]
fn a_stream_whose_length_is_itself_and_never_ends_loads_quickly() {
// From the nightly fuzz job: `6 0 obj << /Length 6 0 R >> stream` with no `endstream`
// after it. Every parse attempt failed, and each attempt resolved the length by loading
// object 6 again, retrying the same ways down to the nesting limit: about 2^32 parses.
// Objects being loaded are now a seen-set, lengths are memoised, and the second parse
// only runs when the header is shifted.
let mut bytes = b"%PDF-1.7\n1 0 obj\n<< /Type /Catalog >>\nendobj\n".to_vec();
bytes.extend_from_slice(b"6 0 obj\n<< /Length 6 0 R >>\nstream\nno end marker follows\n");
let (tx, rx) = std::sync::mpsc::channel();
std::thread::spawn(move || {
if let Ok(doc) = Document::open(Arc::new(bytes)) {
let _ = doc.try_get(6);
}
let _ = tx.send(());
});
rx.recv_timeout(std::time::Duration::from_secs(20)).expect("loading must not retry exponentially");
}
}