Skip to main content

zpdf_document/
catalog.rs

1use std::borrow::Cow;
2use std::collections::{HashMap, HashSet};
3
4use tracing::warn;
5use zpdf_core::{Error, ObjectId, PdfObject, Result};
6use zpdf_parser::PdfFile;
7
8use crate::page::{PdfPage, MAX_PAGE_COUNT, MAX_PAGE_TREE_DEPTH};
9
10pub struct Catalog {
11    pub pages_ref: ObjectId,
12    pub page_count: usize,
13    page_refs: Vec<ObjectId>,
14    /// Reverse index page-object id → 0-based page index, for resolving a
15    /// destination's target page reference to a page number. Built once at open.
16    page_index: HashMap<ObjectId, usize>,
17}
18
19impl Catalog {
20    pub fn from_trailer(file: &PdfFile) -> Result<Self> {
21        // /Count is advisory only; the guarded kid walk determines the real
22        // page list (broken kids are skipped, cycles and over-deep chains pruned).
23        let pages_ref = Self::resolve_pages_ref(file);
24        let mut page_refs = Vec::new();
25        let mut visited = HashSet::new();
26        if let Some(pages_ref) = pages_ref {
27            Self::collect_page_refs(file, pages_ref, &mut page_refs, &mut visited, 0)?;
28        }
29
30        // Fallback: the /Root or /Pages tree was missing, null, or yielded no
31        // leaves — but the page objects often physically exist in the file (a
32        // broken xref, a catalog stranded in an /ObjStm, a /Root aimed at the
33        // wrong object, or a tree pruned by the cycle/depth guards). Mainstream
34        // readers degrade to a whole-document scan for /Type /Page; do the same.
35        if page_refs.is_empty() {
36            warn!("page tree unreachable via /Pages; scanning all objects for /Type /Page");
37            page_refs = file.find_objects_by_type("Page");
38        }
39
40        // Last resort: fuzzed files often byte-flip or drop the page's /Type
41        // (e.g. it parses as a Stream, or the type name is corrupted). Accept
42        // any "page-shaped" dict — carries /MediaBox or /Contents, is not a
43        // page-tree node (/Kids) or catalog (/Pages). Only reached when the
44        // document is already otherwise unopenable, so the loose heuristic
45        // cannot regress healthy files.
46        if page_refs.is_empty() {
47            warn!("no /Type /Page objects; scanning for page-shaped dicts");
48            page_refs = Self::scan_page_like(file);
49        }
50
51        if page_refs.is_empty() {
52            return Err(Error::InvalidObject(
53                0,
54                "page tree contains no usable pages".into(),
55            ));
56        }
57
58        // Reverse index for destination resolution. First occurrence wins, so a
59        // page object reused in two slots (malformed) maps to its earliest index.
60        let mut page_index = HashMap::with_capacity(page_refs.len());
61        for (i, &id) in page_refs.iter().enumerate() {
62            page_index.entry(id).or_insert(i);
63        }
64
65        Ok(Self {
66            pages_ref: pages_ref.unwrap_or(ObjectId(0, 0)),
67            page_count: page_refs.len(),
68            page_refs,
69            page_index,
70        })
71    }
72
73    /// The 0-based page index of a page object, or `None` when the reference is
74    /// not a page in this document's page tree. Used to turn a destination's
75    /// target page reference into a page number.
76    pub fn page_index_of(&self, id: ObjectId) -> Option<usize> {
77        self.page_index.get(&id).copied()
78    }
79
80    /// Whole-document scan for "page-shaped" dicts: a leaf carries `/MediaBox`
81    /// or `/Contents`, is not an interior page-tree node (`/Kids`) and not the
82    /// catalog (`/Pages`). Used only when `/Type /Page` matching already came up
83    /// empty, to recover pages whose `/Type` was corrupted or dropped.
84    fn scan_page_like(file: &PdfFile) -> Vec<ObjectId> {
85        file.all_object_ids()
86            .into_iter()
87            .filter(|&id| {
88                let Ok(obj) = file.resolve(id) else {
89                    return false;
90                };
91                let Ok(dict) = obj.as_dict() else {
92                    return false;
93                };
94                dict.get("Kids").is_none()
95                    && dict.get("Pages").is_none()
96                    && (dict.get("MediaBox").is_some() || dict.get("Contents").is_some())
97            })
98            .collect()
99    }
100
101    /// Resolve `/Root` → `/Pages`, tolerating an absent/null/non-dict Root or a
102    /// missing /Pages by returning `None` (the caller then falls back to a
103    /// whole-document page scan instead of failing the open).
104    fn resolve_pages_ref(file: &PdfFile) -> Option<ObjectId> {
105        let root_ref = file.trailer.get_ref("Root").ok()?;
106        let root = file.resolve(root_ref).ok()?;
107        root.as_dict().ok()?.get_ref("Pages").ok()
108    }
109
110    fn collect_page_refs(
111        file: &PdfFile,
112        node_id: ObjectId,
113        refs: &mut Vec<ObjectId>,
114        visited: &mut HashSet<ObjectId>,
115        depth: usize,
116    ) -> Result<()> {
117        if depth > MAX_PAGE_TREE_DEPTH {
118            warn!("page tree deeper than {MAX_PAGE_TREE_DEPTH} at {node_id}; pruning subtree");
119            return Ok(());
120        }
121        if !visited.insert(node_id) {
122            warn!("page tree cycle: node {node_id} already visited; pruning");
123            return Ok(());
124        }
125
126        let node = match file.resolve(node_id) {
127            Ok(PdfObject::Null) => {
128                warn!("page tree node {node_id} resolves to null; skipping");
129                return Ok(());
130            }
131            Ok(obj) => obj,
132            Err(e) => {
133                warn!("failed to resolve page tree node {node_id}: {e}; skipping");
134                return Ok(());
135            }
136        };
137        let Ok(dict) = node.as_dict() else {
138            warn!(
139                "page tree node {node_id} is {}, expected Dict; skipping",
140                node.type_name()
141            );
142            return Ok(());
143        };
144
145        // /Type is formally required but missing or wrong in real-world files;
146        // fall back on the presence of /Kids to tell interior nodes from leaves.
147        let is_pages = match dict.get_name("Type") {
148            Ok("Pages") => true,
149            Ok("Page") => false,
150            _ => dict.get("Kids").is_some(),
151        };
152
153        if is_pages {
154            // /Kids may itself be an indirect ref to the array.
155            let kids: Cow<'_, [PdfObject]> = match dict.get("Kids") {
156                Some(PdfObject::Array(a)) => Cow::Borrowed(a.as_slice()),
157                Some(PdfObject::Ref(r)) => match file.resolve(*r) {
158                    Ok(PdfObject::Array(a)) => Cow::Owned(a),
159                    _ => {
160                        warn!("pages node {node_id}: /Kids ref {r} is not an array; skipping");
161                        return Ok(());
162                    }
163                },
164                _ => {
165                    warn!("pages node {node_id} has no /Kids array; skipping");
166                    return Ok(());
167                }
168            };
169            for kid in kids.iter() {
170                match kid {
171                    PdfObject::Ref(r) => {
172                        Self::collect_page_refs(file, *r, refs, visited, depth + 1)?;
173                    }
174                    PdfObject::Null => {
175                        warn!("pages node {node_id}: null kid; skipping");
176                    }
177                    other => {
178                        warn!(
179                            "pages node {node_id}: kid is {}, expected Ref; skipping",
180                            other.type_name()
181                        );
182                    }
183                }
184            }
185        } else {
186            // L8 Fix: Check page count limit before adding new page
187            if refs.len() >= MAX_PAGE_COUNT {
188                warn!("page tree exceeds {MAX_PAGE_COUNT} pages; stopping collection");
189                return Ok(());
190            }
191            refs.push(node_id);
192        }
193        Ok(())
194    }
195
196    pub fn get_page(&self, file: &PdfFile, index: usize) -> Result<PdfPage> {
197        let page_ref =
198            self.page_refs.get(index).copied().ok_or_else(|| {
199                Error::InvalidObject(0, format!("page index {index} out of range"))
200            })?;
201
202        PdfPage::from_object(file, page_ref)
203    }
204}
205
206#[cfg(test)]
207mod tests {
208    use crate::page::MAX_PAGE_TREE_DEPTH;
209    use crate::test_util::build_pdf;
210    use crate::PdfDocument;
211
212    #[test]
213    fn kids_cycle_is_pruned() {
214        // The pages node lists itself as a kid; the walk must terminate.
215        let doc = PdfDocument::open(build_pdf(&[
216            "<< /Type /Catalog /Pages 2 0 R >>",
217            "<< /Type /Pages /Kids [3 0 R 2 0 R] /Count 1 >>",
218            "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 100 100] >>",
219        ]))
220        .expect("open");
221        assert_eq!(doc.page_count(), 1);
222    }
223
224    #[test]
225    fn dangling_and_null_kids_are_skipped() {
226        // 99 0 R is dangling (skipped whether resolve errors or returns Null);
227        // the literal null kid is skipped outright.
228        let doc = PdfDocument::open(build_pdf(&[
229            "<< /Type /Catalog /Pages 2 0 R >>",
230            "<< /Type /Pages /Kids [99 0 R 3 0 R null] /Count 3 >>",
231            "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 100 100] >>",
232        ]))
233        .expect("open");
234        assert_eq!(doc.page_count(), 1);
235        assert!(doc.page(0).is_ok());
236    }
237
238    #[test]
239    fn missing_type_nodes_tolerated() {
240        // Neither tree node carries /Type; /Kids presence tells interior from
241        // leaf, and inheritance still works through the untyped interior node.
242        let doc = PdfDocument::open(build_pdf(&[
243            "<< /Type /Catalog /Pages 2 0 R >>",
244            "<< /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 200] >>",
245            "<< /Parent 2 0 R >>",
246        ]))
247        .expect("open");
248        assert_eq!(doc.page_count(), 1);
249        let page = doc.page(0).expect("page");
250        assert_eq!(page.media_box.width(), 200.0);
251    }
252
253    #[test]
254    fn empty_page_tree_is_an_error() {
255        assert!(PdfDocument::open(build_pdf(&[
256            "<< /Type /Catalog /Pages 2 0 R >>",
257            "<< /Type /Pages /Kids [] /Count 0 >>",
258        ]))
259        .is_err());
260    }
261
262    #[test]
263    fn null_root_is_a_hard_error() {
264        // Object 1 (the /Root target) is the literal null object.
265        assert!(PdfDocument::open(build_pdf(&["null"])).is_err());
266    }
267
268    #[test]
269    fn overly_deep_page_tree_is_pruned() {
270        // A single-kid Pages chain deeper than the guard: the kid walk must
271        // terminate (no hang/stack overflow) with the leaf pruned. The
272        // document-level fallback then recovers the orphaned /Type /Page leaf
273        // via a whole-document scan, so the document still opens with that page.
274        let mut objects: Vec<String> = vec!["<< /Type /Catalog /Pages 2 0 R >>".into()];
275        let chain = MAX_PAGE_TREE_DEPTH + 10;
276        for i in 0..chain {
277            objects.push(format!("<< /Type /Pages /Kids [{} 0 R] /Count 1 >>", i + 3));
278        }
279        objects.push("<< /Type /Page /MediaBox [0 0 10 10] >>".into());
280        let refs: Vec<&str> = objects.iter().map(|s| s.as_str()).collect();
281        let doc = PdfDocument::open(build_pdf(&refs)).expect("fallback recovers the pruned leaf");
282        assert_eq!(doc.page_count(), 1);
283    }
284}