Skip to main content

pdf_syntax/
page.rs

1//! Reading the pages of a PDF document.
2
3use crate::content::{TypedIter, UntypedIter};
4use crate::object::Array;
5use crate::object::Dict;
6use crate::object::Name;
7use crate::object::Rect;
8use crate::object::Stream;
9use crate::object::dict::keys::*;
10use crate::object::{Object, ObjectIdentifier};
11use crate::reader::ReaderContext;
12use crate::sync::OnceLock;
13use crate::util::FloatExt;
14use crate::xref::XRef;
15use alloc::collections::BTreeSet;
16use alloc::vec;
17use alloc::vec::Vec;
18use core::ops::Deref;
19use log::warn;
20
21/// Attributes that can be inherited.
22#[derive(Debug, Clone)]
23struct PagesContext<'a> {
24    media_box: Option<Rect>,
25    crop_box: Option<Rect>,
26    rotate: Option<u32>,
27    resources: Dict<'a>,
28}
29
30impl PagesContext<'_> {
31    fn new() -> Self {
32        Self {
33            media_box: None,
34            crop_box: None,
35            rotate: None,
36            resources: Dict::empty(),
37        }
38    }
39}
40
41/// A structure holding the pages of a PDF document.
42pub struct Pages<'a> {
43    pages: Vec<Page<'a>>,
44    xref: &'a XRef,
45}
46
47impl<'a> Pages<'a> {
48    /// Create a new `Pages` object.
49    pub(crate) fn new(
50        pages_dict: &Dict<'a>,
51        ctx: &ReaderContext<'a>,
52        xref: &'a XRef,
53    ) -> Option<Self> {
54        let mut pages = vec![];
55        let pages_ctx = PagesContext::new();
56        resolve_pages(pages_dict, &mut pages, pages_ctx, ctx)?;
57
58        Some(Self { pages, xref })
59    }
60
61    /// Create a new `Pages` object by bruteforce-searching.
62    ///
63    /// Of course this could result in the order of pages being messed up, but
64    /// this is still better than nothing.
65    pub(crate) fn new_brute_force(ctx: &ReaderContext<'a>, xref: &'a XRef) -> Option<Self> {
66        let mut pages = vec![];
67
68        for object in xref.objects() {
69            if let Some(dict) = object.into_dict()
70                && let Some(page) = Page::new(&dict, &PagesContext::new(), ctx, true)
71            {
72                pages.push(page);
73            }
74        }
75
76        if pages.is_empty() {
77            return None;
78        }
79
80        Some(Self { pages, xref })
81    }
82
83    /// Return the xref table (of the document the pages belong to).   
84    pub fn xref(&self) -> &'a XRef {
85        self.xref
86    }
87}
88
89impl<'a> Deref for Pages<'a> {
90    type Target = [Page<'a>];
91
92    fn deref(&self) -> &Self::Target {
93        &self.pages
94    }
95}
96
97/// Maximum depth for recursive page tree traversal.
98/// Prevents stack overflow on malformed PDFs with deeply nested or circular page trees.
99const MAX_PAGE_TREE_DEPTH: usize = 256;
100
101/// Maximum number of pages to collect from the page tree.
102/// Prevents exponential blowup from "page tree bombs" where shared Kids nodes
103/// cause the same subtrees to be visited multiple times (e.g., 8 KB PDF → 2^27 pages).
104const MAX_PAGE_COUNT: usize = 100_000;
105
106fn resolve_pages<'a>(
107    pages_dict: &Dict<'a>,
108    entries: &mut Vec<Page<'a>>,
109    ctx: PagesContext<'a>,
110    reader_ctx: &ReaderContext<'a>,
111) -> Option<()> {
112    let max_depth = reader_ctx
113        .load_limits()
114        .object_depth_limit()
115        .map(|d| d as usize)
116        .unwrap_or(MAX_PAGE_TREE_DEPTH);
117
118    let mut visited = BTreeSet::new();
119    resolve_pages_depth(
120        pages_dict,
121        entries,
122        ctx,
123        reader_ctx,
124        0,
125        max_depth,
126        &mut visited,
127    )
128}
129
130fn resolve_pages_depth<'a>(
131    pages_dict: &Dict<'a>,
132    entries: &mut Vec<Page<'a>>,
133    mut ctx: PagesContext<'a>,
134    reader_ctx: &ReaderContext<'a>,
135    depth: usize,
136    max_depth: usize,
137    visited: &mut BTreeSet<ObjectIdentifier>,
138) -> Option<()> {
139    if depth > max_depth {
140        log::warn!("Page tree depth exceeds {max_depth}, stopping traversal");
141        return None;
142    }
143
144    // Cycle protection: a /Pages node reachable from itself (directly or via a
145    // /Kids back-reference) would otherwise be re-walked, duplicating pages and
146    // wasting work up to the depth cap. Stop the first time a node is re-entered.
147    // Inline (non-indirect) nodes have no object id and are simply not tracked.
148    if let Some(node_id) = pages_dict.obj_id()
149        && !visited.insert(node_id)
150    {
151        log::warn!("Page tree cycle detected at {node_id:?}, stopping traversal");
152        return Some(());
153    }
154
155    if let Some(media_box) = pages_dict.get::<Rect>(MEDIA_BOX) {
156        ctx.media_box = Some(media_box);
157    }
158
159    if let Some(crop_box) = pages_dict.get::<Rect>(CROP_BOX) {
160        ctx.crop_box = Some(crop_box);
161    }
162
163    // /Rotate may be negative (e.g. -90), which is valid per the PDF spec.
164    // Normalise to [0, 360) via Euclidean remainder so u32 storage is safe.
165    if let Some(rotate) = pages_dict.get::<i32>(ROTATE) {
166        ctx.rotate = Some(rotate.rem_euclid(360) as u32);
167    }
168
169    // Nearest Resources wins: a dict on this node replaces the inherited
170    // one wholesale, it is not merged with it.
171    if let Some(resources) = pages_dict.get::<Dict<'a>>(RESOURCES) {
172        ctx.resources = resources;
173    }
174
175    let kids = pages_dict.get::<Array<'a>>(KIDS)?;
176
177    for dict in kids.iter::<Dict<'_>>() {
178        if entries.len() >= MAX_PAGE_COUNT {
179            log::warn!("Page count exceeds {MAX_PAGE_COUNT}, stopping page tree traversal");
180            return Some(());
181        }
182
183        match dict.get::<Name>(TYPE).as_deref() {
184            Some(PAGES) => {
185                resolve_pages_depth(
186                    &dict,
187                    entries,
188                    ctx.clone(),
189                    reader_ctx,
190                    depth + 1,
191                    max_depth,
192                    visited,
193                );
194            }
195            // Let's be lenient and assume it's a `Page` in case it's `None` or something else
196            // (see corpus test case 0083781).
197            _ => {
198                if let Some(page) = Page::new(&dict, &ctx, reader_ctx, false) {
199                    entries.push(page);
200                }
201            }
202        }
203    }
204
205    Some(())
206}
207
208/// The rotation of the page.
209#[derive(Debug, Copy, Clone)]
210pub enum Rotation {
211    /// No rotation.
212    None,
213    /// A rotation of 90 degrees.
214    Horizontal,
215    /// A rotation of 180 degrees.
216    Flipped,
217    /// A rotation of 270 degrees.
218    FlippedHorizontal,
219}
220
221/// A PDF page.
222pub struct Page<'a> {
223    inner: Dict<'a>,
224    media_box: Rect,
225    crop_box: Rect,
226    rotation: Rotation,
227    page_streams: OnceLock<Option<Vec<u8>>>,
228    resources: Resources<'a>,
229    ctx: ReaderContext<'a>,
230}
231
232impl<'a> Page<'a> {
233    fn new(
234        dict: &Dict<'a>,
235        ctx: &PagesContext<'a>,
236        reader_ctx: &ReaderContext<'a>,
237        brute_force: bool,
238    ) -> Option<Self> {
239        // In general, pages without content are allowed, but in case we are brute-forcing
240        // we ignore them.
241        if brute_force && !dict.contains_key(CONTENTS) {
242            return None;
243        }
244
245        let media_box = dict
246            .get::<Rect>(MEDIA_BOX)
247            .or(ctx.media_box)
248            .unwrap_or(US_LETTER);
249
250        let crop_box = dict
251            .get::<Rect>(CROP_BOX)
252            .or(ctx.crop_box)
253            .unwrap_or(media_box);
254
255        let rotation = match dict
256            .get::<i32>(ROTATE)
257            .map(|r| r.rem_euclid(360) as u32)
258            .or(ctx.rotate)
259            .unwrap_or(0)
260        {
261            0 => Rotation::None,
262            90 => Rotation::Horizontal,
263            180 => Rotation::Flipped,
264            270 => Rotation::FlippedHorizontal,
265            _ => Rotation::None,
266        };
267
268        let resources = Resources::new(
269            dict.get::<Dict<'a>>(RESOURCES)
270                .unwrap_or_else(|| ctx.resources.clone()),
271        );
272
273        Some(Self {
274            inner: dict.clone(),
275            media_box,
276            crop_box,
277            rotation,
278            page_streams: OnceLock::new(),
279            resources,
280            ctx: reader_ctx.clone(),
281        })
282    }
283
284    fn operations_impl(&self) -> Option<UntypedIter<'_>> {
285        let stream = self.page_stream()?;
286        let iter = UntypedIter::new(stream);
287
288        Some(iter)
289    }
290
291    /// Return the decoded content stream of the page.
292    pub fn page_stream(&self) -> Option<&[u8]> {
293        let convert_single = |s: Stream<'_>| {
294            let data = s.decoded().ok()?;
295            Some(data.to_vec())
296        };
297
298        self.page_streams
299            .get_or_init(|| {
300                if let Some(stream) = self.inner.get::<Stream<'_>>(CONTENTS) {
301                    convert_single(stream)
302                } else if let Some(array) = self.inner.get::<Array<'_>>(CONTENTS) {
303                    let streams = array.iter::<Stream<'_>>().flat_map(convert_single);
304
305                    let mut collected = vec![];
306
307                    for stream in streams {
308                        collected.extend(stream);
309                        // Streams must have at least one whitespace in-between.
310                        collected.push(b' ');
311                    }
312
313                    Some(collected)
314                } else {
315                    warn!("contents entry of page was neither stream nor array of streams");
316
317                    None
318                }
319            })
320            .as_ref()
321            .map(|d| d.as_slice())
322    }
323
324    /// Get the resources of the page.
325    pub fn resources(&self) -> &Resources<'a> {
326        &self.resources
327    }
328
329    /// Get the media box of the page.
330    pub fn media_box(&self) -> Rect {
331        self.media_box
332    }
333
334    /// Get the rotation of the page.
335    pub fn rotation(&self) -> Rotation {
336        self.rotation
337    }
338
339    /// Get the crop box of the page.
340    pub fn crop_box(&self) -> Rect {
341        self.crop_box
342    }
343
344    /// Return the intersection of crop box and media box.
345    pub fn intersected_crop_box(&self) -> Rect {
346        self.crop_box().intersect(self.media_box())
347    }
348
349    /// Return the base dimensions of the page used for the canvas size.
350    ///
351    /// When the CropBox origin is within the MediaBox (i.e. CropBox.x0 >=
352    /// MediaBox.x0 and CropBox.y0 >= MediaBox.y0), the canvas is sized to
353    /// intersect(CropBox, MediaBox).  This matches MuPDF's behaviour for
354    /// spec-violating PDFs where CropBox.y1 > MediaBox.y1 (e.g. gen-271:
355    /// CropBox=[0,0,595,793.7] vs MediaBox=[0,0,612,792] — using raw
356    /// CropBox gives 1654px height, a 4px vertical content offset, and
357    /// SSIM 0.49; intersecting gives 1650px matching MuPDF exactly).
358    ///
359    /// When CropBox extends below the MediaBox origin (gen-802 style:
360    /// CropBox=[0,0,684,864] vs MediaBox=[36,36,648,828]), MuPDF still uses
361    /// the full CropBox dimensions, so we do too. (#544, #558, gen-271)
362    pub fn base_dimensions(&self) -> (f32, f32) {
363        let crop_box = self.crop_box();
364        let media_box = self.media_box();
365
366        // Clip to MediaBox only when the CropBox origin lies within the
367        // MediaBox (both axes).  When the CropBox extends below the MediaBox
368        // origin MuPDF uses the raw CropBox, so preserve that behaviour.
369        let effective = if crop_box.x0 >= media_box.x0 && crop_box.y0 >= media_box.y0 {
370            crop_box.intersect(media_box)
371        } else {
372            crop_box
373        };
374
375        if (effective.width() as f32).is_nearly_zero()
376            || (effective.height() as f32).is_nearly_zero()
377        {
378            (US_LETTER.width() as f32, US_LETTER.height() as f32)
379        } else {
380            (
381                effective.width().max(1.0) as f32,
382                effective.height().max(1.0) as f32,
383            )
384        }
385    }
386
387    /// Return the with and height of the page that should be assumed when rendering the page.
388    ///
389    /// Depending on the document, it is either based on the media box or the crop box
390    /// of the page. In addition to that, it also takes the rotation of the page into account.
391    pub fn render_dimensions(&self) -> (f32, f32) {
392        let (mut base_width, mut base_height) = self.base_dimensions();
393
394        if matches!(
395            self.rotation(),
396            Rotation::Horizontal | Rotation::FlippedHorizontal
397        ) {
398            core::mem::swap(&mut base_width, &mut base_height);
399        }
400
401        (base_width, base_height)
402    }
403
404    /// Return an untyped iterator over the operators of the page's content stream.
405    pub fn operations(&self) -> UntypedIter<'_> {
406        self.operations_impl().unwrap_or(UntypedIter::empty())
407    }
408
409    /// Get the raw dictionary of the page.
410    pub fn raw(&self) -> &Dict<'a> {
411        &self.inner
412    }
413
414    /// Get the xref table (of the document the page belongs to).
415    pub fn xref(&self) -> &'a XRef {
416        self.ctx.xref()
417    }
418
419    /// Return a typed iterator over the operators of the page's content stream.
420    pub fn typed_operations(&self) -> TypedIter<'_> {
421        TypedIter::from_untyped(self.operations())
422    }
423
424    /// Return the annotation dictionaries for this page, if any.
425    pub fn annots(&self) -> Vec<Dict<'a>> {
426        self.inner
427            .get::<Array<'_>>(crate::object::dict::keys::ANNOTS)
428            .map(|arr| arr.iter::<Dict<'_>>().collect())
429            .unwrap_or_default()
430    }
431}
432
433/// A structure keeping track of the resources of a page.
434#[derive(Clone, Debug)]
435pub struct Resources<'a> {
436    /// The raw dictionary of external graphics states.
437    pub ext_g_states: Dict<'a>,
438    /// The raw dictionary of fonts.
439    pub fonts: Dict<'a>,
440    /// The raw dictionary of properties.
441    pub properties: Dict<'a>,
442    /// The raw dictionary of color spaces.
443    pub color_spaces: Dict<'a>,
444    /// The raw dictionary of x objects.
445    pub x_objects: Dict<'a>,
446    /// The raw dictionary of patterns.
447    pub patterns: Dict<'a>,
448    /// The raw dictionary of shadings.
449    pub shadings: Dict<'a>,
450}
451
452impl<'a> Resources<'a> {
453    /// Create a new `Resources` object.
454    ///
455    /// A single dictionary: the nearest `/Resources` wins and is never
456    /// merged with an ancestor's (LaurenzV/hayro#1387).
457    pub fn new(resources: Dict<'a>) -> Self {
458        // A Resources dict is always an interpret boundary (page content,
459        // form, pattern cell, mask group, glyph), so resolution ancestry
460        // restarts here: without the rebase, cross-construct nesting that
461        // legitimately re-resolves an ancestor's object (pattern -> mask ->
462        // group -> pattern) trips the resolution-cycle guard on the second
463        // visit, and the #318 alternating cycles silently stop nesting
464        // instead of reaching the depth bound.
465        let resources = resources.without_parent_chain();
466        let ext_g_states = resources.get::<Dict<'_>>(EXT_G_STATE).unwrap_or_default();
467        let fonts = resources.get::<Dict<'_>>(FONT).unwrap_or_default();
468        let color_spaces = resources.get::<Dict<'_>>(COLORSPACE).unwrap_or_default();
469        let x_objects = resources.get::<Dict<'_>>(XOBJECT).unwrap_or_default();
470        let patterns = resources.get::<Dict<'_>>(PATTERN).unwrap_or_default();
471        let shadings = resources.get::<Dict<'_>>(SHADING).unwrap_or_default();
472        let properties = resources.get::<Dict<'_>>(PROPERTIES).unwrap_or_default();
473
474        Self {
475            ext_g_states,
476            fonts,
477            color_spaces,
478            properties,
479            x_objects,
480            patterns,
481            shadings,
482        }
483    }
484
485    /// Get an external graphics state by name.
486    pub fn get_ext_g_state(&self, name: Name) -> Option<Dict<'a>> {
487        self.ext_g_states.get(name.deref())
488    }
489
490    /// Get a color space by name.
491    pub fn get_color_space(&self, name: Name) -> Option<Object<'a>> {
492        self.color_spaces.get(name.deref())
493    }
494
495    /// Get a font by name.
496    pub fn get_font(&self, name: Name) -> Option<Dict<'a>> {
497        self.fonts.get(name.deref())
498    }
499
500    /// Get a pattern by name.
501    pub fn get_pattern(&self, name: Name) -> Option<Object<'a>> {
502        self.patterns.get(name.deref())
503    }
504
505    /// Get an x object by name.
506    pub fn get_x_object(&self, name: Name) -> Option<Stream<'a>> {
507        self.x_objects.get(name.deref())
508    }
509
510    /// Get a shading by name.
511    pub fn get_shading(&self, name: Name) -> Option<Object<'a>> {
512        self.shadings.get(name.deref())
513    }
514}
515
516// <https://github.com/apache/pdfbox/blob/a53a70db16ea3133994120bcf1e216b9e760c05b/pdfbox/src/main/java/org/apache/pdfbox/pdmodel/common/PDRectangle.java#L38>
517const POINTS_PER_INCH: f64 = 72.0;
518const POINTS_PER_MM: f64 = 1.0 / (10.0 * 2.54) * POINTS_PER_INCH;
519
520/// The dimension of an A4 page (kept for completeness).
521pub const A4: Rect = Rect {
522    x0: 0.0,
523    y0: 0.0,
524    x1: 210.0 * POINTS_PER_MM,
525    y1: 297.0 * POINTS_PER_MM,
526};
527
528/// US Letter (8.5×11 in) — used as fallback when no MediaBox is present.
529///
530/// Old PDF 1.0/1.1 documents (especially US-government publications from the
531/// 1990s) omit MediaBox entirely and assume a US-Letter canvas, which matches
532/// the PostScript default and MuPDF's behaviour.  Using A4 here causes a
533/// ~5 % scale mismatch that tanks SSIM against MuPDF's oracle renders.
534/// See render_mupdf_oracle failures gen-059, gen-069, …  (#544)
535const US_LETTER: Rect = Rect {
536    x0: 0.0,
537    y0: 0.0,
538    x1: 8.5 * POINTS_PER_INCH,
539    y1: 11.0 * POINTS_PER_INCH,
540};
541
542pub(crate) mod cached {
543    use crate::page::Pages;
544    use crate::reader::ReaderContext;
545    use crate::xref::XRef;
546    use core::ops::Deref;
547
548    // Keep in sync with the implementation in `sync`. We duplicate it here
549    // to make it more visible since we have unsafe code here.
550    #[cfg(feature = "std")]
551    pub(crate) use std::sync::Arc;
552
553    #[cfg(not(feature = "std"))]
554    pub(crate) use alloc::rc::Rc as Arc;
555
556    pub(crate) struct CachedPages {
557        pages: Pages<'static>,
558        // NOTE: `pages` references the data in `xref`, so it's important that `xref`
559        // appears after `pages` in the struct definition to ensure correct drop order.
560        _xref: Arc<XRef>,
561        /// `true` if the normal page-tree walk failed and pages were recovered
562        /// via brute-force scan (page order may differ from the source).
563        page_tree_rebuilt: bool,
564    }
565
566    impl CachedPages {
567        pub(crate) fn new(xref: Arc<XRef>) -> Option<Self> {
568            // SAFETY:
569            // - The XRef's location is stable in memory:
570            //   - We wrapped it in a `Arc` (or `Rc` in `no_std`), which implements `StableDeref`.
571            //   - The struct owns the `Arc`, ensuring that the inner value is not dropped during the whole
572            //     duration.
573            // - The internal 'static lifetime is not leaked because its rewritten
574            //   to the self-lifetime in `pages()`.
575            let xref_reference: &'static XRef = unsafe { core::mem::transmute(xref.deref()) };
576
577            let ctx = ReaderContext::new(xref_reference, false);
578            // Detect whether the normal page-tree walk succeeded: if it returns
579            // `None` and we fall back to a brute-force scan, the page tree was
580            // rebuilt (recovery is still always attempted).
581            let normal = xref_reference
582                .get_with(xref.trailer_data().pages_ref, &ctx)
583                .and_then(|p| Pages::new(&p, &ctx, xref_reference));
584            let page_tree_rebuilt = normal.is_none();
585            let pages = normal.or_else(|| Pages::new_brute_force(&ctx, xref_reference))?;
586
587            Some(Self {
588                pages,
589                _xref: xref,
590                page_tree_rebuilt,
591            })
592        }
593
594        pub(crate) fn get(&self) -> &Pages<'_> {
595            &self.pages
596        }
597
598        /// Whether the page tree was recovered via brute-force scan.
599        pub(crate) fn page_tree_rebuilt(&self) -> bool {
600            self.page_tree_rebuilt
601        }
602    }
603}
604
605#[cfg(test)]
606mod cycle_tests {
607    use crate::pdf::Pdf;
608    use alloc::format;
609    use alloc::vec::Vec;
610
611    /// Build a minimal PDF whose page tree contains a self-cycle: the single
612    /// `/Pages` node lists a real `/Page` followed by a back-reference to
613    /// itself. Without cycle detection the walker re-enters the node up to the
614    /// depth cap, duplicating the page hundreds of times.
615    fn cyclic_pages_pdf() -> Vec<u8> {
616        let mut buf: Vec<u8> = Vec::new();
617        let mut offsets = [0usize; 4];
618        buf.extend_from_slice(b"%PDF-1.7\n");
619        offsets[1] = buf.len();
620        buf.extend_from_slice(b"1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n");
621        offsets[2] = buf.len();
622        buf.extend_from_slice(
623            b"2 0 obj\n<< /Type /Pages /Kids [3 0 R 2 0 R] /Count 1 >>\nendobj\n",
624        );
625        offsets[3] = buf.len();
626        buf.extend_from_slice(
627            b"3 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] >>\nendobj\n",
628        );
629        let xref_off = buf.len();
630        buf.extend_from_slice(b"xref\n0 4\n0000000000 65535 f \n");
631        for off in &offsets[1..4] {
632            buf.extend_from_slice(format!("{off:010} 00000 n \n").as_bytes());
633        }
634        buf.extend_from_slice(
635            format!("trailer\n<< /Size 4 /Root 1 0 R >>\nstartxref\n{xref_off}\n%%EOF").as_bytes(),
636        );
637        buf
638    }
639
640    #[test]
641    fn cyclic_page_tree_yields_one_page_without_runaway() {
642        let pdf = Pdf::new(cyclic_pages_pdf()).expect("cyclic PDF should still load");
643        // Exactly the one real page; the /Kids self-reference is detected and
644        // skipped. Without cycle detection this would be hundreds of duplicates.
645        assert_eq!(
646            pdf.pages().len(),
647            1,
648            "page-tree cycle must not duplicate pages"
649        );
650    }
651}