Skip to main content

stet_pdf_reader/
page_tree.rs

1// stet-pdf-reader
2// Copyright (c) 2026 Scott Bowman
3// SPDX-License-Identifier: Apache-2.0 OR MIT
4
5//! PDF page tree traversal with attribute inheritance.
6
7use crate::error::PdfError;
8use crate::objects::{PdfDict, PdfObj};
9use crate::resolver::Resolver;
10use std::collections::HashSet;
11
12/// Maximum depth for /Pages tree recursion. Protects against pathologically
13/// deep trees when cycle detection hasn't triggered (e.g., an intermediate
14/// node reached via an inline dict rather than an indirect reference).
15const MAX_PAGE_TREE_DEPTH: u32 = 256;
16
17/// Resolved page information (after inheritance).
18#[derive(Debug, Clone)]
19pub struct PageInfo {
20    /// Page object number.
21    pub obj_num: u32,
22    /// MediaBox [llx, lly, urx, ury] in points.
23    pub media_box: [f64; 4],
24    /// CropBox (defaults to MediaBox if absent).
25    pub crop_box: [f64; 4],
26    /// Rotation in degrees (0, 90, 180, 270).
27    pub rotate: i32,
28    /// Resources dictionary (may be inherited).
29    pub resources: PdfDict,
30    /// Content stream references.
31    pub contents: Vec<(u32, u16)>,
32    /// Annotation references (obj_num, gen_num).
33    pub annots: Vec<(u32, u16)>,
34}
35
36/// Inherited attributes propagated down the page tree.
37#[derive(Clone, Default)]
38struct Inherited {
39    media_box: Option<[f64; 4]>,
40    crop_box: Option<[f64; 4]>,
41    rotate: Option<i32>,
42    resources: Option<PdfDict>,
43}
44
45/// Traverse the page tree and collect all leaf pages in order.
46pub fn collect_pages(resolver: &Resolver) -> Result<Vec<PageInfo>, PdfError> {
47    // Get /Root -> Catalog (may be an indirect reference or an inline dict)
48    let catalog_owned;
49    let catalog_dict = if let Some(root_ref) = resolver.trailer().get_ref(b"Root") {
50        let catalog = match resolver.resolve(root_ref.0, root_ref.1) {
51            Ok(c) => c,
52            Err(_) => return collect_pages_by_scan(resolver),
53        };
54        catalog_owned = catalog;
55        match catalog_owned.as_dict() {
56            Some(d) => d,
57            None => return collect_pages_by_scan(resolver),
58        }
59    } else if let Some(root_obj) = resolver.trailer().get(b"Root") {
60        match root_obj.as_dict() {
61            Some(d) => d,
62            None => return collect_pages_by_scan(resolver),
63        }
64    } else {
65        return collect_pages_by_scan(resolver);
66    };
67
68    // Get /Pages — if the page tree root is missing (truncated PDF or
69    // corrupt incremental update that swapped /Root and /Info),
70    // try to find the real Catalog by scanning objects before falling
71    // back to raw page scanning (which can pick up stale pages from
72    // earlier revisions).
73    let pages_ref = match catalog_dict.get(b"Pages") {
74        Some(r) => r,
75        None => return collect_pages_via_catalog_scan(resolver),
76    };
77    let pages_obj = match resolver.deref(pages_ref) {
78        Ok(obj) => obj,
79        Err(_) => return collect_pages_by_scan(resolver),
80    };
81    let pages_dict = match pages_obj.as_dict() {
82        Some(d) => d,
83        None => return collect_pages_by_scan(resolver),
84    };
85
86    let mut pages = Vec::new();
87    let inherited = Inherited::default();
88    let mut visited: HashSet<u32> = HashSet::new();
89    collect_pages_recursive(
90        resolver,
91        pages_dict,
92        0,
93        &inherited,
94        &mut pages,
95        &mut visited,
96        0,
97    )?;
98
99    Ok(pages)
100}
101
102/// Try to find the real Catalog's /Pages tree by scanning for a /Type /Catalog
103/// object. Falls back to raw page scanning only if no valid Catalog is found.
104fn collect_pages_via_catalog_scan(resolver: &Resolver) -> Result<Vec<PageInfo>, PdfError> {
105    let xref_len = resolver.xref_len();
106    for obj_num in 0..xref_len as u32 {
107        if let Ok(obj) = resolver.resolve(obj_num, 0) {
108            if let Some(dict) = obj.as_dict() {
109                if dict.get_name(b"Type") == Some(b"Catalog") {
110                    if let Some(pages_ref) = dict.get(b"Pages") {
111                        if let Ok(pages_obj) = resolver.deref(pages_ref) {
112                            if let Some(pages_dict) = pages_obj.as_dict() {
113                                let mut pages = Vec::new();
114                                let inherited = Inherited::default();
115                                let mut visited: HashSet<u32> = HashSet::new();
116                                if collect_pages_recursive(
117                                    resolver,
118                                    pages_dict,
119                                    0,
120                                    &inherited,
121                                    &mut pages,
122                                    &mut visited,
123                                    0,
124                                )
125                                .is_ok()
126                                    && !pages.is_empty()
127                                {
128                                    return Ok(pages);
129                                }
130                            }
131                        }
132                    }
133                }
134            }
135        }
136    }
137    collect_pages_by_scan(resolver)
138}
139
140/// Fallback: scan all xref entries for `/Type /Page` objects.
141/// Used when the page tree root is missing (e.g., truncated PDF).
142fn collect_pages_by_scan(resolver: &Resolver) -> Result<Vec<PageInfo>, PdfError> {
143    let mut pages = Vec::new();
144    let xref_len = resolver.xref_len();
145
146    for obj_num in 0..xref_len as u32 {
147        if let Ok(obj) = resolver.resolve(obj_num, 0) {
148            if let Some(dict) = obj.as_dict() {
149                if dict.get_name(b"Type") == Some(b"Page") && dict.get(b"Kids").is_none() {
150                    let media_box =
151                        parse_rect(dict, b"MediaBox", resolver).unwrap_or([0.0, 0.0, 612.0, 792.0]);
152                    let crop_box = clamp_box_to_media(
153                        &parse_rect(dict, b"CropBox", resolver).unwrap_or(media_box),
154                        &media_box,
155                    );
156                    let rotate = dict.get_int(b"Rotate").unwrap_or(0) as i32;
157                    let resources = resolve_resources_inherited(dict, resolver);
158                    let contents = parse_contents(dict, resolver)?;
159                    let annots = parse_annots(dict, resolver);
160
161                    pages.push(PageInfo {
162                        obj_num,
163                        media_box,
164                        crop_box,
165                        rotate,
166                        resources,
167                        contents,
168                        annots,
169                    });
170                }
171            }
172        }
173    }
174
175    if pages.is_empty() {
176        return Err(PdfError::MissingKey("Pages"));
177    }
178
179    Ok(pages)
180}
181
182/// Resolve /Resources, walking up the /Parent chain if not found on the page.
183fn resolve_resources_inherited(dict: &PdfDict, resolver: &Resolver) -> PdfDict {
184    let res = resolve_resources(dict, resolver);
185    if !res.is_empty() {
186        return res;
187    }
188    // Walk up /Parent chain to find inherited /Resources
189    let mut current = dict.clone();
190    for _ in 0..10 {
191        if let Some(&PdfObj::Ref(n, g)) = current.get(b"Parent") {
192            if let Ok(parent_obj) = resolver.resolve(n, g) {
193                if let Some(parent_dict) = parent_obj.as_dict() {
194                    let res = resolve_resources(parent_dict, resolver);
195                    if !res.is_empty() {
196                        return res;
197                    }
198                    current = parent_dict.clone();
199                    continue;
200                }
201            }
202        }
203        break;
204    }
205    PdfDict::default()
206}
207
208/// Resolve /Resources from a dict (direct or indirect ref).
209fn resolve_resources(dict: &PdfDict, resolver: &Resolver) -> PdfDict {
210    if let Some(res) = dict.get_dict(b"Resources") {
211        return res.clone();
212    }
213    if let Some(PdfObj::Ref(n, g)) = dict.get(b"Resources") {
214        if let Ok(resolved) = resolver.resolve(*n, *g) {
215            if let Some(d) = resolved.as_dict() {
216                return d.clone();
217            }
218        }
219    }
220    PdfDict::default()
221}
222
223fn collect_pages_recursive(
224    resolver: &Resolver,
225    node_dict: &PdfDict,
226    obj_num: u32,
227    parent_inherited: &Inherited,
228    pages: &mut Vec<PageInfo>,
229    visited: &mut HashSet<u32>,
230    depth: u32,
231) -> Result<(), PdfError> {
232    // Guard against /Pages trees that form a cycle (malformed PDFs) or are
233    // pathologically deep. Without this, a kid that points back to an ancestor
234    // would recurse forever and overflow the stack.
235    if depth >= MAX_PAGE_TREE_DEPTH {
236        return Ok(());
237    }
238    if obj_num != 0 && !visited.insert(obj_num) {
239        return Ok(());
240    }
241    // Update inherited attributes from this node
242    let mut inherited = parent_inherited.clone();
243    if let Some(mb) = parse_rect(node_dict, b"MediaBox", resolver) {
244        inherited.media_box = Some(mb);
245    }
246    if let Some(cb) = parse_rect(node_dict, b"CropBox", resolver) {
247        inherited.crop_box = Some(cb);
248    }
249    if let Some(r) = node_dict.get_int(b"Rotate") {
250        inherited.rotate = Some(r as i32);
251    }
252    if let Some(res) = node_dict.get_dict(b"Resources") {
253        inherited.resources = Some(res.clone());
254    } else if let Some(PdfObj::Ref(n, g)) = node_dict.get(b"Resources") {
255        // /Resources may be an indirect reference — dereference it
256        if let Ok(resolved) = resolver.resolve(*n, *g)
257            && let Some(d) = resolved.as_dict()
258        {
259            inherited.resources = Some(d.clone());
260        }
261    }
262
263    // Determine node type.
264    // Use /Kids presence as the definitive indicator of an intermediate node —
265    // some malformed PDFs have duplicate /Type keys (both /Pages and /Page)
266    // in the same dict, where "last wins" parsing produces /Type /Page even
267    // though the node is clearly an intermediate Pages node with /Kids.
268    let has_kids = node_dict.get(b"Kids").is_some();
269    let type_name = node_dict.get_name(b"Type");
270
271    if !has_kids && (matches!(type_name, Some(b"Page")) || (type_name.is_none() && !has_kids)) {
272        // Leaf page node
273        let media_box = inherited.media_box.unwrap_or([0.0, 0.0, 612.0, 792.0]); // Default US Letter
274        // CropBox defaults to MediaBox; clamp to MediaBox if it extends beyond
275        // (per PDF spec: "should be equal to or smaller than the media box").
276        let crop_box = clamp_box_to_media(&inherited.crop_box.unwrap_or(media_box), &media_box);
277        let rotate = inherited.rotate.unwrap_or(0);
278        let resources = inherited.resources.clone().unwrap_or_default();
279
280        // Parse /Contents (may be a ref to an array, not just a direct array)
281        let contents = parse_contents(node_dict, resolver)?;
282
283        // Parse /Annots (annotation references)
284        let annots = parse_annots(node_dict, resolver);
285
286        pages.push(PageInfo {
287            obj_num,
288            media_box,
289            crop_box,
290            rotate,
291            resources,
292            contents,
293            annots,
294        });
295    } else {
296        // Intermediate /Pages node — recurse into /Kids.
297        // /Kids may be a direct array or an indirect reference to one.
298        let kids_owned;
299        let kids: &[PdfObj] = if let Some(arr) = node_dict.get_array(b"Kids") {
300            arr
301        } else if let Some(PdfObj::Ref(n, g)) = node_dict.get(b"Kids") {
302            kids_owned = match resolver.resolve(*n, *g) {
303                Ok(PdfObj::Array(arr)) => arr,
304                _ => Vec::new(),
305            };
306            &kids_owned
307        } else {
308            &[]
309        };
310        for kid in kids {
311            match kid {
312                PdfObj::Ref(n, g) => {
313                    let child = resolver.resolve(*n, *g)?;
314                    if let Some(child_dict) = child.as_dict() {
315                        collect_pages_recursive(
316                            resolver,
317                            child_dict,
318                            *n,
319                            &inherited,
320                            pages,
321                            visited,
322                            depth + 1,
323                        )?;
324                    }
325                }
326                _ => {
327                    // Inline dict (unusual but possible)
328                    if let Some(child_dict) = kid.as_dict() {
329                        collect_pages_recursive(
330                            resolver,
331                            child_dict,
332                            0,
333                            &inherited,
334                            pages,
335                            visited,
336                            depth + 1,
337                        )?;
338                    }
339                }
340            }
341        }
342    }
343
344    Ok(())
345}
346
347/// Clamp a box to fit within the media box (intersection).
348fn clamp_box_to_media(crop: &[f64; 4], media: &[f64; 4]) -> [f64; 4] {
349    // Normalize both boxes so [0]<[2] and [1]<[3]
350    let (c_llx, c_urx) = (crop[0].min(crop[2]), crop[0].max(crop[2]));
351    let (c_lly, c_ury) = (crop[1].min(crop[3]), crop[1].max(crop[3]));
352    let (m_llx, m_urx) = (media[0].min(media[2]), media[0].max(media[2]));
353    let (m_lly, m_ury) = (media[1].min(media[3]), media[1].max(media[3]));
354    [
355        c_llx.max(m_llx),
356        c_lly.max(m_lly),
357        c_urx.min(m_urx),
358        c_ury.min(m_ury),
359    ]
360}
361
362/// Convert an array of PdfObj values to a rectangle [llx, lly, urx, ury].
363fn arr_to_rect(arr: &[PdfObj], resolver: &Resolver) -> Option<[f64; 4]> {
364    if arr.len() >= 4 {
365        // Array elements may be indirect references (e.g. `4 0 R`)
366        let resolve = |obj: &PdfObj| -> Option<f64> {
367            obj.as_f64()
368                .or_else(|| resolver.deref(obj).ok().and_then(|r| r.as_f64()))
369        };
370        Some([
371            resolve(&arr[0])?,
372            resolve(&arr[1])?,
373            resolve(&arr[2])?,
374            resolve(&arr[3])?,
375        ])
376    } else {
377        None
378    }
379}
380
381/// Parse a rectangle array [llx, lly, urx, ury] from a dict key.
382/// Handles both direct arrays and indirect references to arrays.
383fn parse_rect(dict: &PdfDict, key: &[u8], resolver: &Resolver) -> Option<[f64; 4]> {
384    match dict.get(key)? {
385        PdfObj::Array(a) => arr_to_rect(a, resolver),
386        PdfObj::Ref(n, g) => match resolver.resolve(*n, *g).ok()? {
387            PdfObj::Array(a) => arr_to_rect(&a, resolver),
388            _ => None,
389        },
390        _ => None,
391    }
392}
393
394/// Parse /Contents as a list of indirect references.
395/// /Contents can be a single stream ref, a direct array of refs,
396/// or an indirect ref to an array of refs (e.g., in linearized PDFs
397/// where the array is stored in an object stream).
398fn parse_contents(dict: &PdfDict, resolver: &Resolver) -> Result<Vec<(u32, u16)>, PdfError> {
399    match dict.get(b"Contents") {
400        None => Ok(Vec::new()), // Blank page
401        Some(PdfObj::Ref(n, g)) => {
402            // Could be a ref to a stream OR a ref to an array of refs.
403            // Try resolving to check.
404            if let Ok(resolved) = resolver.resolve(*n, *g)
405                && let PdfObj::Array(arr) = &resolved
406            {
407                return collect_refs_from_array(arr);
408            }
409            // Single content stream reference
410            Ok(vec![(*n, *g)])
411        }
412        Some(PdfObj::Array(arr)) => collect_refs_from_array(arr),
413        _ => Ok(Vec::new()),
414    }
415}
416
417/// Parse /Annots as a list of indirect references.
418fn parse_annots(dict: &PdfDict, resolver: &Resolver) -> Vec<(u32, u16)> {
419    let annot_arr = match dict.get(b"Annots") {
420        Some(PdfObj::Array(arr)) => arr.clone(),
421        Some(PdfObj::Ref(n, g)) => {
422            // Indirect ref to array
423            if let Ok(PdfObj::Array(arr)) = resolver.resolve(*n, *g) {
424                arr
425            } else {
426                return Vec::new();
427            }
428        }
429        _ => return Vec::new(),
430    };
431
432    let mut refs = Vec::new();
433    for obj in &annot_arr {
434        if let PdfObj::Ref(n, g) = obj {
435            refs.push((*n, *g));
436        }
437    }
438    refs
439}
440
441/// Extract (obj_num, gen_num) pairs from an array of Ref objects.
442fn collect_refs_from_array(arr: &[PdfObj]) -> Result<Vec<(u32, u16)>, PdfError> {
443    let mut refs = Vec::new();
444    for obj in arr {
445        if let PdfObj::Ref(n, g) = obj {
446            refs.push((*n, *g));
447        }
448    }
449    Ok(refs)
450}
451
452#[cfg(test)]
453mod tests {
454    use super::*;
455    use crate::xref::XrefTable;
456
457    /// Create a dummy resolver for tests that only use direct values.
458    fn dummy_resolver() -> Resolver<'static> {
459        static EMPTY: &[u8] = b"";
460        Resolver::new(EMPTY, &XrefTable::empty())
461    }
462
463    #[test]
464    fn arr_to_rect_valid() {
465        let r = dummy_resolver();
466        let arr = vec![
467            PdfObj::Int(0),
468            PdfObj::Int(0),
469            PdfObj::Real(612.0),
470            PdfObj::Real(792.0),
471        ];
472        let rect = arr_to_rect(&arr, &r).unwrap();
473        assert_eq!(rect, [0.0, 0.0, 612.0, 792.0]);
474    }
475
476    #[test]
477    fn arr_to_rect_too_short() {
478        let r = dummy_resolver();
479        let arr = vec![PdfObj::Int(0), PdfObj::Int(0)];
480        assert!(arr_to_rect(&arr, &r).is_none());
481    }
482
483    #[test]
484    fn collect_refs_from_array_basic() {
485        let arr = vec![PdfObj::Ref(5, 0), PdfObj::Ref(6, 0)];
486        let refs = collect_refs_from_array(&arr).unwrap();
487        assert_eq!(refs, vec![(5, 0), (6, 0)]);
488    }
489
490    #[test]
491    fn collect_refs_from_array_empty() {
492        let refs = collect_refs_from_array(&[]).unwrap();
493        assert!(refs.is_empty());
494    }
495}