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