#![cfg(feature = "pptx")]
use std::io::Cursor;
use quick_xml::events::Event;
use quick_xml::Reader;
use crate::error::{Error, Result};
use crate::tools::workspace::Workspace;
const SLIDES: &str = "ppt/slides/slide";
fn unreadable(rel: &str, e: impl std::fmt::Display) -> Error {
Error::Config(format!("{rel} is not a readable .pptx deck: {e}"))
}
fn slide_number(name: &str) -> Option<u32> {
name.strip_prefix(SLIDES)?
.strip_suffix(".xml")?
.parse()
.ok()
}
fn slide_text(xml: &[u8]) -> std::result::Result<String, quick_xml::Error> {
let mut reader = Reader::from_reader(xml);
reader.config_mut().trim_text(false);
let mut out = String::new();
let mut in_text = false;
loop {
match reader.read_event()? {
Event::Start(e) if e.local_name().as_ref() == b"t" => in_text = true,
Event::End(e) => match e.local_name().as_ref() {
b"t" => in_text = false,
b"p" => out.push('\n'),
_ => {}
},
Event::Empty(e) if e.local_name().as_ref() == b"br" => out.push('\n'),
Event::Text(t) if in_text => out.push_str(&t.unescape()?),
Event::Eof => break,
_ => {}
}
}
Ok(out)
}
pub fn read_text(ws: &Workspace, rel: &str) -> Result<String> {
let bytes = ws.read_bytes(rel)?;
let mut archive = zip::ZipArchive::new(Cursor::new(bytes)).map_err(|e| unreadable(rel, e))?;
let mut slides: Vec<(u32, usize)> = (0..archive.len())
.filter_map(|i| {
let name = archive.by_index(i).ok()?.name().to_string();
slide_number(&name).map(|n| (n, i))
})
.collect();
if slides.is_empty() {
return Err(Error::Config(format!(
"{rel} contains no slides; is it a .pptx?"
)));
}
slides.sort_unstable();
let mut out = String::new();
for (number, index) in slides {
let mut xml = Vec::new();
std::io::copy(
&mut archive.by_index(index).map_err(|e| unreadable(rel, e))?,
&mut xml,
)
.map_err(|e| unreadable(rel, e))?;
let text = slide_text(&xml).map_err(|e| unreadable(rel, e))?;
if !out.is_empty() {
out.push('\n');
}
out.push_str(&format!("# Slide {number}\n"));
out.push_str(text.trim_matches('\n'));
out.push('\n');
}
Ok(out)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::policy::Policy;
use std::io::Write;
fn dir() -> tempfile::TempDir {
tempfile::tempdir().unwrap()
}
fn guarded(root: &std::path::Path) -> Workspace {
Workspace::with_policy(
root,
Policy::default()
.layer("base")
.allow_read("*")
.allow_write("*")
.deny_read("secrets/*")
.deny_write("secrets/*"),
)
}
fn slide_xml(lines: &[&str]) -> String {
let body: String = lines
.iter()
.map(|l| format!("<a:p><a:r><a:t>{l}</a:t></a:r></a:p>"))
.collect();
format!(
r#"<?xml version="1.0" encoding="UTF-8"?>
<p:sld xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main"
xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main">
<p:cSld><p:spTree><p:sp><p:txBody>{body}</p:txBody></p:sp></p:spTree></p:cSld>
</p:sld>"#
)
}
fn deck(entries: &[(&str, String)]) -> Vec<u8> {
let mut z = zip::ZipWriter::new(Cursor::new(Vec::new()));
for (name, content) in entries {
z.start_file(*name, zip::write::SimpleFileOptions::default())
.unwrap();
z.write_all(content.as_bytes()).unwrap();
}
z.finish().unwrap().into_inner()
}
fn twelve_slides() -> Vec<u8> {
let mut names: Vec<u32> = (1..=12).collect();
names.sort_by_key(|n| n.to_string());
let entries: Vec<(String, String)> = names
.iter()
.map(|n| {
(
format!("ppt/slides/slide{n}.xml"),
slide_xml(&[&format!("Slide {n} title"), &format!("body {n}")]),
)
})
.collect();
let borrowed: Vec<(&str, String)> = entries
.iter()
.map(|(n, c)| (n.as_str(), c.clone()))
.collect();
deck(&borrowed)
}
#[test]
fn slide_text_extracts_in_numeric_slide_order_past_nine() {
let d = dir();
let ws = Workspace::new(d.path());
ws.write_bytes("deck.pptx", &twelve_slides()).unwrap();
let text = read_text(&ws, "deck.pptx").unwrap();
let headings: Vec<&str> = text.lines().filter(|l| l.starts_with("# Slide")).collect();
assert_eq!(
headings,
(1..=12).map(|n| format!("# Slide {n}")).collect::<Vec<_>>(),
"numeric order, not the archive's and not lexicographic: {text}"
);
assert!(
text.contains("Slide 2 title") && text.contains("Slide 10 title"),
"every slide's text is present: {text}"
);
assert!(
text.find("body 2").unwrap() < text.find("# Slide 10").unwrap(),
"{text}"
);
}
#[test]
fn paragraphs_and_breaks_become_line_boundaries_and_entities_are_unescaped() {
let d = dir();
let ws = Workspace::new(d.path());
let xml =
slide_xml(&["Q1 & Q2", "up 4%"]).replace("</a:r></a:p>", "</a:r><a:br/></a:p>");
ws.write_bytes("deck.pptx", &deck(&[("ppt/slides/slide1.xml", xml)]))
.unwrap();
assert_eq!(
read_text(&ws, "deck.pptx").unwrap(),
"# Slide 1\nQ1 & Q2\n\nup 4%\n",
"a break adds its own line; the entity comes back as the character"
);
}
#[test]
fn only_slide_parts_are_read_not_layouts_masters_or_notes() {
let d = dir();
let ws = Workspace::new(d.path());
let bytes = deck(&[
("ppt/slides/slide1.xml", slide_xml(&["real slide text"])),
(
"ppt/slideLayouts/slideLayout1.xml",
slide_xml(&["click to edit master title"]),
),
(
"ppt/slideMasters/slideMaster1.xml",
slide_xml(&["master placeholder"]),
),
(
"ppt/notesSlides/notesSlide1.xml",
slide_xml(&["private speaker note"]),
),
(
"ppt/slides/_rels/slide1.xml.rels",
slide_xml(&["relationship part"]),
),
("ppt/slides/slideX.xml", slide_xml(&["not a number"])),
]);
ws.write_bytes("deck.pptx", &bytes).unwrap();
let text = read_text(&ws, "deck.pptx").unwrap();
assert_eq!(text, "# Slide 1\nreal slide text\n", "{text}");
}
#[test]
fn a_file_that_is_not_a_deck_is_an_error_not_a_panic() {
let d = dir();
let ws = Workspace::new(d.path());
ws.write_bytes("notes.pptx", b"this is plainly not a zip archive")
.unwrap();
ws.write_bytes(
"jar.pptx",
&deck(&[("META-INF/MANIFEST.MF", "Manifest-Version: 1.0".to_string())]),
)
.unwrap();
ws.write_bytes(
"torn.pptx",
&deck(&[(
"ppt/slides/slide1.xml",
"<a:p><a:r><a:t>x</a:t></a:p></a:r>".to_string(),
)]),
)
.unwrap();
for rel in ["notes.pptx", "jar.pptx", "torn.pptx"] {
let shown = read_text(&ws, rel).unwrap_err().to_string();
assert!(
shown.contains(rel),
"the message names the file, got {shown}"
);
}
}
#[test]
fn a_truncated_slide_returns_what_could_be_read_rather_than_failing() {
let d = dir();
let ws = Workspace::new(d.path());
ws.write_bytes(
"cut.pptx",
&deck(&[(
"ppt/slides/slide1.xml",
"<a:p><a:r><a:t>kept</a:t></a:r></a:p><a:p><a:r><a:t>cut".to_string(),
)]),
)
.unwrap();
assert_eq!(
read_text(&ws, "cut.pptx").unwrap(),
"# Slide 1\nkept\ncut\n"
);
}
#[test]
fn a_denied_path_is_refused_for_reading() {
let d = dir();
Workspace::new(d.path())
.write_bytes("secrets/deck.pptx", &twelve_slides())
.unwrap();
let ws = guarded(d.path());
let read = read_text(&ws, "secrets/deck.pptx");
assert!(
matches!(&read, Err(Error::Refused { act, target, .. })
if act == "read" && target == "secrets/deck.pptx"),
"got {read:?}"
);
}
#[test]
fn the_same_read_succeeds_on_a_path_the_policy_allows() {
let d = dir();
let ws = guarded(d.path());
ws.write_bytes("open/deck.pptx", &twelve_slides()).unwrap();
assert!(read_text(&ws, "open/deck.pptx")
.unwrap()
.contains("Slide 2 title"));
}
}