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 page_index: HashMap<ObjectId, usize>,
17}
18
19impl Catalog {
20 pub fn from_trailer(file: &PdfFile) -> Result<Self> {
21 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 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 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 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 pub fn page_index_of(&self, id: ObjectId) -> Option<usize> {
77 self.page_index.get(&id).copied()
78 }
79
80 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 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 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 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 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 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 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 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 assert!(PdfDocument::open(build_pdf(&["null"])).is_err());
273 }
274
275 #[test]
276 fn overly_deep_page_tree_is_pruned() {
277 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}