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