Skip to main content

pptx_to_md/
slide.rs

1use crate::markdown::{MarkdownContext, render_runs};
2use crate::parser_config::ImageHandlingMode;
3use crate::{
4    Bounds, ImageBlock, ImageReference, ListInfo, ListKind, MarkdownOptions, Paragraph,
5    ParseDiagnostic, ParserConfig, ReadingOrder, Result, SemanticTable, SemanticTableCell,
6    SemanticTableRow, SlideBlock, SlideBlockContent, SlideElement, TextBlock, TextRole,
7    UnsupportedBlock,
8};
9use base64::{Engine as _, engine::general_purpose};
10use image::codecs::jpeg::JpegEncoder;
11use std::collections::HashMap;
12use std::fs;
13use std::path::{Path, PathBuf};
14
15/// Encapsulates images for manual extraction of images from slides
16#[derive(Debug)]
17pub struct ManualImage {
18    pub base64_content: String,
19    pub img_ref: ImageReference,
20}
21impl ManualImage {
22    pub fn new(base64_content: String, img_ref: ImageReference) -> ManualImage {
23        Self {
24            base64_content,
25            img_ref,
26        }
27    }
28}
29/// Represents a single slide extracted from a PowerPoint (pptx) file.
30///
31/// Contains structured slide data including slide number, parsed content elements
32/// (text, tables, images, lists), speaker notes, and associated image references.
33///
34/// A `Slide` can be converted into other formats, such as Markdown, or its
35/// contained images can be extracted in base64 representation.
36///
37/// Typically, you retrieve instances of `Slide` through [`PptxContainer::parse()`].
38#[derive(Debug)]
39pub struct Slide {
40    pub rel_path: String,
41    pub slide_number: u32,
42    pub elements: Vec<SlideElement>,
43    pub speaker_notes: Vec<crate::TextElement>,
44    pub comments: Vec<crate::TextElement>,
45    pub images: Vec<ImageReference>,
46    pub image_data: HashMap<String, Vec<u8>>,
47    pub config: ParserConfig,
48    pub blocks: Vec<SlideBlock>,
49    pub diagnostics: Vec<ParseDiagnostic>,
50}
51
52impl Slide {
53    #[allow(clippy::too_many_arguments)]
54    pub fn new(
55        rel_path: String,
56        slide_number: u32,
57        elements: Vec<SlideElement>,
58        speaker_notes: Vec<crate::TextElement>,
59        comments: Vec<crate::TextElement>,
60        images: Vec<ImageReference>,
61        image_data: HashMap<String, Vec<u8>>,
62        config: ParserConfig,
63    ) -> Self {
64        let blocks = legacy_blocks(&elements);
65        Self {
66            rel_path,
67            slide_number,
68            elements,
69            speaker_notes,
70            comments,
71            images,
72            image_data,
73            config,
74            blocks,
75            diagnostics: Vec::new(),
76        }
77    }
78
79    #[allow(clippy::too_many_arguments)]
80    pub fn new_semantic(
81        rel_path: String,
82        slide_number: u32,
83        elements: Vec<SlideElement>,
84        blocks: Vec<SlideBlock>,
85        speaker_notes: Vec<crate::TextElement>,
86        comments: Vec<crate::TextElement>,
87        images: Vec<ImageReference>,
88        image_data: HashMap<String, Vec<u8>>,
89        config: ParserConfig,
90        diagnostics: Vec<ParseDiagnostic>,
91    ) -> Self {
92        Self {
93            rel_path,
94            slide_number,
95            elements,
96            speaker_notes,
97            comments,
98            images,
99            image_data,
100            config,
101            blocks,
102            diagnostics,
103        }
104    }
105
106    /// Converts slide contents into a Markdown formatted string.
107    ///
108    /// Translates internal slide elements (text, tables, lists, images) to valid
109    /// and readable Markdown. Embedded images will be encoded as base64 inline images.
110    ///
111    /// # Returns
112    ///
113    /// Returns an `Option<String>`:
114    /// - `Some(String)`: Markdown representation of slide if conversion succeeds.
115    /// - `None`: If a conversion error occurs during image encoding.
116    pub fn convert_to_md(&self) -> Result<String> {
117        let options = MarkdownOptions {
118            include_slide_number_as_comment: self.config.include_slide_number_as_comment,
119            include_speaker_notes: self.config.include_speaker_notes,
120            include_comments: self.config.include_comments,
121            ..MarkdownOptions::default()
122        };
123        self.to_markdown(&options)
124    }
125
126    pub fn to_markdown(&self, options: &MarkdownOptions) -> Result<String> {
127        let mut slide_txt = String::new();
128        if options.include_slide_number_as_comment {
129            slide_txt.push_str(format!("<!-- Slide {} -->\n\n", self.slide_number).as_str());
130        }
131        let mut image_count = 0;
132        let fallback_blocks;
133        let blocks = if self.blocks.is_empty() {
134            fallback_blocks = legacy_blocks(&self.elements);
135            &fallback_blocks
136        } else {
137            &self.blocks
138        };
139
140        for block in ordered_blocks(blocks, options.reading_order) {
141            match &block.content {
142                SlideBlockContent::Text(text) => {
143                    render_text_block(&mut slide_txt, text);
144                    if !slide_txt.ends_with("\n\n") {
145                        slide_txt.push('\n');
146                    }
147                }
148                SlideBlockContent::Table(table) => render_table(&mut slide_txt, table),
149                SlideBlockContent::Image(image) => {
150                    let image_ref = &image.reference;
151                    match self.config.image_handling_mode {
152                        ImageHandlingMode::InMarkdown => {
153                            if let Some(image_data) = self.image_data.get(&image_ref.id) {
154                                let image_data = if self.config.compress_images {
155                                    self.compress_image(image_data)
156                                } else {
157                                    Some(image_data.clone())
158                                };
159
160                                let Some(image_data) = image_data else {
161                                    slide_txt.push_str(&missing_image_markdown(image));
162                                    continue;
163                                };
164                                let base64_string = general_purpose::STANDARD.encode(image_data);
165                                let image_name =
166                                    image_ref.target.split('/').next_back().unwrap_or("image");
167                                let file_ext = image
168                                    .mime_type
169                                    .as_deref()
170                                    .and_then(|mime| mime.split('/').next_back())
171                                    .or_else(|| image_name.rsplit('.').next())
172                                    .unwrap_or("bin");
173                                let alt = image.alt_text.as_deref().unwrap_or(image_name);
174
175                                slide_txt.push_str(
176                                    format!(
177                                        "![{}](data:image/{};base64,{})",
178                                        alt, file_ext, base64_string
179                                    )
180                                    .as_str(),
181                                );
182                            } else {
183                                slide_txt.push_str(&missing_image_markdown(image));
184                            }
185                        }
186                        ImageHandlingMode::Save => {
187                            if let Some(image_data) = self.image_data.get(&image_ref.id) {
188                                let image_data = if self.config.compress_images {
189                                    self.compress_image(image_data)
190                                } else {
191                                    Some(image_data.clone())
192                                };
193
194                                let ext = if self.config.compress_images {
195                                    "jpg".to_string()
196                                } else {
197                                    self.get_image_extension(&image_ref.target)
198                                };
199
200                                let output_dir = self
201                                    .config
202                                    .image_output_path
203                                    .clone()
204                                    .unwrap_or_else(|| PathBuf::from("."));
205
206                                fs::create_dir_all(&output_dir)?;
207
208                                let mut image_path = output_dir.clone();
209                                let file_name = format!(
210                                    "slide{}_image{}_{}.{}",
211                                    self.slide_number,
212                                    image_count + 1,
213                                    &image_ref.id,
214                                    ext
215                                );
216                                image_path.push(&file_name);
217
218                                let Some(image_data) = image_data else {
219                                    slide_txt.push_str(&missing_image_markdown(image));
220                                    continue;
221                                };
222                                fs::write(&image_path, image_data)?;
223
224                                let abs_file_url = self.path_to_file_url(&image_path);
225                                let Some(abs_file_url) = abs_file_url else {
226                                    slide_txt.push_str(&missing_image_markdown(image));
227                                    continue;
228                                };
229                                let alt = image.alt_text.as_deref().unwrap_or(&file_name);
230                                let html_link = format!("![{alt}]({abs_file_url})");
231                                image_count += 1;
232                                slide_txt.push_str(&html_link);
233                                slide_txt.push('\n');
234                            } else {
235                                slide_txt.push_str(&missing_image_markdown(image));
236                            }
237                        }
238                        ImageHandlingMode::Manually => {
239                            slide_txt.push('\n');
240                            continue;
241                        }
242                    }
243                    slide_txt.push('\n');
244                }
245                SlideBlockContent::Unsupported(unsupported) => {
246                    if let Some(text) = &unsupported.fallback_text {
247                        slide_txt.push_str(text);
248                        slide_txt.push_str("\n\n");
249                    }
250                    if options.render_unsupported_comments {
251                        slide_txt.push_str(&format!(
252                            "<!-- Unsupported slide element: {} -->\n\n",
253                            unsupported.kind.replace("--", "—")
254                        ));
255                    }
256                }
257            }
258        }
259        if options.include_speaker_notes && !self.speaker_notes.is_empty() {
260            append_quoted_section(&mut slide_txt, "Speaker Notes", &self.speaker_notes);
261        }
262        if options.include_comments && !self.comments.is_empty() {
263            append_quoted_section(&mut slide_txt, "Comments", &self.comments);
264        }
265        Ok(slide_txt)
266    }
267
268    /// Extracts the numeric slide identifier from a slide path.
269    ///
270    /// Helper method to parse slide numbers from internal pptx
271    /// slide paths (e.g., "ppt/slides/slide1.xml" → `1`).
272    pub fn extract_slide_number(path: &str) -> Option<u32> {
273        path.split('/')
274            .next_back()
275            .and_then(|filename| {
276                filename
277                    .strip_prefix("slide")
278                    .and_then(|s| s.strip_suffix(".xml"))
279            })
280            .and_then(|num_str| num_str.parse::<u32>().ok())
281    }
282
283    /// Links slide images references with their corresponding targets.
284    ///
285    /// Ensures that each image referenced by its ID is correctly
286    /// linked to the actual internal resource paths stored in the slide.
287    /// This method is typically used internally after parsing a slide
288    ///
289    /// # Notes
290    ///
291    /// Internally those are the values image references are holding
292    ///
293    /// | Parameter | Example value         |
294    /// |---------- |---------------------- |
295    /// | `id`      | *rId2*                |
296    /// | `target`  | *../media/image2.png* |
297    ///
298    pub fn link_images(&mut self) {
299        let id_to_target: HashMap<String, String> = self
300            .images
301            .iter()
302            .map(|img_ref| (img_ref.id.clone(), img_ref.target.clone()))
303            .collect();
304
305        for element in &mut self.elements {
306            if let SlideElement::Image(img_ref, _pos) = element
307                && let Some(target) = id_to_target.get(&img_ref.id)
308            {
309                img_ref.target = target.clone();
310            }
311        }
312        for block in &mut self.blocks {
313            if let SlideBlockContent::Image(image) = &mut block.content
314                && let Some(target) = id_to_target.get(&image.reference.id)
315            {
316                image.reference.target = target.clone();
317                image.mime_type = mime_type_from_path(target).map(str::to_string);
318            } else if let SlideBlockContent::Image(image) = &mut block.content {
319                image.mime_type = mime_type_from_path(&image.reference.target).map(str::to_string);
320            }
321        }
322    }
323
324    /// Extracts the file extension from image paths
325    pub fn get_image_extension(&self, path: &str) -> String {
326        Path::new(path)
327            .extension()
328            .and_then(|ext| ext.to_str())
329            .unwrap_or("bin")
330            .to_string()
331    }
332
333    /// Compresses the image data and returning it as a `jpg` byte slice
334    ///
335    /// # Parameter
336    ///
337    /// - `image_data`: The raw image data as a byte array
338    ///
339    /// # Returns
340    ///
341    /// - `Vec<u8>`: Returns the compressed and converted jpg byte array
342    ///
343    /// # Notes
344    ///
345    /// All images will be converted to `jpg`
346    pub fn compress_image(&self, image_data: &[u8]) -> Option<Vec<u8>> {
347        let img = match image::load_from_memory(image_data) {
348            Ok(image) => image,
349            Err(_) => return None,
350        };
351
352        let mut output = Vec::new();
353        let quality = self.config.quality;
354
355        if JpegEncoder::new_with_quality(&mut output, quality)
356            .encode_image(&img)
357            .is_ok()
358        {
359            Some(output)
360        } else {
361            None
362        }
363    }
364
365    pub fn load_images_manually(&self) -> Option<Vec<ManualImage>> {
366        let mut images: Vec<ManualImage> = Vec::new();
367
368        let image_refs: Vec<&ImageReference> = self
369            .elements
370            .iter()
371            .filter_map(|element| match element {
372                SlideElement::Image(img, _pos) => Some(img),
373                _ => None,
374            })
375            .collect();
376
377        for image_ref in image_refs {
378            if let Some(image_data) = self.image_data.get(&image_ref.id) {
379                let image_data = if self.config.compress_images {
380                    self.compress_image(image_data)
381                } else {
382                    Some(image_data.clone())
383                };
384
385                let base64_str = general_purpose::STANDARD.encode(image_data?);
386
387                let image = ManualImage::new(base64_str, image_ref.clone());
388                images.push(image);
389            }
390        }
391
392        Some(images)
393    }
394
395    fn path_to_file_url(&self, path: &Path) -> Option<String> {
396        let abs_path = path.canonicalize().ok()?;
397        let mut path_str = abs_path.to_string_lossy().replace('\\', "/");
398
399        // remove windows unc prefix
400        if cfg!(windows) {
401            if let Some(stripped) = path_str.strip_prefix("//?/") {
402                path_str = stripped.to_string();
403            }
404            Some(format!("file:///{}", path_str))
405        } else {
406            Some(format!("file://{}", path_str))
407        }
408    }
409}
410
411pub(crate) fn legacy_blocks(elements: &[SlideElement]) -> Vec<SlideBlock> {
412    elements
413        .iter()
414        .enumerate()
415        .map(|(source_order, element)| legacy_block(element, source_order))
416        .collect()
417}
418
419pub(crate) fn legacy_block(element: &SlideElement, source_order: usize) -> SlideBlock {
420    let (bounds, content) = match element {
421        SlideElement::Text(text, position) => (
422            (*position).into(),
423            SlideBlockContent::Text(TextBlock {
424                role: TextRole::Other,
425                paragraphs: vec![Paragraph::plain(text.runs.clone())],
426            }),
427        ),
428        SlideElement::List(list, position) => (
429            (*position).into(),
430            SlideBlockContent::Text(TextBlock {
431                role: TextRole::Body,
432                paragraphs: list
433                    .items
434                    .iter()
435                    .map(|item| Paragraph {
436                        runs: item.runs.clone(),
437                        alignment: Default::default(),
438                        list: Some(ListInfo {
439                            level: item.level,
440                            kind: if item.is_ordered {
441                                ListKind::Ordered {
442                                    style: None,
443                                    start: 1,
444                                }
445                            } else {
446                                ListKind::Bullet { character: None }
447                            },
448                        }),
449                        list_explicit: true,
450                    })
451                    .collect(),
452            }),
453        ),
454        SlideElement::Table(table, position) => (
455            (*position).into(),
456            SlideBlockContent::Table(SemanticTable {
457                rows: table
458                    .rows
459                    .iter()
460                    .map(|row| SemanticTableRow {
461                        cells: row
462                            .cells
463                            .iter()
464                            .map(|cell| SemanticTableCell {
465                                paragraphs: vec![Paragraph::plain(cell.runs.clone())],
466                                row_span: 1,
467                                column_span: 1,
468                                covered: false,
469                            })
470                            .collect(),
471                    })
472                    .collect(),
473            }),
474        ),
475        SlideElement::Image(image, position) => (
476            (*position).into(),
477            SlideBlockContent::Image(ImageBlock {
478                reference: image.clone(),
479                alt_text: None,
480                mime_type: None,
481            }),
482        ),
483        SlideElement::Unknown => (
484            Bounds::default(),
485            SlideBlockContent::Unsupported(UnsupportedBlock {
486                kind: "unknown".to_string(),
487                fallback_text: None,
488            }),
489        ),
490    };
491    SlideBlock {
492        bounds,
493        source_order,
494        content,
495    }
496}
497
498fn ordered_blocks(blocks: &[SlideBlock], reading_order: ReadingOrder) -> Vec<&SlideBlock> {
499    if reading_order == ReadingOrder::Source {
500        let mut ordered: Vec<_> = blocks
501            .iter()
502            .filter(|block| !block_is_semantically_empty(block))
503            .collect();
504        ordered.sort_by_key(|block| block.source_order);
505        return ordered;
506    }
507
508    let has_dimensions = blocks
509        .iter()
510        .any(|block| block.bounds.width > 0 || block.bounds.height > 0);
511    if !has_dimensions {
512        let mut ordered: Vec<_> = blocks
513            .iter()
514            .filter(|block| !block_is_semantically_empty(block))
515            .collect();
516        ordered.sort_by_key(|block| {
517            (
518                role_priority(block),
519                block.bounds.y,
520                block.bounds.x,
521                block.source_order,
522            )
523        });
524        return ordered;
525    }
526
527    let mut ordered = Vec::with_capacity(blocks.len());
528    let mut remaining: Vec<_> = blocks
529        .iter()
530        .filter(|block| !block_is_semantically_empty(block))
531        .collect();
532    remaining.sort_by_key(|block| block.source_order);
533    for priority in [0, 1] {
534        let mut index = 0;
535        while index < remaining.len() {
536            if role_priority(remaining[index]) == priority {
537                ordered.push(remaining.remove(index));
538            } else {
539                index += 1;
540            }
541        }
542    }
543
544    let left = remaining
545        .iter()
546        .map(|block| block.bounds.x)
547        .min()
548        .unwrap_or(0);
549    let right = remaining
550        .iter()
551        .map(|block| block.bounds.x + block.bounds.width)
552        .max()
553        .unwrap_or(left);
554    let page_width = (right - left).max(1);
555    let mut separators: Vec<_> = remaining
556        .iter()
557        .copied()
558        .filter(|block| block.bounds.width * 100 >= page_width * 65)
559        .collect();
560    separators.sort_by_key(|block| (block.bounds.y, block.source_order));
561
562    let mut last_y = i64::MIN;
563    for separator in separators {
564        let mut band: Vec<_> = remaining
565            .iter()
566            .copied()
567            .filter(|block| {
568                !std::ptr::eq(*block, separator)
569                    && block.bounds.y >= last_y
570                    && block.bounds.y < separator.bounds.y
571            })
572            .collect();
573        sort_spatial_band(&mut band);
574        ordered.extend(band);
575        ordered.push(separator);
576        last_y = separator
577            .bounds
578            .y
579            .saturating_add(separator.bounds.height.max(1));
580    }
581    let mut tail: Vec<_> = remaining
582        .into_iter()
583        .filter(|block| {
584            block.bounds.y >= last_y && !ordered.iter().any(|item| std::ptr::eq(*item, *block))
585        })
586        .collect();
587    sort_spatial_band(&mut tail);
588    ordered.extend(tail);
589    ordered
590}
591
592fn role_priority(block: &SlideBlock) -> u8 {
593    match &block.content {
594        SlideBlockContent::Text(TextBlock {
595            role: TextRole::Title,
596            ..
597        }) => 0,
598        SlideBlockContent::Text(TextBlock {
599            role: TextRole::Subtitle,
600            ..
601        }) => 1,
602        _ => 2,
603    }
604}
605
606fn block_is_semantically_empty(block: &SlideBlock) -> bool {
607    matches!(
608        &block.content,
609        SlideBlockContent::Text(text)
610            if text
611                .paragraphs
612                .iter()
613                .all(|paragraph| paragraph.runs.iter().all(|run| run.text.is_empty()))
614    )
615}
616
617fn sort_spatial_band(blocks: &mut Vec<&SlideBlock>) {
618    blocks.sort_by_key(|block| (block.bounds.x, block.bounds.y, block.source_order));
619}
620
621fn render_text_block(output: &mut String, text: &TextBlock) {
622    let mut counters: HashMap<u32, u32> = HashMap::new();
623    for (index, paragraph) in text.paragraphs.iter().enumerate() {
624        let context = if paragraph.list.is_some() {
625            MarkdownContext::ListItem
626        } else {
627            MarkdownContext::Flow
628        };
629        let mut rendered = render_runs(&paragraph.runs, context);
630        if context == MarkdownContext::Flow || context == MarkdownContext::Quote {
631            if rendered.ends_with('\n') {
632                rendered.pop();
633            }
634        } else if rendered.ends_with("<br>") {
635            rendered.truncate(rendered.len() - "<br>".len());
636        }
637        if let Some(list) = &paragraph.list {
638            counters.retain(|level, _| *level <= list.level);
639            let indent = "\t".repeat(list.level as usize);
640            let marker = match &list.kind {
641                ListKind::Bullet { .. } => "- ".to_string(),
642                ListKind::Ordered { start, .. } => {
643                    let counter = counters.entry(list.level).or_insert(*start);
644                    let marker = format!("{}. ", *counter);
645                    *counter += 1;
646                    marker
647                }
648            };
649            output.push_str(&indent);
650            output.push_str(&marker);
651            output.push_str(&rendered);
652            output.push('\n');
653            continue;
654        }
655
656        counters.clear();
657        let prefix = match text.role {
658            TextRole::Title => "## ",
659            TextRole::Heading => "### ",
660            _ => "",
661        };
662        if text.role == TextRole::Subtitle && !rendered.is_empty() {
663            output.push('_');
664            output.push_str(&rendered);
665            output.push('_');
666        } else {
667            output.push_str(prefix);
668            output.push_str(&rendered);
669        }
670        if index + 1 < text.paragraphs.len() {
671            output.push_str("\n\n");
672        } else {
673            output.push('\n');
674        }
675    }
676}
677
678fn render_table(output: &mut String, table: &SemanticTable) {
679    let complex = table.rows.iter().flat_map(|row| &row.cells).any(|cell| {
680        cell.row_span > 1 || cell.column_span > 1 || cell.covered || cell.paragraphs.len() > 1
681    });
682    if complex {
683        output.push_str("<table>\n");
684        for row in &table.rows {
685            output.push_str("  <tr>");
686            for cell in &row.cells {
687                if cell.covered {
688                    continue;
689                }
690                let mut attributes = String::new();
691                if cell.row_span > 1 {
692                    attributes.push_str(&format!(" rowspan=\"{}\"", cell.row_span));
693                }
694                if cell.column_span > 1 {
695                    attributes.push_str(&format!(" colspan=\"{}\"", cell.column_span));
696                }
697                let value = cell
698                    .paragraphs
699                    .iter()
700                    .map(|paragraph| render_runs(&paragraph.runs, MarkdownContext::TableCell))
701                    .collect::<Vec<_>>()
702                    .join("<br>");
703                output.push_str(&format!("<td{attributes}>{value}</td>"));
704            }
705            output.push_str("</tr>\n");
706        }
707        output.push_str("</table>\n\n");
708        return;
709    }
710
711    for (row_index, row) in table.rows.iter().enumerate() {
712        let cells = row
713            .cells
714            .iter()
715            .map(|cell| {
716                cell.paragraphs
717                    .iter()
718                    .map(|paragraph| render_runs(&paragraph.runs, MarkdownContext::TableCell))
719                    .collect::<Vec<_>>()
720                    .join("<br>")
721            })
722            .collect::<Vec<_>>();
723        output.push_str(&format!("| {} |\n", cells.join(" | ")));
724        if row_index == 0 {
725            output.push_str(&format!("|{}|\n", vec![" --- "; cells.len()].join("|")));
726        }
727    }
728    output.push('\n');
729}
730
731fn missing_image_markdown(image: &ImageBlock) -> String {
732    let label = image
733        .alt_text
734        .as_deref()
735        .or_else(|| image.reference.target.split('/').next_back())
736        .unwrap_or("image");
737    format!("[Image unavailable: {label}]")
738}
739
740fn mime_type_from_path(path: &str) -> Option<&'static str> {
741    match Path::new(path)
742        .extension()
743        .and_then(|extension| extension.to_str())?
744        .to_ascii_lowercase()
745        .as_str()
746    {
747        "jpg" | "jpeg" => Some("image/jpeg"),
748        "png" => Some("image/png"),
749        "gif" => Some("image/gif"),
750        "svg" => Some("image/svg+xml"),
751        "webp" => Some("image/webp"),
752        "bmp" => Some("image/bmp"),
753        "tif" | "tiff" => Some("image/tiff"),
754        _ => None,
755    }
756}
757
758fn append_quoted_section(output: &mut String, title: &str, elements: &[crate::TextElement]) {
759    if !output.is_empty() && !output.ends_with("\n\n") {
760        output.push('\n');
761    }
762    output.push_str(&format!("> **{}**\n>\n", title));
763    for (index, element) in elements.iter().enumerate() {
764        let content = render_runs(&element.runs, MarkdownContext::Quote);
765        for line in content.lines() {
766            output.push_str("> ");
767            output.push_str(line);
768            output.push('\n');
769        }
770        if index + 1 < elements.len() {
771            output.push_str(">\n");
772        }
773    }
774}
775
776#[cfg(test)]
777#[path = "../tests/unit/slide.rs"]
778mod tests;