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(&format!("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            if let Some(table) = &slide.table {
133                md.push_str(&export_table_to_gfm(table));
134                md.push('\n');
135            }
136        }
137
138        // Images
139        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                // Note: Actual image data would need to be saved separately
143                md.push_str(&format!(
144                    "![{}](images/slide{}_image{}{})\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        // Code blocks
154        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        // Speaker notes
165        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
177/// Export a table to GitHub Flavored Markdown format
178fn export_table_to_gfm(table: &crate::generator::Table) -> String {
179    let mut md = String::new();
180
181    // Header row
182    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        // Separator
191        md.push_str("|");
192        for _ in &first_row.cells {
193            md.push_str(" --- |");
194        }
195        md.push('\n');
196
197        // Data rows
198        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
211/// Escape special Markdown characters
212fn 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
224/// Escape special YAML characters in frontmatter
225fn escape_yaml(text: &str) -> String {
226    if text.contains('\n') || text.contains('"') || text.contains('\\') {
227        // Use literal block scalar for multiline or complex strings
228        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        // Single line with quotes gets escaped or uses literal block
333        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")); // No slides
363    }
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}