1use std::collections::{BTreeSet, HashSet};
7use std::fmt::Write as _;
8
9use super::*;
10
11pub fn render_pptx_markdown(
13 content: &PptxStructuredContent,
14 options: &PptxMarkdownOptions,
15) -> Result<PptxMarkdownContent, ExportFailure> {
16 render(content, options).map(|(rendered, _)| rendered)
17}
18
19fn 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
102const MAX_RENDER_DEPTH: usize = 128;
104const MAX_RENDER_RECORDS: usize = 16 * MAX_BLOCKS_LIMIT as usize;
106fn 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 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(¬es.id)?;
205 let expected = PptxAnchor::Notes {
206 slide_id: slide_id.clone(),
207 range: TextSpan {
208 start: 0,
209 end: units(¬es.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(¶graph.id)?;
390 let range = match text_of(¶graph.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 ¶graph.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
461fn 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 cut: bool,
491 built: usize,
493}
494
495fn is_line_break(ch: char) -> bool {
497 matches!(ch, '\r' | '\n' | '\u{2028}' | '\u{2029}')
498}
499
500fn 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("<"),
506 '>' => output.push_str(">"),
507 ch if is_line_break(ch) => output.push(' '),
508 ch => output.push(ch),
509 }
510 }
511 output
512}
513
514fn 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
543fn 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("&"),
549 '<' => output.push_str("<"),
550 '>' => output.push_str(">"),
551 ch if is_line_break(ch) => output.push(' '),
552 ch => output.push(ch),
553 }
554 }
555 output
556}
557
558fn 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("&"),
564 '"' => output.push_str("""),
565 '\'' => output.push_str("'"),
566 '<' => output.push_str("<"),
567 '>' => output.push_str(">"),
568 ch => output.push(ch),
569 }
570 }
571 output
572}
573
574const LINK_SCHEMES: [&str; 3] = ["http", "https", "mailto"];
576
577fn 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
584const MAX_DECODE_ROUNDS: usize = 8;
586
587fn 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(¤t));
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
608const 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
640fn 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
664fn 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
714fn 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("&"),
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
741fn 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
755fn 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
771fn 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
811fn least(text: &str) -> usize {
814 text.len() / 3
815}
816
817fn 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
837fn 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(¶graph.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 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 fn over(&self, pending: usize) -> bool {
887 self.output.len().saturating_add(pending) > self.max_bytes
888 }
889
890 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 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 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(¶graph.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(¶graph.runs, title);
1018 let mut output = String::new();
1019 match ¶graph.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(¶graph.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(¶graph.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 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 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 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(¶graph.anchor));
1242 text.push_str(&self.runs(¶graph.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(¶graph.anchor));
1282 let marker = match ¶graph.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 ¶graph.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(¬es.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 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\"),
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}