Skip to main content

oxidize_pdf/parser/
document.rs

1//! PDF Document wrapper - High-level interface for PDF parsing and manipulation
2//!
3//! This module provides a robust, high-level interface for working with PDF documents.
4//! It solves Rust's borrow checker challenges through careful use of interior mutability
5//! (RefCell) and separation of concerns between parsing, caching, and page access.
6//!
7//! # Architecture
8//!
9//! The module uses a layered architecture:
10//! - **PdfDocument**: Main entry point with RefCell-based state management
11//! - **ResourceManager**: Centralized object caching with interior mutability
12//! - **PdfReader**: Low-level file access (wrapped in RefCell)
13//! - **PageTree**: Lazy-loaded page navigation
14//!
15//! # Key Features
16//!
17//! - **Automatic caching**: Objects are cached after first access
18//! - **Resource management**: Objects are cached per-document for efficient reuse
19//! - **Page navigation**: Fast access to any page in the document
20//! - **Reference resolution**: Automatic resolution of indirect references
21//! - **Text extraction**: Built-in support for extracting text from pages
22//!
23//! # Example
24//!
25//! ```rust,no_run
26//! use oxidize_pdf::parser::{PdfDocument, PdfReader};
27//!
28//! # fn main() -> Result<(), Box<dyn std::error::Error>> {
29//! // Open a PDF document
30//! let reader = PdfReader::open("document.pdf")?;
31//! let document = PdfDocument::new(reader);
32//!
33//! // Get document information
34//! let page_count = document.page_count()?;
35//! let metadata = document.metadata()?;
36//! println!("Title: {:?}", metadata.title);
37//! println!("Pages: {}", page_count);
38//!
39//! // Access a specific page
40//! let page = document.get_page(0)?;
41//! println!("Page size: {}x{}", page.width(), page.height());
42//!
43//! // Extract text from all pages
44//! let extracted_text = document.extract_text()?;
45//! for (i, page_text) in extracted_text.iter().enumerate() {
46//!     println!("Page {}: {}", i + 1, page_text.text);
47//! }
48//! # Ok(())
49//! # }
50//! ```
51
52#[cfg(test)]
53use super::objects::{PdfArray, PdfName};
54use super::objects::{PdfDictionary, PdfObject};
55use super::page_tree::{PageTree, ParsedPage};
56use super::reader::PdfReader;
57use super::{ParseError, ParseOptions, ParseResult};
58use std::cell::RefCell;
59use std::collections::HashMap;
60use std::fs::File;
61use std::io::{Read, Seek};
62use std::path::Path;
63
64/// Resource manager for efficient PDF object caching.
65///
66/// The ResourceManager provides centralized caching of PDF objects to avoid
67/// repeated parsing overhead. It is owned exclusively by a `PdfDocument` and uses
68/// `RefCell` for interior mutability, so cache writes can occur through a shared
69/// borrow of the document.
70///
71/// # Caching Strategy
72///
73/// - Objects are cached on first access
74/// - Cache persists for the lifetime of the document
75/// - Manual cache clearing is supported for memory management
76///
77/// # Example
78///
79/// ```rust,no_run
80/// use oxidize_pdf::parser::document::ResourceManager;
81///
82/// let resources = ResourceManager::new();
83///
84/// // Objects are cached automatically when accessed through PdfDocument
85/// // Manual cache management:
86/// resources.clear_cache(); // Free memory when needed
87/// ```
88pub struct ResourceManager {
89    /// Cached objects indexed by (object_number, generation_number)
90    object_cache: RefCell<HashMap<(u32, u16), PdfObject>>,
91}
92
93impl Default for ResourceManager {
94    fn default() -> Self {
95        Self::new()
96    }
97}
98
99impl ResourceManager {
100    /// Create a new resource manager
101    pub fn new() -> Self {
102        Self {
103            object_cache: RefCell::new(HashMap::new()),
104        }
105    }
106
107    /// Get an object from cache if available.
108    ///
109    /// # Arguments
110    ///
111    /// * `obj_ref` - Object reference (object_number, generation_number)
112    ///
113    /// # Returns
114    ///
115    /// Cloned object if cached, None otherwise.
116    ///
117    /// # Example
118    ///
119    /// ```rust,no_run
120    /// # use oxidize_pdf::parser::document::ResourceManager;
121    /// # let resources = ResourceManager::new();
122    /// if let Some(obj) = resources.get_cached((10, 0)) {
123    ///     println!("Object 10 0 R found in cache");
124    /// }
125    /// ```
126    pub fn get_cached(&self, obj_ref: (u32, u16)) -> Option<PdfObject> {
127        self.object_cache.borrow().get(&obj_ref).cloned()
128    }
129
130    /// Cache an object for future access.
131    ///
132    /// # Arguments
133    ///
134    /// * `obj_ref` - Object reference (object_number, generation_number)
135    /// * `obj` - The PDF object to cache
136    ///
137    /// # Example
138    ///
139    /// ```rust,no_run
140    /// # use oxidize_pdf::parser::document::ResourceManager;
141    /// # use oxidize_pdf::parser::objects::PdfObject;
142    /// # let resources = ResourceManager::new();
143    /// resources.cache_object((10, 0), PdfObject::Integer(42));
144    /// ```
145    pub fn cache_object(&self, obj_ref: (u32, u16), obj: PdfObject) {
146        self.object_cache.borrow_mut().insert(obj_ref, obj);
147    }
148
149    /// Clear all cached objects to free memory.
150    ///
151    /// Use this when processing large documents to manage memory usage.
152    ///
153    /// # Example
154    ///
155    /// ```rust,no_run
156    /// # use oxidize_pdf::parser::document::ResourceManager;
157    /// # let resources = ResourceManager::new();
158    /// // After processing many pages
159    /// resources.clear_cache();
160    /// println!("Cache cleared to free memory");
161    /// ```
162    pub fn clear_cache(&self) {
163        self.object_cache.borrow_mut().clear();
164    }
165}
166
167/// High-level PDF document interface for parsing and manipulation.
168///
169/// `PdfDocument` provides a clean, safe API for working with PDF files.
170/// It handles the complexity of PDF structure, object references, and resource
171/// management behind a simple interface.
172///
173/// # Type Parameter
174///
175/// * `R` - The reader type (must implement Read + Seek)
176///
177/// # Architecture Benefits
178///
179/// - **RefCell Usage**: Allows multiple parts of the API to access the document
180/// - **Lazy Loading**: Pages and resources are loaded on demand
181/// - **Automatic Caching**: Frequently accessed objects are cached
182/// - **Safe API**: Borrow checker issues are handled internally
183///
184/// # Thread Safety
185///
186/// `PdfDocument<R>` is `Send` for `R: Send` (issue #369): it can be moved to
187/// another thread or across a Python GIL boundary (e.g. `Python::allow_threads`).
188/// It is intentionally `!Sync` because `RefCell` does not permit shared concurrent
189/// access; callers that need concurrent reads should use one instance per thread.
190///
191/// # Example
192///
193/// ```rust,no_run
194/// use oxidize_pdf::parser::{PdfDocument, PdfReader};
195/// use std::fs::File;
196///
197/// # fn main() -> Result<(), Box<dyn std::error::Error>> {
198/// // From a file
199/// let reader = PdfReader::open("document.pdf")?;
200/// let document = PdfDocument::new(reader);
201///
202/// // From any Read + Seek source
203/// let file = File::open("document.pdf")?;
204/// let reader = PdfReader::new(file)?;
205/// let document = PdfDocument::new(reader);
206///
207/// // Use the document
208/// let page_count = document.page_count()?;
209/// for i in 0..page_count {
210///     let page = document.get_page(i)?;
211///     // Process page...
212/// }
213/// # Ok(())
214/// # }
215/// ```
216pub struct PdfDocument<R: Read + Seek> {
217    /// The underlying PDF reader wrapped for interior mutability
218    reader: RefCell<PdfReader<R>>,
219    /// Page tree navigator (lazily initialized)
220    page_tree: RefCell<Option<PageTree>>,
221    /// Resource manager for object caching (owned; interior mutability via RefCell).
222    /// Not shared/cloned, so a plain owned field keeps `PdfDocument<R>: Send` for
223    /// `R: Send` without any locking overhead (issue #369).
224    resources: ResourceManager,
225    /// Cached document metadata to avoid repeated parsing
226    metadata_cache: RefCell<Option<super::reader::DocumentMetadata>>,
227}
228
229impl<R: Read + Seek> PdfDocument<R> {
230    /// Create a new PDF document from a reader
231    pub fn new(reader: PdfReader<R>) -> Self {
232        Self {
233            reader: RefCell::new(reader),
234            page_tree: RefCell::new(None),
235            resources: ResourceManager::new(),
236            metadata_cache: RefCell::new(None),
237        }
238    }
239
240    /// Get the PDF version of the document.
241    ///
242    /// # Returns
243    ///
244    /// PDF version string (e.g., "1.4", "1.7", "2.0")
245    ///
246    /// # Example
247    ///
248    /// ```rust,no_run
249    /// # use oxidize_pdf::parser::{PdfDocument, PdfReader};
250    /// # fn example() -> Result<(), Box<dyn std::error::Error>> {
251    /// # let reader = PdfReader::open("document.pdf")?;
252    /// # let document = PdfDocument::new(reader);
253    /// let version = document.version()?;
254    /// println!("PDF version: {}", version);
255    /// # Ok(())
256    /// # }
257    /// ```
258    pub fn version(&self) -> ParseResult<String> {
259        Ok(self.reader.borrow().version().to_string())
260    }
261
262    /// Get the parse options
263    pub fn options(&self) -> ParseOptions {
264        self.reader.borrow().options().clone()
265    }
266
267    /// Get the total number of pages in the document.
268    ///
269    /// # Returns
270    ///
271    /// The page count as an unsigned 32-bit integer.
272    ///
273    /// # Errors
274    ///
275    /// Returns an error if the page tree is malformed or missing.
276    ///
277    /// # Example
278    ///
279    /// ```rust,no_run
280    /// # use oxidize_pdf::parser::{PdfDocument, PdfReader};
281    /// # fn example() -> Result<(), Box<dyn std::error::Error>> {
282    /// # let reader = PdfReader::open("document.pdf")?;
283    /// # let document = PdfDocument::new(reader);
284    /// let count = document.page_count()?;
285    /// println!("Document has {} pages", count);
286    ///
287    /// // Iterate through all pages
288    /// for i in 0..count {
289    ///     let page = document.get_page(i)?;
290    ///     // Process page...
291    /// }
292    /// # Ok(())
293    /// # }
294    /// ```
295    pub fn page_count(&self) -> ParseResult<u32> {
296        self.ensure_page_tree()?;
297        if let Some(pt) = self.page_tree.borrow().as_ref() {
298            Ok(pt.page_count())
299        } else {
300            // Fallback: should never reach here since ensure_page_tree() just ran
301            self.reader.borrow_mut().page_count()
302        }
303    }
304
305    /// Get document metadata including title, author, creation date, etc.
306    ///
307    /// Metadata is cached after first access for performance.
308    ///
309    /// # Returns
310    ///
311    /// A `DocumentMetadata` struct containing all available metadata fields.
312    ///
313    /// # Example
314    ///
315    /// ```rust,no_run
316    /// # use oxidize_pdf::parser::{PdfDocument, PdfReader};
317    /// # fn example() -> Result<(), Box<dyn std::error::Error>> {
318    /// # let reader = PdfReader::open("document.pdf")?;
319    /// # let document = PdfDocument::new(reader);
320    /// let metadata = document.metadata()?;
321    ///
322    /// if let Some(title) = &metadata.title {
323    ///     println!("Title: {}", title);
324    /// }
325    /// if let Some(author) = &metadata.author {
326    ///     println!("Author: {}", author);
327    /// }
328    /// if let Some(creation_date) = &metadata.creation_date {
329    ///     println!("Created: {}", creation_date);
330    /// }
331    /// println!("PDF Version: {}", metadata.version);
332    /// # Ok(())
333    /// # }
334    /// ```
335    pub fn metadata(&self) -> ParseResult<super::reader::DocumentMetadata> {
336        // Check cache first
337        if let Some(metadata) = self.metadata_cache.borrow().as_ref() {
338            return Ok(metadata.clone());
339        }
340
341        // Load metadata
342        let metadata = self.reader.borrow_mut().metadata()?;
343        self.metadata_cache.borrow_mut().replace(metadata.clone());
344        Ok(metadata)
345    }
346
347    /// Initialize the page tree if not already done.
348    ///
349    /// Builds a flat index of all leaf Page references by walking the tree once.
350    /// This provides O(1) page access and detects cycles and absurd /Count values.
351    fn ensure_page_tree(&self) -> ParseResult<()> {
352        if self.page_tree.borrow().is_none() {
353            let pages_dict = self.load_pages_dict()?;
354            let page_refs = {
355                let mut reader = self.reader.borrow_mut();
356                PageTree::flatten_page_tree(&mut *reader, &pages_dict)?
357            };
358            let page_tree = PageTree::new_with_flat_index(pages_dict, page_refs);
359            self.page_tree.borrow_mut().replace(page_tree);
360        }
361        Ok(())
362    }
363
364    /// Load the pages dictionary
365    fn load_pages_dict(&self) -> ParseResult<PdfDictionary> {
366        let mut reader = self.reader.borrow_mut();
367        let pages = reader.pages()?;
368        Ok(pages.clone())
369    }
370
371    /// Get a page by index (0-based).
372    ///
373    /// Pages are cached after first access. This method handles page tree
374    /// traversal and property inheritance automatically.
375    ///
376    /// # Arguments
377    ///
378    /// * `index` - Zero-based page index (0 to page_count-1)
379    ///
380    /// # Returns
381    ///
382    /// A complete `ParsedPage` with all properties and inherited resources.
383    ///
384    /// # Errors
385    ///
386    /// Returns an error if:
387    /// - Index is out of bounds
388    /// - Page tree is malformed
389    /// - Required page properties are missing
390    ///
391    /// # Example
392    ///
393    /// ```rust,no_run
394    /// # use oxidize_pdf::parser::{PdfDocument, PdfReader};
395    /// # fn example() -> Result<(), Box<dyn std::error::Error>> {
396    /// # let reader = PdfReader::open("document.pdf")?;
397    /// # let document = PdfDocument::new(reader);
398    /// // Get the first page
399    /// let page = document.get_page(0)?;
400    ///
401    /// // Access page properties
402    /// println!("Page size: {}x{} points", page.width(), page.height());
403    /// println!("Rotation: {}°", page.rotation);
404    ///
405    /// // Get content streams
406    /// let streams = page.content_streams_with_document(&document)?;
407    /// println!("Page has {} content streams", streams.len());
408    /// # Ok(())
409    /// # }
410    /// ```
411    pub fn get_page(&self, index: u32) -> ParseResult<ParsedPage> {
412        self.ensure_page_tree()?;
413
414        // First check if page is already cached
415        if let Some(page_tree) = self.page_tree.borrow().as_ref() {
416            if let Some(page) = page_tree.get_cached_page(index) {
417                return Ok(page.clone());
418            }
419        }
420
421        // Try flat index O(1) lookup first
422        let (page_ref, has_flat_index) = {
423            let pt_borrow = self.page_tree.borrow();
424            let pt = pt_borrow.as_ref();
425            let ref_val = pt.and_then(|pt| pt.get_page_ref(index));
426            let has_index = pt.map_or(false, |pt| pt.page_count() > 0 || ref_val.is_some());
427            (ref_val, has_index)
428        };
429
430        let page = if let Some(page_ref) = page_ref {
431            self.load_page_by_ref(page_ref)?
432        } else if has_flat_index {
433            // Flat index exists but page not found — index is out of range
434            return Err(ParseError::SyntaxError {
435                position: 0,
436                message: format!(
437                    "Page index {} out of range (document has {} pages)",
438                    index,
439                    self.page_tree
440                        .borrow()
441                        .as_ref()
442                        .map_or(0, |pt| pt.page_count())
443                ),
444            });
445        } else {
446            // No flat index available — fallback to tree traversal
447            self.load_page_at_index(index)?
448        };
449
450        // Cache it
451        if let Some(page_tree) = self.page_tree.borrow_mut().as_mut() {
452            page_tree.cache_page(index, page.clone());
453        }
454
455        Ok(page)
456    }
457
458    /// Load a specific page by index (legacy tree traversal fallback)
459    fn load_page_at_index(&self, index: u32) -> ParseResult<ParsedPage> {
460        // Get the pages root
461        let pages_dict = self.load_pages_dict()?;
462
463        // Navigate to the specific page
464        let page_info = self.find_page_in_tree(&pages_dict, index, 0, None)?;
465
466        Ok(page_info)
467    }
468
469    /// Load a page directly by its object reference (O(1) via flat index).
470    fn load_page_by_ref(&self, page_ref: (u32, u16)) -> ParseResult<ParsedPage> {
471        let obj = self.get_object(page_ref.0, page_ref.1)?;
472        let dict = obj.as_dict().ok_or_else(|| ParseError::SyntaxError {
473            position: 0,
474            message: format!(
475                "Page object {} {} R is not a dictionary",
476                page_ref.0, page_ref.1
477            ),
478        })?;
479
480        let inherited = self.collect_inherited_attributes(dict);
481        self.create_parsed_page(page_ref, dict, Some(&inherited))
482    }
483
484    /// Walk up the /Parent chain to collect inheritable attributes (Resources, MediaBox, CropBox, Rotate).
485    /// Uses cycle detection to prevent infinite loops in malformed PDFs.
486    fn collect_inherited_attributes(&self, page_dict: &PdfDictionary) -> PdfDictionary {
487        let mut inherited = PdfDictionary::new();
488        let inheritable_keys = ["Resources", "MediaBox", "CropBox", "Rotate"];
489
490        // Collect from the page's own parent chain
491        let mut current_parent_ref = page_dict.get("Parent").and_then(|p| p.as_reference());
492        let mut visited: std::collections::HashSet<(u32, u16)> = std::collections::HashSet::new();
493
494        while let Some(parent_ref) = current_parent_ref {
495            if !visited.insert(parent_ref) {
496                break; // Cycle detected
497            }
498
499            match self.get_object(parent_ref.0, parent_ref.1) {
500                Ok(obj) => {
501                    if let Some(parent_dict) = obj.as_dict() {
502                        for key in &inheritable_keys {
503                            // Only inherit if the page itself doesn't have it
504                            // and we haven't already found it in a closer ancestor
505                            if !page_dict.contains_key(key) && !inherited.contains_key(key) {
506                                if let Some(val) = parent_dict.get(key) {
507                                    inherited.insert((*key).to_string(), val.clone());
508                                }
509                            }
510                        }
511                        current_parent_ref =
512                            parent_dict.get("Parent").and_then(|p| p.as_reference());
513                    } else {
514                        break;
515                    }
516                }
517                Err(_) => break,
518            }
519        }
520
521        inherited
522    }
523
524    /// Find a page in the page tree (iterative implementation for stack safety)
525    fn find_page_in_tree(
526        &self,
527        root_node: &PdfDictionary,
528        target_index: u32,
529        initial_current_index: u32,
530        initial_inherited: Option<&PdfDictionary>,
531    ) -> ParseResult<ParsedPage> {
532        // Work item for the traversal queue
533        #[derive(Debug)]
534        struct WorkItem {
535            node_dict: PdfDictionary,
536            node_ref: Option<(u32, u16)>,
537            current_index: u32,
538            inherited: Option<PdfDictionary>,
539        }
540
541        // Initialize work queue with root node
542        let mut work_queue = Vec::new();
543        work_queue.push(WorkItem {
544            node_dict: root_node.clone(),
545            node_ref: None,
546            current_index: initial_current_index,
547            inherited: initial_inherited.cloned(),
548        });
549
550        // Iterative traversal
551        while let Some(work_item) = work_queue.pop() {
552            let WorkItem {
553                node_dict,
554                node_ref,
555                current_index,
556                inherited,
557            } = work_item;
558
559            let node_type = node_dict
560                .get_type()
561                .or_else(|| {
562                    // If Type is missing, try to infer from content
563                    if node_dict.contains_key("Kids") && node_dict.contains_key("Count") {
564                        Some("Pages")
565                    } else if node_dict.contains_key("Contents")
566                        || node_dict.contains_key("MediaBox")
567                    {
568                        Some("Page")
569                    } else {
570                        None
571                    }
572                })
573                .or_else(|| {
574                    // If Type is missing, try to infer from structure
575                    if node_dict.contains_key("Kids") {
576                        Some("Pages")
577                    } else if node_dict.contains_key("Contents")
578                        || (node_dict.contains_key("MediaBox") && !node_dict.contains_key("Kids"))
579                    {
580                        Some("Page")
581                    } else {
582                        None
583                    }
584                })
585                .ok_or_else(|| ParseError::MissingKey("Type".to_string()))?;
586
587            match node_type {
588                "Pages" => {
589                    // This is a page tree node
590                    let kids = node_dict
591                        .get("Kids")
592                        .and_then(|obj| obj.as_array())
593                        .or_else(|| {
594                            // If Kids is missing, use empty array
595                            tracing::debug!(
596                                "Warning: Missing Kids array in Pages node, using empty array"
597                            );
598                            Some(&super::objects::EMPTY_PDF_ARRAY)
599                        })
600                        .ok_or_else(|| ParseError::MissingKey("Kids".to_string()))?;
601
602                    // Merge inherited attributes
603                    let mut merged_inherited = inherited.unwrap_or_else(PdfDictionary::new);
604
605                    // Inheritable attributes
606                    for key in ["Resources", "MediaBox", "CropBox", "Rotate"] {
607                        if let Some(value) = node_dict.get(key) {
608                            if !merged_inherited.contains_key(key) {
609                                merged_inherited.insert(key.to_string(), value.clone());
610                            }
611                        }
612                    }
613
614                    // Process kids in reverse order (since we're using a stack/Vec::pop())
615                    // This ensures we process them in the correct order
616                    let mut current_idx = current_index;
617                    let mut pending_kids = Vec::new();
618
619                    for kid_ref in &kids.0 {
620                        let kid_ref =
621                            kid_ref
622                                .as_reference()
623                                .ok_or_else(|| ParseError::SyntaxError {
624                                    position: 0,
625                                    message: "Kids array must contain references".to_string(),
626                                })?;
627
628                        // Get the kid object
629                        let kid_obj = self.get_object(kid_ref.0, kid_ref.1)?;
630                        let kid_dict = match kid_obj.as_dict() {
631                            Some(dict) => dict,
632                            None => {
633                                // Skip invalid page tree nodes in lenient mode
634                                tracing::debug!(
635                                    "Warning: Page tree node {} {} R is not a dictionary, skipping",
636                                    kid_ref.0,
637                                    kid_ref.1
638                                );
639                                current_idx += 1; // Count as processed but skip
640                                continue;
641                            }
642                        };
643
644                        let kid_type = kid_dict
645                            .get_type()
646                            .or_else(|| {
647                                // If Type is missing, try to infer from content
648                                if kid_dict.contains_key("Kids") && kid_dict.contains_key("Count") {
649                                    Some("Pages")
650                                } else if kid_dict.contains_key("Contents")
651                                    || kid_dict.contains_key("MediaBox")
652                                {
653                                    Some("Page")
654                                } else {
655                                    None
656                                }
657                            })
658                            .ok_or_else(|| ParseError::MissingKey("Type".to_string()))?;
659
660                        let count = if kid_type == "Pages" {
661                            kid_dict
662                                .get("Count")
663                                .and_then(|obj| obj.as_integer())
664                                .unwrap_or(1) // Fallback to 1 if Count is missing (defensive)
665                                as u32
666                        } else {
667                            1
668                        };
669
670                        if target_index < current_idx + count {
671                            // Found the right subtree/page
672                            if kid_type == "Page" {
673                                // This is the page we want
674                                return self.create_parsed_page(
675                                    kid_ref,
676                                    kid_dict,
677                                    Some(&merged_inherited),
678                                );
679                            } else {
680                                // Need to traverse this subtree - add to queue
681                                pending_kids.push(WorkItem {
682                                    node_dict: kid_dict.clone(),
683                                    node_ref: Some(kid_ref),
684                                    current_index: current_idx,
685                                    inherited: Some(merged_inherited.clone()),
686                                });
687                                break; // Found our target subtree, no need to continue
688                            }
689                        }
690
691                        current_idx += count;
692                    }
693
694                    // Add pending kids to work queue in reverse order for correct processing
695                    work_queue.extend(pending_kids.into_iter().rev());
696                }
697                "Page" => {
698                    // This is a page object
699                    if target_index != current_index {
700                        return Err(ParseError::SyntaxError {
701                            position: 0,
702                            message: "Page index mismatch".to_string(),
703                        });
704                    }
705
706                    // We need the reference for creating the parsed page
707                    if let Some(page_ref) = node_ref {
708                        return self.create_parsed_page(page_ref, &node_dict, inherited.as_ref());
709                    } else {
710                        return Err(ParseError::SyntaxError {
711                            position: 0,
712                            message: "Direct page object without reference".to_string(),
713                        });
714                    }
715                }
716                _ => {
717                    return Err(ParseError::SyntaxError {
718                        position: 0,
719                        message: format!("Invalid page tree node type: {node_type}"),
720                    });
721                }
722            }
723        }
724
725        // Try fallback: search for the page by direct object scanning
726        tracing::debug!(
727            "Warning: Page {} not found in tree, attempting direct lookup",
728            target_index
729        );
730
731        // Scan for Page objects directly (try first few hundred objects)
732        for obj_num in 1..500 {
733            if let Ok(obj) = self.reader.borrow_mut().get_object(obj_num, 0) {
734                if let Some(dict) = obj.as_dict() {
735                    if let Some(obj_type) = dict.get("Type").and_then(|t| t.as_name()) {
736                        if obj_type.0 == "Page" {
737                            // Found a page, check if it's the right index (approximate)
738                            return self.create_parsed_page((obj_num, 0), dict, None);
739                        }
740                    }
741                }
742            }
743        }
744
745        Err(ParseError::SyntaxError {
746            position: 0,
747            message: format!("Page {} not found in tree or document", target_index),
748        })
749    }
750
751    /// Create a ParsedPage from a page dictionary
752    fn create_parsed_page(
753        &self,
754        obj_ref: (u32, u16),
755        page_dict: &PdfDictionary,
756        inherited: Option<&PdfDictionary>,
757    ) -> ParseResult<ParsedPage> {
758        // Extract page attributes with fallback for missing MediaBox
759        let media_box = match self.get_rectangle(page_dict, inherited, "MediaBox")? {
760            Some(mb) => mb,
761            None => {
762                // Use default Letter size if MediaBox is missing
763                #[cfg(debug_assertions)]
764                tracing::debug!(
765                    "Warning: Page {} {} R missing MediaBox, using default Letter size",
766                    obj_ref.0,
767                    obj_ref.1
768                );
769                [0.0, 0.0, 612.0, 792.0]
770            }
771        };
772
773        let crop_box = self.get_rectangle(page_dict, inherited, "CropBox")?;
774
775        let rotation = self
776            .get_integer(page_dict, inherited, "Rotate")?
777            .unwrap_or(0) as i32;
778
779        // Resolve the effective /Resources into an owned dictionary so that
780        // `ParsedPage::get_resources()` always yields a dictionary, even when
781        // /Resources is given as an indirect reference (issue #286). The page's
782        // own /Resources takes precedence over inherited ones; when it is an
783        // inline dictionary `get_resources()` returns it directly from the page
784        // dict, so we only need a resolved fallback for the reference / inherited
785        // cases.
786        let inherited_resources = {
787            let own_is_inline_dict = page_dict
788                .get("Resources")
789                .map(|o| o.as_dict().is_some())
790                .unwrap_or(false);
791            if own_is_inline_dict {
792                None
793            } else {
794                page_dict
795                    .get("Resources")
796                    .or_else(|| inherited.and_then(|i| i.get("Resources")))
797                    .and_then(|r| self.resolve(r).ok())
798                    .and_then(|r| r.as_dict().cloned())
799            }
800        };
801
802        // Get annotations if present
803        let annotations = page_dict
804            .get("Annots")
805            .and_then(|obj| obj.as_array())
806            .cloned();
807
808        Ok(ParsedPage {
809            obj_ref,
810            dict: page_dict.clone(),
811            inherited_resources,
812            media_box,
813            crop_box,
814            rotation,
815            annotations,
816        })
817    }
818
819    /// Get a rectangle value
820    fn get_rectangle(
821        &self,
822        node: &PdfDictionary,
823        inherited: Option<&PdfDictionary>,
824        key: &str,
825    ) -> ParseResult<Option<[f64; 4]>> {
826        let array = node.get(key).or_else(|| inherited.and_then(|i| i.get(key)));
827
828        if let Some(array) = array.and_then(|obj| obj.as_array()) {
829            if array.len() != 4 {
830                return Err(ParseError::SyntaxError {
831                    position: 0,
832                    message: format!("{key} must have 4 elements"),
833                });
834            }
835
836            // After length check, we know array has exactly 4 elements
837            // Safe to index directly without unwrap
838            let rect = [
839                array.0[0].as_real().unwrap_or(0.0),
840                array.0[1].as_real().unwrap_or(0.0),
841                array.0[2].as_real().unwrap_or(0.0),
842                array.0[3].as_real().unwrap_or(0.0),
843            ];
844
845            Ok(Some(rect))
846        } else {
847            Ok(None)
848        }
849    }
850
851    /// Get an integer value
852    fn get_integer(
853        &self,
854        node: &PdfDictionary,
855        inherited: Option<&PdfDictionary>,
856        key: &str,
857    ) -> ParseResult<Option<i64>> {
858        let value = node.get(key).or_else(|| inherited.and_then(|i| i.get(key)));
859
860        Ok(value.and_then(|obj| obj.as_integer()))
861    }
862
863    /// Get an object by its reference numbers.
864    ///
865    /// This method first checks the cache, then loads from the file if needed.
866    /// Objects are automatically cached after loading.
867    ///
868    /// # Arguments
869    ///
870    /// * `obj_num` - Object number
871    /// * `gen_num` - Generation number
872    ///
873    /// # Returns
874    ///
875    /// The resolved PDF object.
876    ///
877    /// # Errors
878    ///
879    /// Returns an error if:
880    /// - Object doesn't exist
881    /// - Object is part of an encrypted object stream
882    /// - File is corrupted
883    ///
884    /// # Example
885    ///
886    /// ```rust,no_run
887    /// # use oxidize_pdf::parser::{PdfDocument, PdfReader};
888    /// # use oxidize_pdf::parser::objects::PdfObject;
889    /// # fn example() -> Result<(), Box<dyn std::error::Error>> {
890    /// # let reader = PdfReader::open("document.pdf")?;
891    /// # let document = PdfDocument::new(reader);
892    /// // Get object 10 0 R
893    /// let obj = document.get_object(10, 0)?;
894    ///
895    /// // Check object type
896    /// match obj {
897    ///     PdfObject::Dictionary(dict) => {
898    ///         println!("Object is a dictionary with {} entries", dict.0.len());
899    ///     }
900    ///     PdfObject::Stream(stream) => {
901    ///         println!("Object is a stream");
902    ///     }
903    ///     _ => {}
904    /// }
905    /// # Ok(())
906    /// # }
907    /// ```
908    pub fn get_object(&self, obj_num: u32, gen_num: u16) -> ParseResult<PdfObject> {
909        // Check resource cache first
910        if let Some(obj) = self.resources.get_cached((obj_num, gen_num)) {
911            return Ok(obj);
912        }
913
914        // Load from reader
915        let obj = {
916            let mut reader = self.reader.borrow_mut();
917            reader.get_object(obj_num, gen_num)?.clone()
918        };
919
920        // Cache it
921        self.resources.cache_object((obj_num, gen_num), obj.clone());
922
923        Ok(obj)
924    }
925
926    /// Resolve a reference to get the actual object.
927    ///
928    /// If the input is a Reference, fetches the referenced object.
929    /// Otherwise returns a clone of the input object.
930    ///
931    /// # Arguments
932    ///
933    /// * `obj` - The object to resolve (may be a Reference or direct object)
934    ///
935    /// # Returns
936    ///
937    /// The resolved object (never a Reference).
938    ///
939    /// # Example
940    ///
941    /// ```rust,no_run
942    /// # use oxidize_pdf::parser::{PdfDocument, PdfReader};
943    /// # use oxidize_pdf::parser::objects::PdfObject;
944    /// # fn example() -> Result<(), Box<dyn std::error::Error>> {
945    /// # let reader = PdfReader::open("document.pdf")?;
946    /// # let document = PdfDocument::new(reader);
947    /// # let page = document.get_page(0)?;
948    /// // Contents might be a reference or direct object
949    /// if let Some(contents) = page.dict.get("Contents") {
950    ///     let resolved = document.resolve(contents)?;
951    ///     match resolved {
952    ///         PdfObject::Stream(_) => println!("Single content stream"),
953    ///         PdfObject::Array(_) => println!("Multiple content streams"),
954    ///         _ => println!("Unexpected content type"),
955    ///     }
956    /// }
957    /// # Ok(())
958    /// # }
959    /// ```
960    pub fn resolve(&self, obj: &PdfObject) -> ParseResult<PdfObject> {
961        match obj {
962            PdfObject::Reference(obj_num, gen_num) => self.get_object(*obj_num, *gen_num),
963            _ => Ok(obj.clone()),
964        }
965    }
966
967    /// Get content streams for a specific page.
968    ///
969    /// This method handles both single streams and arrays of streams,
970    /// automatically decompressing them according to their filters.
971    ///
972    /// # Arguments
973    ///
974    /// * `page` - The page to get content streams from
975    ///
976    /// # Returns
977    ///
978    /// Vector of decompressed content stream data ready for parsing.
979    ///
980    /// # Example
981    ///
982    /// ```rust,no_run
983    /// # use oxidize_pdf::parser::{PdfDocument, PdfReader};
984    /// # use oxidize_pdf::parser::content::ContentParser;
985    /// # fn example() -> Result<(), Box<dyn std::error::Error>> {
986    /// # let reader = PdfReader::open("document.pdf")?;
987    /// # let document = PdfDocument::new(reader);
988    /// let page = document.get_page(0)?;
989    /// let streams = document.get_page_content_streams(&page)?;
990    ///
991    /// // Parse content streams
992    /// for stream_data in streams {
993    ///     let operations = ContentParser::parse(&stream_data)?;
994    ///     println!("Stream has {} operations", operations.len());
995    /// }
996    /// # Ok(())
997    /// # }
998    /// ```
999    /// Get page resources dictionary.
1000    ///
1001    /// This method returns the resources dictionary for a page, which may include
1002    /// fonts, images (XObjects), patterns, color spaces, and other resources.
1003    ///
1004    /// # Arguments
1005    ///
1006    /// * `page` - The page to get resources from
1007    ///
1008    /// # Returns
1009    ///
1010    /// Optional resources dictionary if the page has resources.
1011    ///
1012    /// # Example
1013    ///
1014    /// ```rust,no_run
1015    /// # use oxidize_pdf::parser::{PdfDocument, PdfReader, PdfObject, PdfName};
1016    /// # fn example() -> Result<(), Box<dyn std::error::Error>> {
1017    /// # let reader = PdfReader::open("document.pdf")?;
1018    /// # let document = PdfDocument::new(reader);
1019    /// let page = document.get_page(0)?;
1020    /// if let Some(resources) = document.get_page_resources(&page)? {
1021    ///     // Check for images (XObjects)
1022    ///     if let Some(PdfObject::Dictionary(xobjects)) = resources.0.get(&PdfName("XObject".to_string())) {
1023    ///         for (name, _) in xobjects.0.iter() {
1024    ///             println!("Found XObject: {}", name.0);
1025    ///         }
1026    ///     }
1027    /// }
1028    /// # Ok(())
1029    /// # }
1030    /// ```
1031    pub fn get_page_resources<'a>(
1032        &self,
1033        page: &'a ParsedPage,
1034    ) -> ParseResult<Option<&'a PdfDictionary>> {
1035        Ok(page.get_resources())
1036    }
1037
1038    pub fn get_page_content_streams(&self, page: &ParsedPage) -> ParseResult<Vec<Vec<u8>>> {
1039        let mut streams = Vec::new();
1040        let options = self.options();
1041
1042        if let Some(contents) = page.dict.get("Contents") {
1043            let resolved_contents = self.resolve(contents)?;
1044
1045            match &resolved_contents {
1046                PdfObject::Stream(stream) => {
1047                    streams.push(stream.decode(&options)?);
1048                }
1049                PdfObject::Array(array) => {
1050                    for item in &array.0 {
1051                        let resolved = self.resolve(item)?;
1052                        if let PdfObject::Stream(stream) = resolved {
1053                            streams.push(stream.decode(&options)?);
1054                        }
1055                    }
1056                }
1057                _ => {
1058                    return Err(ParseError::SyntaxError {
1059                        position: 0,
1060                        message: "Contents must be a stream or array of streams".to_string(),
1061                    })
1062                }
1063            }
1064        }
1065
1066        Ok(streams)
1067    }
1068
1069    /// Extract text from all pages in the document.
1070    ///
1071    /// Uses the default text extraction settings. For custom settings,
1072    /// use `extract_text_with_options`.
1073    ///
1074    /// # Returns
1075    ///
1076    /// A vector of `ExtractedText`, one for each page in the document.
1077    ///
1078    /// # Example
1079    ///
1080    /// ```rust,no_run
1081    /// # use oxidize_pdf::parser::{PdfDocument, PdfReader};
1082    /// # fn example() -> Result<(), Box<dyn std::error::Error>> {
1083    /// # let reader = PdfReader::open("document.pdf")?;
1084    /// # let document = PdfDocument::new(reader);
1085    /// let extracted_pages = document.extract_text()?;
1086    ///
1087    /// for (page_num, page_text) in extracted_pages.iter().enumerate() {
1088    ///     println!("=== Page {} ===", page_num + 1);
1089    ///     println!("{}", page_text.text);
1090    ///     println!();
1091    /// }
1092    /// # Ok(())
1093    /// # }
1094    /// ```
1095    pub fn extract_text(&self) -> ParseResult<Vec<crate::text::ExtractedText>> {
1096        let mut extractor = crate::text::TextExtractor::new();
1097        extractor.extract_from_document(self)
1098    }
1099
1100    /// Extract text from a specific page.
1101    ///
1102    /// # Arguments
1103    ///
1104    /// * `page_index` - Zero-based page index
1105    ///
1106    /// # Returns
1107    ///
1108    /// Extracted text with optional position information.
1109    ///
1110    /// # Example
1111    ///
1112    /// ```rust,no_run
1113    /// # use oxidize_pdf::parser::{PdfDocument, PdfReader};
1114    /// # fn example() -> Result<(), Box<dyn std::error::Error>> {
1115    /// # let reader = PdfReader::open("document.pdf")?;
1116    /// # let document = PdfDocument::new(reader);
1117    /// // Extract text from first page only
1118    /// let page_text = document.extract_text_from_page(0)?;
1119    /// println!("First page text: {}", page_text.text);
1120    ///
1121    /// // Access text fragments with positions (if preserved)
1122    /// for fragment in &page_text.fragments {
1123    ///     println!("'{}' at ({}, {})", fragment.text, fragment.x, fragment.y);
1124    /// }
1125    /// # Ok(())
1126    /// # }
1127    /// ```
1128    pub fn extract_text_from_page(
1129        &self,
1130        page_index: u32,
1131    ) -> ParseResult<crate::text::ExtractedText> {
1132        let mut extractor = crate::text::TextExtractor::new();
1133        extractor.extract_from_page(self, page_index)
1134    }
1135
1136    /// Extract text from a specific page with custom options.
1137    ///
1138    /// This method combines the functionality of [`extract_text_from_page`] and
1139    /// [`extract_text_with_options`], allowing fine control over extraction
1140    /// behavior for a single page.
1141    ///
1142    /// # Arguments
1143    ///
1144    /// * `page_index` - Zero-based page index
1145    /// * `options` - Text extraction configuration
1146    ///
1147    /// # Returns
1148    ///
1149    /// Extracted text with optional position information.
1150    ///
1151    /// # Example
1152    ///
1153    /// ```rust,no_run
1154    /// # use oxidize_pdf::parser::{PdfDocument, PdfReader};
1155    /// # use oxidize_pdf::text::ExtractionOptions;
1156    /// # fn example() -> Result<(), Box<dyn std::error::Error>> {
1157    /// # let reader = PdfReader::open("document.pdf")?;
1158    /// # let document = PdfDocument::new(reader);
1159    /// // Use higher space threshold for PDFs with micro-adjustments
1160    /// let options = ExtractionOptions {
1161    ///     space_threshold: 0.4,
1162    ///     ..Default::default()
1163    /// };
1164    ///
1165    /// let page_text = document.extract_text_from_page_with_options(0, options)?;
1166    /// println!("Text: {}", page_text.text);
1167    /// # Ok(())
1168    /// # }
1169    /// ```
1170    pub fn extract_text_from_page_with_options(
1171        &self,
1172        page_index: u32,
1173        options: crate::text::ExtractionOptions,
1174    ) -> ParseResult<crate::text::ExtractedText> {
1175        let mut extractor = crate::text::TextExtractor::with_options(options);
1176        extractor.extract_from_page(self, page_index)
1177    }
1178
1179    /// Extract text with custom extraction options.
1180    ///
1181    /// Allows fine control over text extraction behavior including
1182    /// layout preservation, spacing thresholds, and more.
1183    ///
1184    /// # Arguments
1185    ///
1186    /// * `options` - Text extraction configuration
1187    ///
1188    /// # Returns
1189    ///
1190    /// A vector of `ExtractedText`, one for each page.
1191    ///
1192    /// # Example
1193    ///
1194    /// ```rust,no_run
1195    /// # use oxidize_pdf::parser::{PdfDocument, PdfReader};
1196    /// # use oxidize_pdf::text::ExtractionOptions;
1197    /// # fn example() -> Result<(), Box<dyn std::error::Error>> {
1198    /// # let reader = PdfReader::open("document.pdf")?;
1199    /// # let document = PdfDocument::new(reader);
1200    /// // Configure extraction to preserve layout
1201    /// let options = ExtractionOptions {
1202    ///     preserve_layout: true,
1203    ///     space_threshold: 0.3,
1204    ///     newline_threshold: 10.0,
1205    ///     ..Default::default()
1206    /// };
1207    ///
1208    /// let extracted_pages = document.extract_text_with_options(options)?;
1209    ///
1210    /// // Text fragments will include position information
1211    /// for page_text in extracted_pages {
1212    ///     for fragment in &page_text.fragments {
1213    ///         println!("{:?}", fragment);
1214    ///     }
1215    /// }
1216    /// # Ok(())
1217    /// # }
1218    /// ```
1219    pub fn extract_text_with_options(
1220        &self,
1221        options: crate::text::ExtractionOptions,
1222    ) -> ParseResult<Vec<crate::text::ExtractedText>> {
1223        let mut extractor = crate::text::TextExtractor::with_options(options);
1224        extractor.extract_from_document(self)
1225    }
1226
1227    /// Get annotations from a specific page.
1228    ///
1229    /// Returns a vector of annotation dictionaries for the specified page.
1230    /// Each annotation dictionary contains properties like Type, Rect, Contents, etc.
1231    ///
1232    /// # Arguments
1233    ///
1234    /// * `page_index` - Zero-based page index
1235    ///
1236    /// # Returns
1237    ///
1238    /// A vector of PdfDictionary objects representing annotations, or an empty vector
1239    /// if the page has no annotations.
1240    ///
1241    /// # Example
1242    ///
1243    /// ```rust,no_run
1244    /// # use oxidize_pdf::parser::{PdfDocument, PdfReader};
1245    /// # fn example() -> Result<(), Box<dyn std::error::Error>> {
1246    /// # let reader = PdfReader::open("document.pdf")?;
1247    /// # let document = PdfDocument::new(reader);
1248    /// let annotations = document.get_page_annotations(0)?;
1249    /// for annot in &annotations {
1250    ///     if let Some(contents) = annot.get("Contents").and_then(|c| c.as_string()) {
1251    ///         println!("Annotation: {:?}", contents);
1252    ///     }
1253    /// }
1254    /// # Ok(())
1255    /// # }
1256    /// ```
1257    pub fn get_page_annotations(&self, page_index: u32) -> ParseResult<Vec<PdfDictionary>> {
1258        let page = self.get_page(page_index)?;
1259
1260        if let Some(annots_array) = page.get_annotations() {
1261            let mut annotations = Vec::new();
1262            let mut reader = self.reader.borrow_mut();
1263
1264            for annot_ref in &annots_array.0 {
1265                if let Some(ref_nums) = annot_ref.as_reference() {
1266                    match reader.get_object(ref_nums.0, ref_nums.1) {
1267                        Ok(obj) => {
1268                            if let Some(dict) = obj.as_dict() {
1269                                annotations.push(dict.clone());
1270                            }
1271                        }
1272                        Err(_) => {
1273                            // Skip annotations that can't be loaded
1274                            continue;
1275                        }
1276                    }
1277                }
1278            }
1279
1280            Ok(annotations)
1281        } else {
1282            Ok(Vec::new())
1283        }
1284    }
1285
1286    /// Get all annotations from all pages in the document.
1287    ///
1288    /// Returns a vector of tuples containing (page_index, annotations) for each page
1289    /// that has annotations.
1290    ///
1291    /// # Returns
1292    ///
1293    /// A vector of tuples where the first element is the page index and the second
1294    /// is a vector of annotation dictionaries for that page.
1295    ///
1296    /// # Example
1297    ///
1298    /// ```rust,no_run
1299    /// # use oxidize_pdf::parser::{PdfDocument, PdfReader};
1300    /// # fn example() -> Result<(), Box<dyn std::error::Error>> {
1301    /// # let reader = PdfReader::open("document.pdf")?;
1302    /// # let document = PdfDocument::new(reader);
1303    /// let all_annotations = document.get_all_annotations()?;
1304    /// for (page_idx, annotations) in all_annotations {
1305    ///     println!("Page {} has {} annotations", page_idx, annotations.len());
1306    /// }
1307    /// # Ok(())
1308    /// # }
1309    /// ```
1310    pub fn get_all_annotations(&self) -> ParseResult<Vec<(u32, Vec<PdfDictionary>)>> {
1311        let page_count = self.page_count()?;
1312        let mut all_annotations = Vec::new();
1313
1314        for i in 0..page_count {
1315            let annotations = self.get_page_annotations(i)?;
1316            if !annotations.is_empty() {
1317                all_annotations.push((i, annotations));
1318            }
1319        }
1320
1321        Ok(all_annotations)
1322    }
1323
1324    // --- VibeCoding Facade Methods ---
1325
1326    /// Export the document to LLM-optimized Markdown format.
1327    ///
1328    /// Delegates to [`crate::ai::export_to_markdown`]. Includes YAML frontmatter
1329    /// with document metadata followed by extracted text content.
1330    #[allow(deprecated)]
1331    pub fn to_markdown(&self) -> crate::error::Result<String> {
1332        crate::ai::export_to_markdown(self)
1333    }
1334
1335    /// Export the document to element-aware Markdown format.
1336    ///
1337    /// Unlike [`to_markdown`](Self::to_markdown), this method classifies elements
1338    /// by type and maps each to its canonical Markdown representation.
1339    pub fn to_element_markdown(&self) -> ParseResult<String> {
1340        let elements = self.partition()?;
1341        let exporter = crate::pipeline::export::ElementMarkdownExporter::default();
1342        Ok(exporter.export(&elements))
1343    }
1344
1345    /// Export the document to a contextual text format for LLM consumption.
1346    ///
1347    /// Delegates to [`crate::ai::export_to_contextual`].
1348    #[allow(deprecated)]
1349    pub fn to_contextual(&self) -> crate::error::Result<String> {
1350        crate::ai::export_to_contextual(self)
1351    }
1352
1353    /// Export the document to structured JSON format.
1354    ///
1355    /// Requires the `semantic` feature. Delegates to [`crate::ai::export_to_json`].
1356    #[cfg(feature = "semantic")]
1357    #[allow(deprecated)]
1358    pub fn to_json(&self) -> crate::error::Result<String> {
1359        crate::ai::export_to_json(self)
1360    }
1361
1362    /// Extract and chunk the document into RAG-ready chunks with full metadata.
1363    ///
1364    /// Uses default [`HybridChunkConfig`](crate::pipeline::HybridChunkConfig)
1365    /// (512 tokens, `AnyInlineContent` merge policy). Returns serializable
1366    /// [`RagChunk`](crate::pipeline::RagChunk)s with page numbers, bounding boxes,
1367    /// element types, and heading context — everything a vector store needs.
1368    ///
1369    /// # Example
1370    ///
1371    /// ```rust,no_run
1372    /// use oxidize_pdf::parser::{PdfDocument, PdfReader};
1373    ///
1374    /// let doc = PdfDocument::open("document.pdf")?;
1375    /// let chunks = doc.rag_chunks()?;
1376    /// for chunk in &chunks {
1377    ///     println!("Chunk {}: pages {:?}, ~{} tokens",
1378    ///         chunk.chunk_index, chunk.page_numbers, chunk.token_estimate);
1379    /// }
1380    /// # Ok::<(), Box<dyn std::error::Error>>(())
1381    /// ```
1382    pub fn rag_chunks(&self) -> ParseResult<Vec<crate::pipeline::RagChunk>> {
1383        self.rag_chunks_with(crate::pipeline::HybridChunkConfig::default())
1384    }
1385
1386    /// Extract and chunk the document with a custom chunking configuration.
1387    ///
1388    /// Use this when the default 512-token limit is too large or too small for your
1389    /// vector store or embedding model. All other metadata (pages, bounding boxes,
1390    /// element types, heading context) is identical to [`rag_chunks()`](Self::rag_chunks).
1391    ///
1392    /// # Example
1393    ///
1394    /// ```rust,no_run
1395    /// use oxidize_pdf::parser::{PdfDocument, PdfReader};
1396    /// use oxidize_pdf::pipeline::HybridChunkConfig;
1397    ///
1398    /// let doc = PdfDocument::open("document.pdf")?;
1399    /// let config = HybridChunkConfig {
1400    ///     max_tokens: 256,
1401    ///     ..HybridChunkConfig::default()
1402    /// };
1403    /// let chunks = doc.rag_chunks_with(config)?;
1404    /// println!("Got {} chunks at 256-token limit", chunks.len());
1405    /// # Ok::<(), Box<dyn std::error::Error>>(())
1406    /// ```
1407    pub fn rag_chunks_with(
1408        &self,
1409        config: crate::pipeline::HybridChunkConfig,
1410    ) -> ParseResult<Vec<crate::pipeline::RagChunk>> {
1411        let elements = self.partition()?;
1412        let context_mode = config.context_mode;
1413        let chunker = crate::pipeline::HybridChunker::new(config);
1414        let hybrid_chunks = chunker.chunk(&elements);
1415        Ok(self.build_rag_chunks(&hybrid_chunks, None, context_mode))
1416    }
1417
1418    /// Extract and chunk with a custom [`TokenCounter`](crate::pipeline::TokenCounter).
1419    ///
1420    /// Identical to [`rag_chunks_with`](Self::rag_chunks_with) except the injected
1421    /// counter governs both the chunk split/size decisions and the reported
1422    /// `token_estimate` on each [`RagChunk`](crate::pipeline::RagChunk). Use with
1423    /// [`TiktokenCounter`](crate::pipeline::TiktokenCounter) (feature `tiktoken`)
1424    /// to size chunks against a real subword tokenizer.
1425    ///
1426    /// Note: this flows through [`HybridChunker`](crate::pipeline::HybridChunker).
1427    /// The `unstable-spi` pipeline entry points (`rag_chunks_with_pipeline` /
1428    /// `rag_chunks_from_elements`) remain word-proxy only.
1429    pub fn rag_chunks_with_counter(
1430        &self,
1431        config: crate::pipeline::HybridChunkConfig,
1432        counter: std::sync::Arc<dyn crate::pipeline::TokenCounter>,
1433    ) -> ParseResult<Vec<crate::pipeline::RagChunk>> {
1434        let elements = self.partition()?;
1435        let context_mode = config.context_mode;
1436        let chunker = crate::pipeline::HybridChunker::new(config).with_token_counter(counter);
1437        let hybrid_chunks = chunker.chunk(&elements);
1438        Ok(self.build_rag_chunks(&hybrid_chunks, None, context_mode))
1439    }
1440
1441    /// Build RAG chunks stamped with source-document metadata.
1442    ///
1443    /// Auto-fills `title`/`author`/`creation_date`/`total_pages` from the info
1444    /// dictionary (only where the caller left them `None`); the caller-supplied
1445    /// `source` provides `filename`/`doc_hash` (and may override any auto-filled
1446    /// field). `doc_hash`, when set, becomes the stable prefix of every
1447    /// `chunk_id`. Same chunking pipeline as [`rag_chunks`](Self::rag_chunks).
1448    ///
1449    /// # Example
1450    ///
1451    /// ```rust,no_run
1452    /// use oxidize_pdf::parser::PdfDocument;
1453    /// use oxidize_pdf::pipeline::DocumentSource;
1454    ///
1455    /// let doc = PdfDocument::open("document.pdf")?;
1456    /// let mut source = DocumentSource::default();
1457    /// source.filename = Some("document.pdf".to_string());
1458    /// source.doc_hash = Some("sha256-prefix".to_string());
1459    /// let chunks = doc.rag_chunks_with_source(source)?;
1460    /// # Ok::<(), Box<dyn std::error::Error>>(())
1461    /// ```
1462    pub fn rag_chunks_with_source(
1463        &self,
1464        source: crate::pipeline::DocumentSource,
1465    ) -> ParseResult<Vec<crate::pipeline::RagChunk>> {
1466        self.rag_chunks_with_source_and_config(
1467            source,
1468            crate::pipeline::HybridChunkConfig::default(),
1469        )
1470    }
1471
1472    /// Like [`rag_chunks_with_source`](Self::rag_chunks_with_source) but with a
1473    /// custom chunking configuration — for callers that need both
1474    /// source-document stamping and a non-default token budget.
1475    ///
1476    /// # Example
1477    ///
1478    /// ```rust,no_run
1479    /// use oxidize_pdf::parser::PdfDocument;
1480    /// use oxidize_pdf::pipeline::{DocumentSource, HybridChunkConfig};
1481    ///
1482    /// let doc = PdfDocument::open("document.pdf")?;
1483    /// let source = DocumentSource::with_file(Some("document.pdf".into()), None);
1484    /// let config = HybridChunkConfig { max_tokens: 256, ..Default::default() };
1485    /// let chunks = doc.rag_chunks_with_source_and_config(source, config)?;
1486    /// # Ok::<(), Box<dyn std::error::Error>>(())
1487    /// ```
1488    pub fn rag_chunks_with_source_and_config(
1489        &self,
1490        mut source: crate::pipeline::DocumentSource,
1491        config: crate::pipeline::HybridChunkConfig,
1492    ) -> ParseResult<Vec<crate::pipeline::RagChunk>> {
1493        self.autofill_source(&mut source);
1494        let elements = self.partition()?;
1495        let context_mode = config.context_mode;
1496        let chunker = crate::pipeline::HybridChunker::new(config);
1497        let hybrid_chunks = chunker.chunk(&elements);
1498        Ok(self.build_rag_chunks(&hybrid_chunks, Some(source), context_mode))
1499    }
1500
1501    /// Fill `title`/`author`/`creation_date`/`total_pages` from the info
1502    /// dictionary where the caller left them `None`.
1503    fn autofill_source(&self, source: &mut crate::pipeline::DocumentSource) {
1504        if let Ok(meta) = self.metadata() {
1505            source.title = source.title.take().or(meta.title);
1506            source.author = source.author.take().or(meta.author);
1507            source.creation_date = source.creation_date.take().or(meta.creation_date);
1508            source.total_pages = source.total_pages.or(meta.page_count);
1509        }
1510        if source.total_pages.is_none() {
1511            source.total_pages = self.page_count().ok();
1512        }
1513    }
1514
1515    /// Run a custom [`AnalysisPipeline`](crate::pipeline::AnalysisPipeline):
1516    /// partition, optionally classify elements, apply the pipeline's chunking
1517    /// strategy, build linked `RagChunk`s (ids, prev/next, metadata, optional
1518    /// source) exactly as the other `rag_chunks*` entry points do, then run any
1519    /// enrichers over each chunk's `extra` bag.
1520    ///
1521    /// `AnalysisPipeline::new()` reproduces [`rag_chunks`](Self::rag_chunks).
1522    ///
1523    /// Partitioning uses the pipeline's
1524    /// [`PartitionConfig`](crate::pipeline::PartitionConfig) (default unless set
1525    /// via [`with_partition_config`](crate::pipeline::AnalysisPipeline::with_partition_config)),
1526    /// so a structure-aware consumer can override the table detector when it
1527    /// misclassifies the document (issue #345).
1528    ///
1529    /// **Stability:** requires `unstable-spi`; exempt from semver until promoted.
1530    ///
1531    /// # Example
1532    ///
1533    /// ```rust,no_run
1534    /// # use oxidize_pdf::parser::PdfDocument;
1535    /// # use oxidize_pdf::pipeline::AnalysisPipeline;
1536    /// # fn main() -> Result<(), Box<dyn std::error::Error>> {
1537    /// let doc = PdfDocument::open("document.pdf")?;
1538    /// // Default pipeline == rag_chunks(); swap in a custom strategy/classifier/
1539    /// // enricher via the builder to extend it.
1540    /// let chunks = doc.rag_chunks_with_pipeline(&AnalysisPipeline::new())?;
1541    /// println!("{} chunks", chunks.len());
1542    /// # Ok(())
1543    /// # }
1544    /// ```
1545    #[cfg(feature = "unstable-spi")]
1546    pub fn rag_chunks_with_pipeline(
1547        &self,
1548        pipeline: &crate::pipeline::AnalysisPipeline,
1549    ) -> ParseResult<Vec<crate::pipeline::RagChunk>> {
1550        let elements = self.partition_with(pipeline.partition_config.clone())?;
1551        self.rag_chunks_from_elements(elements, pipeline)
1552    }
1553
1554    /// Run a custom [`AnalysisPipeline`](crate::pipeline::AnalysisPipeline)
1555    /// (classify → chunk → enrich) over **caller-provided** elements, instead of
1556    /// the document's own partition.
1557    ///
1558    /// This is the element-source seam behind
1559    /// [`rag_chunks_with_pipeline`](Self::rag_chunks_with_pipeline), which is
1560    /// exactly `self.rag_chunks_from_elements(self.partition_with(cfg)?, pipeline)`.
1561    /// Use it to feed externally-recovered elements (e.g. list items a two-column
1562    /// layout scrambles past the partitioner) into the same enriched chunk flow
1563    /// as the rest of the document — with uniform classification and enrichment,
1564    /// avoiding the `RagChunk` metadata-stamping workaround that bypasses both
1565    /// (issue #360). Partitioned and recovered elements can be mixed freely.
1566    ///
1567    /// The pipeline's [`PartitionConfig`](crate::pipeline::PartitionConfig) is not
1568    /// consulted here — the caller has already chosen the elements. The pipeline's
1569    /// [`source`](crate::pipeline::AnalysisPipeline::with_source), when set, is
1570    /// still autofilled from this document (title/author/page count) and stamped
1571    /// onto the chunks, exactly as in `rag_chunks_with_pipeline`.
1572    ///
1573    /// **Stability:** requires `unstable-spi`; exempt from semver until promoted.
1574    #[cfg(feature = "unstable-spi")]
1575    pub fn rag_chunks_from_elements(
1576        &self,
1577        mut elements: Vec<crate::pipeline::Element>,
1578        pipeline: &crate::pipeline::AnalysisPipeline,
1579    ) -> ParseResult<Vec<crate::pipeline::RagChunk>> {
1580        let mut source = pipeline.source.clone();
1581        if let Some(src) = source.as_mut() {
1582            self.autofill_source(src);
1583        }
1584        if let Some(classifier) = pipeline.classifier.as_deref() {
1585            // Two passes: read labels against an immutable slice, then apply —
1586            // the classifier inspects neighbours via `ClassifyContext`, so it
1587            // cannot run while the slice is being mutated.
1588            let labels: Vec<Option<crate::pipeline::ClassLabel>> = (0..elements.len())
1589                .map(|index| {
1590                    let ctx = crate::pipeline::ClassifyContext {
1591                        elements: &elements,
1592                        index,
1593                    };
1594                    classifier.classify(&elements[index], &ctx)
1595                })
1596                .collect();
1597            for (element, label) in elements.iter_mut().zip(labels) {
1598                if let Some(label) = label {
1599                    element.metadata_mut().class_label = Some(label.0.into_owned());
1600                }
1601            }
1602        }
1603        let groups = pipeline.chunking.chunk(&elements);
1604        let hybrid: Vec<crate::pipeline::HybridChunk> = groups
1605            .into_iter()
1606            .map(|g| crate::pipeline::HybridChunk::from_group(g, pipeline.max_tokens))
1607            .collect();
1608        // `mut` is needed only for the enricher pass below (gated `semantic`);
1609        // without that feature the binding is never mutated — silence the warning.
1610        #[allow(unused_mut)]
1611        let mut chunks = self.build_rag_chunks(&hybrid, source, pipeline.context_mode);
1612        #[cfg(feature = "semantic")]
1613        if !pipeline.enrichers.is_empty() {
1614            // Enrich each chunk's `extra` bag. The hybrid chunk (kept alongside)
1615            // supplies the source elements; text/heading_path are snapshotted to
1616            // release the immutable borrow before mutating `metadata`.
1617            for (chunk, hc) in chunks.iter_mut().zip(hybrid.iter()) {
1618                let text = chunk.text.clone();
1619                let heading_path = chunk.metadata.heading_path.clone();
1620                let ctx = crate::pipeline::EnrichContext {
1621                    text: &text,
1622                    elements: hc.elements(),
1623                    heading_path: &heading_path,
1624                };
1625                for enricher in &pipeline.enrichers {
1626                    enricher.enrich(&ctx, &mut chunk.metadata);
1627                }
1628            }
1629        }
1630        Ok(chunks)
1631    }
1632
1633    /// Build linked [`RagChunk`]s from hybrid chunks, optionally stamping a
1634    /// [`DocumentSource`](crate::pipeline::DocumentSource), then wiring
1635    /// prev/next ids. Shared by all `rag_chunks*` entry points (DRY).
1636    fn build_rag_chunks(
1637        &self,
1638        hybrid_chunks: &[crate::pipeline::HybridChunk],
1639        source: Option<crate::pipeline::DocumentSource>,
1640        context_mode: crate::pipeline::ContextMode,
1641    ) -> Vec<crate::pipeline::RagChunk> {
1642        let mut chunks: Vec<crate::pipeline::RagChunk> = match &source {
1643            Some(s) => hybrid_chunks
1644                .iter()
1645                .enumerate()
1646                .map(|(i, hc)| {
1647                    crate::pipeline::RagChunk::from_hybrid_chunk_with_source_and_mode(
1648                        i,
1649                        hc,
1650                        s,
1651                        context_mode,
1652                    )
1653                })
1654                .collect(),
1655            None => hybrid_chunks
1656                .iter()
1657                .enumerate()
1658                .map(|(i, hc)| {
1659                    crate::pipeline::RagChunk::from_hybrid_chunk_with_mode(i, hc, context_mode)
1660                })
1661                .collect(),
1662        };
1663        crate::pipeline::chunk_metadata::link_chunks(&mut chunks);
1664        chunks
1665    }
1666
1667    /// Extract and chunk the document using a pre-configured extraction profile.
1668    ///
1669    /// Combines [`partition_with_profile`](Self::partition_with_profile) with
1670    /// [`HybridChunker`](crate::pipeline::HybridChunker) using default chunking
1671    /// settings. Use [`rag_chunks_with`](Self::rag_chunks_with) when you need
1672    /// to tune `max_tokens` or `overlap_tokens`.
1673    ///
1674    /// # Example
1675    ///
1676    /// ```rust,no_run
1677    /// use oxidize_pdf::parser::PdfDocument;
1678    /// use oxidize_pdf::pipeline::ExtractionProfile;
1679    ///
1680    /// let doc = PdfDocument::open("document.pdf")?;
1681    /// let chunks = doc.rag_chunks_with_profile(ExtractionProfile::Rag)?;
1682    /// println!("Got {} RAG chunks", chunks.len());
1683    /// # Ok::<(), Box<dyn std::error::Error>>(())
1684    /// ```
1685    pub fn rag_chunks_with_profile(
1686        &self,
1687        profile: crate::pipeline::ExtractionProfile,
1688    ) -> ParseResult<Vec<crate::pipeline::RagChunk>> {
1689        let elements = self.partition_with_profile(profile)?;
1690        let chunker = crate::pipeline::HybridChunker::default();
1691        let hybrid_chunks = chunker.chunk(&elements);
1692        Ok(self.build_rag_chunks(&hybrid_chunks, None, crate::pipeline::ContextMode::Heading))
1693    }
1694
1695    /// Combine a pre-configured extraction profile with a custom chunking config.
1696    ///
1697    /// Use this when you need both profile-tuned partitioning (e.g. `Rag` with
1698    /// XYCut reading order) and a non-default chunk size.
1699    ///
1700    /// # Example
1701    ///
1702    /// ```rust,no_run
1703    /// use oxidize_pdf::parser::PdfDocument;
1704    /// use oxidize_pdf::pipeline::{ExtractionProfile, HybridChunkConfig};
1705    ///
1706    /// let doc = PdfDocument::open("document.pdf")?;
1707    /// let config = HybridChunkConfig { max_tokens: 256, ..Default::default() };
1708    /// let chunks = doc.rag_chunks_with_profile_config(ExtractionProfile::Rag, config)?;
1709    /// # Ok::<(), Box<dyn std::error::Error>>(())
1710    /// ```
1711    pub fn rag_chunks_with_profile_config(
1712        &self,
1713        profile: crate::pipeline::ExtractionProfile,
1714        config: crate::pipeline::HybridChunkConfig,
1715    ) -> ParseResult<Vec<crate::pipeline::RagChunk>> {
1716        let elements = self.partition_with_profile(profile)?;
1717        let context_mode = config.context_mode;
1718        let chunker = crate::pipeline::HybridChunker::new(config);
1719        let hybrid_chunks = chunker.chunk(&elements);
1720        Ok(self.build_rag_chunks(&hybrid_chunks, None, context_mode))
1721    }
1722
1723    /// Extract chunks as a JSON string ready for vector store ingestion.
1724    ///
1725    /// # Feature flags
1726    ///
1727    /// Requires the `semantic` feature: `oxidize-pdf = { features = ["semantic"] }`.
1728    /// Without it this method is not compiled.
1729    #[cfg(feature = "semantic")]
1730    pub fn rag_chunks_json(&self) -> ParseResult<String> {
1731        let chunks = self.rag_chunks()?;
1732        serde_json::to_string(&chunks).map_err(|e| ParseError::SerializationError(e.to_string()))
1733    }
1734
1735    /// Split the document text into chunks of approximately `target_tokens` size.
1736    ///
1737    /// Uses a default overlap of 10% of the target token count.
1738    #[deprecated(
1739        since = "2.2.0",
1740        note = "Use rag_chunks() for structure-aware RAG chunking"
1741    )]
1742    #[allow(deprecated)]
1743    pub fn chunk(
1744        &self,
1745        target_tokens: usize,
1746    ) -> crate::error::Result<Vec<crate::ai::DocumentChunk>> {
1747        let overlap = target_tokens / 10;
1748        self.chunk_with(target_tokens, overlap)
1749    }
1750
1751    /// Split the document text into chunks with explicit size and overlap control.
1752    #[deprecated(
1753        since = "2.2.0",
1754        note = "Use rag_chunks_with() for structure-aware RAG chunking"
1755    )]
1756    pub fn chunk_with(
1757        &self,
1758        target_tokens: usize,
1759        overlap: usize,
1760    ) -> crate::error::Result<Vec<crate::ai::DocumentChunk>> {
1761        let chunker = crate::ai::DocumentChunker::new(target_tokens, overlap);
1762        let extracted = self.extract_text()?;
1763        let page_texts: Vec<(usize, String)> = extracted
1764            .iter()
1765            .enumerate()
1766            .map(|(i, t)| (i + 1, t.text.clone()))
1767            .collect();
1768        chunker
1769            .chunk_text_with_pages(&page_texts)
1770            .map_err(|e| crate::error::PdfError::InvalidStructure(e.to_string()))
1771    }
1772
1773    /// Partition the document into typed elements using default configuration.
1774    ///
1775    /// Extracts text with layout preservation, then classifies fragments into
1776    /// [`Element`](crate::pipeline::Element) variants (Title, Paragraph, Table, etc.).
1777    pub fn partition(&self) -> ParseResult<Vec<crate::pipeline::Element>> {
1778        self.partition_with(crate::pipeline::PartitionConfig::default())
1779    }
1780
1781    /// Partition the document into typed elements with custom configuration.
1782    pub fn partition_with(
1783        &self,
1784        config: crate::pipeline::PartitionConfig,
1785    ) -> ParseResult<Vec<crate::pipeline::Element>> {
1786        let options = crate::text::ExtractionOptions {
1787            preserve_layout: true,
1788            reconstruct_paragraphs: true,
1789            ..Default::default()
1790        };
1791        self.do_partition_pages(options, config)
1792    }
1793
1794    /// Partition the document using a pre-configured extraction profile.
1795    pub fn partition_with_profile(
1796        &self,
1797        profile: crate::pipeline::ExtractionProfile,
1798    ) -> ParseResult<Vec<crate::pipeline::Element>> {
1799        let profile_cfg = profile.config();
1800        let options = crate::text::ExtractionOptions {
1801            preserve_layout: true,
1802            reconstruct_paragraphs: true,
1803            space_threshold: profile_cfg.extraction.space_threshold,
1804            detect_columns: profile_cfg.extraction.detect_columns,
1805            ..crate::text::ExtractionOptions::default()
1806        };
1807        self.do_partition_pages(options, profile_cfg.partition)
1808    }
1809
1810    fn do_partition_pages(
1811        &self,
1812        options: crate::text::ExtractionOptions,
1813        config: crate::pipeline::PartitionConfig,
1814    ) -> ParseResult<Vec<crate::pipeline::Element>> {
1815        // Read the gating flags before `config` is moved into the partitioner,
1816        // so we avoid cloning the config just to inspect two bools.
1817        let extract_graphics = config.detect_tables && config.prefer_ruling_tables;
1818
1819        // The reconstructed `pages` (extracted with `reconstruct_paragraphs = true`)
1820        // merge per-cell fragments into paragraph-granular fragments (issue #261),
1821        // which the ruling-based table detector cannot map back to grid cells. When
1822        // a page actually has a drawn table grid we re-extract just that page with
1823        // `reconstruct_paragraphs = false` to recover cell-granular fragments for
1824        // the detector; the reconstructed fragments still drive prose
1825        // classification. Inherit every other option (notably `space_threshold`
1826        // and `detect_columns`, which profiles override) so cell text is assembled
1827        // identically to the primary pass. Built before `options` is moved into
1828        // `extract_text_with_options`.
1829        let mut raw_options = options.clone();
1830        raw_options.reconstruct_paragraphs = false;
1831
1832        let pages = self.extract_text_with_options(options)?;
1833
1834        let partitioner = crate::pipeline::Partitioner::new(config);
1835        let mut graphics_extractor = crate::graphics::extraction::GraphicsExtractor::default();
1836        // Extracting per table-bearing page (rather than a second whole-document
1837        // pass) keeps the cost proportional to pages that need it and zero for
1838        // table-free documents even with `prefer_ruling_tables` on.
1839        let mut raw_extractor = crate::text::TextExtractor::with_options(raw_options);
1840
1841        let mut all_elements = Vec::new();
1842        for (page_idx, page_text) in pages.iter().enumerate() {
1843            let page_idx_u32 = u32::try_from(page_idx).map_err(|_| ParseError::SyntaxError {
1844                position: 0,
1845                message: format!("Page index {} exceeds u32 range", page_idx),
1846            })?;
1847            let page_height = self
1848                .get_page(page_idx_u32)
1849                .map(|p| p.height())
1850                .unwrap_or(842.0);
1851            let page_graphics = if extract_graphics {
1852                graphics_extractor.extract_from_page(self, page_idx).ok()
1853            } else {
1854                None
1855            };
1856            // Re-extract cell-granular fragments only for pages with a drawn grid.
1857            let raw_page = if page_graphics
1858                .as_ref()
1859                .is_some_and(|g| g.has_table_structure())
1860            {
1861                raw_extractor.extract_from_page(self, page_idx_u32).ok()
1862            } else {
1863                None
1864            };
1865            let raw_fragments = raw_page.as_ref().map(|pt| pt.fragments.as_slice());
1866            let elements = partitioner.partition_fragments_with_graphics_raw(
1867                &page_text.fragments,
1868                raw_fragments,
1869                page_graphics.as_ref(),
1870                page_idx_u32,
1871                page_height,
1872            );
1873            all_elements.extend(elements);
1874        }
1875
1876        Ok(all_elements)
1877    }
1878
1879    /// Partition the document into typed elements and build a relationship graph.
1880    ///
1881    /// Returns a tuple of `(elements, graph)` where the graph captures parent/child
1882    /// and next/prev relationships between elements by index.
1883    ///
1884    /// # Example
1885    ///
1886    /// ```rust,no_run
1887    /// use oxidize_pdf::parser::PdfDocument;
1888    /// use oxidize_pdf::pipeline::PartitionConfig;
1889    ///
1890    /// # fn main() -> Result<(), Box<dyn std::error::Error>> {
1891    /// let doc = PdfDocument::open("document.pdf")?;
1892    /// let (elements, graph) = doc.partition_graph(PartitionConfig::default())?;
1893    ///
1894    /// for title_idx in graph.top_level_sections() {
1895    ///     println!("Section: {}", elements[title_idx].text());
1896    ///     for child_idx in graph.elements_in_section(title_idx) {
1897    ///         println!("  {}", elements[child_idx].text());
1898    ///     }
1899    /// }
1900    /// # Ok(())
1901    /// # }
1902    /// ```
1903    pub fn partition_graph(
1904        &self,
1905        config: crate::pipeline::PartitionConfig,
1906    ) -> ParseResult<(Vec<crate::pipeline::Element>, crate::pipeline::ElementGraph)> {
1907        let elements = self.partition_with(config)?;
1908        let graph = crate::pipeline::ElementGraph::build(&elements);
1909        Ok((elements, graph))
1910    }
1911}
1912
1913impl PdfDocument<File> {
1914    /// Open a PDF file by path — the simplest way to start working with a PDF.
1915    ///
1916    /// This is a convenience method that combines `PdfReader::open()` and
1917    /// `PdfDocument::new()` into a single call.
1918    ///
1919    /// # Example
1920    ///
1921    /// ```rust,no_run
1922    /// use oxidize_pdf::parser::PdfDocument;
1923    ///
1924    /// let doc = PdfDocument::open("report.pdf").unwrap();
1925    /// let text = doc.extract_text().unwrap();
1926    /// let markdown = doc.to_markdown().unwrap();
1927    /// ```
1928    pub fn open<P: AsRef<Path>>(path: P) -> ParseResult<Self> {
1929        PdfReader::open_document(path)
1930    }
1931}
1932
1933#[cfg(test)]
1934mod tests {
1935    use super::*;
1936    use crate::parser::objects::{PdfObject, PdfString};
1937    use std::io::Cursor;
1938
1939    // Issue #369: PdfDocument<R> must be Send so callers (e.g. the PyO3
1940    // bindings) can release a thread-blocking lock around reader operations.
1941    #[test]
1942    fn test_pdf_document_is_send() {
1943        fn assert_send<T: Send>() {}
1944        assert_send::<PdfDocument<std::fs::File>>();
1945        assert_send::<PdfDocument<Cursor<Vec<u8>>>>();
1946        assert_send::<PdfDocument<Cursor<&'static [u8]>>>();
1947    }
1948
1949    // Issue #369: dropping the unshared Rc must not change cache behavior.
1950    #[test]
1951    fn test_resource_cache_roundtrip_after_owned_field() {
1952        let rm = ResourceManager::new();
1953        assert!(rm.get_cached((7, 0)).is_none());
1954        rm.cache_object((7, 0), PdfObject::Integer(42));
1955        assert_eq!(rm.get_cached((7, 0)), Some(PdfObject::Integer(42)));
1956        rm.clear_cache();
1957        assert!(rm.get_cached((7, 0)).is_none());
1958    }
1959
1960    // Helper function to create a minimal PDF in memory
1961    fn create_minimal_pdf() -> Vec<u8> {
1962        let mut pdf = Vec::new();
1963
1964        // PDF header
1965        pdf.extend_from_slice(b"%PDF-1.4\n");
1966
1967        // Catalog object
1968        pdf.extend_from_slice(b"1 0 obj\n");
1969        pdf.extend_from_slice(b"<< /Type /Catalog /Pages 2 0 R >>\n");
1970        pdf.extend_from_slice(b"endobj\n");
1971
1972        // Pages object
1973        pdf.extend_from_slice(b"2 0 obj\n");
1974        pdf.extend_from_slice(b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>\n");
1975        pdf.extend_from_slice(b"endobj\n");
1976
1977        // Page object
1978        pdf.extend_from_slice(b"3 0 obj\n");
1979        pdf.extend_from_slice(
1980            b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Resources << >> >>\n",
1981        );
1982        pdf.extend_from_slice(b"endobj\n");
1983
1984        // Cross-reference table
1985        let xref_pos = pdf.len();
1986        pdf.extend_from_slice(b"xref\n");
1987        pdf.extend_from_slice(b"0 4\n");
1988        pdf.extend_from_slice(b"0000000000 65535 f \n");
1989        pdf.extend_from_slice(b"0000000009 00000 n \n");
1990        pdf.extend_from_slice(b"0000000058 00000 n \n");
1991        pdf.extend_from_slice(b"0000000115 00000 n \n");
1992
1993        // Trailer
1994        pdf.extend_from_slice(b"trailer\n");
1995        pdf.extend_from_slice(b"<< /Size 4 /Root 1 0 R >>\n");
1996        pdf.extend_from_slice(b"startxref\n");
1997        pdf.extend_from_slice(format!("{xref_pos}\n").as_bytes());
1998        pdf.extend_from_slice(b"%%EOF\n");
1999
2000        pdf
2001    }
2002
2003    // Helper to create a PDF with metadata
2004    fn create_pdf_with_metadata() -> Vec<u8> {
2005        let mut pdf = Vec::new();
2006
2007        // PDF header
2008        pdf.extend_from_slice(b"%PDF-1.5\n");
2009
2010        // Record positions for xref
2011        let obj1_pos = pdf.len();
2012
2013        // Catalog object
2014        pdf.extend_from_slice(b"1 0 obj\n");
2015        pdf.extend_from_slice(b"<< /Type /Catalog /Pages 2 0 R >>\n");
2016        pdf.extend_from_slice(b"endobj\n");
2017
2018        let obj2_pos = pdf.len();
2019
2020        // Pages object
2021        pdf.extend_from_slice(b"2 0 obj\n");
2022        pdf.extend_from_slice(b"<< /Type /Pages /Kids [] /Count 0 >>\n");
2023        pdf.extend_from_slice(b"endobj\n");
2024
2025        let obj3_pos = pdf.len();
2026
2027        // Info object
2028        pdf.extend_from_slice(b"3 0 obj\n");
2029        pdf.extend_from_slice(
2030            b"<< /Title (Test Document) /Author (Test Author) /Subject (Test Subject) >>\n",
2031        );
2032        pdf.extend_from_slice(b"endobj\n");
2033
2034        // Cross-reference table
2035        let xref_pos = pdf.len();
2036        pdf.extend_from_slice(b"xref\n");
2037        pdf.extend_from_slice(b"0 4\n");
2038        pdf.extend_from_slice(b"0000000000 65535 f \n");
2039        pdf.extend_from_slice(format!("{obj1_pos:010} 00000 n \n").as_bytes());
2040        pdf.extend_from_slice(format!("{obj2_pos:010} 00000 n \n").as_bytes());
2041        pdf.extend_from_slice(format!("{obj3_pos:010} 00000 n \n").as_bytes());
2042
2043        // Trailer
2044        pdf.extend_from_slice(b"trailer\n");
2045        pdf.extend_from_slice(b"<< /Size 4 /Root 1 0 R /Info 3 0 R >>\n");
2046        pdf.extend_from_slice(b"startxref\n");
2047        pdf.extend_from_slice(format!("{xref_pos}\n").as_bytes());
2048        pdf.extend_from_slice(b"%%EOF\n");
2049
2050        pdf
2051    }
2052
2053    #[test]
2054    fn test_pdf_document_new() {
2055        let pdf_data = create_minimal_pdf();
2056        let cursor = Cursor::new(pdf_data);
2057        let reader = PdfReader::new(cursor).unwrap();
2058        let document = PdfDocument::new(reader);
2059
2060        // Verify document is created with empty caches
2061        assert!(document.page_tree.borrow().is_none());
2062        assert!(document.metadata_cache.borrow().is_none());
2063    }
2064
2065    #[test]
2066    fn test_version() {
2067        let pdf_data = create_minimal_pdf();
2068        let cursor = Cursor::new(pdf_data);
2069        let reader = PdfReader::new(cursor).unwrap();
2070        let document = PdfDocument::new(reader);
2071
2072        let version = document.version().unwrap();
2073        assert_eq!(version, "1.4");
2074    }
2075
2076    #[test]
2077    fn test_page_count() {
2078        let pdf_data = create_minimal_pdf();
2079        let cursor = Cursor::new(pdf_data);
2080        let reader = PdfReader::new(cursor).unwrap();
2081        let document = PdfDocument::new(reader);
2082
2083        let count = document.page_count().unwrap();
2084        assert_eq!(count, 1);
2085    }
2086
2087    #[test]
2088    fn test_metadata() {
2089        let pdf_data = create_pdf_with_metadata();
2090        let cursor = Cursor::new(pdf_data);
2091        let reader = PdfReader::new(cursor).unwrap();
2092        let document = PdfDocument::new(reader);
2093
2094        let metadata = document.metadata().unwrap();
2095        assert_eq!(metadata.title, Some("Test Document".to_string()));
2096        assert_eq!(metadata.author, Some("Test Author".to_string()));
2097        assert_eq!(metadata.subject, Some("Test Subject".to_string()));
2098
2099        // Verify caching works
2100        let metadata2 = document.metadata().unwrap();
2101        assert_eq!(metadata.title, metadata2.title);
2102    }
2103
2104    #[test]
2105    fn test_get_page() {
2106        let pdf_data = create_minimal_pdf();
2107        let cursor = Cursor::new(pdf_data);
2108        let reader = PdfReader::new(cursor).unwrap();
2109        let document = PdfDocument::new(reader);
2110
2111        // Get first page
2112        let page = document.get_page(0).unwrap();
2113        assert_eq!(page.media_box, [0.0, 0.0, 612.0, 792.0]);
2114
2115        // Verify caching works
2116        let page2 = document.get_page(0).unwrap();
2117        assert_eq!(page.media_box, page2.media_box);
2118    }
2119
2120    #[test]
2121    fn test_get_page_out_of_bounds() {
2122        let pdf_data = create_minimal_pdf();
2123        let cursor = Cursor::new(pdf_data);
2124        let reader = PdfReader::new(cursor).unwrap();
2125        let document = PdfDocument::new(reader);
2126
2127        // Try to get page that doesn't exist
2128        let result = document.get_page(10);
2129        // With fallback lookup, this might succeed or fail gracefully
2130        if result.is_err() {
2131            assert!(result.unwrap_err().to_string().contains("Page"));
2132        } else {
2133            // If succeeds, should return a valid page
2134            let _page = result.unwrap();
2135        }
2136    }
2137
2138    #[test]
2139    fn test_resource_manager_caching() {
2140        let resources = ResourceManager::new();
2141
2142        // Test caching an object
2143        let obj_ref = (1, 0);
2144        let obj = PdfObject::String(PdfString("Test".as_bytes().to_vec()));
2145
2146        assert!(resources.get_cached(obj_ref).is_none());
2147
2148        resources.cache_object(obj_ref, obj.clone());
2149
2150        let cached = resources.get_cached(obj_ref).unwrap();
2151        assert_eq!(cached, obj);
2152
2153        // Test clearing cache
2154        resources.clear_cache();
2155        assert!(resources.get_cached(obj_ref).is_none());
2156    }
2157
2158    #[test]
2159    fn test_get_object() {
2160        let pdf_data = create_minimal_pdf();
2161        let cursor = Cursor::new(pdf_data);
2162        let reader = PdfReader::new(cursor).unwrap();
2163        let document = PdfDocument::new(reader);
2164
2165        // Get catalog object
2166        let catalog = document.get_object(1, 0).unwrap();
2167        if let PdfObject::Dictionary(dict) = catalog {
2168            if let Some(PdfObject::Name(name)) = dict.get("Type") {
2169                assert_eq!(name.0, "Catalog");
2170            } else {
2171                panic!("Expected /Type name");
2172            }
2173        } else {
2174            panic!("Expected dictionary object");
2175        }
2176    }
2177
2178    #[test]
2179    fn test_resolve_reference() {
2180        let pdf_data = create_minimal_pdf();
2181        let cursor = Cursor::new(pdf_data);
2182        let reader = PdfReader::new(cursor).unwrap();
2183        let document = PdfDocument::new(reader);
2184
2185        // Create a reference to the catalog
2186        let ref_obj = PdfObject::Reference(1, 0);
2187
2188        // Resolve it
2189        let resolved = document.resolve(&ref_obj).unwrap();
2190        if let PdfObject::Dictionary(dict) = resolved {
2191            if let Some(PdfObject::Name(name)) = dict.get("Type") {
2192                assert_eq!(name.0, "Catalog");
2193            } else {
2194                panic!("Expected /Type name");
2195            }
2196        } else {
2197            panic!("Expected dictionary object");
2198        }
2199    }
2200
2201    #[test]
2202    fn test_resolve_non_reference() {
2203        let pdf_data = create_minimal_pdf();
2204        let cursor = Cursor::new(pdf_data);
2205        let reader = PdfReader::new(cursor).unwrap();
2206        let document = PdfDocument::new(reader);
2207
2208        // Try to resolve a non-reference object
2209        let obj = PdfObject::String(PdfString("Test".as_bytes().to_vec()));
2210        let resolved = document.resolve(&obj).unwrap();
2211
2212        // Should return the same object
2213        assert_eq!(resolved, obj);
2214    }
2215
2216    #[test]
2217    fn test_invalid_pdf_data() {
2218        let invalid_data = b"This is not a PDF";
2219        let cursor = Cursor::new(invalid_data.to_vec());
2220        let result = PdfReader::new(cursor);
2221
2222        assert!(result.is_err());
2223    }
2224
2225    #[test]
2226    fn test_empty_page_tree() {
2227        // Create PDF with empty page tree
2228        let pdf_data = create_pdf_with_metadata(); // This has 0 pages
2229        let cursor = Cursor::new(pdf_data);
2230        let reader = PdfReader::new(cursor).unwrap();
2231        let document = PdfDocument::new(reader);
2232
2233        let count = document.page_count().unwrap();
2234        assert_eq!(count, 0);
2235
2236        // Try to get a page from empty document
2237        let result = document.get_page(0);
2238        assert!(result.is_err());
2239    }
2240
2241    #[test]
2242    fn test_extract_text_empty_document() {
2243        let pdf_data = create_pdf_with_metadata();
2244        let cursor = Cursor::new(pdf_data);
2245        let reader = PdfReader::new(cursor).unwrap();
2246        let document = PdfDocument::new(reader);
2247
2248        let text = document.extract_text().unwrap();
2249        assert!(text.is_empty());
2250    }
2251
2252    #[test]
2253    fn test_concurrent_access() {
2254        let pdf_data = create_minimal_pdf();
2255        let cursor = Cursor::new(pdf_data);
2256        let reader = PdfReader::new(cursor).unwrap();
2257        let document = PdfDocument::new(reader);
2258
2259        // Access multiple things concurrently
2260        let version = document.version().unwrap();
2261        let count = document.page_count().unwrap();
2262        let page = document.get_page(0).unwrap();
2263
2264        assert_eq!(version, "1.4");
2265        assert_eq!(count, 1);
2266        assert_eq!(page.media_box[2], 612.0);
2267    }
2268
2269    // Additional comprehensive tests
2270    mod comprehensive_tests {
2271        use super::*;
2272
2273        #[test]
2274        fn test_resource_manager_default() {
2275            let resources = ResourceManager::default();
2276            assert!(resources.get_cached((1, 0)).is_none());
2277        }
2278
2279        #[test]
2280        fn test_resource_manager_multiple_objects() {
2281            let resources = ResourceManager::new();
2282
2283            // Cache multiple objects
2284            resources.cache_object((1, 0), PdfObject::Integer(42));
2285            resources.cache_object((2, 0), PdfObject::Boolean(true));
2286            resources.cache_object(
2287                (3, 0),
2288                PdfObject::String(PdfString("test".as_bytes().to_vec())),
2289            );
2290
2291            // Verify all are cached
2292            assert!(resources.get_cached((1, 0)).is_some());
2293            assert!(resources.get_cached((2, 0)).is_some());
2294            assert!(resources.get_cached((3, 0)).is_some());
2295
2296            // Clear and verify empty
2297            resources.clear_cache();
2298            assert!(resources.get_cached((1, 0)).is_none());
2299            assert!(resources.get_cached((2, 0)).is_none());
2300            assert!(resources.get_cached((3, 0)).is_none());
2301        }
2302
2303        #[test]
2304        fn test_resource_manager_object_overwrite() {
2305            let resources = ResourceManager::new();
2306
2307            // Cache an object
2308            resources.cache_object((1, 0), PdfObject::Integer(42));
2309            assert_eq!(resources.get_cached((1, 0)), Some(PdfObject::Integer(42)));
2310
2311            // Overwrite with different object
2312            resources.cache_object((1, 0), PdfObject::Boolean(true));
2313            assert_eq!(resources.get_cached((1, 0)), Some(PdfObject::Boolean(true)));
2314        }
2315
2316        #[test]
2317        fn test_get_object_caching() {
2318            let pdf_data = create_minimal_pdf();
2319            let cursor = Cursor::new(pdf_data);
2320            let reader = PdfReader::new(cursor).unwrap();
2321            let document = PdfDocument::new(reader);
2322
2323            // Get object first time (should cache)
2324            let obj1 = document.get_object(1, 0).unwrap();
2325
2326            // Get same object again (should use cache)
2327            let obj2 = document.get_object(1, 0).unwrap();
2328
2329            // Objects should be identical
2330            assert_eq!(obj1, obj2);
2331
2332            // Verify it's cached
2333            assert!(document.resources.get_cached((1, 0)).is_some());
2334        }
2335
2336        #[test]
2337        fn test_get_object_different_generations() {
2338            let pdf_data = create_minimal_pdf();
2339            let cursor = Cursor::new(pdf_data);
2340            let reader = PdfReader::new(cursor).unwrap();
2341            let document = PdfDocument::new(reader);
2342
2343            // Get object with generation 0
2344            let _obj1 = document.get_object(1, 0).unwrap();
2345
2346            // Try to get same object with different generation (should fail)
2347            let result = document.get_object(1, 1);
2348            assert!(result.is_err());
2349
2350            // Original should still be cached
2351            assert!(document.resources.get_cached((1, 0)).is_some());
2352        }
2353
2354        #[test]
2355        fn test_get_object_nonexistent() {
2356            let pdf_data = create_minimal_pdf();
2357            let cursor = Cursor::new(pdf_data);
2358            let reader = PdfReader::new(cursor).unwrap();
2359            let document = PdfDocument::new(reader);
2360
2361            // Try to get non-existent object
2362            let result = document.get_object(999, 0);
2363            assert!(result.is_err());
2364        }
2365
2366        #[test]
2367        fn test_resolve_nested_references() {
2368            let pdf_data = create_minimal_pdf();
2369            let cursor = Cursor::new(pdf_data);
2370            let reader = PdfReader::new(cursor).unwrap();
2371            let document = PdfDocument::new(reader);
2372
2373            // Test resolving a reference
2374            let ref_obj = PdfObject::Reference(2, 0);
2375            let resolved = document.resolve(&ref_obj).unwrap();
2376
2377            // Should resolve to the pages object
2378            if let PdfObject::Dictionary(dict) = resolved {
2379                if let Some(PdfObject::Name(name)) = dict.get("Type") {
2380                    assert_eq!(name.0, "Pages");
2381                }
2382            }
2383        }
2384
2385        #[test]
2386        fn test_resolve_various_object_types() {
2387            let pdf_data = create_minimal_pdf();
2388            let cursor = Cursor::new(pdf_data);
2389            let reader = PdfReader::new(cursor).unwrap();
2390            let document = PdfDocument::new(reader);
2391
2392            // Test resolving different object types
2393            let test_objects = vec![
2394                PdfObject::Integer(42),
2395                PdfObject::Boolean(true),
2396                PdfObject::String(PdfString("test".as_bytes().to_vec())),
2397                PdfObject::Real(3.14),
2398                PdfObject::Null,
2399            ];
2400
2401            for obj in test_objects {
2402                let resolved = document.resolve(&obj).unwrap();
2403                assert_eq!(resolved, obj);
2404            }
2405        }
2406
2407        #[test]
2408        fn test_get_page_cached() {
2409            let pdf_data = create_minimal_pdf();
2410            let cursor = Cursor::new(pdf_data);
2411            let reader = PdfReader::new(cursor).unwrap();
2412            let document = PdfDocument::new(reader);
2413
2414            // Get page first time
2415            let page1 = document.get_page(0).unwrap();
2416
2417            // Get same page again
2418            let page2 = document.get_page(0).unwrap();
2419
2420            // Should be identical
2421            assert_eq!(page1.media_box, page2.media_box);
2422            assert_eq!(page1.rotation, page2.rotation);
2423            assert_eq!(page1.obj_ref, page2.obj_ref);
2424        }
2425
2426        #[test]
2427        fn test_metadata_caching() {
2428            let pdf_data = create_pdf_with_metadata();
2429            let cursor = Cursor::new(pdf_data);
2430            let reader = PdfReader::new(cursor).unwrap();
2431            let document = PdfDocument::new(reader);
2432
2433            // Get metadata first time
2434            let meta1 = document.metadata().unwrap();
2435
2436            // Get metadata again
2437            let meta2 = document.metadata().unwrap();
2438
2439            // Should be identical
2440            assert_eq!(meta1.title, meta2.title);
2441            assert_eq!(meta1.author, meta2.author);
2442            assert_eq!(meta1.subject, meta2.subject);
2443            assert_eq!(meta1.version, meta2.version);
2444        }
2445
2446        #[test]
2447        fn test_page_tree_initialization() {
2448            let pdf_data = create_minimal_pdf();
2449            let cursor = Cursor::new(pdf_data);
2450            let reader = PdfReader::new(cursor).unwrap();
2451            let document = PdfDocument::new(reader);
2452
2453            // Initially page tree should be None
2454            assert!(document.page_tree.borrow().is_none());
2455
2456            // After getting page count, page tree should be initialized
2457            let _count = document.page_count().unwrap();
2458            // Note: page_tree is private, so we can't directly check it
2459            // But we can verify it works by getting a page
2460            let _page = document.get_page(0).unwrap();
2461        }
2462
2463        #[test]
2464        fn test_get_page_resources() {
2465            let pdf_data = create_minimal_pdf();
2466            let cursor = Cursor::new(pdf_data);
2467            let reader = PdfReader::new(cursor).unwrap();
2468            let document = PdfDocument::new(reader);
2469
2470            let page = document.get_page(0).unwrap();
2471            let resources = document.get_page_resources(&page).unwrap();
2472
2473            // The minimal PDF has empty resources
2474            assert!(resources.is_some());
2475        }
2476
2477        #[test]
2478        fn test_get_page_content_streams_empty() {
2479            let pdf_data = create_minimal_pdf();
2480            let cursor = Cursor::new(pdf_data);
2481            let reader = PdfReader::new(cursor).unwrap();
2482            let document = PdfDocument::new(reader);
2483
2484            let page = document.get_page(0).unwrap();
2485            let streams = document.get_page_content_streams(&page).unwrap();
2486
2487            // Minimal PDF has no content streams
2488            assert!(streams.is_empty());
2489        }
2490
2491        #[test]
2492        fn test_extract_text_from_page() {
2493            let pdf_data = create_minimal_pdf();
2494            let cursor = Cursor::new(pdf_data);
2495            let reader = PdfReader::new(cursor).unwrap();
2496            let document = PdfDocument::new(reader);
2497
2498            let result = document.extract_text_from_page(0);
2499            // Should succeed even with empty page
2500            assert!(result.is_ok());
2501        }
2502
2503        #[test]
2504        fn test_extract_text_from_page_out_of_bounds() {
2505            let pdf_data = create_minimal_pdf();
2506            let cursor = Cursor::new(pdf_data);
2507            let reader = PdfReader::new(cursor).unwrap();
2508            let document = PdfDocument::new(reader);
2509
2510            let result = document.extract_text_from_page(999);
2511            // With fallback lookup, this might succeed or fail gracefully
2512            if result.is_err() {
2513                assert!(result.unwrap_err().to_string().contains("Page"));
2514            } else {
2515                // If succeeds, should return empty or valid text
2516                let _text = result.unwrap();
2517            }
2518        }
2519
2520        #[test]
2521        fn test_extract_text_with_options() {
2522            let pdf_data = create_minimal_pdf();
2523            let cursor = Cursor::new(pdf_data);
2524            let reader = PdfReader::new(cursor).unwrap();
2525            let document = PdfDocument::new(reader);
2526
2527            let options = crate::text::ExtractionOptions {
2528                preserve_layout: true,
2529                space_threshold: 0.5,
2530                newline_threshold: 15.0,
2531                ..Default::default()
2532            };
2533
2534            let result = document.extract_text_with_options(options);
2535            assert!(result.is_ok());
2536        }
2537
2538        #[test]
2539        fn test_version_different_pdf_versions() {
2540            // Test with different PDF versions
2541            let versions = vec!["1.3", "1.4", "1.5", "1.6", "1.7"];
2542
2543            for version in versions {
2544                let mut pdf_data = Vec::new();
2545
2546                // PDF header
2547                pdf_data.extend_from_slice(format!("%PDF-{version}\n").as_bytes());
2548
2549                // Track positions for xref
2550                let obj1_pos = pdf_data.len();
2551
2552                // Catalog object
2553                pdf_data.extend_from_slice(b"1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n");
2554
2555                let obj2_pos = pdf_data.len();
2556
2557                // Pages object
2558                pdf_data
2559                    .extend_from_slice(b"2 0 obj\n<< /Type /Pages /Kids [] /Count 0 >>\nendobj\n");
2560
2561                // Cross-reference table
2562                let xref_pos = pdf_data.len();
2563                pdf_data.extend_from_slice(b"xref\n");
2564                pdf_data.extend_from_slice(b"0 3\n");
2565                pdf_data.extend_from_slice(b"0000000000 65535 f \n");
2566                pdf_data.extend_from_slice(format!("{obj1_pos:010} 00000 n \n").as_bytes());
2567                pdf_data.extend_from_slice(format!("{obj2_pos:010} 00000 n \n").as_bytes());
2568
2569                // Trailer
2570                pdf_data.extend_from_slice(b"trailer\n");
2571                pdf_data.extend_from_slice(b"<< /Size 3 /Root 1 0 R >>\n");
2572                pdf_data.extend_from_slice(b"startxref\n");
2573                pdf_data.extend_from_slice(format!("{xref_pos}\n").as_bytes());
2574                pdf_data.extend_from_slice(b"%%EOF\n");
2575
2576                let cursor = Cursor::new(pdf_data);
2577                let reader = PdfReader::new(cursor).unwrap();
2578                let document = PdfDocument::new(reader);
2579
2580                let pdf_version = document.version().unwrap();
2581                assert_eq!(pdf_version, version);
2582            }
2583        }
2584
2585        #[test]
2586        fn test_page_count_zero() {
2587            let pdf_data = create_pdf_with_metadata(); // Has 0 pages
2588            let cursor = Cursor::new(pdf_data);
2589            let reader = PdfReader::new(cursor).unwrap();
2590            let document = PdfDocument::new(reader);
2591
2592            let count = document.page_count().unwrap();
2593            assert_eq!(count, 0);
2594        }
2595
2596        #[test]
2597        fn test_multiple_object_access() {
2598            let pdf_data = create_minimal_pdf();
2599            let cursor = Cursor::new(pdf_data);
2600            let reader = PdfReader::new(cursor).unwrap();
2601            let document = PdfDocument::new(reader);
2602
2603            // Access multiple objects
2604            let catalog = document.get_object(1, 0).unwrap();
2605            let pages = document.get_object(2, 0).unwrap();
2606            let page = document.get_object(3, 0).unwrap();
2607
2608            // Verify they're all different objects
2609            assert_ne!(catalog, pages);
2610            assert_ne!(pages, page);
2611            assert_ne!(catalog, page);
2612        }
2613
2614        #[test]
2615        fn test_error_handling_invalid_object_reference() {
2616            let pdf_data = create_minimal_pdf();
2617            let cursor = Cursor::new(pdf_data);
2618            let reader = PdfReader::new(cursor).unwrap();
2619            let document = PdfDocument::new(reader);
2620
2621            // Try to resolve an invalid reference
2622            let invalid_ref = PdfObject::Reference(999, 0);
2623            let result = document.resolve(&invalid_ref);
2624            assert!(result.is_err());
2625        }
2626
2627        #[test]
2628        fn test_concurrent_metadata_access() {
2629            let pdf_data = create_pdf_with_metadata();
2630            let cursor = Cursor::new(pdf_data);
2631            let reader = PdfReader::new(cursor).unwrap();
2632            let document = PdfDocument::new(reader);
2633
2634            // Access metadata and other properties concurrently
2635            let metadata = document.metadata().unwrap();
2636            let version = document.version().unwrap();
2637            let count = document.page_count().unwrap();
2638
2639            assert_eq!(metadata.title, Some("Test Document".to_string()));
2640            assert_eq!(version, "1.5");
2641            assert_eq!(count, 0);
2642        }
2643
2644        #[test]
2645        fn test_page_properties_comprehensive() {
2646            let pdf_data = create_minimal_pdf();
2647            let cursor = Cursor::new(pdf_data);
2648            let reader = PdfReader::new(cursor).unwrap();
2649            let document = PdfDocument::new(reader);
2650
2651            let page = document.get_page(0).unwrap();
2652
2653            // Test all page properties
2654            assert_eq!(page.media_box, [0.0, 0.0, 612.0, 792.0]);
2655            assert_eq!(page.crop_box, None);
2656            assert_eq!(page.rotation, 0);
2657            assert_eq!(page.obj_ref, (3, 0));
2658
2659            // Test width/height calculation
2660            assert_eq!(page.width(), 612.0);
2661            assert_eq!(page.height(), 792.0);
2662        }
2663
2664        #[test]
2665        fn test_memory_usage_efficiency() {
2666            let pdf_data = create_minimal_pdf();
2667            let cursor = Cursor::new(pdf_data);
2668            let reader = PdfReader::new(cursor).unwrap();
2669            let document = PdfDocument::new(reader);
2670
2671            // Access same page multiple times
2672            for _ in 0..10 {
2673                let _page = document.get_page(0).unwrap();
2674            }
2675
2676            // Should only have one copy in cache
2677            let page_count = document.page_count().unwrap();
2678            assert_eq!(page_count, 1);
2679        }
2680
2681        #[test]
2682        fn test_reader_borrow_safety() {
2683            let pdf_data = create_minimal_pdf();
2684            let cursor = Cursor::new(pdf_data);
2685            let reader = PdfReader::new(cursor).unwrap();
2686            let document = PdfDocument::new(reader);
2687
2688            // Multiple concurrent borrows should work
2689            let version = document.version().unwrap();
2690            let count = document.page_count().unwrap();
2691            let metadata = document.metadata().unwrap();
2692
2693            assert_eq!(version, "1.4");
2694            assert_eq!(count, 1);
2695            assert!(metadata.title.is_none());
2696        }
2697
2698        #[test]
2699        fn test_cache_consistency() {
2700            let pdf_data = create_minimal_pdf();
2701            let cursor = Cursor::new(pdf_data);
2702            let reader = PdfReader::new(cursor).unwrap();
2703            let document = PdfDocument::new(reader);
2704
2705            // Get object and verify caching
2706            let obj1 = document.get_object(1, 0).unwrap();
2707            let cached = document.resources.get_cached((1, 0)).unwrap();
2708
2709            assert_eq!(obj1, cached);
2710
2711            // Clear cache and get object again
2712            document.resources.clear_cache();
2713            let obj2 = document.get_object(1, 0).unwrap();
2714
2715            // Should be same content but loaded fresh
2716            assert_eq!(obj1, obj2);
2717        }
2718    }
2719
2720    #[test]
2721    fn test_resource_manager_new() {
2722        let resources = ResourceManager::new();
2723        assert!(resources.get_cached((1, 0)).is_none());
2724    }
2725
2726    #[test]
2727    fn test_resource_manager_cache_and_get() {
2728        let resources = ResourceManager::new();
2729
2730        // Cache an object
2731        let obj = PdfObject::Integer(42);
2732        resources.cache_object((10, 0), obj.clone());
2733
2734        // Should be retrievable
2735        let cached = resources.get_cached((10, 0));
2736        assert!(cached.is_some());
2737        assert_eq!(cached.unwrap(), obj);
2738
2739        // Non-existent object
2740        assert!(resources.get_cached((11, 0)).is_none());
2741    }
2742
2743    #[test]
2744    fn test_resource_manager_clear_cache() {
2745        let resources = ResourceManager::new();
2746
2747        // Cache multiple objects
2748        resources.cache_object((1, 0), PdfObject::Integer(1));
2749        resources.cache_object((2, 0), PdfObject::Integer(2));
2750        resources.cache_object((3, 0), PdfObject::Integer(3));
2751
2752        // Verify they're cached
2753        assert!(resources.get_cached((1, 0)).is_some());
2754        assert!(resources.get_cached((2, 0)).is_some());
2755        assert!(resources.get_cached((3, 0)).is_some());
2756
2757        // Clear cache
2758        resources.clear_cache();
2759
2760        // Should all be gone
2761        assert!(resources.get_cached((1, 0)).is_none());
2762        assert!(resources.get_cached((2, 0)).is_none());
2763        assert!(resources.get_cached((3, 0)).is_none());
2764    }
2765
2766    #[test]
2767    fn test_resource_manager_overwrite_cached() {
2768        let resources = ResourceManager::new();
2769
2770        // Cache initial object
2771        resources.cache_object((1, 0), PdfObject::Integer(42));
2772        assert_eq!(
2773            resources.get_cached((1, 0)).unwrap(),
2774            PdfObject::Integer(42)
2775        );
2776
2777        // Overwrite with new object
2778        resources.cache_object((1, 0), PdfObject::Integer(100));
2779        assert_eq!(
2780            resources.get_cached((1, 0)).unwrap(),
2781            PdfObject::Integer(100)
2782        );
2783    }
2784
2785    #[test]
2786    fn test_resource_manager_multiple_generations() {
2787        let resources = ResourceManager::new();
2788
2789        // Cache objects with different generations
2790        resources.cache_object((1, 0), PdfObject::Integer(10));
2791        resources.cache_object((1, 1), PdfObject::Integer(11));
2792        resources.cache_object((1, 2), PdfObject::Integer(12));
2793
2794        // Each should be distinct
2795        assert_eq!(
2796            resources.get_cached((1, 0)).unwrap(),
2797            PdfObject::Integer(10)
2798        );
2799        assert_eq!(
2800            resources.get_cached((1, 1)).unwrap(),
2801            PdfObject::Integer(11)
2802        );
2803        assert_eq!(
2804            resources.get_cached((1, 2)).unwrap(),
2805            PdfObject::Integer(12)
2806        );
2807    }
2808
2809    #[test]
2810    fn test_resource_manager_cache_complex_objects() {
2811        let resources = ResourceManager::new();
2812
2813        // Cache different object types
2814        resources.cache_object((1, 0), PdfObject::Boolean(true));
2815        resources.cache_object((2, 0), PdfObject::Real(3.14159));
2816        resources.cache_object(
2817            (3, 0),
2818            PdfObject::String(PdfString::new(b"Hello PDF".to_vec())),
2819        );
2820        resources.cache_object((4, 0), PdfObject::Name(PdfName::new("Type".to_string())));
2821
2822        let mut dict = PdfDictionary::new();
2823        dict.insert(
2824            "Key".to_string(),
2825            PdfObject::String(PdfString::new(b"Value".to_vec())),
2826        );
2827        resources.cache_object((5, 0), PdfObject::Dictionary(dict));
2828
2829        let array = vec![PdfObject::Integer(1), PdfObject::Integer(2)];
2830        resources.cache_object((6, 0), PdfObject::Array(PdfArray(array)));
2831
2832        // Verify all cached correctly
2833        assert_eq!(
2834            resources.get_cached((1, 0)).unwrap(),
2835            PdfObject::Boolean(true)
2836        );
2837        assert_eq!(
2838            resources.get_cached((2, 0)).unwrap(),
2839            PdfObject::Real(3.14159)
2840        );
2841        assert_eq!(
2842            resources.get_cached((3, 0)).unwrap(),
2843            PdfObject::String(PdfString::new(b"Hello PDF".to_vec()))
2844        );
2845        assert_eq!(
2846            resources.get_cached((4, 0)).unwrap(),
2847            PdfObject::Name(PdfName::new("Type".to_string()))
2848        );
2849        assert!(matches!(
2850            resources.get_cached((5, 0)).unwrap(),
2851            PdfObject::Dictionary(_)
2852        ));
2853        assert!(matches!(
2854            resources.get_cached((6, 0)).unwrap(),
2855            PdfObject::Array(_)
2856        ));
2857    }
2858
2859    // Tests for PdfDocument removed due to API incompatibilities
2860    // The methods tested don't exist in the current implementation
2861
2862    /*
2863        #[test]
2864        fn test_pdf_document_new_initialization() {
2865            // Create a minimal PDF for testing
2866            let data = b"%PDF-1.4
2867    1 0 obj<</Type/Catalog/Pages 2 0 R>>endobj
2868    2 0 obj<</Type/Pages/Kids[3 0 R]/Count 1>>endobj
2869    3 0 obj<</Type/Page/Parent 2 0 R/MediaBox[0 0 612 792]>>endobj
2870    xref
2871    0 4
2872    0000000000 65535 f
2873    0000000009 00000 n
2874    0000000052 00000 n
2875    0000000101 00000 n
2876    trailer<</Size 4/Root 1 0 R>>
2877    startxref
2878    164
2879    %%EOF";
2880            let reader = PdfReader::new(std::io::Cursor::new(data.to_vec())).unwrap();
2881            let document = PdfDocument::new(reader);
2882
2883            // Document should be created successfully
2884            // Initially no page tree loaded
2885            assert!(document.page_tree.borrow().is_none());
2886            assert!(document.metadata_cache.borrow().is_none());
2887        }
2888
2889        #[test]
2890        fn test_pdf_document_version() {
2891            // Create a minimal PDF for testing
2892            let data = b"%PDF-1.4
2893    1 0 obj<</Type/Catalog/Pages 2 0 R>>endobj
2894    2 0 obj<</Type/Pages/Kids[3 0 R]/Count 1>>endobj
2895    3 0 obj<</Type/Page/Parent 2 0 R/MediaBox[0 0 612 792]>>endobj
2896    xref
2897    0 4
2898    0000000000 65535 f
2899    0000000009 00000 n
2900    0000000052 00000 n
2901    0000000101 00000 n
2902    trailer<</Size 4/Root 1 0 R>>
2903    startxref
2904    164
2905    %%EOF";
2906            let reader = PdfReader::new(std::io::Cursor::new(data.to_vec())).unwrap();
2907            let document = PdfDocument::new(reader);
2908
2909            let version = document.version().unwrap();
2910            assert!(!version.is_empty());
2911            // Most PDFs are version 1.4 to 1.7
2912            assert!(version.starts_with("1.") || version.starts_with("2."));
2913        }
2914
2915        #[test]
2916        fn test_pdf_document_page_count() {
2917            // Create a minimal PDF for testing
2918            let data = b"%PDF-1.4
2919    1 0 obj<</Type/Catalog/Pages 2 0 R>>endobj
2920    2 0 obj<</Type/Pages/Kids[3 0 R]/Count 1>>endobj
2921    3 0 obj<</Type/Page/Parent 2 0 R/MediaBox[0 0 612 792]>>endobj
2922    xref
2923    0 4
2924    0000000000 65535 f
2925    0000000009 00000 n
2926    0000000052 00000 n
2927    0000000101 00000 n
2928    trailer<</Size 4/Root 1 0 R>>
2929    startxref
2930    164
2931    %%EOF";
2932            let reader = PdfReader::new(std::io::Cursor::new(data.to_vec())).unwrap();
2933            let document = PdfDocument::new(reader);
2934
2935            let count = document.page_count().unwrap();
2936            assert!(count > 0);
2937        }
2938
2939        #[test]
2940        fn test_pdf_document_metadata() {
2941            // Create a minimal PDF for testing
2942            let data = b"%PDF-1.4
2943    1 0 obj<</Type/Catalog/Pages 2 0 R>>endobj
2944    2 0 obj<</Type/Pages/Kids[3 0 R]/Count 1>>endobj
2945    3 0 obj<</Type/Page/Parent 2 0 R/MediaBox[0 0 612 792]>>endobj
2946    xref
2947    0 4
2948    0000000000 65535 f
2949    0000000009 00000 n
2950    0000000052 00000 n
2951    0000000101 00000 n
2952    trailer<</Size 4/Root 1 0 R>>
2953    startxref
2954    164
2955    %%EOF";
2956            let reader = PdfReader::new(std::io::Cursor::new(data.to_vec())).unwrap();
2957            let document = PdfDocument::new(reader);
2958
2959            let metadata = document.metadata().unwrap();
2960            // Metadata should be cached after first access
2961            assert!(document.metadata_cache.borrow().is_some());
2962
2963            // Second call should use cache
2964            let metadata2 = document.metadata().unwrap();
2965            assert_eq!(metadata.title, metadata2.title);
2966        }
2967
2968        #[test]
2969        fn test_pdf_document_get_page() {
2970            // Create a minimal PDF for testing
2971            let data = b"%PDF-1.4
2972    1 0 obj<</Type/Catalog/Pages 2 0 R>>endobj
2973    2 0 obj<</Type/Pages/Kids[3 0 R]/Count 1>>endobj
2974    3 0 obj<</Type/Page/Parent 2 0 R/MediaBox[0 0 612 792]>>endobj
2975    xref
2976    0 4
2977    0000000000 65535 f
2978    0000000009 00000 n
2979    0000000052 00000 n
2980    0000000101 00000 n
2981    trailer<</Size 4/Root 1 0 R>>
2982    startxref
2983    164
2984    %%EOF";
2985            let reader = PdfReader::new(std::io::Cursor::new(data.to_vec())).unwrap();
2986            let document = PdfDocument::new(reader);
2987
2988            // Get first page
2989            let page = document.get_page(0).unwrap();
2990            assert!(page.width() > 0.0);
2991            assert!(page.height() > 0.0);
2992
2993            // Page tree should be loaded now
2994            assert!(document.page_tree.borrow().is_some());
2995        }
2996
2997        #[test]
2998        fn test_pdf_document_get_page_out_of_bounds() {
2999            // Create a minimal PDF for testing
3000            let data = b"%PDF-1.4
3001    1 0 obj<</Type/Catalog/Pages 2 0 R>>endobj
3002    2 0 obj<</Type/Pages/Kids[3 0 R]/Count 1>>endobj
3003    3 0 obj<</Type/Page/Parent 2 0 R/MediaBox[0 0 612 792]>>endobj
3004    xref
3005    0 4
3006    0000000000 65535 f
3007    0000000009 00000 n
3008    0000000052 00000 n
3009    0000000101 00000 n
3010    trailer<</Size 4/Root 1 0 R>>
3011    startxref
3012    164
3013    %%EOF";
3014            let reader = PdfReader::new(std::io::Cursor::new(data.to_vec())).unwrap();
3015            let document = PdfDocument::new(reader);
3016
3017            let page_count = document.page_count().unwrap();
3018
3019            // Try to get page beyond count
3020            let result = document.get_page(page_count + 10);
3021            assert!(result.is_err());
3022        }
3023
3024
3025        #[test]
3026        fn test_pdf_document_get_object() {
3027            // Create a minimal PDF for testing
3028            let data = b"%PDF-1.4
3029    1 0 obj<</Type/Catalog/Pages 2 0 R>>endobj
3030    2 0 obj<</Type/Pages/Kids[3 0 R]/Count 1>>endobj
3031    3 0 obj<</Type/Page/Parent 2 0 R/MediaBox[0 0 612 792]>>endobj
3032    xref
3033    0 4
3034    0000000000 65535 f
3035    0000000009 00000 n
3036    0000000052 00000 n
3037    0000000101 00000 n
3038    trailer<</Size 4/Root 1 0 R>>
3039    startxref
3040    164
3041    %%EOF";
3042            let reader = PdfReader::new(std::io::Cursor::new(data.to_vec())).unwrap();
3043            let document = PdfDocument::new(reader);
3044
3045            // Get an object (catalog is usually object 1 0)
3046            let obj = document.get_object(1, 0);
3047            assert!(obj.is_ok());
3048
3049            // Object should be cached
3050            assert!(document.resources.get_cached((1, 0)).is_some());
3051        }
3052
3053
3054
3055        #[test]
3056        fn test_pdf_document_extract_text_from_page() {
3057            // Create a minimal PDF for testing
3058            let data = b"%PDF-1.4
3059    1 0 obj<</Type/Catalog/Pages 2 0 R>>endobj
3060    2 0 obj<</Type/Pages/Kids[3 0 R]/Count 1>>endobj
3061    3 0 obj<</Type/Page/Parent 2 0 R/MediaBox[0 0 612 792]>>endobj
3062    xref
3063    0 4
3064    0000000000 65535 f
3065    0000000009 00000 n
3066    0000000052 00000 n
3067    0000000101 00000 n
3068    trailer<</Size 4/Root 1 0 R>>
3069    startxref
3070    164
3071    %%EOF";
3072            let reader = PdfReader::new(std::io::Cursor::new(data.to_vec())).unwrap();
3073            let document = PdfDocument::new(reader);
3074
3075            // Try to extract text from first page
3076            let result = document.extract_text_from_page(0);
3077            // Even if no text, should not error
3078            assert!(result.is_ok());
3079        }
3080
3081        #[test]
3082        fn test_pdf_document_extract_all_text() {
3083            // Create a minimal PDF for testing
3084            let data = b"%PDF-1.4
3085    1 0 obj<</Type/Catalog/Pages 2 0 R>>endobj
3086    2 0 obj<</Type/Pages/Kids[3 0 R]/Count 1>>endobj
3087    3 0 obj<</Type/Page/Parent 2 0 R/MediaBox[0 0 612 792]>>endobj
3088    xref
3089    0 4
3090    0000000000 65535 f
3091    0000000009 00000 n
3092    0000000052 00000 n
3093    0000000101 00000 n
3094    trailer<</Size 4/Root 1 0 R>>
3095    startxref
3096    164
3097    %%EOF";
3098            let reader = PdfReader::new(std::io::Cursor::new(data.to_vec())).unwrap();
3099            let document = PdfDocument::new(reader);
3100
3101            let extracted = document.extract_text().unwrap();
3102            let page_count = document.page_count().unwrap();
3103
3104            // Should have text for each page
3105            assert_eq!(extracted.len(), page_count);
3106        }
3107
3108
3109        #[test]
3110        fn test_pdf_document_ensure_page_tree() {
3111            // Create a minimal PDF for testing
3112            let data = b"%PDF-1.4
3113    1 0 obj<</Type/Catalog/Pages 2 0 R>>endobj
3114    2 0 obj<</Type/Pages/Kids[3 0 R]/Count 1>>endobj
3115    3 0 obj<</Type/Page/Parent 2 0 R/MediaBox[0 0 612 792]>>endobj
3116    xref
3117    0 4
3118    0000000000 65535 f
3119    0000000009 00000 n
3120    0000000052 00000 n
3121    0000000101 00000 n
3122    trailer<</Size 4/Root 1 0 R>>
3123    startxref
3124    164
3125    %%EOF";
3126            let reader = PdfReader::new(std::io::Cursor::new(data.to_vec())).unwrap();
3127            let document = PdfDocument::new(reader);
3128
3129            // Initially no page tree
3130            assert!(document.page_tree.borrow().is_none());
3131
3132            // After ensuring, should be loaded
3133            document.ensure_page_tree().unwrap();
3134            assert!(document.page_tree.borrow().is_some());
3135
3136            // Second call should not error
3137            document.ensure_page_tree().unwrap();
3138        }
3139
3140        #[test]
3141        fn test_resource_manager_concurrent_access() {
3142            let resources = ResourceManager::new();
3143
3144            // Simulate concurrent-like access pattern
3145            resources.cache_object((1, 0), PdfObject::Integer(1));
3146            let obj1 = resources.get_cached((1, 0));
3147
3148            resources.cache_object((2, 0), PdfObject::Integer(2));
3149            let obj2 = resources.get_cached((2, 0));
3150
3151            // Both should be accessible
3152            assert_eq!(obj1.unwrap(), PdfObject::Integer(1));
3153            assert_eq!(obj2.unwrap(), PdfObject::Integer(2));
3154        }
3155
3156        #[test]
3157        fn test_resource_manager_large_cache() {
3158            let resources = ResourceManager::new();
3159
3160            // Cache many objects
3161            for i in 0..1000 {
3162                resources.cache_object((i, 0), PdfObject::Integer(i as i64));
3163            }
3164
3165            // Verify random access
3166            assert_eq!(resources.get_cached((500, 0)).unwrap(), PdfObject::Integer(500));
3167            assert_eq!(resources.get_cached((999, 0)).unwrap(), PdfObject::Integer(999));
3168            assert_eq!(resources.get_cached((0, 0)).unwrap(), PdfObject::Integer(0));
3169
3170            // Clear should remove all
3171            resources.clear_cache();
3172            assert!(resources.get_cached((500, 0)).is_none());
3173        }
3174        */
3175}