Skip to main content

pptxboss_core/
markdown.rs

1//! Markdown rendering of a deck: one `##` heading per slide, bullets with
2//! their levels, GFM tables, images, chart tables, diagram outlines, and
3//! speaker notes and comments as block quotes on request.
4
5use crate::chart::ChartData;
6use crate::document::{Document, Slide};
7use crate::model::{Bullet, Content, Paragraph, PlaceholderKind, RunKind, Shape, Table, TextBody};
8use crate::opc::{Relationships, TargetMode};
9use crate::text::ExtractReport;
10
11/// What goes into the Markdown.
12#[derive(Clone, Debug, PartialEq, Eq)]
13pub struct MarkdownOptions {
14    /// A `## Title` (or `## Slide N`) heading per slide.
15    pub headings: bool,
16    /// Speaker notes as a `> **Notes:**` block quote after the slide.
17    pub notes: bool,
18    /// Comments as `> **Comment (Author):**` block quotes after the slide.
19    pub comments: bool,
20    /// Include slides marked hidden.
21    pub hidden_slides: bool,
22    /// Include shapes marked hidden.
23    pub hidden_shapes: bool,
24    /// Include date, footer, header and slide number placeholders.
25    pub furniture: bool,
26    /// Pictures as `![alt](part)` images.
27    pub images: bool,
28}
29
30impl Default for MarkdownOptions {
31    fn default() -> Self {
32        Self {
33            headings: true,
34            notes: false,
35            comments: false,
36            hidden_slides: true,
37            hidden_shapes: false,
38            furniture: false,
39            images: true,
40        }
41    }
42}
43
44impl Document {
45    /// The whole deck as Markdown, slides separated by a rule, with what was skipped.
46    pub fn markdown(&self, options: &MarkdownOptions) -> (String, ExtractReport) {
47        self.markdown_at(&self.all_slides(), options)
48    }
49
50    /// The slides at `indices` (zero-based) as Markdown in the written
51    /// order, separated by a rule, with what was skipped.
52    pub fn markdown_at(
53        &self,
54        indices: &[usize],
55        options: &MarkdownOptions,
56    ) -> (String, ExtractReport) {
57        let results = self.map_slides_at(indices, |slide| match slide {
58            Ok(slide) => {
59                let mut report = ExtractReport::default();
60                let text = slide.markdown(options, &mut report);
61                (text, report)
62            }
63            Err(err) => {
64                let mut report = ExtractReport::default();
65                report.failed_slides.push((0, err.to_string()));
66                (String::new(), report)
67            }
68        });
69        let mut report = ExtractReport::default();
70        let mut out = String::new();
71        for (&index, (text, slide_report)) in indices.iter().zip(results) {
72            report.merge(index, slide_report);
73            if text.is_empty() {
74                continue;
75            }
76            if !out.is_empty() {
77                out.push_str("\n\n---\n\n");
78            }
79            out.push_str(&text);
80        }
81        if !out.is_empty() {
82            out.push('\n');
83        }
84        (out, report)
85    }
86}
87
88impl Slide<'_> {
89    /// This slide as Markdown, without a trailing newline; empty for a hidden slide left out.
90    pub fn markdown(&self, options: &MarkdownOptions, report: &mut ExtractReport) -> String {
91        self.merge_parse_report(report);
92        if self.is_hidden() && !options.hidden_slides {
93            report.hidden_slides_skipped += 1;
94            return String::new();
95        }
96        let rels = self.rels().ok();
97        let mut blocks: Vec<String> = Vec::new();
98        let title_shape = self
99            .content
100            .walk()
101            .find(|shape| {
102                shape.is_title() && shape.text_body().is_some_and(|body| !body.is_empty())
103            })
104            .map(|shape| shape.id);
105        if options.headings {
106            let title = title_shape
107                .and_then(|_| self.title())
108                .map(|title| collapse_whitespace(&title))
109                .filter(|title| !title.is_empty())
110                .unwrap_or_else(|| format!("Slide {}", self.number()));
111            blocks.push(format!("## {}", escape_inline(&title)));
112        }
113        for shape in self.content.walk() {
114            if Some(shape.id) == title_shape && options.headings {
115                continue;
116            }
117            if shape.hidden && !options.hidden_shapes {
118                continue;
119            }
120            let furniture = shape
121                .placeholder
122                .as_ref()
123                .is_some_and(|ph| ph.kind.is_furniture());
124            if furniture && !options.furniture {
125                continue;
126            }
127            if let Some(block) = self.shape_markdown(shape, rels.as_deref(), options, report) {
128                blocks.push(block);
129            }
130        }
131        if options.notes {
132            match self.notes() {
133                Ok(Some(notes)) if !notes.is_empty() => {
134                    let mut quote = String::from("> **Notes:**");
135                    for paragraph in notes.paragraphs.iter().filter(|p| !p.is_empty()) {
136                        quote.push_str("\n> ");
137                        quote.push_str(&escape_inline(&collapse_whitespace(&paragraph.text())));
138                    }
139                    blocks.push(quote);
140                }
141                Ok(_) => {}
142                Err(err) => report.failed_notes.push((self.index, err.to_string())),
143            }
144        }
145        if options.comments {
146            match self.comments() {
147                Ok(comments) => {
148                    for comment in comments {
149                        let who = comment.author.as_deref().unwrap_or("unknown");
150                        let label = match comment.reply {
151                            true => "Reply",
152                            false => "Comment",
153                        };
154                        let when = comment
155                            .date
156                            .as_deref()
157                            .map(|date| format!(", {date}"))
158                            .unwrap_or_default();
159                        blocks.push(format!(
160                            "> **{label} ({}{when}):** {}",
161                            escape_inline(who),
162                            escape_inline(&collapse_whitespace(&comment.text))
163                        ));
164                    }
165                }
166                Err(err) => report.failed_comments.push((self.index, err.to_string())),
167            }
168        }
169        blocks.join("\n\n")
170    }
171
172    fn shape_markdown(
173        &self,
174        shape: &Shape,
175        rels: Option<&Relationships>,
176        options: &MarkdownOptions,
177        report: &mut ExtractReport,
178    ) -> Option<String> {
179        let description = shape
180            .description
181            .as_deref()
182            .map(collapse_whitespace)
183            .filter(|text| !text.is_empty());
184        match &shape.content {
185            Content::Text(body) => {
186                let in_list = shape.placeholder.as_ref().is_some_and(|ph| {
187                    matches!(ph.kind, PlaceholderKind::Body | PlaceholderKind::Object)
188                });
189                let text = body_markdown(body, in_list, rels);
190                (!text.is_empty()).then_some(text)
191            }
192            Content::Table(table) => table_markdown(table),
193            Content::Picture(picture) => {
194                if !options.images {
195                    return description.map(|text| format!("*{}*", escape_inline(&text)));
196                }
197                let target = picture
198                    .embed
199                    .as_deref()
200                    .or(picture.link.as_deref())
201                    .and_then(|id| rels.and_then(|rels| rels.get(id).map(|rel| (rels, rel))))
202                    .map(|(rels, rel)| match rel.mode {
203                        TargetMode::External => rel.target.clone(),
204                        TargetMode::Internal => rels
205                            .resolve(rel)
206                            .map(|part| part.trim_start_matches('/').to_string())
207                            .unwrap_or_else(|| rel.target.clone()),
208                    })
209                    .unwrap_or_default();
210                let target = match target.is_empty() {
211                    false => target,
212                    true => self
213                        .images()
214                        .ok()
215                        .and_then(|images| {
216                            images
217                                .into_iter()
218                                .find(|image| image.shape_id == shape.id)
219                                .and_then(|image| image.part)
220                        })
221                        .unwrap_or_default(),
222                };
223                let alt = description.unwrap_or_else(|| collapse_whitespace(&shape.name));
224                Some(format!(
225                    "![{}]({})",
226                    escape_inline(&alt),
227                    target.replace(' ', "%20")
228                ))
229            }
230            Content::Chart(rel_id) => {
231                let Some(rel_id) = rel_id else {
232                    return description.map(|text| format!("*{}*", escape_inline(&text)));
233                };
234                match self.chart(rel_id) {
235                    Ok(chart) => Some(chart_markdown(&chart)).filter(|text| !text.is_empty()),
236                    Err(err) => {
237                        report.failed_frames.push((self.index, err.to_string()));
238                        None
239                    }
240                }
241            }
242            Content::Diagram(rel_id) => {
243                let Some(rel_id) = rel_id else {
244                    return description.map(|text| format!("*{}*", escape_inline(&text)));
245                };
246                match self.diagram(rel_id) {
247                    Ok(diagram) => {
248                        let lines: Vec<String> = diagram
249                            .items
250                            .iter()
251                            .map(|item| {
252                                format!(
253                                    "{}- {}",
254                                    "  ".repeat(usize::from(item.level)),
255                                    escape_inline(&collapse_whitespace(&item.text))
256                                )
257                            })
258                            .collect();
259                        (!lines.is_empty()).then(|| lines.join("\n"))
260                    }
261                    Err(err) => {
262                        report.failed_frames.push((self.index, err.to_string()));
263                        None
264                    }
265                }
266            }
267            Content::Ole(ole) => {
268                let label = match (&description, &ole.prog_id) {
269                    (Some(text), _) => text.clone(),
270                    (None, Some(prog_id)) => format!("Embedded object ({prog_id})"),
271                    (None, None) => "Embedded object".to_string(),
272                };
273                Some(format!("*{}*", escape_inline(&label)))
274            }
275            Content::Group(..) | Content::Connector => None,
276            Content::ContentPart(_) | Content::UnknownGraphic(_) => {
277                description.map(|text| format!("*{}*", escape_inline(&text)))
278            }
279        }
280    }
281}
282
283/// Paragraphs as bullets or plain paragraphs. Paragraphs that inherit
284/// their bullet from the list style count as bullets inside body
285/// placeholders and as plain text elsewhere.
286fn body_markdown(body: &TextBody, in_list: bool, rels: Option<&Relationships>) -> String {
287    let mut out = String::new();
288    let mut previous_was_list = false;
289    for paragraph in body.paragraphs.iter().filter(|p| !p.is_empty()) {
290        let marker = match &paragraph.bullet {
291            Bullet::Char(_) | Bullet::Picture => Some("- "),
292            Bullet::AutoNumber { .. } => Some("1. "),
293            Bullet::None => None,
294            Bullet::Inherited => in_list.then_some("- "),
295        };
296        let inline = paragraph_markdown(paragraph, rels, marker.is_some(), paragraph.level);
297        match marker {
298            Some(marker) => {
299                if !out.is_empty() {
300                    out.push('\n');
301                    if !previous_was_list {
302                        out.push('\n');
303                    }
304                }
305                out.push_str(&"  ".repeat(usize::from(paragraph.level)));
306                out.push_str(marker);
307                out.push_str(&inline);
308                previous_was_list = true;
309            }
310            None => {
311                if !out.is_empty() {
312                    out.push_str("\n\n");
313                }
314                out.push_str(&inline);
315                previous_was_list = false;
316            }
317        }
318    }
319    out
320}
321
322/// The runs of a paragraph with bold, italic and links; adjacent runs with
323/// the same formatting are merged so markers never touch.
324fn paragraph_markdown(
325    paragraph: &Paragraph,
326    rels: Option<&Relationships>,
327    in_list: bool,
328    level: u8,
329) -> String {
330    let mut segments: Vec<(bool, bool, Option<String>, String)> = Vec::new();
331    for run in &paragraph.runs {
332        if run.kind == RunKind::LineBreak {
333            segments.push((false, false, None, "\n".to_string()));
334            continue;
335        }
336        if run.text.is_empty() {
337            continue;
338        }
339        let bold = run.props.bold == Some(true);
340        let italic = run.props.italic == Some(true);
341        let link = run
342            .props
343            .hyperlink
344            .as_deref()
345            .and_then(|id| rels.and_then(|rels| rels.get(id)))
346            .filter(|rel| rel.mode == TargetMode::External)
347            .map(|rel| rel.target.clone());
348        match segments.last_mut() {
349            Some((b, i, l, text)) if *b == bold && *i == italic && *l == link && text != "\n" => {
350                text.push_str(&run.text)
351            }
352            _ => segments.push((bold, italic, link, run.text.clone())),
353        }
354    }
355    let mut out = String::new();
356    for (bold, italic, link, text) in segments {
357        if text == "\n" {
358            out.push_str("  \n");
359            if in_list {
360                out.push_str(&"  ".repeat(usize::from(level) + 1));
361            }
362            continue;
363        }
364        let leading = text.len() - text.trim_start().len();
365        let trailing = text.len() - text.trim_end().len();
366        let core = text.trim();
367        if core.is_empty() {
368            out.push_str(&text);
369            continue;
370        }
371        out.push_str(&text[..leading]);
372        let mut piece = escape_inline(core);
373        if bold {
374            piece = format!("**{piece}**");
375        }
376        if italic {
377            piece = format!("*{piece}*");
378        }
379        if let Some(url) = link {
380            piece = format!("[{piece}]({})", url.replace(' ', "%20"));
381        }
382        out.push_str(&piece);
383        out.push_str(&text[text.len() - trailing..]);
384    }
385    let line_start = out.trim_start();
386    let needs_guard = !in_list
387        && (line_start.starts_with(['#', '>', '-', '+'])
388            || line_start
389                .split_once(['.', ')'])
390                .is_some_and(|(digits, _)| {
391                    !digits.is_empty() && digits.bytes().all(|b| b.is_ascii_digit())
392                }));
393    match needs_guard {
394        true => format!("\\{}", out.trim_start()),
395        false => out,
396    }
397}
398
399/// A GFM table; the first row is the header. Merged-away cells stay as
400/// empty cells so every row keeps the column count.
401fn table_markdown(table: &Table) -> Option<String> {
402    let rows: Vec<Vec<String>> = table
403        .rows
404        .iter()
405        .map(|row| {
406            row.cells
407                .iter()
408                .map(|cell| match cell.is_origin() {
409                    true => cell_text(&cell.body),
410                    false => String::new(),
411                })
412                .collect()
413        })
414        .collect();
415    gfm_table(&rows)
416}
417
418fn cell_text(body: &TextBody) -> String {
419    body.paragraphs
420        .iter()
421        .filter(|p| !p.is_empty())
422        .map(|p| escape_cell(&collapse_whitespace(&p.text())))
423        .collect::<Vec<_>>()
424        .join("<br>")
425}
426
427fn gfm_table(rows: &[Vec<String>]) -> Option<String> {
428    let width = rows.iter().map(Vec::len).max().unwrap_or(0);
429    if width == 0 || rows.iter().all(|row| row.iter().all(String::is_empty)) {
430        return None;
431    }
432    let line = |row: &[String]| {
433        let mut cells: Vec<&str> = row.iter().map(String::as_str).collect();
434        cells.resize(width, "");
435        format!("| {} |", cells.join(" | "))
436    };
437    let mut out = line(&rows[0]);
438    out.push('\n');
439    out.push_str(&format!("|{}", "---|".repeat(width)));
440    for row in &rows[1..] {
441        out.push('\n');
442        out.push_str(&line(row));
443    }
444    Some(out)
445}
446
447/// `**Chart: title**` and the chart's rows as a table.
448fn chart_markdown(chart: &ChartData) -> String {
449    let mut blocks = Vec::new();
450    match &chart.title {
451        Some(title) => blocks.push(format!(
452            "**Chart: {}**",
453            escape_inline(&collapse_whitespace(title))
454        )),
455        None if !chart.series.is_empty() => blocks.push("**Chart**".to_string()),
456        None => {}
457    }
458    let mut rows: Vec<Vec<String>> = chart
459        .rows()
460        .into_iter()
461        .map(|row| row.iter().map(|cell| escape_cell(cell)).collect())
462        .collect();
463    let has_header = chart.shares_categories() && chart.series.iter().any(|s| s.name.is_some());
464    if !rows.is_empty() && !has_header {
465        let width = rows.iter().map(Vec::len).max().unwrap_or(0);
466        rows.insert(0, vec![String::new(); width]);
467    }
468    if let Some(table) = gfm_table(&rows) {
469        blocks.push(table);
470    }
471    blocks.join("\n\n")
472}
473
474fn collapse_whitespace(text: &str) -> String {
475    text.split_whitespace().collect::<Vec<_>>().join(" ")
476}
477
478/// Escapes the punctuation Markdown would otherwise interpret inside text.
479fn escape_inline(text: &str) -> String {
480    let mut out = String::with_capacity(text.len());
481    for ch in text.chars() {
482        if matches!(ch, '\\' | '*' | '_' | '`' | '[' | ']' | '<' | '>') {
483            out.push('\\');
484        }
485        out.push(ch);
486    }
487    out
488}
489
490fn escape_cell(text: &str) -> String {
491    escape_inline(text).replace('|', "\\|")
492}
493
494#[cfg(test)]
495mod tests {
496    use super::*;
497    use crate::model::{Run, RunProps};
498
499    fn run(text: &str, bold: bool, italic: bool) -> Run {
500        Run {
501            kind: RunKind::Text,
502            text: text.to_string(),
503            props: RunProps {
504                bold: Some(bold),
505                italic: Some(italic),
506                ..RunProps::default()
507            },
508        }
509    }
510
511    #[test]
512    fn runs_merge_and_wrap_in_markers() {
513        let paragraph = Paragraph {
514            level: 0,
515            bullet: Bullet::None,
516            runs: vec![
517                run("Plain ", false, false),
518                run("bo", true, false),
519                run("ld", true, false),
520                run(" and *stars*", false, true),
521            ],
522        };
523        assert_eq!(
524            paragraph_markdown(&paragraph, None, false, 0),
525            "Plain **bold** *and \\*stars\\**"
526        );
527    }
528
529    #[test]
530    fn leading_markdown_syntax_is_guarded() {
531        let paragraph = Paragraph {
532            level: 0,
533            bullet: Bullet::None,
534            runs: vec![run("- not a bullet", false, false)],
535        };
536        assert_eq!(
537            paragraph_markdown(&paragraph, None, false, 0),
538            "\\- not a bullet"
539        );
540        let numbered = Paragraph {
541            level: 0,
542            bullet: Bullet::None,
543            runs: vec![run("2024. A year", false, false)],
544        };
545        assert_eq!(
546            paragraph_markdown(&numbered, None, false, 0),
547            "\\2024. A year"
548        );
549    }
550
551    #[test]
552    fn tables_and_charts_become_gfm_tables() {
553        let rows = vec![
554            vec!["a|b".to_string(), "c".to_string()],
555            vec!["1".to_string()],
556        ];
557        assert_eq!(
558            gfm_table(&rows).unwrap(),
559            "| a|b | c |\n|---|---|\n| 1 |  |"
560        );
561        let chart = ChartData {
562            title: Some("Sales".into()),
563            series: vec![crate::chart::Series {
564                name: None,
565                categories: vec!["N".into(), "S".into()],
566                values: vec!["1".into(), "2".into()],
567            }],
568            ..ChartData::default()
569        };
570        assert_eq!(
571            chart_markdown(&chart),
572            "**Chart: Sales**\n\n|  |  |\n|---|---|\n| N | 1 |\n| S | 2 |"
573        );
574    }
575}