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