pdfrum_parser/doc.rs
1//! Opening a document: the header, the load ladder, and the page tree.
2//!
3//! # The ladder
4//!
5//! [`load`] is a sequence of attempts, each one falling back to a cruder
6//! repair. Find the header; read the cross-reference information, rebuilding
7//! it from a full-file scan if the structured paths fail; set up decryption;
8//! then check that the trailer names a catalog and that the catalog names at
9//! least one page. If that last check fails the reader *throws away the
10//! table it just built* and rebuilds anyway, because a table that yields no
11//! pages is more likely stale than the file is empty.
12//!
13//! That retry is why the ladder is written as a ladder rather than a straight
14//! line: the same steps run twice with different inputs, and which rung a
15//! file lands on decides whether it opens at all.
16//!
17//! # `/Root` must be a reference
18//!
19//! A trailer whose `/Root` is a dictionary written inline is treated as
20//! having no catalog, even though the dictionary is right there. It reads as
21//! damage, and damage triggers the rebuild — which on real files finds a
22//! better catalog. Honoring the inline dictionary would skip that.
23//!
24//! # Counting pages without walking them
25//!
26//! A `/Pages` node's `/Count` is believed whenever it is positive and below
27//! the cap, without checking it against the tree. Files whose counts are
28//! wrong therefore report the wrong number — and lookups past the real end
29//! fail individually, which is exactly what a reader that trusted the walk
30//! instead would not reproduce.
31
32use std::sync::{Arc, Mutex};
33
34use pdfrum_common::{
35 DiagKind, Diagnostics, LimitExceeded, Limits, Operation, PageIndex, PdfVersion, Severity,
36};
37use pdfrum_crypt::{Permissions, SecurityHandler};
38use pdfrum_object::{ByteSpan, Dict, NoResolve, ObjRef, Object, Resolve, names};
39
40use crate::error::Error;
41use crate::store::ObjectStore;
42use crate::xref::{Trailer, Xref, XrefShape};
43
44/// How many bytes a `%PDF-1.7\n` header occupies, and the least a file can be.
45const HEADER_SIZE: usize = 9;
46
47/// Why a document could not be opened.
48#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
49#[non_exhaustive]
50pub enum LoadError {
51 /// No `%PDF` header within the first kilobyte, or a file too short to
52 /// hold one.
53 #[error("not a PDF file")]
54 NotPdf,
55
56 /// The document is encrypted and the password given does not open it.
57 /// Distinct from the others because callers ask again rather than give
58 /// up.
59 #[error("wrong password")]
60 WrongPassword,
61
62 /// The document uses a security handler this reader does not implement.
63 #[error("unsupported encryption: {0}")]
64 UnsupportedEncryption(String),
65
66 /// The file is damaged past what recovery could repair: no usable
67 /// cross-reference information, or no catalog with pages in it.
68 #[error("damaged beyond recovery: {0}")]
69 Broken(String),
70
71 /// The caller's `Limits::deadline` had passed when the open began, or
72 /// passed during the cross-reference rebuild scan.
73 #[error(transparent)]
74 Limit(LimitExceeded),
75}
76
77/// How to open a document.
78#[derive(Debug, Clone, Default)]
79pub struct LoadOptions {
80 /// The password to try, as raw bytes. Not capped in length.
81 pub password: Option<Vec<u8>>,
82 /// Caps to enforce while reading.
83 pub limits: Limits,
84}
85
86/// One page's dictionary, with the attributes it inherits already resolved.
87#[derive(Debug, Clone, PartialEq)]
88pub struct PageDict {
89 /// The page's own dictionary.
90 pub dict: Dict,
91 /// The reference it was reached through, when it had one. A page written
92 /// inline in its parent's `/Kids` has none.
93 pub reference: Option<ObjRef>,
94}
95
96impl PageDict {
97 /// An attribute of this page, looked up through its `/Parent` chain
98 /// (ISO 32000-1 §7.7.3.4).
99 ///
100 /// `/Resources`, `/MediaBox`, `/CropBox` and `/Rotate` are inheritable:
101 /// a page that does not state one takes its parent's, or its
102 /// grandparent's. The walk stops at the first node that states the key
103 /// *directly* — a value that is itself a reference is resolved, but a
104 /// `/Parent` that is not a dictionary ends the chain.
105 #[must_use]
106 pub fn inherited(&self, key: &pdfrum_object::Name, r: &impl Resolve) -> Option<Object> {
107 let mut node = self.dict.clone();
108 let mut seen: Vec<Dict> = Vec::new();
109 for _ in 0..64 {
110 if let Some(value) = node.get(key, r) {
111 return Some(value.get().clone());
112 }
113 // A cycle in the parent chain would otherwise spin forever.
114 if seen.contains(&node) {
115 return None;
116 }
117 seen.push(node.clone());
118 node = node.dict(names::PARENT, r)?;
119 }
120 None
121 }
122}
123
124/// An opened document.
125///
126/// Holds the file, everything the reader learned about where its objects are,
127/// and the lazy store that turns references into objects. `Send + Sync`, so
128/// pages can be rendered in parallel.
129#[derive(Debug)]
130pub struct Document {
131 /// The file from its header onwards; every offset indexes into this.
132 bytes: ByteSpan,
133 /// The trailer, merged across every section that contributed one.
134 trailer: Trailer,
135 /// The object store.
136 store: Arc<ObjectStore>,
137 /// The version the header declared, or `None` when it declared none.
138 version: Option<PdfVersion>,
139 /// Where the header was found in the original file.
140 header_offset: u64,
141 /// The shape of the cross-reference the load used, for the writer.
142 xref_shape: XrefShape,
143 /// The `/Encrypt` dictionary as the file wrote it, and whether the
144 /// trailer held it directly rather than by reference.
145 encrypt: Option<(Dict, bool)>,
146 /// How many pages the catalog says there are.
147 page_count: u32,
148 /// Page dictionaries found so far, by index.
149 pages: Mutex<PageCache>,
150 /// Everything repaired while opening the file.
151 pub diags: Diagnostics,
152}
153
154/// The page lookup's memory. The cache, not the index.
155#[derive(Debug, Default)]
156struct PageCache {
157 /// One slot per page, filled as pages are found.
158 slots: Vec<Option<PageDict>>,
159 /// Whether the tree turned out to be deeper than the cap, which stops
160 /// every later lookup as well.
161 poisoned: bool,
162}
163
164/// Open a document.
165///
166/// `bytes` is the whole file. Every repair the reader performed is in
167/// [`Document::diags`] afterwards, and a document that opened with a rebuilt
168/// table reports so through [`Document::xref_was_rebuilt`].
169///
170/// # Errors
171///
172/// [`LoadError::NotPdf`] for a file with no header, [`LoadError::WrongPassword`]
173/// and [`LoadError::UnsupportedEncryption`] for encryption the password or
174/// the reader cannot handle, and [`LoadError::Broken`] for damage recovery
175/// could not repair.
176///
177/// ```
178/// use std::sync::Arc;
179/// use pdfrum_parser::{LoadError, LoadOptions, load};
180///
181/// let not_a_pdf: Arc<[u8]> = Arc::from(&b"just some bytes"[..]);
182/// assert_eq!(load(not_a_pdf, &LoadOptions::default()).err(), Some(LoadError::NotPdf));
183/// ```
184pub fn load(bytes: impl Into<ByteSpan>, opts: &LoadOptions) -> Result<Document, LoadError> {
185 opts.limits
186 .check_deadline(Operation::Open)
187 .map_err(LoadError::Limit)?;
188 let mut diags = Diagnostics::default();
189 let bytes: ByteSpan = bytes.into();
190 let header_offset = find_header(&bytes, &opts.limits).ok_or(LoadError::NotPdf)?;
191 if bytes.len() < header_offset.saturating_add(HEADER_SIZE) {
192 return Err(LoadError::NotPdf);
193 }
194 if header_offset > 0 {
195 diags.record(
196 Severity::Recovered,
197 DiagKind::HeaderOffset,
198 Some(header_offset as u64),
199 );
200 }
201
202 // Everything before the header is invisible: offsets in the file are
203 // relative to it, so the reader works on the slice from there on.
204 //
205 // A window, not a copy: junk before `%PDF` costs a refcount bump like
206 // any other offset. Every stream is then a window into this one.
207 let body = bytes
208 .subspan(header_offset..bytes.len())
209 .unwrap_or_else(|_| ByteSpan::empty());
210 let version = read_version(&body);
211
212 let (xref, mut trailer, mut xref_shape) =
213 crate::xref::read_xref_full(&body, &opts.limits, &mut diags).map_err(|e| match e {
214 crate::Error::Limit(limit) => LoadError::Limit(limit),
215 other => LoadError::Broken(other.to_string()),
216 })?;
217
218 // First attempt: the catalog has to be reachable *and* have pages in it.
219 // Shared from here on: the stores below only read the table, and a slot
220 // vector is expensive to copy — see `ObjectStore`'s `xref` field. The
221 // rebuild path below re-shares after it mutates.
222 let mut shared_xref = Arc::new(xref);
223 let mut security = build_security(&body, &shared_xref, &trailer.dict, opts, &mut diags)?;
224 let mut store = build_store(&body, &shared_xref, opts, &trailer.dict, security);
225 let mut page_count = catalog_page_count(&store, &trailer.dict, &opts.limits);
226
227 if page_count.is_none() {
228 if xref_shape.rebuilt {
229 return Err(LoadError::Broken("no document catalog".into()));
230 }
231 // The table is the suspect, not the file: scan it and try again.
232 diags.record(Severity::Recovered, DiagKind::RootRecovered, None);
233 let mut fresh = Xref::new();
234 let mut fresh_trailer = Trailer::default();
235 let rebuilt = crate::xref::rebuild(
236 &body,
237 &mut fresh,
238 &mut fresh_trailer,
239 &opts.limits,
240 &mut diags,
241 &NoResolve,
242 )
243 .map_err(LoadError::Limit)?;
244 if !rebuilt {
245 return Err(LoadError::Broken("no document catalog".into()));
246 }
247 // The recovery path, so the copy this makes is not the common case:
248 // the first attempt's store still holds a reference, and the merged
249 // table has to be a fresh value for the second attempt's stores to
250 // share in turn.
251 let mut merged = (*shared_xref).clone();
252 merged.merge_up(&fresh);
253 crate::xref::merge_trailers(&mut trailer, &fresh_trailer);
254 xref_shape = XrefShape::rebuilt();
255
256 shared_xref = Arc::new(merged);
257 security = build_security(&body, &shared_xref, &trailer.dict, opts, &mut diags)?;
258 store = build_store(&body, &shared_xref, opts, &trailer.dict, security);
259 // Second attempt asks only for a catalog. A rebuilt document whose
260 // catalog is reachable but describes no pages still opens — it is
261 // then a document of zero pages, which is a thing a file can be.
262 if catalog(&store, &trailer.dict).is_none() {
263 return Err(LoadError::Broken("no document catalog".into()));
264 }
265 page_count = catalog_page_count(&store, &trailer.dict, &opts.limits);
266 }
267
268 let page_count = page_count.unwrap_or(0);
269 // Read last, from the trailer as it finally stands, so a rebuild that
270 // replaced the trailer is reflected.
271 let encrypt = encrypt_dict_located(&body, &shared_xref, &trailer.dict, &opts.limits);
272
273 diags.extend(&store.drain_diags());
274
275 Ok(Document {
276 bytes: body,
277 trailer,
278 store,
279 version,
280 header_offset: header_offset as u64,
281 xref_shape,
282 encrypt,
283 page_count,
284 pages: Mutex::new(PageCache {
285 slots: vec![None; usize::try_from(page_count).unwrap_or(0)],
286 poisoned: false,
287 }),
288 diags,
289 })
290}
291
292/// Find `%PDF` within the first `limits.header_scan` bytes.
293fn find_header(bytes: &[u8], limits: &Limits) -> Option<usize> {
294 let window = usize::try_from(limits.header_scan).unwrap_or(usize::MAX);
295 let last = bytes.len().checked_sub(4)?.min(window);
296 (0..=last).find(|&i| bytes.get(i..i + 4) == Some(b"%PDF"))
297}
298
299/// Read the version digits out of `%PDF-M.N`.
300///
301/// Never validated: a header claiming version 9.9 opens like any other, and a
302/// non-digit contributes nothing.
303///
304/// This is the one place in the workspace that knows the `major × 10 + minor`
305/// packing the old `Document::version() -> u8` published: the digits are read,
306/// packed, and immediately unpacked into a [`PdfVersion`]. The round trip is
307/// kept rather than removed because `0` — the answer when neither digit is
308/// readable — is what distinguishes "no version declared" from version 0.0,
309/// and collapsing the two would change which documents the writer gives its
310/// 1.7 fallback to.
311fn read_version(body: &[u8]) -> Option<PdfVersion> {
312 let digit = |i: usize| -> u8 {
313 body.get(i)
314 .filter(|b| b.is_ascii_digit())
315 .map_or(0, |b| b - b'0')
316 };
317 match digit(5).saturating_mul(10).saturating_add(digit(7)) {
318 0 => None,
319 packed => Some(PdfVersion::new(packed / 10, packed % 10)),
320 }
321}
322
323/// Build the security handler the trailer's `/Encrypt` calls for.
324fn build_security(
325 body: &ByteSpan,
326 xref: &Arc<Xref>,
327 trailer: &Dict,
328 opts: &LoadOptions,
329 diags: &mut Diagnostics,
330) -> Result<SecurityHandler, LoadError> {
331 let Some(encrypt) = encrypt_dict(body, xref, trailer, &opts.limits) else {
332 return Ok(SecurityHandler::Identity);
333 };
334 // The handler name is type-checked before being resolved, so a `/Filter`
335 // written as a string is not the standard handler however it spells it.
336 if encrypt.name(names::FILTER) != Some(names::STANDARD) {
337 return Err(LoadError::UnsupportedEncryption(
338 encrypt
339 .name(names::FILTER)
340 .map_or_else(|| "unnamed".to_owned(), |n| n.as_text().into_owned()),
341 ));
342 }
343
344 let file_id = trailer
345 .array(names::ID, &NoResolve)
346 .and_then(|a| a.string_at(0).map(|s| s.bytes.to_vec()))
347 .unwrap_or_default();
348 let password = opts.password.clone().unwrap_or_default();
349
350 match SecurityHandler::from_encrypt_dict(&encrypt, &file_id, &password, &NoResolve) {
351 Ok(handler) => {
352 if handler.password_encoding() != pdfrum_crypt::PasswordEncoding::AsGiven {
353 diags.record(Severity::Recovered, DiagKind::PasswordReencoded, None);
354 }
355 Ok(handler)
356 }
357 Err(pdfrum_crypt::Error::WrongPassword) => Err(LoadError::WrongPassword),
358 Err(pdfrum_crypt::Error::UnsupportedHandler(name)) => Err(
359 LoadError::UnsupportedEncryption(String::from_utf8_lossy(&name).into_owned()),
360 ),
361 Err(e) => Err(LoadError::UnsupportedEncryption(e.to_string())),
362 }
363}
364
365/// The `/Encrypt` dictionary, written inline or reached through one
366/// reference.
367///
368/// Most files write it indirectly, so the reference has to be chased — but
369/// the real store does not exist yet, and could not read this dictionary if
370/// it did, since it would try to decrypt it with the key this dictionary
371/// defines. So the lookup goes through a throwaway store that decrypts
372/// nothing. That is not a shortcut: the encryption dictionary is the one
373/// object in a document that is always plaintext.
374fn encrypt_dict(
375 body: &ByteSpan,
376 xref: &Arc<Xref>,
377 trailer: &Dict,
378 limits: &Limits,
379) -> Option<Dict> {
380 encrypt_dict_located(body, xref, trailer, limits).map(|(d, _)| d)
381}
382
383/// The same lookup, additionally reporting whether the trailer held the
384/// dictionary **directly** rather than by reference.
385///
386/// A writer needs that second fact: an inline encryption dictionary has no
387/// object number of its own, so it must be promoted to a fresh indirect
388/// object before the trailer's `/Encrypt` can name it (ISO 32000-1 §7.6.1
389/// requires `/Encrypt` be indirect).
390fn encrypt_dict_located(
391 body: &ByteSpan,
392 xref: &Arc<Xref>,
393 trailer: &Dict,
394 limits: &Limits,
395) -> Option<(Dict, bool)> {
396 match trailer.raw(names::ENCRYPT)? {
397 Object::Dict(d) => Some((d.clone(), true)),
398 Object::Ref(r) => {
399 let plain = ObjectStore::new(
400 body.clone(),
401 Arc::clone(xref),
402 limits.clone(),
403 SecurityHandler::Identity,
404 );
405 Some((plain.get(r.num).ok()?.as_dict().cloned()?, false))
406 }
407 _ => None,
408 }
409}
410
411/// Record the metadata object as exempt from decryption when the document
412/// says its metadata is not encrypted.
413fn exempt_metadata(store: &mut ObjectStore, trailer: &Dict) {
414 if store.security().encrypt_metadata() {
415 return;
416 }
417 let Some(root) = trailer.reference(names::ROOT) else {
418 return;
419 };
420 let Ok(catalog) = store.get(root.num) else {
421 return;
422 };
423 if let Some(metadata) = catalog.as_dict().and_then(|d| d.reference(names::METADATA)) {
424 store.exempt_from_decryption(metadata.num);
425 }
426}
427
428/// Build a store over the table, with the metadata exemption applied.
429fn build_store(
430 body: &ByteSpan,
431 xref: &Arc<Xref>,
432 opts: &LoadOptions,
433 trailer: &Dict,
434 security: SecurityHandler,
435) -> Arc<ObjectStore> {
436 let mut store = ObjectStore::new(
437 body.clone(),
438 Arc::clone(xref),
439 opts.limits.clone(),
440 security,
441 );
442 exempt_metadata(&mut store, trailer);
443 Arc::new(store)
444}
445
446/// The document catalog, if the trailer names one reachably.
447///
448/// A `/Root` written as anything but a reference does not name a catalog,
449/// even when it is a perfectly good dictionary written inline: that reads as
450/// damage, and damage is what triggers the rebuild that finds a better one.
451fn catalog(store: &ObjectStore, trailer: &Dict) -> Option<Dict> {
452 let root = trailer.reference(names::ROOT)?;
453 store.get(root.num).ok()?.as_dict().cloned()
454}
455
456/// How many pages the catalog claims, or `None` when there is no usable
457/// catalog or it describes no pages at all.
458fn catalog_page_count(store: &ObjectStore, trailer: &Dict, limits: &Limits) -> Option<u32> {
459 let catalog = catalog(store, trailer)?;
460 let count = page_count_of(store, &catalog, limits);
461 (count > 0).then_some(count)
462}
463
464/// The number of pages under a catalog.
465///
466/// A catalog without `/Pages` has none. A `/Pages` node without `/Kids` is
467/// itself the single page — that is not a repair, it is what a file with one
468/// page and no tree means.
469fn page_count_of(store: &ObjectStore, catalog: &Dict, limits: &Limits) -> u32 {
470 let Some(pages) = catalog.dict(names::PAGES, store) else {
471 return 0;
472 };
473 if pages.raw(names::KIDS).is_none() {
474 return 1;
475 }
476 // The root counts as an ancestor from the start, so a kid pointing back
477 // at it is a loop rather than a subtree.
478 let mut ancestors = vec![pages.clone()];
479 count_subtree(store, &pages, limits, &mut ancestors).unwrap_or(0)
480}
481
482/// Count the leaves under a node, or `None` when the tree claims more pages
483/// than a document may have.
484///
485/// `/Count` is believed whenever it is positive and under the cap, without
486/// checking it against the tree — so a file that lies about its length
487/// reports the lie, and the individual lookups past its real end are what
488/// fail.
489///
490/// The `None` propagates all the way out rather than being absorbed as a
491/// zero: a subtree that overflows makes the *whole* document uncountable,
492/// which is why this returns an `Option` instead of saturating.
493///
494/// `ancestors` holds the nodes currently being descended through, and is the
495/// cycle guard — the only one, since there is no depth cap here. Note what it
496/// is *not*: a record of every node already seen. A node listed twice among
497/// one parent's `/Kids` is counted twice, because the second listing is a
498/// sibling rather than a loop, and a file whose tree shares subtrees that way
499/// really does have that many pages.
500fn count_subtree(
501 store: &ObjectStore,
502 node: &Dict,
503 limits: &Limits,
504 ancestors: &mut Vec<Dict>,
505) -> Option<u32> {
506 if let Some(count) = node.int(names::COUNT, store)
507 && count > 0
508 && count < i64::from(limits.max_page_count)
509 && let Ok(count) = u32::try_from(count)
510 {
511 return Some(count);
512 }
513
514 let Some(kids) = node.array(names::KIDS, store) else {
515 return Some(0);
516 };
517 let mut total: u32 = 0;
518 for kid in kids.iter() {
519 let Some(kid) = kid.resolve(store).ok().and_then(|k| k.as_dict().cloned()) else {
520 continue;
521 };
522 // Only a kid that is already an ancestor would loop.
523 if ancestors.contains(&kid) {
524 continue;
525 }
526 total = total.saturating_add(match node_kind(&kid) {
527 NodeKind::Branch => {
528 ancestors.push(kid.clone());
529 let under = count_subtree(store, &kid, limits, ancestors);
530 ancestors.pop();
531 under?
532 }
533 NodeKind::Leaf => 1,
534 });
535 if total >= limits.max_page_count {
536 return None;
537 }
538 }
539 Some(total)
540}
541
542/// What a page-tree node is.
543enum NodeKind {
544 /// An interior node whose `/Kids` hold more nodes.
545 Branch,
546 /// A page.
547 Leaf,
548}
549
550/// Classify a node, guessing when `/Type` does not say.
551///
552/// A node with `/Kids` is a branch and one without is a page, whatever its
553/// `/Type` claims — files write the wrong type often enough that the
554/// structure is the more reliable witness.
555fn node_kind(node: &Dict) -> NodeKind {
556 match node.name(names::TYPE) {
557 Some(t) if t == names::PAGES => NodeKind::Branch,
558 Some(t) if t == names::PAGE => NodeKind::Leaf,
559 _ => {
560 if node.contains_key(names::KIDS) {
561 NodeKind::Branch
562 } else {
563 NodeKind::Leaf
564 }
565 }
566 }
567}
568
569impl Document {
570 /// How many pages the document has.
571 ///
572 /// A `u32`, deliberately, and not a
573 /// [`PageIndex`](pdfrum_common::PageIndex): a count answers "how many"
574 /// and an index answers "which one", and the last valid index of a
575 /// three-page document is 2, not 3. Giving them one type would let each be
576 /// passed where the other is meant, which is what the newtype exists to
577 /// stop.
578 #[must_use]
579 pub fn page_count(&self) -> u32 {
580 self.page_count
581 }
582
583 /// The page at `index`, counting from zero.
584 ///
585 /// The tree is walked in order and the pages found along the way are
586 /// remembered, so reading a document front to back costs one traversal.
587 /// A kid that will not load as a dictionary still **consumes its slot**:
588 /// a missing page leaves a hole rather than shifting every page after it.
589 ///
590 /// Takes `impl Into<PageIndex>`, so `doc.page(0)` reads as it always has.
591 ///
592 /// # Errors
593 ///
594 /// [`Error::NoPage`] for an index past the count, or one the walk could
595 /// not reach.
596 pub fn page(&self, index: impl Into<PageIndex>) -> Result<PageDict, Error> {
597 let index = index.into();
598 let slot = usize::try_from(index.get()).unwrap_or(usize::MAX);
599 if index.get() >= self.page_count {
600 return Err(Error::NoPage(index));
601 }
602 let Ok(mut pages) = self.pages.lock() else {
603 return Err(Error::NoPage(index));
604 };
605 if let Some(Some(found)) = pages.slots.get(slot) {
606 return Ok(found.clone());
607 }
608 if pages.poisoned {
609 return Err(Error::NoPage(index));
610 }
611
612 self.walk_pages(&mut pages);
613 pages
614 .slots
615 .get(slot)
616 .and_then(Clone::clone)
617 .ok_or(Error::NoPage(index))
618 }
619
620 /// Walk the whole tree once, filling every slot it can reach.
621 fn walk_pages(&self, pages: &mut PageCache) {
622 let Some(root) = self.trailer.dict.reference(names::ROOT) else {
623 return;
624 };
625 let Ok(catalog) = self.store.get(root.num) else {
626 return;
627 };
628 let Some(node) = catalog
629 .as_dict()
630 .and_then(|d| d.dict(names::PAGES, &*self.store))
631 else {
632 return;
633 };
634
635 let mut next: usize = 0;
636 let mut ancestors = Vec::new();
637 let objref = catalog.as_dict().and_then(|d| d.reference(names::PAGES));
638 self.visit(&node, objref, pages, &mut next, 0, &mut ancestors);
639 }
640
641 /// Depth-first, in order, filling slots as leaves are reached.
642 ///
643 /// `ancestors` is the cycle guard: the nodes on the path from the root to
644 /// here. A node that reappears as a *sibling* is a second page, not a
645 /// loop, so only an ancestor stops the descent.
646 fn visit(
647 &self,
648 node: &Dict,
649 reference: Option<ObjRef>,
650 pages: &mut PageCache,
651 next: &mut usize,
652 depth: u32,
653 ancestors: &mut Vec<Dict>,
654 ) {
655 if *next >= pages.slots.len() {
656 return;
657 }
658 // A node without `/Kids` is where the walk stops, whatever it claims
659 // to be — but a node that claims `/Type /Pages` and has no children
660 // is describing a subtree that is not there, so no page comes of it.
661 // (`page_count` still counts such a root as one page; the lookup is
662 // what fails.)
663 if node.raw(names::KIDS).is_none() {
664 if matches!(node_kind(node), NodeKind::Branch) {
665 return;
666 }
667 if let Some(slot) = pages.slots.get_mut(*next) {
668 *slot = Some(PageDict {
669 dict: node.clone(),
670 reference,
671 });
672 }
673 *next += 1;
674 return;
675 }
676
677 // Only a node with children can be too deep, so the cap is checked
678 // after the leaf case rather than on the way in. Exceeding it stops
679 // every later lookup too, not just this one.
680 if depth >= self.store.limits().max_page_tree_depth {
681 // The oracle records the same fact in the same shape:
682 // `reached_max_page_level_ = true` at `cpdf_document.cpp:281-283`,
683 // after which its own later lookups fail too. `poisoned` is that
684 // flag; the diagnostic is how a caller finds out *why* the pages
685 // stopped resolving.
686 self.store
687 .note(Severity::Suspicious, DiagKind::PageTreeDepthExceeded, None);
688 pages.poisoned = true;
689 return;
690 }
691
692 let Some(kids) = node.array(names::KIDS, &*self.store) else {
693 return;
694 };
695 ancestors.push(node.clone());
696 for kid in kids.iter() {
697 let kid_ref = kid.as_ref_id();
698 let loaded = kid
699 .resolve(&*self.store)
700 .ok()
701 .and_then(|k| k.as_dict().cloned());
702 let Some(loaded) = loaded else {
703 // A kid that will not load still costs a slot, so a missing
704 // page leaves a hole rather than shifting every page after it.
705 *next += 1;
706 continue;
707 };
708 // Only a kid that is already an ancestor would loop; the same
709 // node appearing twice as a sibling is two pages. PDFium skips a
710 // kid it has already visited for the same reason
711 // (`cpdf_document.cpp:87-88`), as part of the same pass that
712 // rewrites a wrong `/Count` (`:111`) and guesses a missing `/Type`
713 // (`:60`) — all of it "fix the in-memory representation for page
714 // tree nodes that violate the spec".
715 if ancestors.contains(&loaded) {
716 self.store
717 .note(Severity::Recovered, DiagKind::PageTreeRepaired, None);
718 continue;
719 }
720 self.visit(&loaded, kid_ref, pages, next, depth + 1, ancestors);
721 if *next >= pages.slots.len() {
722 break;
723 }
724 }
725 ancestors.pop();
726 }
727
728 /// The trailer dictionary, merged across every section.
729 #[must_use]
730 pub fn trailer(&self) -> &Dict {
731 &self.trailer.dict
732 }
733
734 /// The object number the trailer came from; zero for a bare `trailer`
735 /// dictionary.
736 #[must_use]
737 pub fn trailer_object_number(&self) -> u32 {
738 self.trailer.object_number
739 }
740
741 /// The document catalog.
742 ///
743 /// # Errors
744 ///
745 /// [`Error::NoCatalog`] when the trailer names none.
746 pub fn catalog(&self) -> Result<Dict, Error> {
747 let root = self
748 .trailer
749 .dict
750 .reference(names::ROOT)
751 .ok_or(Error::NoCatalog)?;
752 self.store
753 .get(root.num)
754 .ok()
755 .and_then(|c| c.as_dict().cloned())
756 .ok_or(Error::NoCatalog)
757 }
758
759 /// The version the header declared: [`PdfVersion::PDF_1_7`] for
760 /// `%PDF-1.7`.
761 ///
762 /// Never validated — a header claiming 9.9 opens like any other and
763 /// reports 9.9. `None` means the header carried no readable digits at
764 /// all, which a file with no `%PDF` line and one with `%PDF-x.y` both
765 /// produce; the writer's fallback for that case is 1.7.
766 #[must_use]
767 pub fn version(&self) -> Option<PdfVersion> {
768 self.version
769 }
770
771 /// Where the `%PDF` header sat in the original file. Non-zero means
772 /// everything before it was ignored.
773 #[must_use]
774 pub fn header_offset(&self) -> u64 {
775 self.header_offset
776 }
777
778 /// What the object store has repaired since the file opened, as a running
779 /// total — read it *after* the work, not at load.
780 ///
781 /// [`Document::diags`] is the load-time snapshot and never changes. This
782 /// one grows, because the store is lazy: a wrong `/Length` or a bad table
783 /// offset is only discovered when a caller first reaches that object. It
784 /// clones rather than draining, so asking twice between two fetches gives
785 /// the same answer twice.
786 ///
787 /// ```
788 /// # use std::sync::Arc;
789 /// # use pdfrum_parser::{LoadOptions, load};
790 /// let bytes: Arc<[u8]> = Arc::from(&include_bytes!("../tests/files/minimal.pdf")[..]);
791 /// let doc = load(bytes, &LoadOptions::default())?;
792 /// // A clean file repairs nothing, before or after its pages are read.
793 /// let _ = doc.page(0);
794 /// assert!(doc.lazy_diagnostics().is_empty());
795 /// # Ok::<(), pdfrum_parser::LoadError>(())
796 /// ```
797 #[must_use]
798 pub fn lazy_diagnostics(&self) -> Diagnostics {
799 self.store.peek_diags()
800 }
801
802 /// Whether the cross-reference table came from the recovery scan rather
803 /// than the file's own sections. An incremental save is unsafe when it
804 /// did.
805 #[must_use]
806 pub fn xref_was_rebuilt(&self) -> bool {
807 self.xref_shape.rebuilt
808 }
809
810 /// Byte offset of the newest cross-reference section the load chained
811 /// from, or **0** when the table was rebuilt by scanning.
812 ///
813 /// This is what an incremental update writes as its `/Prev`, so the zero
814 /// carries meaning rather than being an absence: a rebuilt document has
815 /// no previous section worth naming, and the writer answers by emitting a
816 /// full table after the original bytes instead of a delta.
817 ///
818 /// ```
819 /// # use std::sync::Arc;
820 /// # use pdfrum_parser::{LoadOptions, load};
821 /// let bytes: Arc<[u8]> = Arc::from(&include_bytes!("../tests/files/minimal.pdf")[..]);
822 /// let doc = load(bytes, &LoadOptions::default())?;
823 /// assert!(doc.last_xref_offset() > 0);
824 /// assert!(!doc.xref_was_rebuilt());
825 /// # Ok::<(), pdfrum_parser::LoadError>(())
826 /// ```
827 #[must_use]
828 pub fn last_xref_offset(&self) -> u64 {
829 self.xref_shape.last_offset
830 }
831
832 /// Whether the document's **main** cross-reference — the newest section,
833 /// the one `startxref` names — was a stream rather than a classic table.
834 ///
835 /// Not "the chain contained a stream somewhere": a hybrid file whose
836 /// newest section is a classic table answers `false`. The writer reads it
837 /// to decide whether an incremental update appends a classic delta table
838 /// or folds the cross-reference into a stream object, and matching the
839 /// original keeps a reader that only understands one of the two working.
840 #[must_use]
841 pub fn main_xref_is_stream(&self) -> bool {
842 self.xref_shape.main_is_stream
843 }
844
845 /// The `/Encrypt` dictionary as the file wrote it, and whether the
846 /// trailer held it **directly** rather than by reference.
847 ///
848 /// Returned raw and undecrypted, because the encryption dictionary is the
849 /// one object in a document that is always plaintext. `Some` here does
850 /// not imply the document opened encrypted — a file can declare a handler
851 /// this reader answered with [`SecurityHandler::Identity`].
852 #[must_use]
853 pub fn encrypt_dict(&self) -> Option<(&Dict, bool)> {
854 self.encrypt.as_ref().map(|(d, inline)| (d, *inline))
855 }
856
857 /// What the document permits, for the password that opened it.
858 ///
859 /// The owner's own unrestricted view is
860 /// [`Document::owner_permissions`]. An unencrypted document permits
861 /// everything.
862 #[must_use]
863 pub fn permissions(&self) -> Permissions {
864 self.store.security().permissions()
865 }
866
867 /// What the document permits under the owner's view.
868 ///
869 /// Every permission, for a document the owner password opened; otherwise
870 /// the same answer as [`Document::permissions`].
871 #[must_use]
872 pub fn owner_permissions(&self) -> Permissions {
873 self.store.security().owner_permissions()
874 }
875
876 /// Whether the document is encrypted.
877 #[must_use]
878 pub fn is_encrypted(&self) -> bool {
879 !matches!(self.store.security(), SecurityHandler::Identity)
880 }
881
882 /// The security handler the password opened this document with.
883 ///
884 /// [`SecurityHandler::Identity`] for an unencrypted document, and for one
885 /// whose crypt filter is `/Identity`.
886 ///
887 /// The writer needs this to save an encrypted document *as encrypted*: it
888 /// re-enciphers every string and stream under the same handler, so the
889 /// result opens with the same password. It carries the file key, so it
890 /// is deliberately not `Clone`-friendly to hold onto — borrow it for the
891 /// length of a save and let it go.
892 #[must_use]
893 pub fn security_handler(&self) -> &SecurityHandler {
894 self.store.security()
895 }
896
897 /// The file, from its header onwards.
898 #[must_use]
899 pub fn bytes(&self) -> &[u8] {
900 &self.bytes
901 }
902
903 /// The object store, for fetching references.
904 #[must_use]
905 pub fn store(&self) -> &Arc<ObjectStore> {
906 &self.store
907 }
908
909 /// Where every object lives.
910 #[must_use]
911 pub fn xref(&self) -> &Xref {
912 self.store.xref()
913 }
914}
915
916impl Resolve for Document {
917 fn fetch(&self, r: ObjRef) -> Result<Arc<Object>, pdfrum_object::Error> {
918 self.store.fetch(r)
919 }
920}
921
922/// The content of `dict`'s page, decoded and joined, with the end offset of
923/// each `/Contents` element — read through `r`.
924pub fn content_segments(
925 dict: &PageDict,
926 r: &impl Resolve,
927 limits: &Limits,
928 diags: &mut Diagnostics,
929) -> (Vec<u8>, Vec<usize>) {
930 let Some(contents) = dict.dict.get(&pdfrum_object::Name::from("Contents"), r) else {
931 return (Vec::new(), Vec::new());
932 };
933 let mut out = Vec::new();
934 let mut ends = Vec::new();
935 let mut push = |object: &pdfrum_object::Object, out: &mut Vec<u8>, ends: &mut Vec<usize>| {
936 if let Some(stream) = object.as_stream() {
937 let decoded = pdfrum_filters::decode_chain(stream, 0, r, limits, diags);
938 out.extend_from_slice(&decoded.data);
939 // The separating space belongs to the element before it: it is
940 // what terminates a stream ending mid-token.
941 out.push(b' ');
942 }
943 ends.push(out.len());
944 };
945 let Some(direct) = contents.as_direct() else {
946 return (out, ends);
947 };
948 match direct {
949 pdfrum_object::Object::Stream(_) => push(direct, &mut out, &mut ends),
950 pdfrum_object::Object::Array(array) => {
951 for element in array.iter() {
952 if let Ok(resolved) = element.resolve(r) {
953 push(resolved.get(), &mut out, &mut ends);
954 } else {
955 // A dangling element still occupies an index, so the
956 // ones after it keep their numbers.
957 ends.push(out.len());
958 }
959 }
960 }
961 _ => {}
962 }
963 (out, ends)
964}
965
966/// The byte after the `%%EOF` that closes the revision whose `startxref`
967/// names `offset`.
968pub fn revision_end(bytes: &[u8], offset: u64) -> Option<usize> {
969 let needle = b"startxref";
970 let mut at = 0;
971 while let Some(found) = find(bytes.get(at..)?, needle) {
972 let start = at + found + needle.len();
973 let rest = bytes.get(start..)?;
974 let digits: String = rest
975 .iter()
976 .skip_while(|b| b.is_ascii_whitespace())
977 .take_while(|b| b.is_ascii_digit())
978 .map(|&b| char::from(b))
979 .collect();
980 if digits.parse::<u64>().ok() == Some(offset) {
981 let eof = find(rest, b"%%EOF")?;
982 let mut end = start + eof + b"%%EOF".len();
983 while bytes.get(end).is_some_and(|b| *b == b'\r' || *b == b'\n') {
984 end += 1;
985 }
986 return Some(end);
987 }
988 at = start;
989 }
990 None
991}
992
993fn find(haystack: &[u8], needle: &[u8]) -> Option<usize> {
994 haystack.windows(needle.len()).position(|w| w == needle)
995}
996
997#[cfg(test)]
998mod tests {
999 use super::{LoadError, LoadOptions, find_header, load, read_version};
1000 use pdfrum_common::{DiagKind, Limits, PdfVersion};
1001 use pdfrum_crypt::Permissions;
1002 use pdfrum_object::{Name, names};
1003 use std::sync::Arc;
1004
1005 fn open(bytes: &[u8]) -> Result<super::Document, LoadError> {
1006 load(Arc::from(bytes), &LoadOptions::default())
1007 }
1008
1009 /// A document with `count` pages under one `/Pages` node.
1010 fn build(count: usize) -> Vec<u8> {
1011 let mut out = Vec::new();
1012 out.extend_from_slice(b"%PDF-1.7\n");
1013 let mut offsets = vec![0usize];
1014
1015 offsets.push(out.len());
1016 out.extend_from_slice(b"1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n");
1017
1018 offsets.push(out.len());
1019 let kids: Vec<String> = (0..count).map(|i| format!("{} 0 R", i + 3)).collect();
1020 out.extend_from_slice(
1021 format!(
1022 "2 0 obj\n<< /Type /Pages /Count {count} /Kids [{}] /MediaBox [0 0 612 792] >>\nendobj\n",
1023 kids.join(" ")
1024 )
1025 .as_bytes(),
1026 );
1027
1028 for i in 0..count {
1029 offsets.push(out.len());
1030 out.extend_from_slice(
1031 format!(
1032 "{} 0 obj\n<< /Type /Page /Parent 2 0 R /PageNumber {i} >>\nendobj\n",
1033 i + 3
1034 )
1035 .as_bytes(),
1036 );
1037 }
1038
1039 let xref_at = out.len();
1040 out.extend_from_slice(format!("xref\n0 {}\n", offsets.len()).as_bytes());
1041 out.extend_from_slice(b"0000000000 65535 f \n");
1042 for offset in offsets.iter().skip(1) {
1043 out.extend_from_slice(format!("{offset:010} 00000 n \n").as_bytes());
1044 }
1045 out.extend_from_slice(
1046 format!(
1047 "trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref_at}\n%%EOF\n",
1048 offsets.len()
1049 )
1050 .as_bytes(),
1051 );
1052 out
1053 }
1054
1055 #[test]
1056 fn finds_a_header_at_the_start_or_after_junk() {
1057 assert_eq!(find_header(b"%PDF-1.7\n", &Limits::default()), Some(0));
1058 assert_eq!(find_header(b"junk%PDF-1.7\n", &Limits::default()), Some(4));
1059 assert_eq!(find_header(b"no header", &Limits::default()), None);
1060 }
1061
1062 #[test]
1063 fn version_digits_are_read_not_validated() {
1064 assert_eq!(read_version(b"%PDF-1.7\n"), Some(PdfVersion::PDF_1_7));
1065 assert_eq!(read_version(b"%PDF-2.0\n"), Some(PdfVersion::PDF_2_0));
1066 assert_eq!(read_version(b"%PDF-x.y\n"), None);
1067 // The private packing round-trips every digit pair the header can
1068 // spell, and only `0.0` — which is unreachable, since a `0` packed
1069 // value is reported as "no version" — is not a `Some`.
1070 for major in 0..=9u8 {
1071 for minor in 0..=9u8 {
1072 let header = format!("%PDF-{major}.{minor}\n");
1073 let expected = (major, minor) != (0, 0);
1074 assert_eq!(
1075 read_version(header.as_bytes()),
1076 expected.then(|| PdfVersion::new(major, minor)),
1077 "header {header:?}"
1078 );
1079 }
1080 }
1081 }
1082
1083 #[test]
1084 fn a_file_without_a_header_is_not_a_pdf() {
1085 assert_eq!(open(b"just some bytes").err(), Some(LoadError::NotPdf));
1086 // A header at the very end with no room for a document.
1087 assert_eq!(open(b"%PDF").err(), Some(LoadError::NotPdf));
1088 }
1089
1090 #[test]
1091 fn opens_a_document_and_counts_its_pages() {
1092 let doc = open(&build(3)).expect("document");
1093 assert_eq!(doc.page_count(), 3);
1094 assert_eq!(doc.version(), Some(PdfVersion::PDF_1_7));
1095 assert!(!doc.xref_was_rebuilt());
1096 assert!(!doc.is_encrypted());
1097 assert_eq!(doc.permissions(), Permissions::ALL);
1098 }
1099
1100 #[test]
1101 fn reads_pages_in_order() {
1102 let doc = open(&build(5)).expect("document");
1103 let number = Name::from("PageNumber");
1104 for i in 0..5u32 {
1105 let page = doc.page(i).expect("page");
1106 assert_eq!(page.dict.direct_int(&number), Some(i64::from(i)));
1107 }
1108 assert!(doc.page(5).is_err());
1109 }
1110
1111 #[test]
1112 fn reads_pages_in_reverse_and_out_of_order() {
1113 let doc = open(&build(5)).expect("document");
1114 let number = Name::from("PageNumber");
1115 for i in (0..5u32).rev() {
1116 assert_eq!(
1117 doc.page(i).expect("page").dict.direct_int(&number),
1118 Some(i64::from(i))
1119 );
1120 }
1121 // An out-of-range lookup must not poison the ones after it.
1122 assert!(doc.page(99).is_err());
1123 assert_eq!(doc.page(3).expect("page").dict.direct_int(&number), Some(3));
1124 }
1125
1126 #[test]
1127 fn a_count_larger_than_the_tree_reports_the_lie() {
1128 let text = String::from_utf8_lossy(&build(3)).replace("/Count 3", "/Count 9");
1129 let doc = open(text.as_bytes()).expect("document");
1130 // The claimed count is what the document reports...
1131 assert_eq!(doc.page_count(), 9);
1132 // ...and the pages that exist still resolve.
1133 assert!(doc.page(0).is_ok());
1134 assert!(doc.page(2).is_ok());
1135 // The ones past the real tree do not.
1136 assert!(doc.page(3).is_err());
1137 assert!(doc.page(8).is_err());
1138 // And the real ones still work afterwards.
1139 assert!(doc.page(2).is_ok());
1140 }
1141
1142 #[test]
1143 fn a_kids_less_pages_node_counts_as_a_page_it_cannot_produce() {
1144 // Counting and looking up disagree here, and both are right. The
1145 // count treats a `/Pages` node with no `/Kids` as the document's one
1146 // page, but the walk refuses to hand back a node that calls itself a
1147 // branch — so the document reports one page and has none.
1148 let file = b"%PDF-1.7\n\
1149 1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n\
1150 2 0 obj\n<< /Type /Pages /Count 3 >>\nendobj\n\
1151 trailer\n<< /Root 1 0 R >>\nstartxref\n0\n%%EOF\n";
1152 let doc = open(file).expect("document");
1153 assert_eq!(doc.page_count(), 1);
1154 assert!(doc.page(0).is_err());
1155 assert!(doc.page(1).is_err());
1156 }
1157
1158 #[test]
1159 fn a_kids_less_node_that_does_not_claim_to_be_a_branch_is_a_page() {
1160 // The same shape without the `/Type /Pages` claim: the node has no
1161 // children, so it is the page itself and the lookup succeeds.
1162 let file = b"%PDF-1.7\n\
1163 1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n\
1164 2 0 obj\n<< /MediaBox [0 0 10 10] >>\nendobj\n\
1165 trailer\n<< /Root 1 0 R >>\nstartxref\n0\n%%EOF\n";
1166 let doc = open(file).expect("document");
1167 assert_eq!(doc.page_count(), 1);
1168 assert!(doc.page(0).is_ok());
1169 }
1170
1171 #[test]
1172 fn a_catalog_without_pages_will_not_open() {
1173 let file = b"%PDF-1.7\n\
1174 1 0 obj\n<< /Type /Catalog >>\nendobj\n\
1175 trailer\n<< /Root 1 0 R >>\nstartxref\n0\n%%EOF\n";
1176 assert!(matches!(open(file), Err(LoadError::Broken(_))));
1177 }
1178
1179 #[test]
1180 fn a_root_written_inline_does_not_name_a_catalog() {
1181 let file = b"%PDF-1.7\n\
1182 1 0 obj\n<< /Type /Page >>\nendobj\n\
1183 trailer\n<< /Root << /Type /Catalog /Pages 2 0 R >> >>\n\
1184 startxref\n0\n%%EOF\n";
1185 assert!(matches!(open(file), Err(LoadError::Broken(_))));
1186 }
1187
1188 #[test]
1189 fn a_broken_start_xref_still_opens_the_document() {
1190 let text = String::from_utf8_lossy(&build(2)).into_owned();
1191 let broken = text
1192 .replace("%%EOF", "")
1193 .replace("startxref\n", "startxref\n1\n");
1194 let doc = open(broken.as_bytes()).expect("document");
1195 assert!(doc.xref_was_rebuilt());
1196 assert_eq!(doc.page_count(), 2);
1197 assert!(doc.diags.contains(&DiagKind::XrefRebuilt));
1198 }
1199
1200 #[test]
1201 fn a_header_after_junk_shifts_every_offset() {
1202 let mut file = vec![b'x'; 100];
1203 file.extend_from_slice(&build(2));
1204 let doc = open(&file).expect("document");
1205 assert_eq!(doc.header_offset(), 100);
1206 assert_eq!(doc.page_count(), 2);
1207 assert!(doc.diags.contains(&DiagKind::HeaderOffset));
1208 }
1209
1210 #[test]
1211 fn inheritable_attributes_come_from_the_parent() {
1212 let doc = open(&build(2)).expect("document");
1213 let page = doc.page(0).expect("page");
1214 // The page states no /MediaBox; its /Pages parent does.
1215 let inherited = page
1216 .inherited(names::MEDIA_BOX, &doc)
1217 .expect("inherited media box");
1218 let array = inherited.as_array().expect("array");
1219 assert_eq!(array.number_at(2), Some(612.0));
1220 assert_eq!(array.number_at(3), Some(792.0));
1221 // A key nobody states is absent.
1222 assert!(page.inherited(names::ROTATE, &doc).is_none());
1223 }
1224
1225 #[test]
1226 fn a_pages_reference_is_reported() {
1227 let doc = open(&build(1)).expect("document");
1228 assert_eq!(doc.page(0).expect("page").reference.map(|r| r.num), Some(3));
1229 }
1230
1231 #[test]
1232 fn documents_are_send_and_sync() {
1233 fn assert_both<T: Send + Sync>() {}
1234 assert_both::<super::Document>();
1235 }
1236
1237 #[test]
1238 fn never_panics_on_arbitrary_bytes() {
1239 let seeds: &[&[u8]] = &[
1240 b"",
1241 b"%PDF",
1242 b"%PDF-1.7",
1243 b"%PDF-1.7\nstartxref\n0\n%%EOF",
1244 b"%PDF-1.7\ntrailer<</Root 1 0 R>>",
1245 b"%PDF-1.7\n1 0 obj<</Length 1 0 R>>stream\n",
1246 b"%PDF-1.7\n\x00\xff\x80\x0b",
1247 ];
1248 for seed in seeds {
1249 let _ = open(seed);
1250 }
1251 }
1252}