Skip to main content

xberg_native_pdf/document/
pages.rs

1//! Page tree traversal, counting, and per-page geometry.
2//!
3//! Split out of the parent's single 18,544-line `impl PdfDocument`, which made
4//! `document.rs` 1.2 MiB and tripped the 500 KiB file-safety limit. A child module's
5//! `impl` is the same inherent impl and sees the parent's private items unchanged. ~keep
6
7use super::*;
8
9impl PdfDocument {
10    fn inheritable_value_is_resolvable(&self, value: &Object) -> bool {
11        let Some(reference) = value.as_reference() else {
12            return true;
13        };
14        // ~keep Free xref slots load as Null rather than Err; neither can supply an inherited value.
15        matches!(self.load_object(reference), Ok(resolved) if !matches!(resolved, Object::Null | Object::Reference(_)))
16    }
17
18    /// Get the number of pages in the document.
19    ///
20    /// This function:
21    /// 1. Loads the catalog (root object)
22    /// 2. Follows the /Pages reference to the page tree root
23    /// 3. Extracts the /Count value from the page tree
24    ///
25    /// # Errors
26    ///
27    /// Returns an error if:
28    /// - The catalog cannot be loaded
29    /// - The /Pages entry is missing or invalid
30    /// - The page tree root does not contain a /Count entry
31    ///
32    /// # Example
33    ///
34    /// ```no_run
35    /// # use xberg_native_pdf::document::PdfDocument;
36    /// # let mut doc = PdfDocument::open("sample.pdf")?;
37    /// let count = doc.page_count()?;
38    /// println!("Document has {} pages", count);
39    /// # Ok::<(), xberg_native_pdf::error::Error>(())
40    /// ```
41    pub fn page_count(&self) -> Result<usize> {
42        let primary: Result<usize> = match self.get_page_count_standard() {
43            Ok(count) => {
44                tracing::debug!(target: LOG_TARGET, "Page count from /Count: {}", count);
45                Ok(count)
46            }
47            Err(Error::EncryptedPdf) => return Err(Error::EncryptedPdf),
48            Err(e) => {
49                // For encrypted PDFs any failure to read the page tree means we
50                // cannot access the content, so surface it immediately. ~keep
51                if self.is_encrypted() {
52                    trace_recoverable_pdf_error("count_encrypted_pages", &e);
53                    return Err(Error::EncryptedPdf);
54                }
55                trace_recoverable_pdf_error("count_pages_from_tree", &e);
56                match self.get_page_count_by_scanning() {
57                    Ok(count) => {
58                        tracing::warn!(target: LOG_TARGET, "Page count from scanning: {}", count);
59                        Ok(count)
60                    }
61                    Err(scan_err) => {
62                        tracing::error!(target: LOG_TARGET,
63                            operation = "count_pages",
64                            primary_error_code = e.telemetry_code(),
65                            primary_error_offset = ?e.telemetry_offset(),
66                            fallback_error_code = scan_err.telemetry_code(),
67                            fallback_error_offset = ?scan_err.telemetry_offset(),
68                            "all PDF page count strategies failed"
69                        );
70                        Err(e)
71                    }
72                }
73            }
74        };
75
76        // Enumerator rescue. A count of 0 from the /Count-based readers on a
77        // non-encrypted document is almost always a page tree they could not
78        // resolve - `/Pages` packed inside an object stream, or a deeply nested
79        // `/Pages` -> `/Pages` -> `/Page` tree - not a genuinely empty document.
80        // The /Count readers and `all_page_refs` (which walks `/Pages` -> `/Kids`
81        // via `collect_page_refs`) both MISS such a tree; `get_page` still reaches
82        // every page through its own per-page traversal / `collect_all_pages` bulk
83        // walk, so count by agreeing with what it can actually reach. Gated on a
84        // primary result of 0, so every document the standard reader counts
85        // normally is unchanged. ~keep
86        if matches!(primary, Ok(0)) && !self.is_encrypted() {
87            // The /Count readers - and `all_page_refs`, which walks
88            // `/Pages` -> `/Kids` via `collect_page_refs` - miss a page tree
89            // packed inside an object stream. `get_page` still resolves every
90            // such page through its own per-page traversal / `collect_all_pages`
91            // bulk walk, so count by probing it: the definitive agreement with
92            // the pages the rest of the API can actually reach. `get_page` never
93            // calls back into `page_count` (no recursion) and caches each page
94            // (repeat probes are cheap). For an ObjStm-packed tree each `get_page`
95            // can fall back to a full object scan, so counting this way is
96            // O(n * objects) - bounded by the sanity cap, and only ever on an
97            // already-broken document. Only runs when the primary count is 0, so
98            // normally-counted documents are byte-identical. ~keep
99            let mut n = 0usize;
100            while n < 1_000_000 && self.get_page(n).is_ok() {
101                n += 1;
102            }
103            if n > 0 {
104                tracing::warn!(target: LOG_TARGET, "Page /Count was 0; enumerated {} pages via get_page", n);
105                return Ok(n);
106            }
107        }
108        primary
109    }
110
111    /// Get the MediaBox of a page.
112    ///
113    /// MediaBox defines the physical boundaries of the page in user space units.
114    pub fn get_page_media_box(&self, page_index: usize) -> Result<(f32, f32, f32, f32)> {
115        let page = self.get_page(page_index)?;
116        let page_dict = page
117            .as_dict()
118            .ok_or_else(|| Error::InvalidPdf("Page is not a dictionary".to_string()))?;
119
120        // Resolve indirect reference if present — PDF spec §7.3.10 permits any value
121        // to be an indirect reference, e.g. `/MediaBox 174 0 R` where 174 0 R is `[0 0 612 792]`.
122        // ~keep
123        let media_box_obj_raw = page_dict
124            .get("MediaBox")
125            .ok_or_else(|| Error::InvalidPdf("MediaBox not found or not an array".to_string()))?;
126        let media_box_obj = self.resolve_obj_ref(media_box_obj_raw);
127        let media_box = media_box_obj
128            .as_array()
129            .ok_or_else(|| Error::InvalidPdf("MediaBox not found or not an array".to_string()))?;
130
131        if media_box.len() < 4 {
132            return Err(Error::InvalidPdf("MediaBox must have at least 4 elements".to_string()));
133        }
134
135        fn to_f32(obj: &Object) -> f32 {
136            match obj {
137                Object::Integer(v) => *v as f32,
138                Object::Real(v) => *v as f32,
139                _ => 0.0,
140            }
141        }
142
143        // §7.3.10: *any* element of the rectangle array may itself be an
144        // indirect reference (pdf.js issue7872 stores `/MediaBox
145        // [4 0 R 5 0 R 6 0 R 7 0 R]`). Resolve each element before
146        // coercing — otherwise an unresolved Reference reads as 0.0 and
147        // the page collapses to a zero-area box that clips all content. ~keep
148        Ok((
149            to_f32(&self.resolve_obj_ref(&media_box[0])),
150            to_f32(&self.resolve_obj_ref(&media_box[1])),
151            to_f32(&self.resolve_obj_ref(&media_box[2])),
152            to_f32(&self.resolve_obj_ref(&media_box[3])),
153        ))
154    }
155
156    /// Page `/Rotate` normalised to one of `{0, 90, 180, 270}`
157    /// (ISO 32000-1 §7.7.3.3); `0` when absent or invalid.
158    ///
159    /// Pure inspection (no feature gate) for the auto-extraction
160    /// classifier (case I — transformed-bbox coverage / OCR
161    /// orientation). Resolves via [`get_page`](Self::get_page), so the
162    /// inheritable `/Rotate` attribute (ISO 32000-1 §7.7.3.4) is walked
163    /// up the page tree — a `/Rotate` set on an ancestor `/Pages` node
164    /// is honoured, not just one on the leaf page object.
165    pub fn get_page_rotation(&self, page_index: usize) -> Result<i32> {
166        let page = self.get_page(page_index)?;
167        let dict = page
168            .as_dict()
169            .ok_or_else(|| Error::InvalidPdf("Page is not a dictionary".to_string()))?;
170        let raw = match dict.get("Rotate") {
171            Some(r) => match self.resolve_obj_ref(r) {
172                Object::Integer(v) => v as i32,
173                Object::Real(v) => v as i32,
174                _ => 0,
175            },
176            None => 0,
177        };
178        // `/Rotate` shall be a multiple of 90 (ISO 32000-1 §7.7.3.3);
179        // a non-multiple is invalid → `0` (per this fn's contract),
180        // NOT silently floored (e.g. 135 must not become 90). ~keep
181        let n = ((raw % 360) + 360) % 360;
182        Ok(if n % 90 == 0 { n } else { 0 })
183    }
184
185    /// Sibling of [`get_page_rotation`](Self::get_page_rotation) that keeps the
186    /// three cases the latter folds into `0` (absent, non-numeric, and a
187    /// numeric value that is not a multiple of 90) distinct. Same inheritance
188    /// resolution via [`get_page`](Self::get_page).
189    pub fn get_page_rotation_status(&self, page_index: usize) -> Result<PageRotation> {
190        let page = self.get_page(page_index)?;
191        let dict = page
192            .as_dict()
193            .ok_or_else(|| Error::InvalidPdf("Page is not a dictionary".to_string()))?;
194        let Some(r) = dict.get("Rotate") else {
195            return Ok(PageRotation::Absent);
196        };
197        let raw = match self.resolve_obj_ref(r) {
198            Object::Integer(v) => v as i32,
199            // ~keep A non-integral `/Rotate` (e.g. `90.5`) is out of spec. `get_page_rotation`
200            // truncates it because it folds everything invalid to 0 anyway; here truncating
201            // would report `Valid(90)` and re-introduce the exact conflation this type exists
202            // to remove, so only an integral Real is accepted.
203            Object::Real(v) if v.fract() == 0.0 => v as i32,
204            _ => return Ok(PageRotation::Malformed),
205        };
206        let n = ((raw % 360) + 360) % 360;
207        Ok(if n % 90 == 0 {
208            PageRotation::Valid(n)
209        } else {
210            PageRotation::Malformed
211        })
212    }
213
214    /// Get page count using the standard /Count field
215    pub(super) fn get_page_count_standard(&self) -> Result<usize> {
216        let catalog = self.catalog()?;
217        let catalog_dict = catalog.as_dict().ok_or_else(|| Error::InvalidObjectType {
218            expected: "Dictionary".to_string(),
219            found: catalog.type_name().to_string(),
220        })?;
221
222        let pages_ref = catalog_dict
223            .get("Pages")
224            .ok_or_else(|| Error::InvalidPdf("Catalog missing /Pages entry".to_string()))?
225            .as_reference()
226            .ok_or_else(|| Error::InvalidPdf("/Pages is not a reference".to_string()))?;
227
228        let pages_obj = self.load_object(pages_ref)?;
229        let pages_dict = match pages_obj.as_dict() {
230            Some(d) => d,
231            None => {
232                // If the page tree root resolved to Null it usually means the
233                // PDF is encrypted and the page tree could not be decrypted.
234                // Surface the real error instead of silently reporting 0 pages. ~keep
235                if matches!(pages_obj, crate::object::Object::Null) && self.is_encrypted() {
236                    return Err(Error::EncryptedPdf);
237                }
238                tracing::warn!(target: LOG_TARGET,
239                    "Page tree root is {} (expected Dictionary), treating as 0 pages",
240                    pages_obj.type_name()
241                );
242                return Ok(0);
243            }
244        };
245
246        let count = pages_dict
247            .get("Count")
248            .ok_or_else(|| Error::InvalidPdf("Page tree missing /Count entry".to_string()))?
249            .as_integer()
250            .ok_or_else(|| Error::InvalidPdf("/Count is not an integer".to_string()))?;
251
252        // Validate /Count against PDF spec limits (Annex C.2: max 8,388,607 indirect objects) ~keep
253        const MAX_PAGES: i64 = 8_388_607;
254        if !(0..=MAX_PAGES).contains(&count) {
255            tracing::warn!(target: LOG_TARGET,
256                "/Count value {} is unreasonable (max {}), falling back to tree scan",
257                count,
258                MAX_PAGES
259            );
260            return self.get_page_count_by_scanning();
261        }
262
263        let max_objects = self.xref.len();
264        if (count as usize) > max_objects {
265            tracing::warn!(target: LOG_TARGET,
266                "/Count {} exceeds total objects {}, falling back to tree scan",
267                count,
268                max_objects
269            );
270            return self.get_page_count_by_scanning();
271        }
272
273        Ok(count as usize)
274    }
275
276    /// Get page count by scanning the page tree (fallback method)
277    fn get_page_count_by_scanning(&self) -> Result<usize> {
278        let catalog = self.catalog()?;
279        let catalog_dict = catalog.as_dict().ok_or_else(|| Error::InvalidObjectType {
280            expected: "Dictionary".to_string(),
281            found: catalog.type_name().to_string(),
282        })?;
283
284        let pages_ref = catalog_dict
285            .get("Pages")
286            .ok_or_else(|| Error::InvalidPdf("Catalog missing /Pages entry".to_string()))?
287            .as_reference()
288            .ok_or_else(|| Error::InvalidPdf("/Pages is not a reference".to_string()))?;
289
290        self.count_pages_recursive(pages_ref, 0)
291    }
292
293    /// Recursively count pages in the page tree
294    pub(super) fn count_pages_recursive(&self, node_ref: ObjectRef, depth: u32) -> Result<usize> {
295        if depth >= MAX_PAGE_TREE_DEPTH {
296            tracing::warn!(target: LOG_TARGET,
297                object_id = node_ref.id,
298                max_depth = MAX_PAGE_TREE_DEPTH,
299                "page tree depth limit exceeded while counting pages"
300            );
301            return Err(Error::RecursionLimitExceeded(MAX_PAGE_TREE_DEPTH));
302        }
303
304        let node = match self.load_object(node_ref) {
305            Ok(n) => n,
306            Err(error) => {
307                tracing::warn!(target: LOG_TARGET,
308                    object_id = node_ref.id,
309                    generation = node_ref.generation,
310                    error_code = error.telemetry_code(),
311                    error_offset = ?error.telemetry_offset(),
312                    "failed to load page tree node"
313                );
314                return Ok(0);
315            }
316        };
317
318        let node_dict = match node.as_dict() {
319            Some(d) => d,
320            None => {
321                tracing::warn!(target: LOG_TARGET, "Page tree node {} is not a dictionary", node_ref);
322                return Ok(0);
323            }
324        };
325
326        let node_type = node_dict.get("Type").and_then(|obj| obj.as_name());
327
328        match node_type {
329            Some("Page") => Ok(1),
330            Some("Pages") => {
331                let kids = match node_dict.get("Kids").and_then(|obj| obj.as_array()) {
332                    Some(k) => k,
333                    None => {
334                        tracing::warn!(target: LOG_TARGET, "Pages node {} missing /Kids array", node_ref);
335                        return Ok(0);
336                    }
337                };
338
339                let mut count = 0;
340                for kid in kids {
341                    if let Some(kid_ref) = kid.as_reference() {
342                        match self.count_pages_recursive(kid_ref, depth + 1) {
343                            Ok(page_count) => count += page_count,
344                            Err(Error::CircularReference(obj_ref)) => {
345                                tracing::warn!(target: LOG_TARGET, "Circular reference in page tree at object {}, skipping", obj_ref);
346                                continue;
347                            }
348                            // GH#1755: RecursionLimitExceeded used to get its own arm here,
349                            // but it only ever changed the log line — telemetry_code()
350                            // already reports "recursion_limit_exceeded" through the
351                            // general arm below, so the split was dead. ~keep
352                            Err(error) => {
353                                tracing::warn!(target: LOG_TARGET,
354                                    error_code = error.telemetry_code(),
355                                    error_offset = ?error.telemetry_offset(),
356                                    "error counting pages in branch; skipping"
357                                );
358                                continue;
359                            }
360                        }
361                    }
362                }
363                Ok(count)
364            }
365            _ => {
366                tracing::warn!(
367                    target: crate::LOG_TARGET_ROOT,
368                    operation = "traverse_page_tree",
369                    error_code = "unknown_node_type",
370                    "skipping unknown page tree node"
371                );
372                Ok(0)
373            }
374        }
375    }
376
377    /// Get page count as u32 (legacy API).
378    ///
379    /// This is a convenience method that returns the page count as a u32.
380    /// It calls `page_count()` internally but converts the result
381    /// returns 0 if an error occurs (for backward compatibility).
382    #[deprecated(since = "0.1.0", note = "Use page_count() instead, which returns Result")]
383    pub fn page_count_u32(&self) -> u32 {
384        self.page_count().unwrap_or(0) as u32
385    }
386
387    /// Returns the page index range `0..page_count`, or an empty range
388    /// when `page_count()` fails.
389    ///
390    /// Designed for `for i in doc.page_indices() { ... }` so callers
391    /// don't have to write `for i in 0..doc.page_count()?`. The
392    /// fallible-vs-iterator tension that motivated the issue is
393    /// resolved by treating a metadata-broken document as having no
394    /// pages at the iteration level — every per-page extraction call
395    /// is already fallible and surfaces the real error.
396    ///
397    /// # Example
398    ///
399    /// ```ignore
400    /// for i in doc.page_indices() {
401    ///     let text = doc.extract_text(i)?;
402    ///     println!("page {}: {} chars", i, text.len());
403    /// }
404    /// ```
405    pub fn page_indices(&self) -> std::ops::Range<usize> {
406        0..self.page_count().unwrap_or(0)
407    }
408
409    /// Get a page object by index (0-based).
410    ///
411    /// # Arguments
412    ///
413    /// * `page_index` - Zero-based page index
414    ///
415    /// # Returns
416    ///
417    /// The page dictionary object.
418    ///
419    /// # Errors
420    ///
421    /// Returns an error if the page index is out of bounds or if the page
422    /// tree structure is invalid.
423    pub fn get_page(&self, page_index: usize) -> Result<Object> {
424        // Check page cache first — page tree is static per §7.7.3.2 ~keep
425        if let Some(cached) = self.page_cache.lock_or_recover().get(&page_index).cloned() {
426            return Ok(cached);
427        }
428
429        // Defer bulk page tree walk until enough pages are accessed. ~keep
430        const LAZY_THRESHOLD: usize = 64;
431        let cache_misses = self.page_cache.lock_or_recover().len();
432
433        if !self.page_cache_populated.load(Ordering::Acquire) && cache_misses >= LAZY_THRESHOLD {
434            self.page_cache_populated.store(true, Ordering::Release);
435            if let Err(e) = self.populate_page_cache() {
436                trace_recoverable_pdf_error("populate_page_cache", &e);
437            }
438            if let Some(cached) = self.page_cache.lock_or_recover().get(&page_index).cloned() {
439                return Ok(cached);
440            }
441        }
442
443        let catalog = self.catalog()?;
444        let catalog_dict = catalog.as_dict().ok_or_else(|| Error::InvalidObjectType {
445            expected: "Dictionary".to_string(),
446            found: catalog.type_name().to_string(),
447        })?;
448
449        let pages_ref = catalog_dict
450            .get("Pages")
451            .ok_or_else(|| Error::InvalidPdf("Catalog missing /Pages entry".to_string()))?
452            .as_reference()
453            .ok_or_else(|| Error::InvalidPdf("/Pages is not a reference".to_string()))?;
454
455        let mut inherited = HashMap::new();
456
457        let page = match self.get_page_from_tree(pages_ref, page_index, &mut 0, &mut inherited) {
458            Ok(page) => {
459                if let Some(dict) = page.as_dict() {
460                    tracing::debug!(target: LOG_TARGET, "Collected page {}, keys: {:?}", page_index, dict.keys());
461                    if let Some(contents) = dict.get("Contents") {
462                        tracing::debug!(target: LOG_TARGET, "  -> /Contents: {:?}", contents);
463                    }
464                    if let Some(rotate) = dict.get("Rotate") {
465                        tracing::debug!(target: LOG_TARGET, "  -> /Rotate: {:?}", rotate);
466                    }
467                }
468                Ok(page)
469            }
470            Err(error) => {
471                if matches!(
472                    error,
473                    Error::InvalidPdf(_)
474                        | Error::InvalidObjectType { .. }
475                        | Error::CircularReference(_)
476                        | Error::ObjectNotFound(_, _)
477                ) {
478                    trace_recoverable_pdf_error("traverse_page_tree", &error);
479                    self.get_page_by_scanning(page_index)
480                } else {
481                    Err(error)
482                }
483            }
484        }?;
485
486        self.page_cache.lock_or_recover().insert(page_index, page.clone());
487        Ok(page)
488    }
489
490    /// Walk the page tree once and populate page_cache for ALL pages.
491    /// This avoids O(n²) cost when pages are accessed sequentially.
492    fn populate_page_cache(&self) -> Result<()> {
493        let catalog = self.catalog()?;
494        let catalog_dict = catalog.as_dict().ok_or_else(|| Error::InvalidObjectType {
495            expected: "Dictionary".to_string(),
496            found: catalog.type_name().to_string(),
497        })?;
498
499        let pages_ref = catalog_dict
500            .get("Pages")
501            .ok_or_else(|| Error::InvalidPdf("Catalog missing /Pages entry".to_string()))?
502            .as_reference()
503            .ok_or_else(|| Error::InvalidPdf("/Pages is not a reference".to_string()))?;
504
505        let mut page_index = 0usize;
506        let mut inherited = HashMap::new();
507        self.collect_all_pages(pages_ref, &mut page_index, &mut inherited, &mut HashSet::new(), 0)?;
508        tracing::debug!(target: LOG_TARGET, "Populated page cache with {} pages", page_index);
509        Ok(())
510    }
511
512    /// Pre-populate `image_xobject_cache` for all XObject refs across all cached pages.
513    /// Collects all unique XObject references, sorts them by xref offset for sequential
514    /// I/O (avoids random seeking in large files), then peeks each one via `is_form_xobject()`.
515    #[allow(dead_code)]
516    fn prefetch_xobject_subtypes(&self) {
517        let mut xobj_refs: Vec<ObjectRef> = Vec::new();
518        let page_dicts: Vec<Object> = self.page_cache.lock_or_recover().values().cloned().collect();
519
520        for page_obj in &page_dicts {
521            let page_dict = match page_obj.as_dict() {
522                Some(d) => d,
523                None => continue,
524            };
525            let resources = match page_dict.get("Resources") {
526                Some(r) => {
527                    if let Some(ref_obj) = r.as_reference() {
528                        match self.load_object(ref_obj) {
529                            Ok(obj) => obj,
530                            Err(_) => continue,
531                        }
532                    } else {
533                        r.clone()
534                    }
535                }
536                None => continue,
537            };
538            let res_dict = match resources.as_dict() {
539                Some(d) => d,
540                None => continue,
541            };
542            let xobj_obj = match res_dict.get("XObject") {
543                Some(x) => {
544                    if let Some(ref_obj) = x.as_reference() {
545                        match self.load_object(ref_obj) {
546                            Ok(obj) => obj,
547                            Err(_) => continue,
548                        }
549                    } else {
550                        x.clone()
551                    }
552                }
553                None => continue,
554            };
555            if let Some(xobj_dict) = xobj_obj.as_dict() {
556                for val in xobj_dict.values() {
557                    if let Some(obj_ref) = val.as_reference()
558                        && !self.image_xobject_cache.lock_or_recover().contains(&obj_ref)
559                    {
560                        xobj_refs.push(obj_ref);
561                    }
562                }
563            }
564        }
565
566        xobj_refs.sort_unstable_by_key(|r| (r.id, r.generation));
567        xobj_refs.dedup();
568
569        xobj_refs.sort_by_key(|r| self.xref.get(r.id).map(|e| e.offset).unwrap_or(u64::MAX));
570
571        tracing::debug!(target: LOG_TARGET, "Prefetching XObject subtypes for {} unique refs", xobj_refs.len());
572
573        // Peek each ref — populates image_xobject_cache as a side effect ~keep
574        for obj_ref in xobj_refs {
575            self.is_form_xobject(obj_ref);
576        }
577    }
578
579    /// Recursively walk the page tree and collect all pages into page_cache.
580    fn collect_all_pages(
581        &self,
582        node_ref: ObjectRef,
583        page_index: &mut usize,
584        inherited: &mut HashMap<String, Object>,
585        visited: &mut HashSet<ObjectRef>,
586        depth: u32,
587    ) -> Result<()> {
588        if depth >= MAX_PAGE_TREE_DEPTH {
589            tracing::warn!(target: LOG_TARGET,
590                object_id = node_ref.id,
591                max_depth = MAX_PAGE_TREE_DEPTH,
592                "page tree depth limit exceeded while collecting pages"
593            );
594            return Err(Error::RecursionLimitExceeded(MAX_PAGE_TREE_DEPTH));
595        }
596        if !visited.insert(node_ref) {
597            return Err(Error::CircularReference(node_ref));
598        }
599
600        let node = self.load_object(node_ref)?;
601        let node_dict = match node.as_dict() {
602            Some(d) => d,
603            None => return Ok(()),
604        };
605
606        let node_type = node_dict.get("Type").and_then(|obj| obj.as_name()).unwrap_or("");
607
608        match node_type {
609            "Page" => {
610                let mut page_dict = node_dict.clone();
611                for attr_name in &["Resources", "MediaBox", "CropBox", "Rotate"] {
612                    if !page_dict
613                        .get(*attr_name)
614                        .is_some_and(|value| self.inheritable_value_is_resolvable(value))
615                        && let Some(inherited_value) = inherited.get(*attr_name)
616                    {
617                        tracing::debug!(target: LOG_TARGET, "Page {} inheriting {}: {:?}", *page_index, attr_name, inherited_value);
618                        page_dict.insert(attr_name.to_string(), inherited_value.clone());
619                    }
620                }
621                tracing::debug!(target: LOG_TARGET, "Collected page {}, keys: {:?}", *page_index, page_dict.keys());
622                if let Some(contents) = page_dict.get("Contents") {
623                    tracing::debug!(target: LOG_TARGET, "  -> /Contents: {:?}", contents);
624                }
625                if let Some(rotate) = page_dict.get("Rotate") {
626                    tracing::debug!(target: LOG_TARGET, "  -> /Rotate: {:?}", rotate);
627                }
628                self.page_cache
629                    .lock_or_recover()
630                    .insert(*page_index, Object::Dictionary(page_dict));
631                *page_index += 1;
632            }
633            "Pages" => {
634                // Save inherited state so siblings don't see each other's overrides ~keep
635                let saved = inherited.clone();
636
637                // Nearest ancestor's attributes override more distant ones (PDF spec §7.7.3.4).
638                // insert() is correct here because we snapshot/restore `inherited` around
639                // the recursion, so this node's values apply only to its subtree. ~keep
640                for attr_name in &["Resources", "MediaBox", "CropBox", "Rotate"] {
641                    if let Some(attr_value) = node_dict
642                        .get(*attr_name)
643                        .filter(|value| self.inheritable_value_is_resolvable(value))
644                    {
645                        tracing::debug!(target: LOG_TARGET,
646                            "Pages node at {:?} providing inheritable {}: {:?}",
647                            node_ref,
648                            attr_name,
649                            attr_value
650                        );
651                        inherited.insert(attr_name.to_string(), attr_value.clone());
652                    }
653                }
654
655                if let Some(kids) = node_dict.get("Kids").and_then(|obj| obj.as_array()) {
656                    for kid in kids {
657                        if let Some(kid_ref) = kid.as_reference()
658                            && let Err(error) =
659                                self.collect_all_pages(kid_ref, page_index, inherited, visited, depth + 1)
660                        {
661                            tracing::warn!(target: LOG_TARGET,
662                                error_code = error.telemetry_code(),
663                                error_offset = ?error.telemetry_offset(),
664                                "error collecting page from tree; skipping branch"
665                            );
666                        }
667                    }
668                }
669
670                *inherited = saved;
671            }
672            _ => {}
673        }
674
675        Ok(())
676    }
677
678    /// Get a page by scanning all objects in the PDF (fallback for broken page trees)
679    /// This method is used when the standard page tree traversal fails due to malformed structure.
680    fn get_page_by_scanning(&self, target_index: usize) -> Result<Object> {
681        let mut current_index = 0;
682
683        // Prime the ObjStm recovery cache up front when the xref looks
684        // unreliable. Without this, the first pass below iterates only
685        // `xref.all_object_numbers()` — which misses compressed objects
686        // whose xref slots have been mis-flagged free. The sweep is a
687        // one-shot, guarded by `objstm_recovery_done`, so this is cheap
688        // if recovery already happened. ~keep
689        self.recover_from_object_streams();
690
691        // Collect all object numbers first to avoid borrow checker issues.
692        // Sort for deterministic iteration order (HashMap iteration is
693        // non-deterministic). We union the xref-listed ids with the object
694        // ids recovered from the ObjStm sweep so that pages compressed in
695        // streams whose xref slots were mis-flagged free still get visited. ~keep
696        let mut obj_nums: Vec<u32> = self.xref.all_object_numbers().collect();
697        for r in self.object_cache.lock_or_recover().keys() {
698            obj_nums.push(r.id);
699        }
700        obj_nums.sort_unstable();
701        obj_nums.dedup();
702
703        for &obj_num in &obj_nums {
704            if let Ok(obj) = self.load_object(ObjectRef {
705                id: obj_num,
706                generation: 0,
707            }) && let Some(dict) = obj.as_dict()
708                && let Some(type_obj) = dict.get("Type")
709                && let Some(type_name) = type_obj.as_name()
710                && type_name == "Page"
711            {
712                if current_index == target_index {
713                    return Ok(obj);
714                }
715                current_index += 1;
716            }
717        }
718
719        // Second pass: heuristic detection for pages without /Type entry.
720        // Runs as a complement to pass 1 — counts page-like dicts that lack
721        // a /Type entry alongside the /Type /Page matches, so that PDFs
722        // whose corruption stripped /Type from some page dicts still reach
723        // the full page count. Previously this pass only ran when pass 1
724        // found zero pages, which meant any partial pass-1 match (e.g. 200
725        // of 253 pages) would silently short pass 2 and fail. ~keep
726        let mut heuristic_index = current_index;
727        for &obj_num in &obj_nums {
728            if let Ok(obj) = self.load_object(ObjectRef {
729                id: obj_num,
730                generation: 0,
731            }) && let Some(dict) = obj.as_dict()
732            {
733                let has_no_type = dict.get("Type").is_none();
734                // Also handle /Type that is an unresolvable reference (Null) ~keep
735                let type_is_null = dict.get("Type").is_some_and(|t| matches!(t, Object::Null));
736                if (has_no_type || type_is_null)
737                    && (dict.contains_key("MediaBox")
738                        || dict.contains_key("Contents")
739                        || (dict.contains_key("Resources") && dict.contains_key("Parent")))
740                {
741                    tracing::warn!(target: LOG_TARGET,
742                        "Heuristic page candidate: object {} (page-like keys without valid /Type)",
743                        obj_num
744                    );
745                    if heuristic_index == target_index {
746                        return Ok(obj);
747                    }
748                    heuristic_index += 1;
749                }
750            }
751        }
752        current_index = heuristic_index;
753
754        if current_index == 0
755            && let Ok(catalog) = self.catalog()
756            && let Some(catalog_dict) = catalog.as_dict()
757            && let Some(pages_ref) = catalog_dict.get("Pages").and_then(|p| p.as_reference())
758            && let Ok(pages_obj) = self.load_object(pages_ref)
759            && let Some(pages_dict) = pages_obj.as_dict()
760            && let Some(kids) = pages_dict.get("Kids").and_then(|k| k.as_array())
761        {
762            let mut kids_index = 0;
763            for kid in kids {
764                if let Some(kid_ref) = kid.as_reference() {
765                    // Skip self-referencing kids (cycle detection) ~keep
766                    if kid_ref == pages_ref {
767                        continue;
768                    }
769                    if let Ok(kid_obj) = self.load_object(kid_ref)
770                        && let Some(kid_dict) = kid_obj.as_dict()
771                    {
772                        let is_pages_node = kid_dict
773                            .get("Type")
774                            .and_then(|t| t.as_name())
775                            .is_some_and(|n| n == "Pages");
776                        if is_pages_node {
777                            continue;
778                        }
779                        if kids_index == target_index {
780                            tracing::warn!(target: LOG_TARGET,
781                                "Found page {} via direct /Kids resolution of object {}",
782                                target_index,
783                                kid_ref.id
784                            );
785                            return Ok(kid_obj);
786                        }
787                        kids_index += 1;
788                    }
789                }
790            }
791        }
792
793        Err(Error::InvalidPdf(format!(
794            "Page index {} not found by scanning",
795            target_index
796        )))
797    }
798
799    /// Recursively traverse page tree to find a specific page.
800    ///
801    /// PDF Spec: ISO 32000-1:2008, Section 7.7.3.3 - Page Objects
802    /// Implements attribute inheritance for /Resources, /MediaBox, /CropBox, /Rotate.
803    ///
804    /// Inheritable attributes from parent Pages nodes are collected as we traverse down
805    /// the tree. When a Page is found, inherited attributes are merged in (only if the
806    /// Page doesn't already have them - child values override parent values).
807    fn get_page_from_tree(
808        &self,
809        node_ref: ObjectRef,
810        target_index: usize,
811        current_index: &mut usize,
812        inherited: &mut HashMap<String, Object>,
813    ) -> Result<Object> {
814        self.get_page_from_tree_inner(node_ref, target_index, current_index, inherited, &mut HashSet::new(), 0)?
815            .ok_or_else(|| Error::InvalidPdf(format!("Page index {} not found in tree", target_index)))
816    }
817
818    /// `Ok(None)` means "the target page is not under this node" — an ordinary
819    /// traversal outcome for a leaf that is not the target, a subtree the `/Count`
820    /// fast path proves cannot hold it, or a `Pages` node whose kids were all tried.
821    /// `Err` is reserved for a real fault: a load failure, a cycle, the depth limit,
822    /// or a node that fails a structural check (not a dictionary, missing `/Type`,
823    /// missing `/Kids`, an unresolvable kid, an unknown node type). GH#1798: before
824    /// this split, "not found here" and "real fault" were the same `Err` variant, so
825    /// the ordinary case logged as a warning once for every branch the walk passed —
826    /// up to n(n-1)/2 times for an n-page flat tree. ~keep
827    fn get_page_from_tree_inner(
828        &self,
829        node_ref: ObjectRef,
830        target_index: usize,
831        current_index: &mut usize,
832        inherited: &mut HashMap<String, Object>,
833        visited: &mut HashSet<ObjectRef>,
834        depth: u32,
835    ) -> Result<Option<Object>> {
836        if depth >= MAX_PAGE_TREE_DEPTH {
837            tracing::warn!(target: LOG_TARGET,
838                object_id = node_ref.id,
839                max_depth = MAX_PAGE_TREE_DEPTH,
840                "page tree depth limit exceeded while resolving a page"
841            );
842            return Err(Error::RecursionLimitExceeded(MAX_PAGE_TREE_DEPTH));
843        }
844        if !visited.insert(node_ref) {
845            return Err(Error::CircularReference(node_ref));
846        }
847        let node = self.load_object(node_ref)?;
848        let node_dict = match node.as_dict() {
849            Some(d) => d,
850            None => {
851                tracing::warn!(target: LOG_TARGET,
852                    "Page tree node {} is {} (expected Dictionary), skipping",
853                    node_ref.id,
854                    node.type_name()
855                );
856                return Err(Error::InvalidPdf(format!(
857                    "Page tree node {} is not a dictionary",
858                    node_ref.id
859                )));
860            }
861        };
862
863        let node_type = node_dict
864            .get("Type")
865            .and_then(|obj| obj.as_name())
866            .ok_or_else(|| Error::InvalidPdf("Page tree node missing /Type".to_string()))?;
867
868        match node_type {
869            "Pages" if *current_index < target_index => {
870                // Skip entire subtree if /Count shows target is past this node.
871                // Not found here, not a fault — the caller keeps walking siblings. ~keep
872                if let Some(count) = node_dict.get("Count").and_then(|c| c.as_integer()).filter(|&c| c > 0) {
873                    let count = count as usize;
874                    if *current_index + count <= target_index {
875                        *current_index += count;
876                        return Ok(None);
877                    }
878                }
879            }
880            _ => {}
881        }
882
883        match node_type {
884            "Page" => {
885                if *current_index == target_index {
886                    // Apply inherited attributes to this page
887                    // PDF Spec: "If not present in the page dictionary, the value is inherited
888                    // from an ancestor node in the page tree" ~keep
889                    let mut page_dict = node_dict.clone();
890
891                    // Inheritable attributes per PDF Spec Table 30:
892                    // - Resources (required, can be inherited)
893                    // - MediaBox (required, can be inherited)
894                    // - CropBox (optional, can be inherited)
895                    // - Rotate (optional, can be inherited) ~keep
896                    let inheritable_attrs = ["Resources", "MediaBox", "CropBox", "Rotate"];
897
898                    for attr_name in &inheritable_attrs {
899                        if !page_dict
900                            .get(*attr_name)
901                            .is_some_and(|value| self.inheritable_value_is_resolvable(value))
902                            && let Some(inherited_value) = inherited.get(*attr_name)
903                        {
904                            tracing::debug!(target: LOG_TARGET,
905                                "Page {} inheriting /{} from ancestor Pages node",
906                                target_index,
907                                attr_name
908                            );
909                            page_dict.insert(attr_name.to_string(), inherited_value.clone());
910                        }
911                    }
912
913                    Ok(Some(Object::Dictionary(page_dict)))
914                } else {
915                    *current_index += 1;
916                    Ok(None)
917                }
918            }
919            "Pages" => {
920                let inheritable_attrs = ["Resources", "MediaBox", "CropBox", "Rotate"];
921
922                for attr_name in &inheritable_attrs {
923                    if let Some(attr_value) = node_dict
924                        .get(*attr_name)
925                        .filter(|value| self.inheritable_value_is_resolvable(value))
926                    {
927                        inherited.insert(attr_name.to_string(), attr_value.clone());
928                    }
929                }
930
931                let kids = match node_dict.get("Kids").and_then(|obj| obj.as_array()) {
932                    Some(k) => k,
933                    None => {
934                        tracing::warn!(target: LOG_TARGET, "Malformed PDF: Pages node missing /Kids array");
935                        // Malformed PDF: Pages node has no /Kids array
936                        // Gracefully return without error to allow other recovery paths
937                        // The scanning method will find pages eventually ~keep
938                        return Err(Error::InvalidPdf(
939                            "Pages node missing /Kids array - try fallback method".to_string(),
940                        ));
941                    }
942                };
943
944                for kid in kids {
945                    let kid_ref = kid
946                        .as_reference()
947                        .ok_or_else(|| Error::InvalidPdf("Kid in /Kids array is not a reference".to_string()))?;
948
949                    // ~keep A failed child may alter inheritance while descending; isolate siblings.
950                    let mut child_inherited = inherited.clone();
951                    match self.get_page_from_tree_inner(
952                        kid_ref,
953                        target_index,
954                        current_index,
955                        &mut child_inherited,
956                        visited,
957                        depth + 1,
958                    ) {
959                        Ok(Some(page)) => return Ok(Some(page)),
960                        // Ordinary traversal: the target is not under this kid. GH#1798:
961                        // this used to be an `Err` arm and logged here on every branch
962                        // the walk passed, which is not a fault. ~keep
963                        Ok(None) => continue,
964                        Err(Error::CircularReference(obj_ref)) => {
965                            tracing::warn!(target: LOG_TARGET, "Circular reference in page tree at object {}, skipping", obj_ref);
966                            continue;
967                        }
968                        // GH#1755: RecursionLimitExceeded used to get its own arm here,
969                        // but it only ever changed the log line (and the previous
970                        // catch-all below logged nothing at all) — fold both into one
971                        // arm with the same structured logging `count_pages_recursive`
972                        // uses for its equivalent catch-all. ~keep
973                        Err(error) => {
974                            tracing::warn!(target: LOG_TARGET,
975                                error_code = error.telemetry_code(),
976                                error_offset = ?error.telemetry_offset(),
977                                "error walking to page in tree; skipping branch"
978                            );
979                            continue;
980                        }
981                    }
982                }
983
984                Ok(None)
985            }
986            _ => Err(Error::InvalidPdf(format!("Unknown page tree node type: {}", node_type))),
987        }
988    }
989
990    /// Get the object reference for a page by index.
991    ///
992    /// This is used by outline and annotations to find page references.
993    pub(crate) fn get_page_ref(&self, page_index: usize) -> Result<ObjectRef> {
994        let catalog = self.catalog()?;
995        let catalog_dict = catalog.as_dict().ok_or_else(|| Error::InvalidObjectType {
996            expected: "Dictionary".to_string(),
997            found: catalog.type_name().to_string(),
998        })?;
999
1000        let pages_ref = catalog_dict
1001            .get("Pages")
1002            .ok_or_else(|| Error::InvalidPdf("Catalog missing /Pages entry".to_string()))?
1003            .as_reference()
1004            .ok_or_else(|| Error::InvalidPdf("/Pages is not a reference".to_string()))?;
1005
1006        self.get_page_ref_recursive(pages_ref, page_index, &mut 0, &mut HashSet::new(), 0)?
1007            .ok_or_else(|| Error::InvalidPdf(format!("Page {} not found", page_index)))
1008    }
1009
1010    /// Recursively find page reference in the page tree.
1011    ///
1012    /// `Ok(None)` means the target is not under this node (an ordinary leaf mismatch
1013    /// or an exhausted `Kids` array); `Err` is a real fault. Same shape as
1014    /// [`Self::get_page_from_tree_inner`] — see GH#1798. ~keep
1015    pub(crate) fn get_page_ref_recursive(
1016        &self,
1017        node_ref: ObjectRef,
1018        target_index: usize,
1019        current_index: &mut usize,
1020        visited: &mut HashSet<ObjectRef>,
1021        depth: u32,
1022    ) -> Result<Option<ObjectRef>> {
1023        if depth >= MAX_PAGE_TREE_DEPTH {
1024            tracing::warn!(target: LOG_TARGET,
1025                object_id = node_ref.id,
1026                max_depth = MAX_PAGE_TREE_DEPTH,
1027                "page tree depth limit exceeded while resolving a page reference"
1028            );
1029            return Err(Error::RecursionLimitExceeded(MAX_PAGE_TREE_DEPTH));
1030        }
1031        if !visited.insert(node_ref) {
1032            return Err(Error::CircularReference(node_ref));
1033        }
1034        let node = self.load_object(node_ref)?;
1035        let node_dict = match node.as_dict() {
1036            Some(d) => d,
1037            None => {
1038                tracing::warn!(target: LOG_TARGET,
1039                    "Page tree node {} is {} (expected Dictionary), skipping",
1040                    node_ref.id,
1041                    node.type_name()
1042                );
1043                return Err(Error::InvalidPdf(format!(
1044                    "Page tree node {} is not a dictionary",
1045                    node_ref.id
1046                )));
1047            }
1048        };
1049
1050        let node_type = node_dict
1051            .get("Type")
1052            .and_then(|t| t.as_name())
1053            .ok_or_else(|| Error::InvalidPdf("Node missing Type".to_string()))?;
1054
1055        match node_type {
1056            "Page" => {
1057                if *current_index == target_index {
1058                    Ok(Some(node_ref))
1059                } else {
1060                    *current_index += 1;
1061                    Ok(None)
1062                }
1063            }
1064            "Pages" => {
1065                let kids = node_dict
1066                    .get("Kids")
1067                    .and_then(|k| k.as_array())
1068                    .ok_or_else(|| Error::InvalidPdf("Pages node missing Kids".to_string()))?;
1069
1070                for kid_obj in kids {
1071                    if let Some(kid_ref) = kid_obj.as_reference() {
1072                        match self.get_page_ref_recursive(kid_ref, target_index, current_index, visited, depth + 1) {
1073                            Ok(Some(page_ref)) => return Ok(Some(page_ref)),
1074                            Ok(None) => continue,
1075                            Err(_) => continue,
1076                        }
1077                    }
1078                }
1079
1080                Ok(None)
1081            }
1082            _ => Err(Error::InvalidPdf(format!("Unknown node type: {}", node_type))),
1083        }
1084    }
1085
1086    /// 0-based page-tree position of a page object.
1087    ///
1088    /// Defers to [`Self::all_page_refs`] rather than walking the tree again:
1089    /// that collector already handles the `/Count` fast path, nodes that omit
1090    /// `/Type`, and cycles, and sharing it keeps this direction of the
1091    /// index <-> reference mapping consistent with the other by construction.
1092    /// A page whose object is not in the tree is an error, never a guess —
1093    /// a plausible-but-wrong page number is worse than no destination.
1094    pub(crate) fn page_index_of_ref(&self, page_ref: ObjectRef) -> Result<usize> {
1095        self.all_page_refs()?
1096            .iter()
1097            .position(|candidate| *candidate == page_ref)
1098            .ok_or_else(|| Error::InvalidPdf(format!("object {} is not a page in the page tree", page_ref.id)))
1099    }
1100}