Skip to main content

ppt_rs/
api.rs

1//! Public API module
2//!
3//! High-level API for working with PowerPoint presentations.
4
5use crate::exc::{PptxError, Result};
6use crate::export::html::export_to_html;
7use crate::generator::{create_pptx_with_content, Image, SlideContent};
8use crate::import::import_pptx;
9use std::path::Path;
10use std::process::Command;
11
12/// Represents a PowerPoint presentation
13#[derive(Debug, Clone, Default)]
14pub struct Presentation {
15    title: String,
16    slides: Vec<SlideContent>,
17}
18
19impl Presentation {
20    /// Create a new empty presentation
21    pub fn new() -> Self {
22        Presentation {
23            title: String::new(),
24            slides: Vec::new(),
25        }
26    }
27
28    /// Create a presentation with a title
29    pub fn with_title(title: &str) -> Self {
30        Presentation {
31            title: title.to_string(),
32            slides: Vec::new(),
33        }
34    }
35
36    /// Set the presentation title
37    pub fn title(mut self, title: &str) -> Self {
38        self.title = title.to_string();
39        self
40    }
41
42    /// Add a slide to the presentation
43    pub fn add_slide(mut self, slide: SlideContent) -> Self {
44        self.slides.push(slide);
45        self
46    }
47
48    /// Append slides from another presentation
49    pub fn add_presentation(mut self, other: Presentation) -> Self {
50        self.slides.extend(other.slides);
51        self
52    }
53
54    /// Get the number of slides
55    pub fn slide_count(&self) -> usize {
56        self.slides.len()
57    }
58
59    /// Get the slides in the presentation
60    pub fn slides(&self) -> &[SlideContent] {
61        &self.slides
62    }
63
64    /// Get the presentation title
65    pub fn get_title(&self) -> &str {
66        &self.title
67    }
68
69    /// Build the presentation as PPTX bytes
70    pub fn build(&self) -> Result<Vec<u8>> {
71        if self.slides.is_empty() {
72            return Err(PptxError::InvalidState("Presentation has no slides".into()));
73        }
74        create_pptx_with_content(&self.title, self.slides.clone())
75            .map_err(|e| PptxError::Generic(e.to_string()))
76    }
77
78    /// Save the presentation to a file
79    pub fn save<P: AsRef<Path>>(&self, path: P) -> Result<()> {
80        let data = self.build()?;
81        std::fs::write(path, data)?;
82        Ok(())
83    }
84
85    /// Create a presentation from a PPTX file
86    pub fn from_path<P: AsRef<Path>>(path: P) -> Result<Self> {
87        let path_str = path.as_ref().to_string_lossy();
88        import_pptx(&path_str)
89    }
90
91    /// Export the presentation to HTML
92    pub fn save_as_html<P: AsRef<Path>>(&self, path: P) -> Result<()> {
93        let html = export_to_html(self)?;
94        std::fs::write(path, html)?;
95        Ok(())
96    }
97
98    /// Export the presentation to PDF using LibreOffice
99    ///
100    /// Requires LibreOffice to be installed and available via `soffice` command.
101    /// On macOS, it also checks `/Applications/LibreOffice.app/Contents/MacOS/soffice`.
102    pub fn save_as_pdf<P: AsRef<Path>>(&self, output_path: P) -> Result<()> {
103        // Create a temp file
104        let temp_dir = std::env::temp_dir();
105        let temp_filename = format!("ppt_rs_{}.pptx", uuid::Uuid::new_v4());
106        let temp_path = temp_dir.join(&temp_filename);
107
108        // Save current presentation to temp file
109        self.save(&temp_path)?;
110
111        // Try to find soffice
112        let soffice_cmd = if cfg!(target_os = "macos") {
113            if Path::new("/Applications/LibreOffice.app/Contents/MacOS/soffice").exists() {
114                "/Applications/LibreOffice.app/Contents/MacOS/soffice"
115            } else {
116                "soffice"
117            }
118        } else {
119            "soffice"
120        };
121
122        // Get output directory
123        let output_parent = output_path.as_ref().parent().unwrap_or(Path::new("."));
124
125        // Run conversion
126        // soffice --headless --convert-to pdf <temp_path> --outdir <output_dir>
127        let result = Command::new(soffice_cmd)
128            .arg("--headless")
129            .arg("--convert-to")
130            .arg("pdf")
131            .arg(&temp_path)
132            .arg("--outdir")
133            .arg(output_parent)
134            .output();
135
136        // Clean up temp file (ignore error)
137        let _ = std::fs::remove_file(&temp_path);
138
139        match result {
140            Ok(output) => {
141                if !output.status.success() {
142                    let stderr = String::from_utf8_lossy(&output.stderr);
143                    return Err(PptxError::Generic(format!(
144                        "LibreOffice conversion failed: {}",
145                        stderr
146                    )));
147                }
148            }
149            Err(e) => {
150                return Err(PptxError::Generic(format!(
151                    "Failed to execute libreoffice: {}",
152                    e
153                )));
154            }
155        }
156
157        // LibreOffice creates file with same basename but .pdf extension in outdir
158        // The generated file will be temp_filename.pdf (since input was temp_filename.pptx)
159        let generated_pdf_name = temp_filename.replace(".pptx", ".pdf");
160        let generated_pdf_path = output_parent.join(&generated_pdf_name);
161
162        if generated_pdf_path.exists() {
163            std::fs::rename(&generated_pdf_path, output_path.as_ref())?;
164            Ok(())
165        } else {
166            Err(PptxError::Generic("PDF output file not found".to_string()))
167        }
168    }
169
170    /// Export slides to PNG images
171    ///
172    /// Requires LibreOffice (for PDF conversion) and `pdftoppm` (from poppler).
173    /// Images will be named `slide-1.png`, `slide-2.png`, etc. in the output directory.
174    pub fn save_as_png<P: AsRef<Path>>(&self, output_dir: P) -> Result<()> {
175        let output_dir = output_dir.as_ref();
176        if !output_dir.exists() {
177            std::fs::create_dir_all(output_dir)?;
178        }
179
180        // Create temp PDF
181        let temp_dir = std::env::temp_dir();
182        let temp_pdf_name = format!("ppt_rs_temp_{}.pdf", uuid::Uuid::new_v4());
183        let temp_pdf_path = temp_dir.join(&temp_pdf_name);
184
185        // Convert to PDF first
186        self.save_as_pdf(&temp_pdf_path)?;
187
188        // Convert PDF to PNGs using pdftoppm
189        // pdftoppm -png <pdf_file> <image_prefix>
190        let prefix = output_dir.join("slide");
191
192        let status = Command::new("pdftoppm")
193            .arg("-png")
194            .arg(&temp_pdf_path)
195            .arg(&prefix)
196            .status()
197            .map_err(|e| PptxError::Generic(format!("Failed to execute pdftoppm: {}", e)))?;
198
199        // Cleanup temp PDF
200        let _ = std::fs::remove_file(&temp_pdf_path);
201
202        if !status.success() {
203            return Err(PptxError::Generic("pdftoppm conversion failed".to_string()));
204        }
205
206        Ok(())
207    }
208
209    /// Create a presentation from a PDF file (each page becomes a slide)
210    ///
211    /// Requires `pdftoppm` (from poppler) to be installed.
212    pub fn from_pdf<P: AsRef<Path>>(path: P) -> Result<Self> {
213        let path = path.as_ref();
214        if !path.exists() {
215            return Err(PptxError::NotFound(format!(
216                "PDF file not found: {}",
217                path.display()
218            )));
219        }
220
221        // Create temp dir for images
222        let temp_dir = std::env::temp_dir().join(format!("ppt_rs_import_{}", uuid::Uuid::new_v4()));
223        std::fs::create_dir_all(&temp_dir)?;
224
225        // Convert PDF to PNGs
226        let prefix = temp_dir.join("page");
227
228        let status = Command::new("pdftoppm")
229            .arg("-png")
230            .arg(path)
231            .arg(&prefix)
232            .status()
233            .map_err(|e| PptxError::Generic(format!("Failed to execute pdftoppm: {}", e)))?;
234
235        if !status.success() {
236            let _ = std::fs::remove_dir_all(&temp_dir);
237            return Err(PptxError::Generic("pdftoppm failed".to_string()));
238        }
239
240        // Read images and create slides
241        let mut pres = Presentation::new();
242        // Set title from filename
243        if let Some(stem) = path.file_stem() {
244            pres = pres.title(&stem.to_string_lossy());
245        }
246
247        // Read dir
248        let mut entries: Vec<_> = std::fs::read_dir(&temp_dir)?
249            .filter_map(|e| e.ok())
250            .collect();
251
252        // Sort by filename to ensure page order
253        // pdftoppm names files like page-1.png, page-2.png... page-10.png
254        // Default string sort might put page-10 before page-2
255        // We need to sort by length then by name, or rely on pdftoppm zero padding (it usually does -01 if needed, but safer to trust number)
256        // pdftoppm default is -1, -2... -10.
257        // So page-1.png, page-10.png, page-2.png.
258        // We need natural sort.
259        entries.sort_by_key(|e| {
260            let name = e.file_name().to_string_lossy().to_string();
261            // Extract number from end
262            // "page-1.png" -> 1
263            if let Some(start) = name.rfind('-') {
264                if let Some(end) = name.rfind('.') {
265                    if start < end {
266                        if let Ok(num) = name[start + 1..end].parse::<u32>() {
267                            return num;
268                        }
269                    }
270                }
271            }
272            0 // Fallback
273        });
274
275        for entry in entries {
276            let path = entry.path();
277            if path.extension().map_or(false, |e| e == "png") {
278                // Create slide with full screen image
279                let image = Image::from_path(&path).map_err(|e| PptxError::Generic(e))?;
280
281                // Add image to slide
282                // Use a default layout?
283                // Just create a slide with this image
284                // We'll center it.
285                // Assuming standard 16:9 slide (10x5.625 inches) -> 9144000 x 5143500 EMU
286                // But we don't know image dimensions here easily without reading it.
287                // Image builder defaults to auto size?
288                // Let's just add it.
289
290                let mut slide = SlideContent::new("");
291                slide.images.push(image);
292                pres = pres.add_slide(slide);
293            }
294        }
295
296        let _ = std::fs::remove_dir_all(&temp_dir);
297        Ok(pres)
298    }
299}
300
301#[cfg(test)]
302mod tests {
303    use super::*;
304
305    #[test]
306    fn test_presentation_builder() {
307        let pres = Presentation::with_title("Test")
308            .add_slide(SlideContent::new("Slide 1").add_bullet("Point 1"));
309
310        assert_eq!(pres.get_title(), "Test");
311        assert_eq!(pres.slide_count(), 1);
312    }
313
314    #[test]
315    fn test_presentation_build() {
316        let pres = Presentation::with_title("Test").add_slide(SlideContent::new("Slide 1"));
317
318        let result = pres.build();
319        assert!(result.is_ok());
320    }
321}