Skip to main content

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;