Skip to main content

ppt_rs/export/
md.rs

1//! Markdown export module
2//!
3//! Provides functionality to export presentations to Markdown format.
4//! Supports GitHub Flavored Markdown with extensions for slides.
5
6use crate::api::Presentation;
7use crate::exc::Result;
8
9/// Export options for Markdown generation
10#[derive(Debug, Clone)]
11pub struct MarkdownOptions {
12    /// Include slide numbers as headers
13    pub include_slide_numbers: bool,
14    /// Format for slide separators (--- or horizontal rule)
15    pub slide_separator: String,
16    /// Include speaker notes
17    pub include_notes: bool,
18    /// Use GFM tables for table export
19    pub use_gfm_tables: bool,
20    /// Include image references
21    pub include_images: bool,
22    /// Add YAML frontmatter with presentation metadata
23    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    /// Create new options with defaults
41    pub fn new() -> Self {
42        Self::default()
43    }
44
45    /// Set slide number inclusion
46    pub fn with_slide_numbers(mut self, include: bool) -> Self {
47        self.include_slide_numbers = include;
48        self
49    }
50
51    /// Set slide separator
52    pub fn with_separator(mut self, sep: &str) -> Self {
53        self.slide_separator = sep.to_string();
54        self
55    }
56
57    /// Set notes inclusion
58    pub fn with_notes(mut self, include: bool) -> Self {
59        self.include_notes = include;
60        self
61    }
62
63    /// Set GFM table usage
64    pub fn with_gfm_tables(mut self, use_gfm: bool) -> Self {
65        self.use_gfm_tables = use_gfm;
66        self
67    }
68
69    /// Set image inclusion
70    pub fn with_images(mut self, include: bool) -> Self {
71        self.include_images = include;
72        self
73    }
74
75    /// Set frontmatter inclusion
76    pub fn with_frontmatter(mut self, include: bool) -> Self {
77        self.include_frontmatter = include;
78        self
79    }
80}
81
82/// Export a presentation to Markdown format
83pub fn export_to_markdown(presentation: &Presentation) -> Result<String> {
84    export_to_markdown_with_options(presentation, &MarkdownOptions::default())
85}
86
87/// Export a presentation to Markdown with custom options
88pub fn export_to_markdown_with_options(
89    presentation: &Presentation,
90    options: &MarkdownOptions,
91) -> Result<String> {
92    let mut md = String::new();
93
94    // YAML frontmatter
95    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    // Presentation title as main heading
104    md.push_str(&format!("# {}\n\n", presentation.get_title()));
105
106    // Export each slide
107    for (i, slide) in presentation.slides().iter().enumerate() {
108        let slide_num = i + 1;
109
110        // Slide separator
111        if i > 0 || options.include_slide_numbers {
112            md.push_str(&format!("\n{}\n\n", options.slide_separator));
113        }
114
115        // Slide number header
116        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        // Bullet content
123        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        // Table export (GFM format)
131        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        // Images
138        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                // Note: Actual image data would need to be saved separately
142                md.push_str(&format!(
143                    "![{}](images/slide{}_image{}{})\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        // Code blocks
153        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        // Speaker notes
164        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
176/// Export a table to GitHub Flavored Markdown format
177fn export_table_to_gfm(table: &crate::generator::Table) -> String {
178    let mut md = String::new();
179
180    // Header row
181    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        // Separator
190        md.push('|');
191        for _ in &first_row.cells {
192            md.push_str(" --- |");
193        }
194        md.push('\n');
195
196        // Data rows
197        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
210/// Escape special Markdown characters
211fn 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
223/// Escape special YAML characters in frontmatter
224fn escape_yaml(text: &str) -> String {
225    if text.contains('\n') || text.contains('"') || text.contains('\\') {
226        // Use literal block scalar for multiline or complex strings
227        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        // Single line with quotes gets escaped or uses literal block
332        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")); // No slides
362    }
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}