Skip to main content

xberg_native_pdf/document/
objects.rs

1//! Indirect-object loading, xref recovery, and reference resolution.
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    /// Get the PDF version.
11    ///
12    /// Returns a tuple (major, minor) representing the PDF version.
13    /// For example, PDF 1.7 returns (1, 7).
14    ///
15    /// # Example
16    ///
17    /// ```no_run
18    /// # use xberg_native_pdf::document::PdfDocument;
19    /// # let mut doc = PdfDocument::open("sample.pdf")?;
20    /// let (major, minor) = doc.version();
21    /// println!("PDF version: {}.{}", major, minor);
22    /// # Ok::<(), xberg_native_pdf::error::Error>(())
23    /// ```
24    pub fn version(&self) -> (u8, u8) {
25        self.version
26    }
27
28    /// Get a reference to the trailer dictionary.
29    ///
30    /// The trailer dictionary contains important document metadata including:
31    /// - /Root: Reference to the catalog dictionary
32    /// - /Info: Reference to the document info dictionary (optional)
33    /// - /Size: Number of entries in the cross-reference table
34    /// - /Encrypt: Encryption dictionary (if encrypted)
35    /// - /ID: File identifier array
36    ///
37    /// # Example
38    ///
39    /// ```no_run
40    /// # use xberg_native_pdf::document::PdfDocument;
41    /// # let mut doc = PdfDocument::open("sample.pdf")?;
42    /// let trailer = doc.trailer();
43    /// if let Some(dict) = trailer.as_dict() {
44    ///     if let Some(info_ref) = dict.get("Info") {
45    ///         println!("Document has an Info dictionary");
46    ///     }
47    /// }
48    /// # Ok::<(), xberg_native_pdf::error::Error>(())
49    /// ```
50    pub fn trailer(&self) -> &Object {
51        &self.trailer
52    }
53
54    /// Return every object ID known to this document.
55    ///
56    /// Unions the cross-reference table with any object IDs that were
57    /// recovered from compressed object streams (which may not have an
58    /// explicit xref entry). The result is sorted and deduplicated so
59    /// callers can iterate once and write each object exactly once.
60    ///
61    /// Used by `DocumentEditor::write_full_to_writer` to sweep any
62    /// objects that were not reached during the shallow page-tree
63    /// traversal (e.g. embedded font sub-objects such as
64    /// `DescendantFonts`, `FontFile2`, `ToUnicode`, `FontDescriptor`).
65    pub fn all_object_ids(&self) -> Vec<u32> {
66        let mut ids: Vec<u32> = self.xref.all_object_numbers().collect();
67        for r in self.object_cache.lock_or_recover().keys() {
68            ids.push(r.id);
69        }
70        ids.sort_unstable();
71        ids.dedup();
72        ids
73    }
74
75    /// Return references to every leaf page, in document order, with a single
76    /// page-tree traversal.
77    ///
78    /// Replaces the O(n²) pattern of calling [`get_page_ref`] in a 0..n loop:
79    /// each `get_page_ref(i)` walks the tree from the root and stops at the
80    /// i-th leaf, so collecting all n refs walks 1+2+...+n nodes.
81    ///
82    /// Optimised for the common flat-tree case: when a `Pages` node's
83    /// `Count` matches `Kids.len()`, every kid is a leaf and we can take
84    /// the references straight from the array without loading each leaf.
85    /// Only when the tree is multi-level do we recurse and load child nodes.
86    pub(crate) fn all_page_refs(&self) -> Result<Vec<ObjectRef>> {
87        let catalog = self.catalog()?;
88        let catalog_dict = catalog.as_dict().ok_or_else(|| Error::InvalidObjectType {
89            expected: "Dictionary".to_string(),
90            found: catalog.type_name().to_string(),
91        })?;
92        let pages_ref = catalog_dict
93            .get("Pages")
94            .and_then(|p| p.as_reference())
95            .ok_or_else(|| Error::InvalidPdf("Catalog missing /Pages entry".to_string()))?;
96
97        let mut out: Vec<ObjectRef> = Vec::new();
98        let mut visited: HashSet<ObjectRef> = HashSet::new();
99        self.collect_page_refs(pages_ref, &mut out, &mut visited, 0)?;
100        Ok(out)
101    }
102
103    fn collect_page_refs(
104        &self,
105        node_ref: ObjectRef,
106        out: &mut Vec<ObjectRef>,
107        visited: &mut HashSet<ObjectRef>,
108        depth: u32,
109    ) -> Result<()> {
110        if depth >= MAX_PAGE_TREE_DEPTH {
111            tracing::warn!(target: LOG_TARGET,
112                object_id = node_ref.id,
113                max_depth = MAX_PAGE_TREE_DEPTH,
114                "page tree depth limit exceeded while collecting page references"
115            );
116            return Err(Error::RecursionLimitExceeded(MAX_PAGE_TREE_DEPTH));
117        }
118        if !visited.insert(node_ref) {
119            return Ok(());
120        }
121        let node = self.load_object(node_ref)?;
122        let dict = match node.as_dict() {
123            Some(d) => d,
124            None => return Ok(()),
125        };
126
127        let kids = match dict.get("Kids").and_then(|k| k.as_array()) {
128            Some(k) => k,
129            None => {
130                out.push(node_ref);
131                return Ok(());
132            }
133        };
134
135        // Fast path: flat subtree — every kid is a leaf when /Count == kids.len(). ~keep
136        let count = dict.get("Count").and_then(|c| c.as_integer()).unwrap_or(-1);
137        if count >= 0 && (count as usize) == kids.len() {
138            for kid in kids {
139                if let Some(kid_ref) = kid.as_reference() {
140                    out.push(kid_ref);
141                }
142            }
143            return Ok(());
144        }
145
146        // GH#1755: degrade rather than abort. One unloadable branch (corrupt object,
147        // recursion limit, ...) must not cost the caller every OTHER page in the
148        // document. Matches `collect_all_pages`'s per-kid handling in `pages.rs`, and
149        // brings this walker in line with the other four page-tree walkers, which all
150        // skip a bad branch instead of failing the whole traversal. ~keep
151        for kid in kids {
152            if let Some(kid_ref) = kid.as_reference()
153                && let Err(error) = self.collect_page_refs(kid_ref, out, visited, depth + 1)
154            {
155                tracing::warn!(target: LOG_TARGET,
156                    error_code = error.telemetry_code(),
157                    error_offset = ?error.telemetry_offset(),
158                    "error collecting page ref from tree; skipping branch"
159                );
160            }
161        }
162        Ok(())
163    }
164
165    /// Up to `len` bytes of the document starting at `offset`, or `None` when
166    /// `offset` is past the end. Every read of the file body goes through an
167    /// absolute offset into the document's own bytes, so no two callers share a
168    /// cursor and no read depends on what another thread did first. ~keep
169    pub(super) fn bytes_at(&self, offset: u64, len: usize) -> Option<&[u8]> {
170        let start = usize::try_from(offset).ok()?;
171        let bytes = self.source_bytes.get(start..)?;
172        Some(&bytes[..len.min(bytes.len())])
173    }
174
175    /// Scan the file to find an object by its header.
176    ///
177    /// This is a fallback method used when an object is not in the xref table
178    /// but is referenced by critical structures (like Pages from Catalog).
179    /// Some PDFs have incomplete xref tables that are missing entries for
180    /// objects that actually exist in the file.
181    fn scan_for_object(&self, obj_ref: ObjectRef) -> Result<u64> {
182        {
183            let scan_cache = self.scanned_object_offsets.lock_or_recover();
184            if let Some(offsets) = scan_cache.as_ref() {
185                if let Some(&offset) = offsets.get(&obj_ref.id) {
186                    return Ok(offset);
187                }
188                return Err(Error::ObjectNotFound(obj_ref.id, obj_ref.generation));
189            }
190        }
191
192        tracing::info!(target: LOG_TARGET,
193            "Building object offset map from file scan (triggered by object {} {})",
194            obj_ref.id,
195            obj_ref.generation
196        );
197
198        let content: &[u8] = &self.source_bytes;
199
200        let mut offsets = HashMap::new();
201
202        let mut pos = 0;
203        while pos < content.len() {
204            let valid_start = pos == 0 || content[pos - 1] == b'\n' || content[pos - 1] == b'\r';
205            if !valid_start || !content[pos].is_ascii_digit() {
206                pos += 1;
207                continue;
208            }
209
210            let start = pos;
211            while pos < content.len() && content[pos].is_ascii_digit() {
212                pos += 1;
213            }
214            if pos >= content.len() || content[pos] != b' ' {
215                continue;
216            }
217            let obj_num_str = std::str::from_utf8(&content[start..pos]).unwrap_or("");
218            let obj_num: u32 = match obj_num_str.parse() {
219                Ok(n) => n,
220                Err(_) => continue,
221            };
222
223            pos += 1;
224
225            let gen_start = pos;
226            while pos < content.len() && content[pos].is_ascii_digit() {
227                pos += 1;
228            }
229            if pos >= content.len() || content[pos] != b' ' {
230                continue;
231            }
232            let _gen_str = std::str::from_utf8(&content[gen_start..pos]).unwrap_or("");
233
234            pos += 1;
235
236            if pos + 3 <= content.len() && &content[pos..pos + 3] == b"obj" {
237                let after_obj = pos + 3;
238                let valid_end = after_obj >= content.len() || {
239                    let c = content[after_obj];
240                    c == b'\n' || c == b'\r' || c == b' ' || c == b'\t' || c == b'<'
241                };
242                if valid_end {
243                    offsets.entry(obj_num).or_insert(start as u64);
244                    pos = after_obj;
245                    continue;
246                }
247            }
248            // Reset pos to just after the start to avoid infinite loop ~keep
249            pos = start + 1;
250        }
251
252        tracing::debug!(target: LOG_TARGET, "File scan found {} objects", offsets.len());
253
254        let result = offsets.get(&obj_ref.id).copied();
255        *self.scanned_object_offsets.lock_or_recover() = Some(offsets);
256
257        match result {
258            Some(offset) => Ok(offset),
259            None => Err(Error::ObjectNotFound(obj_ref.id, obj_ref.generation)),
260        }
261    }
262
263    /// One-time sweep over every known object stream (`/Type /ObjStm`),
264    /// used to recover from xref tables that mis-mark compressed objects as
265    /// free.
266    ///
267    /// Some PDF producers emit an xref where a compressed object's slot is
268    /// type 0 (free) instead of type 2 (compressed → stream#). The object
269    /// is physically stored inside an `ObjStm`, but `scan_for_object` can't
270    /// find it because it has no standalone `N G obj` marker in the body.
271    ///
272    /// The recovery: iterate every uncompressed candidate, peek at the
273    /// dictionary, and for those that are `/Type /ObjStm`, parse the stream
274    /// and cache everything inside (overwriting any stale `Object::Null`
275    /// entries from earlier free-entry short-circuits).
276    ///
277    /// Runs at most once per document — guarded by `objstm_recovery_done`.
278    /// Cost is amortised across every recovered object.
279    pub(super) fn recover_from_object_streams(&self) {
280        use crate::objstm::parse_object_stream_with_decryption_outcome;
281
282        {
283            let done = self.objstm_recovery_done.lock_or_recover();
284            if *done {
285                return;
286            }
287        }
288
289        tracing::debug!(target: LOG_TARGET, "Sweeping object streams to recover xref-flagged-free objects");
290
291        // Find ObjStm candidates by raw pattern search in the file body.
292        //
293        // Why not iterate xref entries here: the xref is precisely what we
294        // don't trust in this recovery path — its offsets may be wrong
295        // its type tags may be lying about what each slot contains. A raw
296        // search for `N G obj ... /Type /ObjStm` finds every object stream
297        // the producer actually wrote, independent of how the xref
298        // describes them.
299        //
300        // Only flip `objstm_recovery_done` after we finish the scan+parse
301        // pass, so a later retry can still attempt recovery. ~keep
302        let candidates = find_objstm_candidates(&self.source_bytes);
303
304        let mut objstms_found = 0usize;
305        let mut recovered = 0usize;
306        for (stream_obj_num, offset) in &candidates {
307            let stream_ref = ObjectRef::new(*stream_obj_num, 0);
308            let stream_obj = match self.load_uncompressed_object(stream_ref, *offset) {
309                Ok(obj) => obj,
310                Err(_) => continue,
311            };
312
313            let is_objstm = stream_obj
314                .as_dict()
315                .and_then(|d| d.get("Type"))
316                .and_then(|t| t.as_name())
317                .is_some_and(|n| n == "ObjStm");
318            if !is_objstm {
319                continue;
320            }
321            objstms_found += 1;
322
323            // Parse the stream body. ISO 32000-2:2020 §7.6.3 says ObjStm
324            // shall NOT be individually encrypted, so skip decryption here
325            // — mirrors the default branch in `load_compressed_object`. ~keep
326            let outcome = match parse_object_stream_with_decryption_outcome(&stream_obj, None, 0, 0) {
327                Ok(outcome) => outcome,
328                Err(error) => {
329                    tracing::warn!(
330                        target: crate::LOG_TARGET_ROOT,
331                        operation = "recover_object_stream",
332                        stream_object_id = stream_obj_num,
333                        error_code = error.telemetry_code(),
334                        error_offset = ?error.telemetry_offset(),
335                        "skipping malformed object stream during recovery"
336                    );
337                    continue;
338                }
339            };
340            self.trace_object_stream_recovery_once(*stream_obj_num, &outcome);
341            let objects_map = Arc::new(outcome.objects);
342            self.object_stream_cache
343                .lock_or_recover()
344                .insert(ObjectRef::new(*stream_obj_num, 0), Arc::clone(&objects_map));
345
346            let mut cache = self.object_cache.lock_or_recover();
347            for (obj_num, object) in objects_map.iter() {
348                let cache_ref = ObjectRef::new(*obj_num, 0);
349                // Only overwrite entries we'd otherwise have resolved to
350                // Null (the free-entry short-circuit caches Null). Never
351                // clobber a real object loaded through the normal path. ~keep
352                match cache.get(&cache_ref) {
353                    Some(Object::Null) | None => {
354                        cache.insert(cache_ref, object.clone());
355                        recovered += 1;
356                    }
357                    _ => {}
358                }
359            }
360        }
361
362        tracing::debug!(target: LOG_TARGET,
363            "Object-stream recovery sweep: {} candidate positions, {} ObjStms, {} objects cached",
364            candidates.len(),
365            objstms_found,
366            recovered
367        );
368
369        *self.objstm_recovery_done.lock_or_recover() = true;
370    }
371
372    /// Run [`recover_from_object_streams`](Self::recover_from_object_streams)
373    /// proactively, once, when this document's xref came from full-file
374    /// reconstruction (GH#1774).
375    ///
376    /// Reconstruction only ever finds objects via a literal "N G obj" header
377    /// scan, so it is structurally blind to anything packed inside a
378    /// `/ObjStm` — routinely true for font dictionaries etc. in the final
379    /// revision of an incrementally-updated PDF. Left unrecovered, such a
380    /// reference resolves to `Object::Null` per §7.3.10 on first use, with no
381    /// error anywhere: exactly the "86 of 88 pages silently empty" shape of
382    /// GH#1774. The sweep is guarded by `objstm_recovery_done`, so calling it
383    /// here costs one whole-file scan, not a per-miss cost — the same sweep
384    /// `pages.rs::get_page_by_scanning` already runs for the analogous
385    /// "compressed object's xref slot is wrong" shape on a *parsed* xref that
386    /// mis-flags a compressed object as free. ~keep
387    pub(super) fn recover_object_streams_if_reconstructed(&self) {
388        if self.xref_reconstructed {
389            self.recover_from_object_streams();
390        }
391    }
392
393    /// Load an object by its reference.
394    ///
395    /// This function:
396    /// 1. Checks the object cache first
397    /// 2. If not cached, looks up the byte offset in the xref table
398    /// 3. Seeks to that offset and parses the object
399    /// 4. Caches the result for future access
400    /// 5. If object not in xref but is critical, scans file for it
401    ///
402    /// # Errors
403    ///
404    /// Returns an error if:
405    /// - The object reference is not in the xref table and file scan fails
406    /// - The object is not in use (free object)
407    /// - Seeking to the object offset fails
408    /// - Parsing the object fails
409    /// - A circular reference is detected
410    /// - The recursion depth limit is exceeded
411    ///
412    /// # Example
413    ///
414    /// ```no_run
415    /// # use xberg_native_pdf::document::PdfDocument;
416    /// # use xberg_native_pdf::object::ObjectRef;
417    /// # let mut doc = PdfDocument::open("sample.pdf")?;
418    /// let obj_ref = ObjectRef::new(1, 0);
419    /// let obj = doc.load_object(obj_ref)?;
420    /// # Ok::<(), xberg_native_pdf::error::Error>(())
421    /// ```
422    pub fn load_object(&self, obj_ref: ObjectRef) -> Result<Object> {
423        tracing::trace!(target: LOG_TARGET,
424            object_id = obj_ref.id,
425            generation = obj_ref.generation,
426            "loading object"
427        );
428
429        // Check recursion depth (per-thread counter; no lock needed) ~keep
430        {
431            let depth = RECURSION_DEPTH.with(|d| *d.borrow());
432            if depth >= MAX_RECURSION_DEPTH {
433                tracing::error!(target: LOG_TARGET,
434                    "Recursion depth limit exceeded ({}) while loading object {} gen {}",
435                    MAX_RECURSION_DEPTH,
436                    obj_ref.id,
437                    obj_ref.generation
438                );
439                return Err(Error::RecursionLimitExceeded(MAX_RECURSION_DEPTH));
440            }
441        }
442
443        // Check for circular references (per-thread stack; concurrent threads
444        // resolving the same object do NOT appear as a false cycle) ~keep
445        if RESOLVING_STACK.with(|s| s.borrow().contains(&obj_ref)) {
446            tracing::error!(target: LOG_TARGET,
447                "Circular reference detected for object {} gen {} (depth: {})",
448                obj_ref.id,
449                obj_ref.generation,
450                RECURSION_DEPTH.with(|d| *d.borrow())
451            );
452            return Err(Error::CircularReference(obj_ref));
453        }
454
455        // Check cache first (warm path: fully parallel, no serialization). ~keep
456        let cached_opt = self.object_cache.lock_or_recover().get(&obj_ref).cloned();
457        if let Some(cached) = cached_opt {
458            return Ok(cached);
459        }
460
461        let entry = match self.xref.get(obj_ref.id) {
462            Some(entry) => entry,
463            None => {
464                // Object not in xref table - try scanning the file as fallback
465                // This handles PDFs with incomplete/corrupted xref tables ~keep
466                self.recovery
467                    .xref_misses
468                    .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
469                let available: Vec<u32> = self.xref.entries.keys().copied().take(20).collect();
470                tracing::trace!(target: LOG_TARGET,
471                    "Object {} not in xref table. Total entries: {}. First 20 objects: {:?}",
472                    obj_ref.id,
473                    self.xref.len(),
474                    available
475                );
476
477                match self.scan_for_object(obj_ref) {
478                    Ok(offset) => {
479                        self.recovery
480                            .file_scan_recoveries
481                            .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
482                        tracing::trace!(target: LOG_TARGET,
483                            "Successfully found object {} via file scan at offset {}",
484                            obj_ref.id,
485                            offset
486                        );
487
488                        RESOLVING_STACK.with(|s| {
489                            s.borrow_mut().insert(obj_ref);
490                        });
491                        RECURSION_DEPTH.with(|d| *d.borrow_mut() += 1);
492
493                        let result = self.load_uncompressed_object(obj_ref, offset);
494
495                        RECURSION_DEPTH.with(|d| *d.borrow_mut() -= 1);
496                        RESOLVING_STACK.with(|s| {
497                            s.borrow_mut().remove(&obj_ref);
498                        });
499
500                        return result;
501                    }
502                    Err(_) => {
503                        // PDF Spec §7.3.10: missing object reference "shall be treated as null"
504                        // ~keep
505                        self.recovery
506                            .objects_treated_as_null
507                            .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
508                        tracing::trace!(target: LOG_TARGET,
509                            "Object {} gen {} not found (xref + file scan failed), treating as Null per §7.3.10",
510                            obj_ref.id,
511                            obj_ref.generation
512                        );
513                        // GH#1774: on a document whose xref came from full-file
514                        // reconstruction, "not in the xref table at all" (this
515                        // branch) is a materially different situation than a
516                        // reference that is legitimately free per §7.3.10 (the
517                        // `!entry.in_use` branch above, reached only for an
518                        // entry the xref DOES contain) — a reconstructed xref
519                        // never emits a free entry in the first place (see
520                        // `xref_reconstruction.rs`'s `XRefEntry::uncompressed`
521                        // use), so that branch can't fire here regardless. This
522                        // one fires when reconstruction's header-only scan
523                        // simply never saw the object, which is exactly what
524                        // happens for one still compressed inside an `/ObjStm`
525                        // that `recover_from_object_streams` (already run
526                        // proactively in `open.rs` for a reconstructed xref)
527                        // could not recover either — genuinely gone, not a
528                        // silent-by-design deletion. Surface it instead of
529                        // reproducing the empty-page-with-no-error shape. ~keep
530                        if self.xref_reconstructed {
531                            let msg = format!(
532                                "Object {} {} referenced but absent from the reconstructed xref table \
533                                 (not found by header scan or object-stream recovery); treating as Null \
534                                 per §7.3.10, but this document's xref was reconstructed and content may \
535                                 be missing as a result",
536                                obj_ref.id, obj_ref.generation
537                            );
538                            tracing::warn!(target: LOG_TARGET, "{}", msg);
539                            self.push_structured_warning(crate::extractors::warnings::Warning {
540                                category: crate::extractors::warnings::WarningCategory::XrefRecovery,
541                                page: None,
542                                message: msg,
543                                spec_section: Some("7.3.10"),
544                            });
545                        }
546                        self.object_cache.lock_or_recover().insert(obj_ref, Object::Null);
547                        return Ok(Object::Null);
548                    }
549                }
550            }
551        };
552
553        tracing::trace!(target: LOG_TARGET,
554            "  → Found in xref: type={:?}, offset={}, gen={}, in_use={}",
555            entry.entry_type,
556            entry.offset,
557            entry.generation,
558            entry.in_use
559        );
560
561        if !entry.in_use {
562            tracing::debug!(target: LOG_TARGET,
563                "Object {} is marked as free (not in use). This may be due to a corrupted xref table.",
564                obj_ref.id
565            );
566
567            // xref flags the object free, but this may be xref corruption
568            // rather than an actual deletion. Run two recovery paths before
569            // falling back to §7.3.10's null. The branches below apply
570            // uniformly for all object ids (critical low-numbered catalog
571            // objects and page objects in the thousands); previously low
572            // ids took a separate "fall through to loading logic" path
573            // that silently hit the Free arm of the entry_type match
574            // still ended up Null.
575            //
576            // Recovery path 1 — standalone `N G obj` marker in the file
577            // body. `scan_for_object` builds a whole-file offset map once
578            // per document and caches it, so the amortised cost is a
579            // single O(filesize) pass no matter how many free-marked
580            // objects we probe. ~keep
581            if let Ok(scanned_offset) = self.scan_for_object(obj_ref) {
582                tracing::warn!(target: LOG_TARGET,
583                    "Object {} marked free in xref but found in file scan at offset {}; recovering",
584                    obj_ref.id,
585                    scanned_offset
586                );
587                RESOLVING_STACK.with(|s| {
588                    s.borrow_mut().insert(obj_ref);
589                });
590                RECURSION_DEPTH.with(|d| *d.borrow_mut() += 1);
591                let result = self.load_uncompressed_object(obj_ref, scanned_offset);
592                RECURSION_DEPTH.with(|d| *d.borrow_mut() -= 1);
593                RESOLVING_STACK.with(|s| {
594                    s.borrow_mut().remove(&obj_ref);
595                });
596                return result;
597            }
598
599            // Recovery path 2 — the object may be compressed inside a
600            // `/Type /ObjStm`. Real-world producers have been seen to
601            // mis-flag every compressed object's xref slot as free, so
602            // sweep the object streams once and recheck the cache. ~keep
603            self.recover_from_object_streams();
604            if let Some(obj) = self.object_cache.lock_or_recover().get(&obj_ref).cloned()
605                && !matches!(obj, Object::Null)
606            {
607                tracing::warn!(target: LOG_TARGET, "Object {} recovered from object-stream sweep", obj_ref.id);
608                return Ok(obj);
609            }
610
611            // PDF Spec §7.3.10: free object treated as null ~keep
612            tracing::warn!(target: LOG_TARGET,
613                "Free object {} gen {}, treating as Null per §7.3.10",
614                obj_ref.id,
615                obj_ref.generation
616            );
617            self.object_cache.lock_or_recover().insert(obj_ref, Object::Null);
618            return Ok(Object::Null);
619        }
620
621        RESOLVING_STACK.with(|s| {
622            s.borrow_mut().insert(obj_ref);
623        });
624        RECURSION_DEPTH.with(|d| *d.borrow_mut() += 1);
625
626        use crate::xref::XRefEntryType;
627        let entry_type = entry.entry_type;
628        let entry_offset = entry.offset;
629        let entry_gen = entry.generation;
630        let result = match entry_type {
631            XRefEntryType::Compressed => {
632                // Type 2 entry: object is in an object stream
633                // entry.offset = stream object number
634                // entry.generation = index within stream ~keep
635                tracing::trace!(target: LOG_TARGET, "  → Compressed object in stream {}, index {}", entry_offset, entry_gen);
636                self.load_compressed_object(obj_ref, entry_offset as u32, entry_gen)
637            }
638            XRefEntryType::Uncompressed => {
639                tracing::trace!(target: LOG_TARGET, "  → Uncompressed object at offset {}", entry_offset);
640                self.load_uncompressed_object(obj_ref, entry_offset)
641            }
642            XRefEntryType::Free => {
643                // Free object - shouldn't happen since we check in_use above
644                // PDF Spec §7.3.10: treat as null ~keep
645                tracing::warn!(target: LOG_TARGET,
646                    "Object {} has type Free despite in_use=true, treating as Null",
647                    obj_ref.id
648                );
649                self.object_cache.lock_or_recover().insert(obj_ref, Object::Null);
650                Ok(Object::Null)
651            }
652        };
653
654        RECURSION_DEPTH.with(|d| *d.borrow_mut() -= 1);
655        RESOLVING_STACK.with(|s| {
656            s.borrow_mut().remove(&obj_ref);
657        });
658
659        result
660    }
661
662    /// Resolve references within an object recursively.
663    ///
664    /// This utility method resolves indirect references within an object,
665    /// handling nested dictionaries and arrays up to a specified depth.
666    /// Useful for processing complex PDF structures where properties
667    /// may be stored as indirect references.
668    ///
669    /// # Arguments
670    ///
671    /// * `obj` - The object to resolve references within
672    /// * `max_depth` - Maximum recursion depth to prevent infinite loops
673    ///
674    /// # Returns
675    ///
676    /// The object with all references resolved up to max_depth levels.
677    /// If a reference cannot be resolved, it is left as-is.
678    ///
679    /// # Example
680    ///
681    /// ```no_run
682    /// # use xberg_native_pdf::document::PdfDocument;
683    /// # let mut doc = PdfDocument::open("sample.pdf")?;
684    /// # let obj = doc.catalog()?;
685    /// // Resolve all references in a dictionary up to 3 levels deep
686    /// let resolved = doc.resolve_references(&obj, 3)?;
687    /// # Ok::<(), xberg_native_pdf::error::Error>(())
688    /// ```
689    pub fn resolve_references(&self, obj: &Object, max_depth: usize) -> Result<Object> {
690        if max_depth == 0 {
691            return Ok(obj.clone());
692        }
693
694        match obj {
695            Object::Reference(obj_ref) => match self.load_object(*obj_ref) {
696                Ok(resolved) => self.resolve_references(&resolved, max_depth - 1),
697                Err(error) => {
698                    tracing::warn!(target: LOG_TARGET,
699                        operation = "resolve_reference",
700                        object_id = obj_ref.id,
701                        generation = obj_ref.generation,
702                        error_code = error.telemetry_code(),
703                        error_offset = ?error.telemetry_offset(),
704                        "failed to resolve reference"
705                    );
706                    Ok(obj.clone())
707                }
708            },
709
710            Object::Dictionary(dict) => {
711                let mut resolved_dict = std::collections::HashMap::new();
712                for (key, value) in dict.iter() {
713                    let resolved_value = self.resolve_references(value, max_depth - 1)?;
714                    resolved_dict.insert(key.clone(), resolved_value);
715                }
716                Ok(Object::Dictionary(resolved_dict))
717            }
718
719            Object::Array(arr) => {
720                let resolved_arr: Result<Vec<Object>> = arr
721                    .iter()
722                    .map(|item| self.resolve_references(item, max_depth - 1))
723                    .collect();
724                Ok(Object::Array(resolved_arr?))
725            }
726
727            _ => Ok(obj.clone()),
728        }
729    }
730
731    /// Resolve a single-level indirect reference (PDF spec §7.3.10).
732    ///
733    /// If `obj` is `Object::Reference(...)`, loads and returns the target object.
734    /// For any other object type, returns a clone unchanged. This is the
735    /// canonical way to handle "any value may be a direct or indirect reference"
736    /// throughout the parser.
737    pub(super) fn resolve_obj_ref(&self, obj: &Object) -> Object {
738        if let Some(obj_ref) = obj.as_reference() {
739            match self.load_object(obj_ref) {
740                Ok(resolved) => resolved,
741                Err(error) => {
742                    tracing::warn!(target: LOG_TARGET,
743                        operation = "resolve_indirect_reference",
744                        object_id = obj_ref.id,
745                        generation = obj_ref.generation,
746                        error_code = error.telemetry_code(),
747                        error_offset = ?error.telemetry_offset(),
748                        "failed to resolve indirect reference"
749                    );
750                    obj.clone()
751                }
752            }
753        } else {
754            obj.clone()
755        }
756    }
757
758    /// Peek at an XObject's /Subtype without loading the full object.
759    /// Returns true if the XObject is a Form XObject, false if Image or unknown.
760    /// For compressed objects or on any error, returns true (conservative — will load fully).
761    pub fn is_form_xobject(&self, obj_ref: ObjectRef) -> bool {
762        {
763            if self.image_xobject_cache.lock_or_recover().contains(&obj_ref) {
764                return false;
765            }
766        }
767
768        let cached_opt = self.object_cache.lock_or_recover().get(&obj_ref).cloned();
769        if let Some(cached) = cached_opt {
770            let is_form = cached
771                .as_dict()
772                .and_then(|d| d.get("Subtype"))
773                .and_then(|s| s.as_name())
774                == Some("Form");
775            if !is_form {
776                self.image_xobject_cache.lock_or_recover().insert(obj_ref);
777            }
778            return is_form;
779        }
780
781        let entry = match self.xref.get(obj_ref.id) {
782            Some(e) => e,
783            None => return true,
784        };
785
786        // Only peek uncompressed objects — compressed ones require full load ~keep
787        use crate::xref::XRefEntryType;
788        if entry.entry_type != XRefEntryType::Uncompressed || !entry.in_use {
789            return true;
790        }
791
792        let Some(data) = self.bytes_at(entry.offset, 1024) else {
793            return true;
794        };
795
796        if let Some(pos) = data.windows(8).position(|w| w == b"/Subtype") {
797            let after = &data[pos + 8..];
798            let trimmed = after
799                .iter()
800                .position(|&b| b != b' ' && b != b'\t' && b != b'\r' && b != b'\n');
801            if let Some(start) = trimmed {
802                let name_data = &after[start..];
803                if name_data.starts_with(b"/Form") {
804                    return true;
805                }
806                self.image_xobject_cache.lock_or_recover().insert(obj_ref);
807                return false;
808            }
809        }
810
811        true
812    }
813
814    /// Load an uncompressed object (Type 1 xref entry).
815    fn load_uncompressed_object(&self, obj_ref: ObjectRef, offset: u64) -> Result<Object> {
816        self.load_uncompressed_object_impl(obj_ref, offset, false)
817    }
818
819    /// Recursively decrypt every `Object::String` inside `obj` using the
820    /// per-object key derived from `obj_num`/`gen_num`. Streams are left
821    /// untouched — they are decrypted lazily at read time through
822    /// `decode_stream_with_encryption`. The `/Encrypt` dictionary itself
823    /// must never be passed to this function; its strings are key material,
824    /// not ciphertext.
825    ///
826    /// Per ISO 32000-1:2008 §7.6.2, strings inside encrypted-document
827    /// objects are individually encrypted with the standard encryption
828    /// algorithm. Parsed string tokens hold raw ciphertext and must be
829    /// decrypted before downstream consumers (widget text, form field
830    /// values, outlines, document info) can read them.
831    fn decrypt_strings_in_object(handler: &EncryptionHandler, obj: &mut Object, obj_num: u32, gen_num: u32) {
832        // A failure here leaves ciphertext in place for downstream readers, so
833        // it must be visible — but this walk recurses through every array and
834        // dictionary, so logging per string would emit thousands of identical
835        // events for one broken key. Count instead, and report once per
836        // object. ~keep
837        let mut failed = 0usize;
838        Self::decrypt_strings_in_object_inner(handler, obj, obj_num, gen_num, &mut failed);
839        if failed > 0 {
840            tracing::warn!(target: LOG_TARGET,
841                object_id = obj_num,
842                generation = gen_num,
843                count = failed,
844                "string decryption failed; ciphertext left in place"
845            );
846        }
847    }
848
849    fn decrypt_strings_in_object_inner(
850        handler: &EncryptionHandler,
851        obj: &mut Object,
852        obj_num: u32,
853        gen_num: u32,
854        failed: &mut usize,
855    ) {
856        match obj {
857            Object::String(bytes) => match handler.decrypt_string(bytes, obj_num, gen_num) {
858                Ok(decrypted) => *bytes = decrypted,
859                Err(e) => {
860                    *failed += 1;
861                    tracing::trace!(target: LOG_TARGET,
862                        object_id = obj_num,
863                        generation = gen_num,
864                        "string decryption failed: {}",
865                        e
866                    );
867                }
868            },
869            Object::Array(items) => {
870                for item in items {
871                    Self::decrypt_strings_in_object_inner(handler, item, obj_num, gen_num, failed);
872                }
873            }
874            Object::Dictionary(dict) => {
875                for value in dict.values_mut() {
876                    Self::decrypt_strings_in_object_inner(handler, value, obj_num, gen_num, failed);
877                }
878            }
879            Object::Stream { dict, .. } => {
880                // Stream *data* is decrypted separately in
881                // `decode_stream_with_encryption`. Its dict may still
882                // contain encrypted strings (e.g., /Metadata). ~keep
883                for value in dict.values_mut() {
884                    Self::decrypt_strings_in_object_inner(handler, value, obj_num, gen_num, failed);
885                }
886            }
887            _ => {}
888        }
889    }
890
891    /// Implementation with recursion guard to prevent infinite loops.
892    pub(super) fn load_uncompressed_object_impl(
893        &self,
894        obj_ref: ObjectRef,
895        offset: u64,
896        already_corrected: bool,
897    ) -> Result<Object> {
898        // The header and the body are read through ONE cursor, private to this
899        // call, over the document's own bytes. The body read continues from where
900        // the header read stopped, so the two must not be separable: a cursor any
901        // other reader can move between them reads the body of a different object.
902        // ~keep
903        // Cap a single header-line read so a CR-terminated PDF (legal per ISO
904        // 32000-1) whose next LF is far away — or absent for the rest of the
905        // file — cannot make one `read_until` call allocate without limit
906        // before any size check runs. MAX_BYTES on the body loop further down
907        // does not cover this: it only checks *between* calls, and both reads
908        // in this header phase had no cap of their own at all. A well-formed
909        // header is a handful of bytes, so this is comfortably above any
910        // legitimate header line while still bounding the pathological case.
911        // ~keep
912        const MAX_HEADER_LINE_BYTES: u64 = 64 * 1024; // 64 KB safety limit per header line ~keep
913        let mut cursor = Cursor::new(self.source_bytes.as_slice());
914        let (header_bytes, full_header) = {
915            let reader = &mut cursor;
916            reader.seek(SeekFrom::Start(offset))?;
917
918            let mut header_bytes = Vec::new();
919            let bytes_read = reader
920                .by_ref()
921                .take(MAX_HEADER_LINE_BYTES)
922                .read_until(b'\n', &mut header_bytes)?;
923
924            if bytes_read == 0 {
925                let msg = format!("Unexpected EOF while reading object {} header", obj_ref.id);
926                tracing::warn!(target: LOG_TARGET, "{}", msg);
927                // also push into structured sink so
928                // callers can retrieve as data via flatten_warnings. ~keep
929                self.push_structured_warning(crate::extractors::warnings::Warning {
930                    category: crate::extractors::warnings::WarningCategory::EofPremature,
931                    page: None,
932                    message: msg,
933                    spec_section: Some("7.5"),
934                });
935                return Err(Error::UnexpectedEof);
936            }
937
938            let line = String::from_utf8_lossy(&header_bytes);
939
940            // Handle multi-line object headers ~keep
941            let mut full_header = line.to_string();
942            let max_header_lines = 5;
943            let mut lines_read = 1;
944
945            while !has_standalone_obj_keyword(full_header.as_bytes()) && lines_read < max_header_lines {
946                let mut next_bytes = Vec::new();
947                // Same cap as the first header-line read above: without it, a
948                // CR-terminated stream with no standalone "obj" keyword would
949                // just move the unbounded single-call read from the first
950                // line to this continuation read instead of bounding it. ~keep
951                let next_read = reader
952                    .by_ref()
953                    .take(MAX_HEADER_LINE_BYTES)
954                    .read_until(b'\n', &mut next_bytes)?;
955                if next_read == 0 {
956                    break;
957                }
958                let next_line = String::from_utf8_lossy(&next_bytes);
959                full_header.push(' ');
960                full_header.push_str(&next_line);
961                lines_read += 1;
962            }
963            (header_bytes, full_header)
964        };
965
966        let parts: Vec<&str> = full_header.split_whitespace().collect();
967
968        let obj_pos = parts
969            .iter()
970            .position(|&p| p == "obj" || (p.starts_with("obj") && !p.starts_with("endobj")));
971
972        let obj_pos = match obj_pos {
973            Some(pos) if pos >= 2 => pos,
974            _ => {
975                // Only try backwards search once to prevent infinite recursion ~keep
976                if !already_corrected {
977                    // xref offset might be incorrect (pointing to object body instead of header)
978                    // Try searching backwards for the object header ~keep
979                    tracing::warn!(target: LOG_TARGET,
980                        "No object header at offset {}, searching backwards for object {} {} obj",
981                        offset,
982                        obj_ref.id,
983                        obj_ref.generation
984                    );
985
986                    if let Ok(corrected_offset) = self.find_object_header_backwards(obj_ref, offset) {
987                        tracing::warn!(target: LOG_TARGET,
988                            "Found object header at offset {} (xref said {})",
989                            corrected_offset,
990                            offset
991                        );
992                        return self.load_uncompressed_object_impl(obj_ref, corrected_offset, true);
993                    }
994                }
995
996                tracing::warn!(target: LOG_TARGET,
997                    operation = "load_uncompressed_object",
998                    error_code = "malformed_object_header",
999                    error_offset = offset,
1000                    object_id = obj_ref.id,
1001                    generation = obj_ref.generation,
1002                    "PDF object recovery failed"
1003                );
1004                return Err(Error::ParseError {
1005                    offset: offset as usize,
1006                    reason: format!("Expected object header, found: {}", full_header.trim()),
1007                });
1008            }
1009        };
1010
1011        let _obj_pos = obj_pos;
1012
1013        // Parse the object number and generation from header. If either
1014        // fails to parse as a number, the xref-reported offset is pointing
1015        // into the middle of a previous object's tail (e.g. xref says 12345
1016        // but the real `N G obj` header starts at 12348 because three bytes
1017        // of CR/LF/terminator got mis-accounted for by the producer — a
1018        // pattern seen in the wild). Fall back to the whole-file scan
1019        // cache: if scan recorded a different offset for this id, retry
1020        // from there before giving up. ~keep
1021        let obj_num_parsed = parts[0].parse::<u32>();
1022        let gen_num_parsed = parts[1].parse::<u16>();
1023        if !already_corrected
1024            && (obj_num_parsed.is_err() || gen_num_parsed.is_err())
1025            && let Ok(scan_offset) = self.scan_for_object(obj_ref)
1026            && scan_offset != offset
1027        {
1028            tracing::warn!(target: LOG_TARGET,
1029                operation = "load_uncompressed_object",
1030                error_code = "invalid_object_header_number",
1031                error_offset = offset,
1032                recovered_offset = scan_offset,
1033                object_id = obj_ref.id,
1034                generation = obj_ref.generation,
1035                "retrying PDF object load at scan-reported offset"
1036            );
1037            return self.load_uncompressed_object_impl(obj_ref, scan_offset, true);
1038        }
1039        let obj_num: u32 = obj_num_parsed.map_err(|_| Error::ParseError {
1040            offset: offset as usize,
1041            reason: format!("Invalid object number in header: {}", parts[0]),
1042        })?;
1043        let gen_num: u16 = gen_num_parsed.map_err(|_| Error::ParseError {
1044            offset: offset as usize,
1045            reason: format!("Invalid generation number in header: {}", parts[1]),
1046        })?;
1047
1048        // An xref entry that points at another object's valid header must not
1049        // poison the cache under the requested reference. Retry once at the
1050        // whole-file scan's offset for the requested object, then fail closed
1051        // if that header still disagrees. ~keep
1052        if obj_num != obj_ref.id || gen_num != obj_ref.generation {
1053            if !already_corrected
1054                && let Ok(scan_offset) = self.scan_for_object(obj_ref)
1055                && scan_offset != offset
1056            {
1057                self.recovery
1058                    .file_scan_recoveries
1059                    .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1060                tracing::warn!(target: LOG_TARGET,
1061                    operation = "load_uncompressed_object",
1062                    error_code = "object_reference_mismatch",
1063                    error_offset = offset,
1064                    recovered_offset = scan_offset,
1065                    expected_object_id = obj_ref.id,
1066                    expected_generation = obj_ref.generation,
1067                    found_object_id = obj_num,
1068                    found_generation = gen_num,
1069                    "retrying PDF object load at scan-reported offset"
1070                );
1071                return self.load_uncompressed_object_impl(obj_ref, scan_offset, true);
1072            }
1073
1074            tracing::warn!(target: LOG_TARGET,
1075                operation = "load_uncompressed_object",
1076                error_code = "object_reference_mismatch",
1077                error_offset = offset,
1078                expected_object_id = obj_ref.id,
1079                expected_generation = obj_ref.generation,
1080                found_object_id = obj_num,
1081                found_generation = gen_num,
1082                "PDF object header does not match requested reference"
1083            );
1084            return Err(Error::ParseError {
1085                offset: offset as usize,
1086                reason: format!(
1087                    "Object reference mismatch: expected {} {} obj, found {} {} obj",
1088                    obj_ref.id, obj_ref.generation, obj_num, gen_num
1089                ),
1090            });
1091        }
1092
1093        // Check if there's content after "obj" on the same line
1094        // Some PDFs have "N G obj\n<<..." while others have "N G obj<<..." on one line ~keep
1095        let mut data = Vec::new();
1096
1097        if let Some(obj_keyword_pos) = header_bytes.windows(3).position(|w| w == b"obj") {
1098            let after_obj_pos = obj_keyword_pos + 3;
1099
1100            let mut content_start = after_obj_pos;
1101            while content_start < header_bytes.len()
1102                && (header_bytes[content_start] == b' '
1103                    || header_bytes[content_start] == b'\t'
1104                    || header_bytes[content_start] == b'\r')
1105            {
1106                content_start += 1;
1107            }
1108
1109            // If there's a newline, skip it (normal case: "N G obj\n")
1110            // If there's content (like "<<"), include it (malformed case: "N G obj<<...") ~keep
1111            if content_start < header_bytes.len() && header_bytes[content_start] != b'\n' {
1112                data.extend_from_slice(&header_bytes[content_start..]);
1113                tracing::trace!(target: LOG_TARGET,
1114                    "Object {} has content after 'obj' on header line ({} bytes)",
1115                    obj_ref.id,
1116                    header_bytes.len() - content_start
1117                );
1118            }
1119        }
1120
1121        // Body, read from the same cursor the header left positioned.
1122        // Use byte limit instead of line count — large uncompressed streams can have
1123        // hundreds of thousands of short lines (e.g., vector path drawing commands). ~keep
1124        const MAX_BYTES: usize = 100 * 1024 * 1024; // 100 MB safety limit ~keep
1125
1126        {
1127            let reader = &mut cursor;
1128            loop {
1129                let mut chunk = Vec::new();
1130                let bytes_read = reader.read_until(b'\n', &mut chunk)?;
1131
1132                if data.len() > MAX_BYTES {
1133                    tracing::warn!(target: LOG_TARGET,
1134                        "Object {} exceeded maximum byte limit ({} bytes), truncating",
1135                        obj_ref.id,
1136                        MAX_BYTES
1137                    );
1138                    break;
1139                }
1140
1141                if bytes_read == 0 {
1142                    let msg = format!(
1143                        "Unexpected EOF while reading object {} (no endobj found after {} bytes)",
1144                        obj_ref.id,
1145                        data.len()
1146                    );
1147                    tracing::warn!(target: LOG_TARGET, "{}", msg);
1148                    // structured-warnings sink. ~keep
1149                    self.push_structured_warning(crate::extractors::warnings::Warning {
1150                        category: crate::extractors::warnings::WarningCategory::EofPremature,
1151                        page: None,
1152                        message: msg,
1153                        spec_section: Some("7.5"),
1154                    });
1155                    // Don't fail - try to parse what we have ~keep
1156                    break;
1157                }
1158
1159                if chunk.contains(&b'e')
1160                    && let Some(endobj_pos) = find_substring(&chunk, b"endobj")
1161                {
1162                    data.extend_from_slice(&chunk[..endobj_pos]);
1163                    break;
1164                }
1165
1166                data.extend_from_slice(&chunk);
1167            }
1168        }
1169
1170        tracing::trace!(target: LOG_TARGET,
1171            "About to parse object {} gen {} ({} bytes)",
1172            obj_ref.id,
1173            obj_ref.generation,
1174            data.len()
1175        );
1176
1177        let mut obj = match parse_object(&data) {
1178            Ok((_, parsed_obj)) => parsed_obj,
1179            Err(e) => {
1180                let error_kind = match &e {
1181                    nom::Err::Incomplete(_) => "Incomplete data",
1182                    nom::Err::Error(err) | nom::Err::Failure(err) => match err.code {
1183                        nom::error::ErrorKind::Eof => "Unexpected EOF",
1184                        nom::error::ErrorKind::Tag => "Expected tag not found",
1185                        nom::error::ErrorKind::Fail => "Parse failed",
1186                        _ => "Parse error",
1187                    },
1188                };
1189                tracing::warn!(target: LOG_TARGET,
1190                    "Object {} at offset {} is corrupted ({}), using Null placeholder. \
1191                     This may result in missing content from the PDF.",
1192                    obj_ref.id,
1193                    offset,
1194                    error_kind
1195                );
1196                Object::Null
1197            }
1198        };
1199
1200        let is_encrypt_dict = *self.encrypt_dict_ref.lock_or_recover() == Some(obj_ref);
1201        if !is_encrypt_dict {
1202            let handler_guard = self.encryption_handler.lock_or_recover();
1203            if let Some(handler) = handler_guard.as_ref()
1204                && handler.is_authenticated()
1205            {
1206                Self::decrypt_strings_in_object(handler, &mut obj, obj_ref.id, obj_ref.generation as u32);
1207            }
1208        }
1209
1210        self.object_cache.lock_or_recover().insert(obj_ref, obj.clone());
1211
1212        Ok(obj)
1213    }
1214
1215    /// Load a compressed object from an object stream (Type 2 xref entry).
1216    ///
1217    /// # Arguments
1218    ///
1219    /// * `obj_ref` - The object reference being loaded
1220    /// * `stream_obj_num` - The object number of the object stream
1221    /// * `index_in_stream` - The index within the stream (unused but provided for completeness)
1222    pub(super) fn load_compressed_object(
1223        &self,
1224        obj_ref: ObjectRef,
1225        stream_obj_num: u32,
1226        _index_in_stream: u16,
1227    ) -> Result<Object> {
1228        tracing::trace!(target: LOG_TARGET,
1229            object_id = obj_ref.id,
1230            stream_object_id = stream_obj_num,
1231            "loading compressed object from object stream"
1232        );
1233
1234        if let Err(error) = self.ensure_encryption_initialized() {
1235            trace_recoverable_pdf_error("initialize_object_stream_encryption", &error);
1236        }
1237
1238        let Some((objects_map, newly_parsed)) = self.load_object_stream_objects(stream_obj_num)? else {
1239            self.object_cache.lock_or_recover().insert(obj_ref, Object::Null);
1240            return Ok(Object::Null);
1241        };
1242
1243        let obj = match objects_map.get(&obj_ref.id) {
1244            Some(o) => o.clone(),
1245            None => {
1246                tracing::warn!(
1247                    target: crate::LOG_TARGET_ROOT,
1248                    operation = "load_compressed_object",
1249                    error_code = "missing_stream_object",
1250                    object_id = obj_ref.id,
1251                    stream_object_id = stream_obj_num,
1252                    "treating missing compressed object as null"
1253                );
1254                Object::Null
1255            }
1256        };
1257
1258        if newly_parsed {
1259            self.cache_object_stream_entries(stream_obj_num, &objects_map);
1260        }
1261        Ok(obj)
1262    }
1263
1264    fn load_object_stream_objects(&self, stream_obj_num: u32) -> Result<Option<(Arc<HashMap<u32, Object>>, bool)>> {
1265        use crate::objstm::parse_object_stream_with_decryption_outcome;
1266
1267        let stream_ref = ObjectRef::new(stream_obj_num, 0);
1268        if let Some(cached) = self.object_stream_cache.lock_or_recover().get(&stream_ref).cloned() {
1269            return Ok(Some((cached, false)));
1270        }
1271        let Some(stream_entry) = self.xref.get(stream_obj_num) else {
1272            tracing::warn!(
1273                target: crate::LOG_TARGET_ROOT,
1274                operation = "load_object_stream",
1275                error_code = "missing_xref_entry",
1276                stream_object_id = stream_obj_num,
1277                "treating compressed object as null"
1278            );
1279            return Ok(None);
1280        };
1281        if stream_entry.entry_type != crate::xref::XRefEntryType::Uncompressed {
1282            return Err(Error::InvalidPdf(format!(
1283                "object stream {} is not an uncompressed object",
1284                stream_obj_num
1285            )));
1286        }
1287        let stream_obj = self.load_uncompressed_object(stream_ref, stream_entry.offset)?;
1288        let handler_ref = self.encryption_handler.lock_or_recover();
1289        let outcome = if let Some(handler) = handler_ref.as_ref() {
1290            match parse_object_stream_with_decryption_outcome(&stream_obj, None, 0, 0) {
1291                Ok(outcome) => outcome,
1292                Err(_) => {
1293                    let decrypt_fn =
1294                        |data: &[u8]| -> Result<Vec<u8>> { handler.decrypt_stream(data, stream_obj_num, 0) };
1295                    parse_object_stream_with_decryption_outcome(&stream_obj, Some(&decrypt_fn), stream_obj_num, 0)?
1296                }
1297            }
1298        } else {
1299            parse_object_stream_with_decryption_outcome(&stream_obj, None, 0, 0)?
1300        };
1301        drop(handler_ref);
1302        self.trace_object_stream_recovery_once(stream_obj_num, &outcome);
1303        let parsed = Arc::new(outcome.objects);
1304        self.object_stream_cache
1305            .lock_or_recover()
1306            .insert(stream_ref, Arc::clone(&parsed));
1307        Ok(Some((parsed, true)))
1308    }
1309
1310    pub(super) fn trace_object_stream_recovery_once(
1311        &self,
1312        stream_obj_num: u32,
1313        outcome: &crate::objstm::ObjectStreamParseOutcome,
1314    ) {
1315        if !outcome.has_recovery() {
1316            return;
1317        }
1318        if self
1319            .object_stream_telemetry_seen
1320            .lock_or_recover()
1321            .should_emit(stream_obj_num)
1322        {
1323            outcome.trace_recovery();
1324        }
1325    }
1326
1327    fn cache_object_stream_entries(&self, stream_obj_num: u32, objects_map: &HashMap<u32, Object>) {
1328        for (obj_num, object) in objects_map {
1329            let cache_ref = ObjectRef::new(*obj_num, 0);
1330            let should_cache = if let Some(entry) = self.xref.get(*obj_num) {
1331                entry.entry_type == crate::xref::XRefEntryType::Compressed && entry.offset == stream_obj_num as u64
1332            } else {
1333                true
1334            };
1335            if should_cache {
1336                self.object_cache.lock_or_recover().insert(cache_ref, object.clone());
1337            } else {
1338                tracing::trace!(target: LOG_TARGET,
1339                    object_id = obj_num,
1340                    stream_object_id = stream_obj_num,
1341                    "not caching object from stream — xref points elsewhere"
1342                );
1343            }
1344        }
1345    }
1346
1347    /// Find object header by searching backwards from a given offset.
1348    ///
1349    /// Some PDF generators create xref tables with incorrect offsets that point
1350    /// to the object body instead of the header. This function searches backwards
1351    /// from the xref offset to find the actual "N G obj" header.
1352    ///
1353    /// We search up to 100 bytes backwards, looking for a line that matches
1354    /// the expected object header format.
1355    fn find_object_header_backwards(&self, obj_ref: ObjectRef, wrong_offset: u64) -> Result<u64> {
1356        if wrong_offset == 0 {
1357            return Err(Error::ParseError {
1358                offset: wrong_offset as usize,
1359                reason: "Cannot search backwards from offset 0".to_string(),
1360            });
1361        }
1362
1363        let search_distance = std::cmp::min(100, wrong_offset);
1364        let search_start = wrong_offset - search_distance;
1365
1366        let buffer = self
1367            .bytes_at(search_start, search_distance as usize + 100)
1368            .unwrap_or_default();
1369        let bytes_read = buffer.len();
1370
1371        if bytes_read == 0 {
1372            return Err(Error::ParseError {
1373                offset: wrong_offset as usize,
1374                reason: "Could not read backwards search region".to_string(),
1375            });
1376        }
1377
1378        // Build the expected header pattern as bytes (NOT string to avoid UTF-8 corruption) ~keep
1379        let expected_header = format!("{} {} obj", obj_ref.id, obj_ref.generation);
1380        let pattern_bytes = expected_header.as_bytes();
1381
1382        // Search for the byte pattern directly (avoids UTF-8 conversion issues with binary data)
1383        // Find the match closest to wrong_offset (prefer before, but allow small offsets after)
1384        // ~keep
1385        let mut best_match: Option<(usize, i64)> = None;
1386
1387        for (i, window) in buffer[..bytes_read].windows(pattern_bytes.len()).enumerate() {
1388            if window == pattern_bytes {
1389                let candidate_offset = search_start + i as u64;
1390                let distance = (candidate_offset as i64) - (wrong_offset as i64);
1391
1392                // Accept matches within -100 to +10 bytes of wrong_offset
1393                // (xref might be slightly off by a few bytes) ~keep
1394                if (-100..=10).contains(&distance) {
1395                    let is_better = best_match
1396                        .as_ref()
1397                        .is_none_or(|(_, best_dist)| distance.abs() < best_dist.abs());
1398
1399                    if is_better {
1400                        best_match = Some((i, distance));
1401                    }
1402                }
1403            }
1404        }
1405
1406        if let Some((pos, distance)) = best_match {
1407            let absolute_offset = search_start + pos as u64;
1408            tracing::warn!(target: LOG_TARGET,
1409                "Found object header '{}' at offset {} ({:+} bytes from xref at {})",
1410                expected_header,
1411                absolute_offset,
1412                distance,
1413                wrong_offset
1414            );
1415            return Ok(absolute_offset);
1416        }
1417
1418        // Try with whitespace variations (space, double-space, tab between obj_id and gen) ~keep
1419        let patterns = [
1420            format!("{} {} obj", obj_ref.id, obj_ref.generation).into_bytes(),
1421            format!("{}  {} obj", obj_ref.id, obj_ref.generation).into_bytes(),
1422            format!("{}\t{} obj", obj_ref.id, obj_ref.generation).into_bytes(),
1423            format!("{} {}\tobj", obj_ref.id, obj_ref.generation).into_bytes(),
1424        ];
1425
1426        for pattern in &patterns {
1427            let mut best_match: Option<(usize, i64)> = None;
1428
1429            for (i, window) in buffer[..bytes_read].windows(pattern.len()).enumerate() {
1430                if window == pattern.as_slice() {
1431                    let candidate_offset = search_start + i as u64;
1432                    let distance = (candidate_offset as i64) - (wrong_offset as i64);
1433
1434                    if (-100..=10).contains(&distance) {
1435                        let is_better = best_match
1436                            .as_ref()
1437                            .is_none_or(|(_, best_dist)| distance.abs() < best_dist.abs());
1438
1439                        if is_better {
1440                            best_match = Some((i, distance));
1441                        }
1442                    }
1443                }
1444            }
1445
1446            if let Some((pos, distance)) = best_match {
1447                let absolute_offset = search_start + pos as u64;
1448                tracing::warn!(target: LOG_TARGET,
1449                    "Found object header '{}' at offset {} ({:+} bytes, pattern match)",
1450                    expected_header,
1451                    absolute_offset,
1452                    distance
1453                );
1454                return Ok(absolute_offset);
1455            }
1456        }
1457
1458        Err(Error::ParseError {
1459            offset: wrong_offset as usize,
1460            reason: format!(
1461                "Could not find object header '{}' within {} bytes before offset",
1462                expected_header, search_distance
1463            ),
1464        })
1465    }
1466}