use super::{collapse_blank_lines, ooxml, squash};
use quick_xml::events::{BytesStart, Event};
use std::io::Cursor;
use zip::ZipArchive;
const SLIDE_PREFIX: &str = "ppt/slides/slide";
const SLIDE_SUFFIX: &str = ".xml";
pub(super) fn convert(bytes: &[u8]) -> Result<String, String> {
let mut zip = ooxml::open(bytes)?;
let mut slides = slide_entries(&mut zip);
if slides.is_empty() {
return Err(format!(
"pptx has no {SLIDE_PREFIX}N{SLIDE_SUFFIX} entries; the file may be damaged"
));
}
slides.sort_by_key(|(number, _)| *number);
let mut sections = Vec::new();
for (number, name) in slides {
let Some(xml) = ooxml::read_xml(&mut zip, &name)? else {
continue;
};
let section = render_slide(number, &xml)?;
if !section.is_empty() {
sections.push(section);
}
}
Ok(collapse_blank_lines(§ions.join("\n\n")))
}
fn slide_entries(zip: &mut ZipArchive<Cursor<&[u8]>>) -> Vec<(u32, String)> {
let mut slides = Vec::new();
for name in zip.file_names() {
let Ok(name) = name else {
continue;
};
let Some(rest) = name.strip_prefix(SLIDE_PREFIX) else {
continue;
};
let Some(digits) = rest.strip_suffix(SLIDE_SUFFIX) else {
continue;
};
let Ok(number) = digits.parse::<u32>() else {
continue;
};
slides.push((number, name.into_owned()));
}
slides
}
fn render_slide(number: u32, xml: &str) -> Result<String, String> {
let mut slide = Slide::default();
ooxml::walk(xml, |event| {
slide.event(event);
Ok(())
})?;
slide.finish();
Ok(slide.into_text(number))
}
#[derive(Default)]
struct Slide {
title_shape: bool,
para: String,
level: usize,
title: Option<String>,
body: Vec<(usize, String)>,
}
impl Slide {
fn event(&mut self, event: &Event<'_>) {
match event {
Event::Start(e) | Event::Empty(e) => self.start(e),
Event::End(e) => match e.name().local_name().as_ref() {
"p" => self.flush_para(),
"sp" => self.title_shape = false,
_ => {}
},
Event::Text(_) | Event::CData(_) | Event::GeneralRef(_) => {
ooxml::push_text(event, &mut self.para)
}
_ => {}
}
}
fn start(&mut self, e: &BytesStart<'_>) {
match e.name().local_name().as_ref() {
"sp" => self.title_shape = false,
"ph" => {
if matches!(
ooxml::attr(e, "type").as_deref(),
Some("title") | Some("ctrTitle")
) {
self.title_shape = true;
}
}
"p" => {
self.para.clear();
self.level = 0;
}
"pPr" => {
self.level = ooxml::attr(e, "lvl")
.and_then(|l| l.parse::<usize>().ok())
.unwrap_or(0)
.min(8);
}
"br" => self.para.push(' '),
_ => {}
}
}
fn flush_para(&mut self) {
let text = squash(&std::mem::take(&mut self.para));
if text.is_empty() {
return;
}
if self.title_shape && self.title.is_none() {
self.title = Some(text);
} else {
self.body.push((self.level, text));
}
}
fn finish(&mut self) {
self.flush_para();
}
fn into_text(self, number: u32) -> String {
let mut out = match self.title {
Some(title) => format!("## Slide {number}: {title}"),
None => format!("## Slide {number}"),
};
for (level, text) in self.body {
out.push('\n');
out.push_str(&" ".repeat(level));
out.push_str("- ");
out.push_str(&text);
}
out
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn slide_entries_are_filtered_by_name_shape() {
let xml = r#"<p:sld xmlns:p="p"><p:cSld><p:spTree/></p:cSld></p:sld>"#;
let mut writer = zip::ZipWriter::new(Cursor::new(Vec::new()));
for name in [
"ppt/slides/slide1.xml",
"ppt/slides/slide12.xml",
"ppt/slides/_rels/slide1.xml.rels",
"ppt/slideLayouts/slideLayout1.xml",
"ppt/slides/slideX.xml",
] {
writer
.start_file(name, zip::write::SimpleFileOptions::default())
.unwrap();
use std::io::Write;
writer.write_all(xml.as_bytes()).unwrap();
}
let bytes = writer.finish().unwrap().into_inner();
let mut zip = ooxml::open(&bytes).unwrap();
let mut found = slide_entries(&mut zip);
found.sort();
assert_eq!(
found,
vec![
(1, "ppt/slides/slide1.xml".to_string()),
(12, "ppt/slides/slide12.xml".to_string()),
]
);
}
}