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("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 && let Some(table) = &slide.table {
133 md.push_str(&export_table_to_gfm(table));
134 md.push('\n');
135 }
136
137 if options.include_images && !slide.images.is_empty() {
139 for (img_idx, image) in slide.images.iter().enumerate() {
140 let alt_text = format!("Image {} on slide {}", img_idx + 1, slide_num);
141 md.push_str(&format!(
143 "\n\n",
144 alt_text,
145 slide_num,
146 img_idx + 1,
147 image.format.to_lowercase().replace("jpeg", ".jpg").replace("png", ".png")
148 ));
149 }
150 }
151
152 if !slide.code_blocks.is_empty() {
154 for code_block in &slide.code_blocks {
155 md.push_str(&format!(
156 "```{lang}\n{code}\n```\n\n",
157 lang = &code_block.language,
158 code = &code_block.code
159 ));
160 }
161 }
162
163 let has_notes = slide.notes.as_ref().is_some_and(|n| !n.is_empty());
165 if options.include_notes && has_notes {
166 md.push_str("**Notes:**\n\n");
167 if let Some(notes) = &slide.notes {
168 md.push_str(&format!("> {}\n\n", escape_markdown(notes)));
169 }
170 }
171 }
172
173 Ok(md)
174}
175
176fn export_table_to_gfm(table: &crate::generator::Table) -> String {
178 let mut md = String::new();
179
180 if let Some(first_row) = table.rows.first() {
182 md.push_str("| ");
183 for cell in &first_row.cells {
184 md.push_str(&escape_markdown(&cell.text));
185 md.push_str(" | ");
186 }
187 md.push('\n');
188
189 md.push('|');
191 for _ in &first_row.cells {
192 md.push_str(" --- |");
193 }
194 md.push('\n');
195
196 for row in table.rows.iter().skip(1) {
198 md.push_str("| ");
199 for cell in &row.cells {
200 md.push_str(&escape_markdown(&cell.text));
201 md.push_str(" | ");
202 }
203 md.push('\n');
204 }
205 }
206
207 md
208}
209
210fn escape_markdown(text: &str) -> String {
212 text.replace('\\', "\\\\")
213 .replace('*', "\\*")
214 .replace('_', "\\_")
215 .replace('[', "\\[")
216 .replace(']', "\\]")
217 .replace('`', "\\`")
218 .replace('#', "\\#")
219 .replace('<', "\\<")
220 .replace('>', "\\>")
221}
222
223fn escape_yaml(text: &str) -> String {
225 if text.contains('\n') || text.contains('"') || text.contains('\\') {
226 format!("|\n {}", text.replace('\n', "\n "))
228 } else {
229 text.replace('"', "\\\"").replace('\\', "\\\\")
230 }
231}
232
233#[cfg(test)]
234mod tests {
235 use super::*;
236 use crate::generator::{SlideContent, TableBuilder, TableCell, TableRow, CodeBlock};
237
238 #[test]
239 fn test_export_simple_presentation() {
240 let mut presentation = Presentation::with_title("Test Presentation");
241 presentation = presentation.add_slide(SlideContent::new("Slide 1").add_bullet("Point 1"));
242 presentation = presentation.add_slide(SlideContent::new("Slide 2").add_bullet("Point 2"));
243
244 let md = export_to_markdown(&presentation).unwrap();
245
246 assert!(md.contains("# Test Presentation"));
247 assert!(md.contains("## Slide 1: Slide 1"));
248 assert!(md.contains("- Point 1"));
249 assert!(md.contains("---"));
250 }
251
252 #[test]
253 fn test_markdown_options() {
254 let mut presentation = Presentation::with_title("Test");
255 presentation = presentation.add_slide(SlideContent::new("Slide").add_bullet("Point"));
256
257 let options = MarkdownOptions::new()
258 .with_slide_numbers(false)
259 .with_frontmatter(false);
260
261 let md = export_to_markdown_with_options(&presentation, &options).unwrap();
262
263 assert!(!md.contains("## Slide 1:"));
264 assert!(md.contains("## Slide"));
265 assert!(!md.contains("---\ntitle:"));
266 }
267
268 #[test]
269 fn test_escape_markdown() {
270 assert_eq!(escape_markdown("*bold*"), "\\*bold\\*");
271 assert_eq!(escape_markdown("[link]"), "\\[link\\]");
272 assert_eq!(escape_markdown("`code`"), "\\`code\\`");
273 }
274
275 #[test]
276 fn test_export_table_to_gfm() {
277 let cells1 = vec![TableCell::new("Header 1"), TableCell::new("Header 2")];
278 let cells2 = vec![TableCell::new("Row 1 Col 1"), TableCell::new("Row 1 Col 2")];
279 let table = TableBuilder::new(vec![100, 100])
280 .add_row(TableRow::new(cells1))
281 .add_row(TableRow::new(cells2))
282 .build();
283
284 let md = export_table_to_gfm(&table);
285
286 assert!(md.contains("| Header 1 | Header 2 |"));
287 assert!(md.contains("| --- | --- |"));
288 assert!(md.contains("| Row 1 Col 1 | Row 1 Col 2 |"));
289 }
290
291 #[test]
292 fn test_export_with_code_blocks() {
293 let mut presentation = Presentation::with_title("Code Test");
294 let mut slide = SlideContent::new("Code Slide");
295 slide.code_blocks.push(CodeBlock::new("println!(\"Hello\");", "rust"));
296 presentation = presentation.add_slide(slide);
297
298 let md = export_to_markdown(&presentation).unwrap();
299
300 assert!(md.contains("```rust"));
301 assert!(md.contains("println!(\"Hello\");"));
302 assert!(md.contains("```"));
303 }
304
305 #[test]
306 fn test_export_with_speaker_notes() {
307 let mut presentation = Presentation::with_title("Notes Test");
308 let mut slide = SlideContent::new("Notes Slide");
309 slide.notes = Some("This is a speaker note".to_string());
310 presentation = presentation.add_slide(slide);
311
312 let md = export_to_markdown(&presentation).unwrap();
313
314 assert!(md.contains("**Notes:**"));
315 assert!(md.contains("> This is a speaker note"));
316 }
317
318 #[test]
319 fn test_yaml_escape_multiline() {
320 let multiline = "Line 1\nLine 2";
321 let escaped = escape_yaml(multiline);
322 assert!(escaped.starts_with("|"));
323 assert!(escaped.contains("Line 1"));
324 assert!(escaped.contains("Line 2"));
325 }
326
327 #[test]
328 fn test_yaml_escape_quotes() {
329 let with_quotes = r#"Title with "quotes""#;
330 let escaped = escape_yaml(with_quotes);
331 assert!(escaped.contains("quotes") || escaped.contains("\\\""));
333 }
334
335 #[test]
336 fn test_markdown_all_options_disabled() {
337 let mut presentation = Presentation::with_title("Minimal");
338 let mut slide = SlideContent::new("Slide");
339 slide.notes = Some("Note".to_string());
340 presentation = presentation.add_slide(slide);
341
342 let options = MarkdownOptions::new()
343 .with_frontmatter(false)
344 .with_slide_numbers(false)
345 .with_notes(false)
346 .with_images(false);
347
348 let md = export_to_markdown_with_options(&presentation, &options).unwrap();
349
350 assert!(!md.contains("---\ntitle:"));
351 assert!(!md.contains("Slide 1:"));
352 assert!(!md.contains("**Notes:**"));
353 }
354
355 #[test]
356 fn test_empty_presentation() {
357 let presentation = Presentation::with_title("Empty");
358 let md = export_to_markdown(&presentation).unwrap();
359
360 assert!(md.contains("# Empty"));
361 assert!(!md.contains("## Slide")); }
363
364 #[test]
365 fn test_markdown_escape_various_chars() {
366 let text = r#"Special chars: * _ [ ] ` # < > \ "#;
367 let escaped = escape_markdown(text);
368 assert!(escaped.contains("\\*"));
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 }
377}