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