Skip to main content

xberg_native_pdf/document/
catalog.rs

1//! Document catalog, structure tree, and output intents.
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 document catalog (root object).
11    ///
12    /// The catalog is the root of the document's object hierarchy.
13    /// It contains references to the page tree, outlines, etc.
14    ///
15    /// # Errors
16    ///
17    /// Returns an error if:
18    /// - The /Root entry is present but is not a reference
19    /// - Loading the catalog object fails
20    /// - The trailer omits /Root **and** no `/Type /Catalog` object can be
21    ///   found by scanning (the recovery path: a missing /Root is
22    ///   not itself fatal — the Catalog is discovered by object scan, as
23    ///   Poppler / PDFium do — but it does error if that scan also fails)
24    ///
25    /// # Example
26    ///
27    /// ```no_run
28    /// # use xberg_native_pdf::document::PdfDocument;
29    /// # let mut doc = PdfDocument::open("sample.pdf")?;
30    /// let catalog = doc.catalog()?;
31    /// # Ok::<(), xberg_native_pdf::error::Error>(())
32    /// ```
33    pub fn catalog(&self) -> Result<Object> {
34        let trailer_dict = self
35            .trailer
36            .as_dict()
37            .ok_or_else(|| Error::InvalidPdf("Trailer is not a dictionary".to_string()))?;
38
39        if let Some(root_obj) = trailer_dict.get("Root") {
40            let root_ref = root_obj
41                .as_reference()
42                .ok_or_else(|| Error::InvalidPdf("/Root is not a reference".to_string()))?;
43            return self.load_object(root_ref);
44        }
45
46        // The trailer omits /Root. A Linearized file's sparse end-of-file
47        // trailer legitimately does this; discover the Catalog
48        // by scanning indirect objects for /Type /Catalog, as Poppler /
49        // PDFium do. ~keep
50        self.find_catalog_by_scan().ok_or_else(|| {
51            Error::InvalidPdf("Trailer omits /Root and no /Type /Catalog object could be found by scanning".to_string())
52        })
53    }
54
55    /// Scan indirect objects for the document Catalog (`/Type /Catalog`).
56    ///
57    /// Used only as a fallback when the trailer omits `/Root`.
58    /// Bounded so a pathological xref can't turn this into an unbounded
59    /// scan; the Catalog is virtually always one of the first objects.
60    ///
61    /// The smallest `MAX_SCAN` object numbers are scanned, ascending.
62    /// `all_object_numbers()` is `HashMap`-backed, so iterating it directly
63    /// would be nondeterministic — a bounded scan over an arbitrary subset
64    /// can miss the Catalog on different runs. `smallest_object_numbers`
65    /// makes discovery deterministic, scans low-numbered objects first
66    /// (where the Catalog conventionally lives), and bounds the candidate
67    /// set *before* sorting so a pathological xref stays O(n log MAX_SCAN).
68    fn find_catalog_by_scan(&self) -> Option<Object> {
69        const MAX_SCAN: usize = 4096;
70        let nums = self.xref.smallest_object_numbers(MAX_SCAN);
71        let mut checked = 0usize;
72        for num in nums {
73            if checked >= MAX_SCAN {
74                break;
75            }
76            let generation = match self.xref.get(num) {
77                Some(e) if e.in_use => e.generation,
78                _ => continue,
79            };
80            checked += 1;
81            if let Ok(obj) = self.load_object(ObjectRef::new(num, generation))
82                && obj.as_dict().and_then(|d| d.get("Type")).and_then(|t| t.as_name()) == Some("Catalog")
83            {
84                tracing::warn!(target: LOG_TARGET, "Catalog discovered by object scan: {} {} obj", num, generation);
85                return Some(obj);
86            }
87        }
88        None
89    }
90
91    /// Get the structure tree (logical structure) of the document.
92    ///
93    /// Tagged PDFs contain a structure tree that defines the logical structure
94    /// and reading order of the document. This is the PDF-spec-compliant way
95    /// to determine reading order.
96    ///
97    /// Returns `Ok(Some(StructTreeRoot))` if the document has a structure tree,
98    /// `Ok(None)` if it's not a tagged PDF, or an error if parsing fails.
99    ///
100    /// # Example
101    ///
102    /// ```no_run
103    /// # use xberg_native_pdf::document::PdfDocument;
104    /// # let mut doc = PdfDocument::open("sample.pdf")?;
105    /// if let Some(struct_tree) = doc.structure_tree()? {
106    ///     println!("This is a Tagged PDF with logical structure");
107    /// } else {
108    ///     println!("This PDF does not have a structure tree");
109    /// }
110    /// # Ok::<(), xberg_native_pdf::error::Error>(())
111    /// ```
112    pub fn structure_tree(&self) -> Result<Option<crate::structure::StructTreeRoot>> {
113        crate::structure::parse_structure_tree(self)
114    }
115
116    /// Returns the document's structure tree, bounding the parse work by an
117    /// optional wall-clock `budget`.
118    ///
119    /// `None` parses the complete tree (identical to [`Self::structure_tree`]);
120    /// `Some(duration)` returns `Ok(None)` if parsing exceeds that budget, so a
121    /// latency-sensitive caller can fall back to another strategy. Prefer `None`
122    /// unless you have a concrete responsiveness requirement.
123    pub fn structure_tree_with_budget(
124        &self,
125        budget: Option<std::time::Duration>,
126    ) -> Result<Option<crate::structure::StructTreeRoot>> {
127        crate::structure::parse_structure_tree_with_budget(self, budget)
128    }
129
130    /// Returns the document's structure tree **only when it is trustworthy for
131    /// reading-order purposes**, per ISO 32000-1:2008 §14.8.2.3.1 and §14.7.1.
132    ///
133    /// A `/StructTreeRoot` encodes the producer's *logical structure order* — a
134    /// depth-first traversal of the tag hierarchy — which is authoritative for
135    /// reading order independent of glyph geometry (§14.7.1). It is trusted when
136    /// the document is `/Marked` (Tagged PDF) **or** the catalog directly
137    /// references a `/StructTreeRoot` (PDF 1.3/1.4 tagged files predate the
138    /// `/MarkInfo` dictionary; §7.7.2) — matching the historical gate so output
139    /// for non-suspect documents is byte-for-byte unchanged — **and**
140    /// `/MarkInfo /Suspects` is not `true`. A `true` `/Suspects` flag is the
141    /// spec-sanctioned signal (the `/TagSuspect /Ordering` mechanism,
142    /// §14.8.2.3.1) that page content order may not match logical structure
143    /// order, so the tree is rejected and callers fall back to geometric order.
144    ///
145    /// Shares `structure_tree_cache`, so this costs a single cached parse.
146    pub(crate) fn struct_tree_trustworthy(&self) -> Option<Arc<crate::structure::StructTreeRoot>> {
147        let mark = self.mark_info().unwrap_or_default();
148        // Suspect documents: geometric reading order is spec-correct
149        // (§14.8.2.3.1). This is the only behavioural change versus the legacy
150        // inline gate, which never consulted /Suspects. ~keep
151        if mark.suspects {
152            return None;
153        }
154        let cached = self.structure_tree_cache.lock_or_recover().clone();
155        match cached {
156            Some(tree) => tree,
157            None => {
158                let has_struct_tree_root = self
159                    .catalog()
160                    .ok()
161                    .and_then(|cat| cat.as_dict().map(|d| d.contains_key("StructTreeRoot")))
162                    .unwrap_or(false);
163                let tree = if mark.marked || has_struct_tree_root {
164                    self.structure_tree().ok().flatten().map(Arc::new)
165                } else {
166                    None
167                };
168                *self.structure_tree_cache.lock_or_recover() = Some(tree.clone());
169                tree
170            }
171        }
172    }
173
174    /// Returns the document's structure tree whenever it is **available**,
175    /// independent of `/MarkInfo /Suspects`.
176    ///
177    /// The `/Suspects` flag (§14.7.1) signals that the producer's *reading
178    /// order* may be unreliable, so `struct_tree_trustworthy` rejects the
179    /// tree for ordering. `/ActualText`, however, is content replacement
180    /// (§14.9.4) and remains trustworthy: a producer that bothered to
181    /// supply the replacement text for a glyph run is asserting what
182    /// that run is *meant* to read as, regardless of whether sibling
183    /// reading-order tags are reliable. This accessor lets the
184    /// ActualText pipeline honour the producer's intent on Suspects=true
185    /// documents while geometric reading order takes over the ordering
186    /// problem.
187    ///
188    /// Shares `structure_tree_cache` with `struct_tree_trustworthy`, so
189    /// both predicates cost a single cached parse.
190    pub(crate) fn struct_tree_marked(&self) -> Option<Arc<crate::structure::StructTreeRoot>> {
191        let cached = self.structure_tree_cache.lock_or_recover().clone();
192        match cached {
193            Some(tree) => tree,
194            None => {
195                let mark = self.mark_info().unwrap_or_default();
196                let has_struct_tree_root = self
197                    .catalog()
198                    .ok()
199                    .and_then(|cat| cat.as_dict().map(|d| d.contains_key("StructTreeRoot")))
200                    .unwrap_or(false);
201                let tree = if mark.marked || has_struct_tree_root {
202                    self.structure_tree().ok().flatten().map(Arc::new)
203                } else {
204                    None
205                };
206                *self.structure_tree_cache.lock_or_recover() = Some(tree.clone());
207                tree
208            }
209        }
210    }
211
212    /// Returns the cached [`ActualTextIndex`] for this document.
213    ///
214    /// Builds the index lazily on first call, then serves cached copies.
215    /// Returns `None` for untagged documents and for tagged documents
216    /// whose structure tree carries no `/ActualText`.
217    ///
218    /// Decoupled from `/MarkInfo /Suspects` — see [`struct_tree_marked`].
219    pub(crate) fn actualtext_index(&self) -> Option<Arc<crate::structure::ActualTextIndex>> {
220        if let Some(cached) = self.actualtext_index_cache.lock_or_recover().clone() {
221            return cached;
222        }
223        let tree = self.struct_tree_marked();
224        let built = tree.and_then(|t| {
225            let idx = crate::structure::traversal::build_actualtext_index(&t);
226            if idx.is_empty() { None } else { Some(Arc::new(idx)) }
227        });
228        *self.actualtext_index_cache.lock_or_recover() = Some(built.clone());
229        built
230    }
231
232    /// Whether text extraction uses the Tagged-PDF *logical structure order* (a
233    /// depth-first traversal of `/StructTreeRoot`) rather than geometric
234    /// page-content order for this document.
235    ///
236    /// Returns `true` exactly when the document carries a trustworthy structure
237    /// tree per ISO 32000-1:2008 §14.8.2.3.1 / §14.7.1: it is `/Marked` or the
238    /// catalog references a `/StructTreeRoot`, the tree resolves non-empty, and
239    /// `/MarkInfo /Suspects` is not `true`. When `false`, extraction falls back
240    /// to geometric reading order. This is a read-only introspection accessor;
241    /// it does not change extraction behaviour.
242    pub fn prefers_structure_reading_order(&self) -> bool {
243        self.struct_tree_trustworthy().is_some()
244    }
245
246    /// Find the document's default CMYK output-intent profile.
247    ///
248    /// Per ISO 32000-1:2008 §14.11.5, an `/OutputIntents` array in the
249    /// catalog advertises the colour characteristics of the target
250    /// output device. Each entry is a dictionary; the `DestOutputProfile`
251    /// key (when present) references an ICC profile stream identifying
252    /// the intended press / display calibration.
253    ///
254    /// This method returns the **first CMYK** `DestOutputProfile` it
255    /// finds (N = 4) — the usual match for "here is how my CMYK ink
256    /// should look" on PDF/X files. Callers can use it as a fallback
257    /// profile for plain `/DeviceCMYK` images that lack their own ICC
258    /// colour space.
259    ///
260    /// Returns `None` when no output intent exists, no CMYK entry is
261    /// present, or the profile stream can't be parsed as ICC.
262    pub fn output_intent_cmyk_profile(&self) -> Option<std::sync::Arc<crate::color::IccProfile>> {
263        // Memoise the (potentially expensive) decode + parse: hot rendering
264        // paths consult this accessor once per paint, and qcms / lcms2
265        // header validation + LUT decode on a hundreds-of-KB profile is
266        // not free. `Some(None)` means "checked once, no usable CMYK
267        // OutputIntent"; a subsequent call must NOT re-walk the catalog. ~keep
268        if let Some(cached) = self.output_intent_cmyk_profile_cache.lock_or_recover().as_ref() {
269            return cached.clone();
270        }
271        let resolved = self.compute_output_intent_cmyk_profile();
272        *self.output_intent_cmyk_profile_cache.lock_or_recover() = Some(resolved.clone());
273        resolved
274    }
275
276    /// True when the document catalog declares an `/OutputIntents`
277    /// array, regardless of whether the contained profile bytes
278    /// successfully parse. Coupled with
279    /// [`Self::output_intent_cmyk_profile`] returning `None`, this
280    /// distinguishes "no OutputIntent requested" (acceptable silent
281    /// fallback) from "OutputIntent requested but unusable" (degraded
282    /// press output that callers should warn about). Tracks upstream
283    /// issue upstream issue #712 on swallowed profile-parse
284    /// diagnostics.
285    pub fn has_output_intents_declaration(&self) -> bool {
286        let Ok(catalog) = self.catalog() else {
287            return false;
288        };
289        let Some(cat_dict) = catalog.as_dict() else {
290            return false;
291        };
292        let Some(intents_obj) = cat_dict.get("OutputIntents") else {
293            return false;
294        };
295        let intents_obj = match intents_obj {
296            Object::Reference(r) => match self.load_object(*r) {
297                Ok(o) => o,
298                Err(_) => return false,
299            },
300            other => other.clone(),
301        };
302        matches!(intents_obj, Object::Array(_))
303    }
304
305    fn compute_output_intent_cmyk_profile(&self) -> Option<std::sync::Arc<crate::color::IccProfile>> {
306        let catalog = self.catalog().ok()?;
307        let cat_dict = catalog.as_dict()?;
308
309        let intents_obj = cat_dict.get("OutputIntents")?;
310        let intents_obj = match intents_obj {
311            Object::Reference(r) => self.load_object(*r).ok()?,
312            _ => intents_obj.clone(),
313        };
314        let intents_arr = match &intents_obj {
315            Object::Array(a) => a.clone(),
316            _ => return None,
317        };
318
319        for entry in intents_arr {
320            let entry = match entry {
321                // Skip a broken entry rather than aborting the whole array (§7.3.10). ~keep
322                Object::Reference(r) => match self.load_object(r) {
323                    Ok(o) => o,
324                    Err(error) => {
325                        crate::error::trace_recovery("load_output_intent_entry", &error);
326                        continue;
327                    }
328                },
329                other => other,
330            };
331            let entry_dict = match entry.as_dict() {
332                Some(d) => d.clone(),
333                None => continue,
334            };
335            let profile_obj = match entry_dict.get("DestOutputProfile") {
336                Some(p) => p.clone(),
337                None => continue,
338            };
339            let profile_stream = match profile_obj {
340                Object::Reference(r) => match self.load_object(r) {
341                    Ok(o) => o,
342                    Err(error) => {
343                        crate::error::trace_recovery("load_output_intent_profile", &error);
344                        continue;
345                    }
346                },
347                other => other,
348            };
349
350            let Object::Stream { dict, .. } = &profile_stream else {
351                continue;
352            };
353            let n = match dict.get("N").and_then(|o| o.as_integer()) {
354                Some(4) => 4u8, // only CMYK; ignore RGB/Gray output intents here ~keep
355                _ => continue,
356            };
357            let bytes = match profile_stream.decode_stream_data() {
358                Ok(b) => b,
359                Err(error) => {
360                    crate::error::trace_recovery("decode_output_intent_profile", &error);
361                    continue;
362                }
363            };
364            match crate::color::IccProfile::parse(bytes, n) {
365                Some(prof) => return Some(std::sync::Arc::new(prof)),
366                None => tracing::warn!(
367                    target: crate::LOG_TARGET_ROOT,
368                    operation = "parse_output_intent_profile",
369                    error_code = "invalid_icc_profile",
370                    "skipping invalid CMYK output profile"
371                ),
372            }
373        }
374        None
375    }
376
377    /// Get the MarkInfo dictionary from the document catalog.
378    ///
379    /// The MarkInfo dictionary indicates whether the document conforms to
380    /// Tagged PDF conventions and whether the structure tree might contain
381    /// suspect (unreliable) content.
382    ///
383    /// Per ISO 32000-1:2008 Section 14.7.1, the MarkInfo dictionary contains:
384    /// - `/Marked` - Whether the document conforms to Tagged PDF conventions
385    /// - `/Suspects` - Whether the document contains suspect content
386    /// - `/UserProperties` - Whether the document uses user properties
387    ///
388    /// # Returns
389    ///
390    /// Returns `MarkInfo` with the parsed values, or default values if
391    /// the MarkInfo dictionary is not present.
392    ///
393    /// # Example
394    ///
395    /// ```no_run
396    /// # use xberg_native_pdf::document::PdfDocument;
397    /// # let mut doc = PdfDocument::open("sample.pdf")?;
398    /// let mark_info = doc.mark_info()?;
399    /// if mark_info.is_structure_reliable() {
400    ///     println!("Structure tree can be trusted for reading order");
401    /// } else if mark_info.suspects {
402    ///     println!("Structure tree may contain unreliable content");
403    /// }
404    /// # Ok::<(), xberg_native_pdf::error::Error>(())
405    /// ```
406    pub fn mark_info(&self) -> Result<crate::structure::MarkInfo> {
407        let catalog = self.catalog()?;
408        let catalog_dict = match catalog.as_dict() {
409            Some(d) => d,
410            None => return Ok(crate::structure::MarkInfo::default()),
411        };
412
413        let mark_info_obj = match catalog_dict.get("MarkInfo") {
414            Some(obj) => obj,
415            None => return Ok(crate::structure::MarkInfo::default()),
416        };
417
418        let mark_info_obj = if let Some(r) = mark_info_obj.as_reference() {
419            self.load_object(r)?
420        } else {
421            mark_info_obj.clone()
422        };
423
424        let mark_info_dict = match mark_info_obj.as_dict() {
425            Some(d) => d,
426            None => return Ok(crate::structure::MarkInfo::default()),
427        };
428
429        let marked = mark_info_dict
430            .get("Marked")
431            .and_then(|o: &crate::object::Object| o.as_bool())
432            .unwrap_or(false);
433
434        let suspects = mark_info_dict
435            .get("Suspects")
436            .and_then(|o: &crate::object::Object| o.as_bool())
437            .unwrap_or(false);
438
439        let user_properties = mark_info_dict
440            .get("UserProperties")
441            .and_then(|o: &crate::object::Object| o.as_bool())
442            .unwrap_or(false);
443
444        Ok(crate::structure::MarkInfo {
445            marked,
446            suspects,
447            user_properties,
448        })
449    }
450}