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