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            .take(MAX_PAGE_COUNT)
99            .collect()
100    }
101
102    /// Resolve `/Root` → `/Pages`, tolerating an absent/null/non-dict Root or a
103    /// missing /Pages by returning `None` (the caller then falls back to a
104    /// whole-document page scan instead of failing the open).
105    fn resolve_pages_ref(file: &PdfFile) -> Option<ObjectId> {
106        let root_ref = file.trailer.get_ref("Root").ok()?;
107        let root = file.resolve(root_ref).ok()?;
108        root.as_dict().ok()?.get_ref("Pages").ok()
109    }
110
111    fn collect_page_refs(
112        file: &PdfFile,
113        node_id: ObjectId,
114        refs: &mut Vec<ObjectId>,
115        visited: &mut HashSet<ObjectId>,
116        depth: usize,
117    ) -> Result<()> {
118        if refs.len() >= MAX_PAGE_COUNT {
119            return Ok(());
120        }
121        if depth > MAX_PAGE_TREE_DEPTH {
122            warn!("page tree deeper than {MAX_PAGE_TREE_DEPTH} at {node_id}; pruning subtree");
123            return Ok(());
124        }
125        if !visited.insert(node_id) {
126            warn!("page tree cycle: node {node_id} already visited; pruning");
127            return Ok(());
128        }
129
130        let node = match file.resolve(node_id) {
131            Ok(PdfObject::Null) => {
132                warn!("page tree node {node_id} resolves to null; skipping");
133                return Ok(());
134            }
135            Ok(obj) => obj,
136            Err(e) => {
137                warn!("failed to resolve page tree node {node_id}: {e}; skipping");
138                return Ok(());
139            }
140        };
141        let Ok(dict) = node.as_dict() else {
142            warn!(
143                "page tree node {node_id} is {}, expected Dict; skipping",
144                node.type_name()
145            );
146            return Ok(());
147        };
148
149        // /Type is formally required but missing or wrong in real-world files;
150        // fall back on the presence of /Kids to tell interior nodes from leaves.
151        let is_pages = match dict.get_name("Type") {
152            Ok("Pages") => true,
153            Ok("Page") => false,
154            _ => dict.get("Kids").is_some(),
155        };
156
157        if is_pages {
158            // /Kids may itself be an indirect ref to the array.
159            let kids: Cow<'_, [PdfObject]> = match dict.get("Kids") {
160                Some(PdfObject::Array(a)) => Cow::Borrowed(a.as_slice()),
161                Some(PdfObject::Ref(r)) => match file.resolve(*r) {
162                    Ok(PdfObject::Array(a)) => Cow::Owned(a),
163                    _ => {
164                        warn!("pages node {node_id}: /Kids ref {r} is not an array; skipping");
165                        return Ok(());
166                    }
167                },
168                _ => {
169                    warn!("pages node {node_id} has no /Kids array; skipping");
170                    return Ok(());
171                }
172            };
173            for kid in kids.iter() {
174                if refs.len() >= MAX_PAGE_COUNT {
175                    break;
176                }
177                match kid {
178                    PdfObject::Ref(r) => {
179                        Self::collect_page_refs(file, *r, refs, visited, depth + 1)?;
180                    }
181                    PdfObject::Null => {
182                        warn!("pages node {node_id}: null kid; skipping");
183                    }
184                    other => {
185                        warn!(
186                            "pages node {node_id}: kid is {}, expected Ref; skipping",
187                            other.type_name()
188                        );
189                    }
190                }
191            }
192        } else {
193            // L8 Fix: Check page count limit before adding new page
194            if refs.len() >= MAX_PAGE_COUNT {
195                warn!("page tree exceeds {MAX_PAGE_COUNT} pages; stopping collection");
196                return Ok(());
197            }
198            refs.push(node_id);
199        }
200        Ok(())
201    }
202
203    pub fn get_page(&self, file: &PdfFile, index: usize) -> Result<PdfPage> {
204        let page_ref =
205            self.page_refs.get(index).copied().ok_or_else(|| {
206                Error::InvalidObject(0, format!("page index {index} out of range"))
207            })?;
208
209        PdfPage::from_object(file, page_ref)
210    }
211}
212
213#[cfg(test)]
214mod tests {
215    use crate::page::MAX_PAGE_TREE_DEPTH;
216    use crate::test_util::build_pdf;
217    use crate::PdfDocument;
218
219    #[test]
220    fn kids_cycle_is_pruned() {
221        // The pages node lists itself as a kid; the walk must terminate.
222        let doc = PdfDocument::open(build_pdf(&[
223            "<< /Type /Catalog /Pages 2 0 R >>",
224            "<< /Type /Pages /Kids [3 0 R 2 0 R] /Count 1 >>",
225            "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 100 100] >>",
226        ]))
227        .expect("open");
228        assert_eq!(doc.page_count(), 1);
229    }
230
231    #[test]
232    fn dangling_and_null_kids_are_skipped() {
233        // 99 0 R is dangling (skipped whether resolve errors or returns Null);
234        // the literal null kid is skipped outright.
235        let doc = PdfDocument::open(build_pdf(&[
236            "<< /Type /Catalog /Pages 2 0 R >>",
237            "<< /Type /Pages /Kids [99 0 R 3 0 R null] /Count 3 >>",
238            "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 100 100] >>",
239        ]))
240        .expect("open");
241        assert_eq!(doc.page_count(), 1);
242        assert!(doc.page(0).is_ok());
243    }
244
245    #[test]
246    fn missing_type_nodes_tolerated() {
247        // Neither tree node carries /Type; /Kids presence tells interior from
248        // leaf, and inheritance still works through the untyped interior node.
249        let doc = PdfDocument::open(build_pdf(&[
250            "<< /Type /Catalog /Pages 2 0 R >>",
251            "<< /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 200] >>",
252            "<< /Parent 2 0 R >>",
253        ]))
254        .expect("open");
255        assert_eq!(doc.page_count(), 1);
256        let page = doc.page(0).expect("page");
257        assert_eq!(page.media_box.width(), 200.0);
258    }
259
260    #[test]
261    fn empty_page_tree_is_an_error() {
262        assert!(PdfDocument::open(build_pdf(&[
263            "<< /Type /Catalog /Pages 2 0 R >>",
264            "<< /Type /Pages /Kids [] /Count 0 >>",
265        ]))
266        .is_err());
267    }
268
269    #[test]
270    fn null_root_is_a_hard_error() {
271        // Object 1 (the /Root target) is the literal null object.
272        assert!(PdfDocument::open(build_pdf(&["null"])).is_err());
273    }
274
275    #[test]
276    fn overly_deep_page_tree_is_pruned() {
277        // A single-kid Pages chain deeper than the guard: the kid walk must
278        // terminate (no hang/stack overflow) with the leaf pruned. The
279        // document-level fallback then recovers the orphaned /Type /Page leaf
280        // via a whole-document scan, so the document still opens with that page.
281        let mut objects: Vec<String> = vec!["<< /Type /Catalog /Pages 2 0 R >>".into()];
282        let chain = MAX_PAGE_TREE_DEPTH + 10;
283        for i in 0..chain {
284            objects.push(format!("<< /Type /Pages /Kids [{} 0 R] /Count 1 >>", i + 3));
285        }
286        objects.push("<< /Type /Page /MediaBox [0 0 10 10] >>".into());
287        let refs: Vec<&str> = objects.iter().map(|s| s.as_str()).collect();
288        let doc = PdfDocument::open(build_pdf(&refs)).expect("fallback recovers the pruned leaf");
289        assert_eq!(doc.page_count(), 1);
290    }
291}