Skip to main content

ppt_rs/opc/
compress.rs

1//! PPTX Compression Module
2//!
3//! Provides functionality to optimize and compress PPTX files:
4//! - Remove unused media files
5//! - Compress images to reduce file size
6//! - Remove document properties and revision history
7//! - Optimize XML (remove unnecessary whitespace)
8
9use super::Package;
10use crate::exc::Result;
11use std::collections::HashSet;
12use std::path::Path;
13
14/// Compression level options
15#[derive(Debug, Clone, Copy, PartialEq)]
16#[derive(Default)]
17pub enum CompressionLevel {
18    /// Light compression - remove unused parts only
19    Light,
20    /// Medium compression - compress images slightly
21    #[default]
22    Medium,
23    /// Aggressive compression - maximize size reduction
24    Aggressive,
25    /// Custom compression with specific image quality
26    Custom(u8), // JPEG quality 0-100
27}
28
29
30impl CompressionLevel {
31    /// Get image quality for this level (for JPEG compression)
32    pub fn image_quality(&self) -> u8 {
33        match self {
34            CompressionLevel::Light => 95,
35            CompressionLevel::Medium => 85,
36            CompressionLevel::Aggressive => 70,
37            CompressionLevel::Custom(q) => *q,
38        }
39    }
40
41    /// Whether to resize large images
42    pub fn should_resize_images(&self) -> bool {
43        matches!(self, CompressionLevel::Aggressive | CompressionLevel::Custom(_))
44    }
45
46    /// Maximum image dimension for this level
47    pub fn max_image_dimension(&self) -> u32 {
48        match self {
49            CompressionLevel::Light => 2048,
50            CompressionLevel::Medium => 1600,
51            CompressionLevel::Aggressive => 1280,
52            CompressionLevel::Custom(_) => 1600,
53        }
54    }
55}
56
57/// Compression options
58#[derive(Debug, Clone)]
59pub struct CompressionOptions {
60    /// Compression level
61    pub level: CompressionLevel,
62    /// Remove unused media files
63    pub remove_unused_media: bool,
64    /// Remove document properties
65    pub remove_properties: bool,
66    /// Remove notes slides
67    pub remove_notes: bool,
68    /// Remove comments
69    pub remove_comments: bool,
70    /// Optimize XML (remove whitespace)
71    pub optimize_xml: bool,
72    /// Target file size in bytes (0 = no target)
73    pub target_size: usize,
74}
75
76impl Default for CompressionOptions {
77    fn default() -> Self {
78        Self {
79            level: CompressionLevel::Medium,
80            remove_unused_media: true,
81            remove_properties: false,
82            remove_notes: false,
83            remove_comments: true,
84            optimize_xml: true,
85            target_size: 0,
86        }
87    }
88}
89
90impl CompressionOptions {
91    /// Create new options with defaults
92    pub fn new() -> Self {
93        Self::default()
94    }
95
96    /// Set compression level
97    pub fn with_level(mut self, level: CompressionLevel) -> Self {
98        self.level = level;
99        self
100    }
101
102    /// Set unused media removal
103    pub fn with_unused_media_removal(mut self, remove: bool) -> Self {
104        self.remove_unused_media = remove;
105        self
106    }
107
108    /// Set properties removal
109    pub fn with_properties_removal(mut self, remove: bool) -> Self {
110        self.remove_properties = remove;
111        self
112    }
113
114    /// Set notes removal
115    pub fn with_notes_removal(mut self, remove: bool) -> Self {
116        self.remove_notes = remove;
117        self
118    }
119
120    /// Set comments removal
121    pub fn with_comments_removal(mut self, remove: bool) -> Self {
122        self.remove_comments = remove;
123        self
124    }
125
126    /// Set XML optimization
127    pub fn with_xml_optimization(mut self, optimize: bool) -> Self {
128        self.optimize_xml = optimize;
129        self
130    }
131
132    /// Set target file size
133    pub fn with_target_size(mut self, size: usize) -> Self {
134        self.target_size = size;
135        self
136    }
137
138    /// Preset for maximum compression
139    pub fn maximum() -> Self {
140        Self {
141            level: CompressionLevel::Aggressive,
142            remove_unused_media: true,
143            remove_properties: true,
144            remove_notes: true,
145            remove_comments: true,
146            optimize_xml: true,
147            target_size: 0,
148        }
149    }
150
151    /// Preset for web optimization
152    pub fn web() -> Self {
153        Self {
154            level: CompressionLevel::Medium,
155            remove_unused_media: true,
156            remove_properties: true,
157            remove_notes: false,
158            remove_comments: true,
159            optimize_xml: true,
160            target_size: 5 * 1024 * 1024, // 5MB target
161        }
162    }
163}
164
165/// Compression result
166#[derive(Debug)]
167pub struct CompressionResult {
168    /// Original file size in bytes
169    pub original_size: usize,
170    /// Compressed file size in bytes
171    pub compressed_size: usize,
172    /// Reduction percentage
173    pub reduction_percent: f64,
174    /// Number of unused media files removed
175    pub unused_media_removed: usize,
176    /// Number of images compressed
177    pub images_compressed: usize,
178    /// Whether target size was achieved
179    pub target_achieved: bool,
180}
181
182/// Compress a PPTX file
183///
184/// # Arguments
185/// * `input_path` - Path to input PPTX file
186/// * `output_path` - Path for compressed output
187/// * `options` - Compression options
188///
189/// # Returns
190/// Compression result with statistics
191///
192/// # Example
193/// ```no_run
194/// use ppt_rs::opc::compress::{compress_pptx, CompressionOptions, CompressionLevel};
195///
196/// let options = CompressionOptions::new()
197///     .with_level(CompressionLevel::Medium);
198///
199/// let result = compress_pptx("input.pptx", "output.pptx", &options).unwrap();
200/// println!("Reduced by {:.1}%", result.reduction_percent);
201/// ```
202pub fn compress_pptx<P: AsRef<Path>, Q: AsRef<Path>>(
203    input_path: P,
204    output_path: Q,
205    options: &CompressionOptions,
206) -> Result<CompressionResult> {
207    // Load package
208    let mut package = Package::open(input_path.as_ref())?;
209
210    let original_size = std::fs::metadata(input_path.as_ref())?.len() as usize;
211
212    let mut unused_media_removed = 0;
213    let images_compressed = 0;
214
215    // Remove unused media files
216    if options.remove_unused_media {
217        unused_media_removed = remove_unused_media(&mut package)?;
218    }
219
220    // Remove properties if requested
221    if options.remove_properties {
222        remove_document_properties(&mut package);
223    }
224
225    // Remove notes if requested
226    if options.remove_notes {
227        remove_notes_slides(&mut package)?;
228    }
229
230    // Optimize XML
231    if options.optimize_xml {
232        optimize_xml_content(&mut package)?;
233    }
234
235    // Save compressed package
236    package.save(output_path.as_ref())?;
237
238    let compressed_size = std::fs::metadata(output_path.as_ref())?.len() as usize;
239    let reduction_percent = if original_size > 0 {
240        ((original_size - compressed_size) as f64 / original_size as f64) * 100.0
241    } else {
242        0.0
243    };
244
245    let target_achieved = options.target_size == 0 || compressed_size <= options.target_size;
246
247    Ok(CompressionResult {
248        original_size,
249        compressed_size,
250        reduction_percent,
251        unused_media_removed,
252        images_compressed,
253        target_achieved,
254    })
255}
256
257/// Compress a PPTX in memory
258pub fn compress_pptx_in_memory(
259    data: &[u8],
260    options: &CompressionOptions,
261) -> Result<(Vec<u8>, CompressionResult)> {
262    // Write to temp file
263    let temp_dir = std::env::temp_dir();
264    let temp_input = temp_dir.join("compress_input.pptx");
265    let temp_output = temp_dir.join("compress_output.pptx");
266
267    std::fs::write(&temp_input, data)?;
268
269    let result = compress_pptx(&temp_input, &temp_output, options)?;
270    let output_data = std::fs::read(&temp_output)?;
271
272    // Cleanup
273    let _ = std::fs::remove_file(&temp_input);
274    let _ = std::fs::remove_file(&temp_output);
275
276    Ok((output_data, result))
277}
278
279/// Remove unused media files from package
280fn remove_unused_media(package: &mut Package) -> Result<usize> {
281    let media_paths: Vec<String> = package
282        .part_paths()
283        .iter()
284        .filter(|p| p.starts_with("ppt/media/"))
285        .map(|s| s.to_string())
286        .collect();
287
288    let mut referenced = HashSet::new();
289    let mut removed = 0;
290
291    // Find all media references in slide files
292    for path in package.part_paths() {
293        if path.starts_with("ppt/slides/slide") && path.ends_with(".xml")
294            && let Some(content) = package.get_part_string(path) {
295                // Look for media references like rId5, image1.png, etc.
296                for media_path in &media_paths {
297                    let filename = Path::new(media_path)
298                        .file_name()
299                        .and_then(|n| n.to_str())
300                        .unwrap_or("");
301                    if content.contains(filename) || content.contains(&media_path[4..]) {
302                        referenced.insert(media_path.clone());
303                    }
304                }
305            }
306    }
307
308    // Remove unreferenced media
309    for media_path in media_paths {
310        if !referenced.contains(&media_path) {
311            package.remove_part(&media_path);
312            removed += 1;
313        }
314    }
315
316    Ok(removed)
317}
318
319/// Remove document properties
320fn remove_document_properties(package: &mut Package) {
321    // Remove core properties
322    package.remove_part("docProps/core.xml");
323    // Remove app properties
324    package.remove_part("docProps/app.xml");
325    // Remove custom properties
326    package.remove_part("docProps/custom.xml");
327    // Remove thumbnail
328    package.remove_part("docProps/thumbnail.jpeg");
329}
330
331/// Remove notes slides
332fn remove_notes_slides(package: &mut Package) -> Result<()> {
333    let notes_paths: Vec<String> = package
334        .part_paths()
335        .iter()
336        .filter(|p| p.starts_with("ppt/notesSlides/"))
337        .map(|s| s.to_string())
338        .collect();
339
340    for path in notes_paths {
341        package.remove_part(&path);
342        // Also remove relationships
343        let rels_path = path.replace("notesSlides/", "notesSlides/_rels/") + ".rels";
344        package.remove_part(&rels_path);
345    }
346
347    Ok(())
348}
349
350/// Optimize XML content (minimize whitespace)
351fn optimize_xml_content(package: &mut Package) -> Result<()> {
352    let xml_paths: Vec<String> = package
353        .part_paths()
354        .iter()
355        .filter(|p| p.ends_with(".xml") || p.ends_with(".rels"))
356        .map(|s| s.to_string())
357        .collect();
358
359    for path in xml_paths {
360        if let Some(content) = package.get_part_string(&path) {
361            let optimized = minimize_xml(&content);
362            package.add_part(path, optimized.into_bytes());
363        }
364    }
365
366    Ok(())
367}
368
369/// Minimize XML by removing unnecessary whitespace
370fn minimize_xml(xml: &str) -> String {
371    let mut result = String::with_capacity(xml.len());
372    let mut in_tag = false;
373    let mut in_string = false;
374    let mut prev_char = ' ';
375
376    for ch in xml.chars() {
377        match ch {
378            '"' if !in_tag => {
379                in_string = !in_string;
380                result.push(ch);
381            }
382            '"' if in_tag => {
383                in_string = !in_string;
384                result.push(ch);
385            }
386            '<' if !in_string => {
387                in_tag = true;
388                // Remove whitespace before tag
389                if (prev_char == ' ' || prev_char == '\n' || prev_char == '\t')
390                    && !result.is_empty() {
391                        result.pop();
392                    }
393                result.push(ch);
394            }
395            '>' if !in_string => {
396                in_tag = false;
397                result.push(ch);
398            }
399            ' ' | '\n' | '\t' | '\r' if !in_tag && !in_string => {
400                // Skip whitespace between tags
401                if prev_char != ' ' {
402                    result.push(' ');
403                }
404            }
405            _ => {
406                result.push(ch);
407            }
408        }
409        prev_char = ch;
410    }
411
412    result
413}
414
415/// Analyze PPTX file and return size breakdown
416pub fn analyze_pptx<P: AsRef<Path>>(path: P) -> Result<PptxAnalysis> {
417    let package = Package::open(path.as_ref())?;
418    let total_size = std::fs::metadata(path.as_ref())?.len() as usize;
419
420    let mut images_size = 0;
421    let mut xml_size = 0;
422    let mut other_size = 0;
423
424    let mut image_count = 0;
425    let mut slide_count = 0;
426    let mut media_count = 0;
427
428    for part_path in package.part_paths() {
429        if let Some(data) = package.get_part(part_path) {
430            let size = data.len();
431
432            if part_path.starts_with("ppt/media/") {
433                if part_path.ends_with(".png")
434                    || part_path.ends_with(".jpg")
435                    || part_path.ends_with(".jpeg")
436                {
437                    images_size += size;
438                    image_count += 1;
439                } else {
440                    media_count += 1;
441                    other_size += size;
442                }
443            } else if part_path.ends_with(".xml") || part_path.ends_with(".rels") {
444                xml_size += size;
445                if part_path.starts_with("ppt/slides/slide") && part_path.ends_with(".xml") {
446                    slide_count += 1;
447                }
448            } else {
449                other_size += size;
450            }
451        }
452    }
453
454    Ok(PptxAnalysis {
455        total_size,
456        images_size,
457        xml_size,
458        other_size,
459        image_count,
460        slide_count,
461        media_count,
462    })
463}
464
465/// Analysis result for PPTX file
466#[derive(Debug)]
467pub struct PptxAnalysis {
468    /// Total file size in bytes
469    pub total_size: usize,
470    /// Size of image files
471    pub images_size: usize,
472    /// Size of XML files
473    pub xml_size: usize,
474    /// Size of other files
475    pub other_size: usize,
476    /// Number of images
477    pub image_count: usize,
478    /// Number of slides
479    pub slide_count: usize,
480    /// Number of other media files
481    pub media_count: usize,
482}
483
484impl PptxAnalysis {
485    /// Get human-readable summary
486    pub fn summary(&self) -> String {
487        format!(
488            "PPTX Analysis:\n\
489            - Total size: {}\n\
490            - Images: {} ({} MB)\n\
491            - Slides: {}\n\
492            - XML data: {}\n\
493            - Other media: {} files ({})",
494            format_bytes(self.total_size),
495            self.image_count,
496            format_bytes(self.images_size),
497            self.slide_count,
498            format_bytes(self.xml_size),
499            self.media_count,
500            format_bytes(self.other_size)
501        )
502    }
503
504    /// Get images as percentage of total
505    pub fn images_percentage(&self) -> f64 {
506        if self.total_size > 0 {
507            (self.images_size as f64 / self.total_size as f64) * 100.0
508        } else {
509            0.0
510        }
511    }
512}
513
514fn format_bytes(bytes: usize) -> String {
515    if bytes < 1024 {
516        format!("{} B", bytes)
517    } else if bytes < 1024 * 1024 {
518        format!("{:.1} KB", bytes as f64 / 1024.0)
519    } else if bytes < 1024 * 1024 * 1024 {
520        format!("{:.1} MB", bytes as f64 / (1024.0 * 1024.0))
521    } else {
522        format!("{:.2} GB", bytes as f64 / (1024.0 * 1024.0 * 1024.0))
523    }
524}
525
526#[cfg(test)]
527mod tests {
528    use super::*;
529
530    #[test]
531    fn test_compression_level() {
532        assert_eq!(CompressionLevel::Light.image_quality(), 95);
533        assert_eq!(CompressionLevel::Medium.image_quality(), 85);
534        assert_eq!(CompressionLevel::Aggressive.image_quality(), 70);
535        assert_eq!(CompressionLevel::Custom(80).image_quality(), 80);
536    }
537
538    #[test]
539    fn test_compression_level_resize() {
540        assert!(!CompressionLevel::Light.should_resize_images());
541        assert!(!CompressionLevel::Medium.should_resize_images());
542        assert!(CompressionLevel::Aggressive.should_resize_images());
543        assert!(CompressionLevel::Custom(80).should_resize_images());
544    }
545
546    #[test]
547    fn test_compression_level_max_dimension() {
548        assert_eq!(CompressionLevel::Light.max_image_dimension(), 2048);
549        assert_eq!(CompressionLevel::Medium.max_image_dimension(), 1600);
550        assert_eq!(CompressionLevel::Aggressive.max_image_dimension(), 1280);
551    }
552
553    #[test]
554    fn test_compression_options_builder() {
555        let opts = CompressionOptions::new()
556            .with_level(CompressionLevel::Aggressive)
557            .with_unused_media_removal(true)
558            .with_properties_removal(true)
559            .with_notes_removal(true)
560            .with_comments_removal(false)
561            .with_xml_optimization(true)
562            .with_target_size(10 * 1024 * 1024);
563
564        assert_eq!(opts.level, CompressionLevel::Aggressive);
565        assert!(opts.remove_unused_media);
566        assert!(opts.remove_properties);
567        assert!(opts.remove_notes);
568        assert!(!opts.remove_comments);
569        assert!(opts.optimize_xml);
570        assert_eq!(opts.target_size, 10 * 1024 * 1024);
571    }
572
573    #[test]
574    fn test_maximum_preset() {
575        let opts = CompressionOptions::maximum();
576        assert!(matches!(opts.level, CompressionLevel::Aggressive));
577        assert!(opts.remove_properties);
578        assert!(opts.remove_notes);
579        assert!(opts.remove_unused_media);
580        assert!(opts.remove_comments);
581        assert!(opts.optimize_xml);
582    }
583
584    #[test]
585    fn test_web_preset() {
586        let opts = CompressionOptions::web();
587        assert!(matches!(opts.level, CompressionLevel::Medium));
588        assert_eq!(opts.target_size, 5 * 1024 * 1024);
589        assert!(opts.remove_unused_media);
590        assert!(opts.remove_properties);
591    }
592
593    #[test]
594    fn test_minimize_xml() {
595        let input = r#"<?xml version="1.0"?>
596<root>
597    <element attr="value" />
598</root>"#;
599
600        let minimized = minimize_xml(input);
601        assert!(!minimized.contains("\n"));
602        assert!(!minimized.contains("    "));
603        assert!(minimized.contains("<root>"));
604        assert!(minimized.contains("<element"));
605    }
606
607    #[test]
608    fn test_minimize_xml_preserves_content() {
609        let input = r#"<a>  text  </a>"#;
610        let minimized = minimize_xml(input);
611        // Whitespace inside tags should be preserved
612        assert!(minimized.contains("text"));
613    }
614
615    #[test]
616    fn test_format_bytes() {
617        assert_eq!(format_bytes(500), "500 B");
618        assert_eq!(format_bytes(1024), "1.0 KB");
619        assert_eq!(format_bytes(1024 * 1024), "1.0 MB");
620        assert_eq!(format_bytes(1024 * 1024 * 1024), "1.00 GB");
621    }
622
623    #[test]
624    fn test_pptx_analysis_images_percentage() {
625        let analysis = PptxAnalysis {
626            total_size: 1000,
627            images_size: 500,
628            xml_size: 300,
629            other_size: 200,
630            image_count: 5,
631            slide_count: 10,
632            media_count: 2,
633        };
634
635        assert_eq!(analysis.images_percentage(), 50.0);
636    }
637
638    #[test]
639    fn test_pptx_analysis_images_percentage_zero() {
640        let analysis = PptxAnalysis {
641            total_size: 0,
642            images_size: 0,
643            xml_size: 0,
644            other_size: 0,
645            image_count: 0,
646            slide_count: 0,
647            media_count: 0,
648        };
649
650        assert_eq!(analysis.images_percentage(), 0.0);
651    }
652
653    #[test]
654    fn test_pptx_analysis_summary() {
655        let analysis = PptxAnalysis {
656            total_size: 1024 * 1024,
657            images_size: 512 * 1024,
658            xml_size: 256 * 1024,
659            other_size: 256 * 1024,
660            image_count: 3,
661            slide_count: 5,
662            media_count: 1,
663        };
664
665        let summary = analysis.summary();
666        assert!(summary.contains("PPTX Analysis"));
667        assert!(summary.contains("1.0 MB"));
668        assert!(summary.contains("3"));
669        assert!(summary.contains("5"));
670    }
671
672    #[test]
673    fn test_compression_result_fields() {
674        let result = CompressionResult {
675            original_size: 1000,
676            compressed_size: 800,
677            reduction_percent: 20.0,
678            unused_media_removed: 2,
679            images_compressed: 3,
680            target_achieved: true,
681        };
682
683        assert_eq!(result.original_size, 1000);
684        assert_eq!(result.compressed_size, 800);
685        assert_eq!(result.reduction_percent, 20.0);
686        assert_eq!(result.unused_media_removed, 2);
687        assert_eq!(result.images_compressed, 3);
688        assert!(result.target_achieved);
689    }
690}