Skip to main content

pdfboss_core/
document.rs

1//! The document model: loading, object resolution with caching, the
2//! lazily-flattened page tree with attribute inheritance, and document
3//! metadata.
4
5use crate::hash::{FastMap, FastSet};
6use std::cell::{OnceCell, RefCell};
7use std::path::Path;
8use std::rc::Rc;
9
10use crate::crypt::Decryptor;
11use crate::elements::Span;
12use crate::error::{Error, Result};
13use crate::filters;
14use crate::geom::Rect;
15use crate::object::{decode_text_string, Dict, ObjRef, Object, Stream};
16use crate::objstm;
17use crate::parser::{Parser, Resolve};
18use crate::xref::{load_xref, Xref, XrefEntry};
19
20/// Default page size when `/MediaBox` is absent or invalid: US Letter.
21const US_LETTER: Rect = Rect::new(0.0, 0.0, 612.0, 792.0);
22
23/// Reference-chase depth limit for [`Document::resolve`].
24const MAX_RESOLVE_DEPTH: usize = 32;
25
26/// Page-tree traversal depth cap.
27const MAX_TREE_DEPTH: usize = 256;
28
29/// A loaded PDF document.
30pub struct Document {
31    data: Vec<u8>,
32    version: (u8, u8),
33    xref: Xref,
34    /// Interior cache of fetched indirect objects.
35    cache: RefCell<FastMap<(u32, u16), Rc<Object>>>,
36    /// Object numbers currently being parsed, guarding re-entrant fetches
37    /// (e.g. a stream whose `/Length` refers back to the stream itself).
38    loading: RefCell<FastSet<u32>>,
39    /// Decoded object streams, keyed by their stream object number, so a
40    /// stream is decompressed and its header parsed at most once even when
41    /// many compressed objects are read from it.
42    objstms: RefCell<FastMap<u32, Rc<objstm::ObjStm>>>,
43    /// Present when the file uses the Standard security handler (RC4 or AES)
44    /// and opens under the empty user password; decrypts strings and stream
45    /// data as objects are loaded from the file.
46    decryptor: Option<Decryptor>,
47    /// The flattened page tree, built lazily on the first page access so that
48    /// merely opening a document (or reading its page count) never parses
49    /// every page dictionary. See [`Document::pages`].
50    pages: OnceCell<Vec<PageRec>>,
51}
52
53/// The flattened, inheritance-applied record for one page.
54struct PageRec {
55    obj_ref: Option<ObjRef>,
56    media_box: Rect,
57    crop_box: Rect,
58    rotate: i32,
59    resources: Dict,
60    dict: Dict,
61}
62
63/// Attributes inherited down the page tree (ISO 32000 §7.7.3.4).
64#[derive(Clone, Default)]
65struct Inherited {
66    resources: Option<Dict>,
67    media_box: Option<Rect>,
68    crop_box: Option<Rect>,
69    rotate: Option<i32>,
70}
71
72/// Parses the `%PDF-x.y` header, scanning the first 1 KiB; absent or
73/// malformed headers default to version 1.4.
74fn parse_version(data: &[u8]) -> (u8, u8) {
75    try_parse_version(data).unwrap_or((1, 4))
76}
77
78fn try_parse_version(data: &[u8]) -> Option<(u8, u8)> {
79    let window = &data[..data.len().min(1024)];
80    let pos = memchr::memmem::find(window, b"%PDF-")?;
81    let rest = &window[pos + 5..];
82    let (major, used) = read_version_component(rest)?;
83    if rest.get(used) != Some(&b'.') {
84        return None;
85    }
86    let (minor, _) = read_version_component(&rest[used + 1..])?;
87    Some((major, minor))
88}
89
90/// Reads a run of 1–3 ASCII digits as a `u8`, returning the value and the
91/// number of bytes consumed.
92fn read_version_component(bytes: &[u8]) -> Option<(u8, usize)> {
93    let end = bytes
94        .iter()
95        .position(|b| !b.is_ascii_digit())
96        .unwrap_or(bytes.len());
97    if end == 0 || end > 3 {
98        return None;
99    }
100    let value = std::str::from_utf8(&bytes[..end]).ok()?.parse().ok()?;
101    Some((value, end))
102}
103
104/// Normalizes a `/Rotate` value to one of {0, 90, 180, 270}; values that
105/// are not multiples of 90 fall back to 0 (lenient).
106fn normalize_rotation(deg: i32) -> i32 {
107    let r = deg.rem_euclid(360);
108    if r % 90 == 0 {
109        r
110    } else {
111        0
112    }
113}
114
115impl Document {
116    /// Loads a document from bytes: locates the `%PDF-x.y` header (scanning
117    /// the first 1 KiB, defaulting to 1.4), loads the xref, and sets up
118    /// decryption for files using the Standard security handler (RC4 or AES)
119    /// under the empty user password (password-protected files yield
120    /// [`Error::Encrypted`]).
121    ///
122    /// The page tree is **not** walked here: it is flattened lazily on the
123    /// first page access, so opening a document (or reading `page_count`) does
124    /// not parse every page dictionary.
125    pub fn load(data: Vec<u8>) -> Result<Document> {
126        let version = parse_version(&data);
127        let xref = load_xref(&data)?;
128        let mut doc = Document {
129            data,
130            version,
131            xref,
132            cache: RefCell::new(FastMap::default()),
133            loading: RefCell::new(FastSet::default()),
134            objstms: RefCell::new(FastMap::default()),
135            decryptor: None,
136            pages: OnceCell::new(),
137        };
138        if doc
139            .xref
140            .trailer
141            .get("Encrypt")
142            .is_some_and(|o| !o.is_null())
143        {
144            doc.setup_decryption()?;
145        }
146        Ok(doc)
147    }
148
149    /// Configures decryption for an encrypted file. Supports the Standard
150    /// security handler with RC4 (`/V` 1–2), AESV2 (`/V` 4) and AESV3 (`/V` 5)
151    /// under the empty user password; a required password is reported as
152    /// [`Error::Encrypted`]. Must run before any content object is fetched, and
153    /// reads `/Encrypt` and `/ID` while decryption is still off (those values
154    /// are stored unencrypted).
155    fn setup_decryption(&mut self) -> Result<()> {
156        let enc_obj = self
157            .xref
158            .trailer
159            .get("Encrypt")
160            .cloned()
161            .unwrap_or(Object::Null);
162        let enc = self.resolve(&enc_obj)?;
163        let enc_dict = enc.as_dict().ok_or(Error::Encrypted)?;
164        let id0: Vec<u8> = self
165            .xref
166            .trailer
167            .get("ID")
168            .and_then(Object::as_array)
169            .and_then(<[Object]>::first)
170            .and_then(Object::as_str_bytes)
171            .unwrap_or(&[])
172            .to_vec();
173        match Decryptor::from_standard(enc_dict, &id0) {
174            Some(dec) => {
175                self.decryptor = Some(dec);
176                // Objects fetched while resolving /Encrypt were cached without
177                // decryption; drop them so they are re-read through the
178                // decrypting path if referenced again.
179                self.cache.borrow_mut().clear();
180                Ok(())
181            }
182            None => Err(Error::Encrypted),
183        }
184    }
185
186    /// Reads the file at `path` and loads it via [`Document::load`].
187    pub fn open(path: impl AsRef<Path>) -> Result<Document> {
188        Document::load(std::fs::read(path)?)
189    }
190
191    /// The PDF version from the header, e.g. `(1, 7)`.
192    pub fn version(&self) -> (u8, u8) {
193        self.version
194    }
195
196    /// Raw bytes of the loaded file.
197    pub fn bytes(&self) -> &[u8] {
198        &self.data
199    }
200
201    /// The merged cross-reference table and trailer.
202    pub fn xref(&self) -> &Xref {
203        &self.xref
204    }
205
206    /// Fetches an indirect object by reference (xref lookup, object-stream
207    /// indirection, cached). A generation mismatch between the request and
208    /// the file is tolerated (lenient).
209    pub fn get(&self, r: ObjRef) -> Result<Object> {
210        if let Some(cached) = self.cache.borrow().get(&(r.num, r.gen)) {
211            return Ok((**cached).clone());
212        }
213        if !self.loading.borrow_mut().insert(r.num) {
214            return Err(Error::CircularReference(r.num));
215        }
216        let result = self.load_object(r);
217        self.loading.borrow_mut().remove(&r.num);
218        let object = result?;
219        self.cache
220            .borrow_mut()
221            .insert((r.num, r.gen), Rc::new(object.clone()));
222        Ok(object)
223    }
224
225    /// Uncached fetch: parses the object at its file offset or extracts it
226    /// from its containing object stream.
227    fn load_object(&self, r: ObjRef) -> Result<Object> {
228        match self.xref.get(r.num) {
229            None | Some(XrefEntry::Free) => Err(Error::ObjectNotFound(r.num, r.gen)),
230            Some(XrefEntry::InFile { offset, .. }) => {
231                let offset = usize::try_from(offset)
232                    .ok()
233                    .filter(|&o| o < self.data.len())
234                    .ok_or(Error::ObjectNotFound(r.num, r.gen))?;
235                self.object_at_spanned(offset).map(|parsed| parsed.1)
236            }
237            Some(XrefEntry::InStream { stream_num, index }) => {
238                self.load_from_object_stream(stream_num, index)
239            }
240        }
241    }
242
243    /// Parses the indirect object at `offset`, applying decryption, and
244    /// reports the byte range consumed (`N G obj … endobj`).
245    pub(crate) fn object_at_spanned(&self, offset: usize) -> Result<(ObjRef, Object, Span)> {
246        let mut parser = Parser::at(&self.data, offset);
247        let (r, mut object) = parser.parse_indirect(self)?;
248        // Objects stored directly in the file carry encrypted strings and
249        // stream data; decrypt with this object's key. (Objects living in
250        // object streams are decrypted with their container.)
251        if let Some(dec) = &self.decryptor {
252            dec.decrypt_object(&mut object, r.num, r.gen);
253        }
254        Ok((r, object, Span::new(offset as u64, parser.pos() as u64)))
255    }
256
257    /// The decoded, header-parsed object stream `stream_num`, built at most
258    /// once and cached.
259    pub(crate) fn objstm_handle(&self, stream_num: u32) -> Result<Rc<objstm::ObjStm>> {
260        if let Some(stm) = self.objstms.borrow().get(&stream_num) {
261            return Ok(Rc::clone(stm));
262        }
263        let container = self.get(ObjRef {
264            num: stream_num,
265            gen: 0,
266        })?;
267        let stream = container.as_stream().ok_or_else(|| Error::TypeMismatch {
268            expected: "stream",
269            found: type_name(&container),
270        })?;
271        let n = self
272            .resolve(stream.dict.get("N").unwrap_or(&Object::Null))?
273            .as_int()
274            .and_then(|v| usize::try_from(v).ok())
275            .ok_or(Error::MissingKey("N"))?;
276        let first = self
277            .resolve(stream.dict.get("First").unwrap_or(&Object::Null))?
278            .as_int()
279            .and_then(|v| usize::try_from(v).ok())
280            .ok_or(Error::MissingKey("First"))?;
281        let decoded = self.stream_data(stream)?;
282        let stm = Rc::new(objstm::ObjStm::parse(decoded, n, first)?);
283        self.objstms
284            .borrow_mut()
285            .insert(stream_num, Rc::clone(&stm));
286        Ok(stm)
287    }
288
289    /// Extracts a compressed object from the object stream `stream_num`.
290    fn load_from_object_stream(&self, stream_num: u32, index: u32) -> Result<Object> {
291        self.objstm_handle(stream_num)?.object(index)
292    }
293
294    /// Chases reference chains with a depth guard of `MAX_RESOLVE_DEPTH`
295    /// (beyond that: [`Error::CircularReference`]); a reference to a missing
296    /// or unreadable object resolves to `Null` (lenient).
297    pub fn resolve(&self, o: &Object) -> Result<Object> {
298        let mut current = o.clone();
299        let mut last_num = 0;
300        for _ in 0..MAX_RESOLVE_DEPTH {
301            match current {
302                Object::Ref(r) => {
303                    last_num = r.num;
304                    current = match self.get(r) {
305                        Ok(object) => object,
306                        Err(Error::CircularReference(n)) => {
307                            return Err(Error::CircularReference(n))
308                        }
309                        Err(_) => return Ok(Object::Null),
310                    };
311                }
312                other => return Ok(other),
313            }
314        }
315        Err(Error::CircularReference(last_num))
316    }
317
318    /// Decodes a stream's data through its filter chain, resolving indirect
319    /// filter parameters against this document.
320    pub fn stream_data(&self, s: &Stream) -> Result<Vec<u8>> {
321        filters::decode_stream(s, self)
322    }
323
324    /// The flattened page tree, built once on first access.
325    fn pages(&self) -> &[PageRec] {
326        self.pages.get_or_init(|| self.flatten_pages())
327    }
328
329    /// Number of pages.
330    ///
331    /// Reports the page tree's declared `/Count` — the same value mature
332    /// engines return — without walking the tree, so it is cheap on an
333    /// otherwise-untouched document. If `/Count` is absent or implausible the
334    /// tree is flattened and its true (lenient, cycle- and depth-guarded)
335    /// length is returned instead. Once the tree has been flattened for any
336    /// reason, that flattened length is authoritative.
337    pub fn page_count(&self) -> usize {
338        if let Some(pages) = self.pages.get() {
339            return pages.len();
340        }
341        if let Some(count) = self.declared_page_count() {
342            return count;
343        }
344        self.pages().len()
345    }
346
347    /// Reads the page tree root's `/Count` cheaply (Root → `/Pages` →
348    /// `/Count`) without descending into `/Kids`. Returns `None` when the
349    /// entry is missing, non-integer, negative, or larger than the file could
350    /// possibly hold (a corrupt count), so the caller falls back to a real
351    /// walk.
352    fn declared_page_count(&self) -> Option<usize> {
353        let root = self.xref.trailer.get("Root")?;
354        let catalog = self.resolve(root).ok()?;
355        let pages = self.resolve(catalog.as_dict()?.get("Pages")?).ok()?;
356        let count = usize::try_from(self.int_value(pages.as_dict()?, "Count")?).ok()?;
357        // A page occupies at least a handful of bytes on disk, so a count that
358        // exceeds the file length is corrupt: fall back to walking the tree.
359        (count <= self.data.len()).then_some(count)
360    }
361
362    /// The page at 0-based `index`.
363    pub fn page(&self, index: usize) -> Result<Page> {
364        let pages = self.pages();
365        let rec = pages
366            .get(index)
367            .ok_or(Error::PageNotFound(index, pages.len()))?;
368        Ok(Page {
369            index,
370            media_box: rec.media_box,
371            crop_box: rec.crop_box,
372            rotate: rec.rotate,
373            resources: rec.resources.clone(),
374            dict: rec.dict.clone(),
375            obj_ref: rec.obj_ref,
376        })
377    }
378
379    /// Document metadata from the trailer `/Info` dictionary (lenient:
380    /// absent or malformed entries are simply `None`).
381    pub fn metadata(&self) -> Metadata {
382        let mut meta = Metadata::default();
383        let Some(info) = self.xref.trailer.get("Info") else {
384            return meta;
385        };
386        let Ok(info) = self.resolve(info) else {
387            return meta;
388        };
389        let Some(dict) = info.as_dict() else {
390            return meta;
391        };
392        meta.title = self.meta_string(dict, "Title");
393        meta.author = self.meta_string(dict, "Author");
394        meta.subject = self.meta_string(dict, "Subject");
395        meta.keywords = self.meta_string(dict, "Keywords");
396        meta.creator = self.meta_string(dict, "Creator");
397        meta.producer = self.meta_string(dict, "Producer");
398        meta.creation_date = self.meta_string(dict, "CreationDate");
399        meta.mod_date = self.meta_string(dict, "ModDate");
400        meta
401    }
402
403    /// Reads `key` from an info dictionary as a decoded text string.
404    fn meta_string(&self, dict: &Dict, key: &str) -> Option<String> {
405        let value = self.resolve(dict.get(key)?).ok()?;
406        Some(decode_text_string(value.as_str_bytes()?))
407    }
408
409    /// Flattens the page tree by iterative depth-first traversal of `/Kids`
410    /// with a visited-reference cycle guard and a depth cap, applying
411    /// attribute inheritance. Any structural problem simply truncates or
412    /// skips (lenient) — this never fails.
413    fn flatten_pages(&self) -> Vec<PageRec> {
414        let mut pages = Vec::new();
415        let Some(root) = self.xref.trailer.get("Root") else {
416            return pages;
417        };
418        let Ok(catalog) = self.resolve(root) else {
419            return pages;
420        };
421        let Some(tree_root) = catalog.as_dict().and_then(|d| d.get("Pages")) else {
422            return pages;
423        };
424        let mut visited: FastSet<ObjRef> = FastSet::default();
425        let mut stack: Vec<(Object, Inherited, usize)> =
426            vec![(tree_root.clone(), Inherited::default(), 0)];
427        while let Some((node, mut inherited, depth)) = stack.pop() {
428            if depth > MAX_TREE_DEPTH {
429                continue;
430            }
431            let node_ref = if let Object::Ref(r) = node {
432                Some(r)
433            } else {
434                None
435            };
436            if let Some(r) = node_ref {
437                if !visited.insert(r) {
438                    continue; // cycle: this node was already traversed
439                }
440            }
441            let Ok(resolved) = self.resolve(&node) else {
442                continue;
443            };
444            let Some(dict) = resolved.as_dict() else {
445                continue;
446            };
447            if let Some(res) = self.dict_value(dict, "Resources") {
448                inherited.resources = Some(res);
449            }
450            if let Some(mb) = self.rect_value(dict, "MediaBox") {
451                inherited.media_box = Some(mb);
452            }
453            if let Some(cb) = self.rect_value(dict, "CropBox") {
454                inherited.crop_box = Some(cb);
455            }
456            if let Some(rot) = self.int_value(dict, "Rotate") {
457                inherited.rotate = Some(rot);
458            }
459            let is_page = dict.get_name("Type").is_some_and(|n| n.0 == "Page");
460            let kids = if is_page {
461                None
462            } else {
463                self.array_value(dict, "Kids")
464            };
465            match kids {
466                Some(kids) => {
467                    // Reverse push so pop order matches document order.
468                    for kid in kids.iter().rev() {
469                        stack.push((kid.clone(), inherited.clone(), depth + 1));
470                    }
471                }
472                None => pages.push(make_page_rec(node_ref, dict.clone(), &inherited)),
473            }
474        }
475        pages
476    }
477
478    /// Resolves `dict[key]` to a dictionary, if present and well-formed.
479    fn dict_value(&self, dict: &Dict, key: &str) -> Option<Dict> {
480        self.resolve(dict.get(key)?).ok()?.as_dict().cloned()
481    }
482
483    /// Resolves `dict[key]` to an array, if present and well-formed.
484    fn array_value(&self, dict: &Dict, key: &str) -> Option<Vec<Object>> {
485        match self.resolve(dict.get(key)?).ok()? {
486            Object::Array(items) => Some(items),
487            _ => None,
488        }
489    }
490
491    /// Resolves `dict[key]` to an integer (reals truncate, lenient).
492    fn int_value(&self, dict: &Dict, key: &str) -> Option<i32> {
493        let v = self.resolve(dict.get(key)?).ok()?.as_f64()?;
494        if v.is_finite() {
495            Some(v as i32)
496        } else {
497            None
498        }
499    }
500
501    /// Resolves `dict[key]` to a normalized rectangle: a four-number array
502    /// whose elements may themselves be references.
503    fn rect_value(&self, dict: &Dict, key: &str) -> Option<Rect> {
504        let items = self.array_value(dict, key)?;
505        if items.len() != 4 {
506            return None;
507        }
508        let mut coords = [0.0f32; 4];
509        for (slot, item) in coords.iter_mut().zip(&items) {
510            let n = self.resolve(item).ok()?.as_f64()?;
511            if !n.is_finite() {
512                return None;
513            }
514            *slot = n as f32;
515        }
516        Some(Rect::new(coords[0], coords[1], coords[2], coords[3]).normalize())
517    }
518}
519
520/// Builds the final page record from a leaf dictionary and its inherited
521/// attributes, applying the spec defaults.
522fn make_page_rec(obj_ref: Option<ObjRef>, dict: Dict, inherited: &Inherited) -> PageRec {
523    let media_box = inherited
524        .media_box
525        .filter(|r| r.width() > 0.0 && r.height() > 0.0)
526        .unwrap_or(US_LETTER);
527    let crop_box = inherited
528        .crop_box
529        .and_then(|c| c.intersect(media_box))
530        .filter(|r| r.width() > 0.0 && r.height() > 0.0)
531        .unwrap_or(media_box);
532    PageRec {
533        obj_ref,
534        media_box,
535        crop_box,
536        rotate: normalize_rotation(inherited.rotate.unwrap_or(0)),
537        resources: inherited.resources.clone().unwrap_or_default(),
538        dict,
539    }
540}
541
542/// Human-readable object type name for error messages.
543fn type_name(o: &Object) -> &'static str {
544    match o {
545        Object::Null => "null",
546        Object::Bool(_) => "boolean",
547        Object::Int(_) => "integer",
548        Object::Real(_) => "real",
549        Object::String(_) => "string",
550        Object::Name(_) => "name",
551        Object::Array(_) => "array",
552        Object::Dict(_) => "dictionary",
553        Object::Stream(_) => "stream",
554        Object::Ref(_) => "reference",
555    }
556}
557
558impl Resolve for Document {
559    fn resolve_ref(&self, r: ObjRef) -> Option<Object> {
560        self.get(r).ok()
561    }
562}
563
564/// Document information from the trailer `/Info` dictionary. Only present,
565/// well-formed entries are populated.
566#[derive(Debug, Clone, Default, PartialEq, Eq)]
567pub struct Metadata {
568    pub title: Option<String>,
569    pub author: Option<String>,
570    pub subject: Option<String>,
571    pub keywords: Option<String>,
572    pub creator: Option<String>,
573    pub producer: Option<String>,
574    pub creation_date: Option<String>,
575    pub mod_date: Option<String>,
576}
577
578/// A single page with inherited attributes already applied.
579///
580/// Defaults: `media_box` falls back to US Letter (612x792) when absent or
581/// invalid, `crop_box` falls back to (and is intersected with) `media_box`,
582/// and `rotate` is normalized to one of {0, 90, 180, 270}.
583pub struct Page {
584    /// 0-based page index.
585    pub index: usize,
586    pub media_box: Rect,
587    pub crop_box: Rect,
588    pub rotate: i32,
589    /// The page's (inherited) `/Resources` dictionary.
590    pub resources: Dict,
591    dict: Dict,
592    obj_ref: Option<ObjRef>,
593}
594
595impl Page {
596    /// The page's indirect object reference, when the page came from an
597    /// indirect kid in the page tree (pages inlined directly into a `/Kids`
598    /// array have none).
599    pub fn object_ref(&self) -> Option<ObjRef> {
600        self.obj_ref
601    }
602
603    /// The page's decoded content: the `/Contents` stream, or all streams
604    /// of a `/Contents` array decoded and joined with `b"\n"`. A missing
605    /// `/Contents` yields empty content (lenient).
606    pub fn content(&self, doc: &Document) -> Result<Vec<u8>> {
607        let Some(contents) = self.dict.get("Contents") else {
608            return Ok(Vec::new());
609        };
610        match doc.resolve(contents)? {
611            Object::Stream(ref s) => doc.stream_data(s),
612            Object::Array(items) => {
613                let mut out = Vec::new();
614                let mut first = true;
615                for item in &items {
616                    let part = doc.resolve(item)?;
617                    let Some(stream) = part.as_stream() else {
618                        continue; // non-stream entries are skipped (lenient)
619                    };
620                    if !first {
621                        out.push(b'\n');
622                    }
623                    out.extend_from_slice(&doc.stream_data(stream)?);
624                    first = false;
625                }
626                Ok(out)
627            }
628            _ => Ok(Vec::new()),
629        }
630    }
631
632    /// Crop-box width and height, swapped when `/Rotate` is 90 or 270.
633    pub fn size(&self) -> (f32, f32) {
634        let (w, h) = (self.crop_box.width(), self.crop_box.height());
635        if self.rotate == 90 || self.rotate == 270 {
636            (h, w)
637        } else {
638            (w, h)
639        }
640    }
641
642    /// The raw page dictionary.
643    pub fn dict(&self) -> &Dict {
644        &self.dict
645    }
646}
647
648#[cfg(test)]
649mod tests {
650    use super::*;
651    use crate::parser::{NoResolve, Parser};
652    use crate::xref::XrefEntry;
653    use pdfboss_testkit::{multi_page_doc, objstm_doc, objstm_payload, simple_doc, PdfBuilder};
654
655    /// Replaces the first occurrence of `from` with `to`. Splicing happens
656    /// after the xref section, so byte offsets stay valid.
657    fn replace_once(data: &[u8], from: &[u8], to: &[u8]) -> Vec<u8> {
658        let pos = memchr::memmem::find(data, from).expect("pattern present in fixture");
659        let mut out = Vec::with_capacity(data.len() - from.len() + to.len());
660        out.extend_from_slice(&data[..pos]);
661        out.extend_from_slice(to);
662        out.extend_from_slice(&data[pos + from.len()..]);
663        out
664    }
665
666    fn contains(haystack: &[u8], needle: &[u8]) -> bool {
667        memchr::memmem::find(haystack, needle).is_some()
668    }
669
670    const FONT: &str = "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>";
671
672    #[test]
673    fn loads_simple_doc() {
674        let doc = Document::load(simple_doc("Greetings, cosmos!")).unwrap();
675        assert_eq!(doc.version(), (1, 7));
676        assert_eq!(doc.page_count(), 1);
677        let page = doc.page(0).unwrap();
678        assert_eq!(page.index, 0);
679        assert_eq!(page.media_box, Rect::new(0.0, 0.0, 612.0, 792.0));
680        assert_eq!(page.crop_box, page.media_box);
681        assert_eq!(page.rotate, 0);
682        assert_eq!(page.size(), (612.0, 792.0));
683        assert!(page.resources.get("Font").is_some());
684        let content = page.content(&doc).unwrap();
685        assert!(contains(&content, b"Greetings, cosmos!"));
686    }
687
688    #[test]
689    fn multi_page_ordering() {
690        let doc = Document::load(multi_page_doc(&["alpha", "beta", "gamma"])).unwrap();
691        assert_eq!(doc.page_count(), 3);
692        for (i, text) in ["alpha", "beta", "gamma"].iter().enumerate() {
693            let content = doc.page(i).unwrap().content(&doc).unwrap();
694            assert!(
695                contains(&content, text.as_bytes()),
696                "page {i} should show {text}"
697            );
698        }
699    }
700
701    #[test]
702    fn page_index_out_of_bounds() {
703        let doc = Document::load(simple_doc("x")).unwrap();
704        assert!(matches!(doc.page(5), Err(Error::PageNotFound(5, 1))));
705    }
706
707    #[test]
708    fn open_reads_from_disk() {
709        let dir = std::env::temp_dir();
710        let path = dir.join(format!("pdfboss-doc-test-{}.pdf", std::process::id()));
711        std::fs::write(&path, simple_doc("from disk")).unwrap();
712        let doc = Document::open(&path).unwrap();
713        std::fs::remove_file(&path).ok();
714        assert_eq!(doc.page_count(), 1);
715        let content = doc.page(0).unwrap().content(&doc).unwrap();
716        assert!(contains(&content, b"from disk"));
717        assert!(matches!(
718            Document::open(dir.join("pdfboss-doc-test-missing.pdf")),
719            Err(Error::Io(_))
720        ));
721    }
722
723    #[test]
724    fn encrypt_in_trailer_is_rejected() {
725        let data = replace_once(
726            &simple_doc("secret"),
727            b"trailer\n<< /Size",
728            b"trailer\n<< /Encrypt 9 0 R /Size",
729        );
730        assert!(matches!(Document::load(data), Err(Error::Encrypted)));
731    }
732
733    #[test]
734    fn metadata_utf16be_round_trip() {
735        let mut b = PdfBuilder::new();
736        b.object(1, "<< /Type /Catalog /Pages 2 0 R >>");
737        b.object(2, "<< /Type /Pages /Kids [] /Count 0 >>");
738        // /Title is UTF-16BE with BOM: "H\u{151}" (H + o with double acute).
739        b.object(6, "<< /Title <FEFF00480151> /Author (plain author) >>");
740        let data = replace_once(&b.build(1), b"<< /Size", b"<< /Info 6 0 R /Size");
741        let doc = Document::load(data).unwrap();
742        let meta = doc.metadata();
743        assert_eq!(meta.title.as_deref(), Some("H\u{151}"));
744        assert_eq!(meta.author.as_deref(), Some("plain author"));
745        assert_eq!(meta.subject, None);
746        assert_eq!(meta.keywords, None);
747        assert_eq!(meta.creation_date, None);
748    }
749
750    #[test]
751    fn metadata_without_info_is_all_none() {
752        let doc = Document::load(simple_doc("x")).unwrap();
753        assert_eq!(doc.metadata(), Metadata::default());
754    }
755
756    #[test]
757    fn missing_object_resolves_to_null() {
758        let doc = Document::load(simple_doc("x")).unwrap();
759        let missing = Object::Ref(ObjRef { num: 99, gen: 0 });
760        assert_eq!(doc.resolve(&missing).unwrap(), Object::Null);
761        assert!(matches!(
762            doc.get(ObjRef { num: 99, gen: 0 }),
763            Err(Error::ObjectNotFound(99, 0))
764        ));
765    }
766
767    #[test]
768    fn self_reference_is_circular() {
769        let mut b = PdfBuilder::new();
770        b.object(1, "<< /Type /Catalog >>");
771        b.object(6, "6 0 R");
772        let doc = Document::load(b.build(1)).unwrap();
773        let loops = Object::Ref(ObjRef { num: 6, gen: 0 });
774        assert!(matches!(
775            doc.resolve(&loops),
776            Err(Error::CircularReference(6))
777        ));
778    }
779
780    #[test]
781    fn generation_mismatch_is_tolerated() {
782        let doc = Document::load(simple_doc("x")).unwrap();
783        let catalog = doc.get(ObjRef { num: 1, gen: 7 }).unwrap();
784        let dict = catalog.as_dict().unwrap();
785        assert_eq!(dict.get_name("Type").map(|n| n.0.as_str()), Some("Catalog"));
786    }
787
788    #[test]
789    fn objects_in_object_streams_are_fetched() {
790        let mut b = PdfBuilder::new();
791        let (dict, payload) =
792            objstm_payload(&[(1, "<< /Type /Catalog /Pages 2 0 R >>"), (5, FONT)]);
793        b.stream(6, &dict, &payload);
794        b.object(2, "<< /Type /Pages /Kids [3 0 R] /Count 1 >>");
795        b.object(
796            3,
797            "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] \
798             /Resources << /Font << /F1 5 0 R >> >> /Contents 4 0 R >>",
799        );
800        b.stream(4, "", b"BT /F1 12 Tf (compressed hello) Tj ET");
801        let doc = Document::load(b.build_xref_stream(1)).unwrap();
802        assert_eq!(doc.page_count(), 1);
803        let page = doc.page(0).unwrap();
804        assert!(contains(&page.content(&doc).unwrap(), b"compressed hello"));
805        let font = doc.get(ObjRef { num: 5, gen: 0 }).unwrap();
806        assert_eq!(
807            font.as_dict()
808                .and_then(|d| d.get_name("BaseFont"))
809                .map(|n| n.0.as_str()),
810            Some("Helvetica")
811        );
812    }
813
814    #[test]
815    fn contents_array_is_joined_with_newlines() {
816        let mut b = PdfBuilder::new();
817        b.object(1, "<< /Type /Catalog /Pages 2 0 R >>");
818        b.object(2, "<< /Type /Pages /Kids [3 0 R] /Count 1 >>");
819        b.object(
820            3,
821            "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] \
822             /Contents [4 0 R null 5 0 R] >>",
823        );
824        b.stream(4, "", b"q");
825        b.stream(5, "", b"Q");
826        let doc = Document::load(b.build(1)).unwrap();
827        let content = doc.page(0).unwrap().content(&doc).unwrap();
828        assert_eq!(content, b"q\nQ", "streams joined by \\n, null skipped");
829    }
830
831    #[test]
832    fn inheritance_from_pages_node_and_rotate_swap() {
833        let mut b = PdfBuilder::new();
834        b.object(1, "<< /Type /Catalog /Pages 2 0 R >>");
835        b.object(
836            2,
837            "<< /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 \
838             /Resources << /Font << /F1 5 0 R >> >> /MediaBox [0 0 400 600] >>",
839        );
840        b.object(3, "<< /Type /Page /Parent 2 0 R >>");
841        b.object(4, "<< /Type /Page /Parent 2 0 R /Rotate 270 >>");
842        b.object(5, FONT);
843        let doc = Document::load(b.build(1)).unwrap();
844        assert_eq!(doc.page_count(), 2);
845
846        let first = doc.page(0).unwrap();
847        assert_eq!(first.media_box, Rect::new(0.0, 0.0, 400.0, 600.0));
848        assert_eq!(first.crop_box, first.media_box);
849        assert!(first.resources.get("Font").is_some(), "inherited resources");
850        assert_eq!(first.rotate, 0);
851        assert!(
852            first.content(&doc).unwrap().is_empty(),
853            "no /Contents means empty content"
854        );
855        assert_eq!(first.size(), (400.0, 600.0));
856
857        let second = doc.page(1).unwrap();
858        assert_eq!(second.rotate, 270);
859        assert_eq!(second.size(), (600.0, 400.0), "rotate 270 swaps w/h");
860    }
861
862    #[test]
863    fn crop_box_intersected_and_rotate_normalized() {
864        let mut b = PdfBuilder::new();
865        b.object(1, "<< /Type /Catalog /Pages 2 0 R >>");
866        b.object(2, "<< /Type /Pages /Kids [3 0 R 4 0 R 5 0 R] /Count 3 >>");
867        b.object(
868            3,
869            "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] \
870             /CropBox [100 100 400 400] /Rotate 450 >>",
871        );
872        b.object(4, "<< /Type /Page /Parent 2 0 R /Rotate -90 >>");
873        b.object(
874            5,
875            "<< /Type /Page /Parent 2 0 R /Rotate 45 /MediaBox [0 0 0 0] >>",
876        );
877        let doc = Document::load(b.build(1)).unwrap();
878
879        let clipped = doc.page(0).unwrap();
880        assert_eq!(clipped.crop_box, Rect::new(100.0, 100.0, 200.0, 200.0));
881        assert_eq!(clipped.rotate, 90, "450 normalizes to 90");
882        assert_eq!(clipped.size(), (100.0, 100.0));
883
884        assert_eq!(doc.page(1).unwrap().rotate, 270, "-90 normalizes to 270");
885        let odd = doc.page(2).unwrap();
886        assert_eq!(odd.rotate, 0, "non-multiple of 90 falls back to 0");
887        assert_eq!(odd.media_box, US_LETTER, "degenerate media box defaults");
888    }
889
890    #[test]
891    fn kids_cycle_truncates_without_hanging() {
892        let mut b = PdfBuilder::new();
893        b.object(1, "<< /Type /Catalog /Pages 2 0 R >>");
894        // 2 → 3 → {4, back to 2}: the back-edge must be ignored.
895        b.object(2, "<< /Type /Pages /Kids [3 0 R] /Count 1 >>");
896        b.object(3, "<< /Type /Pages /Kids [4 0 R 2 0 R] /Count 1 >>");
897        b.object(
898            4,
899            "<< /Type /Page /Parent 3 0 R /MediaBox [0 0 100 100] /Contents 5 0 R >>",
900        );
901        b.stream(5, "", b"0 0 50 50 re f");
902        let doc = Document::load(b.build(1)).unwrap();
903        assert_eq!(doc.page_count(), 1, "cycle back-edge yields no extra pages");
904        assert!(contains(
905            &doc.page(0).unwrap().content(&doc).unwrap(),
906            b"re f"
907        ));
908    }
909
910    #[test]
911    fn tree_depth_is_capped() {
912        let mut b = PdfBuilder::new();
913        b.object(1, "<< /Type /Catalog /Pages 2 0 R >>");
914        // A unary chain of 300 intermediate nodes, page leaf at the bottom.
915        let last = 302u32;
916        for num in 2..last {
917            b.object(
918                num,
919                &format!("<< /Type /Pages /Kids [{} 0 R] /Count 1 >>", num + 1),
920            );
921        }
922        b.object(last, "<< /Type /Page >>");
923        let doc = Document::load(b.build(1)).unwrap();
924        // `page_count` reports the tree's declared `/Count` (1) cheaply, as
925        // mature engines do; the leaf itself lies beyond the traversal depth
926        // cap, so the flattened tree is empty and the page cannot be
927        // materialized.
928        assert_eq!(doc.page_count(), 1, "declared /Count is reported cheaply");
929        assert!(
930            matches!(doc.page(0), Err(Error::PageNotFound(0, 0))),
931            "leaf beyond the depth cap cannot be materialized"
932        );
933    }
934
935    #[test]
936    fn page_count_reports_declared_count_cheaply() {
937        // The tree declares five pages but supplies only one kid. `page_count`
938        // reports the declared `/Count` (as mature engines do) without walking,
939        // while page access is bounded by the pages that actually materialize.
940        let mut b = PdfBuilder::new();
941        b.object(1, "<< /Type /Catalog /Pages 2 0 R >>");
942        b.object(2, "<< /Type /Pages /Kids [3 0 R] /Count 5 >>");
943        b.object(3, "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 100 100] >>");
944        let doc = Document::load(b.build(1)).unwrap();
945        assert_eq!(doc.page_count(), 5, "declared /Count reported verbatim");
946        assert!(doc.page(0).is_ok(), "the one real page materializes");
947        assert!(
948            matches!(doc.page(1), Err(Error::PageNotFound(1, 1))),
949            "access past the real pages fails with the true length"
950        );
951    }
952
953    #[test]
954    fn page_count_falls_back_to_walk_when_count_absent() {
955        let mut b = PdfBuilder::new();
956        b.object(1, "<< /Type /Catalog /Pages 2 0 R >>");
957        b.object(2, "<< /Type /Pages /Kids [3 0 R] >>"); // no /Count
958        b.object(3, "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 100 100] >>");
959        let doc = Document::load(b.build(1)).unwrap();
960        assert_eq!(
961            doc.page_count(),
962            1,
963            "missing /Count is recovered by walking"
964        );
965    }
966
967    #[test]
968    fn page_count_ignores_corrupt_oversized_count() {
969        // A `/Count` larger than the whole file is impossible: fall back to a
970        // real walk rather than trust it.
971        let mut b = PdfBuilder::new();
972        b.object(1, "<< /Type /Catalog /Pages 2 0 R >>");
973        b.object(2, "<< /Type /Pages /Kids [3 0 R] /Count 999999999 >>");
974        b.object(3, "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 100 100] >>");
975        let doc = Document::load(b.build(1)).unwrap();
976        assert_eq!(doc.page_count(), 1, "implausible /Count is rejected");
977    }
978
979    #[test]
980    fn version_scan_and_default() {
981        let mut b = PdfBuilder::new().version(2, 0);
982        b.object(1, "<< /Type /Catalog >>");
983        assert_eq!(Document::load(b.build(1)).unwrap().version(), (2, 0));
984        // Corrupting the header magic (same length) falls back to 1.4.
985        let data = replace_once(&simple_doc("v"), b"%PDF-", b"%QQQ-");
986        assert_eq!(Document::load(data).unwrap().version(), (1, 4));
987    }
988
989    #[test]
990    fn deeply_nested_root_object_does_not_overflow_the_stack() {
991        // A ~100 KB file whose Root is a 50k-deep array used to drive the
992        // object parser's recursion into a fatal stack overflow during
993        // `Document::load`. Run on a small stack so a regression aborts
994        // loudly rather than depending on the main thread's stack size.
995        let mut data = b"%PDF-1.7\n1 0 obj\n".to_vec();
996        data.extend(std::iter::repeat_n(b'[', 50_000));
997        data.extend(std::iter::repeat_n(b']', 50_000));
998        data.extend_from_slice(b"\nendobj\ntrailer\n<</Root 1 0 R>>\n%%EOF\n");
999        let outcome = std::thread::Builder::new()
1000            .stack_size(1024 * 1024)
1001            .spawn(move || Document::load(data).map(|doc| doc.page_count()))
1002            .expect("spawn test thread")
1003            .join()
1004            .expect("Document::load must not overflow the stack");
1005        // The over-nested Root is rejected or ignored (lenient), but the
1006        // process survives and no page is fabricated from it.
1007        assert!(matches!(outcome, Ok(0) | Err(_)));
1008    }
1009
1010    #[test]
1011    fn bytes_and_xref_accessors() {
1012        let data = simple_doc("accessors");
1013        let doc = Document::load(data.clone()).unwrap();
1014        assert_eq!(doc.bytes(), &data[..]);
1015        assert!(!doc.xref().is_empty());
1016        assert!(doc.xref().trailer.get("Root").is_some());
1017    }
1018
1019    #[test]
1020    fn object_at_spanned_reparses_identically() {
1021        let data = simple_doc("spanned");
1022        let doc = Document::load(data).unwrap();
1023        for (num, entry) in doc.xref().iter() {
1024            let XrefEntry::InFile { offset, gen } = entry else {
1025                continue;
1026            };
1027            let (r, object, span) = doc.object_at_spanned(offset as usize).unwrap();
1028            assert_eq!(r.num, num);
1029            assert_eq!(r.gen, gen);
1030            assert_eq!(span.start, offset);
1031            assert!(span.end as usize <= doc.bytes().len());
1032            // The bytes at the span parse back to the same object.
1033            let slice = &doc.bytes()[span.start as usize..span.end as usize];
1034            let (r2, object2) = Parser::new(slice).parse_indirect(&NoResolve).unwrap();
1035            assert_eq!(r2, r);
1036            assert_eq!(object2, object);
1037        }
1038    }
1039
1040    #[test]
1041    fn page_object_ref_points_at_a_page_dict() {
1042        let doc = Document::load(multi_page_doc(&["one", "two"])).unwrap();
1043        for index in 0..doc.page_count() {
1044            let page = doc.page(index).unwrap();
1045            let r = page.object_ref().expect("builder pages are indirect");
1046            let resolved = doc.get(r).unwrap();
1047            assert_eq!(
1048                resolved
1049                    .as_dict()
1050                    .unwrap()
1051                    .get_name("Type")
1052                    .map(|n| n.0.as_str()),
1053                Some("Page")
1054            );
1055        }
1056    }
1057
1058    // --- Minimal Standard-handler (RC4 V2/R3) fixture builder, duplicating
1059    // the key-derivation mechanism `crypt::tests` uses under the empty user
1060    // password (those helpers are private to that module's tests). Needed
1061    // only to pin the decrypt-identity regression test below: RC4's
1062    // per-object key depends on the object's num/gen, so it is the cipher
1063    // that can actually distinguish "decrypt with the parsed header's
1064    // identity" from "decrypt with the caller's requested identity".
1065
1066    const RC4_FIXTURE_KEY_LEN: usize = 16; // 128-bit key
1067    const RC4_FIXTURE_P: i32 = -44;
1068    const RC4_FIXTURE_ID0: &[u8] = b"0123456789abcdef";
1069    const RC4_FIXTURE_PAD: [u8; 32] = [
1070        0x28, 0xBF, 0x4E, 0x5E, 0x4E, 0x75, 0x8A, 0x41, 0x64, 0x00, 0x4E, 0x56, 0xFF, 0xFA, 0x01,
1071        0x08, 0x2E, 0x2E, 0x00, 0xB6, 0xD0, 0x68, 0x3E, 0x80, 0x2F, 0x0C, 0xA9, 0xFE, 0x64, 0x53,
1072        0x69, 0x7A,
1073    ];
1074
1075    #[rustfmt::skip]
1076    const RC4_FIXTURE_MD5_S: [u32; 64] = [
1077        7, 12, 17, 22, 7, 12, 17, 22, 7, 12, 17, 22, 7, 12, 17, 22,
1078        5, 9, 14, 20, 5, 9, 14, 20, 5, 9, 14, 20, 5, 9, 14, 20,
1079        4, 11, 16, 23, 4, 11, 16, 23, 4, 11, 16, 23, 4, 11, 16, 23,
1080        6, 10, 15, 21, 6, 10, 15, 21, 6, 10, 15, 21, 6, 10, 15, 21,
1081    ];
1082    #[rustfmt::skip]
1083    const RC4_FIXTURE_MD5_K: [u32; 64] = [
1084        0xd76aa478, 0xe8c7b756, 0x242070db, 0xc1bdceee, 0xf57c0faf, 0x4787c62a, 0xa8304613, 0xfd469501,
1085        0x698098d8, 0x8b44f7af, 0xffff5bb1, 0x895cd7be, 0x6b901122, 0xfd987193, 0xa679438e, 0x49b40821,
1086        0xf61e2562, 0xc040b340, 0x265e5a51, 0xe9b6c7aa, 0xd62f105d, 0x02441453, 0xd8a1e681, 0xe7d3fbc8,
1087        0x21e1cde6, 0xc33707d6, 0xf4d50d87, 0x455a14ed, 0xa9e3e905, 0xfcefa3f8, 0x676f02d9, 0x8d2a4c8a,
1088        0xfffa3942, 0x8771f681, 0x6d9d6122, 0xfde5380c, 0xa4beea44, 0x4bdecfa9, 0xf6bb4b60, 0xbebfbc70,
1089        0x289b7ec6, 0xeaa127fa, 0xd4ef3085, 0x04881d05, 0xd9d4d039, 0xe6db99e5, 0x1fa27cf8, 0xc4ac5665,
1090        0xf4292244, 0x432aff97, 0xab9423a7, 0xfc93a039, 0x655b59c3, 0x8f0ccc92, 0xffeff47d, 0x85845dd1,
1091        0x6fa87e4f, 0xfe2ce6e0, 0xa3014314, 0x4e0811a1, 0xf7537e82, 0xbd3af235, 0x2ad7d2bb, 0xeb86d391,
1092    ];
1093
1094    fn rc4_fixture_md5(input: &[u8]) -> [u8; 16] {
1095        let (mut a0, mut b0, mut c0, mut d0) = (
1096            0x6745_2301u32,
1097            0xefcd_ab89u32,
1098            0x98ba_dcfeu32,
1099            0x1032_5476u32,
1100        );
1101        let mut msg = input.to_vec();
1102        let bitlen = (input.len() as u64).wrapping_mul(8);
1103        msg.push(0x80);
1104        while msg.len() % 64 != 56 {
1105            msg.push(0);
1106        }
1107        msg.extend_from_slice(&bitlen.to_le_bytes());
1108        for chunk in msg.chunks_exact(64) {
1109            let mut m = [0u32; 16];
1110            for (word, bytes) in m.iter_mut().zip(chunk.chunks_exact(4)) {
1111                *word = u32::from_le_bytes(bytes.try_into().unwrap());
1112            }
1113            let (mut a, mut b, mut c, mut d) = (a0, b0, c0, d0);
1114            for i in 0..64 {
1115                let (f, g) = match i {
1116                    0..=15 => ((b & c) | (!b & d), i),
1117                    16..=31 => ((d & b) | (!d & c), (5 * i + 1) % 16),
1118                    32..=47 => (b ^ c ^ d, (3 * i + 5) % 16),
1119                    _ => (c ^ (b | !d), (7 * i) % 16),
1120                };
1121                let f = f
1122                    .wrapping_add(a)
1123                    .wrapping_add(RC4_FIXTURE_MD5_K[i])
1124                    .wrapping_add(m[g]);
1125                a = d;
1126                d = c;
1127                c = b;
1128                b = b.wrapping_add(f.rotate_left(RC4_FIXTURE_MD5_S[i]));
1129            }
1130            a0 = a0.wrapping_add(a);
1131            b0 = b0.wrapping_add(b);
1132            c0 = c0.wrapping_add(c);
1133            d0 = d0.wrapping_add(d);
1134        }
1135        let mut out = [0u8; 16];
1136        out[0..4].copy_from_slice(&a0.to_le_bytes());
1137        out[4..8].copy_from_slice(&b0.to_le_bytes());
1138        out[8..12].copy_from_slice(&c0.to_le_bytes());
1139        out[12..16].copy_from_slice(&d0.to_le_bytes());
1140        out
1141    }
1142
1143    fn rc4_fixture_rc4(key: &[u8], data: &[u8]) -> Vec<u8> {
1144        let mut s: [u8; 256] = core::array::from_fn(|i| i as u8);
1145        let mut j = 0u8;
1146        for i in 0..256 {
1147            j = j.wrapping_add(s[i]).wrapping_add(key[i % key.len()]);
1148            s.swap(i, j as usize);
1149        }
1150        let mut out = Vec::with_capacity(data.len());
1151        let (mut i, mut j) = (0u8, 0u8);
1152        for &byte in data {
1153            i = i.wrapping_add(1);
1154            j = j.wrapping_add(s[i as usize]);
1155            s.swap(i as usize, j as usize);
1156            let k = s[s[i as usize].wrapping_add(s[j as usize]) as usize];
1157            out.push(byte ^ k);
1158        }
1159        out
1160    }
1161
1162    /// `/O` for empty owner and user passwords (Algorithm 3, R3).
1163    fn rc4_fixture_owner_entry() -> Vec<u8> {
1164        let mut d = rc4_fixture_md5(&RC4_FIXTURE_PAD);
1165        for _ in 0..50 {
1166            d = rc4_fixture_md5(&d[..RC4_FIXTURE_KEY_LEN]);
1167        }
1168        let rc4key = d[..RC4_FIXTURE_KEY_LEN].to_vec();
1169        let mut o = rc4_fixture_rc4(&rc4key, &RC4_FIXTURE_PAD);
1170        for i in 1u8..=19 {
1171            let k: Vec<u8> = rc4key.iter().map(|b| b ^ i).collect();
1172            o = rc4_fixture_rc4(&k, &o);
1173        }
1174        o
1175    }
1176
1177    /// File key from `/O` for the empty user password (Algorithm 2, R3).
1178    fn rc4_fixture_file_key(o: &[u8]) -> Vec<u8> {
1179        let mut input = Vec::new();
1180        input.extend_from_slice(&RC4_FIXTURE_PAD);
1181        input.extend_from_slice(o);
1182        input.extend_from_slice(&(RC4_FIXTURE_P as u32).to_le_bytes());
1183        input.extend_from_slice(RC4_FIXTURE_ID0);
1184        let mut d = rc4_fixture_md5(&input);
1185        for _ in 0..50 {
1186            d = rc4_fixture_md5(&d[..RC4_FIXTURE_KEY_LEN]);
1187        }
1188        d[..RC4_FIXTURE_KEY_LEN].to_vec()
1189    }
1190
1191    /// `/U` for the empty user password (Algorithm 5, R3).
1192    fn rc4_fixture_user_entry(key: &[u8]) -> Vec<u8> {
1193        let mut input = Vec::new();
1194        input.extend_from_slice(&RC4_FIXTURE_PAD);
1195        input.extend_from_slice(RC4_FIXTURE_ID0);
1196        let mut x = rc4_fixture_md5(&input).to_vec();
1197        x = rc4_fixture_rc4(key, &x);
1198        for i in 1u8..=19 {
1199            let k: Vec<u8> = key.iter().map(|b| b ^ i).collect();
1200            x = rc4_fixture_rc4(&k, &x);
1201        }
1202        x.resize(32, 0); // trailing padding is arbitrary
1203        x
1204    }
1205
1206    fn rc4_fixture_obj_key(key: &[u8], num: u32, gen: u16) -> Vec<u8> {
1207        let mut input = key.to_vec();
1208        input.extend_from_slice(&num.to_le_bytes()[..3]);
1209        input.extend_from_slice(&gen.to_le_bytes()[..2]);
1210        rc4_fixture_md5(&input)[..(key.len() + 5).min(16)].to_vec()
1211    }
1212
1213    fn rc4_fixture_hexstr(b: &[u8]) -> String {
1214        let mut s = String::from("<");
1215        for x in b {
1216            s.push_str(&format!("{x:02x}"));
1217        }
1218        s.push('>');
1219        s
1220    }
1221
1222    /// Builds a V2/R3 (128-bit RC4) file, encrypted under the empty
1223    /// password, with a single indirect object (`3 0 obj`, i.e. gen 0)
1224    /// holding an encrypted string.
1225    fn rc4_encrypted_fixture() -> Vec<u8> {
1226        let o = rc4_fixture_owner_entry();
1227        let key = rc4_fixture_file_key(&o);
1228        let u = rc4_fixture_user_entry(&key);
1229        let msg = rc4_fixture_rc4(&rc4_fixture_obj_key(&key, 3, 0), b"Top secret message");
1230
1231        let mut b = PdfBuilder::new().version(1, 4);
1232        b.object(1, "<< /Type /Catalog /Pages 2 0 R >>");
1233        b.object(2, "<< /Type /Pages /Kids [] /Count 0 >>");
1234        b.object(3, &format!("<< /Msg {} >>", rc4_fixture_hexstr(&msg)));
1235        b.object(
1236            9,
1237            &format!(
1238                "<< /Filter /Standard /V 2 /R 3 /Length 128 /P {} /O {} /U {} >>",
1239                RC4_FIXTURE_P,
1240                rc4_fixture_hexstr(&o),
1241                rc4_fixture_hexstr(&u)
1242            ),
1243        );
1244        let trailer = format!(
1245            "/Encrypt 9 0 R /ID [{}{}]",
1246            rc4_fixture_hexstr(RC4_FIXTURE_ID0),
1247            rc4_fixture_hexstr(RC4_FIXTURE_ID0)
1248        );
1249        b.trailer_extra(&trailer).build(1)
1250    }
1251
1252    #[test]
1253    fn encrypted_generation_mismatch_still_decrypts() {
1254        // `object_at_spanned` derives the per-object RC4/AESV2 decrypt key
1255        // from the PARSED "N G obj" header at the object's file offset
1256        // (`r.num`, `r.gen` from `parser.parse_indirect`), never from the
1257        // caller's requested `ObjRef` — mirroring how plain (unencrypted)
1258        // lookups already tolerate a generation mismatch
1259        // (`generation_mismatch_is_tolerated`). Request object 3 (really
1260        // "3 0 obj" in the file) under a deliberately wrong generation: if
1261        // decryption instead used the requested (wrong) gen to derive the
1262        // RC4 object key, the result would be garbage, not the plaintext.
1263        let doc = Document::load(rc4_encrypted_fixture()).expect("empty password opens the file");
1264        let obj3 = doc.get(ObjRef { num: 3, gen: 7 }).unwrap();
1265        let msg = obj3
1266            .as_dict()
1267            .unwrap()
1268            .get("Msg")
1269            .unwrap()
1270            .as_str_bytes()
1271            .unwrap();
1272        assert_eq!(
1273            msg, b"Top secret message",
1274            "decrypted using the file's real gen (0), not the mismatched request (7)"
1275        );
1276    }
1277
1278    #[test]
1279    fn objstm_doc_fixture_loads_and_resolves_members() {
1280        let data = objstm_doc(&[(7, "<< /Marker (inside) >>")]);
1281        let doc = Document::load(data).unwrap();
1282        assert_eq!(doc.page_count(), 1);
1283        let member = doc.get(ObjRef { num: 7, gen: 0 }).unwrap();
1284        let text = member.as_dict().unwrap().get("Marker").unwrap();
1285        assert_eq!(text.as_str_bytes(), Some(&b"inside"[..]));
1286        // The member really is xref'd into the object stream.
1287        assert!(matches!(
1288            doc.xref().get(7),
1289            Some(XrefEntry::InStream { stream_num: 4, .. })
1290        ));
1291    }
1292}