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;
9use std::sync::Arc;
10
11use crate::crypt::Decryptor;
12use crate::elements::Span;
13use crate::error::{Error, Result};
14use crate::filters;
15use crate::geom::Rect;
16use crate::object::{decode_text_string, Dict, ObjRef, Object, Stream};
17use crate::objstm;
18use crate::parser::{Parser, Resolve};
19use crate::source::{block_on, AsyncObjectSource, Immediate};
20use crate::xref::{load_xref, Xref, XrefEntry};
21
22/// Page-tree traversal depth cap.
23const MAX_TREE_DEPTH: usize = 256;
24
25/// A loaded PDF document.
26pub struct Document {
27    data: Arc<Vec<u8>>,
28    version: (u8, u8),
29    xref: Arc<Xref>,
30    /// Interior cache of fetched indirect objects.
31    cache: RefCell<FastMap<(u32, u16), Rc<Object>>>,
32    /// Object numbers currently being parsed, guarding re-entrant fetches
33    /// (e.g. a stream whose `/Length` refers back to the stream itself).
34    loading: RefCell<FastSet<u32>>,
35    /// Decoded object streams, keyed by their stream object number, so a
36    /// stream is decompressed and its header parsed at most once even when
37    /// many compressed objects are read from it.
38    objstms: RefCell<FastMap<u32, Rc<objstm::ObjStm>>>,
39    /// Present when the file uses the Standard security handler (RC4 or AES)
40    /// and opens under the empty user password; decrypts strings and stream
41    /// data as objects are loaded from the file.
42    decryptor: Option<Decryptor>,
43    /// The flattened page tree, built lazily on the first page access so that
44    /// merely opening a document (or reading its page count) never parses
45    /// every page dictionary. See [`Document::pages`]. Shared so a
46    /// [`Document::fork`] does not re-walk the tree.
47    pages: OnceCell<Arc<Vec<PageRec>>>,
48}
49
50/// A document's immutable core, detached from its single-threaded caches:
51/// the file bytes, the merged cross-reference table, the decryption key
52/// material, and the flattened page tree. `Send + Sync` (asserted in this
53/// module's tests), which the [`Document`] deliberately is not — this is
54/// the value that crosses a thread boundary, one fresh document
55/// materializing from it on each side. See [`Document::seed`],
56/// [`Document::from_seed`] and [`map_pages`].
57pub struct DocumentSeed {
58    data: Arc<Vec<u8>>,
59    version: (u8, u8),
60    xref: Arc<Xref>,
61    decryptor: Option<Decryptor>,
62    pages: Arc<Vec<PageRec>>,
63}
64
65impl Clone for DocumentSeed {
66    fn clone(&self) -> DocumentSeed {
67        DocumentSeed {
68            data: Arc::clone(&self.data),
69            version: self.version,
70            xref: Arc::clone(&self.xref),
71            decryptor: self.decryptor.clone(),
72            pages: Arc::clone(&self.pages),
73        }
74    }
75}
76
77/// The flattened, inheritance-applied record for one page.
78struct PageRec {
79    obj_ref: Option<ObjRef>,
80    media_box: Rect,
81    crop_box: Rect,
82    rotate: i32,
83    resources: Dict,
84    dict: Dict,
85}
86
87/// Attributes inherited down the page tree (ISO 32000 §7.7.3.4).
88#[derive(Clone, Default)]
89struct Inherited {
90    resources: Option<Dict>,
91    media_box: Option<Rect>,
92    crop_box: Option<Rect>,
93    rotate: Option<i32>,
94}
95
96/// Parses the `%PDF-x.y` header, scanning the first 1 KiB; absent or
97/// malformed headers default to version 1.4.
98fn parse_version(data: &[u8]) -> (u8, u8) {
99    try_parse_version(data).unwrap_or((1, 4))
100}
101
102fn try_parse_version(data: &[u8]) -> Option<(u8, u8)> {
103    let window = &data[..data.len().min(1024)];
104    let pos = memchr::memmem::find(window, b"%PDF-")?;
105    let rest = &window[pos + 5..];
106    let (major, used) = read_version_component(rest)?;
107    if rest.get(used) != Some(&b'.') {
108        return None;
109    }
110    let (minor, _) = read_version_component(&rest[used + 1..])?;
111    Some((major, minor))
112}
113
114/// Reads a run of 1–3 ASCII digits as a `u8`, returning the value and the
115/// number of bytes consumed.
116fn read_version_component(bytes: &[u8]) -> Option<(u8, usize)> {
117    let end = bytes
118        .iter()
119        .position(|b| !b.is_ascii_digit())
120        .unwrap_or(bytes.len());
121    if end == 0 || end > 3 {
122        return None;
123    }
124    let value = std::str::from_utf8(&bytes[..end]).ok()?.parse().ok()?;
125    Some((value, end))
126}
127
128/// Normalizes a `/Rotate` value to one of {0, 90, 180, 270}; values that
129/// are not multiples of 90 fall back to 0 (lenient).
130fn normalize_rotation(deg: i32) -> i32 {
131    let r = deg.rem_euclid(360);
132    if r % 90 == 0 {
133        r
134    } else {
135        0
136    }
137}
138
139impl Document {
140    /// Loads a document from bytes: locates the `%PDF-x.y` header (scanning
141    /// the first 1 KiB, defaulting to 1.4), loads the xref, and sets up
142    /// decryption for files using the Standard security handler (RC4 or AES)
143    /// under the empty user password (password-protected files yield
144    /// [`Error::Encrypted`]).
145    ///
146    /// The page tree is **not** walked here: it is flattened lazily on the
147    /// first page access, so opening a document (or reading `page_count`) does
148    /// not parse every page dictionary.
149    pub fn load(data: Vec<u8>) -> Result<Document> {
150        let version = parse_version(&data);
151        let xref = load_xref(&data)?;
152        let mut doc = Document {
153            data: Arc::new(data),
154            version,
155            xref: Arc::new(xref),
156            cache: RefCell::new(FastMap::default()),
157            loading: RefCell::new(FastSet::default()),
158            objstms: RefCell::new(FastMap::default()),
159            decryptor: None,
160            pages: OnceCell::new(),
161        };
162        if doc
163            .xref
164            .trailer
165            .get("Encrypt")
166            .is_some_and(|o| !o.is_null())
167        {
168            doc.setup_decryption()?;
169        }
170        Ok(doc)
171    }
172
173    /// Configures decryption for an encrypted file. Supports the Standard
174    /// security handler with RC4 (`/V` 1–2), AESV2 (`/V` 4) and AESV3 (`/V` 5)
175    /// under the empty user password; a required password is reported as
176    /// [`Error::Encrypted`]. Must run before any content object is fetched, and
177    /// reads `/Encrypt` and `/ID` while decryption is still off (those values
178    /// are stored unencrypted).
179    fn setup_decryption(&mut self) -> Result<()> {
180        let enc_obj = self
181            .xref
182            .trailer
183            .get("Encrypt")
184            .cloned()
185            .unwrap_or(Object::Null);
186        let enc = self.resolve(&enc_obj)?;
187        let enc_dict = enc.as_dict().ok_or(Error::Encrypted)?;
188        let id0: Vec<u8> = self
189            .xref
190            .trailer
191            .get("ID")
192            .and_then(Object::as_array)
193            .and_then(<[Object]>::first)
194            .and_then(Object::as_str_bytes)
195            .unwrap_or(&[])
196            .to_vec();
197        match Decryptor::from_standard(enc_dict, &id0) {
198            Some(dec) => {
199                self.decryptor = Some(dec);
200                // Objects fetched while resolving /Encrypt were cached without
201                // decryption; drop them so they are re-read through the
202                // decrypting path if referenced again.
203                self.cache.borrow_mut().clear();
204                Ok(())
205            }
206            None => Err(Error::Encrypted),
207        }
208    }
209
210    /// Reads the file at `path` and loads it via [`Document::load`].
211    pub fn open(path: impl AsRef<Path>) -> Result<Document> {
212        Document::load(std::fs::read(path)?)
213    }
214
215    /// The PDF version from the header, e.g. `(1, 7)`.
216    pub fn version(&self) -> (u8, u8) {
217        self.version
218    }
219
220    /// Raw bytes of the loaded file.
221    pub fn bytes(&self) -> &[u8] {
222        &self.data
223    }
224
225    /// The merged cross-reference table and trailer.
226    pub fn xref(&self) -> &Xref {
227        &self.xref
228    }
229
230    /// Fetches an indirect object by reference (xref lookup, object-stream
231    /// indirection, cached). A generation mismatch between the request and
232    /// the file is tolerated (lenient).
233    pub fn get(&self, r: ObjRef) -> Result<Object> {
234        if let Some(cached) = self.cache.borrow().get(&(r.num, r.gen)) {
235            return Ok((**cached).clone());
236        }
237        if !self.loading.borrow_mut().insert(r.num) {
238            return Err(Error::CircularReference(r.num));
239        }
240        let result = self.load_object(r);
241        self.loading.borrow_mut().remove(&r.num);
242        let object = result?;
243        self.cache
244            .borrow_mut()
245            .insert((r.num, r.gen), Rc::new(object.clone()));
246        Ok(object)
247    }
248
249    /// Uncached fetch: parses the object at its file offset or extracts it
250    /// from its containing object stream.
251    fn load_object(&self, r: ObjRef) -> Result<Object> {
252        match self.xref.get(r.num) {
253            None | Some(XrefEntry::Free) => Err(Error::ObjectNotFound(r.num, r.gen)),
254            Some(XrefEntry::InFile { offset, .. }) => {
255                let offset = usize::try_from(offset)
256                    .ok()
257                    .filter(|&o| o < self.data.len())
258                    .ok_or(Error::ObjectNotFound(r.num, r.gen))?;
259                self.object_at_spanned(offset).map(|parsed| parsed.1)
260            }
261            Some(XrefEntry::InStream { stream_num, index }) => {
262                self.load_from_object_stream(stream_num, index)
263            }
264        }
265    }
266
267    /// Parses the indirect object at `offset`, applying decryption, and
268    /// reports the byte range consumed (`N G obj … endobj`).
269    pub(crate) fn object_at_spanned(&self, offset: usize) -> Result<(ObjRef, Object, Span)> {
270        let mut parser = Parser::at(&self.data, offset);
271        let (r, mut object) = parser.parse_indirect(self)?;
272        // Objects stored directly in the file carry encrypted strings and
273        // stream data; decrypt with this object's key. (Objects living in
274        // object streams are decrypted with their container.)
275        if let Some(dec) = &self.decryptor {
276            dec.decrypt_object(&mut object, r.num, r.gen);
277        }
278        Ok((r, object, Span::new(offset as u64, parser.pos() as u64)))
279    }
280
281    /// The decoded, header-parsed object stream `stream_num`, built at most
282    /// once and cached.
283    pub(crate) fn objstm_handle(&self, stream_num: u32) -> Result<Rc<objstm::ObjStm>> {
284        if let Some(stm) = self.objstms.borrow().get(&stream_num) {
285            return Ok(Rc::clone(stm));
286        }
287        let container = self.get(ObjRef {
288            num: stream_num,
289            gen: 0,
290        })?;
291        let stream = container.as_stream().ok_or_else(|| Error::TypeMismatch {
292            expected: "stream",
293            found: type_name(&container),
294        })?;
295        let n = self
296            .resolve(stream.dict.get("N").unwrap_or(&Object::Null))?
297            .as_int()
298            .and_then(|v| usize::try_from(v).ok())
299            .ok_or(Error::MissingKey("N"))?;
300        let first = self
301            .resolve(stream.dict.get("First").unwrap_or(&Object::Null))?
302            .as_int()
303            .and_then(|v| usize::try_from(v).ok())
304            .ok_or(Error::MissingKey("First"))?;
305        let decoded = self.stream_data(stream)?;
306        let stm = Rc::new(objstm::ObjStm::parse(decoded, n, first)?);
307        self.objstms
308            .borrow_mut()
309            .insert(stream_num, Rc::clone(&stm));
310        Ok(stm)
311    }
312
313    /// Extracts a compressed object from the object stream `stream_num`.
314    fn load_from_object_stream(&self, stream_num: u32, index: u32) -> Result<Object> {
315        self.objstm_handle(stream_num)?.object(index)
316    }
317
318    /// Chases reference chains with a depth guard of
319    /// [`MAX_RESOLVE_DEPTH`](crate::source::MAX_RESOLVE_DEPTH) (beyond that:
320    /// [`Error::CircularReference`], naming the last reference followed); a
321    /// reference to a missing or unreadable object resolves to `Null`
322    /// (lenient).
323    ///
324    /// The loop is [`crate::source::resolve_sync_with`], shared with the
325    /// provided [`crate::source::ObjectSource::resolve`], so the two cannot
326    /// drift apart. It reaches `Document::get` through this type's
327    /// `ObjectSource` implementation, which forwards to it unchanged.
328    pub fn resolve(&self, o: &Object) -> Result<Object> {
329        crate::source::resolve_sync_with(self, o)
330    }
331
332    /// Decodes a stream's data through its filter chain, resolving indirect
333    /// filter parameters against this document.
334    pub fn stream_data(&self, s: &Stream) -> Result<Vec<u8>> {
335        filters::decode_stream(s, self)
336    }
337
338    /// The flattened page tree, built once on first access.
339    fn pages(&self) -> &[PageRec] {
340        self.pages.get_or_init(|| Arc::new(self.flatten_pages()))
341    }
342
343    /// The document's shareable core: everything immutable, nothing cached.
344    /// Forces the page tree first, so no document built from the seed
345    /// re-walks it.
346    pub fn seed(&self) -> DocumentSeed {
347        self.pages();
348        DocumentSeed {
349            data: Arc::clone(&self.data),
350            version: self.version,
351            xref: Arc::clone(&self.xref),
352            decryptor: self.decryptor.clone(),
353            pages: Arc::clone(self.pages.get().expect("pages() was just forced")),
354        }
355    }
356
357    /// A handle to the same document for another thread: share a
358    /// [`DocumentSeed`] across the thread boundary — the seed is `Send` and
359    /// `Sync`; the document, whose caches are single-threaded by design, is
360    /// neither — and materialize one of these per thread.
361    ///
362    /// The immutable core is shared — the file bytes, the merged
363    /// cross-reference table, the decryption key material, and the flattened
364    /// page tree — while the interior caches start fresh, private to this
365    /// document. That is the whole design: per-page work is independent and
366    /// lock-free precisely because nothing mutable is shared, so N documents
367    /// on N threads contend on nothing.
368    ///
369    /// [`map_pages`] is the ready-made consumer.
370    pub fn from_seed(seed: DocumentSeed) -> Document {
371        Document {
372            data: seed.data,
373            version: seed.version,
374            xref: seed.xref,
375            cache: RefCell::new(FastMap::default()),
376            loading: RefCell::new(FastSet::default()),
377            objstms: RefCell::new(FastMap::default()),
378            decryptor: seed.decryptor,
379            pages: OnceCell::from(seed.pages),
380        }
381    }
382
383    /// [`Document::seed`] and [`Document::from_seed`] in one step, for a
384    /// fork used on the calling thread.
385    pub fn fork(&self) -> Document {
386        Document::from_seed(self.seed())
387    }
388
389    /// Number of pages.
390    ///
391    /// Reports the page tree's declared `/Count` — the same value mature
392    /// engines return — without walking the tree, so it is cheap on an
393    /// otherwise-untouched document. If `/Count` is absent or implausible the
394    /// tree is flattened and its true (lenient, cycle- and depth-guarded)
395    /// length is returned instead. Once the tree has been flattened for any
396    /// reason, that flattened length is authoritative.
397    pub fn page_count(&self) -> usize {
398        if let Some(pages) = self.pages.get() {
399            return pages.len();
400        }
401        if let Some(count) = self.declared_page_count() {
402            return count;
403        }
404        self.pages().len()
405    }
406
407    /// Reads the page tree root's `/Count` cheaply (Root → `/Pages` →
408    /// `/Count`) without descending into `/Kids`. Returns `None` when the
409    /// entry is missing, non-integer, negative, or larger than the file could
410    /// possibly hold (a corrupt count), so the caller falls back to a real
411    /// walk.
412    fn declared_page_count(&self) -> Option<usize> {
413        let root = self.xref.trailer.get("Root")?;
414        let catalog = self.resolve(root).ok()?;
415        let pages = self.resolve(catalog.as_dict()?.get("Pages")?).ok()?;
416        let count = usize::try_from(self.int_value(pages.as_dict()?, "Count")?).ok()?;
417        // A page occupies at least a handful of bytes on disk, so a count that
418        // exceeds the file length is corrupt: fall back to walking the tree.
419        (count <= self.data.len()).then_some(count)
420    }
421
422    /// The page at 0-based `index`.
423    pub fn page(&self, index: usize) -> Result<Page> {
424        let pages = self.pages();
425        let rec = pages
426            .get(index)
427            .ok_or(Error::PageNotFound(index, pages.len()))?;
428        Ok(Page {
429            index,
430            media_box: rec.media_box,
431            crop_box: rec.crop_box,
432            rotate: rec.rotate,
433            resources: rec.resources.clone(),
434            dict: rec.dict.clone(),
435            obj_ref: rec.obj_ref,
436        })
437    }
438
439    /// Document metadata from the trailer `/Info` dictionary (lenient:
440    /// absent or malformed entries are simply `None`).
441    pub fn metadata(&self) -> Metadata {
442        let mut meta = Metadata::default();
443        let Some(info) = self.xref.trailer.get("Info") else {
444            return meta;
445        };
446        let Ok(info) = self.resolve(info) else {
447            return meta;
448        };
449        let Some(dict) = info.as_dict() else {
450            return meta;
451        };
452        meta.title = self.meta_string(dict, "Title");
453        meta.author = self.meta_string(dict, "Author");
454        meta.subject = self.meta_string(dict, "Subject");
455        meta.keywords = self.meta_string(dict, "Keywords");
456        meta.creator = self.meta_string(dict, "Creator");
457        meta.producer = self.meta_string(dict, "Producer");
458        meta.creation_date = self.meta_string(dict, "CreationDate");
459        meta.mod_date = self.meta_string(dict, "ModDate");
460        meta
461    }
462
463    /// Reads `key` from an info dictionary as a decoded text string.
464    fn meta_string(&self, dict: &Dict, key: &str) -> Option<String> {
465        let value = self.resolve(dict.get(key)?).ok()?;
466        Some(decode_text_string(value.as_str_bytes()?))
467    }
468
469    /// Flattens the page tree by iterative depth-first traversal of `/Kids`
470    /// with a visited-reference cycle guard and a depth cap, applying
471    /// attribute inheritance. Any structural problem simply truncates or
472    /// skips (lenient) — this never fails.
473    fn flatten_pages(&self) -> Vec<PageRec> {
474        let mut pages = Vec::new();
475        let Some(root) = self.xref.trailer.get("Root") else {
476            return pages;
477        };
478        let Ok(catalog) = self.resolve(root) else {
479            return pages;
480        };
481        let Some(tree_root) = catalog.as_dict().and_then(|d| d.get("Pages")) else {
482            return pages;
483        };
484        let mut visited: FastSet<ObjRef> = FastSet::default();
485        let mut stack: Vec<(Object, Inherited, usize)> =
486            vec![(tree_root.clone(), Inherited::default(), 0)];
487        while let Some((node, mut inherited, depth)) = stack.pop() {
488            if depth > MAX_TREE_DEPTH {
489                continue;
490            }
491            let node_ref = if let Object::Ref(r) = node {
492                Some(r)
493            } else {
494                None
495            };
496            if let Some(r) = node_ref {
497                if !visited.insert(r) {
498                    continue; // cycle: this node was already traversed
499                }
500            }
501            let Ok(resolved) = self.resolve(&node) else {
502                continue;
503            };
504            let Some(dict) = resolved.as_dict() else {
505                continue;
506            };
507            if let Some(res) = self.dict_value(dict, "Resources") {
508                inherited.resources = Some(res);
509            }
510            if let Some(mb) = self.rect_value(dict, "MediaBox") {
511                inherited.media_box = Some(mb);
512            }
513            if let Some(cb) = self.rect_value(dict, "CropBox") {
514                inherited.crop_box = Some(cb);
515            }
516            if let Some(rot) = self.int_value(dict, "Rotate") {
517                inherited.rotate = Some(rot);
518            }
519            let is_page = dict.get_name("Type").is_some_and(|n| n.0 == "Page");
520            let kids = if is_page {
521                None
522            } else {
523                self.array_value(dict, "Kids")
524            };
525            match kids {
526                Some(kids) => {
527                    // Reverse push so pop order matches document order.
528                    for kid in kids.iter().rev() {
529                        stack.push((kid.clone(), inherited.clone(), depth + 1));
530                    }
531                }
532                None => pages.push(make_page_rec(node_ref, dict.clone(), &inherited)),
533            }
534        }
535        pages
536    }
537
538    /// Resolves `dict[key]` to a dictionary, if present and well-formed.
539    fn dict_value(&self, dict: &Dict, key: &str) -> Option<Dict> {
540        self.resolve(dict.get(key)?).ok()?.as_dict().cloned()
541    }
542
543    /// Resolves `dict[key]` to an array, if present and well-formed.
544    fn array_value(&self, dict: &Dict, key: &str) -> Option<Vec<Object>> {
545        match self.resolve(dict.get(key)?).ok()? {
546            Object::Array(items) => Some(items),
547            _ => None,
548        }
549    }
550
551    /// Resolves `dict[key]` to an integer (reals truncate, lenient).
552    fn int_value(&self, dict: &Dict, key: &str) -> Option<i32> {
553        let v = self.resolve(dict.get(key)?).ok()?.as_f64()?;
554        if v.is_finite() {
555            Some(v as i32)
556        } else {
557            None
558        }
559    }
560
561    /// Resolves `dict[key]` to a normalized rectangle: a four-number array
562    /// whose elements may themselves be references.
563    fn rect_value(&self, dict: &Dict, key: &str) -> Option<Rect> {
564        let items = self.array_value(dict, key)?;
565        if items.len() != 4 {
566            return None;
567        }
568        let mut coords = [0.0f32; 4];
569        for (slot, item) in coords.iter_mut().zip(&items) {
570            let n = self.resolve(item).ok()?.as_f64()?;
571            if !n.is_finite() {
572                return None;
573            }
574            *slot = n as f32;
575        }
576        Some(Rect::new(coords[0], coords[1], coords[2], coords[3]).normalize())
577    }
578}
579
580/// Applies `work` to every page of `doc` in parallel and returns the
581/// results in page order.
582///
583/// Per-page work over a `Document` — text extraction, rasterization — is
584/// independent and CPU-bound, so this fans it out over
585/// `std::thread::available_parallelism()` threads, each holding its own
586/// [`Document::fork`]: the immutable core is shared, the caches are private,
587/// and the workers contend on nothing.
588///
589/// Workers pull page indexes from a shared counter rather than a static
590/// stride. This is not a style choice: pages vary wildly in cost, and on a
591/// machine with heterogeneous cores a static partition ends with the fast
592/// cores idle while a slow one finishes its stripe — measured as the
593/// difference between 3.9x and 5.9x on twelve cores. A counter keeps every
594/// worker busy until the pages run out.
595///
596/// A single-page document, or a single-core machine, runs inline on the
597/// calling thread with no fork and no thread.
598pub fn map_pages<T, F>(doc: &Document, work: F) -> Vec<Result<T>>
599where
600    T: Send + Sync,
601    F: Fn(&Document, &Page) -> Result<T> + Send + Sync,
602{
603    let count = doc.pages().len();
604    let workers = std::thread::available_parallelism()
605        .map(std::num::NonZeroUsize::get)
606        .unwrap_or(1)
607        .min(count);
608    if workers <= 1 {
609        return (0..count)
610            .map(|i| doc.page(i).and_then(|page| work(doc, &page)))
611            .collect();
612    }
613
614    let seed = doc.seed();
615    let next = std::sync::atomic::AtomicUsize::new(0);
616    let slots: Vec<std::sync::OnceLock<Result<T>>> =
617        (0..count).map(|_| std::sync::OnceLock::new()).collect();
618    std::thread::scope(|scope| {
619        for _ in 0..workers {
620            // The seed crosses the thread boundary; the document — whose
621            // caches are single-threaded by design — materializes inside.
622            let seed = seed.clone();
623            let next = &next;
624            let slots = &slots;
625            let work = &work;
626            scope.spawn(move || {
627                let worker = Document::from_seed(seed);
628                loop {
629                    let i = next.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
630                    if i >= count {
631                        break;
632                    }
633                    let outcome = worker.page(i).and_then(|page| work(&worker, &page));
634                    // Each index is handed to exactly one worker, so the
635                    // slot is always empty; a failed set would mean the
636                    // counter duplicated an index, which is worth crashing
637                    // over.
638                    if slots[i].set(outcome).is_err() {
639                        unreachable!("page index {i} was dispatched twice");
640                    }
641                }
642            });
643        }
644    });
645    slots
646        .into_iter()
647        .map(|slot| {
648            slot.into_inner()
649                .expect("every page index below the count was dispatched")
650        })
651        .collect()
652}
653
654/// Builds the final page record from a leaf dictionary and its inherited
655/// attributes. The defaults live in [`Page::from_tree_attrs`] — the one
656/// implementation of page defaulting, shared with the asynchronous API —
657/// and this only reshapes its output into the index-less cache record.
658fn make_page_rec(obj_ref: Option<ObjRef>, dict: Dict, inherited: &Inherited) -> PageRec {
659    let page = Page::from_tree_attrs(
660        0,
661        inherited.resources.clone(),
662        inherited.media_box,
663        inherited.crop_box,
664        inherited.rotate,
665        dict,
666        obj_ref,
667    );
668    PageRec {
669        obj_ref: page.obj_ref,
670        media_box: page.media_box,
671        crop_box: page.crop_box,
672        rotate: page.rotate,
673        resources: page.resources,
674        dict: page.dict,
675    }
676}
677
678/// Human-readable object type name for error messages.
679fn type_name(o: &Object) -> &'static str {
680    match o {
681        Object::Null => "null",
682        Object::Bool(_) => "boolean",
683        Object::Int(_) => "integer",
684        Object::Real(_) => "real",
685        Object::String(_) => "string",
686        Object::Name(_) => "name",
687        Object::Array(_) => "array",
688        Object::Dict(_) => "dictionary",
689        Object::Stream(_) => "stream",
690        Object::Ref(_) => "reference",
691    }
692}
693
694impl Resolve for Document {
695    fn resolve_ref(&self, r: ObjRef) -> Option<Object> {
696        self.get(r).ok()
697    }
698}
699
700/// Forwards to the inherent methods, so reading a document through the trait
701/// is bit-identical to reading it directly. `resolve` is forwarded too, even
702/// though the provided implementation is now the same shared chase
703/// ([`crate::source::resolve_sync_with`]) that the inherent method delegates
704/// to: routing it through the inherent method keeps the two entry points a
705/// single call, so they cannot drift should `Document::resolve` ever grow
706/// document-specific behaviour.
707impl crate::source::ObjectSource for Document {
708    fn get(&self, r: ObjRef) -> Result<Object> {
709        Document::get(self, r)
710    }
711
712    fn stream_data(&self, s: &Stream) -> Result<Vec<u8>> {
713        Document::stream_data(self, s)
714    }
715
716    fn resolve(&self, o: &Object) -> Result<Object> {
717        Document::resolve(self, o)
718    }
719}
720
721/// Document information from the trailer `/Info` dictionary. Only present,
722/// well-formed entries are populated.
723#[derive(Debug, Clone, Default, PartialEq, Eq)]
724pub struct Metadata {
725    pub title: Option<String>,
726    pub author: Option<String>,
727    pub subject: Option<String>,
728    pub keywords: Option<String>,
729    pub creator: Option<String>,
730    pub producer: Option<String>,
731    pub creation_date: Option<String>,
732    pub mod_date: Option<String>,
733}
734
735/// A single page with inherited attributes already applied.
736///
737/// Defaults: `media_box` falls back to US Letter (612x792) when absent or
738/// invalid, `crop_box` falls back to (and is intersected with) `media_box`,
739/// and `rotate` is normalized to one of {0, 90, 180, 270}. Every construction
740/// path normalizes `rotate` — [`Document::page`] and [`Page::from_parts`]
741/// alike — so the invariant holds however a `Page` was built. `rotate` is a
742/// public field, so a caller may still overwrite it afterwards; [`Page::size`]
743/// reads it modulo a full turn and so stays correct if they do.
744#[derive(Clone)]
745pub struct Page {
746    /// 0-based page index.
747    pub index: usize,
748    pub media_box: Rect,
749    pub crop_box: Rect,
750    pub rotate: i32,
751    /// The page's (inherited) `/Resources` dictionary.
752    pub resources: Dict,
753    dict: Dict,
754    obj_ref: Option<ObjRef>,
755}
756
757impl Page {
758    /// Builds a page from already-resolved attributes.
759    ///
760    /// [`Document::page`] resolves page-tree inheritance itself; this is for
761    /// a caller that has done that resolution some other way — notably the
762    /// asynchronous API, which flattens the page tree while reading it — and
763    /// needs the same [`Page`] type back.
764    ///
765    /// `rotate` is normalized here to one of `{0, 90, 180, 270}` exactly as
766    /// [`Document::page`] normalizes it, so a caller may pass a raw `/Rotate`
767    /// straight through: a negative or over-a-full-turn multiple of 90 is
768    /// reduced, and a value that is not a multiple of 90 falls back to 0
769    /// (lenient). The caller does not have to pre-normalize, and the [`Page`]
770    /// invariant holds whichever constructor built it.
771    pub fn from_parts(
772        index: usize,
773        media_box: Rect,
774        crop_box: Rect,
775        rotate: i32,
776        resources: Dict,
777        dict: Dict,
778        obj_ref: Option<ObjRef>,
779    ) -> Page {
780        Page {
781            index,
782            media_box,
783            crop_box,
784            rotate: normalize_rotation(rotate),
785            resources,
786            dict,
787            obj_ref,
788        }
789    }
790
791    /// The default media box for a page that declares none: US Letter
792    /// (612 by 792 points). Public because the default is part of the
793    /// observable contract — a caller comparing two APIs' pages needs to
794    /// know which rectangle "the file said nothing" maps to.
795    pub const US_LETTER: Rect = Rect::new(0.0, 0.0, 612.0, 792.0);
796
797    /// Builds a page from raw, possibly missing page-tree attributes,
798    /// applying the same defaults [`Document::page`] applies (ISO 32000
799    /// §7.7.3.3): a missing or degenerate `/MediaBox` reads as
800    /// [`Page::US_LETTER`], `/CropBox` clips to the media box and falls back
801    /// to it, a missing `/Rotate` reads as 0 and is normalized, and missing
802    /// `/Resources` read as empty.
803    ///
804    /// This is the one implementation of page defaulting. The synchronous
805    /// tree walk routes through it, and a caller that resolved inheritance
806    /// some other way — notably the asynchronous API, which flattens the
807    /// page tree while reading it — gets the identical `Page` back, so the
808    /// two APIs cannot disagree about what an attribute-less page looks
809    /// like.
810    pub fn from_tree_attrs(
811        index: usize,
812        resources: Option<Dict>,
813        media_box: Option<Rect>,
814        crop_box: Option<Rect>,
815        rotate: Option<i32>,
816        dict: Dict,
817        obj_ref: Option<ObjRef>,
818    ) -> Page {
819        let media_box = media_box
820            .filter(|r| r.width() > 0.0 && r.height() > 0.0)
821            .unwrap_or(Page::US_LETTER);
822        let crop_box = crop_box
823            .and_then(|c| c.intersect(media_box))
824            .filter(|r| r.width() > 0.0 && r.height() > 0.0)
825            .unwrap_or(media_box);
826        Page {
827            index,
828            media_box,
829            crop_box,
830            rotate: normalize_rotation(rotate.unwrap_or(0)),
831            resources: resources.unwrap_or_default(),
832            dict,
833            obj_ref,
834        }
835    }
836
837    /// The page's indirect object reference, when the page came from an
838    /// indirect kid in the page tree (pages inlined directly into a `/Kids`
839    /// array have none).
840    pub fn object_ref(&self) -> Option<ObjRef> {
841        self.obj_ref
842    }
843
844    /// The page's decoded content: the `/Contents` stream, or all streams
845    /// of a `/Contents` array decoded and joined with `b"\n"`. A missing
846    /// `/Contents` yields empty content (lenient).
847    ///
848    /// This drives [`page_content_with`] to completion on the calling thread.
849    /// There is one implementation of the algorithm, so this and the
850    /// asynchronous API cannot drift apart in what they consider a page's
851    /// content to be.
852    pub fn content(&self, doc: &Document) -> Result<Vec<u8>> {
853        block_on(page_content_with(Immediate(doc), self))
854    }
855
856    /// Crop-box width and height, swapped when `/Rotate` is a quarter turn.
857    ///
858    /// Both constructors normalize `rotate`, but the field is public and so
859    /// can be overwritten with a raw `/Rotate`; the rotation is therefore read
860    /// modulo a full turn rather than compared against 90 and 270 exactly, so
861    /// that -90 and 450 swap the dimensions just as 270 and 90 do. For an
862    /// already-normalized value this is the same test as before: 0, 90, 180
863    /// and 270 are unchanged by `rem_euclid(360)`.
864    pub fn size(&self) -> (f32, f32) {
865        let (w, h) = (self.crop_box.width(), self.crop_box.height());
866        let turn = self.rotate.rem_euclid(360);
867        if turn == 90 || turn == 270 {
868            (h, w)
869        } else {
870            (w, h)
871        }
872    }
873
874    /// The raw page dictionary.
875    pub fn dict(&self) -> &Dict {
876        &self.dict
877    }
878}
879
880/// A page's decoded content, awaiting each fetch: the `/Contents` stream, or
881/// all streams of a `/Contents` array decoded and joined with `b"\n"`. A
882/// missing `/Contents` yields empty content (lenient).
883///
884/// This is the only implementation of that algorithm. [`Page::content`] is a
885/// [`block_on`] wrapper over it, which is what stops the synchronous and
886/// asynchronous APIs from disagreeing about what a page's content is.
887///
888/// # Choosing this signature
889///
890/// `src` is taken **by value** rather than by reference. A future holding
891/// `&'a S` is `Send` but never `'static`, and the asynchronous consumers —
892/// spawning onto a runtime, or crossing into the Python bindings — need both.
893/// By value costs nothing in practice: an asynchronous document is an `Arc`
894/// handle, and [`Immediate`] over a borrowed document is `Copy`.
895///
896/// There is deliberately **no `Send` or `Sync` bound** on `S`. Auto traits are
897/// inferred per instantiation, so this one function yields a `Send` future over
898/// a genuinely asynchronous source and a non-`Send` future over
899/// `Immediate<&Document>` — which is what is wanted, because [`block_on`]
900/// drives the latter on the calling thread and never sends it anywhere. A
901/// `S: Sync` bound would exclude `Immediate<&Document>` outright; that is also
902/// why this calls `src.resolve` directly rather than
903/// [`crate::source::resolve_with`], which does require `Sync`.
904///
905/// The returned future is `'static` only when the caller owns the [`Page`]
906/// inside its own `async` block — the `&Page` borrow is what otherwise
907/// prevents it.
908///
909/// # Errors
910///
911/// Propagates whatever `src` reports for a fetch or a stream decode.
912pub async fn page_content_with<S: AsyncObjectSource>(src: S, page: &Page) -> Result<Vec<u8>> {
913    let Some(contents) = page.dict.get("Contents") else {
914        return Ok(Vec::new());
915    };
916    match src.resolve(contents).await? {
917        Object::Stream(ref s) => src.stream_data(s).await,
918        Object::Array(items) => {
919            let mut out = Vec::new();
920            let mut first = true;
921            for item in &items {
922                let part = src.resolve(item).await?;
923                let Some(stream) = part.as_stream() else {
924                    continue; // non-stream entries are skipped (lenient)
925                };
926                if !first {
927                    out.push(b'\n');
928                }
929                out.extend_from_slice(&src.stream_data(stream).await?);
930                first = false;
931            }
932            Ok(out)
933        }
934        _ => Ok(Vec::new()),
935    }
936}
937
938#[cfg(test)]
939mod tests {
940    use super::*;
941    use crate::object::Name;
942    use crate::parser::{NoResolve, Parser};
943    use crate::xref::XrefEntry;
944    use pdfboss_testkit::{multi_page_doc, objstm_doc, objstm_payload, simple_doc, PdfBuilder};
945
946    /// The synchronous accessor delegates to the asynchronous one, so the two
947    /// cannot report different content for the same page. This asserts the
948    /// equality directly rather than trusting the delegation to stay in place.
949    #[test]
950    fn async_page_content_matches_the_sync_accessor() {
951        let doc = Document::load(multi_page_doc(&["one", "two", "three"])).expect("load");
952        let mut saw_content = false;
953        for index in 0..3 {
954            let page = doc.page(index).expect("page");
955            let direct = page.content(&doc).expect("sync content");
956            let awaited =
957                block_on(page_content_with(Immediate(&doc), &page)).expect("async content");
958            assert_eq!(direct, awaited, "page {index}");
959            saw_content |= !direct.is_empty();
960        }
961        // Without this the loop above would pass on three empty vectors.
962        assert!(
963            saw_content,
964            "fixture must give at least one page real content"
965        );
966    }
967
968    /// The composition every page-reading algorithm actually uses: the caller
969    /// owns its source so that its own future can be `'static`, and reaches this
970    /// helper — which owns its source for the same reason — by handing out a
971    /// reference. That works because `&S` is itself an `AsyncObjectSource`.
972    #[test]
973    fn a_shared_helper_is_reachable_from_a_caller_that_owns_its_source() {
974        let doc = Document::load(simple_doc("Hello")).expect("load");
975        let page = doc.page(0).expect("page");
976
977        let owner = Immediate(&doc);
978        let through_reference = block_on(page_content_with(&owner, &page)).expect("async content");
979
980        assert_eq!(through_reference, page.content(&doc).expect("sync content"));
981        assert!(!through_reference.is_empty());
982    }
983
984    /// The seed is what crosses a thread boundary, so this is the compile
985    /// gate for the whole fan-out design. The `Document` itself must stay
986    /// out of this list: its caches are single-threaded by design.
987    #[test]
988    fn the_seed_is_shareable_across_threads() {
989        fn assert_send_sync<T: Send + Sync>() {}
990        assert_send_sync::<DocumentSeed>();
991    }
992
993    /// A fork shares the file and the page tree but none of the caches, and
994    /// reads identically to its parent — including through decryption, whose
995    /// key material is part of the shared immutable core.
996    #[test]
997    fn a_fork_reads_exactly_what_its_parent_reads() {
998        let doc = Document::load(pdfboss_testkit::encrypted_rc4_doc("forked secret")).unwrap();
999        let fork = doc.fork();
1000        assert_eq!(fork.page_count(), doc.page_count());
1001        let (a, b) = (doc.page(0).unwrap(), fork.page(0).unwrap());
1002        assert_eq!(a.media_box, b.media_box);
1003        assert_eq!(
1004            a.content(&doc).expect("parent content"),
1005            b.content(&fork).expect("fork content"),
1006            "decrypted content agrees"
1007        );
1008    }
1009
1010    /// The results come back in page order whatever order the workers finish
1011    /// in, and a per-page error occupies its own slot without disturbing the
1012    /// others.
1013    #[test]
1014    fn map_pages_preserves_page_order() {
1015        let doc = Document::load(multi_page_doc(&["one", "two", "three"])).unwrap();
1016        let contents = map_pages(&doc, |doc, page| page.content(doc));
1017        assert_eq!(contents.len(), 3);
1018        let texts: Vec<String> = contents
1019            .into_iter()
1020            .map(|c| String::from_utf8_lossy(&c.unwrap()).into_owned())
1021            .collect();
1022        assert!(texts[0].contains("(one)"), "{}", texts[0]);
1023        assert!(texts[1].contains("(two)"), "{}", texts[1]);
1024        assert!(texts[2].contains("(three)"), "{}", texts[2]);
1025    }
1026
1027    /// Replaces the first occurrence of `from` with `to`. Splicing happens
1028    /// after the xref section, so byte offsets stay valid.
1029    fn replace_once(data: &[u8], from: &[u8], to: &[u8]) -> Vec<u8> {
1030        let pos = memchr::memmem::find(data, from).expect("pattern present in fixture");
1031        let mut out = Vec::with_capacity(data.len() - from.len() + to.len());
1032        out.extend_from_slice(&data[..pos]);
1033        out.extend_from_slice(to);
1034        out.extend_from_slice(&data[pos + from.len()..]);
1035        out
1036    }
1037
1038    fn contains(haystack: &[u8], needle: &[u8]) -> bool {
1039        memchr::memmem::find(haystack, needle).is_some()
1040    }
1041
1042    const FONT: &str = "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>";
1043
1044    #[test]
1045    fn loads_simple_doc() {
1046        let doc = Document::load(simple_doc("Greetings, cosmos!")).unwrap();
1047        assert_eq!(doc.version(), (1, 7));
1048        assert_eq!(doc.page_count(), 1);
1049        let page = doc.page(0).unwrap();
1050        assert_eq!(page.index, 0);
1051        assert_eq!(page.media_box, Rect::new(0.0, 0.0, 612.0, 792.0));
1052        assert_eq!(page.crop_box, page.media_box);
1053        assert_eq!(page.rotate, 0);
1054        assert_eq!(page.size(), (612.0, 792.0));
1055        assert!(page.resources.get("Font").is_some());
1056        let content = page.content(&doc).unwrap();
1057        assert!(contains(&content, b"Greetings, cosmos!"));
1058    }
1059
1060    #[test]
1061    fn multi_page_ordering() {
1062        let doc = Document::load(multi_page_doc(&["alpha", "beta", "gamma"])).unwrap();
1063        assert_eq!(doc.page_count(), 3);
1064        for (i, text) in ["alpha", "beta", "gamma"].iter().enumerate() {
1065            let content = doc.page(i).unwrap().content(&doc).unwrap();
1066            assert!(
1067                contains(&content, text.as_bytes()),
1068                "page {i} should show {text}"
1069            );
1070        }
1071    }
1072
1073    #[test]
1074    fn page_index_out_of_bounds() {
1075        let doc = Document::load(simple_doc("x")).unwrap();
1076        assert!(matches!(doc.page(5), Err(Error::PageNotFound(5, 1))));
1077    }
1078
1079    #[test]
1080    fn open_reads_from_disk() {
1081        let dir = std::env::temp_dir();
1082        let path = dir.join(format!("pdfboss-doc-test-{}.pdf", std::process::id()));
1083        std::fs::write(&path, simple_doc("from disk")).unwrap();
1084        let doc = Document::open(&path).unwrap();
1085        std::fs::remove_file(&path).ok();
1086        assert_eq!(doc.page_count(), 1);
1087        let content = doc.page(0).unwrap().content(&doc).unwrap();
1088        assert!(contains(&content, b"from disk"));
1089        assert!(matches!(
1090            Document::open(dir.join("pdfboss-doc-test-missing.pdf")),
1091            Err(Error::Io(_))
1092        ));
1093    }
1094
1095    #[test]
1096    fn encrypt_in_trailer_is_rejected() {
1097        let data = replace_once(
1098            &simple_doc("secret"),
1099            b"trailer\n<< /Size",
1100            b"trailer\n<< /Encrypt 9 0 R /Size",
1101        );
1102        assert!(matches!(Document::load(data), Err(Error::Encrypted)));
1103    }
1104
1105    #[test]
1106    fn metadata_utf16be_round_trip() {
1107        let mut b = PdfBuilder::new();
1108        b.object(1, "<< /Type /Catalog /Pages 2 0 R >>");
1109        b.object(2, "<< /Type /Pages /Kids [] /Count 0 >>");
1110        // /Title is UTF-16BE with BOM: "H\u{151}" (H + o with double acute).
1111        b.object(6, "<< /Title <FEFF00480151> /Author (plain author) >>");
1112        let data = replace_once(&b.build(1), b"<< /Size", b"<< /Info 6 0 R /Size");
1113        let doc = Document::load(data).unwrap();
1114        let meta = doc.metadata();
1115        assert_eq!(meta.title.as_deref(), Some("H\u{151}"));
1116        assert_eq!(meta.author.as_deref(), Some("plain author"));
1117        assert_eq!(meta.subject, None);
1118        assert_eq!(meta.keywords, None);
1119        assert_eq!(meta.creation_date, None);
1120    }
1121
1122    #[test]
1123    fn metadata_without_info_is_all_none() {
1124        let doc = Document::load(simple_doc("x")).unwrap();
1125        assert_eq!(doc.metadata(), Metadata::default());
1126    }
1127
1128    #[test]
1129    fn missing_object_resolves_to_null() {
1130        let doc = Document::load(simple_doc("x")).unwrap();
1131        let missing = Object::Ref(ObjRef { num: 99, gen: 0 });
1132        assert_eq!(doc.resolve(&missing).unwrap(), Object::Null);
1133        assert!(matches!(
1134            doc.get(ObjRef { num: 99, gen: 0 }),
1135            Err(Error::ObjectNotFound(99, 0))
1136        ));
1137    }
1138
1139    #[test]
1140    fn self_reference_is_circular() {
1141        let mut b = PdfBuilder::new();
1142        b.object(1, "<< /Type /Catalog >>");
1143        b.object(6, "6 0 R");
1144        let doc = Document::load(b.build(1)).unwrap();
1145        let loops = Object::Ref(ObjRef { num: 6, gen: 0 });
1146        assert!(matches!(
1147            doc.resolve(&loops),
1148            Err(Error::CircularReference(6))
1149        ));
1150    }
1151
1152    #[test]
1153    fn generation_mismatch_is_tolerated() {
1154        let doc = Document::load(simple_doc("x")).unwrap();
1155        let catalog = doc.get(ObjRef { num: 1, gen: 7 }).unwrap();
1156        let dict = catalog.as_dict().unwrap();
1157        assert_eq!(dict.get_name("Type").map(|n| n.0.as_str()), Some("Catalog"));
1158    }
1159
1160    #[test]
1161    fn objects_in_object_streams_are_fetched() {
1162        let mut b = PdfBuilder::new();
1163        let (dict, payload) =
1164            objstm_payload(&[(1, "<< /Type /Catalog /Pages 2 0 R >>"), (5, FONT)]);
1165        b.stream(6, &dict, &payload);
1166        b.object(2, "<< /Type /Pages /Kids [3 0 R] /Count 1 >>");
1167        b.object(
1168            3,
1169            "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] \
1170             /Resources << /Font << /F1 5 0 R >> >> /Contents 4 0 R >>",
1171        );
1172        b.stream(4, "", b"BT /F1 12 Tf (compressed hello) Tj ET");
1173        let doc = Document::load(b.build_xref_stream(1)).unwrap();
1174        assert_eq!(doc.page_count(), 1);
1175        let page = doc.page(0).unwrap();
1176        assert!(contains(&page.content(&doc).unwrap(), b"compressed hello"));
1177        let font = doc.get(ObjRef { num: 5, gen: 0 }).unwrap();
1178        assert_eq!(
1179            font.as_dict()
1180                .and_then(|d| d.get_name("BaseFont"))
1181                .map(|n| n.0.as_str()),
1182            Some("Helvetica")
1183        );
1184    }
1185
1186    #[test]
1187    fn contents_array_is_joined_with_newlines() {
1188        let mut b = PdfBuilder::new();
1189        b.object(1, "<< /Type /Catalog /Pages 2 0 R >>");
1190        b.object(2, "<< /Type /Pages /Kids [3 0 R] /Count 1 >>");
1191        b.object(
1192            3,
1193            "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] \
1194             /Contents [4 0 R null 5 0 R] >>",
1195        );
1196        b.stream(4, "", b"q");
1197        b.stream(5, "", b"Q");
1198        let doc = Document::load(b.build(1)).unwrap();
1199        let content = doc.page(0).unwrap().content(&doc).unwrap();
1200        assert_eq!(content, b"q\nQ", "streams joined by \\n, null skipped");
1201    }
1202
1203    #[test]
1204    fn inheritance_from_pages_node_and_rotate_swap() {
1205        let mut b = PdfBuilder::new();
1206        b.object(1, "<< /Type /Catalog /Pages 2 0 R >>");
1207        b.object(
1208            2,
1209            "<< /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 \
1210             /Resources << /Font << /F1 5 0 R >> >> /MediaBox [0 0 400 600] >>",
1211        );
1212        b.object(3, "<< /Type /Page /Parent 2 0 R >>");
1213        b.object(4, "<< /Type /Page /Parent 2 0 R /Rotate 270 >>");
1214        b.object(5, FONT);
1215        let doc = Document::load(b.build(1)).unwrap();
1216        assert_eq!(doc.page_count(), 2);
1217
1218        let first = doc.page(0).unwrap();
1219        assert_eq!(first.media_box, Rect::new(0.0, 0.0, 400.0, 600.0));
1220        assert_eq!(first.crop_box, first.media_box);
1221        assert!(first.resources.get("Font").is_some(), "inherited resources");
1222        assert_eq!(first.rotate, 0);
1223        assert!(
1224            first.content(&doc).unwrap().is_empty(),
1225            "no /Contents means empty content"
1226        );
1227        assert_eq!(first.size(), (400.0, 600.0));
1228
1229        let second = doc.page(1).unwrap();
1230        assert_eq!(second.rotate, 270);
1231        assert_eq!(second.size(), (600.0, 400.0), "rotate 270 swaps w/h");
1232    }
1233
1234    #[test]
1235    fn crop_box_intersected_and_rotate_normalized() {
1236        let mut b = PdfBuilder::new();
1237        b.object(1, "<< /Type /Catalog /Pages 2 0 R >>");
1238        b.object(2, "<< /Type /Pages /Kids [3 0 R 4 0 R 5 0 R] /Count 3 >>");
1239        b.object(
1240            3,
1241            "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] \
1242             /CropBox [100 100 400 400] /Rotate 450 >>",
1243        );
1244        b.object(4, "<< /Type /Page /Parent 2 0 R /Rotate -90 >>");
1245        b.object(
1246            5,
1247            "<< /Type /Page /Parent 2 0 R /Rotate 45 /MediaBox [0 0 0 0] >>",
1248        );
1249        let doc = Document::load(b.build(1)).unwrap();
1250
1251        let clipped = doc.page(0).unwrap();
1252        assert_eq!(clipped.crop_box, Rect::new(100.0, 100.0, 200.0, 200.0));
1253        assert_eq!(clipped.rotate, 90, "450 normalizes to 90");
1254        assert_eq!(clipped.size(), (100.0, 100.0));
1255
1256        assert_eq!(doc.page(1).unwrap().rotate, 270, "-90 normalizes to 270");
1257        let odd = doc.page(2).unwrap();
1258        assert_eq!(odd.rotate, 0, "non-multiple of 90 falls back to 0");
1259        assert_eq!(
1260            odd.media_box,
1261            Page::US_LETTER,
1262            "degenerate media box defaults"
1263        );
1264    }
1265
1266    #[test]
1267    fn kids_cycle_truncates_without_hanging() {
1268        let mut b = PdfBuilder::new();
1269        b.object(1, "<< /Type /Catalog /Pages 2 0 R >>");
1270        // 2 → 3 → {4, back to 2}: the back-edge must be ignored.
1271        b.object(2, "<< /Type /Pages /Kids [3 0 R] /Count 1 >>");
1272        b.object(3, "<< /Type /Pages /Kids [4 0 R 2 0 R] /Count 1 >>");
1273        b.object(
1274            4,
1275            "<< /Type /Page /Parent 3 0 R /MediaBox [0 0 100 100] /Contents 5 0 R >>",
1276        );
1277        b.stream(5, "", b"0 0 50 50 re f");
1278        let doc = Document::load(b.build(1)).unwrap();
1279        assert_eq!(doc.page_count(), 1, "cycle back-edge yields no extra pages");
1280        assert!(contains(
1281            &doc.page(0).unwrap().content(&doc).unwrap(),
1282            b"re f"
1283        ));
1284    }
1285
1286    #[test]
1287    fn tree_depth_is_capped() {
1288        let mut b = PdfBuilder::new();
1289        b.object(1, "<< /Type /Catalog /Pages 2 0 R >>");
1290        // A unary chain of 300 intermediate nodes, page leaf at the bottom.
1291        let last = 302u32;
1292        for num in 2..last {
1293            b.object(
1294                num,
1295                &format!("<< /Type /Pages /Kids [{} 0 R] /Count 1 >>", num + 1),
1296            );
1297        }
1298        b.object(last, "<< /Type /Page >>");
1299        let doc = Document::load(b.build(1)).unwrap();
1300        // `page_count` reports the tree's declared `/Count` (1) cheaply, as
1301        // mature engines do; the leaf itself lies beyond the traversal depth
1302        // cap, so the flattened tree is empty and the page cannot be
1303        // materialized.
1304        assert_eq!(doc.page_count(), 1, "declared /Count is reported cheaply");
1305        assert!(
1306            matches!(doc.page(0), Err(Error::PageNotFound(0, 0))),
1307            "leaf beyond the depth cap cannot be materialized"
1308        );
1309    }
1310
1311    #[test]
1312    fn page_count_reports_declared_count_cheaply() {
1313        // The tree declares five pages but supplies only one kid. `page_count`
1314        // reports the declared `/Count` (as mature engines do) without walking,
1315        // while page access is bounded by the pages that actually materialize.
1316        let mut b = PdfBuilder::new();
1317        b.object(1, "<< /Type /Catalog /Pages 2 0 R >>");
1318        b.object(2, "<< /Type /Pages /Kids [3 0 R] /Count 5 >>");
1319        b.object(3, "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 100 100] >>");
1320        let doc = Document::load(b.build(1)).unwrap();
1321        assert_eq!(doc.page_count(), 5, "declared /Count reported verbatim");
1322        assert!(doc.page(0).is_ok(), "the one real page materializes");
1323        assert!(
1324            matches!(doc.page(1), Err(Error::PageNotFound(1, 1))),
1325            "access past the real pages fails with the true length"
1326        );
1327    }
1328
1329    #[test]
1330    fn page_count_falls_back_to_walk_when_count_absent() {
1331        let mut b = PdfBuilder::new();
1332        b.object(1, "<< /Type /Catalog /Pages 2 0 R >>");
1333        b.object(2, "<< /Type /Pages /Kids [3 0 R] >>"); // no /Count
1334        b.object(3, "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 100 100] >>");
1335        let doc = Document::load(b.build(1)).unwrap();
1336        assert_eq!(
1337            doc.page_count(),
1338            1,
1339            "missing /Count is recovered by walking"
1340        );
1341    }
1342
1343    #[test]
1344    fn page_count_ignores_corrupt_oversized_count() {
1345        // A `/Count` larger than the whole file is impossible: fall back to a
1346        // real walk rather than trust it.
1347        let mut b = PdfBuilder::new();
1348        b.object(1, "<< /Type /Catalog /Pages 2 0 R >>");
1349        b.object(2, "<< /Type /Pages /Kids [3 0 R] /Count 999999999 >>");
1350        b.object(3, "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 100 100] >>");
1351        let doc = Document::load(b.build(1)).unwrap();
1352        assert_eq!(doc.page_count(), 1, "implausible /Count is rejected");
1353    }
1354
1355    #[test]
1356    fn version_scan_and_default() {
1357        let mut b = PdfBuilder::new().version(2, 0);
1358        b.object(1, "<< /Type /Catalog >>");
1359        assert_eq!(Document::load(b.build(1)).unwrap().version(), (2, 0));
1360        // Corrupting the header magic (same length) falls back to 1.4.
1361        let data = replace_once(&simple_doc("v"), b"%PDF-", b"%QQQ-");
1362        assert_eq!(Document::load(data).unwrap().version(), (1, 4));
1363    }
1364
1365    #[test]
1366    fn deeply_nested_root_object_does_not_overflow_the_stack() {
1367        // A ~100 KB file whose Root is a 50k-deep array used to drive the
1368        // object parser's recursion into a fatal stack overflow during
1369        // `Document::load`. Run on a small stack so a regression aborts
1370        // loudly rather than depending on the main thread's stack size.
1371        let mut data = b"%PDF-1.7\n1 0 obj\n".to_vec();
1372        data.extend(std::iter::repeat_n(b'[', 50_000));
1373        data.extend(std::iter::repeat_n(b']', 50_000));
1374        data.extend_from_slice(b"\nendobj\ntrailer\n<</Root 1 0 R>>\n%%EOF\n");
1375        let outcome = std::thread::Builder::new()
1376            .stack_size(1024 * 1024)
1377            .spawn(move || Document::load(data).map(|doc| doc.page_count()))
1378            .expect("spawn test thread")
1379            .join()
1380            .expect("Document::load must not overflow the stack");
1381        // The over-nested Root is rejected or ignored (lenient), but the
1382        // process survives and no page is fabricated from it.
1383        assert!(matches!(outcome, Ok(0) | Err(_)));
1384    }
1385
1386    #[test]
1387    fn bytes_and_xref_accessors() {
1388        let data = simple_doc("accessors");
1389        let doc = Document::load(data.clone()).unwrap();
1390        assert_eq!(doc.bytes(), &data[..]);
1391        assert!(!doc.xref().is_empty());
1392        assert!(doc.xref().trailer.get("Root").is_some());
1393    }
1394
1395    #[test]
1396    fn object_at_spanned_reparses_identically() {
1397        let data = simple_doc("spanned");
1398        let doc = Document::load(data).unwrap();
1399        for (num, entry) in doc.xref().iter() {
1400            let XrefEntry::InFile { offset, gen } = entry else {
1401                continue;
1402            };
1403            let (r, object, span) = doc.object_at_spanned(offset as usize).unwrap();
1404            assert_eq!(r.num, num);
1405            assert_eq!(r.gen, gen);
1406            assert_eq!(span.start, offset);
1407            assert!(span.end as usize <= doc.bytes().len());
1408            // The bytes at the span parse back to the same object.
1409            let slice = &doc.bytes()[span.start as usize..span.end as usize];
1410            let (r2, object2) = Parser::new(slice).parse_indirect(&NoResolve).unwrap();
1411            assert_eq!(r2, r);
1412            assert_eq!(object2, object);
1413        }
1414    }
1415
1416    #[test]
1417    fn page_object_ref_points_at_a_page_dict() {
1418        let doc = Document::load(multi_page_doc(&["one", "two"])).unwrap();
1419        for index in 0..doc.page_count() {
1420            let page = doc.page(index).unwrap();
1421            let r = page.object_ref().expect("builder pages are indirect");
1422            let resolved = doc.get(r).unwrap();
1423            assert_eq!(
1424                resolved
1425                    .as_dict()
1426                    .unwrap()
1427                    .get_name("Type")
1428                    .map(|n| n.0.as_str()),
1429                Some("Page")
1430            );
1431        }
1432    }
1433
1434    // --- Minimal Standard-handler (RC4 V2/R3) fixture builder, duplicating
1435    // the key-derivation mechanism `crypt::tests` uses under the empty user
1436    // password (those helpers are private to that module's tests). Needed
1437    // only to pin the decrypt-identity regression test below: RC4's
1438    // per-object key depends on the object's num/gen, so it is the cipher
1439    // that can actually distinguish "decrypt with the parsed header's
1440    // identity" from "decrypt with the caller's requested identity".
1441
1442    const RC4_FIXTURE_KEY_LEN: usize = 16; // 128-bit key
1443    const RC4_FIXTURE_P: i32 = -44;
1444    const RC4_FIXTURE_ID0: &[u8] = b"0123456789abcdef";
1445    const RC4_FIXTURE_PAD: [u8; 32] = [
1446        0x28, 0xBF, 0x4E, 0x5E, 0x4E, 0x75, 0x8A, 0x41, 0x64, 0x00, 0x4E, 0x56, 0xFF, 0xFA, 0x01,
1447        0x08, 0x2E, 0x2E, 0x00, 0xB6, 0xD0, 0x68, 0x3E, 0x80, 0x2F, 0x0C, 0xA9, 0xFE, 0x64, 0x53,
1448        0x69, 0x7A,
1449    ];
1450
1451    #[rustfmt::skip]
1452    const RC4_FIXTURE_MD5_S: [u32; 64] = [
1453        7, 12, 17, 22, 7, 12, 17, 22, 7, 12, 17, 22, 7, 12, 17, 22,
1454        5, 9, 14, 20, 5, 9, 14, 20, 5, 9, 14, 20, 5, 9, 14, 20,
1455        4, 11, 16, 23, 4, 11, 16, 23, 4, 11, 16, 23, 4, 11, 16, 23,
1456        6, 10, 15, 21, 6, 10, 15, 21, 6, 10, 15, 21, 6, 10, 15, 21,
1457    ];
1458    #[rustfmt::skip]
1459    const RC4_FIXTURE_MD5_K: [u32; 64] = [
1460        0xd76aa478, 0xe8c7b756, 0x242070db, 0xc1bdceee, 0xf57c0faf, 0x4787c62a, 0xa8304613, 0xfd469501,
1461        0x698098d8, 0x8b44f7af, 0xffff5bb1, 0x895cd7be, 0x6b901122, 0xfd987193, 0xa679438e, 0x49b40821,
1462        0xf61e2562, 0xc040b340, 0x265e5a51, 0xe9b6c7aa, 0xd62f105d, 0x02441453, 0xd8a1e681, 0xe7d3fbc8,
1463        0x21e1cde6, 0xc33707d6, 0xf4d50d87, 0x455a14ed, 0xa9e3e905, 0xfcefa3f8, 0x676f02d9, 0x8d2a4c8a,
1464        0xfffa3942, 0x8771f681, 0x6d9d6122, 0xfde5380c, 0xa4beea44, 0x4bdecfa9, 0xf6bb4b60, 0xbebfbc70,
1465        0x289b7ec6, 0xeaa127fa, 0xd4ef3085, 0x04881d05, 0xd9d4d039, 0xe6db99e5, 0x1fa27cf8, 0xc4ac5665,
1466        0xf4292244, 0x432aff97, 0xab9423a7, 0xfc93a039, 0x655b59c3, 0x8f0ccc92, 0xffeff47d, 0x85845dd1,
1467        0x6fa87e4f, 0xfe2ce6e0, 0xa3014314, 0x4e0811a1, 0xf7537e82, 0xbd3af235, 0x2ad7d2bb, 0xeb86d391,
1468    ];
1469
1470    fn rc4_fixture_md5(input: &[u8]) -> [u8; 16] {
1471        let (mut a0, mut b0, mut c0, mut d0) = (
1472            0x6745_2301u32,
1473            0xefcd_ab89u32,
1474            0x98ba_dcfeu32,
1475            0x1032_5476u32,
1476        );
1477        let mut msg = input.to_vec();
1478        let bitlen = (input.len() as u64).wrapping_mul(8);
1479        msg.push(0x80);
1480        while msg.len() % 64 != 56 {
1481            msg.push(0);
1482        }
1483        msg.extend_from_slice(&bitlen.to_le_bytes());
1484        for chunk in msg.chunks_exact(64) {
1485            let mut m = [0u32; 16];
1486            for (word, bytes) in m.iter_mut().zip(chunk.chunks_exact(4)) {
1487                *word = u32::from_le_bytes(bytes.try_into().unwrap());
1488            }
1489            let (mut a, mut b, mut c, mut d) = (a0, b0, c0, d0);
1490            for i in 0..64 {
1491                let (f, g) = match i {
1492                    0..=15 => ((b & c) | (!b & d), i),
1493                    16..=31 => ((d & b) | (!d & c), (5 * i + 1) % 16),
1494                    32..=47 => (b ^ c ^ d, (3 * i + 5) % 16),
1495                    _ => (c ^ (b | !d), (7 * i) % 16),
1496                };
1497                let f = f
1498                    .wrapping_add(a)
1499                    .wrapping_add(RC4_FIXTURE_MD5_K[i])
1500                    .wrapping_add(m[g]);
1501                a = d;
1502                d = c;
1503                c = b;
1504                b = b.wrapping_add(f.rotate_left(RC4_FIXTURE_MD5_S[i]));
1505            }
1506            a0 = a0.wrapping_add(a);
1507            b0 = b0.wrapping_add(b);
1508            c0 = c0.wrapping_add(c);
1509            d0 = d0.wrapping_add(d);
1510        }
1511        let mut out = [0u8; 16];
1512        out[0..4].copy_from_slice(&a0.to_le_bytes());
1513        out[4..8].copy_from_slice(&b0.to_le_bytes());
1514        out[8..12].copy_from_slice(&c0.to_le_bytes());
1515        out[12..16].copy_from_slice(&d0.to_le_bytes());
1516        out
1517    }
1518
1519    fn rc4_fixture_rc4(key: &[u8], data: &[u8]) -> Vec<u8> {
1520        let mut s: [u8; 256] = core::array::from_fn(|i| i as u8);
1521        let mut j = 0u8;
1522        for i in 0..256 {
1523            j = j.wrapping_add(s[i]).wrapping_add(key[i % key.len()]);
1524            s.swap(i, j as usize);
1525        }
1526        let mut out = Vec::with_capacity(data.len());
1527        let (mut i, mut j) = (0u8, 0u8);
1528        for &byte in data {
1529            i = i.wrapping_add(1);
1530            j = j.wrapping_add(s[i as usize]);
1531            s.swap(i as usize, j as usize);
1532            let k = s[s[i as usize].wrapping_add(s[j as usize]) as usize];
1533            out.push(byte ^ k);
1534        }
1535        out
1536    }
1537
1538    /// `/O` for empty owner and user passwords (Algorithm 3, R3).
1539    fn rc4_fixture_owner_entry() -> Vec<u8> {
1540        let mut d = rc4_fixture_md5(&RC4_FIXTURE_PAD);
1541        for _ in 0..50 {
1542            d = rc4_fixture_md5(&d[..RC4_FIXTURE_KEY_LEN]);
1543        }
1544        let rc4key = d[..RC4_FIXTURE_KEY_LEN].to_vec();
1545        let mut o = rc4_fixture_rc4(&rc4key, &RC4_FIXTURE_PAD);
1546        for i in 1u8..=19 {
1547            let k: Vec<u8> = rc4key.iter().map(|b| b ^ i).collect();
1548            o = rc4_fixture_rc4(&k, &o);
1549        }
1550        o
1551    }
1552
1553    /// File key from `/O` for the empty user password (Algorithm 2, R3).
1554    fn rc4_fixture_file_key(o: &[u8]) -> Vec<u8> {
1555        let mut input = Vec::new();
1556        input.extend_from_slice(&RC4_FIXTURE_PAD);
1557        input.extend_from_slice(o);
1558        input.extend_from_slice(&(RC4_FIXTURE_P as u32).to_le_bytes());
1559        input.extend_from_slice(RC4_FIXTURE_ID0);
1560        let mut d = rc4_fixture_md5(&input);
1561        for _ in 0..50 {
1562            d = rc4_fixture_md5(&d[..RC4_FIXTURE_KEY_LEN]);
1563        }
1564        d[..RC4_FIXTURE_KEY_LEN].to_vec()
1565    }
1566
1567    /// `/U` for the empty user password (Algorithm 5, R3).
1568    fn rc4_fixture_user_entry(key: &[u8]) -> Vec<u8> {
1569        let mut input = Vec::new();
1570        input.extend_from_slice(&RC4_FIXTURE_PAD);
1571        input.extend_from_slice(RC4_FIXTURE_ID0);
1572        let mut x = rc4_fixture_md5(&input).to_vec();
1573        x = rc4_fixture_rc4(key, &x);
1574        for i in 1u8..=19 {
1575            let k: Vec<u8> = key.iter().map(|b| b ^ i).collect();
1576            x = rc4_fixture_rc4(&k, &x);
1577        }
1578        x.resize(32, 0); // trailing padding is arbitrary
1579        x
1580    }
1581
1582    fn rc4_fixture_obj_key(key: &[u8], num: u32, gen: u16) -> Vec<u8> {
1583        let mut input = key.to_vec();
1584        input.extend_from_slice(&num.to_le_bytes()[..3]);
1585        input.extend_from_slice(&gen.to_le_bytes()[..2]);
1586        rc4_fixture_md5(&input)[..(key.len() + 5).min(16)].to_vec()
1587    }
1588
1589    fn rc4_fixture_hexstr(b: &[u8]) -> String {
1590        let mut s = String::from("<");
1591        for x in b {
1592            s.push_str(&format!("{x:02x}"));
1593        }
1594        s.push('>');
1595        s
1596    }
1597
1598    /// Builds a V2/R3 (128-bit RC4) file, encrypted under the empty
1599    /// password, with a single indirect object (`3 0 obj`, i.e. gen 0)
1600    /// holding an encrypted string.
1601    fn rc4_encrypted_fixture() -> Vec<u8> {
1602        let o = rc4_fixture_owner_entry();
1603        let key = rc4_fixture_file_key(&o);
1604        let u = rc4_fixture_user_entry(&key);
1605        let msg = rc4_fixture_rc4(&rc4_fixture_obj_key(&key, 3, 0), b"Top secret message");
1606
1607        let mut b = PdfBuilder::new().version(1, 4);
1608        b.object(1, "<< /Type /Catalog /Pages 2 0 R >>");
1609        b.object(2, "<< /Type /Pages /Kids [] /Count 0 >>");
1610        b.object(3, &format!("<< /Msg {} >>", rc4_fixture_hexstr(&msg)));
1611        b.object(
1612            9,
1613            &format!(
1614                "<< /Filter /Standard /V 2 /R 3 /Length 128 /P {} /O {} /U {} >>",
1615                RC4_FIXTURE_P,
1616                rc4_fixture_hexstr(&o),
1617                rc4_fixture_hexstr(&u)
1618            ),
1619        );
1620        let trailer = format!(
1621            "/Encrypt 9 0 R /ID [{}{}]",
1622            rc4_fixture_hexstr(RC4_FIXTURE_ID0),
1623            rc4_fixture_hexstr(RC4_FIXTURE_ID0)
1624        );
1625        b.trailer_extra(&trailer).build(1)
1626    }
1627
1628    #[test]
1629    fn encrypted_generation_mismatch_still_decrypts() {
1630        // `object_at_spanned` derives the per-object RC4/AESV2 decrypt key
1631        // from the PARSED "N G obj" header at the object's file offset
1632        // (`r.num`, `r.gen` from `parser.parse_indirect`), never from the
1633        // caller's requested `ObjRef` — mirroring how plain (unencrypted)
1634        // lookups already tolerate a generation mismatch
1635        // (`generation_mismatch_is_tolerated`). Request object 3 (really
1636        // "3 0 obj" in the file) under a deliberately wrong generation: if
1637        // decryption instead used the requested (wrong) gen to derive the
1638        // RC4 object key, the result would be garbage, not the plaintext.
1639        let doc = Document::load(rc4_encrypted_fixture()).expect("empty password opens the file");
1640        let obj3 = doc.get(ObjRef { num: 3, gen: 7 }).unwrap();
1641        let msg = obj3
1642            .as_dict()
1643            .unwrap()
1644            .get("Msg")
1645            .unwrap()
1646            .as_str_bytes()
1647            .unwrap();
1648        assert_eq!(
1649            msg, b"Top secret message",
1650            "decrypted using the file's real gen (0), not the mismatched request (7)"
1651        );
1652    }
1653
1654    #[test]
1655    fn objstm_doc_fixture_loads_and_resolves_members() {
1656        let data = objstm_doc(&[(7, "<< /Marker (inside) >>")]);
1657        let doc = Document::load(data).unwrap();
1658        assert_eq!(doc.page_count(), 1);
1659        let member = doc.get(ObjRef { num: 7, gen: 0 }).unwrap();
1660        let text = member.as_dict().unwrap().get("Marker").unwrap();
1661        assert_eq!(text.as_str_bytes(), Some(&b"inside"[..]));
1662        // The member really is xref'd into the object stream.
1663        assert!(matches!(
1664            doc.xref().get(7),
1665            Some(XrefEntry::InStream { stream_num: 4, .. })
1666        ));
1667    }
1668
1669    /// A page built from parts must expose the same accessors as one that
1670    /// came out of the page tree — this is the constructor aio uses.
1671    #[test]
1672    fn page_from_parts_exposes_its_accessors() {
1673        let mut dict = Dict::default();
1674        dict.insert(Name("Type".into()), Object::Name(Name("Page".into())));
1675        let media = Rect::new(0.0, 0.0, 200.0, 400.0);
1676        let obj_ref = ObjRef { num: 7, gen: 0 };
1677
1678        let page = Page::from_parts(
1679            3,
1680            media,
1681            media,
1682            90,
1683            Dict::default(),
1684            dict.clone(),
1685            Some(obj_ref),
1686        );
1687
1688        assert_eq!(page.index, 3);
1689        assert_eq!(page.object_ref(), Some(obj_ref));
1690        assert_eq!(page.dict(), &dict);
1691        // /Rotate 90 swaps the reported page size.
1692        assert_eq!(page.size(), (400.0, 200.0));
1693    }
1694
1695    /// `from_parts` normalizes `/Rotate` just as the page tree does, so an
1696    /// unnormalized quarter-turn stores — and reports the size of — its
1697    /// canonical equivalent.
1698    #[test]
1699    fn page_from_parts_normalizes_rotation() {
1700        let media = Rect::new(0.0, 0.0, 200.0, 400.0);
1701        let build = |rotate: i32| {
1702            Page::from_parts(
1703                0,
1704                media,
1705                media,
1706                rotate,
1707                Dict::default(),
1708                Dict::default(),
1709                None,
1710            )
1711        };
1712
1713        for (given, canonical) in [(-90, 270), (450, 90), (540, 180), (720, 0), (-360, 0)] {
1714            let page = build(given);
1715            assert_eq!(
1716                page.rotate, canonical,
1717                "from_parts must normalize /Rotate {given} to {canonical}"
1718            );
1719            assert_eq!(
1720                page.size(),
1721                build(canonical).size(),
1722                "/Rotate {given} must report the same size as {canonical}"
1723            );
1724        }
1725    }
1726
1727    /// `rotate` is a public field, so `size()` cannot rely on the constructor
1728    /// having normalized it: any multiple of 90 must be read modulo a full
1729    /// turn. The already-canonical values are listed too, pinning that this
1730    /// is unchanged for them.
1731    #[test]
1732    fn page_size_handles_unnormalized_rotation() {
1733        let media = Rect::new(0.0, 0.0, 200.0, 400.0);
1734        let mut page = Page::from_parts(0, media, media, 0, Dict::default(), Dict::default(), None);
1735
1736        for rotate in [90, 270, -90, -270, 450, 630] {
1737            page.rotate = rotate;
1738            assert_eq!(
1739                page.size(),
1740                (400.0, 200.0),
1741                "/Rotate {rotate} is a quarter turn and must swap the size"
1742            );
1743        }
1744        for rotate in [0, 180, -180, 360, 540, 720] {
1745            page.rotate = rotate;
1746            assert_eq!(
1747                page.size(),
1748                (200.0, 400.0),
1749                "/Rotate {rotate} is a half turn and must leave the size alone"
1750            );
1751        }
1752    }
1753}