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