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