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