1use 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#[derive(Clone, Debug, PartialEq, Eq)]
13pub struct MarkdownOptions {
14 pub headings: bool,
16 pub notes: bool,
18 pub comments: bool,
20 pub hidden_slides: bool,
22 pub hidden_shapes: bool,
24 pub furniture: bool,
26 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 pub fn markdown(&self, options: &MarkdownOptions) -> (String, ExtractReport) {
47 self.markdown_at(&self.all_slides(), options)
48 }
49
50 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 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(¶graph.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
283fn 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 ¶graph.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
322fn 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 ¶graph.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
399fn 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
447fn 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
478fn 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(¶graph, 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(¶graph, 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}