Skip to main content

pptx_edit/structured/
markdown.rs

1//! Markdown rendering of structured content: slide sections, title placeholders as headings,
2//! paragraphs, literal list markers, tables, placeholders for unrepresented objects, and notes
3//! and comments when exported. Tables Markdown cannot express are rendered as HTML, whose text is
4//! entity-escaped rather than Markdown-escaped. Nothing is evaluated or fetched.
5
6use std::collections::{BTreeSet, HashSet};
7use std::fmt::Write as _;
8
9use super::*;
10
11/// Renders `content` as Markdown within `options.max_bytes`, stopping at a block boundary.
12pub fn render_pptx_markdown(
13    content: &PptxStructuredContent,
14    options: &PptxMarkdownOptions,
15) -> Result<PptxMarkdownContent, ExportFailure> {
16    render(content, options).map(|(rendered, _)| rendered)
17}
18
19/// The rendering and the bytes of block text it built on the way.
20fn render(
21    content: &PptxStructuredContent,
22    options: &PptxMarkdownOptions,
23) -> Result<(PptxMarkdownContent, usize), ExportFailure> {
24    let max_bytes = byte_limit(options.max_bytes)?;
25    validate(content)?;
26    let mut renderer = Renderer {
27        output: String::new(),
28        anchors: Vec::new(),
29        lossy: BTreeSet::new(),
30        omitted: Vec::new(),
31        max_bytes,
32        truncated: false,
33        in_list: false,
34        cut: false,
35        built: 0,
36    };
37    'slides: for slide in &content.slides {
38        if !renderer.slide(slide) {
39            break;
40        }
41        for shape in &slide.shapes {
42            if !renderer.shape(shape) {
43                break 'slides;
44            }
45        }
46        if let Some(notes) = &slide.notes
47            && !renderer.notes(notes)
48        {
49            break;
50        }
51        for (index, comment) in slide.comments.iter().enumerate() {
52            if !renderer.comment(comment, index == 0) {
53                break 'slides;
54            }
55        }
56        renderer.end_list();
57    }
58    let mut diagnostics = content.diagnostics.clone();
59    diagnostics.extend(renderer.omitted);
60    for message in &renderer.lossy {
61        diagnostics.push(ExportDiagnostic {
62            code: ExportDiagnosticCode::MarkdownLossy,
63            severity: ExportSeverity::Info,
64            anchor: None,
65            message: (*message).to_owned(),
66        });
67    }
68    if renderer.truncated {
69        diagnostics.push(ExportDiagnostic {
70            code: ExportDiagnosticCode::Truncated,
71            severity: ExportSeverity::Warning,
72            anchor: None,
73            message:
74                "The Markdown stopped at its byte limit; content after this point is not included."
75                    .to_owned(),
76        });
77    }
78    let markdown = if renderer.output.is_empty() {
79        String::new()
80    } else {
81        renderer.output.trim_end().to_owned() + "\n"
82    };
83    Ok((
84        PptxMarkdownContent {
85            markdown,
86            anchors: renderer.anchors,
87            diagnostics,
88            truncated: content.truncated || renderer.truncated,
89        },
90        renderer.built,
91    ))
92}
93
94fn invalid(message: impl Into<String>) -> ExportFailure {
95    ExportFailure {
96        code: ExportFailureCode::InvalidContent,
97        target: None,
98        message: message.into(),
99    }
100}
101
102/// Shape nesting the renderer accepts.
103const MAX_RENDER_DEPTH: usize = 128;
104/// Records and metadata entries one rendering validates before it refuses the content.
105const MAX_RENDER_RECORDS: usize = 16 * MAX_BLOCKS_LIMIT as usize;
106/// Checks the content contract rendering relies on: the schema version, anchor kinds and owners,
107/// UTF-16 ranges that match the text they carry, paragraphs that tile their story (a truncated
108/// export may stop short), at most one formatting mark of each kind per run, unique ids, cells in
109/// column order, resolvable merge origins and a bounded count of records and metadata entries.
110fn validate(content: &PptxStructuredContent) -> Result<(), ExportFailure> {
111    if content.schema_version != SCHEMA_VERSION {
112        return Err(invalid(format!(
113            "structured content schema version {} is not supported; expected {SCHEMA_VERSION}",
114            content.schema_version
115        )));
116    }
117    let mut validator = Validator {
118        truncated: content.truncated,
119        ..Validator::default()
120    };
121    for slide in &content.slides {
122        validator.slide(slide)?;
123    }
124    for diagnostic in &content.diagnostics {
125        validator.charge(1)?;
126        if let Some(anchor) = &diagnostic.anchor {
127            if let PptxAnchor::SourcePart { path, .. } = anchor {
128                validator.charge(path.len())?;
129            }
130            ordered(anchor)?;
131        }
132    }
133    Ok(())
134}
135
136fn range_of(anchor: &PptxAnchor) -> Option<TextSpan> {
137    match anchor {
138        PptxAnchor::Range(range) => Some(TextSpan {
139            start: range.start,
140            end: range.end,
141        }),
142        PptxAnchor::Notes { range, .. } | PptxAnchor::Comment { range, .. } => Some(*range),
143        _ => None,
144    }
145}
146
147fn ordered(anchor: &PptxAnchor) -> Result<(), ExportFailure> {
148    match range_of(anchor) {
149        Some(range) if range.end < range.start => Err(invalid(format!(
150            "anchor range {}..{} ends before it starts",
151            range.start, range.end
152        ))),
153        _ => Ok(()),
154    }
155}
156
157fn units(text: &str) -> u32 {
158    text.encode_utf16().count() as u32
159}
160
161#[derive(Default)]
162struct Validator {
163    ids: HashSet<String>,
164    records: usize,
165    /// Whether the content says it stopped at its limits, so a story may end early.
166    truncated: bool,
167}
168
169impl Validator {
170    fn charge(&mut self, entries: usize) -> Result<(), ExportFailure> {
171        self.records = self.records.saturating_add(entries);
172        if self.records > MAX_RENDER_RECORDS {
173            return Err(ExportFailure {
174                code: ExportFailureCode::LimitExceeded,
175                target: None,
176                message: format!(
177                    "content may hold at most {MAX_RENDER_RECORDS} records and metadata entries"
178                ),
179            });
180        }
181        Ok(())
182    }
183
184    fn record(&mut self, id: &str) -> Result<(), ExportFailure> {
185        self.charge(1)?;
186        if !self.ids.insert(id.to_owned()) {
187            return Err(invalid(format!("id {id:?} occurs more than once")));
188        }
189        Ok(())
190    }
191
192    fn slide(&mut self, slide: &ExportSlide) -> Result<(), ExportFailure> {
193        self.record(&slide.id)?;
194        let PptxAnchor::Slide { slide_id } = &slide.anchor else {
195            return Err(invalid(format!(
196                "slide {} is not anchored to a slide",
197                slide.id
198            )));
199        };
200        for shape in &slide.shapes {
201            self.shape(shape, slide_id, 0)?;
202        }
203        if let Some(notes) = &slide.notes {
204            self.record(&notes.id)?;
205            let expected = PptxAnchor::Notes {
206                slide_id: slide_id.clone(),
207                range: TextSpan {
208                    start: 0,
209                    end: units(&notes.text),
210                },
211            };
212            if notes.anchor != expected {
213                return Err(invalid(format!(
214                    "notes {} are not anchored to their text",
215                    notes.id
216                )));
217            }
218        }
219        for comment in &slide.comments {
220            self.record(&comment.id)?;
221            let expected = PptxAnchor::Comment {
222                slide_id: slide_id.clone(),
223                comment_id: comment.comment_id.clone(),
224                range: TextSpan {
225                    start: 0,
226                    end: units(&comment.text),
227                },
228            };
229            if comment.anchor != expected {
230                return Err(invalid(format!(
231                    "comment {} is not anchored to its text",
232                    comment.id
233                )));
234            }
235        }
236        Ok(())
237    }
238
239    fn shape(
240        &mut self,
241        shape: &ExportShape,
242        slide_id: &str,
243        depth: usize,
244    ) -> Result<(), ExportFailure> {
245        self.record(&shape.id)?;
246        if depth >= MAX_RENDER_DEPTH {
247            return Err(invalid(format!(
248                "shapes nest deeper than {MAX_RENDER_DEPTH} levels"
249            )));
250        }
251        let shape_id = match (&shape.anchor, shape.kind) {
252            (PptxAnchor::SourcePart { path, .. }, ExportShapeKind::Unknown) => {
253                self.charge(path.len())?;
254                None
255            }
256            (
257                PptxAnchor::Shape {
258                    slide_id: owner,
259                    shape_id,
260                },
261                kind,
262            ) if owner == slide_id && kind != ExportShapeKind::Unknown => Some(shape_id.as_str()),
263            _ => {
264                return Err(invalid(format!(
265                    "shape {} is not anchored to itself on its slide",
266                    shape.id
267                )));
268            }
269        };
270        if let Some(object) = &shape.object {
271            self.record(&object.id)?;
272            self.charge(
273                object.relationship_ids.len() + object.parts.len() + object.external_targets.len(),
274            )?;
275        }
276        for story in &shape.stories {
277            self.story(story, slide_id, shape_id)?;
278        }
279        if let Some(table) = &shape.table {
280            self.table(table, slide_id, shape_id, &shape.anchor)?;
281        }
282        for child in &shape.children {
283            self.shape(child, slide_id, depth + 1)?;
284        }
285        Ok(())
286    }
287
288    fn table(
289        &mut self,
290        table: &ExportTable,
291        slide_id: &str,
292        shape_id: Option<&str>,
293        shape_anchor: &PptxAnchor,
294    ) -> Result<(), ExportFailure> {
295        let rows = table.rows.len() as u64;
296        let cell_at = |row: u32, column: u32| {
297            let cells = &table.rows.get(row as usize)?.cells;
298            let index = cells
299                .binary_search_by_key(&column, |cell| cell.column)
300                .ok()?;
301            cells.get(index)
302        };
303        for (row_index, row) in table.rows.iter().enumerate() {
304            for (index, cell) in row.cells.iter().enumerate() {
305                self.record(&cell.id)?;
306                let fits = cell.row as usize == row_index
307                    && index
308                        .checked_sub(1)
309                        .is_none_or(|previous| row.cells[previous].column < cell.column)
310                    && cell.column < table.columns
311                    && cell.grid_span > 0
312                    && cell.row_span > 0
313                    && u64::from(cell.column) + u64::from(cell.grid_span)
314                        <= u64::from(table.columns)
315                    && u64::from(cell.row) + u64::from(cell.row_span) <= rows;
316                let origin = match cell.merge_origin {
317                    None => true,
318                    Some(origin) => {
319                        cell.merged
320                            && cell_at(origin.row, origin.column).is_some_and(|owner| {
321                                !owner.merged
322                                    && (owner.row, owner.column) != (cell.row, cell.column)
323                                    && owner.row <= cell.row
324                                    && owner.column <= cell.column
325                                    && owner.row + owner.row_span > cell.row
326                                    && owner.column + owner.grid_span > cell.column
327                            })
328                    }
329                };
330                if !fits || !origin {
331                    return Err(invalid(format!(
332                        "table cell {} does not fit its table's grid",
333                        cell.id
334                    )));
335                }
336                match &cell.story {
337                    Some(story) => {
338                        if cell.anchor != story.anchor {
339                            return Err(invalid(format!(
340                                "table cell {} is not anchored to its story",
341                                cell.id
342                            )));
343                        }
344                        self.story(story, slide_id, shape_id)?;
345                    }
346                    None if cell.anchor != *shape_anchor => {
347                        return Err(invalid(format!(
348                            "table cell {} is not anchored to its table",
349                            cell.id
350                        )));
351                    }
352                    None => {}
353                }
354            }
355        }
356        Ok(())
357    }
358
359    fn story(
360        &mut self,
361        story: &ExportStory,
362        slide_id: &str,
363        shape_id: Option<&str>,
364    ) -> Result<(), ExportFailure> {
365        self.record(&story.id)?;
366        let text_of = |anchor: &PptxAnchor| match anchor {
367            PptxAnchor::Range(range)
368                if range.slide_id == slide_id && Some(range.shape_id.as_str()) == shape_id =>
369            {
370                Some((
371                    range.story_id.clone(),
372                    TextSpan {
373                        start: range.start,
374                        end: range.end,
375                    },
376                ))
377            }
378            _ => None,
379        };
380        let Some((story_id, span)) = text_of(&story.anchor).filter(|(_, span)| span.start == 0)
381        else {
382            return Err(invalid(format!(
383                "story {} is not anchored to its shape's text",
384                story.id
385            )));
386        };
387        let mut previous_end: Option<u32> = None;
388        for paragraph in &story.paragraphs {
389            self.record(&paragraph.id)?;
390            let range = match text_of(&paragraph.anchor) {
391                Some((id, range)) if id == story_id => range,
392                _ => {
393                    return Err(invalid(format!(
394                        "paragraph {} is not anchored in its story",
395                        paragraph.id
396                    )));
397                }
398            };
399            let expected_start = previous_end.map_or(0, |end| u64::from(end) + 1);
400            if range.start > range.end
401                || range.end > span.end
402                || u64::from(range.start) != expected_start
403            {
404                return Err(invalid(format!(
405                    "paragraph {} is out of order or outside its story",
406                    paragraph.id
407                )));
408            }
409            previous_end = Some(range.end);
410            let mut at = range.start;
411            self.charge(paragraph.runs.len())?;
412            for run in &paragraph.runs {
413                if run.marks.as_deref().is_some_and(repeats_a_mark) {
414                    return Err(invalid(format!(
415                        "a run of paragraph {} carries a formatting mark more than once",
416                        paragraph.id
417                    )));
418                }
419                let run_range = match text_of(&run.anchor) {
420                    Some((id, run_range)) if id == story_id => run_range,
421                    _ => {
422                        return Err(invalid(format!(
423                            "a run of paragraph {} is not anchored in its story",
424                            paragraph.id
425                        )));
426                    }
427                };
428                let length = match &run.content {
429                    ExportRunKind::Text { text } | ExportRunKind::Field { text, .. } => units(text),
430                    ExportRunKind::LineBreak => 1,
431                    ExportRunKind::Unsupported { .. } => 0,
432                };
433                if run_range.start != at
434                    || run_range.end < run_range.start
435                    || run_range.end - run_range.start != length
436                {
437                    return Err(invalid(format!(
438                        "a run of paragraph {} does not match its range",
439                        paragraph.id
440                    )));
441                }
442                at = run_range.end;
443            }
444            if at != range.end {
445                return Err(invalid(format!(
446                    "the runs of paragraph {} do not cover its range",
447                    paragraph.id
448                )));
449            }
450        }
451        if !self.truncated && previous_end.unwrap_or(0) != span.end {
452            return Err(invalid(format!(
453                "the paragraphs of story {} do not cover its text",
454                story.id
455            )));
456        }
457        Ok(())
458    }
459}
460
461/// Whether `marks` holds a kind of mark more than once.
462fn repeats_a_mark(marks: &[ExportMark]) -> bool {
463    let mut seen = 0_u8;
464    marks.iter().any(|mark| {
465        let bit = 1_u8
466            << match mark {
467                ExportMark::Bold => 0,
468                ExportMark::Italic => 1,
469                ExportMark::Underline { .. } => 2,
470                ExportMark::Superscript => 3,
471                ExportMark::Subscript => 4,
472                ExportMark::SmallCaps => 5,
473                ExportMark::AllCaps => 6,
474            };
475        let repeated = seen & bit != 0;
476        seen |= bit;
477        repeated
478    })
479}
480
481struct Renderer {
482    output: String,
483    anchors: Vec<MarkdownAnchor>,
484    lossy: BTreeSet<&'static str>,
485    omitted: Vec<ExportDiagnostic>,
486    max_bytes: usize,
487    truncated: bool,
488    in_list: bool,
489    /// Whether the block being built stopped early because it cannot fit.
490    cut: bool,
491    /// Bytes of block text built, kept or not.
492    built: usize,
493}
494
495/// Whether `ch` ends a line in Markdown or in a browser's text layout.
496fn is_line_break(ch: char) -> bool {
497    matches!(ch, '\r' | '\n' | '\u{2028}' | '\u{2029}')
498}
499
500/// Text safe inside a one-line HTML comment.
501fn comment_text(text: &str) -> String {
502    let mut output = String::with_capacity(text.len());
503    for ch in text.replace("--", "- -").chars() {
504        match ch {
505            '<' => output.push_str("&lt;"),
506            '>' => output.push_str("&gt;"),
507            ch if is_line_break(ch) => output.push(' '),
508            ch => output.push(ch),
509        }
510    }
511    output
512}
513
514/// Escapes Markdown and HTML syntax in document text and puts it on one line, including a
515/// trailing `!` that a following link would turn into an image.
516fn escape(text: &str) -> String {
517    let mut output = String::with_capacity(text.len());
518    let mut chars = text.chars().peekable();
519    while let Some(ch) = chars.next() {
520        if is_line_break(ch) {
521            output.push(' ');
522            continue;
523        }
524        let entity = ch == '&'
525            && chars
526                .peek()
527                .is_some_and(|next| next.is_ascii_alphanumeric() || *next == '#');
528        if matches!(
529            ch,
530            '\\' | '`' | '*' | '_' | '[' | ']' | '<' | '>' | '|' | '~'
531        ) || entity
532        {
533            output.push('\\');
534        }
535        output.push(ch);
536    }
537    if output.ends_with('!') {
538        output.insert(output.len() - 1, '\\');
539    }
540    output
541}
542
543/// Escapes document text for HTML element content.
544fn html_text(text: &str) -> String {
545    let mut output = String::with_capacity(text.len());
546    for ch in text.chars() {
547        match ch {
548            '&' => output.push_str("&amp;"),
549            '<' => output.push_str("&lt;"),
550            '>' => output.push_str("&gt;"),
551            ch if is_line_break(ch) => output.push(' '),
552            ch => output.push(ch),
553        }
554    }
555    output
556}
557
558/// Escapes text for an HTML attribute value.
559fn attribute(text: &str) -> String {
560    let mut output = String::with_capacity(text.len());
561    for ch in text.chars() {
562        match ch {
563            '&' => output.push_str("&amp;"),
564            '"' => output.push_str("&quot;"),
565            '\'' => output.push_str("&#39;"),
566            '<' => output.push_str("&lt;"),
567            '>' => output.push_str("&gt;"),
568            ch => output.push(ch),
569        }
570    }
571    output
572}
573
574/// Link schemes rendered as links, the same as the DOCX and XLSX renderers.
575const LINK_SCHEMES: [&str; 3] = ["http", "https", "mailto"];
576
577/// `href` when the scheme a browser would read from it is allowed.
578fn safe_href(href: &str) -> Option<&str> {
579    let effective = effective_href(href)?;
580    let (scheme, _) = effective.split_once(':')?;
581    LINK_SCHEMES.contains(&scheme).then_some(href)
582}
583
584/// Rounds of decoding after which a still-changing link target is refused.
585const MAX_DECODE_ROUNDS: usize = 8;
586
587/// All of `href` as a browser reads its scheme: character references and percent escapes
588/// decoded until stable, whitespace and controls (which browsers strip or skip) removed,
589/// lowercased. `None` when decoding does not settle within [`MAX_DECODE_ROUNDS`].
590fn effective_href(href: &str) -> Option<String> {
591    let mut current = href.to_owned();
592    for _ in 0..MAX_DECODE_ROUNDS {
593        let decoded = percent_decoded(&references_decoded(&current));
594        if decoded == current {
595            return Some(
596                current
597                    .chars()
598                    .filter(|ch| !ch.is_whitespace() && !ch.is_control())
599                    .collect::<String>()
600                    .to_ascii_lowercase(),
601            );
602        }
603        current = decoded;
604    }
605    None
606}
607
608/// Named character references that spell URL syntax, whitespace or markup.
609const NAMED_REFERENCES: [(&str, char); 28] = [
610    ("AMP", '&'),
611    ("GT", '>'),
612    ("LT", '<'),
613    ("NewLine", '\n'),
614    ("QUOT", '"'),
615    ("Tab", '\t'),
616    ("amp", '&'),
617    ("apos", '\''),
618    ("bsol", '\\'),
619    ("colon", ':'),
620    ("comma", ','),
621    ("commat", '@'),
622    ("equals", '='),
623    ("excl", '!'),
624    ("gt", '>'),
625    ("lowbar", '_'),
626    ("lpar", '('),
627    ("lt", '<'),
628    ("nbsp", '\u{a0}'),
629    ("num", '#'),
630    ("percnt", '%'),
631    ("period", '.'),
632    ("plus", '+'),
633    ("quest", '?'),
634    ("quot", '"'),
635    ("rpar", ')'),
636    ("semi", ';'),
637    ("sol", '/'),
638];
639
640/// `text` with character references decoded as a browser would: numeric and named, with or
641/// without the closing `;`.
642fn references_decoded(text: &str) -> String {
643    let mut output = String::with_capacity(text.len());
644    let mut rest = text;
645    while let Some(start) = rest.find('&') {
646        output.push_str(&rest[..start]);
647        let tail = &rest[start + 1..];
648        match reference(tail) {
649            Some((ch, length)) => {
650                output.push(ch);
651                rest = &tail[length..];
652                rest = rest.strip_prefix(';').unwrap_or(rest);
653            }
654            None => {
655                output.push('&');
656                rest = tail;
657            }
658        }
659    }
660    output.push_str(rest);
661    output
662}
663
664/// The character the reference after an `&` spells, with the bytes its number or name takes.
665fn reference(text: &str) -> Option<(char, usize)> {
666    if let Some(number) = text.strip_prefix('#') {
667        let (digits, radix, prefix) = match number.strip_prefix(['x', 'X']) {
668            Some(hex) => (hex, 16, 2),
669            None => (number, 10, 1),
670        };
671        let length = digits
672            .find(|ch: char| !ch.is_digit(radix))
673            .unwrap_or(digits.len());
674        if length == 0 {
675            return None;
676        }
677        let value = u32::from_str_radix(&digits[..length], radix)
678            .ok()
679            .filter(|value| *value != 0)
680            .and_then(char::from_u32)
681            .unwrap_or('\u{fffd}');
682        return Some((value, prefix + length));
683    }
684    NAMED_REFERENCES
685        .iter()
686        .filter(|(name, _)| text.starts_with(name))
687        .max_by_key(|(name, _)| name.len())
688        .map(|(name, ch)| (*ch, name.len()))
689}
690
691fn percent_decoded(text: &str) -> String {
692    let bytes = text.as_bytes();
693    let mut output = Vec::with_capacity(bytes.len());
694    let mut index = 0;
695    while index < bytes.len() {
696        let hex = bytes
697            .get(index + 1..index + 3)
698            .and_then(|hex| std::str::from_utf8(hex).ok())
699            .and_then(|hex| u8::from_str_radix(hex, 16).ok());
700        match (bytes[index], hex) {
701            (b'%', Some(byte)) => {
702                output.push(byte);
703                index += 3;
704            }
705            (byte, _) => {
706                output.push(byte);
707                index += 1;
708            }
709        }
710    }
711    String::from_utf8_lossy(&output).into_owned()
712}
713
714/// Percent-encodes whitespace and controls in a link target. A Markdown destination also
715/// percent-encodes what could end it early and writes `&` as `&amp;`, which Markdown decodes
716/// back to `&`.
717fn destination(href: &str, markdown: bool) -> String {
718    let mut output = String::with_capacity(href.len());
719    for ch in href.chars() {
720        match ch {
721            ch if ch.is_whitespace() || ch.is_control() => {
722                let mut buffer = [0u8; 4];
723                for byte in ch.encode_utf8(&mut buffer).bytes() {
724                    let _ = write!(output, "%{byte:02X}");
725                }
726            }
727            ch if !markdown => output.push(ch),
728            '&' => output.push_str("&amp;"),
729            '(' => output.push_str("%28"),
730            ')' => output.push_str("%29"),
731            '<' => output.push_str("%3C"),
732            '>' => output.push_str("%3E"),
733            '\\' => output.push_str("%5C"),
734            '|' => output.push_str("%7C"),
735            ch => output.push(ch),
736        }
737    }
738    output
739}
740
741/// Escapes the pipes a Markdown table cell would otherwise split at.
742fn escape_pipes(text: &str) -> String {
743    let mut output = String::with_capacity(text.len());
744    let mut backslashes = 0usize;
745    for ch in text.chars() {
746        if ch == '|' && backslashes.is_multiple_of(2) {
747            output.push('\\');
748        }
749        backslashes = if ch == '\\' { backslashes + 1 } else { 0 };
750        output.push(ch);
751    }
752    output
753}
754
755/// Escapes what would open a block construct at the start of a trimmed line.
756fn line_start(text: String) -> String {
757    let digits = text.chars().take_while(char::is_ascii_digit).count();
758    if digits > 0
759        && digits <= 9
760        && text[digits..].starts_with(['.', ')'])
761        && (text.len() == digits + 1 || text[digits + 1..].starts_with([' ', '\t']))
762    {
763        return format!("{}\\{}", &text[..digits], &text[digits..]);
764    }
765    if text.starts_with(['-', '+', '=', '#']) {
766        return format!("\\{text}");
767    }
768    text
769}
770
771/// Escaped `text` at the start of a line, its leading spaces and tabs kept as no-break spaces,
772/// which cannot indent a code block, and what follows escaped against opening a block.
773fn indented_line_start(text: &str) -> String {
774    let rest = text.trim_start_matches([' ', '\t']);
775    let mut output = String::with_capacity(text.len() + 8);
776    for ch in text[..text.len() - rest.len()].chars() {
777        output.push_str(if ch == '\t' {
778            "\u{a0}\u{a0}\u{a0}\u{a0}"
779        } else {
780            "\u{a0}"
781        });
782    }
783    output + &line_start(rest.to_owned())
784}
785
786fn is_ordered_marker(marker: &str) -> bool {
787    let digits = marker.chars().take_while(char::is_ascii_digit).count();
788    (1..=9).contains(&digits) && digits + 1 == marker.len() && marker.ends_with(['.', ')'])
789}
790
791fn is_title(shape: &ExportShape) -> bool {
792    shape.placeholder.as_ref().is_some_and(|placeholder| {
793        matches!(
794            placeholder.placeholder_type.as_deref(),
795            Some("title" | "ctrTitle")
796        )
797    })
798}
799
800fn has_text(paragraph: &ExportParagraph) -> bool {
801    paragraph.runs.iter().any(|run| match &run.content {
802        ExportRunKind::Text { text } | ExportRunKind::Field { text, .. } => !text.is_empty(),
803        ExportRunKind::LineBreak | ExportRunKind::Unsupported { .. } => true,
804    })
805}
806
807fn story_text(story: &ExportStory) -> bool {
808    story.paragraphs.iter().any(has_text)
809}
810
811/// The fewest bytes `text` escapes to: a line separator, three bytes, becomes one space and
812/// nothing else shrinks.
813fn least(text: &str) -> usize {
814    text.len() / 3
815}
816
817/// The fewest bytes runs render to with whitespace trimmed at line starts: their text, a unit per
818/// line break and the comment an unsupported inline leaves.
819fn least_runs(runs: &[ExportRun]) -> usize {
820    let mut line_start = true;
821    let mut total = 0usize;
822    for run in runs {
823        let bytes = match &run.content {
824            ExportRunKind::Text { text } | ExportRunKind::Field { text, .. } if line_start => {
825                least(text.trim_start())
826            }
827            ExportRunKind::Text { text } | ExportRunKind::Field { text, .. } => least(text),
828            ExportRunKind::LineBreak => 1,
829            ExportRunKind::Unsupported { element } => least(element),
830        };
831        line_start = run.content == ExportRunKind::LineBreak || (line_start && bytes == 0);
832        total = total.saturating_add(bytes);
833    }
834    total
835}
836
837/// The fewest bytes a table renders to: its shown cells' runs and a delimiter per cell.
838fn least_table(table: &ExportTable) -> usize {
839    table
840        .rows
841        .iter()
842        .flat_map(|row| &row.cells)
843        .filter(|cell| !cell.merged)
844        .map(|cell| {
845            cell.story
846                .iter()
847                .flat_map(|story| &story.paragraphs)
848                .fold(3usize, |least, paragraph| {
849                    least.saturating_add(least_runs(&paragraph.runs))
850                })
851        })
852        .fold(0, usize::saturating_add)
853}
854
855fn object_label(kind: ExportObjectKind) -> &'static str {
856    match kind {
857        ExportObjectKind::Picture => "Picture",
858        ExportObjectKind::Video => "Video",
859        ExportObjectKind::Audio => "Audio",
860        ExportObjectKind::Chart => "Chart",
861        ExportObjectKind::SmartArt => "SmartArt",
862        ExportObjectKind::EmbeddedObject => "Embedded object",
863        ExportObjectKind::Unknown => "Unsupported content",
864    }
865}
866
867fn alternative_text(shape: &ExportShape) -> Option<&str> {
868    [shape.description.as_deref(), shape.title.as_deref()]
869        .into_iter()
870        .flatten()
871        .find(|text| !text.trim().is_empty())
872}
873
874impl Renderer {
875    /// Appends `text` if it fits; otherwise marks the rendering truncated.
876    fn push(&mut self, text: &str) -> bool {
877        if self.truncated || self.output.len() + text.len() > self.max_bytes {
878            self.truncated = true;
879            return false;
880        }
881        self.output.push_str(text);
882        true
883    }
884
885    /// Whether `pending` more bytes would run past the byte limit.
886    fn over(&self, pending: usize) -> bool {
887        self.output.len().saturating_add(pending) > self.max_bytes
888    }
889
890    /// Appends one rendered block, dropping the markers it recorded when it does not fit. A
891    /// block whose text alone, `least` bytes, cannot fit is not rendered at all.
892    fn block(&mut self, least: usize, render: impl FnOnce(&mut Self) -> String) -> bool {
893        if self.truncated || self.over(least) {
894            self.truncated = true;
895            return false;
896        }
897        let anchors = self.anchors.len();
898        let omitted = self.omitted.len();
899        let in_list = self.in_list;
900        let rendered = render(self);
901        self.built += rendered.len();
902        if !std::mem::take(&mut self.cut) && self.push(&rendered) {
903            return true;
904        }
905        self.truncated = true;
906        self.anchors.truncate(anchors);
907        self.omitted.truncate(omitted);
908        self.in_list = in_list;
909        false
910    }
911
912    fn end_list(&mut self) {
913        if self.in_list {
914            self.in_list = false;
915            self.push("\n");
916        }
917    }
918
919    fn lossy(&mut self, message: &'static str) {
920        self.lossy.insert(message);
921    }
922
923    /// The marker comment for a record, recorded with its anchor.
924    fn marker(&mut self, anchor: &PptxAnchor) -> String {
925        let marker = format!("pptx-export:{}", self.anchors.len());
926        self.anchors.push(MarkdownAnchor {
927            marker: marker.clone(),
928            anchor: anchor.clone(),
929        });
930        format!("<!-- {marker} -->")
931    }
932
933    /// Output that leaves a list first separates itself from it.
934    fn leave_list(&mut self) -> &'static str {
935        if std::mem::take(&mut self.in_list) {
936            "\n"
937        } else {
938            ""
939        }
940    }
941
942    fn slide(&mut self, slide: &ExportSlide) -> bool {
943        self.end_list();
944        let name = slide.name.as_deref().filter(|name| !name.trim().is_empty());
945        self.block(name.map_or(0, least), |renderer| {
946            let mut heading = format!("## Slide {}", u64::from(slide.index) + 1);
947            if let Some(name) = name {
948                let _ = write!(heading, ": {}", escape(name));
949            }
950            if slide.hidden == Some(true) {
951                heading.push_str(" (hidden)");
952            }
953            let marker = renderer.marker(&slide.anchor);
954            format!("{marker}\n{heading}\n\n")
955        })
956    }
957
958    fn shape(&mut self, shape: &ExportShape) -> bool {
959        let title = is_title(shape);
960        for story in &shape.stories {
961            for paragraph in story
962                .paragraphs
963                .iter()
964                .filter(|paragraph| has_text(paragraph))
965            {
966                if !self.block(least_runs(&paragraph.runs), |renderer| {
967                    renderer.paragraph(paragraph, title)
968                }) {
969                    return false;
970                }
971            }
972        }
973        if let Some(table) = &shape.table
974            && !self.block(least_table(table), |renderer| {
975                let leave = renderer.leave_list();
976                let marker = renderer.marker(&shape.anchor);
977                let table = renderer.table(table);
978                format!("{leave}{marker}\n{table}\n")
979            })
980        {
981            return false;
982        }
983        if let Some(object) = &shape.object
984            && !self.block(alternative_text(shape).map_or(0, least), |renderer| {
985                renderer.object(shape, object)
986            })
987        {
988            return false;
989        }
990        shape.children.iter().all(|child| self.shape(child))
991    }
992
993    fn object(&mut self, shape: &ExportShape, object: &ExportObject) -> String {
994        let leave = self.leave_list();
995        let marker = self.marker(&shape.anchor);
996        let body = match (object.kind, alternative_text(shape)) {
997            (ExportObjectKind::Picture, alt) => {
998                format!("![{}]()", escape(alt.unwrap_or("picture")))
999            }
1000            (ExportObjectKind::Unknown, alt) => {
1001                let mut body = format!(
1002                    "<!-- pptx-unsupported: {} -->",
1003                    comment_text(&object.element)
1004                );
1005                if let Some(alt) = alt {
1006                    let _ = write!(body, "\n\\[{}\\]", escape(alt));
1007                }
1008                body
1009            }
1010            (kind, Some(alt)) => format!("\\[{}: {}\\]", object_label(kind), escape(alt)),
1011            (kind, None) => format!("\\[{}\\]", object_label(kind)),
1012        };
1013        format!("{leave}{marker}\n{body}\n\n")
1014    }
1015
1016    fn paragraph(&mut self, paragraph: &ExportParagraph, title: bool) -> String {
1017        let text = self.runs(&paragraph.runs, title);
1018        let mut output = String::new();
1019        match &paragraph.list {
1020            Some(list) if !title => {
1021                let indent = "    ".repeat(paragraph.level.min(8) as usize);
1022                let marker = self.list_marker(list);
1023                let anchor = self.marker(&paragraph.anchor);
1024                let _ = write!(
1025                    output,
1026                    "{indent}{anchor}\n{indent}{marker} {}\n",
1027                    line_start(text.trim_start().to_owned())
1028                );
1029                self.in_list = true;
1030            }
1031            _ => {
1032                output.push_str(self.leave_list());
1033                let anchor = self.marker(&paragraph.anchor);
1034                if title {
1035                    if paragraph.list.is_some() {
1036                        self.lossy("List markers of title paragraphs are not rendered.");
1037                    }
1038                    let _ = write!(output, "{anchor}\n### {}\n\n", text.trim_start());
1039                } else {
1040                    let _ = write!(
1041                        output,
1042                        "{anchor}\n{}\n\n",
1043                        line_start(text.trim_start().to_owned())
1044                    );
1045                }
1046            }
1047        }
1048        output
1049    }
1050
1051    fn list_marker(&mut self, list: &ExportList) -> String {
1052        match list {
1053            ExportList::Bullet { .. } => "-".to_owned(),
1054            ExportList::Number { marker: None, .. } => {
1055                self.lossy("Unresolved list numbers are rendered as bullets.");
1056                "-".to_owned()
1057            }
1058            ExportList::Number {
1059                marker: Some(marker),
1060                ..
1061            } if is_ordered_marker(marker) => marker.clone(),
1062            ExportList::Number {
1063                marker: Some(marker),
1064                ..
1065            } => {
1066                self.lossy("List markers Markdown cannot number are kept as literal text.");
1067                format!("- {}", escape(marker))
1068            }
1069        }
1070    }
1071
1072    /// The runs' Markdown, cut short once even without its leading whitespace it cannot fit. On a
1073    /// `single_line` line breaks become spaces; otherwise each line after a break is trimmed and
1074    /// escaped against opening a block.
1075    fn runs(&mut self, runs: &[ExportRun], single_line: bool) -> String {
1076        let mut output = String::new();
1077        let mut lead = None;
1078        let mut at_line_start = false;
1079        for run in runs {
1080            let broken = run.content == ExportRunKind::LineBreak;
1081            let mut rendered = if broken && single_line {
1082                " ".to_owned()
1083            } else {
1084                self.run(run, false)
1085            };
1086            if std::mem::take(&mut at_line_start) {
1087                let trimmed = rendered.trim_start();
1088                at_line_start = trimmed.is_empty();
1089                rendered = if at_line_start {
1090                    String::new()
1091                } else {
1092                    line_start(trimmed.to_owned())
1093                };
1094            }
1095            at_line_start |= broken && !single_line;
1096            if lead.is_none() && !rendered.trim_start().is_empty() {
1097                lead = Some(output.len() + rendered.len() - rendered.trim_start().len());
1098            }
1099            output.push_str(&rendered);
1100            if self.over(output.len() - lead.unwrap_or(output.len())) {
1101                self.cut = true;
1102                break;
1103            }
1104        }
1105        output
1106    }
1107
1108    fn run(&mut self, run: &ExportRun, html: bool) -> String {
1109        let body = match &run.content {
1110            ExportRunKind::Text { text } | ExportRunKind::Field { text, .. } => {
1111                if html {
1112                    html_text(text)
1113                } else {
1114                    escape(text)
1115                }
1116            }
1117            ExportRunKind::LineBreak if html => "<br>".to_owned(),
1118            ExportRunKind::LineBreak => "\\\n".to_owned(),
1119            ExportRunKind::Unsupported { element } => {
1120                format!("<!-- pptx-unsupported: {} -->", comment_text(element))
1121            }
1122        };
1123        if body.is_empty()
1124            || !matches!(
1125                run.content,
1126                ExportRunKind::Text { .. } | ExportRunKind::Field { .. }
1127            )
1128        {
1129            return body;
1130        }
1131        let text = self.marked(body, run.marks.as_deref().unwrap_or_default(), html);
1132        match &run.link {
1133            None => text,
1134            Some(link) if !link.external => {
1135                self.lossy("Links within the presentation are rendered as plain text.");
1136                text
1137            }
1138            Some(link) => {
1139                match safe_href(&link.href) {
1140                    None => {
1141                        self.lossy("Link targets whose scheme is not http, https or mailto are not linked.");
1142                        text
1143                    }
1144                    Some(href) if html => {
1145                        format!(
1146                            "<a href=\"{}\">{text}</a>",
1147                            attribute(&destination(href, false))
1148                        )
1149                    }
1150                    Some(href) => format!("[{text}]({})", destination(href, true)),
1151                }
1152            }
1153        }
1154    }
1155
1156    /// Wraps `text` in its marks, keeping surrounding whitespace outside Markdown delimiters.
1157    fn marked(&mut self, text: String, marks: &[ExportMark], html: bool) -> String {
1158        if marks.is_empty() || text.trim().is_empty() {
1159            return text;
1160        }
1161        let leading = text.len() - text.trim_start().len();
1162        let trailing = text.len() - text.trim_end().len();
1163        let mut core = text[leading..text.len() - trailing].to_owned();
1164        for mark in marks {
1165            core = match (mark, html) {
1166                (ExportMark::Bold, false) => format!("**{core}**"),
1167                (ExportMark::Bold, true) => format!("<strong>{core}</strong>"),
1168                (ExportMark::Italic, false) => format!("*{core}*"),
1169                (ExportMark::Italic, true) => format!("<em>{core}</em>"),
1170                (ExportMark::Underline { .. }, _) => format!("<u>{core}</u>"),
1171                (ExportMark::Superscript, _) => format!("<sup>{core}</sup>"),
1172                (ExportMark::Subscript, _) => format!("<sub>{core}</sub>"),
1173                (ExportMark::SmallCaps | ExportMark::AllCaps, _) => {
1174                    self.lossy("Capitalization is rendered as authored.");
1175                    core
1176                }
1177            };
1178        }
1179        format!(
1180            "{}{core}{}",
1181            &text[..leading],
1182            &text[text.len() - trailing..]
1183        )
1184    }
1185
1186    /// A pipe table when every shown cell is at most one line of text on an unmerged grid, and
1187    /// HTML otherwise.
1188    fn table(&mut self, table: &ExportTable) -> String {
1189        let cells = || table.rows.iter().flat_map(|row| &row.cells);
1190        let simple = !table.rows.is_empty()
1191            && table
1192                .rows
1193                .iter()
1194                .all(|row| row.cells.len() == table.columns as usize)
1195            && cells().all(|cell| {
1196                !cell.merged
1197                    && cell.grid_span == 1
1198                    && cell.row_span == 1
1199                    && cell.story.as_ref().is_none_or(|story| {
1200                        story
1201                            .paragraphs
1202                            .iter()
1203                            .filter(|paragraph| has_text(paragraph))
1204                            .count()
1205                            <= 1
1206                            && story.paragraphs.iter().all(|paragraph| {
1207                                paragraph.list.is_none()
1208                                    && paragraph
1209                                        .runs
1210                                        .iter()
1211                                        .all(|run| run.content != ExportRunKind::LineBreak)
1212                            })
1213                    })
1214            });
1215        for cell in cells().filter(|cell| cell.merged) {
1216            if cell.story.as_ref().is_some_and(story_text) {
1217                self.omitted.push(ExportDiagnostic {
1218                    code: ExportDiagnosticCode::MergeContinuationContentOmitted,
1219                    severity: ExportSeverity::Warning,
1220                    anchor: Some(cell.anchor.clone()),
1221                    message: "A merged-away table cell holds text PowerPoint does not show; the \
1222                              Markdown leaves it out."
1223                        .to_owned(),
1224                });
1225            }
1226        }
1227        if !simple {
1228            return self.html_table(table);
1229        }
1230        let mut output = String::new();
1231        for (index, row) in table.rows.iter().enumerate() {
1232            output.push('|');
1233            for cell in &row.cells {
1234                let mut text = String::new();
1235                for paragraph in cell
1236                    .story
1237                    .iter()
1238                    .flat_map(|story| &story.paragraphs)
1239                    .filter(|paragraph| has_text(paragraph))
1240                {
1241                    text.push_str(&self.marker(&paragraph.anchor));
1242                    text.push_str(&self.runs(&paragraph.runs, true));
1243                }
1244                let _ = write!(output, " {} |", escape_pipes(&text));
1245                if self.over(output.len()) {
1246                    self.cut = true;
1247                    return output;
1248                }
1249            }
1250            output.push('\n');
1251            if index == 0 {
1252                output.push('|');
1253                for _ in &row.cells {
1254                    output.push_str(" --- |");
1255                }
1256                output.push('\n');
1257            }
1258        }
1259        output
1260    }
1261
1262    fn html_table(&mut self, table: &ExportTable) -> String {
1263        let mut output = String::from("<table>\n");
1264        for row in &table.rows {
1265            output.push_str("<tr>");
1266            for cell in row.cells.iter().filter(|cell| !cell.merged) {
1267                let mut attributes = String::new();
1268                if cell.grid_span > 1 {
1269                    let _ = write!(attributes, " colspan=\"{}\"", cell.grid_span);
1270                }
1271                if cell.row_span > 1 {
1272                    let _ = write!(attributes, " rowspan=\"{}\"", cell.row_span);
1273                }
1274                let mut content = String::new();
1275                for paragraph in cell
1276                    .story
1277                    .iter()
1278                    .flat_map(|story| &story.paragraphs)
1279                    .filter(|paragraph| has_text(paragraph))
1280                {
1281                    content.push_str(&self.marker(&paragraph.anchor));
1282                    let marker = match &paragraph.list {
1283                        Some(ExportList::Bullet { character, .. }) => {
1284                            format!("{} ", html_text(character))
1285                        }
1286                        Some(ExportList::Number {
1287                            marker: Some(marker),
1288                            ..
1289                        }) => format!("{} ", html_text(marker)),
1290                        Some(ExportList::Number { marker: None, .. }) => {
1291                            self.lossy("Unresolved list numbers are left out of HTML tables.");
1292                            String::new()
1293                        }
1294                        None => String::new(),
1295                    };
1296                    let mut runs = String::new();
1297                    for run in &paragraph.runs {
1298                        runs.push_str(&self.run(run, true));
1299                        if self.over(output.len() + runs.len()) {
1300                            self.cut = true;
1301                            return output;
1302                        }
1303                    }
1304                    let _ = write!(content, "<p>{marker}{runs}</p>");
1305                }
1306                let _ = write!(output, "<td{attributes}>{content}</td>");
1307                if self.over(output.len()) {
1308                    self.cut = true;
1309                    return output;
1310                }
1311            }
1312            output.push_str("</tr>\n");
1313        }
1314        output.push_str("</table>\n");
1315        output
1316    }
1317
1318    fn notes(&mut self, notes: &ExportNotes) -> bool {
1319        self.end_list();
1320        let least = notes
1321            .text
1322            .chars()
1323            .filter(|ch| !ch.is_whitespace())
1324            .map(char::len_utf8)
1325            .sum();
1326        self.block(least, |renderer| {
1327            let marker = renderer.marker(&notes.anchor);
1328            let quoted = notes
1329                .text
1330                .split('\n')
1331                .map(|line| {
1332                    if line.trim().is_empty() {
1333                        ">".to_owned()
1334                    } else {
1335                        format!("> {}", indented_line_start(&escape(line)))
1336                    }
1337                })
1338                .collect::<Vec<_>>()
1339                .join("\n");
1340            format!("**Notes**\n\n{marker}\n{quoted}\n\n")
1341        })
1342    }
1343
1344    fn comment(&mut self, comment: &ExportComment, first: bool) -> bool {
1345        if first {
1346            self.end_list();
1347        }
1348        self.block(least(comment.text.trim_start()), |renderer| {
1349            let heading = if first { "**Comments**\n\n" } else { "" };
1350            let marker = renderer.marker(&comment.anchor);
1351            let indent = if comment.parent_id.is_some() {
1352                "    "
1353            } else {
1354                ""
1355            };
1356            let mut attribution = Vec::new();
1357            if let Some(author) = &comment.author {
1358                attribution.push(format!("**{}**", escape(author)));
1359            }
1360            let details: Vec<String> = comment
1361                .date
1362                .iter()
1363                .map(|date| escape(date))
1364                .chain(comment.resolved.then(|| "resolved".to_owned()))
1365                .collect();
1366            if !details.is_empty() {
1367                attribution.push(format!("({})", details.join(", ")));
1368            }
1369            let text = comment
1370                .text
1371                .split('\n')
1372                .map(escape)
1373                .collect::<Vec<_>>()
1374                .join("<br>");
1375            let line = if attribution.is_empty() {
1376                indented_line_start(&text)
1377            } else {
1378                format!("{}: {text}", attribution.join(" "))
1379            };
1380            renderer.in_list = true;
1381            format!("{heading}{indent}{marker}\n{indent}- {line}\n")
1382        })
1383    }
1384}
1385
1386#[cfg(test)]
1387mod tests {
1388    use super::*;
1389    use crate::structured::walk::tests::{deck_with_shapes, deck_with_table};
1390
1391    fn exported(bytes: &[u8]) -> PptxStructuredContent {
1392        export_pptx_structured(bytes, &PptxExportOptions::default()).unwrap()
1393    }
1394
1395    /// Slide 1 holding one text box whose single paragraph is `runs`.
1396    fn paragraph(runs: &str) -> PptxStructuredContent {
1397        exported(&deck_with_shapes(&format!(
1398            r#"<p:sp><p:nvSpPr><p:cNvPr id="2" name="Body"/><p:cNvSpPr txBox="1"/><p:nvPr/></p:nvSpPr><p:spPr/><p:txBody><a:bodyPr/><a:lstStyle/><a:p>{runs}</a:p></p:txBody></p:sp>"#
1399        )))
1400    }
1401
1402    fn rendered(content: &PptxStructuredContent, max_bytes: u32) -> (PptxMarkdownContent, usize) {
1403        render(
1404            content,
1405            &PptxMarkdownOptions {
1406                max_bytes: Some(max_bytes),
1407            },
1408        )
1409        .unwrap()
1410    }
1411
1412    #[test]
1413    fn text_before_a_link_cannot_make_it_an_image() {
1414        let mut content = paragraph("<a:r><a:t>see!Docs</a:t></a:r>");
1415        let runs = &mut content.slides[0].shapes[0].stories[0].paragraphs[0].runs;
1416        let mut linked = runs[0].clone();
1417        for (run, text, first) in [(&mut runs[0], "see!", true), (&mut linked, "Docs", false)] {
1418            if let PptxAnchor::Range(range) = &mut run.anchor {
1419                if first {
1420                    range.end = range.start + 4;
1421                } else {
1422                    range.start += 4;
1423                }
1424            }
1425            run.content = ExportRunKind::Text {
1426                text: text.to_owned(),
1427            };
1428        }
1429        linked.link = Some(ExportLink {
1430            href: "https://example.test/pixel".to_owned(),
1431            external: true,
1432        });
1433        runs.push(linked);
1434        let (markdown, _) = rendered(&content, 1 << 20);
1435        assert!(
1436            markdown
1437                .markdown
1438                .contains(r"see\![Docs](https://example.test/pixel)"),
1439            "{}",
1440            markdown.markdown
1441        );
1442    }
1443
1444    #[test]
1445    fn a_block_too_long_for_the_limit_is_not_built() {
1446        let content = paragraph(&format!("<a:r><a:t>{}</a:t></a:r>", "a".repeat(1 << 20)));
1447        let (markdown, built) = rendered(&content, 1_024);
1448        assert!(markdown.truncated && !markdown.markdown.contains("aaaa"));
1449        assert!(built < 1_024, "{built} bytes built");
1450    }
1451
1452    #[test]
1453    fn blocks_stop_building_once_they_pass_the_limit() {
1454        let content = paragraph(&"<a:r><a:t>**********</a:t></a:r><a:br/>".repeat(1_000));
1455        let (markdown, built) = rendered(&content, 16_384);
1456        assert!(markdown.truncated && !markdown.markdown.contains(r"\*"));
1457        assert!(built < 16_384 + 1_024, "{built} bytes built");
1458        let content = exported(&deck_with_table(2_000));
1459        let (markdown, built) = rendered(&content, 65_536);
1460        assert!(markdown.truncated && !markdown.markdown.contains("left 0"));
1461        assert!(built < 65_536 + 1_024, "{built} bytes built");
1462    }
1463}