Skip to main content

oxidize_pdf/operations/
split.rs

1//! PDF splitting functionality
2//!
3//! This module provides functionality to split PDF documents into multiple files
4//! based on page ranges or other criteria.
5
6use super::{OperationError, OperationResult, PageRange};
7use crate::parser::page_tree::ParsedPage;
8use crate::parser::{PdfDocument, PdfReader};
9use crate::{Document, Page};
10use std::fs::File;
11use std::path::{Path, PathBuf};
12
13/// Options for PDF splitting
14#[derive(Debug, Clone)]
15pub struct SplitOptions {
16    /// How to split the document
17    pub mode: SplitMode,
18    /// Output file naming pattern
19    pub output_pattern: String,
20    /// Whether to preserve document metadata
21    pub preserve_metadata: bool,
22    /// Whether to optimize output files
23    pub optimize: bool,
24}
25
26impl Default for SplitOptions {
27    fn default() -> Self {
28        Self {
29            mode: SplitMode::SinglePages,
30            output_pattern: "page_{}.pdf".to_string(),
31            preserve_metadata: true,
32            optimize: false,
33        }
34    }
35}
36
37/// Split mode specification
38#[derive(Debug, Clone)]
39pub enum SplitMode {
40    /// Split into single pages
41    SinglePages,
42    /// Split by page ranges
43    Ranges(Vec<PageRange>),
44    /// Split into chunks of N pages
45    ChunkSize(usize),
46    /// Split at specific page numbers (creates files before each split point)
47    SplitAt(Vec<usize>),
48}
49
50/// PDF splitter
51pub struct PdfSplitter {
52    document: PdfDocument<File>,
53    options: SplitOptions,
54}
55
56impl PdfSplitter {
57    /// Create a new PDF splitter
58    pub fn new(document: PdfDocument<File>, options: SplitOptions) -> Self {
59        Self { document, options }
60    }
61
62    /// Split the PDF according to the options
63    pub fn split(&mut self) -> OperationResult<Vec<PathBuf>> {
64        let total_pages =
65            self.document
66                .page_count()
67                .map_err(|e| OperationError::ParseError(e.to_string()))? as usize;
68
69        if total_pages == 0 {
70            return Err(OperationError::NoPagesToProcess);
71        }
72
73        let ranges = match &self.options.mode {
74            SplitMode::SinglePages => {
75                // Create a range for each page
76                (0..total_pages).map(PageRange::Single).collect()
77            }
78            SplitMode::Ranges(ranges) => ranges.clone(),
79            SplitMode::ChunkSize(size) => {
80                // Create ranges for chunks
81                let mut ranges = Vec::new();
82                let mut start = 0;
83                while start < total_pages {
84                    let end = (start + size - 1).min(total_pages - 1);
85                    ranges.push(PageRange::Range(start, end));
86                    start += size;
87                }
88                ranges
89            }
90            SplitMode::SplitAt(split_points) => {
91                // Create ranges between split points
92                let mut ranges = Vec::new();
93                let mut start = 0;
94
95                for &split_point in split_points {
96                    if split_point > 0 && split_point < total_pages {
97                        ranges.push(PageRange::Range(start, split_point - 1));
98                        start = split_point;
99                    }
100                }
101
102                // Add the last range
103                if start < total_pages {
104                    ranges.push(PageRange::Range(start, total_pages - 1));
105                }
106
107                ranges
108            }
109        };
110
111        // Process each range
112        let mut output_files = Vec::new();
113
114        for (index, range) in ranges.iter().enumerate() {
115            let output_path = self.format_output_path(index, range);
116            self.extract_range(range, &output_path)?;
117            output_files.push(output_path);
118        }
119
120        Ok(output_files)
121    }
122
123    /// Extract a page range to a new PDF file
124    fn extract_range(&mut self, range: &PageRange, output_path: &Path) -> OperationResult<()> {
125        let total_pages =
126            self.document
127                .page_count()
128                .map_err(|e| OperationError::ParseError(e.to_string()))? as usize;
129
130        let indices = range.get_indices(total_pages)?;
131        if indices.is_empty() {
132            return Err(OperationError::NoPagesToProcess);
133        }
134
135        // Create new document
136        let mut doc = Document::new();
137
138        // Copy metadata if requested
139        if self.options.preserve_metadata {
140            if let Ok(metadata) = self.document.metadata() {
141                if let Some(title) = metadata.title {
142                    doc.set_title(&title);
143                }
144                if let Some(author) = metadata.author {
145                    doc.set_author(&author);
146                }
147                if let Some(subject) = metadata.subject {
148                    doc.set_subject(&subject);
149                }
150                if let Some(keywords) = metadata.keywords {
151                    doc.set_keywords(&keywords);
152                }
153            }
154        }
155
156        // Extract and add pages
157        for &page_idx in &indices {
158            let parsed_page = self
159                .document
160                .get_page(page_idx as u32)
161                .map_err(|e| OperationError::ParseError(e.to_string()))?;
162
163            let page = self.convert_page(&parsed_page)?;
164            doc.add_page(page);
165        }
166
167        // Save the document
168        doc.save(output_path)?;
169
170        Ok(())
171    }
172
173    /// Convert a parsed page to a new page, preserving its content verbatim.
174    ///
175    /// Copies the original content streams and resources (fonts, images,
176    /// XObjects) unchanged via [`Page::from_parsed_with_content`], the same path
177    /// `merge` uses. The former implementation re-emitted the page through the
178    /// high-level API — mapping every font to one of the standard 14, decoding
179    /// bytes with `from_utf8`, and dropping every operator it did not recognize
180    /// — so it lost images and mangled any non-ASCII or CID-encoded text
181    /// (#453). `/Rotate` is carried over by `from_parsed_with_content`.
182    fn convert_page(&mut self, parsed_page: &ParsedPage) -> OperationResult<Page> {
183        Page::from_parsed_with_content(parsed_page, &self.document)
184            .map_err(|e| OperationError::ParseError(e.to_string()))
185    }
186
187    /// Format the output path based on the pattern
188    fn format_output_path(&self, index: usize, range: &PageRange) -> PathBuf {
189        let filename = match range {
190            PageRange::Single(page) => self
191                .options
192                .output_pattern
193                .replace("{}", &(page + 1).to_string())
194                .replace("{n}", &(index + 1).to_string())
195                .replace("{page}", &(page + 1).to_string()),
196            PageRange::Range(start, end) => self
197                .options
198                .output_pattern
199                .replace("{}", &format!("{}-{}", start + 1, end + 1))
200                .replace("{n}", &(index + 1).to_string())
201                .replace("{start}", &(start + 1).to_string())
202                .replace("{end}", &(end + 1).to_string()),
203            _ => self
204                .options
205                .output_pattern
206                .replace("{}", &(index + 1).to_string())
207                .replace("{n}", &(index + 1).to_string()),
208        };
209
210        PathBuf::from(filename)
211    }
212}
213
214/// Split a PDF file by page ranges
215pub fn split_pdf<P: AsRef<Path>>(
216    input_path: P,
217    options: SplitOptions,
218) -> OperationResult<Vec<PathBuf>> {
219    let document = PdfReader::open_document(input_path)
220        .map_err(|e| OperationError::ParseError(e.to_string()))?;
221
222    let mut splitter = PdfSplitter::new(document, options);
223    splitter.split()
224}
225
226/// Split a PDF file into single pages
227pub fn split_into_pages<P: AsRef<Path>>(
228    input_path: P,
229    output_pattern: &str,
230) -> OperationResult<Vec<PathBuf>> {
231    let options = SplitOptions {
232        mode: SplitMode::SinglePages,
233        output_pattern: output_pattern.to_string(),
234        ..Default::default()
235    };
236
237    split_pdf(input_path, options)
238}
239
240#[cfg(test)]
241mod tests {
242    use super::*;
243
244    #[test]
245    fn test_split_options_default() {
246        let options = SplitOptions::default();
247        assert!(matches!(options.mode, SplitMode::SinglePages));
248        assert_eq!(options.output_pattern, "page_{}.pdf");
249        assert!(options.preserve_metadata);
250        assert!(!options.optimize);
251    }
252
253    #[test]
254    fn test_format_output_path() {
255        let _options = SplitOptions {
256            output_pattern: "output_page_{}.pdf".to_string(),
257            ..Default::default()
258        };
259
260        let _reader = PdfReader::open("test.pdf");
261        // Note: This test would need a valid PDF file to work properly
262        // For now, we're just testing the logic
263    }
264
265    // ============= Additional Split Tests =============
266
267    #[test]
268    fn test_split_mode_variants() {
269        // Test SinglePages variant
270        let single_pages = SplitMode::SinglePages;
271        assert!(matches!(single_pages, SplitMode::SinglePages));
272
273        // Test Ranges variant
274        let ranges = SplitMode::Ranges(vec![
275            super::PageRange::Single(0),
276            super::PageRange::Range(5, 10),
277        ]);
278        assert!(matches!(ranges, SplitMode::Ranges(_)));
279
280        // Test ChunkSize variant
281        let chunk = SplitMode::ChunkSize(5);
282        if let SplitMode::ChunkSize(size) = chunk {
283            assert_eq!(size, 5);
284        } else {
285            panic!("Expected ChunkSize");
286        }
287
288        // Test SplitAt variant
289        let split_at = SplitMode::SplitAt(vec![5, 10, 15]);
290        assert!(matches!(split_at, SplitMode::SplitAt(_)));
291    }
292
293    #[test]
294    fn test_split_options_with_modes() {
295        let options = SplitOptions {
296            mode: SplitMode::ChunkSize(10),
297            output_pattern: "chunk_{}.pdf".to_string(),
298            preserve_metadata: true,
299            optimize: true,
300        };
301
302        assert!(matches!(options.mode, SplitMode::ChunkSize(10)));
303        assert_eq!(options.output_pattern, "chunk_{}.pdf");
304        assert!(options.preserve_metadata);
305        assert!(options.optimize);
306    }
307
308    #[test]
309    fn test_split_options_page_range() {
310        let ranges = vec![
311            super::PageRange::All,
312            super::PageRange::Single(5),
313            super::PageRange::Range(10, 20),
314            super::PageRange::List(vec![1, 3, 5, 7, 9]),
315        ];
316
317        let options = SplitOptions {
318            mode: SplitMode::Ranges(ranges),
319            ..Default::default()
320        };
321
322        if let SplitMode::Ranges(r) = options.mode {
323            assert_eq!(r.len(), 4);
324        } else {
325            panic!("Expected Ranges mode");
326        }
327    }
328
329    #[test]
330    fn test_split_options_split_at() {
331        let split_points = vec![3, 6, 9, 12]; // Split at these page numbers
332
333        let options = SplitOptions {
334            mode: SplitMode::SplitAt(split_points.clone()),
335            output_pattern: "part_{}.pdf".to_string(),
336            ..Default::default()
337        };
338
339        if let SplitMode::SplitAt(points) = options.mode {
340            assert_eq!(points.len(), 4);
341            assert_eq!(points, split_points);
342        } else {
343            panic!("Expected SplitAt mode");
344        }
345    }
346
347    #[test]
348    fn test_output_pattern_formatting() {
349        // Test various output patterns
350        let patterns = vec![
351            "output_{}.pdf",
352            "page_{}.pdf",
353            "document_part_{}.pdf",
354            "{}_split.pdf",
355        ];
356
357        for pattern in patterns {
358            let options = SplitOptions {
359                output_pattern: pattern.to_string(),
360                ..Default::default()
361            };
362            assert!(options.output_pattern.contains("{")); // Just check for placeholder
363        }
364    }
365
366    #[test]
367    fn test_split_options_preserve_metadata() {
368        // Test preserve_metadata flag
369        let with_metadata = SplitOptions {
370            preserve_metadata: true,
371            ..Default::default()
372        };
373        assert!(with_metadata.preserve_metadata);
374
375        let without_metadata = SplitOptions {
376            preserve_metadata: false,
377            ..Default::default()
378        };
379        assert!(!without_metadata.preserve_metadata);
380    }
381
382    #[test]
383    fn test_split_single_pages_mode() {
384        let options = SplitOptions {
385            mode: SplitMode::SinglePages,
386            output_pattern: "page_{:04}.pdf".to_string(),
387            ..Default::default()
388        };
389
390        assert!(matches!(options.mode, SplitMode::SinglePages));
391        assert!(options.output_pattern.contains("{"));
392    }
393
394    #[test]
395    fn test_split_chunk_size_validation() {
396        // Test various chunk sizes
397        let chunk_sizes = vec![1, 5, 10, 50, 100];
398
399        for size in chunk_sizes {
400            let options = SplitOptions {
401                mode: SplitMode::ChunkSize(size),
402                ..Default::default()
403            };
404
405            if let SplitMode::ChunkSize(s) = options.mode {
406                assert_eq!(s, size);
407                assert!(s > 0); // Chunk size should be positive
408            }
409        }
410    }
411
412    #[test]
413    fn test_split_options_optimization() {
414        let optimized = SplitOptions {
415            optimize: true,
416            ..Default::default()
417        };
418        assert!(optimized.optimize);
419
420        let not_optimized = SplitOptions {
421            optimize: false,
422            ..Default::default()
423        };
424        assert!(!not_optimized.optimize);
425    }
426
427    #[test]
428    fn test_split_options_with_custom_pattern() {
429        let options = SplitOptions {
430            output_pattern: "document_part_{}.pdf".to_string(),
431            ..Default::default()
432        };
433        assert_eq!(options.output_pattern, "document_part_{}.pdf");
434    }
435
436    #[test]
437    fn test_split_mode_ranges() {
438        let ranges = vec![
439            PageRange::Single(0),
440            PageRange::Range(1, 3),
441            PageRange::Single(5),
442        ];
443        let mode = SplitMode::Ranges(ranges);
444
445        match mode {
446            SplitMode::Ranges(r) => {
447                assert_eq!(r.len(), 3);
448                assert!(matches!(r[0], PageRange::Single(0)));
449                assert!(matches!(r[1], PageRange::Range(1, 3)));
450                assert!(matches!(r[2], PageRange::Single(5)));
451            }
452            _ => panic!("Wrong mode"),
453        }
454    }
455
456    #[test]
457    fn test_split_mode_split_at() {
458        let split_points = vec![5, 10, 15];
459        let mode = SplitMode::SplitAt(split_points.clone());
460
461        match mode {
462            SplitMode::SplitAt(points) => assert_eq!(points, split_points),
463            _ => panic!("Wrong mode"),
464        }
465    }
466
467    #[test]
468    fn test_page_range_parse() {
469        // Test all pages
470        let range = PageRange::parse("all").unwrap();
471        assert!(matches!(range, PageRange::All));
472
473        // Test single page
474        let range = PageRange::parse("5").unwrap();
475        assert!(matches!(range, PageRange::Single(4))); // 0-indexed
476
477        // Test range
478        let range = PageRange::parse("3-7").unwrap();
479        assert!(matches!(range, PageRange::Range(2, 6))); // 0-indexed
480
481        // Test list
482        let range = PageRange::parse("1,3,5").unwrap();
483        match range {
484            PageRange::List(pages) => assert_eq!(pages, vec![0, 2, 4]),
485            _ => panic!("Expected List"),
486        }
487    }
488
489    #[test]
490    fn test_page_range_invalid_parse() {
491        assert!(PageRange::parse("").is_err());
492        assert!(PageRange::parse("abc").is_err());
493        assert!(PageRange::parse("5-3").is_err()); // Invalid range
494        assert!(PageRange::parse("0").is_err()); // Page numbers start at 1
495    }
496
497    #[test]
498    fn test_split_options_all_fields() {
499        let options = SplitOptions {
500            mode: SplitMode::ChunkSize(5),
501            output_pattern: "chunk_{}.pdf".to_string(),
502            preserve_metadata: false,
503            optimize: true,
504        };
505
506        match options.mode {
507            SplitMode::ChunkSize(size) => assert_eq!(size, 5),
508            _ => panic!("Wrong mode"),
509        }
510        assert_eq!(options.output_pattern, "chunk_{}.pdf");
511        assert!(!options.preserve_metadata);
512        assert!(options.optimize);
513    }
514
515    #[test]
516    fn test_split_mode_chunk_size_edge_cases() {
517        // Chunk size of 1 should be like single pages
518        let mode = SplitMode::ChunkSize(1);
519        match mode {
520            SplitMode::ChunkSize(size) => assert_eq!(size, 1),
521            _ => panic!("Wrong mode"),
522        }
523
524        // Large chunk size
525        let mode = SplitMode::ChunkSize(1000);
526        match mode {
527            SplitMode::ChunkSize(size) => assert_eq!(size, 1000),
528            _ => panic!("Wrong mode"),
529        }
530    }
531
532    #[test]
533    fn test_split_mode_empty_ranges() {
534        let ranges = Vec::new();
535        let mode = SplitMode::Ranges(ranges);
536
537        match mode {
538            SplitMode::Ranges(r) => assert!(r.is_empty()),
539            _ => panic!("Wrong mode"),
540        }
541    }
542
543    #[test]
544    fn test_split_mode_empty_split_points() {
545        let split_points = Vec::new();
546        let mode = SplitMode::SplitAt(split_points);
547
548        match mode {
549            SplitMode::SplitAt(points) => assert!(points.is_empty()),
550            _ => panic!("Wrong mode"),
551        }
552    }
553}
554
555#[cfg(test)]
556#[path = "split_tests.rs"]
557mod split_tests;