1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14pub enum InputFormat {
15 Markdown,
16 Html,
17 Auto,
18}
19
20#[derive(Debug, Clone)]
22pub struct BatchJob {
23 pub input: PathBuf,
24 pub output: Option<PathBuf>,
26 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#[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#[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
93pub 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
110pub 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
184pub 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
217pub 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
225pub 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
239pub 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}