xberg_native_pdf/document/mod.rs
1//! PDF document model.
2
3use crate::encryption::EncryptionHandler;
4use crate::error::{Error, Result};
5use crate::layout::TextSpan;
6use crate::object::{Object, ObjectRef};
7use crate::parser::parse_object;
8use crate::parser_config::ParserOptions;
9use crate::xref::{CrossRefTable, find_xref_offset, parse_xref};
10use std::cell::RefCell;
11use std::collections::HashMap;
12use std::collections::HashSet;
13use std::io::{BufRead, BufReader, Cursor, Read, Seek, SeekFrom};
14#[cfg(not(target_arch = "wasm32"))]
15use std::path::Path;
16use std::sync::Arc;
17use std::sync::Mutex;
18use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
19
20pub(crate) use crate::cache::MutexExt;
21
22/// Reading order mode for span extraction.
23///
24/// Controls how text spans are sorted after extraction from a PDF page.
25/// The default `TopToBottom` mode uses simple geometric sorting, while
26/// `ColumnAware` uses the XY-Cut algorithm to detect columns and read
27/// each column top-to-bottom before moving to the next.
28#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
29pub enum ReadingOrder {
30 /// Simple top-to-bottom, left-to-right ordering.
31 ///
32 /// Sorts spans by Y-coordinate descending (top of page first),
33 /// then by X-coordinate ascending (left to right).
34 #[default]
35 TopToBottom,
36 /// Column-aware ordering using the XY-Cut algorithm.
37 ///
38 /// Detects columns via projection-profile analysis and reads each
39 /// column fully (top-to-bottom) before moving to the next column.
40 /// Best for newspapers, academic papers, and multi-column layouts.
41 ColumnAware,
42 /// Logical-structure ordering from the document's `/StructTreeRoot`.
43 ///
44 /// For a Tagged PDF, ISO 32000-1:2008 §14.8.2.3 makes a pre-order traversal
45 /// of the structure hierarchy AUTHORITATIVE for reading order - it is the
46 /// producer's declared sequence, independent of glyph geometry, so it reads
47 /// tables and complex layouts correctly where a geometric XY-cut guesses.
48 ///
49 /// Spans are ordered by their marked-content id (`/MCID`) following that
50 /// traversal; any span without a matching MCID is appended in geometric
51 /// (`ColumnAware`) order. When the structure tree is absent or not
52 /// trustworthy for ordering (untagged, or `/Suspects true`), this falls back
53 /// to `ColumnAware` entirely, so it is always safe to request.
54 Structure,
55}
56
57/// Distinguishes the three cases `get_page_rotation` folds into `0`.
58#[derive(Debug, Clone, Copy, PartialEq, Eq)]
59pub enum PageRotation {
60 /// No `/Rotate` entry. ISO 32000-1 §7.7.3.3 default of 0 applies.
61 Absent,
62 /// A spec-valid multiple of 90, normalised to 0, 90, 180 or 270.
63 Valid(i32),
64 /// A `/Rotate` entry that is present but not a spec-valid multiple of 90.
65 Malformed,
66}
67
68/// In-memory reader used by `open()` and `from_bytes()`. Wrapping in an enum
69/// is kept (rather than using `BufReader<Cursor<Vec<u8>>>` directly) so a
70/// future file-backed variant can be re-introduced without touching call
71/// sites.
72enum PdfReader {
73 Memory(BufReader<Cursor<Vec<u8>>>),
74}
75
76impl Read for PdfReader {
77 fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
78 match self {
79 PdfReader::Memory(r) => r.read(buf),
80 }
81 }
82}
83
84impl Seek for PdfReader {
85 fn seek(&mut self, pos: SeekFrom) -> std::io::Result<u64> {
86 match self {
87 PdfReader::Memory(r) => r.seek(pos),
88 }
89 }
90}
91
92impl BufRead for PdfReader {
93 fn fill_buf(&mut self) -> std::io::Result<&[u8]> {
94 match self {
95 PdfReader::Memory(r) => r.fill_buf(),
96 }
97 }
98
99 fn consume(&mut self, amt: usize) {
100 match self {
101 PdfReader::Memory(r) => r.consume(amt),
102 }
103 }
104}
105
106/// Maximum recursion depth for object resolution
107const MAX_RECURSION_DEPTH: u32 = 100;
108
109/// Maximum depth of a `/Kids` chain any page-tree walker will descend.
110///
111/// Every page-tree walk in this crate is recursive, so depth is stack frames, and
112/// an uncapped chain overflows the stack — which aborts the process rather than
113/// raising a catchable panic (GH#1755). A conforming page tree is balanced, so at
114/// the minimum useful fan-out of 2 a depth of 128 already addresses 2^128 pages:
115/// more than `/Count`'s 32-bit range can even express, let alone any real
116/// document. Workspace feature unification makes the debug-build
117/// `get_page_from_tree_inner` frame large enough that a 256-node chain exhausts
118/// Rust's 2 MiB test-thread stack, so the cap is 128 to retain headroom in every
119/// supported feature combination. Raising it would mean rewriting these walkers
120/// iteratively with an explicit stack; the obstacle is
121/// `get_page_from_tree_inner`, which merges inherited attributes along the path
122/// as it descends. ~keep
123const MAX_PAGE_TREE_DEPTH: u32 = 128;
124
125/// Page information for rendering.
126#[derive(Debug, Clone)]
127pub struct PageInfo {
128 /// Media box defining the page boundaries
129 pub media_box: crate::geometry::Rect,
130 /// Crop box if specified (for visible area)
131 pub crop_box: Option<crate::geometry::Rect>,
132 /// Page rotation in degrees (0, 90, 180, 270)
133 pub rotation: i32,
134}
135
136/// Default maximum size in bytes for the object cache (64 MB).
137///
138/// This is a soft guardrail, not a hard ceiling. Real memory usage can be
139/// 1.5–2× the cap because `estimate_size` does not account for HashMap bucket
140/// overhead, Arc headers, or allocator padding.
141const DEFAULT_OBJECT_CACHE_MAX_BYTES: usize = 64 * 1024 * 1024;
142
143/// Default maximum number of entries for the XObject span/image caches.
144const DEFAULT_XOBJECT_CACHE_MAX_ENTRIES: usize = 1024;
145
146/// Maximum accounted bytes retained as parsed object-stream maps.
147const DEFAULT_OBJECT_STREAM_CACHE_MAX_BYTES: usize = DEFAULT_OBJECT_CACHE_MAX_BYTES / 4;
148const DEFAULT_OBJECT_STREAM_RECOVERY_MARKERS: usize = 4096;
149
150/// Heuristic multiplier for the forward-gap guard in the main
151/// assembly loop's compound newline predicate
152/// (`y_diff > 2.0 && gap > K * max(fs)`). Visual gap-sweep over
153/// synthetic two-column examples at fs=10 and fs=14 placed the
154/// plausible operating band at roughly 0.7-1.5; 1.25 is a
155/// conservative interim pick. Not corpus-calibrated; a page-level
156/// layout signal would be a stronger long-term replacement for
157/// this pairwise heuristic.
158const FORWARD_GAP_K: f32 = 1.25;
159
160/// Maximum allowed inter-span X gap inside a candidate same-line reorder run.
161/// If the candidate's tentative X-order contains a larger gap, the run is
162/// probably a disjoint footer/header/field layout rather than a local
163/// mixed-baseline repair.
164const SAME_LINE_REORDER_MAX_GAP_FACTOR: f32 = 3.0;
165
166pub(crate) use crate::cache::BoundedEntryCache;
167
168/// Size-bounded object cache with FIFO eviction.
169///
170/// Wraps a `HashMap<ObjectRef, Object>` with byte-size tracking. When an
171/// insertion would push total estimated size past `max_bytes`, the oldest
172/// entries are evicted first (FIFO order via a `VecDeque` of keys).
173///
174/// FIFO is chosen over LRU because the access pattern is predominantly
175/// insert-once-read-once — higher-level caches (font caches, xobject stream
176/// cache) serve repeated lookups, so recency is not a useful signal here.
177struct BoundedObjectCache {
178 map: HashMap<ObjectRef, Object>,
179 insertion_order: std::collections::VecDeque<ObjectRef>,
180 current_bytes: usize,
181 max_bytes: usize,
182}
183
184/// Byte-accounted FIFO cache for decoded object-stream maps.
185struct BoundedObjectStreamCache {
186 map: HashMap<ObjectRef, Arc<HashMap<u32, Object>>>,
187 insertion_order: std::collections::VecDeque<ObjectRef>,
188 current_bytes: usize,
189 max_bytes: usize,
190}
191
192struct BoundedRecoveryTelemetry {
193 seen: HashSet<u32>,
194 max_entries: usize,
195 saturated: bool,
196}
197
198impl BoundedRecoveryTelemetry {
199 fn new(max_entries: usize) -> Self {
200 Self {
201 seen: HashSet::new(),
202 max_entries,
203 saturated: false,
204 }
205 }
206
207 fn should_emit(&mut self, stream_object_id: u32) -> bool {
208 if self.saturated || self.seen.contains(&stream_object_id) {
209 return false;
210 }
211 if self.seen.len() >= self.max_entries {
212 self.seen.clear();
213 self.saturated = true;
214 return false;
215 }
216 self.seen.insert(stream_object_id)
217 }
218}
219
220impl BoundedObjectStreamCache {
221 fn checked_capacity_bytes(capacity: usize, element_size: usize) -> Option<usize> {
222 capacity.checked_mul(element_size)
223 }
224
225 fn new(max_bytes: usize) -> Self {
226 Self {
227 map: HashMap::new(),
228 insertion_order: std::collections::VecDeque::new(),
229 current_bytes: 0,
230 max_bytes,
231 }
232 }
233
234 fn get(&self, key: &ObjectRef) -> Option<&Arc<HashMap<u32, Object>>> {
235 self.map.get(key)
236 }
237
238 fn insert(&mut self, key: ObjectRef, value: Arc<HashMap<u32, Object>>) -> bool {
239 let Some(entry_size) = Self::estimate_size(&value) else {
240 return false;
241 };
242 if entry_size > self.max_bytes {
243 return false;
244 }
245 self.remove(&key);
246 while self.current_bytes.saturating_add(entry_size) > self.max_bytes {
247 let Some(old_key) = self.insertion_order.pop_front() else {
248 return false;
249 };
250 self.remove(&old_key);
251 }
252 self.current_bytes = self.current_bytes.saturating_add(entry_size);
253 self.map.insert(key, value);
254 self.insertion_order.push_back(key);
255 true
256 }
257
258 fn remove(&mut self, key: &ObjectRef) {
259 if let Some(old_value) = self.map.remove(key) {
260 let old_size = Self::estimate_size(&old_value).unwrap_or(self.max_bytes);
261 self.current_bytes = self.current_bytes.saturating_sub(old_size);
262 }
263 if let Some(position) = self.insertion_order.iter().position(|candidate| candidate == key) {
264 self.insertion_order.remove(position);
265 }
266 }
267
268 fn estimate_size(objects: &HashMap<u32, Object>) -> Option<usize> {
269 let headers =
270 std::mem::size_of::<HashMap<u32, Object>>().checked_add(std::mem::size_of::<Arc<HashMap<u32, Object>>>())?;
271 let buckets = Self::checked_capacity_bytes(
272 objects.capacity(),
273 std::mem::size_of::<u32>() + std::mem::size_of::<Object>() + std::mem::size_of::<usize>(),
274 )?;
275 let mut total = headers.checked_add(buckets)?;
276 let mut stack: Vec<&Object> = objects.values().collect();
277 while let Some(object) = stack.pop() {
278 total = total.checked_add(Self::estimate_dynamic_size(object, &mut stack)?)?;
279 }
280 Some(total)
281 }
282
283 fn estimate_dynamic_size<'a>(object: &'a Object, stack: &mut Vec<&'a Object>) -> Option<usize> {
284 match object {
285 Object::String(value) => Some(value.capacity()),
286 Object::Name(value) => Some(value.capacity()),
287 Object::Array(values) => {
288 stack.extend(values);
289 Self::checked_capacity_bytes(values.capacity(), std::mem::size_of::<Object>())
290 }
291 Object::Dictionary(values) => Self::estimate_dictionary(values, stack),
292 Object::Stream { dict, data } => Self::estimate_dictionary(dict, stack)?.checked_add(data.len()),
293 _ => Some(0),
294 }
295 }
296
297 fn estimate_dictionary<'a>(values: &'a HashMap<String, Object>, stack: &mut Vec<&'a Object>) -> Option<usize> {
298 let buckets = Self::checked_capacity_bytes(
299 values.capacity(),
300 std::mem::size_of::<String>() + std::mem::size_of::<Object>() + std::mem::size_of::<usize>(),
301 )?;
302 let mut total = std::mem::size_of::<HashMap<String, Object>>().checked_add(buckets)?;
303 for (key, value) in values {
304 total = total.checked_add(key.capacity())?;
305 stack.push(value);
306 }
307 Some(total)
308 }
309}
310
311impl BoundedObjectCache {
312 fn new(max_bytes: usize) -> Self {
313 Self {
314 map: HashMap::new(),
315 insertion_order: std::collections::VecDeque::new(),
316 current_bytes: 0,
317 max_bytes,
318 }
319 }
320
321 fn get(&self, key: &ObjectRef) -> Option<&Object> {
322 self.map.get(key)
323 }
324
325 fn insert(&mut self, key: ObjectRef, value: Object) {
326 let entry_size = Self::estimate_size(&value);
327
328 if entry_size > self.max_bytes {
329 return;
330 }
331
332 if let Some(old_val) = self.map.get(&key) {
333 self.current_bytes = self.current_bytes.saturating_sub(Self::estimate_size(old_val));
334 }
335
336 // Evict oldest entries until under budget. If the front of the
337 // queue is the key we're about to (re)insert, skip past it so a
338 // larger replacement doesn't leave the cache over budget — keep
339 // evicting other entries instead. ~keep
340 let mut skipped_self = false;
341 while self.current_bytes + entry_size > self.max_bytes {
342 match self.insertion_order.pop_front() {
343 Some(old_key) => {
344 if old_key == key {
345 if skipped_self {
346 self.insertion_order.push_front(old_key);
347 break;
348 }
349 self.insertion_order.push_back(old_key);
350 skipped_self = true;
351 continue;
352 }
353 if let Some(old_val) = self.map.remove(&old_key) {
354 self.current_bytes = self.current_bytes.saturating_sub(Self::estimate_size(&old_val));
355 }
356 }
357 None => break,
358 }
359 }
360
361 if self.map.insert(key, value).is_none() {
362 self.insertion_order.push_back(key);
363 }
364 self.current_bytes += entry_size;
365 }
366
367 fn len(&self) -> usize {
368 self.map.len()
369 }
370
371 fn keys(&self) -> impl Iterator<Item = &ObjectRef> {
372 self.map.keys()
373 }
374
375 fn clear(&mut self) {
376 self.map.clear();
377 self.insertion_order.clear();
378 self.current_bytes = 0;
379 }
380
381 fn estimate_size(obj: &Object) -> usize {
382 Self::estimate_size_depth(obj, 8)
383 }
384
385 /// Rough estimate of an Object's heap size in bytes.
386 /// Recurses into nested containers up to `depth` levels to avoid
387 /// both underestimation and stack overflow on adversarial input.
388 fn estimate_size_depth(obj: &Object, depth: u8) -> usize {
389 if depth == 0 {
390 return 64;
391 }
392 match obj {
393 Object::Stream { dict, data } => {
394 let dict_size: usize = dict
395 .iter()
396 .map(|(k, v)| k.len() + 32 + Self::estimate_size_depth(v, depth - 1))
397 .sum();
398 data.len() + dict_size + 64
399 }
400 Object::Dictionary(d) => {
401 let inner: usize = d
402 .iter()
403 .map(|(k, v)| k.len() + 32 + Self::estimate_size_depth(v, depth - 1))
404 .sum();
405 inner + 64
406 }
407 Object::Array(a) => {
408 let inner: usize = a.iter().map(|v| Self::estimate_size_depth(v, depth - 1)).sum();
409 inner + 64
410 }
411 Object::String(s) => s.len() + 32,
412 Object::Name(s) => s.len() + 32,
413 _ => 32,
414 }
415 }
416}
417
418// Per-thread resolving stack and recursion depth for load_object.
419// Thread-local storage avoids document-global lock contention and prevents
420// false "circular reference" errors when two threads resolve the same object
421// concurrently (Race C). ~keep
422thread_local! {
423 // HashSet::new is not const on the supported Rust toolchain. ~keep
424 #[allow(clippy::missing_const_for_thread_local)]
425 static RESOLVING_STACK: RefCell<HashSet<ObjectRef>> = RefCell::new(HashSet::new());
426 static RECURSION_DEPTH: RefCell<u32> = const { RefCell::new(0) };
427}
428
429const FONT_IDENTITY_MAX_REFERENCE_DEPTH: usize = 32;
430const FONT_IDENTITY_MAX_RESOLVED_REFERENCES: usize = 4096;
431const FONT_IDENTITY_MAX_HASHED_BYTES: usize = 32 * 1024 * 1024;
432const FONT_HASH_BYTE_LIMIT_MARKER: u8 = 247;
433const FONT_HASH_REFERENCE_LIMIT_MARKER: u8 = 248;
434const FONT_HASH_RESOLVED_REFERENCE_MARKER: u8 = 249;
435const FONT_HASH_DEPTH_LIMIT_MARKER: u8 = 250;
436const FONT_HASH_UNRESOLVED_REFERENCE_MARKER: u8 = 251;
437const FONT_HASH_REFERENCE_CYCLE_MARKER: u8 = 252;
438const FONT_IDENTITY_SEMANTIC_FIELDS: &[&str] = &[
439 "Encoding",
440 "ToUnicode",
441 "FontDescriptor",
442 "Widths",
443 "DescendantFonts",
444 "FontMatrix",
445];
446const FONT_IDENTITY_CONSUMED_DICTIONARY_FIELDS: &[&str] = &[
447 "Ascent",
448 "BaseEncoding",
449 "BitsPerComponent",
450 "BlackIs1",
451 "CIDSystemInfo",
452 "CIDToGIDMap",
453 "CMapName",
454 "ColorTransform",
455 "Colors",
456 "Columns",
457 "DW",
458 "DW2",
459 "DamagedRowsBeforeError",
460 "DecodeParms",
461 "Descent",
462 "Differences",
463 "EarlyChange",
464 "EncodedByteAlign",
465 "EndOfBlock",
466 "EndOfLine",
467 "Filter",
468 "Flags",
469 "FontDescriptor",
470 "FontFile",
471 "FontFile2",
472 "FontFile3",
473 "FontWeight",
474 "JBIG2Globals",
475 "K",
476 "Name",
477 "Ordering",
478 "Predictor",
479 "Registry",
480 "StemV",
481 "Subtype",
482 "Supplement",
483 "W",
484 "W2",
485];
486
487#[derive(Clone, Copy)]
488struct FontIdentityHash {
489 value: u64,
490 cacheable: bool,
491 subtree_depth: usize,
492}
493
494#[derive(Default)]
495struct FontHashTraversal {
496 resolving: HashSet<ObjectRef>,
497 resolved_hashes: HashMap<ObjectRef, FontIdentityHash>,
498 resolved_references: usize,
499 depth_frames: Vec<FontHashDepthFrame>,
500}
501
502struct FontHashDepthFrame {
503 entry_depth: usize,
504 max_depth: usize,
505}
506
507impl FontHashTraversal {
508 fn begin_reference(&mut self, entry_depth: usize) {
509 self.depth_frames.push(FontHashDepthFrame {
510 entry_depth,
511 max_depth: entry_depth,
512 });
513 }
514
515 fn observe_depth(&mut self, depth: usize) {
516 for frame in &mut self.depth_frames {
517 frame.max_depth = frame.max_depth.max(depth);
518 }
519 }
520
521 fn end_reference(&mut self) -> usize {
522 let Some(frame) = self.depth_frames.pop() else {
523 return FONT_IDENTITY_MAX_REFERENCE_DEPTH;
524 };
525 frame.max_depth.saturating_sub(frame.entry_depth)
526 }
527}
528
529/// PDF document.
530///
531/// This structure represents an open PDF document, providing access to:
532/// - Document metadata (version, catalog, trailer)
533/// - Page information (count, page tree)
534/// - Object loading and dereferencing
535///
536/// # Example
537///
538/// ```no_run
539/// use xberg_native_pdf::document::PdfDocument;
540///
541/// let mut doc = PdfDocument::open("sample.pdf")?;
542/// println!("PDF version: {}.{}", doc.version().0, doc.version().1);
543/// println!("Page count: {}", doc.page_count()?);
544/// # Ok::<(), xberg_native_pdf::error::Error>(())
545/// ```
546///
547/// # Memory management
548///
549/// The document maintains several internal caches for performance. The main
550/// object cache is bounded at 64 MB (see `DEFAULT_OBJECT_CACHE_MAX_BYTES`)
551/// uses FIFO eviction to prevent unbounded heap growth when processing
552/// many pages sequentially.
553///
554/// # Thread Safety
555///
556/// All interior-mutable fields use `Mutex` / `AtomicUsize`, making
557/// `PdfDocument` both `Send` and `Sync`.
558pub struct PdfDocument {
559 /// Raw bytes of the document. Every read of the file body addresses this
560 /// by absolute offset (see `bytes_at`), so the document carries no shared
561 /// read cursor and a page read never depends on what another page read
562 /// first. ~keep
563 pub source_bytes: Vec<u8>,
564 /// PDF version (major, minor)
565 version: (u8, u8),
566 /// Cross-reference table mapping object IDs to byte offsets
567 xref: CrossRefTable,
568 /// Trailer dictionary
569 trailer: Object,
570 /// Cache for loaded objects to avoid re-parsing.
571 /// Bounded at [`DEFAULT_OBJECT_CACHE_MAX_BYTES`] with FIFO eviction to
572 /// prevent unbounded heap growth during multi-page extraction.
573 object_cache: Mutex<BoundedObjectCache>,
574 /// Parsed object streams keyed by the stream reference and bounded by accounted bytes.
575 object_stream_cache: Mutex<BoundedObjectStreamCache>,
576 /// Bounded markers for streams whose aggregated recovery telemetry fired.
577 /// Saturation fails closed by suppressing further recovery events. ~keep
578 object_stream_telemetry_seen: Mutex<BoundedRecoveryTelemetry>,
579 /// Encryption handler (if PDF is encrypted).
580 /// Wrapped in RefCell for interior mutability (lazy initialization from &self).
581 encryption_handler: Mutex<Option<EncryptionHandler>>,
582 /// ObjectRef of the /Encrypt dictionary, cached so its strings are
583 /// skipped during per-object string decryption. The entries in the
584 /// encryption dict (/O, /U, /OE, /UE, /Perms, …) are key material used
585 /// to derive the encryption key, not ciphertext, and must never be
586 /// passed through `decrypt_string`.
587 encrypt_dict_ref: Mutex<Option<ObjectRef>>,
588 /// Parser configuration options for error handling and recovery
589 #[allow(dead_code)]
590 options: ParserOptions,
591 /// Byte offset where PDF header was found (may not be 0 for malformed PDFs)
592 #[allow(dead_code)]
593 header_offset: u64,
594 /// Font cache keyed by indirect ObjectRef to avoid re-parsing fonts across pages.
595 /// Arc-wrapped to eliminate deep cloning when populating per-page TextExtractor.
596 /// Bounded at 512 entries — TeX PDFs can create unique font objects per page.
597 font_cache: Mutex<BoundedEntryCache<ObjectRef, Arc<crate::fonts::FontInfo>>>,
598 /// Cached font sets keyed by /Font dictionary ObjectRef.
599 /// Pages sharing the same /Font dict skip the entire load_fonts() loop.
600 /// Bounded at 256 entries.
601 font_set_cache: Mutex<BoundedEntryCache<ObjectRef, Vec<(String, Arc<crate::fonts::FontInfo>)>>>,
602 /// Fingerprint-based font set cache for direct /Font dictionaries.
603 /// Keyed by sorted font ObjectRefs hash, catches pages with different
604 /// /Resources but same font references. Bounded at 256 entries.
605 font_fingerprint_cache: Mutex<BoundedEntryCache<u64, Vec<(String, Arc<crate::fonts::FontInfo>)>>>,
606 /// Name-based font set cache keyed by hash of sorted font names.
607 /// Catches pages with different font ObjectRefs but the same font name→base font
608 /// mapping (common in PDFs that create new font objects per page).
609 /// Stores the resolved font set (Arc-wrapped to avoid cloning) plus a combined
610 /// identity hash over ALL fonts for verification before reuse. Bounded at 256 entries.
611 font_name_set_cache: Mutex<BoundedEntryCache<u64, (Arc<Vec<(String, Arc<crate::fonts::FontInfo>)>>, u64)>>,
612 /// Donated companions to `font_set_cache`/`font_fingerprint_cache`/`font_name_set_cache`,
613 /// keyed identically. Holds the SAME dictionary's font set after TrueType cmap donation
614 /// has run only among fonts that dictionary itself resolves — never against fonts a
615 /// sibling dictionary (a different page, or a Form XObject) happens to have contributed
616 /// to the extractor first. That keeps a cache hit's donated value a function of this
617 /// dictionary's own resources alone, so it cannot vary with which page or XObject was
618 /// read before it. Donation that depends on a donor OUTSIDE this dictionary is not
619 /// cached here; `TextExtractor::share_truetype_cmaps` still runs on every `load_fonts`
620 /// call to cover that case, and is cheap when these fonts already carry a cmap. ~keep
621 font_set_donated_cache: Mutex<BoundedEntryCache<ObjectRef, Vec<(String, Arc<crate::fonts::FontInfo>)>>>,
622 /// Donated companion to `font_fingerprint_cache`. See `font_set_donated_cache`.
623 font_fingerprint_donated_cache: Mutex<BoundedEntryCache<u64, Vec<(String, Arc<crate::fonts::FontInfo>)>>>,
624 /// Donated companion to `font_name_set_cache`. See `font_set_donated_cache`.
625 font_name_set_donated_cache: Mutex<BoundedEntryCache<u64, Arc<Vec<(String, Arc<crate::fonts::FontInfo>)>>>>,
626 /// Counts calls to `donate_truetype_cmaps_within_set`, i.e. actual donation
627 /// work rather than a cache hit. Test-only: proves donation runs once per
628 /// font set across repeated page reads instead of once per page. ~keep
629 #[cfg(test)]
630 donation_call_count: AtomicUsize,
631 /// Sum of `TextExtractor::share_truetype_cmaps`'s return value across every
632 /// `load_fonts` call on this document, i.e. the number of fonts actually
633 /// mutated by cmap donation. Test-only: proves a cache hit that already
634 /// carries a donated cmap does not redo that mutation. ~keep
635 #[cfg(test)]
636 donation_apply_count: AtomicUsize,
637 /// Per-font identity cache keyed by the resolved semantic content consumed by
638 /// `FontInfo::from_dict`. Skips expensive parsing when a structurally identical
639 /// font was already parsed.
640 /// Bounded at 512 entries.
641 font_identity_cache: Mutex<BoundedEntryCache<u64, Arc<crate::fonts::FontInfo>>>,
642 /// Per-object resolved font identity, memoized. An object's content is fixed
643 /// within a document, so the Layer-4 cache guard need not traverse each font's
644 /// indirect semantic objects on every page. `None` means the identity was not
645 /// safe for cross-object or cross-document reuse.
646 font_id_hash_cache: Mutex<HashMap<ObjectRef, Option<u64>>>,
647 /// Resolved identities that were proven safe for reuse by another font
648 /// root in this document. Bounded by the traversal reference cap. ~keep
649 font_reference_hash_cache: Mutex<BoundedEntryCache<ObjectRef, FontIdentityHash>>,
650 /// Total stream bytes hashed while establishing semantic font identities.
651 font_identity_hashed_bytes: AtomicUsize,
652 /// Fail-closed gate for cross-object and cross-document font identity reuse.
653 font_identity_shared_cache_enabled: AtomicBool,
654 /// Cached structure tree (None = not yet checked, Some(None) = untagged, Some(Some) = tagged).
655 /// Uses Arc to avoid expensive deep clones on every page extraction.
656 /// Mutex provides interior mutability for `&self` read-path methods.
657 structure_tree_cache: Mutex<Option<Option<Arc<crate::structure::StructTreeRoot>>>>,
658 /// Cached per-page structure tree traversal results.
659 /// Built once from the structure tree, then O(1) lookup per page.
660 /// Mutex provides interior mutability for `&self` read-path methods.
661 structure_content_cache: Mutex<Option<HashMap<u32, Vec<crate::structure::OrderedContent>>>>,
662 /// Cached resolved structure-tree `/ActualText` scopes.
663 ///
664 /// `None` = not yet built, `Some(None)` = built and the document has
665 /// no resolvable ActualText (untagged, or every bearing element
666 /// dropped during finalisation), `Some(Some(idx))` = built.
667 ///
668 /// Mirrors `structure_tree_cache` so every extraction surface
669 /// applies tree-scope ActualText consistently without re-walking the
670 /// structure tree. Decoupled from `/MarkInfo /Suspects`: producer-
671 /// supplied ActualText is trusted regardless of Suspects (it is
672 /// content replacement, not reading order — see
673 /// `actualtext_index`).
674 actualtext_index_cache: Mutex<Option<Option<Arc<crate::structure::ActualTextIndex>>>>,
675 /// Per-page set of MCIDs whose marked-content sequence carried an
676 /// inline `/ActualText` property (ISO 32000-1:2008 §14.6).
677 ///
678 /// Populated by `extract_spans_impl` from the text extractor's
679 /// per-call detection: the per-page entry is REPLACED on each
680 /// extraction so MC-scope precedence reflects the latest run, not
681 /// stale data from an earlier filter set.
682 ///
683 /// The struct-tree-scope ActualText applier consults this set to
684 /// enforce the precedence rule: the MC-scope (inline) replacement
685 /// is the innermost and most specific declaration for the MCID
686 /// it covers, so a struct-tree-scope `/ActualText` on an ancestor
687 /// element must NOT override it.
688 pub(crate) mc_actualtext_mcids: Mutex<HashMap<usize, HashSet<u32>>>,
689 /// `Table` structure elements bucketed by page, built once via
690 /// `find_table_elements_all_pages` (one tree walk) so the converter table
691 /// path does an O(1) lookup instead of walking the tree per page.
692 /// `None` = not yet built.
693 table_elements_cache: Mutex<Option<HashMap<u32, Vec<crate::structure::StructElem>>>>,
694 /// Page object cache keyed by page index to avoid re-traversing the page tree.
695 /// The page tree structure is static (§7.7.3.2), so pages can be safely cached.
696 /// Mutex provides interior mutability for `&self` read-path methods.
697 page_cache: Mutex<HashMap<usize, Object>>,
698 /// Whether the bulk page tree walk has been attempted (successful or not).
699 /// Prevents re-walking the tree on every cache miss for malformed PDFs.
700 page_cache_populated: AtomicBool,
701 /// Cached object offsets from full file scan (built on first xref miss).
702 /// Maps object number to byte offset in file.
703 scanned_object_offsets: Mutex<Option<HashMap<u32, u64>>>,
704 /// Whether the one-time object-stream recovery sweep has been attempted.
705 /// See `recover_from_object_streams`. Separate from the scanned offsets
706 /// cache because the sweep is only triggered on free-entry misses that
707 /// also failed the file-body scan — the common path never needs it.
708 objstm_recovery_done: Mutex<bool>,
709 /// Whether this document's xref table came from full-file reconstruction
710 /// (GH#1774) rather than a parsed xref table/stream. Reconstruction finds
711 /// objects only via a literal "N G obj" header scan, so it is structurally
712 /// blind to objects that live solely inside a compressed `/ObjStm`
713 /// container — unlike a *parsed* xref, which can carry a type-2
714 /// (compressed) entry pointing straight at the container. Set once at
715 /// construction and never mutated, so a plain `bool` (no `Mutex`)
716 /// suffices. Used to (a) proactively sweep object streams once so
717 /// ObjStm-packed objects resolve normally, and (b) gate the
718 /// `XrefRecovery` warning so it fires only when a reference is missing
719 /// *because* reconstruction couldn't see it — never for a healthy,
720 /// normally-parsed document, and never for a reference that is legitimately
721 /// free per §7.3.10. ~keep
722 xref_reconstructed: bool,
723 /// Cache of XObject refs known to NOT be Form XObjects (i.e., Image or unknown).
724 /// Used by text extraction to skip expensive full-object loads for images.
725 image_xobject_cache: Mutex<HashSet<ObjectRef>>,
726 /// Document-level cache of Form XObject refs whose streams contain NO text
727 /// operators (BT) and no nested Do invocations. Persists across pages so that
728 /// shared graphics-only XObjects (watermarks, logos, chart elements) are
729 /// decompressed and scanned at most once across the entire document.
730 pub(crate) xobject_text_free_cache: Mutex<HashSet<ObjectRef>>,
731 /// Cache of decompressed Form XObject streams. Bounded at 50MB total.
732 /// Avoids repeated FlateDecode decompression of shared Form XObjects.
733 pub(crate) xobject_stream_cache: Mutex<HashMap<ObjectRef, std::sync::Arc<Vec<u8>>>>,
734 pub(crate) xobject_stream_cache_bytes: AtomicUsize,
735 /// Cache of extracted TextSpan results from self-contained Form XObjects
736 /// (those with own /Resources/Font). None = processed but no spans.
737 /// Key is `(ObjectRef, [i64; 6])` where the array encodes the caller's CTM
738 /// as millipoint-rounded integers, allowing the same Form XObject to cache
739 /// distinct results for each unique CTM it is painted with.
740 /// Bounded at [`DEFAULT_XOBJECT_CACHE_MAX_ENTRIES`] entries with FIFO eviction.
741 pub(crate) xobject_spans_cache:
742 Mutex<BoundedEntryCache<(ObjectRef, [i64; 6]), Option<Vec<crate::layout::TextSpan>>>>,
743 /// Cache of extracted images from Form XObjects (keyed by ObjectRef).
744 /// Images are stored without CTM applied — caller applies its own CTM.
745 /// Bounded at [`DEFAULT_XOBJECT_CACHE_MAX_ENTRIES`] entries with FIFO eviction.
746 pub(crate) form_xobject_images_cache: Mutex<BoundedEntryCache<ObjectRef, Vec<crate::extractors::PdfImage>>>,
747 /// Regions marked for erasure per page. Mutex for `&self` write-path methods.
748 pub(crate) erase_regions: Mutex<HashMap<usize, Vec<crate::geometry::Rect>>>,
749 /// LRU cache of decompressed page content streams, keyed by page index.
750 page_content_cache: Mutex<BoundedEntryCache<usize, std::sync::Arc<Vec<u8>>>>,
751 /// LRU cache of postprocessed [`TextSpan`]s per page. `to_markdown`/`to_html`
752 /// reach `extract_spans` twice per page — once directly, once via
753 /// `extract_page_tables` → `extract_words` → `page_reading_order`; this serves
754 /// the second from cache. Cleared by redaction (`erase_region` /
755 /// `clear_erase_regions`), the only span-affecting mutation.
756 page_spans_cache: Mutex<BoundedEntryCache<usize, std::sync::Arc<Vec<crate::layout::TextSpan>>>>,
757 /// Per-page lightweight search index (page text + span bounding boxes,
758 /// no fonts/glyph widths) built lazily by `search()`/`search_page()` and
759 /// reused across repeated calls on the same document. Unlike
760 /// `page_spans_cache` this is never size-bounded/evicted — search never
761 /// reads font/glyph data, so retaining every page's projection is far
762 /// cheaper than retaining every page's full `TextSpan`s would be.
763 /// Cleared alongside `page_spans_cache` wherever spans are invalidated.
764 search_index: Mutex<HashMap<usize, std::sync::Arc<crate::search::SearchPageIndex>>>,
765 /// Per-page character cache for the unfiltered (`extract_chars`) result.
766 /// `postprocess_spans` needs the same char sequence the public API returns,
767 /// so without this every span extraction re-parses the content stream a
768 /// second time purely to stamp per-glyph x-origins.
769 page_chars_cache: Mutex<BoundedEntryCache<usize, std::sync::Arc<Vec<crate::layout::TextChar>>>>,
770 /// Cached signatures of running headers/footers detected via cross-page
771 /// repetition. A span whose normalized text matches a signature
772 /// sits near the top/bottom of the page is treated as an artifact.
773 /// Populated lazily on first access; `Some(set)` with an empty set
774 /// means detection ran and found nothing (vs `None` = not yet run).
775 /// Signatures of running headers/footers plus the first page index where
776 /// each signature was observed. Used to mark repeat occurrences as
777 /// pagination artifacts while keeping the first appearance intact — the
778 /// first appearance is often the document's cover-page title that just
779 /// happens to echo into the header band on every page (pdfa_010
780 /// would otherwise drop "University of Oklahoma 2009").
781 running_artifact_signatures: Mutex<Option<std::sync::Arc<std::collections::HashMap<String, usize>>>>,
782 /// Document-wide article threads (`/Threads`), parsed once. Reading-order
783 /// resolution consults them per page, and parsing walks the whole page
784 /// tree — so without this the cost per page scaled with the document.
785 article_threads_cache: Mutex<Option<std::sync::Arc<Vec<crate::structure::ArticleThread>>>>,
786 /// Memoised result of [`PdfDocument::output_intent_cmyk_profile`].
787 ///
788 /// The accessor walks `/OutputIntents` and decodes + parses the ICC
789 /// stream every call. The hot transparency / overprint paths invoke
790 /// it once per paint and the parse is non-trivial (qcms / lcms2
791 /// header validation + LUT decode on a profile blob that can be
792 /// hundreds of KB), so the result is cached for the document
793 /// lifetime here. `Some(None)` means "checked once, no usable CMYK
794 /// OutputIntent" — distinct from `None` (not yet checked).
795 output_intent_cmyk_profile_cache: Mutex<Option<Option<std::sync::Arc<crate::color::IccProfile>>>>,
796 /// Accumulated extraction warnings for programmatic inspection.
797 /// Populated when silent fallbacks occur (font not found, CMap absent, etc.).
798 /// Retrieve with [`PdfDocument::warnings`]; drain with [`PdfDocument::take_warnings`].
799 accumulated_warnings: Mutex<Vec<String>>,
800 /// structured warnings accumulator. Each
801 /// internal warning site that previously only called `tracing::warn!`
802 /// can additionally push a typed [`crate::extractors::warnings::Warning`]
803 /// here, letting callers retrieve diagnostics as structured data
804 /// (via [`PdfDocument::structured_warnings`]) instead of parsing
805 /// stderr text. The existing String-list `accumulated_warnings`
806 /// stays for back-compat.
807 warning_sink: crate::extractors::warnings::WarningSink,
808 /// Counts of input this document recovered from — a missing embedded font,
809 /// an object outside the xref table, an unreadable CFF version. Reported
810 /// once, as a single DEBUG event, when the document is dropped; the
811 /// individual occurrences are TRACE. See `extractors::recovery_tally` for
812 /// why these are counted rather than warned about (GH#1547). ~keep
813 recovery: std::sync::Arc<crate::extractors::recovery_tally::RecoveryCounts>,
814}
815
816/// Tracing target for every event raised under `document`, pinned to the parent
817/// module path.
818///
819/// Targets are public API in this workspace and semver-relevant, so splitting
820/// `document.rs` into files must not rename them. Without this, each child module
821/// inherits its own `module_path!()` and 110 call sites silently move from
822/// `xberg_native_pdf::document` to `xberg_native_pdf::document::<child>`, breaking
823/// every consumer `EnvFilter` that names the old target. Only one test caught it. ~keep
824pub(super) const LOG_TARGET: &str = module_path!();
825
826impl PdfDocument {
827 /// The document's recovery counters, so extraction paths outside this module
828 /// can install a [`RecoveryScope`](crate::extractors::recovery_tally) and let
829 /// the free functions in `fonts::` charge their recoveries here. ~keep
830 pub(crate) fn recovery_counts(&self) -> &std::sync::Arc<crate::extractors::recovery_tally::RecoveryCounts> {
831 &self.recovery
832 }
833}
834
835impl Drop for PdfDocument {
836 /// Emits the one-per-document recovery summary (GH#1547).
837 ///
838 /// Drop rather than an explicit call at the end of extraction: a document is
839 /// opened once and then extracted from, rendered, and queried through many
840 /// separate public entry points, so drop is the only point that sees the
841 /// whole document's totals exactly once. Silent when nothing was recovered.
842 /// ~keep
843 fn drop(&mut self) {
844 crate::extractors::recovery_tally::report(&self.recovery.snapshot());
845 }
846}
847
848const _: () = {
849 fn _assert_send_sync<T: Send + Sync>() {}
850 fn _check() {
851 _assert_send_sync::<PdfDocument>();
852 }
853};
854
855impl std::fmt::Debug for PdfDocument {
856 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
857 f.debug_struct("PdfDocument")
858 .field("version", &self.version)
859 .field("xref_entries", &self.xref.len())
860 .field("cached_objects", &self.object_cache.lock_or_recover().len())
861 .finish_non_exhaustive()
862 }
863}
864
865/// Pre-decompression filter for image extraction.
866///
867/// Dimensions are checked against XObject dictionary metadata (Width, Height,
868/// ColorSpace) BEFORE the stream is decompressed, avoiding expensive decoding
869/// of images that will be discarded downstream.
870struct ImageExtractFilter {
871 /// Minimum width in pixels (images narrower are skipped).
872 min_width: i64,
873 /// Minimum height in pixels (images shorter are skipped).
874 min_height: i64,
875 /// Maximum total pixels (images exceeding this are skipped).
876 max_pixels: u64,
877 /// Skip Indexed-colorspace images below this dimension.
878 /// 0 means disabled.
879 skip_indexed_small: i64,
880}
881
882impl Default for ImageExtractFilter {
883 fn default() -> Self {
884 Self {
885 min_width: 8,
886 min_height: 8,
887 max_pixels: u64::MAX,
888 skip_indexed_small: 0,
889 }
890 }
891}
892
893/// Area of a page for targeted header/footer operations.
894#[derive(Debug, Clone, Copy, PartialEq)]
895pub enum PageArea {
896 /// Top region (Header)
897 Header,
898 /// Bottom region (Footer)
899 Footer,
900}
901
902/// Scan raw file bytes for candidate ObjStm positions.
903///
904/// Each hit is `(object_number, byte_offset_of_N_G_obj_header)`. We look
905/// for the shape `N G obj ... /Type /ObjStm` within a small window after
906/// each object header so that the caller can then `load_uncompressed_object`
907/// at exactly that offset without parsing the whole file body.
908///
909/// The scan is intentionally tolerant: it doesn't require `/Type`
910/// `/ObjStm` to be separated by whitespace (many producers write
911/// `/Type/ObjStm`), doesn't anchor on any particular position within the
912/// header, and doesn't rely on xref entries being correct — which is the
913/// whole point of the recovery path it serves.
914fn find_objstm_candidates(content: &[u8]) -> Vec<(u32, u64)> {
915 const DICT_PEEK_BYTES: usize = 2048;
916 let mut out = Vec::new();
917 let mut pos = 0usize;
918 while pos < content.len() {
919 let valid_start =
920 pos == 0 || content[pos - 1] == b'\n' || content[pos - 1] == b'\r' || content[pos - 1] == b' ';
921 if !valid_start || !content[pos].is_ascii_digit() {
922 pos += 1;
923 continue;
924 }
925 let header_start = pos;
926
927 let num_start = pos;
928 while pos < content.len() && content[pos].is_ascii_digit() {
929 pos += 1;
930 }
931 if pos >= content.len() || content[pos] != b' ' {
932 pos = header_start + 1;
933 continue;
934 }
935 let obj_num: u32 = match std::str::from_utf8(&content[num_start..pos])
936 .ok()
937 .and_then(|s| s.parse().ok())
938 {
939 Some(n) => n,
940 None => {
941 pos = header_start + 1;
942 continue;
943 }
944 };
945 pos += 1;
946
947 let gen_start = pos;
948 while pos < content.len() && content[pos].is_ascii_digit() {
949 pos += 1;
950 }
951 if pos >= content.len() || content[pos] != b' ' {
952 pos = header_start + 1;
953 continue;
954 }
955 if std::str::from_utf8(&content[gen_start..pos])
956 .ok()
957 .and_then(|s| s.parse::<u32>().ok())
958 .is_none()
959 {
960 pos = header_start + 1;
961 continue;
962 }
963 pos += 1;
964
965 if pos + 3 > content.len() || &content[pos..pos + 3] != b"obj" {
966 pos = header_start + 1;
967 continue;
968 }
969
970 // Peek up to DICT_PEEK_BYTES ahead for `/Type` followed (after
971 // optional whitespace) by `/ObjStm`. We don't decompress — the
972 // ObjStm dict header is always uncompressed plaintext even when
973 // the stream body is Flate-encoded. ~keep
974 let window_end = (pos + DICT_PEEK_BYTES).min(content.len());
975 let window = &content[pos..window_end];
976 if contains_objstm_marker(window) {
977 out.push((obj_num, header_start as u64));
978 }
979
980 pos = header_start + 1;
981 }
982 out
983}
984
985fn contains_objstm_marker(window: &[u8]) -> bool {
986 let mut i = 0;
987 while i + 5 <= window.len() {
988 if &window[i..i + 5] == b"/Type" {
989 let mut j = i + 5;
990 while j < window.len()
991 && (window[j] == b' ' || window[j] == b'\t' || window[j] == b'\r' || window[j] == b'\n')
992 {
993 j += 1;
994 }
995 if j + 7 <= window.len() && &window[j..j + 7] == b"/ObjStm" {
996 return true;
997 }
998 }
999 i += 1;
1000 }
1001 false
1002}
1003
1004/// Append ink names declared by `Separation` and `DeviceN` colour spaces
1005/// in `cs_dict` to `out`, including one that is the base of an `Indexed`
1006/// space or the underlying space of a `Pattern`. Reserved colorants `/All` and `/None` (§8.6.6.4)
1007/// are skipped. Caller is responsible for deduping across multiple calls.
1008///
1009/// When `doc` is `Some`, indirect references inside each colour-space array
1010/// (e.g. a DeviceN whose names list is `4 0 R` rather than inline) are
1011/// resolved. Tools that hand-build inline arrays and don't need indirection
1012/// resolution can pass `None`.
1013///
1014/// Used by both [`PdfDocument::get_page_inks`] and
1015/// [`PdfDocument::get_page_inks_deep`] so the per-colorant rules live in
1016/// exactly one place.
1017fn extract_inks_from_color_space_dict(
1018 cs_dict: &std::collections::HashMap<String, Object>,
1019 doc: Option<&PdfDocument>,
1020 out: &mut Vec<String>,
1021) {
1022 let mut visited: std::collections::HashSet<ObjectRef> = std::collections::HashSet::new();
1023 for cs_def in cs_dict.values() {
1024 collect_inks_from_color_space(cs_def, doc, out, &mut visited, 0);
1025 }
1026}
1027
1028/// Inner walker — surfaces inks from a single colour-space definition.
1029/// Factored out of [`extract_inks_from_color_space_dict`] so the
1030/// Pattern and Indexed arm can recurse into its underlying colour space
1031/// without requiring a synthetic single-entry dict.
1032///
1033/// **Cycle handling:** the Pattern and Indexed arm recurses into the
1034/// underlying colour space (§8.7.3.1, §8.6.6.3). A self-referential array such as
1035/// `5 0 obj [/Pattern 5 0 R]` would otherwise blow the stack, so
1036/// indirect references are de-duplicated via `visited` (keyed on
1037/// `ObjectRef`) and total depth is capped at `MAX_RECURSION_DEPTH`
1038/// — the same backstop used by [`PdfDocument::walk_form_xobject_tree_for_inks`].
1039fn collect_inks_from_color_space(
1040 cs_def: &Object,
1041 doc: Option<&PdfDocument>,
1042 out: &mut Vec<String>,
1043 visited: &mut std::collections::HashSet<ObjectRef>,
1044 depth: u32,
1045) {
1046 if depth >= MAX_RECURSION_DEPTH {
1047 return;
1048 }
1049 let deref = |obj: &Object| -> Object {
1050 match (obj.as_reference(), doc) {
1051 (Some(r), Some(d)) => d.load_object(r).unwrap_or_else(|_| obj.clone()),
1052 _ => obj.clone(),
1053 }
1054 };
1055
1056 let arr = match cs_def.as_array() {
1057 Some(a) => a,
1058 None => return,
1059 };
1060 if arr.len() < 2 {
1061 return;
1062 }
1063 let cs_type = match arr.first().and_then(Object::as_name) {
1064 Some(n) => n,
1065 None => return,
1066 };
1067 match cs_type {
1068 "Pattern" | "Indexed" => {
1069 // ISO 32000-1 §8.7.3.1: a Pattern colour space's
1070 // optional second array element is the underlying
1071 // colour space (uncoloured Tiling carries the
1072 // underlying space's tints). §8.6.6.3: an Indexed
1073 // space's second element is its base, and every
1074 // palette entry paints in that base. Recurse so a
1075 // /Separation or /DeviceN underneath either one
1076 // surfaces its spot colorants for plate allocation.
1077 //
1078 // Guard against self-referential cycles (e.g.
1079 // `5 0 obj [/Pattern 5 0 R]`): an indirect underlying
1080 // ref is recorded in `visited`; a repeat hit terminates
1081 // the recursion silently. ~keep
1082 if let Some(r) = arr[1].as_reference()
1083 && !visited.insert(r)
1084 {
1085 return;
1086 }
1087 let underlying = deref(&arr[1]);
1088 collect_inks_from_color_space(&underlying, doc, out, visited, depth + 1);
1089 }
1090 "Separation" => {
1091 // §8.6.6.2: [/Separation /InkName /AlternateCS /TintTransform].
1092 // The name slot is usually inline but resolve indirects for safety. ~keep
1093 let name_obj = deref(&arr[1]);
1094 if let Some(ink) = name_obj.as_name()
1095 && ink != "All"
1096 && ink != "None"
1097 {
1098 out.push(ink.to_string());
1099 }
1100 }
1101 "DeviceN" => {
1102 // §8.6.6.5: [/DeviceN <names-array> /AlternateCS /TintTransform <attrs>].
1103 // The names array is commonly emitted as an indirect reference
1104 // when the same colorant set is shared across multiple DeviceN
1105 // spaces; resolve before unpacking the names. ~keep
1106 let names_obj = match arr.get(1) {
1107 Some(o) => deref(o),
1108 None => return,
1109 };
1110 // ISO 32000-1 §8.6.6.5 / Table 73: the optional 5th array
1111 // element is the attributes dictionary. When its `/Process`
1112 // sub-dictionary declares a `/Components` array, those names
1113 // are PROCESS colorants (riding the page's process plates),
1114 // not spot inks. The same rule applies whether the attrs
1115 // dict's `/Subtype` is `/DeviceN` (the default, PDF 1.6) or
1116 // `/NChannel` (PDF 1.7 stricter subtype) — §8.6.6.5 names the
1117 // /Process key on both subtypes. Build the process-name set
1118 // here so the colorants loop can filter against it. ~keep
1119 let process_names: std::collections::HashSet<String> = arr
1120 .get(4)
1121 .map(&deref)
1122 .as_ref()
1123 .and_then(Object::as_dict)
1124 .and_then(|attrs| attrs.get("Process"))
1125 .map(&deref)
1126 .as_ref()
1127 .and_then(Object::as_dict)
1128 .and_then(|proc_dict| proc_dict.get("Components"))
1129 .map(&deref)
1130 .as_ref()
1131 .and_then(Object::as_array)
1132 .map(|comps| comps.iter().filter_map(|o| o.as_name().map(str::to_string)).collect())
1133 .unwrap_or_default();
1134 if let Some(inks) = names_obj.as_array() {
1135 for ink_obj in inks {
1136 if let Some(ink) = ink_obj.as_name()
1137 && ink != "All"
1138 && ink != "None"
1139 && !process_names.contains(ink)
1140 {
1141 out.push(ink.to_string());
1142 }
1143 }
1144 }
1145 }
1146 _ => {}
1147 }
1148}
1149
1150/// Per-page MCID action computed from the
1151/// [`crate::structure::ActualTextIndex`].
1152///
1153/// Drives every consumer of struct-tree-scope `/ActualText`
1154/// (`extract_text`'s structure-order assembler, the raw-span applier,
1155/// and the ordered-span applier). The map is computed once per page
1156/// from the cached `ActualTextIndex` plus the visibility / MC-scope
1157/// filters; consumers then dispatch per MCID without re-walking the
1158/// structure tree.
1159#[derive(Debug, Clone)]
1160pub(crate) enum ActualTextAction {
1161 /// Replace this MCID's span text with the supplied string AND drop
1162 /// subsequent spans / MCIDs in the same consecutive-replacement
1163 /// run. Assigned to exactly one MCID per emitting run: the first
1164 /// visible MCID that is not exempted by MC-scope-wins.
1165 EmitAndSuppress(std::sync::Arc<str>),
1166 /// Suppress the raw glyphs for this MCID without emitting anything.
1167 /// Used for run continuations after the run's emission MCID, for
1168 /// suppress-only entries (non-first-page coverage of a multi-page
1169 /// ActualText scope), and for MCIDs in a fully-hidden run.
1170 Suppress,
1171}
1172
1173fn trace_open_error(error: &Error) {
1174 if let Some(error_offset) = error.telemetry_offset() {
1175 tracing::error!(error_code = error.telemetry_code(), error_offset, "PDF open failed");
1176 } else {
1177 tracing::error!(error_code = error.telemetry_code(), "PDF open failed");
1178 }
1179}
1180
1181fn trace_xref_parse_failure(error: &Error) {
1182 if let Some(error_offset) = error.telemetry_offset() {
1183 tracing::warn!(
1184 error_code = error.telemetry_code(),
1185 error_offset,
1186 "regular xref parsing failed; attempting reconstruction"
1187 );
1188 } else {
1189 tracing::warn!(
1190 error_code = error.telemetry_code(),
1191 "regular xref parsing failed; attempting reconstruction"
1192 );
1193 }
1194}
1195
1196fn trace_recoverable_pdf_error(operation: &'static str, error: &Error) {
1197 crate::error::trace_recovery(operation, error);
1198}
1199
1200fn trace_fatal_pdf_error(operation: &'static str, error: &Error) {
1201 crate::error::trace_failure(operation, error);
1202}
1203
1204fn resolve_encrypt_dictionary_references(
1205 dictionary: &HashMap<String, Object>,
1206 mut load: impl FnMut(ObjectRef) -> Result<Object>,
1207) -> HashMap<String, Object> {
1208 let mut resolved_dictionary = dictionary.clone();
1209 let mut unresolved_reference_count = 0usize;
1210 for value in resolved_dictionary.values_mut() {
1211 if let Object::Reference(object_reference) = value {
1212 match load(*object_reference) {
1213 Ok(resolved) => *value = resolved,
1214 Err(_) => unresolved_reference_count += 1,
1215 }
1216 }
1217 }
1218 if unresolved_reference_count > 0 {
1219 crate::error::trace_recovery_count(
1220 "resolve_encrypt_reference",
1221 "unresolved_reference",
1222 unresolved_reference_count,
1223 );
1224 }
1225 resolved_dictionary
1226}
1227
1228impl PdfDocument {
1229 /// Collapse a gated RTL visual line into one VISUAL-order span: explode every
1230 /// span into per-glyph `(x, char)` (reusing the `to_chars` advance arithmetic),
1231 /// drop producer shatter spaces, sort base letters by ascending x (visual
1232 /// left-to-right), bind each combining mark to its nearest base, and re-insert
1233 /// a single space at genuine inter-word x-gaps. The downstream
1234 /// [`push_span_text_bidi`] then reverses this to correct logical order with
1235 /// marks kept attached (`reverse_rtl_keeping_marks`).
1236 /// Private-use sentinel that [`merge_rtl_line_to_visual_span`] emits in place
1237 /// of a SPACE at an AUTHORITATIVE producer-segmented Arabic word boundary, so
1238 /// the downstream [`strip_interior_arabic_spaces`] (which strips only U+0020)
1239 /// leaves it intact instead of mistaking a genuine word break for a
1240 /// cursive-shatter artefact. Every output site restores it to a SPACE right
1241 /// after the strip ([`push_span_text_bidi`] for plain text,
1242 /// [`apply_rtl_logical_order_to_ordered_spans`] for md/html). U+F8FF is in the
1243 /// Unicode private-use area and never appears in real producer text reaching
1244 /// the pure-RTL merge path.
1245 const RTL_WORD_BOUNDARY: char = '\u{F8FF}';
1246
1247 /// Return all extraction warnings accumulated since this document was opened.
1248 ///
1249 /// Warnings are recorded when silent fallbacks occur during text extraction
1250 /// (e.g., missing ToUnicode CMap, font not found, malformed structure tree).
1251 /// They do NOT consume the warning list — use [`Self::take_warnings`] to drain it.
1252 ///
1253 /// This API makes previously invisible extraction degradations programmatically
1254 /// observable without requiring callers to hook into the `log` crate.
1255 pub fn warnings(&self) -> Vec<String> {
1256 self.accumulated_warnings.lock_or_recover().clone()
1257 }
1258
1259 /// Drain and return all accumulated extraction warnings, clearing the list.
1260 ///
1261 /// After this call, [`Self::warnings`] returns an empty `Vec` until new warnings
1262 /// are generated. Useful for incremental processing pipelines that want to
1263 /// inspect warnings on a per-page or per-operation basis.
1264 pub fn take_warnings(&self) -> Vec<String> {
1265 std::mem::take(&mut *self.accumulated_warnings.lock_or_recover())
1266 }
1267
1268 /// Record an extraction warning. Called internally when a silent fallback occurs.
1269 pub(crate) fn push_warning(&self, msg: impl Into<String>) {
1270 self.accumulated_warnings.lock_or_recover().push(msg.into());
1271 }
1272
1273 /// Return the document's accumulated structured warnings as a
1274 /// snapshot. Each entry carries the warning's
1275 /// [`WarningCategory`](crate::extractors::warnings::WarningCategory),
1276 /// page (if applicable), human-readable message, and PDF spec
1277 /// section reference (when applicable).
1278 ///
1279 /// Unlike [`Self::warnings`] which returns plain strings, this
1280 /// accessor returns structured records callers can filter, route
1281 /// to observability dashboards, or assert on in tests without
1282 /// parsing message text. Pairs with the `pyo3_log` per-target
1283 /// default-level downgrade to give Python users a clean stderr
1284 /// experience plus an opt-in structured surface.
1285 ///
1286 /// Returns the warnings in insertion order. The vector is
1287 /// non-destructive: subsequent calls return the same entries
1288 /// plus any new ones pushed since the last call. Use
1289 /// [`Self::take_structured_warnings`] to drain.
1290 ///
1291 /// Merges the process-wide `GLOBAL_WARNING_SINK` (where
1292 /// free-function log sites like `SPEC VIOLATION`,
1293 /// operator-cap-exceeded, and Type0/Type3 font fallbacks push
1294 /// their structured records) into the per-document sink on each
1295 /// call. The drain attribution follows the "first caller wins"
1296 /// rule documented at the global sink — process-wide scope means
1297 /// the first document to call `structured_warnings` collects
1298 /// the global tail that accumulated since the last drain.
1299 ///
1300 /// Renamed from `flatten_warnings` in to avoid colliding
1301 /// with the pre-existing `DocumentEditor::flatten_warnings`
1302 /// (which returns the form-flattening side-effect log, a
1303 /// `&[String]` — different feature). Both the Rust and Python
1304 /// (`PyDocument`) surfaces now agree on `structured_warnings`.
1305 pub fn structured_warnings(&self) -> Vec<crate::extractors::warnings::Warning> {
1306 let global = crate::extractors::warnings::drain_global_warnings();
1307 if !global.is_empty() {
1308 self.warning_sink.extend(global);
1309 }
1310 self.warning_sink.snapshot()
1311 }
1312
1313 /// Drain and return all accumulated structured warnings.
1314 /// Companion to [`Self::structured_warnings`].
1315 pub fn take_structured_warnings(&self) -> Vec<crate::extractors::warnings::Warning> {
1316 self.warning_sink.take()
1317 }
1318
1319 /// Record a structured warning. Hook called from migrated
1320 /// `tracing::warn!` sites that also want to surface the warning as
1321 /// structured data.
1322 ///
1323 /// Exposed as `pub` so external diagnostic sources (custom
1324 /// extractors, FFI hooks) can also push warnings into the same
1325 /// sink that [`Self::structured_warnings`] surfaces.
1326 pub fn push_structured_warning(&self, warning: crate::extractors::warnings::Warning) {
1327 self.warning_sink.push(warning);
1328 }
1329
1330 // ========================================================================
1331 // Debug/profiling helpers — thin pub wrappers over internal methods.
1332 // Used by examples/debug_katalog.rs to break extract_spans into phases.
1333 // ======================================================================== ~keep
1334}
1335
1336/// Reference to an extracted image file.
1337///
1338/// Contains metadata about an image that has been extracted and saved to a file.
1339/// Used for HTML export to embed images with correct dimensions and format.
1340#[derive(Debug, Clone, PartialEq)]
1341pub struct ExtractedImageRef {
1342 /// Filename of the saved image (e.g., "img_001.png")
1343 pub filename: String,
1344 /// Image format
1345 pub format: ImageFormat,
1346 /// Image width in pixels
1347 pub width: u32,
1348 /// Image height in pixels
1349 pub height: u32,
1350 /// Bounding box in PDF user space
1351 pub bbox: Option<crate::geometry::Rect>,
1352 /// Rotation in degrees
1353 pub rotation: i32,
1354 /// Transformation matrix
1355 pub matrix: [f32; 6],
1356}
1357
1358/// Image format for extracted images.
1359#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1360pub enum ImageFormat {
1361 /// PNG format (lossless)
1362 Png,
1363 /// JPEG format (lossy, preserves DCT-encoded images)
1364 Jpeg,
1365}
1366
1367/// Extract the /Root reference from a trailer dictionary.
1368fn get_root_ref_from_trailer(trailer: &Object) -> Option<ObjectRef> {
1369 trailer.as_dict()?.get("Root")?.as_reference()
1370}
1371
1372/// First in-use *uncompressed* object in the xref, used as a /Root-independent
1373/// probe for the garbage-prefix offset-shift decision. Compressed
1374/// entries can't be seek-validated, so they're skipped.
1375fn first_in_use_uncompressed(xref: &crate::xref::CrossRefTable) -> Option<ObjectRef> {
1376 xref.all_object_numbers()
1377 .filter_map(|n| xref.get(n).map(|e| (n, e)))
1378 .find(|(_, e)| e.in_use && e.entry_type == crate::xref::XRefEntryType::Uncompressed)
1379 .map(|(n, e)| ObjectRef::new(n, e.generation))
1380}
1381
1382/// Heuristic: does this candidate table actually look like wrapped prose
1383/// clustered into x-columns rather than a real grid?
1384///
1385/// Cell contents in real data tables are atomic units (numbers, codes,
1386/// names, short labels): they almost always start with an uppercase
1387/// letter, a digit, or a symbol (currency, +/-, punctuation marker)
1388/// rarely end with a mid-sentence comma or semicolon. Prose-as-table
1389/// cells, by contrast, are fragments of running sentences — they
1390/// frequently start with a lowercase stopword ("and", "the", "to") because
1391/// the column boundary fell mid-clause, and frequently end with `,` or
1392/// `;` for the same reason.
1393///
1394/// We reject the candidate when either signal exceeds its threshold:
1395/// • > 12 % of cells end in `,` or `;` (mid-sentence tails), or
1396/// • > 25 % of cells start with a lowercase ASCII letter
1397/// (continuation fragments).
1398///
1399/// Thresholds chosen to clear the false positives flagged in the 88-PDF
1400/// regression (`searchable.pdf`, the WFMYY press-release, several arxiv
1401/// preprints) without disturbing legitimate data tables — sailing scores,
1402/// IRS forms, and the CJK traffic-volume grid all stay well below both
1403/// bars.
1404fn looks_like_prose_table(table: &crate::structure::Table) -> bool {
1405 let mut total = 0usize;
1406 let mut sentence_tails = 0usize;
1407 let mut lower_starts = 0usize;
1408 let mut leader_dots = 0usize;
1409 for row in &table.rows {
1410 for cell in &row.cells {
1411 let trimmed = cell.text.trim();
1412 if trimmed.is_empty() {
1413 continue;
1414 }
1415 total += 1;
1416 if let Some(last) = trimmed.chars().last()
1417 && matches!(last, ',' | ';')
1418 {
1419 sentence_tails += 1;
1420 }
1421 if let Some(first) = trimmed.chars().next()
1422 && first.is_ascii_lowercase()
1423 {
1424 lower_starts += 1;
1425 }
1426 // Table-of-contents leader runs (". . . . . . ." between an
1427 // entry's title and its page number) cluster into their own
1428 // x-columns and create phantom 10–12-column "tables" out of
1429 // an ordinary three-column TOC. A cell whose content is
1430 // exclusively dots and spaces is the leader, not data. ~keep
1431 if trimmed.chars().all(|c| c == '.' || c == ' ') {
1432 leader_dots += 1;
1433 }
1434 }
1435 }
1436 if total < 10 {
1437 return false;
1438 }
1439 let tail_ratio = sentence_tails as f32 / total as f32;
1440 let lower_ratio = lower_starts as f32 / total as f32;
1441 let leader_ratio = leader_dots as f32 / total as f32;
1442 tail_ratio > 0.12 || lower_ratio > 0.25 || leader_ratio > 0.10
1443}
1444
1445/// Check whether the object at the xref offset for `obj_ref` looks like a valid header.
1446fn validate_object_at_offset<R: Read + Seek>(
1447 reader: &mut R,
1448 xref: &crate::xref::CrossRefTable,
1449 obj_ref: ObjectRef,
1450) -> bool {
1451 let entry = match xref.get(obj_ref.id) {
1452 Some(e) => e,
1453 None => return false,
1454 };
1455 // Compressed objects live inside object streams — their "offset" is the
1456 // stream object number, not a byte position. We cannot validate them by
1457 // seeking, but their presence in a correctly parsed xref stream is
1458 // sufficient proof that the xref is valid. ~keep
1459 if entry.entry_type == crate::xref::XRefEntryType::Compressed {
1460 return true;
1461 }
1462 if reader.seek(SeekFrom::Start(entry.offset)).is_err() {
1463 return false;
1464 }
1465 let mut buf = [0u8; 32];
1466 let n = reader.read(&mut buf).unwrap_or(0);
1467 if n == 0 {
1468 return false;
1469 }
1470 let s = String::from_utf8_lossy(&buf[..n]);
1471 // A valid object header starts with "N G obj" ~keep
1472 let mut parts = s.split_whitespace();
1473 let first_is_num = parts.next().is_some_and(|t| t.parse::<u32>().is_ok());
1474 let second_is_num = parts.next().is_some_and(|t| t.parse::<u16>().is_ok());
1475 let third_is_obj = parts.next().is_some_and(|t| t == "obj" || t.starts_with("obj"));
1476 first_is_num && second_is_num && third_is_obj
1477}
1478
1479/// Validate that the /Root catalog object is loadable from the xref.
1480fn validate_root_loadable<R: Read + Seek>(reader: &mut R, xref: &crate::xref::CrossRefTable, trailer: &Object) -> bool {
1481 let root_ref = match get_root_ref_from_trailer(trailer) {
1482 Some(r) => r,
1483 None => return false,
1484 };
1485 validate_object_at_offset(reader, xref, root_ref)
1486}
1487
1488/// Check if a byte slice contains the standalone "obj" keyword (not "endobj").
1489///
1490/// This is used during multi-line object header parsing to detect when we've
1491/// accumulated enough lines to have a complete header. A naive `contains("obj")`
1492/// would match "endobj" and cause the loop to exit prematurely.
1493///
1494/// Takes `&[u8]` rather than `&str`: the caller's header text comes from
1495/// `String::from_utf8_lossy` over attacker-controlled bytes at an
1496/// attacker-controlled xref offset, so it can legitimately decode to a
1497/// `String` containing multi-byte UTF-8 characters (e.g. a 4-byte emoji)
1498/// immediately followed by the ASCII bytes `obj`. Slicing that `&str` at a
1499/// fixed byte offset (as the previous `&s[i - 3..i]` did) panics with "byte
1500/// index N is not a char boundary" whenever the offset lands inside such a
1501/// character — the same defect shape fixed for `fonts::cmap` in
1502/// `29fdd59d69`. Operating on the raw bytes instead has no char-boundary
1503/// concept to violate: any `i` satisfying the existing bounds checks below
1504/// is always a valid slice index. ~keep
1505fn has_standalone_obj_keyword(s: &[u8]) -> bool {
1506 for (i, window) in s.windows(3).enumerate() {
1507 if window != b"obj" {
1508 continue;
1509 }
1510 if i >= 3 && &s[i - 3..i] == b"end" {
1511 continue;
1512 }
1513 // Must be at a word boundary: preceded by whitespace, digit, or start of string ~keep
1514 if i == 0 || s[i - 1].is_ascii_whitespace() || s[i - 1].is_ascii_digit() {
1515 return true;
1516 }
1517 }
1518 false
1519}
1520
1521/// Parse PDF header (%PDF-x.y) from a reader.
1522///
1523/// # Arguments
1524///
1525/// * `reader` - A readable and seekable source (e.g., File, Cursor)
1526/// * `lenient` - If false, fail if header not at byte 0; if true, search first 8192 bytes
1527///
1528/// # Returns
1529///
1530/// Returns `Ok((major, minor, offset))` with the PDF version and byte offset where header was found.
1531/// In strict mode, offset will be 0 if successful. In lenient mode, offset may be > 0 for PDFs
1532/// with leading binary data (compliant with ISO 32000-1:2008, page 41).
1533///
1534/// # Examples
1535///
1536/// ```rust
1537/// use std::io::Cursor;
1538/// # use xberg_native_pdf::document::parse_header;
1539///
1540/// let data = b"%PDF-1.7\n";
1541/// let mut cursor = Cursor::new(data);
1542/// let (major, minor, offset) = parse_header(&mut cursor, false).unwrap();
1543/// assert_eq!((major, minor, offset), (1, 7, 0));
1544/// ```
1545pub fn parse_header<R: Read + Seek>(reader: &mut R, lenient: bool) -> Result<(u8, u8, u64)> {
1546 let start_pos = reader.stream_position().unwrap_or(0);
1547
1548 // Read first 8 bytes for fast path (header at byte 0) ~keep
1549 let mut header = [0u8; 8];
1550 let strict_read_ok = match reader.read_exact(&mut header) {
1551 Ok(_) => {
1552 if &header[0..5] == b"%PDF-" {
1553 return parse_version_from_header(&header, lenient).map(|(major, minor)| (major, minor, 0));
1554 }
1555 true
1556 }
1557 Err(e) => {
1558 if e.kind() == std::io::ErrorKind::UnexpectedEof {
1559 if !lenient {
1560 return Err(Error::InvalidHeader(
1561 "File too short for PDF header (expected at least 8 bytes)".to_string(),
1562 ));
1563 }
1564 false
1565 } else {
1566 return Err(Error::InvalidHeader(format!("Failed to read file: {}", e)));
1567 }
1568 }
1569 };
1570
1571 if !lenient && strict_read_ok {
1572 return Err(Error::InvalidHeader(format!(
1573 "Expected '%PDF-' at byte 0, found '{}'",
1574 String::from_utf8_lossy(&header[0..5])
1575 )));
1576 }
1577
1578 reader.seek(SeekFrom::Start(start_pos))?;
1579
1580 let mut buffer = vec![0u8; 8192];
1581 let bytes_read = match reader.read(&mut buffer) {
1582 Ok(0) => return Err(Error::InvalidHeader("File is empty (0 bytes read)".to_string())),
1583 Ok(n) => n,
1584 Err(e) => {
1585 return Err(Error::InvalidHeader(format!(
1586 "I/O error while searching for PDF header: {}",
1587 e
1588 )));
1589 }
1590 };
1591
1592 buffer.truncate(bytes_read);
1593
1594 match find_substring(&buffer, b"%PDF-") {
1595 Some(offset) => {
1596 if offset + 8 > buffer.len() {
1597 return Err(Error::InvalidHeader(
1598 "PDF header found but insufficient bytes for version".to_string(),
1599 ));
1600 }
1601
1602 let header_bytes = &buffer[offset..offset + 8];
1603 let mut header_arr = [0u8; 8];
1604 header_arr.copy_from_slice(header_bytes);
1605
1606 let (major, minor) = parse_version_from_header(&header_arr, true)?;
1607
1608 // Standardize reader position to just after the header
1609 // (consistent with strict mode behavior at line 4378) ~keep
1610 let header_start = start_pos + offset as u64;
1611 let after_header = header_start + 8;
1612 reader.seek(SeekFrom::Start(after_header))?;
1613
1614 Ok((major, minor, header_start))
1615 }
1616 None => {
1617 if lenient {
1618 // Some PDFs lack a %PDF- header entirely (e.g., start with a binary
1619 // comment like %\xe2\xe3\xcf\xd3). Default to version 1.4. ~keep
1620 tracing::warn!(
1621 operation = "parse_pdf_header",
1622 reason = "missing_header",
1623 "PDF header recovery defaulted to version 1.4"
1624 );
1625 reader.seek(SeekFrom::Start(0))?;
1626 Ok((1, 4, 0))
1627 } else {
1628 Err(Error::InvalidHeader(
1629 "No PDF header found in first 8192 bytes of file".to_string(),
1630 ))
1631 }
1632 }
1633 }
1634}
1635
1636/// Parse version information from a header buffer.
1637/// Assumes buffer starts with "%PDF-" and has at least 8 bytes.
1638///
1639/// When `lenient` is true, malformed version strings (e.g., `%PDF-1.\n`, `%PDF-a.4`)
1640/// default to version (1, 4) instead of returning an error.
1641fn parse_version_from_header(header: &[u8; 8], lenient: bool) -> Result<(u8, u8)> {
1642 if &header[0..5] != b"%PDF-" {
1643 return Err(Error::InvalidHeader(format!(
1644 "Expected '%PDF-', found '{}'",
1645 String::from_utf8_lossy(&header[0..5])
1646 )));
1647 }
1648
1649 // Parse version (e.g., "1.7")
1650 // Format: %PDF-M.m where M is major version (1 digit), m is minor version (1 digit) ~keep
1651 if header[6] != b'.' {
1652 if lenient {
1653 tracing::warn!(
1654 operation = "parse_pdf_version",
1655 reason = "invalid_version_separator",
1656 "Malformed PDF version; defaulting to 1.4"
1657 );
1658 return Ok((1, 4));
1659 }
1660 return Err(Error::InvalidHeader(format!(
1661 "Invalid version format: expected '.', found '{}'",
1662 header[6] as char
1663 )));
1664 }
1665
1666 let major = header[5];
1667 let minor = header[7];
1668
1669 if !major.is_ascii_digit() || !minor.is_ascii_digit() {
1670 if lenient {
1671 tracing::warn!(
1672 operation = "parse_pdf_version",
1673 reason = "non_numeric_version",
1674 "Malformed PDF version; defaulting to 1.4"
1675 );
1676 return Ok((1, 4));
1677 }
1678 return Err(Error::InvalidHeader(format!(
1679 "Invalid version: {}.{} (not digits)",
1680 major as char, minor as char
1681 )));
1682 }
1683
1684 let major = major - b'0';
1685 let minor = minor - b'0';
1686
1687 if major > 2 || (major == 0 && minor == 0) {
1688 if lenient {
1689 tracing::warn!(
1690 operation = "parse_pdf_version",
1691 reason = "unsupported_version",
1692 version_major = major,
1693 version_minor = minor,
1694 "Unsupported PDF version; defaulting to 1.4"
1695 );
1696 return Ok((1, 4));
1697 }
1698 return Err(Error::UnsupportedVersion(format!("{}.{}", major, minor)));
1699 }
1700
1701 Ok((major, minor))
1702}
1703
1704/// Parse the trailer dictionary from a reader.
1705///
1706/// The trailer comes immediately after the xref table and before "startxref".
1707/// It starts with the keyword "trailer" followed by a dictionary.
1708///
1709/// # Example Format
1710///
1711/// ```text
1712/// trailer
1713/// << /Size 6 /Root 1 0 R /Info 5 0 R >>
1714/// startxref
1715/// 1234
1716/// %%EOF
1717/// ```
1718///
1719/// # Arguments
1720///
1721/// * `reader` - A readable source positioned after the xref table
1722///
1723/// # Returns
1724///
1725/// Returns the trailer dictionary as an `Object`.
1726///
1727/// # Errors
1728///
1729/// Returns an error if:
1730/// - The "trailer" keyword is not found
1731/// - The dictionary following "trailer" cannot be parsed
1732/// - The reader encounters an I/O error
1733pub fn parse_trailer<R: Read>(reader: &mut R) -> Result<Object> {
1734 let mut buffer = Vec::new();
1735 reader.read_to_end(&mut buffer)?;
1736
1737 let content = String::from_utf8_lossy(&buffer);
1738 let trailer_pos = content
1739 .find("trailer")
1740 .ok_or_else(|| Error::InvalidPdf("Trailer keyword not found after xref table".to_string()))?;
1741
1742 let dict_start = trailer_pos + 7;
1743 if dict_start >= buffer.len() {
1744 return Err(Error::UnexpectedEof);
1745 }
1746
1747 let (_, trailer_dict) = parse_object(&buffer[dict_start..]).map_err(|e| Error::ParseError {
1748 offset: dict_start,
1749 reason: format!("Failed to parse trailer dictionary: {:?}", e),
1750 })?;
1751
1752 if trailer_dict.as_dict().is_none() {
1753 return Err(Error::InvalidPdf("Trailer is not a dictionary".to_string()));
1754 }
1755
1756 Ok(trailer_dict)
1757}
1758
1759/// Find the first occurrence of a substring in a byte slice.
1760///
1761/// Returns the index of the first occurrence, or None if not found.
1762fn find_substring(haystack: &[u8], needle: &[u8]) -> Option<usize> {
1763 if needle.is_empty() {
1764 return Some(0);
1765 }
1766
1767 haystack.windows(needle.len()).position(|window| window == needle)
1768}
1769
1770#[cfg(test)]
1771mod tests;
1772
1773#[cfg(test)]
1774mod ink_dict_extractor_tests;
1775
1776#[cfg(test)]
1777mod open_signals_tests;
1778
1779mod annotations;
1780mod catalog;
1781mod columns;
1782mod extract_api;
1783mod fonts;
1784mod images;
1785mod key_value_rows;
1786mod objects;
1787mod open;
1788mod pages;
1789mod paths;
1790mod reading_order;
1791mod rect_api;
1792mod redaction;
1793mod span_postprocess;
1794mod spans_text;
1795mod tables;
1796pub use key_value_rows::reorder_ltr_key_value_rows;
1797mod text_assembly;