1use crate::api::Presentation;
7use crate::exc::Result;
8
9#[derive(Debug, Clone)]
11pub struct MarkdownOptions {
12 pub include_slide_numbers: bool,
14 pub slide_separator: String,
16 pub include_notes: bool,
18 pub use_gfm_tables: bool,
20 pub include_images: bool,
22 pub include_frontmatter: bool,
24}
25
26impl Default for MarkdownOptions {
27 fn default() -> Self {
28 Self {
29 include_slide_numbers: true,
30 slide_separator: "---".to_string(),
31 include_notes: true,
32 use_gfm_tables: true,
33 include_images: true,
34 include_frontmatter: true,
35 }
36 }
37}
38
39impl MarkdownOptions {
40 pub fn new() -> Self {
42 Self::default()
43 }
44
45 pub fn with_slide_numbers(mut self, include: bool) -> Self {
47 self.include_slide_numbers = include;
48 self
49 }
50
51 pub fn with_separator(mut self, sep: &str) -> Self {
53 self.slide_separator = sep.to_string();
54 self
55 }
56
57 pub fn with_notes(mut self, include: bool) -> Self {
59 self.include_notes = include;
60 self
61 }
62
63 pub fn with_gfm_tables(mut self, use_gfm: bool) -> Self {
65 self.use_gfm_tables = use_gfm;
66 self
67 }
68
69 pub fn with_images(mut self, include: bool) -> Self {
71 self.include_images = include;
72 self
73 }
74
75 pub fn with_frontmatter(mut self, include: bool) -> Self {
77 self.include_frontmatter = include;
78 self
79 }
80}
81
82pub fn export_to_markdown(presentation: &Presentation) -> Result<String> {
84 export_to_markdown_with_options(presentation, &MarkdownOptions::default())
85}
86
87pub fn export_to_markdown_with_options(
89 presentation: &Presentation,
90 options: &MarkdownOptions,
91) -> Result<String> {
92 let mut md = String::new();
93
94 if options.include_frontmatter {
96 md.push_str("---\n");
97 md.push_str(&format!("title: \"{}\"\n", escape_yaml(presentation.get_title())));
98 md.push_str(&format!("slides: {}\n", presentation.slide_count()));
99 md.push_str(&format!("generator: ppt-rs\n"));
100 md.push_str("---\n\n");
101 }
102
103 md.push_str(&format!("# {}\n\n", presentation.get_title()));
105
106 for (i, slide) in presentation.slides().iter().enumerate() {
108 let slide_num = i + 1;
109
110 if i > 0 || options.include_slide_numbers {
112 md.push_str(&format!("\n{}\n\n", options.slide_separator));
113 }
114
115 if options.include_slide_numbers {
117 md.push_str(&format!("## Slide {}: {}\n\n", slide_num, escape_markdown(&slide.title)));
118 } else {
119 md.push_str(&format!("## {}\n\n", escape_markdown(&slide.title)));
120 }
121
122 if !slide.content.is_empty() {
124 for item in &slide.content {
125 md.push_str(&format!("- {}\n", escape_markdown(item)));
126 }
127 md.push('\n');
128 }
129
130 if options.use_gfm_tables && slide.has_table {
132 if let Some(table) = &slide.table {
133 md.push_str(&export_table_to_gfm(table));
134 md.push('\n');
135 }
136 }
137
138 if options.include_images && !slide.images.is_empty() {
140 for (img_idx, image) in slide.images.iter().enumerate() {
141 let alt_text = format!("Image {} on slide {}", img_idx + 1, slide_num);
142 md.push_str(&format!(
144 "\n\n",
145 alt_text,
146 slide_num,
147 img_idx + 1,
148 image.format.to_lowercase().replace("jpeg", ".jpg").replace("png", ".png")
149 ));
150 }
151 }
152
153 if !slide.code_blocks.is_empty() {
155 for code_block in &slide.code_blocks {
156 md.push_str(&format!(
157 "```{lang}\n{code}\n```\n\n",
158 lang = &code_block.language,
159 code = &code_block.code
160 ));
161 }
162 }
163
164 let has_notes = slide.notes.as_ref().map_or(false, |n| !n.is_empty());
166 if options.include_notes && has_notes {
167 md.push_str("**Notes:**\n\n");
168 if let Some(notes) = &slide.notes {
169 md.push_str(&format!("> {}\n\n", escape_markdown(notes)));
170 }
171 }
172 }
173
174 Ok(md)
175}
176
177fn export_table_to_gfm(table: &crate::generator::Table) -> String {
179 let mut md = String::new();
180
181 if let Some(first_row) = table.rows.first() {
183 md.push_str("| ");
184 for cell in &first_row.cells {
185 md.push_str(&escape_markdown(&cell.text));
186 md.push_str(" | ");
187 }
188 md.push('\n');
189
190 md.push_str("|");
192 for _ in &first_row.cells {
193 md.push_str(" --- |");
194 }
195 md.push('\n');
196
197 for row in table.rows.iter().skip(1) {
199 md.push_str("| ");
200 for cell in &row.cells {
201 md.push_str(&escape_markdown(&cell.text));
202 md.push_str(" | ");
203 }
204 md.push('\n');
205 }
206 }
207
208 md
209}
210
211fn escape_markdown(text: &str) -> String {
213 text.replace('\\', "\\\\")
214 .replace('*', "\\*")
215 .replace('_', "\\_")
216 .replace('[', "\\[")
217 .replace(']', "\\]")
218 .replace('`', "\\`")
219 .replace('#', "\\#")
220 .replace('<', "\\<")
221 .replace('>', "\\>")
222}
223
224fn escape_yaml(text: &str) -> String {
226 if text.contains('\n') || text.contains('"') || text.contains('\\') {
227 format!("|\n {}", text.replace('\n', "\n "))
229 } else {
230 text.replace('"', "\\\"").replace('\\', "\\\\")
231 }
232}
233
234#[cfg(test)]
235mod tests {
236 use super::*;
237 use crate::generator::{SlideContent, TableBuilder, TableCell, TableRow, CodeBlock};
238
239 #[test]
240 fn test_export_simple_presentation() {
241 let mut presentation = Presentation::with_title("Test Presentation");
242 presentation = presentation.add_slide(SlideContent::new("Slide 1").add_bullet("Point 1"));
243 presentation = presentation.add_slide(SlideContent::new("Slide 2").add_bullet("Point 2"));
244
245 let md = export_to_markdown(&presentation).unwrap();
246
247 assert!(md.contains("# Test Presentation"));
248 assert!(md.contains("## Slide 1: Slide 1"));
249 assert!(md.contains("- Point 1"));
250 assert!(md.contains("---"));
251 }
252
253 #[test]
254 fn test_markdown_options() {
255 let mut presentation = Presentation::with_title("Test");
256 presentation = presentation.add_slide(SlideContent::new("Slide").add_bullet("Point"));
257
258 let options = MarkdownOptions::new()
259 .with_slide_numbers(false)
260 .with_frontmatter(false);
261
262 let md = export_to_markdown_with_options(&presentation, &options).unwrap();
263
264 assert!(!md.contains("## Slide 1:"));
265 assert!(md.contains("## Slide"));
266 assert!(!md.contains("---\ntitle:"));
267 }
268
269 #[test]
270 fn test_escape_markdown() {
271 assert_eq!(escape_markdown("*bold*"), "\\*bold\\*");
272 assert_eq!(escape_markdown("[link]"), "\\[link\\]");
273 assert_eq!(escape_markdown("`code`"), "\\`code\\`");
274 }
275
276 #[test]
277 fn test_export_table_to_gfm() {
278 let cells1 = vec![TableCell::new("Header 1"), TableCell::new("Header 2")];
279 let cells2 = vec![TableCell::new("Row 1 Col 1"), TableCell::new("Row 1 Col 2")];
280 let table = TableBuilder::new(vec![100, 100])
281 .add_row(TableRow::new(cells1))
282 .add_row(TableRow::new(cells2))
283 .build();
284
285 let md = export_table_to_gfm(&table);
286
287 assert!(md.contains("| Header 1 | Header 2 |"));
288 assert!(md.contains("| --- | --- |"));
289 assert!(md.contains("| Row 1 Col 1 | Row 1 Col 2 |"));
290 }
291
292 #[test]
293 fn test_export_with_code_blocks() {
294 let mut presentation = Presentation::with_title("Code Test");
295 let mut slide = SlideContent::new("Code Slide");
296 slide.code_blocks.push(CodeBlock::new("println!(\"Hello\");", "rust"));
297 presentation = presentation.add_slide(slide);
298
299 let md = export_to_markdown(&presentation).unwrap();
300
301 assert!(md.contains("```rust"));
302 assert!(md.contains("println!(\"Hello\");"));
303 assert!(md.contains("```"));
304 }
305
306 #[test]
307 fn test_export_with_speaker_notes() {
308 let mut presentation = Presentation::with_title("Notes Test");
309 let mut slide = SlideContent::new("Notes Slide");
310 slide.notes = Some("This is a speaker note".to_string());
311 presentation = presentation.add_slide(slide);
312
313 let md = export_to_markdown(&presentation).unwrap();
314
315 assert!(md.contains("**Notes:**"));
316 assert!(md.contains("> This is a speaker note"));
317 }
318
319 #[test]
320 fn test_yaml_escape_multiline() {
321 let multiline = "Line 1\nLine 2";
322 let escaped = escape_yaml(multiline);
323 assert!(escaped.starts_with("|"));
324 assert!(escaped.contains("Line 1"));
325 assert!(escaped.contains("Line 2"));
326 }
327
328 #[test]
329 fn test_yaml_escape_quotes() {
330 let with_quotes = r#"Title with "quotes""#;
331 let escaped = escape_yaml(with_quotes);
332 assert!(escaped.contains("quotes") || escaped.contains("\\\""));
334 }
335
336 #[test]
337 fn test_markdown_all_options_disabled() {
338 let mut presentation = Presentation::with_title("Minimal");
339 let mut slide = SlideContent::new("Slide");
340 slide.notes = Some("Note".to_string());
341 presentation = presentation.add_slide(slide);
342
343 let options = MarkdownOptions::new()
344 .with_frontmatter(false)
345 .with_slide_numbers(false)
346 .with_notes(false)
347 .with_images(false);
348
349 let md = export_to_markdown_with_options(&presentation, &options).unwrap();
350
351 assert!(!md.contains("---\ntitle:"));
352 assert!(!md.contains("Slide 1:"));
353 assert!(!md.contains("**Notes:**"));
354 }
355
356 #[test]
357 fn test_empty_presentation() {
358 let presentation = Presentation::with_title("Empty");
359 let md = export_to_markdown(&presentation).unwrap();
360
361 assert!(md.contains("# Empty"));
362 assert!(!md.contains("## Slide")); }
364
365 #[test]
366 fn test_markdown_escape_various_chars() {
367 let text = r#"Special chars: * _ [ ] ` # < > \ "#;
368 let escaped = escape_markdown(text);
369 assert!(escaped.contains("\\*"));
370 assert!(escaped.contains("\\_"));
371 assert!(escaped.contains("\\["));
372 assert!(escaped.contains("\\]"));
373 assert!(escaped.contains("\\`"));
374 assert!(escaped.contains("\\#"));
375 assert!(escaped.contains("\\<"));
376 assert!(escaped.contains("\\>"));
377 }
378}