Skip to main content

zpdf_document/
page.rs

1use std::borrow::Cow;
2use std::collections::{HashMap, HashSet};
3
4use tracing::warn;
5use zpdf_core::{ObjectId, PdfDict, PdfObject, Rect, Result};
6use zpdf_parser::PdfFile;
7
8/// Hard cap on page-tree walks (`/Parent` chains and `/Kids` recursion) — far
9/// deeper than any sane document, it bounds malformed or adversarial trees in
10/// concert with the visited-set cycle checks.
11pub(crate) const MAX_PAGE_TREE_DEPTH: usize = 64;
12
13/// L8 Fix: Maximum number of pages to collect from the page tree. Protects
14/// against adversarial PDFs with massive /Kids arrays or deeply nested trees
15/// that could exhaust memory. Real-world PDFs rarely exceed 100k pages.
16pub(crate) const MAX_PAGE_COUNT: usize = 1_000_000;
17
18/// US Letter, used when a page has no usable `/MediaBox` (missing, degenerate,
19/// or non-finite). Matches the fallback mainstream PDF readers apply.
20const DEFAULT_MEDIA_BOX: Rect = Rect {
21    x0: 0.0,
22    y0: 0.0,
23    x1: 612.0,
24    y1: 792.0,
25};
26
27/// A box is usable only if all four corners are finite and it encloses a
28/// non-empty area once normalized. Rejects NaN/∞ (which would poison the raster
29/// dimension math downstream) and zero/negative-area rectangles.
30fn is_usable_box(r: &Rect) -> bool {
31    if ![r.x0, r.y0, r.x1, r.y1].iter().all(|v| v.is_finite()) {
32        return false;
33    }
34    let n = r.normalize();
35    n.width() > 0.0 && n.height() > 0.0
36}
37
38#[derive(Debug)]
39pub struct PdfPage {
40    pub id: ObjectId,
41    pub media_box: Rect,
42    pub crop_box: Rect,
43    pub rotate: i32,
44    pub resources: ResourceDict,
45    pub contents: Vec<ObjectId>,
46    /// Annotation object ids from `/Annots`, parsed but not yet rendered.
47    pub annots: Vec<ObjectId>,
48    /// PDF 2.0 page-level `/OutputIntents`. Overrides the document-level intents
49    /// for this page; empty for pre-2.0 / most documents. Not an inheritable
50    /// attribute — read off the leaf page dictionary only.
51    pub output_intents: Vec<crate::output_intents::OutputIntent>,
52}
53
54#[derive(Debug, Default)]
55pub struct ResourceDict {
56    pub fonts: HashMap<String, ObjectId>,
57    pub xobjects: HashMap<String, ObjectId>,
58    pub ext_g_state: HashMap<String, ObjectId>,
59    pub ext_g_state_inline: HashMap<String, zpdf_core::PdfDict>,
60    pub color_spaces: HashMap<String, ObjectId>,
61    /// Colorspace resources whose value is a direct array/name rather than a
62    /// reference (common from Quartz and Ghostscript).
63    pub color_spaces_inline: HashMap<String, PdfObject>,
64    pub patterns: HashMap<String, ObjectId>,
65    pub shadings: HashMap<String, ObjectId>,
66    pub shadings_inline: HashMap<String, PdfObject>,
67    /// /Properties (marked-content property lists, e.g. BDC /OC lookups).
68    pub properties: HashMap<String, ObjectId>,
69    pub properties_inline: HashMap<String, zpdf_core::PdfDict>,
70}
71
72impl PdfPage {
73    pub fn from_object(file: &PdfFile, page_id: ObjectId) -> Result<Self> {
74        let obj = file.resolve(page_id)?;
75        let dict = obj.as_dict()?;
76
77        // MediaBox, CropBox, Rotate and Resources are all inheritable page
78        // attributes (PDF 32000-1 Table 31): one guarded walk up /Parent
79        // gathers whichever values the leaf doesn't carry itself.
80        let inherited = InheritedAttrs::gather(file, dict);
81
82        // /MediaBox is required and inheritable, but real-world files routinely
83        // omit it or carry a degenerate/non-finite one. Mainstream readers fall
84        // back to US Letter rather than refusing the page; do the same so a
85        // single bad page never sinks the whole document.
86        let media_box = inherited
87            .media_box
88            .filter(is_usable_box)
89            .unwrap_or(DEFAULT_MEDIA_BOX);
90        let crop_box = inherited
91            .crop_box
92            .filter(is_usable_box)
93            .unwrap_or(media_box);
94        let rotate = inherited.rotate.unwrap_or(0);
95        let resources = inherited.resources.unwrap_or_default();
96
97        let contents = Self::collect_content_refs(file, dict.get("Contents"));
98        let annots = Self::collect_annot_refs(file, dict.get("Annots"));
99        // PDF 2.0 page-level output intents (off the leaf dict, not inherited).
100        let output_intents = crate::output_intents::parse_page_output_intents(file, dict);
101
102        Ok(Self {
103            id: page_id,
104            media_box,
105            crop_box,
106            rotate,
107            resources,
108            contents,
109            annots,
110            output_intents,
111        })
112    }
113
114    /// Collect the page's content-stream object ids from `/Contents`, which may
115    /// be: a single stream ref; a direct array of stream refs; or — as some
116    /// scanners emit — an indirect ref *to* an array of stream refs (double
117    /// indirection). The latter is resolved one level so the array is flattened
118    /// rather than mistaken for a single (non-stream) object.
119    fn collect_content_refs(file: &PdfFile, contents: Option<&PdfObject>) -> Vec<ObjectId> {
120        fn refs_from_array(arr: &[PdfObject]) -> Vec<ObjectId> {
121            arr.iter()
122                .filter_map(|o| match o {
123                    PdfObject::Ref(r) => Some(*r),
124                    _ => None,
125                })
126                .collect()
127        }
128        match contents {
129            Some(PdfObject::Array(arr)) => refs_from_array(arr),
130            Some(PdfObject::Ref(r)) => match file.resolve(*r) {
131                // Ref → array of stream refs: flatten it.
132                Ok(PdfObject::Array(arr)) => refs_from_array(&arr),
133                // Ref → a single content stream: keep the ref itself.
134                Ok(PdfObject::Stream(_)) => vec![*r],
135                // Anything else (incl. resolve failure): treat as the lone ref so
136                // a later resolve attempt surfaces the real error.
137                _ => vec![*r],
138            },
139            _ => vec![],
140        }
141    }
142
143    /// Collect annotation object ids from `/Annots` (a direct array or a ref
144    /// to an array). Parse-only plumbing: appearance streams are not rendered.
145    fn collect_annot_refs(file: &PdfFile, annots: Option<&PdfObject>) -> Vec<ObjectId> {
146        fn refs_from_array(arr: &[PdfObject]) -> Vec<ObjectId> {
147            arr.iter()
148                .filter_map(|o| match o {
149                    PdfObject::Ref(r) => Some(*r),
150                    _ => None,
151                })
152                .collect()
153        }
154        match annots {
155            Some(PdfObject::Array(arr)) => refs_from_array(arr),
156            Some(PdfObject::Ref(r)) => match file.resolve(*r) {
157                Ok(PdfObject::Array(arr)) => refs_from_array(&arr),
158                _ => Vec::new(),
159            },
160            _ => Vec::new(),
161        }
162    }
163
164    pub fn width(&self) -> f64 {
165        self.media_box.width()
166    }
167
168    pub fn height(&self) -> f64 {
169        self.media_box.height()
170    }
171
172    /// The rectangle the page is rendered into: `/CropBox` intersected with
173    /// `/MediaBox`. Per spec a CropBox extending beyond the MediaBox is
174    /// clamped to it; an empty or non-overlapping CropBox falls back to the
175    /// full MediaBox.
176    pub fn effective_box(&self) -> Rect {
177        let media = self.media_box.normalize();
178        let crop = self.crop_box.normalize();
179        let inter = Rect::new(
180            crop.x0.max(media.x0),
181            crop.y0.max(media.y0),
182            crop.x1.min(media.x1),
183            crop.y1.min(media.y1),
184        );
185        if inter.x1 > inter.x0 && inter.y1 > inter.y0 {
186            inter
187        } else {
188            media
189        }
190    }
191}
192
193/// Inheritable page attributes (PDF 32000-1 Table 31), filled in leaf-first
194/// while walking up the `/Parent` chain with cycle and depth guards.
195#[derive(Default)]
196struct InheritedAttrs {
197    media_box: Option<Rect>,
198    crop_box: Option<Rect>,
199    rotate: Option<i32>,
200    resources: Option<ResourceDict>,
201}
202
203impl InheritedAttrs {
204    fn is_complete(&self) -> bool {
205        self.media_box.is_some()
206            && self.crop_box.is_some()
207            && self.rotate.is_some()
208            && self.resources.is_some()
209    }
210
211    fn gather(file: &PdfFile, leaf: &PdfDict) -> Self {
212        let mut attrs = Self::default();
213        let mut visited: HashSet<ObjectId> = HashSet::new();
214        let mut current: Cow<'_, PdfDict> = Cow::Borrowed(leaf);
215        let mut depth = 0usize;
216
217        loop {
218            attrs.absorb(file, &current);
219            if attrs.is_complete() {
220                break;
221            }
222            let parent_ref = match current.get("Parent") {
223                Some(PdfObject::Ref(r)) => *r,
224                _ => break,
225            };
226            depth += 1;
227            if depth > MAX_PAGE_TREE_DEPTH {
228                warn!("page-tree /Parent chain deeper than {MAX_PAGE_TREE_DEPTH}; stopping inheritance walk");
229                break;
230            }
231            if !visited.insert(parent_ref) {
232                warn!("page-tree /Parent cycle at {parent_ref}; stopping inheritance walk");
233                break;
234            }
235            match file.resolve(parent_ref) {
236                Ok(PdfObject::Dict(d)) => current = Cow::Owned(d),
237                Ok(PdfObject::Null) => {
238                    warn!(
239                        "page-tree parent {parent_ref} resolves to null; stopping inheritance walk"
240                    );
241                    break;
242                }
243                Ok(other) => {
244                    warn!(
245                        "page-tree parent {parent_ref} is {}, expected Dict; stopping inheritance walk",
246                        other.type_name()
247                    );
248                    break;
249                }
250                Err(e) => {
251                    warn!("failed to resolve page-tree parent {parent_ref}: {e}");
252                    break;
253                }
254            }
255        }
256        attrs
257    }
258
259    /// Pick up any attribute the walk hasn't found yet from `dict`. Values
260    /// closer to the leaf win, so only `None` slots are filled.
261    fn absorb(&mut self, file: &PdfFile, dict: &PdfDict) {
262        if self.media_box.is_none() {
263            self.media_box = resolve_rect(file, dict, "MediaBox");
264        }
265        if self.crop_box.is_none() {
266            self.crop_box = resolve_rect(file, dict, "CropBox");
267        }
268        if self.rotate.is_none() {
269            self.rotate = resolve_i64(file, dict.get("Rotate")).map(|n| n as i32);
270        }
271        if self.resources.is_none() {
272            if let Some(d) = resolve_sub_dict(dict, "Resources", file) {
273                match parse_resource_dict(&d, file) {
274                    Ok(r) => self.resources = Some(r),
275                    Err(e) => warn!("failed to parse /Resources: {e}"),
276                }
277            }
278        }
279    }
280}
281
282/// Read a rectangle value that may be a direct array, an indirect ref to an
283/// array, or an array whose elements are themselves indirect number refs.
284pub(crate) fn resolve_rect(file: &PdfFile, dict: &PdfDict, key: &str) -> Option<Rect> {
285    let arr: Cow<'_, [PdfObject]> = match dict.get(key)? {
286        PdfObject::Array(a) => Cow::Borrowed(a.as_slice()),
287        PdfObject::Ref(r) => match file.resolve(*r) {
288            Ok(PdfObject::Array(a)) => Cow::Owned(a),
289            Ok(other) => {
290                warn!(
291                    "/{key} ref {r} resolved to {}, expected Array",
292                    other.type_name()
293                );
294                return None;
295            }
296            Err(e) => {
297                warn!("failed to resolve /{key} ref {r}: {e}");
298                return None;
299            }
300        },
301        _ => return None,
302    };
303    if arr.len() != 4 {
304        warn!("/{key} array has {} elements, expected 4", arr.len());
305        return None;
306    }
307    let mut v = [0f64; 4];
308    for (slot, obj) in v.iter_mut().zip(arr.iter()) {
309        *slot = match obj {
310            PdfObject::Ref(r) => file.resolve(*r).ok()?.as_f64().ok()?,
311            other => other.as_f64().ok()?,
312        };
313    }
314    Some(Rect::new(v[0], v[1], v[2], v[3]))
315}
316
317/// Read an integer value that may be direct or an indirect ref.
318fn resolve_i64(file: &PdfFile, value: Option<&PdfObject>) -> Option<i64> {
319    match value? {
320        PdfObject::Integer(n) => Some(*n),
321        PdfObject::Real(r) => Some(*r as i64),
322        PdfObject::Ref(r) => match file.resolve(*r).ok()? {
323            PdfObject::Integer(n) => Some(n),
324            PdfObject::Real(r) => Some(r as i64),
325            _ => None,
326        },
327        _ => None,
328    }
329}
330
331fn resolve_sub_dict<'a>(
332    dict: &'a zpdf_core::PdfDict,
333    key: &str,
334    file: &'a PdfFile,
335) -> Option<std::borrow::Cow<'a, zpdf_core::PdfDict>> {
336    match dict.get(key) {
337        Some(PdfObject::Dict(d)) => Some(std::borrow::Cow::Borrowed(d)),
338        Some(PdfObject::Ref(r)) => file.resolve(*r).ok().and_then(|o| match o {
339            PdfObject::Dict(d) => Some(std::borrow::Cow::Owned(d)),
340            _ => None,
341        }),
342        _ => None,
343    }
344}
345
346pub fn parse_resource_dict(dict: &zpdf_core::PdfDict, file: &PdfFile) -> Result<ResourceDict> {
347    let mut res = ResourceDict::default();
348
349    if let Some(fonts) = resolve_sub_dict(dict, "Font", file) {
350        for (name, obj) in &fonts.0 {
351            if let PdfObject::Ref(r) = obj {
352                res.fonts.insert(name.0.clone(), *r);
353            }
354        }
355    }
356
357    if let Some(xobjects) = resolve_sub_dict(dict, "XObject", file) {
358        for (name, obj) in &xobjects.0 {
359            if let PdfObject::Ref(r) = obj {
360                res.xobjects.insert(name.0.clone(), *r);
361            }
362        }
363    }
364
365    if let Some(gs) = resolve_sub_dict(dict, "ExtGState", file) {
366        for (name, obj) in &gs.0 {
367            match obj {
368                PdfObject::Ref(r) => {
369                    res.ext_g_state.insert(name.0.clone(), *r);
370                }
371                PdfObject::Dict(d) => {
372                    res.ext_g_state_inline.insert(name.0.clone(), d.clone());
373                }
374                _ => {}
375            }
376        }
377    }
378
379    if let Some(cs) = resolve_sub_dict(dict, "ColorSpace", file) {
380        for (name, obj) in &cs.0 {
381            match obj {
382                PdfObject::Ref(r) => {
383                    res.color_spaces.insert(name.0.clone(), *r);
384                }
385                other @ (PdfObject::Array(_) | PdfObject::Name(_)) => {
386                    res.color_spaces_inline
387                        .insert(name.0.clone(), other.clone());
388                }
389                _ => {}
390            }
391        }
392    }
393
394    if let Some(pat) = resolve_sub_dict(dict, "Pattern", file) {
395        for (name, obj) in &pat.0 {
396            if let PdfObject::Ref(r) = obj {
397                res.patterns.insert(name.0.clone(), *r);
398            }
399        }
400    }
401
402    if let Some(sh) = resolve_sub_dict(dict, "Shading", file) {
403        for (name, obj) in &sh.0 {
404            match obj {
405                PdfObject::Ref(r) => {
406                    res.shadings.insert(name.0.clone(), *r);
407                }
408                other @ PdfObject::Dict(_) => {
409                    res.shadings_inline.insert(name.0.clone(), other.clone());
410                }
411                _ => {}
412            }
413        }
414    }
415
416    if let Some(props) = resolve_sub_dict(dict, "Properties", file) {
417        for (name, obj) in &props.0 {
418            match obj {
419                PdfObject::Ref(r) => {
420                    res.properties.insert(name.0.clone(), *r);
421                }
422                PdfObject::Dict(d) => {
423                    res.properties_inline.insert(name.0.clone(), d.clone());
424                }
425                _ => {}
426            }
427        }
428    }
429
430    Ok(res)
431}
432
433#[cfg(test)]
434mod tests {
435    use super::*;
436    use crate::test_util::build_pdf;
437    use crate::PdfDocument;
438
439    /// Open a synthetic PDF and return its first page.
440    fn page0(objects: &[&str]) -> PdfPage {
441        let doc = PdfDocument::open(build_pdf(objects)).expect("open");
442        doc.page(0).expect("page")
443    }
444
445    #[test]
446    fn rotate_and_resources_inherited_from_pages_node() {
447        let page = page0(&[
448            "<< /Type /Catalog /Pages 2 0 R >>",
449            "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 612 792] /Rotate 90 /Resources << /Font << /F1 4 0 R >> >> >>",
450            "<< /Type /Page /Parent 2 0 R >>",
451            "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>",
452        ]);
453        assert_eq!(page.rotate, 90);
454        assert_eq!(page.media_box, Rect::new(0.0, 0.0, 612.0, 792.0));
455        assert_eq!(page.resources.fonts.get("F1"), Some(&ObjectId(4, 0)));
456    }
457
458    #[test]
459    fn leaf_attributes_override_inherited() {
460        let page = page0(&[
461            "<< /Type /Catalog /Pages 2 0 R >>",
462            "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 612 792] /Rotate 90 /Resources << /Font << /F1 4 0 R >> >> >>",
463            "<< /Type /Page /Parent 2 0 R /Rotate 180 /Resources << /Font << /F2 4 0 R >> >> >>",
464            "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>",
465        ]);
466        assert_eq!(page.rotate, 180);
467        assert!(page.resources.fonts.contains_key("F2"));
468        // The leaf's own /Resources replaces (not merges with) the parent's.
469        assert!(!page.resources.fonts.contains_key("F1"));
470    }
471
472    #[test]
473    fn indirect_media_and_crop_boxes_resolve() {
474        let page = page0(&[
475            "<< /Type /Catalog /Pages 2 0 R >>",
476            "<< /Type /Pages /Kids [3 0 R] /Count 1 >>",
477            "<< /Type /Page /Parent 2 0 R /MediaBox 4 0 R /CropBox [10 10 5 0 R 200] >>",
478            "[0 0 300 400]",
479            "100",
480        ]);
481        assert_eq!(page.media_box, Rect::new(0.0, 0.0, 300.0, 400.0));
482        assert_eq!(page.crop_box, Rect::new(10.0, 10.0, 100.0, 200.0));
483    }
484
485    #[test]
486    fn parent_cycle_terminates_and_keeps_found_values() {
487        // Nodes 2 and 3 name each other as /Parent; the walk must terminate
488        // and still pick up the MediaBox found before the cycle closes.
489        let page = page0(&[
490            "<< /Type /Catalog /Pages 2 0 R >>",
491            "<< /Type /Pages /Kids [3 0 R] /Count 1 /Parent 3 0 R /MediaBox [0 0 100 100] >>",
492            "<< /Type /Page /Parent 2 0 R >>",
493        ]);
494        assert_eq!(page.media_box, Rect::new(0.0, 0.0, 100.0, 100.0));
495        assert_eq!(page.rotate, 0);
496    }
497
498    #[test]
499    fn annots_refs_collected() {
500        let page = page0(&[
501            "<< /Type /Catalog /Pages 2 0 R >>",
502            "<< /Type /Pages /Kids [3 0 R] /Count 1 >>",
503            "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 100 100] /Annots [4 0 R 5 0 R] >>",
504            "<< /Type /Annot /Subtype /Link >>",
505            "<< /Type /Annot /Subtype /Square >>",
506        ]);
507        assert_eq!(page.annots, vec![ObjectId(4, 0), ObjectId(5, 0)]);
508    }
509
510    fn page_with_boxes(media: Rect, crop: Rect) -> PdfPage {
511        PdfPage {
512            id: ObjectId(1, 0),
513            media_box: media,
514            crop_box: crop,
515            rotate: 0,
516            resources: ResourceDict::default(),
517            contents: vec![],
518            annots: vec![],
519            output_intents: vec![],
520        }
521    }
522
523    #[test]
524    fn effective_box_intersects_crop_with_media() {
525        let media = Rect::new(0.0, 0.0, 612.0, 792.0);
526        // CropBox inside MediaBox: used as-is.
527        let p = page_with_boxes(media, Rect::new(10.0, 20.0, 500.0, 700.0));
528        assert_eq!(p.effective_box(), Rect::new(10.0, 20.0, 500.0, 700.0));
529        // CropBox sticking out on every side: clamped to the MediaBox.
530        let p = page_with_boxes(media, Rect::new(-50.0, -50.0, 700.0, 800.0));
531        assert_eq!(p.effective_box(), media);
532        // Partial overlap: the intersection.
533        let p = page_with_boxes(media, Rect::new(300.0, 400.0, 900.0, 900.0));
534        assert_eq!(p.effective_box(), Rect::new(300.0, 400.0, 612.0, 792.0));
535    }
536
537    #[test]
538    fn effective_box_falls_back_to_media_box() {
539        let media = Rect::new(0.0, 0.0, 612.0, 792.0);
540        // Disjoint CropBox.
541        let p = page_with_boxes(media, Rect::new(1000.0, 1000.0, 1100.0, 1100.0));
542        assert_eq!(p.effective_box(), media);
543        // Degenerate (zero-area) CropBox.
544        let p = page_with_boxes(media, Rect::new(100.0, 100.0, 100.0, 100.0));
545        assert_eq!(p.effective_box(), media);
546        // Default: CropBox == MediaBox.
547        let p = page_with_boxes(media, media);
548        assert_eq!(p.effective_box(), media);
549    }
550
551    #[test]
552    fn effective_box_normalizes_inverted_crop() {
553        let media = Rect::new(0.0, 0.0, 612.0, 792.0);
554        let p = page_with_boxes(media, Rect::new(500.0, 700.0, 10.0, 20.0));
555        assert_eq!(p.effective_box(), Rect::new(10.0, 20.0, 500.0, 700.0));
556    }
557}