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}