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