1use super::Package;
10use crate::exc::Result;
11use std::collections::HashSet;
12use std::path::Path;
13
14#[derive(Debug, Clone, Copy, PartialEq)]
16#[derive(Default)]
17pub enum CompressionLevel {
18 Light,
20 #[default]
22 Medium,
23 Aggressive,
25 Custom(u8), }
28
29
30impl CompressionLevel {
31 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 pub fn should_resize_images(&self) -> bool {
43 matches!(self, CompressionLevel::Aggressive | CompressionLevel::Custom(_))
44 }
45
46 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#[derive(Debug, Clone)]
59pub struct CompressionOptions {
60 pub level: CompressionLevel,
62 pub remove_unused_media: bool,
64 pub remove_properties: bool,
66 pub remove_notes: bool,
68 pub remove_comments: bool,
70 pub optimize_xml: bool,
72 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 pub fn new() -> Self {
93 Self::default()
94 }
95
96 pub fn with_level(mut self, level: CompressionLevel) -> Self {
98 self.level = level;
99 self
100 }
101
102 pub fn with_unused_media_removal(mut self, remove: bool) -> Self {
104 self.remove_unused_media = remove;
105 self
106 }
107
108 pub fn with_properties_removal(mut self, remove: bool) -> Self {
110 self.remove_properties = remove;
111 self
112 }
113
114 pub fn with_notes_removal(mut self, remove: bool) -> Self {
116 self.remove_notes = remove;
117 self
118 }
119
120 pub fn with_comments_removal(mut self, remove: bool) -> Self {
122 self.remove_comments = remove;
123 self
124 }
125
126 pub fn with_xml_optimization(mut self, optimize: bool) -> Self {
128 self.optimize_xml = optimize;
129 self
130 }
131
132 pub fn with_target_size(mut self, size: usize) -> Self {
134 self.target_size = size;
135 self
136 }
137
138 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 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, }
162 }
163}
164
165#[derive(Debug)]
167pub struct CompressionResult {
168 pub original_size: usize,
170 pub compressed_size: usize,
172 pub reduction_percent: f64,
174 pub unused_media_removed: usize,
176 pub images_compressed: usize,
178 pub target_achieved: bool,
180}
181
182pub fn compress_pptx<P: AsRef<Path>, Q: AsRef<Path>>(
203 input_path: P,
204 output_path: Q,
205 options: &CompressionOptions,
206) -> Result<CompressionResult> {
207 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 if options.remove_unused_media {
217 unused_media_removed = remove_unused_media(&mut package)?;
218 }
219
220 if options.remove_properties {
222 remove_document_properties(&mut package);
223 }
224
225 if options.remove_notes {
227 remove_notes_slides(&mut package)?;
228 }
229
230 if options.optimize_xml {
232 optimize_xml_content(&mut package)?;
233 }
234
235 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
257pub fn compress_pptx_in_memory(
259 data: &[u8],
260 options: &CompressionOptions,
261) -> Result<(Vec<u8>, CompressionResult)> {
262 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 let _ = std::fs::remove_file(&temp_input);
274 let _ = std::fs::remove_file(&temp_output);
275
276 Ok((output_data, result))
277}
278
279fn 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 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 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 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
319fn remove_document_properties(package: &mut Package) {
321 package.remove_part("docProps/core.xml");
323 package.remove_part("docProps/app.xml");
325 package.remove_part("docProps/custom.xml");
327 package.remove_part("docProps/thumbnail.jpeg");
329}
330
331fn 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 let rels_path = path.replace("notesSlides/", "notesSlides/_rels/") + ".rels";
344 package.remove_part(&rels_path);
345 }
346
347 Ok(())
348}
349
350fn 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
369fn 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 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 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
415pub 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#[derive(Debug)]
467pub struct PptxAnalysis {
468 pub total_size: usize,
470 pub images_size: usize,
472 pub xml_size: usize,
474 pub other_size: usize,
476 pub image_count: usize,
478 pub slide_count: usize,
480 pub media_count: usize,
482}
483
484impl PptxAnalysis {
485 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 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 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}