Skip to main content

xberg_native_pdf/document/
open.rs

1//! Opening, decryption, and whole-document classification.
2//!
3//! Split out of the parent's single 18,544-line `impl PdfDocument`, which made
4//! `document.rs` 1.2 MiB and tripped the 500 KiB file-safety limit. A child module's
5//! `impl` is the same inherent impl and sees the parent's private items unchanged. ~keep
6
7use super::*;
8use crate::content::{Operator, TextElement};
9use crate::extractors::auto::{ImageCodecClass, PageSignals, ProducerPrior, ReasonCode};
10
11impl PdfDocument {
12    /// Open a PDF document from in-memory bytes.
13    ///
14    /// This is the primary constructor for cases where
15    /// the PDF data is already fully loaded in memory. This parses the PDF by
16    /// wrapping the bytes in a memory reader and delegating to internal parsers.
17    ///
18    /// # Errors
19    ///
20    /// Returns an error if the PDF data is invalid, unsupported, or cannot be parsed.
21    #[tracing::instrument(name = "pdf.from_bytes", skip_all, fields(bytes = data.len()))]
22    pub fn from_bytes(data: Vec<u8>) -> Result<Self> {
23        match Self::open_from_bytes_inner(data) {
24            Ok(document) => Ok(document),
25            Err(error) => {
26                trace_open_error(&error);
27                Err(error)
28            }
29        }
30    }
31
32    /// Deprecated alias for `from_bytes`.
33    #[deprecated(since = "0.3.15", note = "Use `from_bytes` instead")]
34    pub fn open_from_bytes(data: Vec<u8>) -> Result<Self> {
35        Self::from_bytes(data)
36    }
37
38    /// Open a PDF document from a file path.
39    ///
40    /// Reads the entire file into memory, then parses the PDF structure.
41    /// This is the standard constructor for desktop/server environments.
42    ///
43    /// # Errors
44    ///
45    /// Returns an error if:
46    /// - The file cannot be opened or read
47    /// - The PDF header is invalid
48    /// - The cross-reference table is corrupted
49    /// - The trailer dictionary is invalid
50    ///
51    /// # Example
52    ///
53    /// ```no_run
54    /// use xberg_native_pdf::document::PdfDocument;
55    ///
56    /// let doc = PdfDocument::open("sample.pdf")?;
57    /// # Ok::<(), xberg_native_pdf::error::Error>(())
58    /// ```
59    #[cfg(not(target_arch = "wasm32"))]
60    #[tracing::instrument(name = "pdf.open", skip_all, fields(path = %path.as_ref().display()))]
61    pub fn open(path: impl AsRef<Path>) -> Result<Self> {
62        // Read once and route through `from_bytes` so the in-memory
63        // `source_bytes` field is populated. Path-loaded documents that
64        // skip this lose access to APIs that re-read the bytes
65        // (notably `compliance::convert_to_pdf_a`, which constructs a
66        // `DocumentEditor` from `source_bytes` — an empty Vec breaks
67        // it with `"Invalid PDF header: ... File is empty"`).
68        //
69        // The doc comment on this function already promised "Reads the
70        // entire file into memory"; this is making it true. ~keep
71        let data = match std::fs::read(path.as_ref()) {
72            Ok(data) => data,
73            Err(error) => {
74                let error = Error::Io(error);
75                trace_open_error(&error);
76                return Err(error);
77            }
78        };
79        Self::from_bytes(data)
80    }
81
82    /// Parse `data` into a document. The reader built here lives only for the
83    /// header, xref and trailer parse; the finished document keeps the bytes and
84    /// reads them by offset instead. ~keep
85    fn open_from_bytes_inner(data: Vec<u8>) -> Result<Self> {
86        let mut reader = PdfReader::Memory(BufReader::new(Cursor::new(data)));
87        // Parse header with lenient mode by default (handle PDFs with binary prefixes) ~keep
88        let (major, minor, header_offset) = parse_header(&mut reader, true)?;
89        let version = (major, minor);
90
91        let (mut xref, trailer, mut xref_reconstructed, mut synthetic_objects) =
92            Self::load_initial_xref_and_trailer(&mut reader)?;
93
94        if header_offset > 0 {
95            Self::adjust_xref_for_header_offset(&mut reader, &mut xref, &trailer, header_offset);
96        }
97
98        // Validate the /Root catalog is actually loadable. If not, the xref data is
99        // corrupt despite parsing successfully — fall back to reconstruction. ~keep
100        let (xref, trailer) = if validate_root_loadable(&mut reader, &xref, &trailer) {
101            (xref, trailer)
102        } else {
103            tracing::warn!(target: LOG_TARGET, "Root object not loadable after xref parse, falling back to xref reconstruction");
104            match Self::try_reconstruct_xref(&mut reader) {
105                Ok((x, t, syn)) => {
106                    xref_reconstructed = true;
107                    synthetic_objects = syn;
108                    (x, t)
109                }
110                Err(_) => (xref, trailer),
111            }
112        };
113
114        let prepopulated_scan = Self::build_prepopulated_scan(&xref, xref_reconstructed);
115
116        // The reader ends here: the document keeps the bytes and reads them by
117        // offset, so nothing downstream shares a cursor. ~keep
118        let PdfReader::Memory(buffered) = reader;
119        let source_bytes = buffered.into_inner().into_inner();
120
121        // Note: Encryption initialization was originally lazy, but decode_stream_with_encryption
122        // only has &self access which prevents initialization.
123        // We now initialize eagerly to ensure the handler is ready when needed. ~keep
124        let document = Self::build_document(
125            (source_bytes, version, xref, trailer),
126            (header_offset, xref_reconstructed, prepopulated_scan),
127        );
128
129        // Seed any SYNTHETIC recovery objects (a Catalog / page-tree root rebuilt
130        // for a truncated file) into the object cache. They have no byte offset,
131        // so `load_object` - which checks the cache before the xref - is the only
132        // way to reach them. Done before encryption init so the /Root resolves. ~keep
133        if !synthetic_objects.is_empty() {
134            let mut cache = document.object_cache.lock_or_recover();
135            for (obj_ref, obj) in synthetic_objects {
136                cache.insert(obj_ref, obj);
137            }
138        }
139
140        document.recover_object_streams_if_reconstructed(); // GH#1774 ~keep
141
142        if let Err(error) = document.ensure_encryption_initialized() {
143            trace_recoverable_pdf_error("initialize_encryption", &error);
144            // We continue anyway, as it might just be an unsupported security handler
145            // and maybe we can still read parts of the file (or fail later) ~keep
146        }
147
148        Ok(document)
149    }
150
151    /// A reconstruction scan already located every uncompressed "N G obj" in
152    /// the file, so a later full-file rescan (on the first object miss) would
153    /// find nothing new. Pre-seed the scan-offset cache so that miss is O(1);
154    /// only when reconstructed, since a normal (parsed) xref may be
155    /// legitimately partial and the full scan is the intended recovery path. ~keep
156    fn build_prepopulated_scan(xref: &CrossRefTable, xref_reconstructed: bool) -> Option<HashMap<u32, u64>> {
157        if !xref_reconstructed {
158            return None;
159        }
160        Some(
161            xref.all_object_numbers()
162                .filter_map(|id| {
163                    xref.get(id).and_then(|e| {
164                        (e.in_use && e.entry_type == crate::xref::XRefEntryType::Uncompressed).then_some((id, e.offset))
165                    })
166                })
167                .collect(),
168        )
169    }
170
171    fn load_initial_xref_and_trailer<R: Read + Seek>(
172        reader: &mut R,
173    ) -> Result<(CrossRefTable, Object, bool, Vec<(ObjectRef, Object)>)> {
174        let mut xref_reconstructed = false;
175        let mut synthetic_objects: Vec<(ObjectRef, Object)> = Vec::new();
176
177        let (xref, trailer) = match Self::try_open_regular(reader) {
178            Ok((xref, trailer)) => {
179                // Success with regular parsing
180                // However, if the xref is suspiciously small (< 5 entries), it's likely corrupted
181                // Try reconstruction to get a complete table ~keep
182                if xref.is_empty() {
183                    tracing::warn!(target: LOG_TARGET, "Regular xref parsing succeeded but table is empty, attempting reconstruction");
184                    xref_reconstructed = true;
185                    let (x, t, syn) = Self::try_reconstruct_xref(reader)?;
186                    synthetic_objects = syn;
187                    (x, t)
188                } else {
189                    // A valid xref can have any number of entries (§7.5.4).
190                    // Small xrefs (e.g. portfolio PDFs with 3-4 objects) are perfectly
191                    // normal — don't trigger expensive full-file reconstruction for them. ~keep
192                    (xref, trailer)
193                }
194            }
195            Err(e) => {
196                trace_xref_parse_failure(&e);
197                match Self::try_reconstruct_xref(reader) {
198                    Ok((reconstructed_xref, reconstructed_trailer, syn)) => {
199                        tracing::info!(target: LOG_TARGET, "Successfully reconstructed xref table");
200                        xref_reconstructed = true;
201                        synthetic_objects = syn;
202                        (reconstructed_xref, reconstructed_trailer)
203                    }
204                    Err(recon_err) => {
205                        tracing::warn!(
206                            target: crate::LOG_TARGET_ROOT,
207                            operation = "reconstruct_xref",
208                            primary_error_code = e.telemetry_code(),
209                            primary_error_offset = ?e.telemetry_offset(),
210                            reconstruction_error_code = recon_err.telemetry_code(),
211                            reconstruction_error_offset = ?recon_err.telemetry_offset(),
212                            "xref reconstruction failed; propagating original parse error"
213                        );
214                        return Err(e);
215                    }
216                }
217            }
218        };
219
220        Ok((xref, trailer, xref_reconstructed, synthetic_objects))
221    }
222
223    /// If PDF header is not at byte 0 (garbage-prepended), xref offsets may need adjustment.
224    /// The xref offsets are relative to the original PDF start, but file positions are
225    /// shifted by header_offset bytes. ~keep
226    fn adjust_xref_for_header_offset<R: Read + Seek>(
227        reader: &mut R,
228        xref: &mut CrossRefTable,
229        trailer: &Object,
230        header_offset: u64,
231    ) {
232        // Probe an object to decide whether xref offsets are off by
233        // header_offset. Prefer /Root (common case), but the probe MUST
234        // be seek-validatable: `validate_object_at_offset` returns true
235        // for *compressed* entries without seeking, so a /Root that
236        // lives in an object stream would falsely report "no shift
237        // needed" and leave every uncompressed offset wrong. Use /Root
238        // only when its entry is in-use + uncompressed; otherwise (no
239        // /Root — or a compressed /Root) fall back to the
240        // first in-use uncompressed object. ~keep
241        let probe = get_root_ref_from_trailer(trailer)
242            .filter(|r| {
243                xref.get(r.id)
244                    .is_some_and(|e| e.in_use && e.entry_type == crate::xref::XRefEntryType::Uncompressed)
245            })
246            .or_else(|| first_in_use_uncompressed(xref));
247        if let Some(probe_ref) = probe
248            && !validate_object_at_offset(reader, xref, probe_ref)
249        {
250            tracing::warn!(target: LOG_TARGET,
251                "Probe object {} not loadable at xref offset, adjusting all offsets by header_offset={}",
252                probe_ref.id,
253                header_offset
254            );
255            xref.shift_offsets(header_offset);
256        }
257    }
258
259    fn build_document(
260        parsed: (Vec<u8>, (u8, u8), CrossRefTable, Object),
261        recovery: (u64, bool, Option<HashMap<u32, u64>>),
262    ) -> Self {
263        let (source_bytes, version, xref, trailer) = parsed;
264        let (header_offset, xref_reconstructed, prepopulated_scan) = recovery;
265        Self {
266            source_bytes,
267            version,
268            xref,
269            trailer,
270            object_cache: Mutex::new(BoundedObjectCache::new(DEFAULT_OBJECT_CACHE_MAX_BYTES)),
271            object_stream_cache: Mutex::new(BoundedObjectStreamCache::new(DEFAULT_OBJECT_STREAM_CACHE_MAX_BYTES)),
272            object_stream_telemetry_seen: Mutex::new(BoundedRecoveryTelemetry::new(
273                DEFAULT_OBJECT_STREAM_RECOVERY_MARKERS,
274            )),
275            encryption_handler: Mutex::new(None),
276            encrypt_dict_ref: Mutex::new(None),
277            options: ParserOptions::default(),
278            header_offset,
279            font_cache: Mutex::new(BoundedEntryCache::new(512)),
280            font_set_cache: Mutex::new(BoundedEntryCache::new(256)),
281            font_fingerprint_cache: Mutex::new(BoundedEntryCache::new(256)),
282            font_name_set_cache: Mutex::new(BoundedEntryCache::new(256)),
283            font_set_donated_cache: Mutex::new(BoundedEntryCache::new(256)),
284            font_fingerprint_donated_cache: Mutex::new(BoundedEntryCache::new(256)),
285            font_name_set_donated_cache: Mutex::new(BoundedEntryCache::new(256)),
286            #[cfg(test)]
287            donation_call_count: AtomicUsize::new(0),
288            #[cfg(test)]
289            donation_apply_count: AtomicUsize::new(0),
290            font_identity_cache: Mutex::new(BoundedEntryCache::new(512)),
291            font_id_hash_cache: Mutex::new(HashMap::new()),
292            font_reference_hash_cache: Mutex::new(BoundedEntryCache::new(FONT_IDENTITY_MAX_RESOLVED_REFERENCES)),
293            font_identity_hashed_bytes: AtomicUsize::new(0),
294            font_identity_shared_cache_enabled: AtomicBool::new(true),
295            structure_tree_cache: Mutex::new(None),
296            structure_content_cache: Mutex::new(None),
297            actualtext_index_cache: Mutex::new(None),
298            mc_actualtext_mcids: Mutex::new(HashMap::new()),
299            table_elements_cache: Mutex::new(None),
300            page_cache: Mutex::new(HashMap::new()),
301            page_cache_populated: AtomicBool::new(false),
302            scanned_object_offsets: Mutex::new(prepopulated_scan),
303            objstm_recovery_done: Mutex::new(false),
304            xref_reconstructed,
305            image_xobject_cache: Mutex::new(HashSet::new()),
306            xobject_text_free_cache: Mutex::new(HashSet::new()),
307            xobject_stream_cache: Mutex::new(HashMap::new()),
308            xobject_stream_cache_bytes: AtomicUsize::new(0),
309            xobject_spans_cache: Mutex::new(BoundedEntryCache::new(DEFAULT_XOBJECT_CACHE_MAX_ENTRIES)),
310            form_xobject_images_cache: Mutex::new(BoundedEntryCache::new(DEFAULT_XOBJECT_CACHE_MAX_ENTRIES)),
311            erase_regions: Mutex::new(HashMap::new()),
312            page_content_cache: Mutex::new(BoundedEntryCache::new(64)),
313            page_spans_cache: Mutex::new(BoundedEntryCache::new(8)),
314            search_index: Mutex::new(HashMap::new()),
315            page_chars_cache: Mutex::new(BoundedEntryCache::new(8)),
316            running_artifact_signatures: Mutex::new(None),
317            article_threads_cache: Mutex::new(None),
318            output_intent_cmyk_profile_cache: Mutex::new(None),
319            accumulated_warnings: Mutex::new(Vec::new()),
320            warning_sink: crate::extractors::warnings::WarningSink::new(),
321            recovery: std::sync::Arc::default(),
322        }
323    }
324
325    /// Try to open the PDF using regular xref parsing.
326    fn try_open_regular<R: Read + Seek>(reader: &mut R) -> Result<(CrossRefTable, Object)> {
327        let xref_offset = find_xref_offset(reader)?;
328
329        let xref = parse_xref(reader, xref_offset)?;
330
331        let trailer = if let Some(trailer_dict) = xref.trailer() {
332            Object::Dictionary(trailer_dict.clone())
333        } else {
334            reader.seek(SeekFrom::Start(xref_offset))?;
335            parse_trailer(reader)?
336        };
337
338        Ok((xref, trailer))
339    }
340
341    /// Try to reconstruct the xref table by scanning the file. The third tuple
342    /// element is any SYNTHETIC objects (a rebuilt Catalog / page-tree root for a
343    /// truncated file) the caller must seed into the object cache - empty in the
344    /// ordinary case.
345    fn try_reconstruct_xref<R: Read + Seek>(
346        reader: &mut R,
347    ) -> Result<(CrossRefTable, Object, Vec<(ObjectRef, Object)>)> {
348        crate::xref_reconstruction::reconstruct_xref(reader)
349    }
350
351    /// Initialize encryption handler lazily if PDF is encrypted.
352    ///
353    /// PDF Spec: Section 7.6.1 - Encryption dictionary in trailer
354    ///
355    /// This checks for the /Encrypt entry in the trailer, loads it if it's a
356    /// reference, and creates an encryption handler. It automatically attempts
357    /// to authenticate with an empty password (common for PDFs with default encryption).
358    ///
359    /// This is called lazily the first time we need to decrypt something, after
360    /// the document is fully constructed and can load objects.
361    pub(super) fn ensure_encryption_initialized(&self) -> Result<()> {
362        if self.encryption_handler.lock_or_recover().is_some() {
363            return Ok(());
364        }
365        let Some(trailer_dict) = self.trailer.as_dict() else {
366            return Ok(());
367        };
368        let encrypt_ref = match trailer_dict.get("Encrypt") {
369            Some(obj) => obj.clone(),
370            None => {
371                tracing::debug!(target: LOG_TARGET, "PDF is not encrypted (no /Encrypt entry)");
372                return Ok(());
373            }
374        };
375        let file_id = match trailer_dict.get("ID") {
376            Some(Object::Array(arr)) => match arr.first() {
377                Some(first_id) => match first_id.as_string() {
378                    Some(id_bytes) => id_bytes.to_vec(),
379                    None => {
380                        tracing::warn!(target: LOG_TARGET, "Invalid /ID array entry (not a string), using empty file ID");
381                        vec![]
382                    }
383                },
384                None => {
385                    tracing::warn!(target: LOG_TARGET, "Empty /ID array, using empty file ID");
386                    vec![]
387                }
388            },
389            _ => {
390                tracing::warn!(target: LOG_TARGET, "Missing or invalid /ID entry in trailer, using empty file ID");
391                vec![]
392            }
393        };
394        let encrypt_obj = match encrypt_ref {
395            Object::Dictionary(_) => encrypt_ref,
396            Object::Reference(obj_ref) => {
397                tracing::debug!(target: LOG_TARGET,
398                    object_id = obj_ref.id,
399                    generation = obj_ref.generation,
400                    "loading /Encrypt object reference"
401                );
402                *self.encrypt_dict_ref.lock_or_recover() = Some(obj_ref);
403                self.load_object(obj_ref)?
404            }
405            _ => {
406                return Err(Error::InvalidPdf(format!(
407                    "Invalid /Encrypt entry type: {}",
408                    encrypt_ref.type_name()
409                )));
410            }
411        };
412        let encrypt_obj = match encrypt_obj.as_dict() {
413            Some(dict) => Object::Dictionary(resolve_encrypt_dictionary_references(dict, |reference| {
414                self.load_object(reference)
415            })),
416            None => encrypt_obj,
417        };
418        let mut handler = EncryptionHandler::new(&encrypt_obj, file_id)?;
419
420        match handler.authenticate(b"") {
421            Ok(true) => {
422                tracing::info!(target: LOG_TARGET, "Successfully authenticated with empty password");
423            }
424            Ok(false) => {
425                tracing::warn!(target: LOG_TARGET, "PDF is encrypted and requires a password");
426                self.push_warning(
427                    "PDF is encrypted and requires a password; call authenticate() before extracting text".to_string(),
428                );
429            }
430            Err(error) => {
431                trace_fatal_pdf_error("authenticate_empty_password", &error);
432                return Err(error);
433            }
434        }
435        *self.encryption_handler.lock_or_recover() = Some(handler);
436        Ok(())
437    }
438
439    /// Decode stream data with encryption support.
440    ///
441    /// This is a helper method that decodes stream data using the PDF's encryption handler
442    /// if the document is encrypted. It automatically handles object-specific key derivation.
443    ///
444    /// # Arguments
445    ///
446    /// * `stream_obj` - The stream object to decode
447    /// * `obj_ref` - The object reference (for encryption key derivation)
448    ///
449    /// # Returns
450    ///
451    /// The decoded (and decrypted if needed) stream data.
452    ///
453    /// # PDF Spec Reference
454    ///
455    /// ISO 32000-1:2008, Section 7.6.2 - Streams must be decrypted BEFORE applying filters.
456    pub(crate) fn decode_stream_with_encryption(&self, stream_obj: &Object, obj_ref: ObjectRef) -> Result<Vec<u8>> {
457        self.decode_stream_with_encryption_and_expected_size(stream_obj, obj_ref, None)
458    }
459
460    pub(crate) fn decode_image_stream_with_encryption(
461        &self,
462        stream_obj: &Object,
463        obj_ref: ObjectRef,
464        expected_filter_output_size: usize,
465    ) -> Result<Vec<u8>> {
466        self.decode_stream_with_encryption_and_expected_size(stream_obj, obj_ref, Some(expected_filter_output_size))
467    }
468
469    fn decode_stream_with_encryption_and_expected_size(
470        &self,
471        stream_obj: &Object,
472        obj_ref: ObjectRef,
473        expected_filter_output_size: Option<usize>,
474    ) -> Result<Vec<u8>> {
475        if matches!(stream_obj, Object::Null) {
476            return Ok(Vec::new());
477        }
478
479        // Per ISO 32000-2:2020 Section 7.6.3, object streams (/Type /ObjStm)
480        // and cross-reference streams (/Type /XRef) shall NOT be encrypted.
481        // Skip decryption for these stream types to avoid AES block-size errors
482        // on data that was never encrypted in the first place. ~keep
483        let is_unencrypted_stream_type = if let Object::Stream { dict, .. } = stream_obj {
484            dict.get("Type")
485                .and_then(|t| t.as_name())
486                .map(|name| name == "ObjStm" || name == "XRef")
487                .unwrap_or(false)
488        } else {
489            false
490        };
491
492        let handler_ref = self.encryption_handler.lock_or_recover();
493        if let Some(handler) = handler_ref.as_ref() {
494            if is_unencrypted_stream_type {
495                drop(handler_ref);
496                return match expected_filter_output_size {
497                    Some(expected_size) => stream_obj.decode_image_stream_data(expected_size),
498                    None => stream_obj.decode_stream_data(),
499                };
500            }
501            let decrypt_fn = |data: &[u8]| -> Result<Vec<u8>> {
502                handler.decrypt_stream(data, obj_ref.id, obj_ref.generation as u32)
503            };
504            stream_obj.decode_stream_data_with_decryption_and_expected_size(
505                Some(&decrypt_fn),
506                obj_ref.id,
507                obj_ref.generation as u32,
508                expected_filter_output_size,
509            )
510        } else {
511            drop(handler_ref);
512            match expected_filter_output_size {
513                Some(expected_size) => stream_obj.decode_image_stream_data(expected_size),
514                None => stream_obj.decode_stream_data(),
515            }
516        }
517    }
518
519    /// Open with custom extraction profile.
520    ///
521    /// Currently, the profile is not used at the document level but is reserved
522    /// for future integration with document-type-specific extraction settings.
523    #[cfg(not(target_arch = "wasm32"))]
524    pub fn open_with_config(path: impl AsRef<Path>, _config: impl std::any::Any) -> Result<Self> {
525        Self::open(path)
526    }
527
528    /// Authenticate with a password to decrypt encrypted PDFs.
529    ///
530    /// If the PDF is encrypted, `open()` automatically tries an empty password.
531    /// Call this method to authenticate with a non-empty password.
532    ///
533    /// # Arguments
534    ///
535    /// * `password` - The password as bytes
536    ///
537    /// # Returns
538    ///
539    /// `Ok(true)` if authentication succeeded, `Ok(false)` if the password was wrong,
540    /// or `Ok(true)` if the PDF is not encrypted (no authentication needed).
541    pub fn authenticate(&self, password: &[u8]) -> Result<bool> {
542        self.ensure_encryption_initialized()?;
543        // Capture current authentication state *before* calling the
544        // handler so we can detect the transition from "not authenticated"
545        // to "authenticated" and invalidate the object cache accordingly.
546        // Any objects loaded and cached before successful authentication
547        // still hold ciphertext strings (see `load_uncompressed_object_impl`
548        // at the `handler.is_authenticated()` guard), so a cache hit after
549        // authentication would return those stale values forever. ~keep
550        let was_authenticated = self
551            .encryption_handler
552            .lock_or_recover()
553            .as_ref()
554            .map(|h| h.is_authenticated())
555            .unwrap_or(true);
556
557        let result = match self.encryption_handler.lock_or_recover().as_mut() {
558            Some(handler) => handler.authenticate(password),
559            None => return Ok(true),
560        };
561
562        if let Ok(true) = result
563            && !was_authenticated
564        {
565            // Transitioned from "encrypted, not authenticated" to
566            // "authenticated". Drop every cached object so subsequent
567            // `load_object` calls re-parse through the path that now
568            // runs `decrypt_strings_in_object` on the uncompressed
569            // string values. The `/Encrypt` dictionary is not in this
570            // cache path (it is resolved independently), so clearing
571            // is always safe. ~keep
572            self.object_cache.lock_or_recover().clear();
573            tracing::debug!(target: LOG_TARGET,
574                "authenticate(): object cache cleared after successful authentication \
575                     to force re-decryption of any pre-auth cached objects"
576            );
577        }
578
579        result
580    }
581
582    /// Check if the PDF is encrypted.
583    ///
584    /// Returns `true` if the PDF has an `/Encrypt` entry in its trailer,
585    /// regardless of whether it has been authenticated.
586    ///
587    /// # Example
588    ///
589    /// ```no_run
590    /// # use xberg_native_pdf::document::PdfDocument;
591    /// # let mut doc = PdfDocument::open("sample.pdf")?;
592    /// if doc.is_encrypted() {
593    ///     println!("PDF is encrypted");
594    /// }
595    /// # Ok::<(), xberg_native_pdf::error::Error>(())
596    /// ```
597    pub fn is_encrypted(&self) -> bool {
598        if self.encryption_handler.lock_or_recover().is_some() {
599            return true;
600        }
601        self.trailer.as_dict().and_then(|d| d.get("Encrypt")).is_some()
602    }
603
604    /// Whether content extraction is permitted right now — `true` if the
605    /// PDF is unencrypted, or encrypted and successfully authenticated.
606    ///
607    /// Cheap, side-effect-free preflight for the auto-extraction
608    /// classifier: lets it emit
609    /// [`ReasonCode::EncryptedNoExtractPermission`](crate::extractors::auto::ReasonCode)
610    /// gracefully instead of attempting extraction and erroring.
611    #[must_use]
612    pub fn is_authenticated(&self) -> bool {
613        // Fail closed: if encryption init errors (malformed / unsupported
614        // `/Encrypt`), the document IS encrypted but we cannot have
615        // authenticated it — a security preflight must report `false`
616        // here, not `true`. Only when init succeeds
617        // (incl. the trivial unencrypted case) do we trust the guard. ~keep
618        if self.ensure_encryption_initialized().is_err() {
619            return false;
620        }
621        !self.is_encrypted_and_unauthenticated()
622    }
623
624    /// Document Info dictionary `/Producer` (decoded, trimmed), if present
625    /// and non-empty. A weak document-level prior for the scanner-vs-
626    /// authoring heuristic (case P) — never decisive.
627    #[must_use]
628    pub fn document_producer(&self) -> Option<String> {
629        self.document_info_string("Producer")
630    }
631
632    /// Document Info dictionary `/Creator` (decoded, trimmed), if present
633    /// and non-empty. See [`document_producer`](Self::document_producer).
634    #[must_use]
635    pub fn document_creator(&self) -> Option<String> {
636        self.document_info_string("Creator")
637    }
638
639    fn document_info_string(&self, key: &str) -> Option<String> {
640        let info_raw = self.trailer.as_dict()?.get("Info")?;
641        let info = self.resolve_obj_ref(info_raw);
642        let val_raw = info.as_dict()?.get(key)?.clone();
643        let val = self.resolve_obj_ref(&val_raw);
644        let s = Self::decode_pdf_text_string(val.as_string()?);
645        let trimmed = s.trim();
646        (!trimmed.is_empty()).then(|| trimmed.to_string())
647    }
648
649    /// Axis-aligned intersection area of a [`Rect`](crate::geometry::Rect)
650    /// with the page box `(x0, y0, x1, y1)`.
651    fn rect_isect_area(r: &crate::geometry::Rect, x0: f32, y0: f32, x1: f32, y1: f32) -> f32 {
652        let (rx1, ry1) = (r.x + r.width, r.y + r.height);
653        let ix = (rx1.min(x1) - r.x.max(x0)).max(0.0);
654        let iy = (ry1.min(y1) - r.y.max(y0)).max(0.0);
655        ix * iy
656    }
657
658    #[allow(clippy::type_complexity)]
659    fn accumulate_text_signals(
660        &self,
661        page: usize,
662        (px0, py0, px1, py1): (f32, f32, f32, f32),
663        page_area: f32,
664    ) -> (usize, f32, f32, f32, f32, String) {
665        let spans = self.extract_spans(page).unwrap_or_default();
666        let mut text = String::new();
667        let mut glyphs = 0usize;
668        let mut text_area = 0.0f32;
669        for s in &spans {
670            if s.artifact_type.is_some() {
671                continue;
672            }
673            let n = s.text.chars().count();
674            if n == 0 {
675                continue;
676            }
677            glyphs += n;
678            text.push_str(&s.text);
679            text.push(' ');
680            text_area += Self::rect_isect_area(&s.bbox, px0, py0, px1, py1);
681        }
682        let text_area_ratio = (text_area / page_area).clamp(0.0, 1.0);
683        let chars: Vec<char> = text.chars().collect();
684        let total = chars.len().max(1);
685        let bad = chars
686            .iter()
687            .filter(|&&c| c == '\u{FFFD}' || c.is_control() || ('\u{E000}'..='\u{F8FF}').contains(&c))
688            .count();
689        let garbled_ratio = bad as f32 / total as f32;
690        let word_text: String = self
691            .extract_words(page)
692            .unwrap_or_default()
693            .into_iter()
694            .map(|w| w.text)
695            .collect::<Vec<_>>()
696            .join(" ");
697        let words: Vec<&str> = word_text.split_whitespace().collect();
698        let (fragmented_word_ratio, consecutive_repeat_ratio) = if words.is_empty() {
699            (0.0, 0.0)
700        } else {
701            let frag = words.iter().filter(|w| w.chars().count() <= 2).count() as f32 / words.len() as f32;
702            let rep = words.windows(2).filter(|w| w[0] == w[1]).count() as f32 / words.len() as f32;
703            let frag = if crate::extractors::auto::is_cjk_dominant_text(&word_text) {
704                0.0
705            } else {
706                frag
707            };
708            (frag, rep)
709        };
710        (
711            glyphs,
712            text_area_ratio,
713            garbled_ratio,
714            fragmented_word_ratio,
715            consecutive_repeat_ratio,
716            word_text,
717        )
718    }
719
720    fn classify_page_images(
721        &self,
722        page: usize,
723        (px0, py0, px1, py1): (f32, f32, f32, f32),
724        page_area: f32,
725    ) -> (f32, ImageCodecClass, usize) {
726        // Uses the cheap Phase 1 handle enumeration (`page_image_handles`) instead of
727        // `extract_images`, which fully decodes pixel data; `handle.bbox`/`filter_chain`
728        // give this loop everything it needs without that cost (GH#1732 is the only
729        // caller needing decoded pixels). Keeps `extract_images`'s 8x8 px size floor:
730        // a 1x1 background-fill image stretched full-bleed would otherwise mis-route a
731        // born-digital slide to OCR. ~keep
732        let images = self.page_image_handles(page).unwrap_or_default();
733        let size_floor = ImageExtractFilter::default();
734        let mut img_area = 0.0f32;
735        let mut codec = ImageCodecClass::None;
736        for im in &images {
737            if i64::from(im.width) < size_floor.min_width || i64::from(im.height) < size_floor.min_height {
738                continue;
739            }
740            img_area += Self::rect_isect_area(&im.bbox, px0, py0, px1, py1);
741            let has_ccitt = im
742                .filter_chain
743                .iter()
744                .any(|f| matches!(f, crate::extractors::images::PdfFilter::CCITTFaxDecode));
745            let has_dct = im
746                .filter_chain
747                .iter()
748                .any(|f| matches!(f, crate::extractors::images::PdfFilter::DCTDecode));
749            let c = if has_ccitt {
750                ImageCodecClass::Ccitt
751            } else if has_dct {
752                ImageCodecClass::Dct
753            } else {
754                ImageCodecClass::Other
755            };
756            codec = match (codec, c) {
757                (ImageCodecClass::None, x) => x,
758                (_, ImageCodecClass::Ccitt) => ImageCodecClass::Ccitt,
759                (cur, _) => cur,
760            };
761        }
762        let image_area_ratio = (img_area / page_area).clamp(0.0, 1.0);
763        (image_area_ratio, codec, images.len())
764    }
765
766    fn compute_invisible_text_ratio(&self, page: usize) -> f32 {
767        let mut invisible = 0usize;
768        let mut glyph_bytes = 0usize;
769        if let Ok(data) = self.get_page_content_data(page)
770            && let Ok(ops) = crate::content::parse_content_stream(&data)
771        {
772            let mut rm: u8 = 0;
773            let mut stack: Vec<u8> = Vec::new();
774            for op in &ops {
775                match op {
776                    Operator::SaveState => stack.push(rm),
777                    Operator::RestoreState => {
778                        if let Some(p) = stack.pop() {
779                            rm = p;
780                        }
781                    }
782                    Operator::Tr { render } => rm = *render,
783                    Operator::Tj { text } => {
784                        glyph_bytes += text.len();
785                        if rm == 3 {
786                            invisible += text.len();
787                        }
788                    }
789                    Operator::TJ { array } => {
790                        let g: usize = array
791                            .iter()
792                            .map(|e| match e {
793                                TextElement::String(b) => b.len(),
794                                TextElement::Offset(_) => 0,
795                            })
796                            .sum();
797                        glyph_bytes += g;
798                        if rm == 3 {
799                            invisible += g;
800                        }
801                    }
802                    _ => {}
803                }
804            }
805        }
806        if glyph_bytes == 0 {
807            0.0
808        } else {
809            invisible as f32 / glyph_bytes as f32
810        }
811    }
812
813    fn classify_producer_prior(&self) -> ProducerPrior {
814        let p = format!(
815            "{} {}",
816            self.document_producer().unwrap_or_default(),
817            self.document_creator().unwrap_or_default()
818        )
819        .to_lowercase();
820        const SCAN: &[&str] = &[
821            "scan",
822            "abbyy",
823            "tesseract",
824            "scansnap",
825            "finereader",
826            "ocr",
827            "lens",
828            "camscanner",
829            "kofax",
830        ];
831        const AUTH: &[&str] = &[
832            "word",
833            "libreoffice",
834            "latex",
835            "pdftex",
836            "chromium",
837            "skia",
838            "quartz",
839            "wkhtmltopdf",
840            // All three names are kept: "pdf_oxide" (upstream), "xberg-pdf-oxide" (the
841            // fork), "xberg-native-pdf" (since vendoring) all still appear in real
842            // files; dropping the older two would misclassify real documents as
843            // scanned. ~keep
844            "pdf_oxide",
845            "xberg-pdf-oxide",
846            "xberg-native-pdf",
847            "reportlab",
848            "prince",
849            "weasyprint",
850            "powerpoint",
851            "excel",
852            "indesign",
853        ];
854        if SCAN.iter().any(|k| p.contains(k)) {
855            ProducerPrior::Scanner
856        } else if AUTH.iter().any(|k| p.contains(k)) {
857            ProducerPrior::Authoring
858        } else {
859            ProducerPrior::Unknown
860        }
861    }
862
863    /// Gather per-page classification signals from xberg-native-pdf
864    /// **internals** (00-common-foundation §9 — never the flattened
865    /// output string). Returns the signals plus the enriched T0.5
866    /// quality-gate verdict (research §3a) computed from the *same*
867    /// single span extraction (no double work). Pure inspection.
868    fn gather_page_signals(&self, page: usize) -> Result<(PageSignals, Option<ReasonCode>)> {
869        let (llx, lly, urx, ury) = self.get_page_media_box(page)?;
870        let rot = self.get_page_rotation(page).unwrap_or(0);
871        let (mut pw, mut ph) = ((urx - llx).abs(), (ury - lly).abs());
872        if rot % 180 != 0 {
873            std::mem::swap(&mut pw, &mut ph);
874        }
875        let page_area = (pw * ph).max(1.0);
876        let bbox = (llx.min(urx), lly.min(ury), llx.max(urx), lly.max(ury));
877
878        let (glyphs, text_area_ratio, garbled_ratio, fragmented_word_ratio, consecutive_repeat_ratio, word_text) =
879            self.accumulate_text_signals(page, bbox, page_area);
880        let (image_area_ratio, codec, images_len) = self.classify_page_images(page, bbox, page_area);
881        let invisible_text_ratio = self.compute_invisible_text_ratio(page);
882        let path_count = self.extract_paths(page).map(|p| p.len()).unwrap_or(0);
883        let vector_path_density = {
884            let denom = (path_count + glyphs + images_len).max(1) as f32;
885            (path_count as f32 / denom).clamp(0.0, 1.0)
886        };
887        let has_reliable_structure = self.mark_info().map(|m| m.is_structure_reliable()).unwrap_or(false);
888        let producer_prior = self.classify_producer_prior();
889        let page_is_empty = glyphs == 0 && image_area_ratio < 0.01 && path_count == 0;
890
891        let signals = PageSignals {
892            text_glyph_count: glyphs,
893            text_area_ratio,
894            image_area_ratio,
895            codec,
896            invisible_text_ratio,
897            garbled_ratio,
898            fragmented_word_ratio,
899            consecutive_repeat_ratio,
900            vector_path_density,
901            has_reliable_structure,
902            producer_prior,
903            page_is_empty,
904        };
905        let gate = crate::extractors::auto::text_quality_gate(&word_text);
906        Ok((signals, gate))
907    }
908
909    /// Cheap per-page text-vs-OCR classification (the `classify_page`
910    /// preflight — no OCR, no rasterisation). Returns kind +
911    /// confidence + typed [`ReasonCode`](crate::extractors::auto::ReasonCode)
912    /// + the raw signals (explainable).
913    ///
914    /// Fails closed on an encrypted-unauthenticated document
915    /// (`Error::EncryptedPdf`, case L) — consistent with every other
916    /// `extract_*`; the graceful warn+fallback applies to *extraction*
917    /// (`extract_page_auto`), not this preflight.
918    pub fn classify_page(&self, page: usize) -> Result<crate::extractors::auto::PageClassification> {
919        use crate::extractors::auto::{AutoExtractOptions, PageClassification, PageKind, classify_from_signals};
920        if !self.is_authenticated() {
921            return Err(Error::EncryptedPdf);
922        }
923        let (signals, gate) = self.gather_page_signals(page)?;
924        let opts = AutoExtractOptions::balanced();
925        let (mut kind, mut confidence, mut reason) = classify_from_signals(&signals, &opts);
926        if matches!(kind, PageKind::TextLayer)
927            && let Some(r) = gate
928        {
929            kind = PageKind::Scanned;
930            confidence = confidence.min(0.80);
931            reason = r;
932        }
933        Ok(PageClassification {
934            page,
935            kind,
936            confidence,
937            reason,
938            signals,
939        })
940    }
941
942    /// Cheap whole-document classification: per-page kinds (the
943    /// decision is **per-page**, never one forced doc mode — case Q),
944    /// the 0-based `pages_needing_ocr` list, and an aggregate summary.
945    ///
946    /// Fails closed on an encrypted-unauthenticated document
947    /// (`Error::EncryptedPdf`) — a security op must never be silently
948    /// degraded to a benign `Empty`. Any *non-security*
949    /// per-page failure degrades to `Empty` (graceful — only security
950    /// ops fail closed).
951    pub fn classify_document(&self) -> Result<crate::extractors::auto::DocumentClassification> {
952        use crate::extractors::auto::{DocumentClassification, PageKind, summarise};
953        let n = self.page_count()?;
954        let mut kinds = Vec::with_capacity(n);
955        let mut need = Vec::new();
956        for p in 0..n {
957            let k = match self.classify_page(p) {
958                Ok(c) => c.kind,
959                Err(e @ Error::EncryptedPdf) => return Err(e),
960                Err(_) => PageKind::Empty,
961            };
962            if matches!(k, PageKind::Scanned | PageKind::ImageText | PageKind::Mixed) {
963                need.push(p);
964            }
965            kinds.push(k);
966        }
967        let summary = summarise(&kinds);
968        Ok(DocumentClassification {
969            pages: kinds,
970            pages_needing_ocr: need,
971            summary,
972        })
973    }
974
975    /// One-shot convenience for the 90% case: equivalent to
976    /// `AutoExtractor::new().extract_text(self, page)`. **Strictly
977    /// additive** — the existing [`extract_text`](Self::extract_text) is
978    /// byte-identical/unchanged; this is a *new* opt-in entry point that
979    /// auto-routes text-vs-OCR with graceful native fallback.
980    pub fn extract_text_auto(&self, page: usize) -> Result<String> {
981        crate::extractors::auto::AutoExtractor::new().extract_text(self, page)
982    }
983
984    /// Check if the PDF is encrypted but has NOT been successfully authenticated.
985    ///
986    /// This returns `true` when the document requires a password that has not
987    /// yet been provided. Extraction methods use this to return a clear error
988    /// instead of silently producing empty output.
989    fn is_encrypted_and_unauthenticated(&self) -> bool {
990        if let Some(handler) = self.encryption_handler.lock_or_recover().as_ref() {
991            !handler.is_authenticated()
992        } else {
993            // Handler not yet initialized — check if /Encrypt exists
994            // If it does, we don't know auth state yet, so return false
995            // (ensure_encryption_initialized will handle it) ~keep
996            false
997        }
998    }
999
1000    /// Guard that returns `Err(Error::EncryptedPdf)` if the PDF is encrypted
1001    /// and not authenticated. Call this at the top of extraction methods.
1002    pub(super) fn require_authenticated(&self) -> Result<()> {
1003        self.ensure_encryption_initialized()?;
1004        if self.is_encrypted_and_unauthenticated() {
1005            return Err(Error::EncryptedPdf);
1006        }
1007        Ok(())
1008    }
1009
1010    /// True once the empty user password has been tried and the document is
1011    /// still locked. Text extraction degrades to empty output in this case
1012    /// (matching pdftotext/PyMuPDF) rather than erroring; `page_count` and
1013    /// write paths keep using [`Self::require_authenticated`].
1014    pub(super) fn is_encrypted_unreadable(&self) -> bool {
1015        let _ = self.ensure_encryption_initialized();
1016        self.is_encrypted_and_unauthenticated()
1017    }
1018}