Skip to main content

ppt_rs/export/
image_export.rs

1//! Image export module
2//!
3//! Provides functionality to export presentations and individual slides
4//! to image formats (PNG, JPEG).
5//!
6//! Uses LibreOffice for rendering (same approach as PDF export).
7
8use crate::api::Presentation;
9use crate::exc::{PptxError, Result};
10use std::path::Path;
11use std::process::Command;
12
13/// Image format for export
14#[derive(Debug, Clone, Copy, PartialEq)]
15#[derive(Default)]
16pub enum ImageFormat {
17    /// PNG format (lossless, good for graphics)
18    #[default]
19    Png,
20    /// JPEG format (lossy, good for photos)
21    Jpeg,
22}
23
24impl ImageFormat {
25    /// Get file extension
26    pub fn extension(&self) -> &'static str {
27        match self {
28            ImageFormat::Png => "png",
29            ImageFormat::Jpeg => "jpg",
30        }
31    }
32
33    /// Get MIME type
34    pub fn mime_type(&self) -> &'static str {
35        match self {
36            ImageFormat::Png => "image/png",
37            ImageFormat::Jpeg => "image/jpeg",
38        }
39    }
40}
41
42
43/// Options for image export
44#[derive(Debug, Clone)]
45pub struct ImageExportOptions {
46    /// Image format (PNG or JPEG)
47    pub format: ImageFormat,
48    /// DPI/resolution (default 150)
49    pub dpi: u32,
50    /// JPEG quality (0-100, default 90)
51    pub jpeg_quality: u8,
52    /// Output width in pixels (0 = auto based on DPI)
53    pub width: u32,
54    /// Output height in pixels (0 = auto based on DPI)
55    pub height: u32,
56    /// Export all slides or specific slide (0 = all, 1+ = specific slide)
57    pub slide_number: usize,
58}
59
60impl Default for ImageExportOptions {
61    fn default() -> Self {
62        Self {
63            format: ImageFormat::Png,
64            dpi: 150,
65            jpeg_quality: 90,
66            width: 0,
67            height: 0,
68            slide_number: 0,
69        }
70    }
71}
72
73impl ImageExportOptions {
74    /// Create new options with defaults
75    pub fn new() -> Self {
76        Self::default()
77    }
78
79    /// Set image format
80    pub fn with_format(mut self, format: ImageFormat) -> Self {
81        self.format = format;
82        self
83    }
84
85    /// Set DPI
86    pub fn with_dpi(mut self, dpi: u32) -> Self {
87        self.dpi = dpi;
88        self
89    }
90
91    /// Set JPEG quality
92    pub fn with_jpeg_quality(mut self, quality: u8) -> Self {
93        self.jpeg_quality = quality.min(100);
94        self
95    }
96
97    /// Set output dimensions
98    pub fn with_dimensions(mut self, width: u32, height: u32) -> Self {
99        self.width = width;
100        self.height = height;
101        self
102    }
103
104    /// Set specific slide to export (1-based, 0 = all)
105    pub fn with_slide(mut self, slide: usize) -> Self {
106        self.slide_number = slide;
107        self
108    }
109
110    /// High quality preset (300 DPI, PNG)
111    pub fn high_quality() -> Self {
112        Self {
113            format: ImageFormat::Png,
114            dpi: 300,
115            jpeg_quality: 95,
116            width: 0,
117            height: 0,
118            slide_number: 0,
119        }
120    }
121
122    /// Web optimized preset (96 DPI, JPEG)
123    pub fn web_optimized() -> Self {
124        Self {
125            format: ImageFormat::Jpeg,
126            dpi: 96,
127            jpeg_quality: 85,
128            width: 0,
129            height: 0,
130            slide_number: 0,
131        }
132    }
133}
134
135/// Export presentation to images
136///
137/// Uses LibreOffice for rendering. Requires LibreOffice to be installed.
138///
139/// # Arguments
140/// * `presentation` - The presentation to export
141/// * `output_dir` - Directory to save images
142/// * `options` - Export options
143///
144/// # Returns
145/// Vector of paths to generated image files
146pub fn export_to_images<P: AsRef<Path>>(
147    presentation: &Presentation,
148    output_dir: P,
149    options: &ImageExportOptions,
150) -> Result<Vec<std::path::PathBuf>> {
151    // First save presentation to temporary PPTX file
152    let temp_dir = std::env::temp_dir();
153    let temp_pptx = temp_dir.join("temp_export.pptx");
154    presentation.save(&temp_pptx)?;
155
156    let output_dir = output_dir.as_ref();
157    std::fs::create_dir_all(output_dir)?;
158
159    // Use LibreOffice to convert to images
160    let result = export_pptx_to_images(&temp_pptx, output_dir, options);
161
162    // Cleanup temp file
163    let _ = std::fs::remove_file(&temp_pptx);
164
165    result
166}
167
168/// Export a specific slide to an image
169pub fn export_slide_to_image<P: AsRef<Path>>(
170    presentation: &Presentation,
171    slide_number: usize,
172    output_path: P,
173    options: &ImageExportOptions,
174) -> Result<std::path::PathBuf> {
175    if slide_number == 0 || slide_number > presentation.slide_count() {
176        return Err(PptxError::InvalidOperation(format!(
177            "Invalid slide number: {} (presentation has {} slides)",
178            slide_number,
179            presentation.slide_count()
180        )));
181    }
182
183    let mut slide_options = options.clone();
184    slide_options.slide_number = slide_number;
185
186    let output_dir = output_path
187        .as_ref()
188        .parent()
189        .unwrap_or(std::path::Path::new("."));
190
191    let paths = export_to_images(presentation, output_dir, &slide_options)?;
192
193    // Find the specific slide file
194    let expected_name = format!("Slide{}.{}", slide_number, slide_options.format.extension());
195    for path in paths {
196        if let Some(name) = path.file_name().and_then(|n| n.to_str())
197            && (name == expected_name || name.contains(&format!("Slide{}", slide_number))) {
198                // Rename to requested output path if different
199                if path != output_path.as_ref() {
200                    std::fs::rename(&path, &output_path)?;
201                    return Ok(output_path.as_ref().to_path_buf());
202                }
203                return Ok(path);
204            }
205    }
206
207    Err(PptxError::Generic(String::from("Export failed")))
208}
209
210/// Internal function to export PPTX file to images using LibreOffice
211fn export_pptx_to_images<P: AsRef<Path>, Q: AsRef<Path>>(
212    pptx_path: P,
213    output_dir: Q,
214    options: &ImageExportOptions,
215) -> Result<Vec<std::path::PathBuf>> {
216    let pptx_path = pptx_path.as_ref();
217    let output_dir = output_dir.as_ref();
218
219    // Check if LibreOffice is available
220    if !is_libreoffice_available() {
221        return Err(PptxError::Generic(String::from(
222            "LibreOffice not found"
223        )));
224    }
225
226    // Build LibreOffice command
227    let ext = options.format.extension();
228    let convert_opt = format!("{}:ExportNotesPages=false", ext);
229    
230    let mut cmd = Command::new("soffice");
231    cmd.arg("--headless")
232        .arg("--convert-to")
233        .arg(&convert_opt)
234        .arg("--outdir")
235        .arg(output_dir)
236        .arg(pptx_path);
237
238    // Execute conversion
239    let output = cmd.output().map_err(|e| {
240        PptxError::Generic(format!("Failed to execute LibreOffice: {}", e))
241    })?;
242
243    if !output.status.success() {
244        let stderr = String::from_utf8_lossy(&output.stderr);
245        return Err(PptxError::Generic(format!(
246            "LibreOffice conversion failed: {}",
247            stderr
248        )));
249    }
250
251    // Collect output files
252    let mut image_files = Vec::new();
253    let file_stem = pptx_path.file_stem().and_then(|s| s.to_str()).unwrap_or("slide");
254
255    for entry in std::fs::read_dir(output_dir)? {
256        let entry = entry?;
257        let path = entry.path();
258        if let Some(file_ext) = path.extension().and_then(|e| e.to_str())
259            && file_ext.eq_ignore_ascii_case(ext) {
260                // Check if it is from our conversion
261                if let Some(name) = path.file_stem().and_then(|s| s.to_str())
262                    && (name.starts_with(file_stem) || name.starts_with("Slide")) {
263                        image_files.push(path);
264                    }
265            }
266    }
267
268    // Sort by slide number
269    image_files.sort_by(|a, b| {
270        let a_num = extract_slide_number(a);
271        let b_num = extract_slide_number(b);
272        a_num.cmp(&b_num)
273    });
274
275    Ok(image_files)
276}
277
278/// Check if LibreOffice is available
279fn is_libreoffice_available() -> bool {
280    Command::new("soffice").arg("--version").output().is_ok()
281}
282
283/// Extract slide number from filename
284fn extract_slide_number(path: &std::path::Path) -> usize {
285    path.file_stem()
286        .and_then(|s| s.to_str())
287        .and_then(|name| {
288            // Extract number from "Slide1" or "slide1" or "temp_export1"
289            let digits: String = name.chars().filter(|c| c.is_ascii_digit()).collect();
290            digits.parse().ok()
291        })
292        .unwrap_or(0)
293}
294
295/// Render presentation thumbnail (first slide only)
296pub fn render_thumbnail<P: AsRef<Path>>(
297    presentation: &Presentation,
298    output_path: P,
299    width: u32,
300) -> Result<std::path::PathBuf> {
301    let options = ImageExportOptions::new()
302        .with_format(ImageFormat::Png)
303        .with_slide(1);
304
305    // Calculate DPI based on desired width
306    let dpi = (width as f32 / 10.0) as u32;
307
308    let options = ImageExportOptions {
309        dpi,
310        ..options
311    };
312
313    export_slide_to_image(presentation, 1, output_path, &options)
314}
315
316#[cfg(test)]
317mod tests {
318    use super::*;
319
320    #[test]
321    fn test_image_format_extension() {
322        assert_eq!(ImageFormat::Png.extension(), "png");
323        assert_eq!(ImageFormat::Jpeg.extension(), "jpg");
324    }
325
326    #[test]
327    fn test_image_export_options() {
328        let opts = ImageExportOptions::new()
329            .with_format(ImageFormat::Jpeg)
330            .with_dpi(200)
331            .with_jpeg_quality(85);
332
333        assert_eq!(opts.format, ImageFormat::Jpeg);
334        assert_eq!(opts.dpi, 200);
335        assert_eq!(opts.jpeg_quality, 85);
336    }
337
338    #[test]
339    fn test_high_quality_preset() {
340        let opts = ImageExportOptions::high_quality();
341        assert_eq!(opts.dpi, 300);
342        assert_eq!(opts.format, ImageFormat::Png);
343    }
344
345    #[test]
346    fn test_web_optimized_preset() {
347        let opts = ImageExportOptions::web_optimized();
348        assert_eq!(opts.dpi, 96);
349        assert_eq!(opts.format, ImageFormat::Jpeg);
350        assert_eq!(opts.jpeg_quality, 85);
351    }
352
353    #[test]
354    fn test_extract_slide_number() {
355        let path = std::path::Path::new("Slide1.png");
356        assert_eq!(extract_slide_number(path), 1);
357
358        let path = std::path::Path::new("slide12.jpg");
359        assert_eq!(extract_slide_number(path), 12);
360
361        let path = std::path::Path::new("temp_export5.png");
362        assert_eq!(extract_slide_number(path), 5);
363    }
364}