Skip to main content

ppt_rs/pipeline/
mod.rs

1//! Batch conversion pipeline: directory of Markdown/HTML → one `.pptx` per file.
2//!
3//! Applies shared theme/template settings across every job. Markdown input
4//! requires the `cli` feature (pulldown-cmark parser lives in `cli::markdown`).
5
6use std::path::{Path, PathBuf};
7
8use crate::exc::{PptxError, Result};
9use crate::generator::{PresentationSettings, PresentationTheme, SlideContent, create_pptx_with_settings};
10use crate::import::{HtmlParseOptions, parse_html_with_options};
11
12/// Supported batch input formats (detected from file extension when `Auto`).
13#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14pub enum InputFormat {
15    Markdown,
16    Html,
17    Auto,
18}
19
20/// One file-to-deck conversion job.
21#[derive(Debug, Clone)]
22pub struct BatchJob {
23    pub input: PathBuf,
24    /// When `None`, output is `{output_dir}/{input_stem}.pptx`.
25    pub output: Option<PathBuf>,
26    /// Override deck title; defaults to filename stem when unset.
27    pub title: Option<String>,
28}
29
30impl BatchJob {
31    pub fn new(input: impl Into<PathBuf>) -> Self {
32        Self {
33            input: input.into(),
34            output: None,
35            title: None,
36        }
37    }
38
39    pub fn with_output(mut self, output: impl Into<PathBuf>) -> Self {
40        self.output = Some(output.into());
41        self
42    }
43
44    pub fn with_title(mut self, title: impl Into<String>) -> Self {
45        self.title = Some(title.into());
46        self
47    }
48}
49
50/// Shared options applied to every job in a batch run.
51#[derive(Debug, Clone, Default)]
52pub struct BatchOptions {
53    pub output_dir: PathBuf,
54    pub theme: Option<PresentationTheme>,
55    pub template: Option<String>,
56    pub html: HtmlParseOptions,
57}
58
59impl BatchOptions {
60    pub fn new(output_dir: impl Into<PathBuf>) -> Self {
61        Self {
62            output_dir: output_dir.into(),
63            ..Self::default()
64        }
65    }
66
67    pub fn with_theme(mut self, theme: PresentationTheme) -> Self {
68        self.theme = Some(theme);
69        self
70    }
71
72    pub fn with_template(mut self, template: impl Into<String>) -> Self {
73        self.template = Some(template.into());
74        self
75    }
76
77    pub fn with_html_options(mut self, html: HtmlParseOptions) -> Self {
78        self.html = html;
79        self
80    }
81}
82
83/// Outcome of one successful batch conversion.
84#[derive(Debug, Clone, PartialEq, Eq)]
85pub struct BatchResult {
86    pub input: PathBuf,
87    pub output: PathBuf,
88    pub title: String,
89    pub slide_count: usize,
90    pub bytes: usize,
91}
92
93/// Detect input format from a path's extension.
94pub fn detect_format(path: &Path) -> Result<InputFormat> {
95    let ext = path
96        .extension()
97        .and_then(|e| e.to_str())
98        .unwrap_or("")
99        .to_ascii_lowercase();
100    match ext.as_str() {
101        "md" | "markdown" => Ok(InputFormat::Markdown),
102        "html" | "htm" => Ok(InputFormat::Html),
103        _ => Err(PptxError::Generic(format!(
104            "Unsupported batch input extension \".{ext}\" for {}",
105            path.display()
106        ))),
107    }
108}
109
110/// Resolve the output path for a job.
111pub fn resolve_output_path(job: &BatchJob, options: &BatchOptions) -> PathBuf {
112    job.output.clone().unwrap_or_else(|| {
113        let stem = job
114            .input
115            .file_stem()
116            .and_then(|s| s.to_str())
117            .unwrap_or("deck");
118        options.output_dir.join(format!("{stem}.pptx"))
119    })
120}
121
122fn default_title(job: &BatchJob) -> String {
123    job.title.clone().unwrap_or_else(|| {
124        job.input
125            .file_stem()
126            .and_then(|s| s.to_str())
127            .unwrap_or("Presentation")
128            .to_string()
129    })
130}
131
132fn build_settings(options: &BatchOptions) -> PresentationSettings {
133    let mut settings = PresentationSettings::new();
134    if let Some(theme) = &options.theme {
135        settings = settings.theme(theme.clone());
136    }
137    if let Some(template) = &options.template {
138        settings = settings.template(template.clone());
139    }
140    settings
141}
142
143fn parse_slides(
144    path: &Path,
145    format: InputFormat,
146    html: &HtmlParseOptions,
147) -> Result<Vec<SlideContent>> {
148    let content = std::fs::read_to_string(path).map_err(|e| {
149        PptxError::Generic(format!("Failed to read {}: {e}", path.display()))
150    })?;
151
152    let slides = match format {
153        InputFormat::Html => parse_html_with_options(&content, html.clone())
154            .map_err(PptxError::Generic)?,
155        InputFormat::Markdown => parse_markdown_slides(&content, path)?,
156        InputFormat::Auto => unreachable!("callers must resolve Auto before parse_slides"),
157    };
158
159    if slides.is_empty() {
160        return Err(PptxError::Generic(format!(
161            "No slides found in {}",
162            path.display()
163        )));
164    }
165    Ok(slides)
166}
167
168fn parse_markdown_slides(content: &str, path: &Path) -> Result<Vec<SlideContent>> {
169    #[cfg(feature = "cli")]
170    {
171        let _ = path;
172        crate::cli::parse_markdown(content).map_err(PptxError::Generic)
173    }
174    #[cfg(not(feature = "cli"))]
175    {
176        let _ = content;
177        Err(PptxError::Generic(format!(
178            "Markdown batch input requires the \"cli\" feature: {}",
179            path.display()
180        )))
181    }
182}
183
184/// Convert a single Markdown or HTML file to `.pptx`.
185pub fn convert_file(
186    job: &BatchJob,
187    options: &BatchOptions,
188    format: InputFormat,
189) -> Result<BatchResult> {
190    let resolved_format = match format {
191        InputFormat::Auto => detect_format(&job.input)?,
192        other => other,
193    };
194
195    let slides = parse_slides(&job.input, resolved_format, &options.html)?;
196    let title = default_title(job);
197    let output = resolve_output_path(job, options);
198
199    if let Some(parent) = output.parent()
200        && !parent.as_os_str().is_empty() {
201            std::fs::create_dir_all(parent)?;
202        }
203
204    let settings = build_settings(options);
205    let bytes = create_pptx_with_settings(&title, &slides, Some(settings))?;
206    std::fs::write(&output, &bytes)?;
207
208    Ok(BatchResult {
209        input: job.input.clone(),
210        output,
211        title,
212        slide_count: slides.len(),
213        bytes: bytes.len(),
214    })
215}
216
217/// Run a list of batch jobs sequentially.
218pub fn run_batch(jobs: &[BatchJob], options: &BatchOptions) -> Result<Vec<BatchResult>> {
219    std::fs::create_dir_all(&options.output_dir)?;
220    jobs.iter()
221        .map(|job| convert_file(job, options, InputFormat::Auto))
222        .collect()
223}
224
225/// Discover `*.md`, `*.markdown`, `*.html`, and `*.htm` files in a directory (non-recursive).
226pub fn discover_inputs(dir: &Path) -> Result<Vec<PathBuf>> {
227    let mut paths = Vec::new();
228    for entry in std::fs::read_dir(dir)? {
229        let entry = entry?;
230        let path = entry.path();
231        if path.is_file() && detect_format(&path).is_ok() {
232            paths.push(path);
233        }
234    }
235    paths.sort();
236    Ok(paths)
237}
238
239/// Convert every Markdown/HTML file in a directory to `{output_dir}/{stem}.pptx`.
240pub fn run_directory(input_dir: &Path, options: &BatchOptions) -> Result<Vec<BatchResult>> {
241    let inputs = discover_inputs(input_dir)?;
242    if inputs.is_empty() {
243        return Err(PptxError::Generic(format!(
244            "No .md/.html files found in {}",
245            input_dir.display()
246        )));
247    }
248    let jobs: Vec<BatchJob> = inputs.into_iter().map(BatchJob::new).collect();
249    run_batch(&jobs, options)
250}
251
252#[cfg(test)]
253mod tests {
254    use super::*;
255    use std::io::Write;
256
257    fn write_temp_md(dir: &Path, name: &str, body: &str) -> PathBuf {
258        let path = dir.join(name);
259        std::fs::File::create(&path)
260            .unwrap()
261            .write_all(body.as_bytes())
262            .unwrap();
263        path
264    }
265
266    #[test]
267    fn detect_format_by_extension() {
268        assert_eq!(
269            detect_format(Path::new("deck.md")).unwrap(),
270            InputFormat::Markdown
271        );
272        assert_eq!(
273            detect_format(Path::new("page.HTML")).unwrap(),
274            InputFormat::Html
275        );
276        assert!(detect_format(Path::new("notes.txt")).is_err());
277    }
278
279    #[test]
280    fn discover_inputs_filters_supported_extensions() {
281        let dir = std::env::temp_dir().join(format!("ppt_rs_batch_{}", std::process::id()));
282        std::fs::create_dir_all(&dir).unwrap();
283        write_temp_md(&dir, "a.md", "# A\n\n- one");
284        write_temp_md(&dir, "skip.txt", "nope");
285        write_temp_md(&dir, "b.html", "<h1>B</h1><p>Hi</p>");
286
287        let found = discover_inputs(&dir).unwrap();
288        assert_eq!(found.len(), 2);
289        let _ = std::fs::remove_dir_all(&dir);
290    }
291
292    #[cfg(feature = "cli")]
293    #[test]
294    fn run_directory_converts_markdown_files() {
295        let root = std::env::temp_dir().join(format!("ppt_rs_batch_run_{}", std::process::id()));
296        let input_dir = root.join("in");
297        let output_dir = root.join("out");
298        std::fs::create_dir_all(&input_dir).unwrap();
299
300        write_temp_md(
301            &input_dir,
302            "report.md",
303            "# Slide 1\n\n- Point A\n\n# Slide 2\n\n- Point B",
304        );
305
306        let results = run_directory(
307            &input_dir,
308            &BatchOptions::new(&output_dir),
309        )
310        .unwrap();
311
312        assert_eq!(results.len(), 1);
313        assert!(results[0].output.exists());
314        assert!(results[0].slide_count >= 2);
315        assert!(results[0].bytes > 1000);
316
317        let _ = std::fs::remove_dir_all(&root);
318    }
319}