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