Skip to main content

oxidize_pdf/operations/
mod.rs

1//! PDF operations module
2//!
3//! This module provides high-level operations for manipulating PDF documents
4//! such as splitting, merging, rotating pages, and reordering.
5
6pub mod chunk_page_mapper;
7pub mod extract_images;
8pub mod merge;
9pub mod overlay;
10pub mod page_analysis;
11pub mod page_extraction;
12pub mod pdf_ocr_converter;
13pub mod reorder;
14pub mod rotate;
15pub mod semantic_redactor;
16pub mod source_highlighter;
17pub mod split;
18
19pub use chunk_page_mapper::ChunkPageMapper;
20pub use extract_images::{
21    extract_images_from_pages, extract_images_from_pdf, ExtractImagesOptions, ExtractedImage,
22    ExtractedImageData, ImageExtractionError, ImageExtractionLimits, ImageExtractionResult,
23    ImageExtractor, ImagePreprocessingOptions,
24};
25pub use merge::{merge_pdf_files, merge_pdfs, MergeInput, MergeOptions, PdfMerger};
26pub use overlay::{overlay_pdf, OverlayOptions, OverlayPosition, PdfOverlay};
27pub use page_analysis::{AnalysisOptions, ContentAnalysis, PageContentAnalyzer, PageType};
28pub use page_extraction::{
29    extract_page, extract_page_range, extract_page_range_to_file, extract_page_to_file,
30    extract_pages, extract_pages_to_file, PageExtractionOptions, PageExtractor,
31};
32pub use pdf_ocr_converter::{ConversionOptions, ConversionResult, PdfOcrConverter};
33pub use reorder::{
34    move_pdf_page, reorder_pdf_pages, reverse_pdf_pages, swap_pdf_pages, PageReorderer,
35    ReorderOptions,
36};
37pub use rotate::{rotate_all_pages, rotate_pdf_pages, PageRotator, RotateOptions, RotationAngle};
38pub use semantic_redactor::{
39    RedactionConfig, RedactionEntry, RedactionReport, RedactionStyle, SemanticRedactor,
40    SemanticRedactorError, SemanticRedactorResult,
41};
42pub use source_highlighter::{
43    fragment_to_highlight_rect, HighlightStyle, IndexedFragment, SourceHighlighter,
44    SourceHighlighterError, SourceHighlighterResult, TextPositionIndex,
45};
46pub use split::{split_into_pages, split_pdf, PdfSplitter, SplitMode, SplitOptions};
47
48use crate::error::PdfError;
49
50/// Result type for operations
51pub type OperationResult<T> = Result<T, OperationError>;
52
53/// Operation-specific errors
54#[derive(Debug, thiserror::Error)]
55pub enum OperationError {
56    /// Page index out of bounds
57    #[error("Page index {0} out of bounds (document has {1} pages)")]
58    PageIndexOutOfBounds(usize, usize),
59
60    /// Invalid page range
61    #[error("Invalid page range: {0}")]
62    InvalidPageRange(String),
63
64    /// No pages to process
65    #[error("No pages to process")]
66    NoPagesToProcess,
67
68    /// Resource conflict during merge
69    #[error("Resource conflict: {0}")]
70    ResourceConflict(String),
71
72    /// Invalid rotation angle
73    #[error("Invalid rotation angle: {0} (must be 0, 90, 180, or 270)")]
74    InvalidRotation(i32),
75
76    /// Parse error
77    #[error("Parse error: {0}")]
78    ParseError(String),
79
80    /// Invalid file path
81    #[error("Invalid file path: {reason}")]
82    InvalidPath { reason: String },
83
84    /// IO error
85    #[error("IO error: {0}")]
86    Io(#[from] std::io::Error),
87
88    /// Core PDF error
89    #[error("PDF error: {0}")]
90    PdfError(#[from] PdfError),
91
92    /// General processing error
93    #[error("Processing error: {0}")]
94    ProcessingError(String),
95}
96
97/// Page range specification
98#[derive(Debug, Clone)]
99pub enum PageRange {
100    /// All pages
101    All,
102    /// Single page (0-based index)
103    Single(usize),
104    /// Range of pages (inclusive, 0-based)
105    Range(usize, usize),
106    /// List of specific pages (0-based indices)
107    List(Vec<usize>),
108}
109
110impl PageRange {
111    /// Parse a page range from a string
112    ///
113    /// Examples:
114    /// - "all" -> All pages
115    /// - "1" -> Single page (converts to 0-based)
116    /// - "1-5" -> Range of pages (converts to 0-based)
117    /// - "1,3,5" -> List of pages (converts to 0-based)
118    pub fn parse(s: &str) -> Result<Self, OperationError> {
119        let s = s.trim();
120
121        if s.eq_ignore_ascii_case("all") {
122            return Ok(PageRange::All);
123        }
124
125        // Try single page
126        if let Ok(page) = s.parse::<usize>() {
127            if page == 0 {
128                return Err(OperationError::InvalidPageRange(
129                    "Page numbers start at 1".to_string(),
130                ));
131            }
132            return Ok(PageRange::Single(page - 1));
133        }
134
135        // Try range (e.g., "1-5")
136        if let Some((start, end)) = s.split_once('-') {
137            let start = start
138                .trim()
139                .parse::<usize>()
140                .map_err(|_| OperationError::InvalidPageRange(format!("Invalid start: {start}")))?;
141            let end = end
142                .trim()
143                .parse::<usize>()
144                .map_err(|_| OperationError::InvalidPageRange(format!("Invalid end: {end}")))?;
145
146            if start == 0 || end == 0 {
147                return Err(OperationError::InvalidPageRange(
148                    "Page numbers start at 1".to_string(),
149                ));
150            }
151
152            if start > end {
153                return Err(OperationError::InvalidPageRange(format!(
154                    "Start {start} is greater than end {end}"
155                )));
156            }
157
158            return Ok(PageRange::Range(start - 1, end - 1));
159        }
160
161        // Try list (e.g., "1,3,5")
162        if s.contains(',') {
163            let pages: Result<Vec<usize>, _> = s
164                .split(',')
165                .map(|p| {
166                    let page = p.trim().parse::<usize>().map_err(|_| {
167                        OperationError::InvalidPageRange(format!("Invalid page: {p}"))
168                    })?;
169                    if page == 0 {
170                        return Err(OperationError::InvalidPageRange(
171                            "Page numbers start at 1".to_string(),
172                        ));
173                    }
174                    Ok(page - 1)
175                })
176                .collect();
177
178            return Ok(PageRange::List(pages?));
179        }
180
181        Err(OperationError::InvalidPageRange(format!(
182            "Invalid format: {s}"
183        )))
184    }
185
186    /// Get the page indices for this range
187    pub fn get_indices(&self, total_pages: usize) -> Result<Vec<usize>, OperationError> {
188        match self {
189            PageRange::All => Ok((0..total_pages).collect()),
190            PageRange::Single(idx) => {
191                if *idx >= total_pages {
192                    Err(OperationError::PageIndexOutOfBounds(*idx, total_pages))
193                } else {
194                    Ok(vec![*idx])
195                }
196            }
197            PageRange::Range(start, end) => {
198                if *start >= total_pages {
199                    Err(OperationError::PageIndexOutOfBounds(*start, total_pages))
200                } else if *end >= total_pages {
201                    Err(OperationError::PageIndexOutOfBounds(*end, total_pages))
202                } else {
203                    Ok((*start..=*end).collect())
204                }
205            }
206            PageRange::List(pages) => {
207                for &page in pages {
208                    if page >= total_pages {
209                        return Err(OperationError::PageIndexOutOfBounds(page, total_pages));
210                    }
211                }
212                Ok(pages.clone())
213            }
214        }
215    }
216}
217
218#[cfg(test)]
219mod error_tests;
220
221#[cfg(test)]
222mod tests {
223    use super::*;
224
225    #[test]
226    fn test_page_range_parsing() {
227        assert!(matches!(PageRange::parse("all").unwrap(), PageRange::All));
228        assert!(matches!(PageRange::parse("ALL").unwrap(), PageRange::All));
229
230        match PageRange::parse("5").unwrap() {
231            PageRange::Single(idx) => assert_eq!(idx, 4),
232            _ => panic!("Expected Single"),
233        }
234
235        match PageRange::parse("2-5").unwrap() {
236            PageRange::Range(start, end) => {
237                assert_eq!(start, 1);
238                assert_eq!(end, 4);
239            }
240            _ => panic!("Expected Range"),
241        }
242
243        match PageRange::parse("1,3,5,7").unwrap() {
244            PageRange::List(pages) => {
245                assert_eq!(pages, vec![0, 2, 4, 6]);
246            }
247            _ => panic!("Expected List"),
248        }
249
250        assert!(PageRange::parse("0").is_err());
251        assert!(PageRange::parse("5-2").is_err());
252        assert!(PageRange::parse("invalid").is_err());
253    }
254
255    #[test]
256    fn test_page_range_indices() {
257        let total = 10;
258
259        assert_eq!(
260            PageRange::All.get_indices(total).unwrap(),
261            vec![0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
262        );
263
264        assert_eq!(PageRange::Single(5).get_indices(total).unwrap(), vec![5]);
265
266        assert_eq!(
267            PageRange::Range(2, 5).get_indices(total).unwrap(),
268            vec![2, 3, 4, 5]
269        );
270
271        assert_eq!(
272            PageRange::List(vec![1, 3, 5]).get_indices(total).unwrap(),
273            vec![1, 3, 5]
274        );
275
276        assert!(PageRange::Single(10).get_indices(total).is_err());
277        assert!(PageRange::Range(8, 15).get_indices(total).is_err());
278    }
279
280    #[test]
281    fn test_page_range_empty_list() {
282        // Test parsing an empty list of pages
283        let result = PageRange::parse("");
284        assert!(result.is_err());
285
286        // Test list with only commas
287        let result2 = PageRange::parse(",,");
288        assert!(result2.is_err());
289    }
290
291    #[test]
292    fn test_page_range_list_with_zero() {
293        // Test that 0 in a list causes error (line 148-151)
294        let result = PageRange::parse("1,0,3");
295        assert!(result.is_err());
296        if let Err(e) = result {
297            match e {
298                OperationError::InvalidPageRange(msg) => {
299                    assert!(msg.contains("Page numbers start at 1"));
300                }
301                _ => panic!("Expected InvalidPageRange error"),
302            }
303        }
304    }
305
306    #[test]
307    fn test_page_range_with_extra_spaces() {
308        // Test parsing with extra spaces in list (line 143-144)
309        match PageRange::parse(" 1 , 3 , 5 ").unwrap() {
310            PageRange::List(pages) => {
311                assert_eq!(pages, vec![0, 2, 4]);
312            }
313            _ => panic!("Expected List"),
314        }
315
316        // Test range with spaces
317        match PageRange::parse(" 2 - 5 ").unwrap() {
318            PageRange::Range(start, end) => {
319                assert_eq!(start, 1);
320                assert_eq!(end, 4);
321            }
322            _ => panic!("Expected Range"),
323        }
324    }
325
326    #[test]
327    fn test_page_range_equal_start_end() {
328        // Test range where start == end (should work)
329        match PageRange::parse("5-5").unwrap() {
330            PageRange::Range(start, end) => {
331                assert_eq!(start, 4);
332                assert_eq!(end, 4);
333            }
334            _ => panic!("Expected Range"),
335        }
336
337        // Verify get_indices works correctly
338        let range = PageRange::Range(4, 4);
339        assert_eq!(range.get_indices(10).unwrap(), vec![4]);
340    }
341
342    #[test]
343    fn test_page_range_list_out_of_bounds() {
344        // Test List variant with out of bounds indices (line 186-190)
345        let pages = PageRange::List(vec![2, 5, 15]);
346        let result = pages.get_indices(10);
347        assert!(result.is_err());
348        if let Err(e) = result {
349            match e {
350                OperationError::PageIndexOutOfBounds(idx, total) => {
351                    assert_eq!(idx, 15);
352                    assert_eq!(total, 10);
353                }
354                _ => panic!("Expected PageIndexOutOfBounds error"),
355            }
356        }
357    }
358
359    #[test]
360    fn test_page_range_empty_document() {
361        // Test get_indices with 0 total pages
362        let total = 0;
363
364        // All should return empty vector
365        assert_eq!(
366            PageRange::All.get_indices(total).unwrap(),
367            Vec::<usize>::new()
368        );
369
370        // Single should fail
371        assert!(PageRange::Single(0).get_indices(total).is_err());
372
373        // Range should fail
374        assert!(PageRange::Range(0, 1).get_indices(total).is_err());
375
376        // Empty list should work
377        assert_eq!(
378            PageRange::List(vec![]).get_indices(total).unwrap(),
379            Vec::<usize>::new()
380        );
381    }
382
383    #[test]
384    fn test_page_range_additional_invalid_formats() {
385        // Test various invalid formats (line 160-162)
386        assert!(PageRange::parse("1-2-3").is_err()); // Multiple dashes
387        assert!(PageRange::parse("abc").is_err()); // Non-numeric
388        assert!(PageRange::parse("1.5").is_err()); // Decimal
389        assert!(PageRange::parse("-5").is_err()); // Negative without start
390        assert!(PageRange::parse("1-").is_err()); // Missing end
391        assert!(PageRange::parse("-").is_err()); // Only dash
392    }
393
394    #[test]
395    fn test_module_exports() {
396        // Verify that all operation types are exported correctly
397        // This test just ensures the module structure is correct
398
399        // We can create these types through their modules
400        use super::extract_images::ExtractImagesOptions;
401        use super::merge::MergeOptions;
402        use super::page_analysis::{AnalysisOptions, PageType};
403        use super::page_extraction::PageExtractionOptions;
404        use super::rotate::{RotateOptions, RotationAngle};
405        use super::split::{SplitMode, SplitOptions};
406
407        // Just verify we can access these types
408        let _extract: ExtractImagesOptions;
409        let _merge: MergeOptions;
410        let _analysis: AnalysisOptions;
411        let _extraction: PageExtractionOptions;
412        let _rotate: RotateOptions;
413        let _split: SplitOptions;
414        let _angle: RotationAngle;
415        let _page_type: PageType;
416        let _mode: SplitMode;
417    }
418
419    #[test]
420    fn test_operation_error_variants() {
421        let errors = vec![
422            OperationError::PageIndexOutOfBounds(5, 3),
423            OperationError::InvalidPageRange("test".to_string()),
424            OperationError::NoPagesToProcess,
425            OperationError::ResourceConflict("test".to_string()),
426            OperationError::InvalidRotation(45),
427            OperationError::ParseError("test".to_string()),
428            OperationError::ProcessingError("test".to_string()),
429        ];
430
431        for error in errors {
432            let message = error.to_string();
433            assert!(!message.is_empty());
434        }
435    }
436
437    #[test]
438    fn test_page_range_edge_cases() {
439        // Test whitespace handling
440        assert!(matches!(
441            PageRange::parse("  all  ").unwrap(),
442            PageRange::All
443        ));
444        assert!(matches!(
445            PageRange::parse(" 5 ").unwrap(),
446            PageRange::Single(4)
447        ));
448
449        // Test various list formats
450        match PageRange::parse(" 1 , 3 , 5 ").unwrap() {
451            PageRange::List(pages) => assert_eq!(pages, vec![0, 2, 4]),
452            _ => panic!("Expected List"),
453        }
454
455        // Test range with spaces
456        match PageRange::parse(" 2 - 5 ").unwrap() {
457            PageRange::Range(start, end) => {
458                assert_eq!(start, 1);
459                assert_eq!(end, 4);
460            }
461            _ => panic!("Expected Range"),
462        }
463    }
464
465    #[test]
466    fn test_page_range_invalid_formats() {
467        // Test various invalid formats
468        assert!(PageRange::parse("").is_err());
469        assert!(PageRange::parse("abc").is_err());
470        assert!(PageRange::parse("1-").is_err());
471        assert!(PageRange::parse("-5").is_err());
472        assert!(PageRange::parse("1-2-3").is_err());
473        assert!(PageRange::parse("1,0,3").is_err());
474        assert!(PageRange::parse("0-5").is_err());
475        assert!(PageRange::parse("5-0").is_err());
476        assert!(PageRange::parse("1,,3").is_err());
477        assert!(PageRange::parse("1.5").is_err());
478    }
479
480    #[test]
481    fn test_page_range_get_indices_empty_document() {
482        let total = 0;
483
484        assert_eq!(
485            PageRange::All.get_indices(total).unwrap(),
486            vec![] as Vec<usize>
487        );
488        assert!(PageRange::Single(0).get_indices(total).is_err());
489        assert!(PageRange::Range(0, 1).get_indices(total).is_err());
490        assert!(PageRange::List(vec![0]).get_indices(total).is_err());
491    }
492
493    #[test]
494    fn test_page_range_get_indices_single_page_document() {
495        let total = 1;
496
497        assert_eq!(PageRange::All.get_indices(total).unwrap(), vec![0]);
498        assert_eq!(PageRange::Single(0).get_indices(total).unwrap(), vec![0]);
499        assert!(PageRange::Single(1).get_indices(total).is_err());
500        assert_eq!(PageRange::Range(0, 0).get_indices(total).unwrap(), vec![0]);
501        assert!(PageRange::Range(0, 1).get_indices(total).is_err());
502    }
503
504    #[test]
505    fn test_page_range_list_duplicates() {
506        // Lists can have duplicates in our implementation
507        match PageRange::parse("1,1,2,2,3").unwrap() {
508            PageRange::List(pages) => {
509                assert_eq!(pages, vec![0, 0, 1, 1, 2]);
510            }
511            _ => panic!("Expected List"),
512        }
513    }
514
515    #[test]
516    fn test_page_range_list_unordered() {
517        // Lists don't need to be ordered
518        match PageRange::parse("5,2,8,1,3").unwrap() {
519            PageRange::List(pages) => {
520                assert_eq!(pages, vec![4, 1, 7, 0, 2]);
521            }
522            _ => panic!("Expected List"),
523        }
524    }
525
526    #[test]
527    fn test_operation_error_display() {
528        let error = OperationError::PageIndexOutOfBounds(10, 5);
529        assert_eq!(
530            error.to_string(),
531            "Page index 10 out of bounds (document has 5 pages)"
532        );
533
534        let error = OperationError::InvalidRotation(45);
535        assert_eq!(
536            error.to_string(),
537            "Invalid rotation angle: 45 (must be 0, 90, 180, or 270)"
538        );
539
540        let error = OperationError::NoPagesToProcess;
541        assert_eq!(error.to_string(), "No pages to process");
542    }
543
544    #[test]
545    fn test_page_range_large_document() {
546        let total = 1000;
547
548        // Test all pages
549        let indices = PageRange::All.get_indices(total).unwrap();
550        assert_eq!(indices.len(), 1000);
551        assert_eq!(indices[0], 0);
552        assert_eq!(indices[999], 999);
553
554        // Test large range
555        let indices = PageRange::Range(100, 200).get_indices(total).unwrap();
556        assert_eq!(indices.len(), 101);
557        assert_eq!(indices[0], 100);
558        assert_eq!(indices[100], 200);
559    }
560
561    #[test]
562    fn test_page_range_parse_case_insensitive() {
563        assert!(matches!(PageRange::parse("all").unwrap(), PageRange::All));
564        assert!(matches!(PageRange::parse("ALL").unwrap(), PageRange::All));
565        assert!(matches!(PageRange::parse("All").unwrap(), PageRange::All));
566        assert!(matches!(PageRange::parse("aLL").unwrap(), PageRange::All));
567    }
568
569    #[test]
570    fn test_operation_result_type() {
571        // Test that OperationResult works correctly
572        fn test_function() -> OperationResult<usize> {
573            Ok(42)
574        }
575
576        fn test_error_function() -> OperationResult<usize> {
577            Err(OperationError::NoPagesToProcess)
578        }
579
580        assert_eq!(test_function().unwrap(), 42);
581        assert!(test_error_function().is_err());
582    }
583
584    #[test]
585    fn test_page_range_boundary_values() {
586        // Test maximum safe values
587        let large_page = usize::MAX / 2;
588
589        match PageRange::parse(&large_page.to_string()).unwrap() {
590            PageRange::Single(idx) => assert_eq!(idx, large_page - 1),
591            _ => panic!("Expected Single"),
592        }
593
594        // Test with actual document
595        let indices = PageRange::Single(5).get_indices(10).unwrap();
596        assert_eq!(indices, vec![5]);
597
598        // Test range boundary
599        let indices = PageRange::Range(0, 9).get_indices(10).unwrap();
600        assert_eq!(indices.len(), 10);
601    }
602
603    #[test]
604    fn test_error_from_io() {
605        use std::io;
606
607        let io_error = io::Error::new(io::ErrorKind::NotFound, "File not found");
608        let op_error: OperationError = io_error.into();
609
610        match op_error {
611            OperationError::Io(_) => {}
612            _ => panic!("Expected Io variant"),
613        }
614    }
615
616    #[test]
617    fn test_page_range_fmt_debug() {
618        // Test Debug implementation
619        let range = PageRange::All;
620        let debug_str = format!("{:?}", range);
621        assert!(debug_str.contains("All"));
622
623        let range = PageRange::Single(5);
624        let debug_str = format!("{:?}", range);
625        assert!(debug_str.contains("Single"));
626        assert!(debug_str.contains("5"));
627
628        let range = PageRange::Range(1, 10);
629        let debug_str = format!("{:?}", range);
630        assert!(debug_str.contains("Range"));
631
632        let range = PageRange::List(vec![1, 2, 3]);
633        let debug_str = format!("{:?}", range);
634        assert!(debug_str.contains("List"));
635    }
636
637    #[test]
638    fn test_page_range_clone() {
639        let original = PageRange::List(vec![1, 2, 3]);
640        let cloned = original.clone();
641
642        match (original, cloned) {
643            (PageRange::List(orig), PageRange::List(clone)) => {
644                assert_eq!(orig, clone);
645            }
646            _ => panic!("Clone failed"),
647        }
648    }
649}