Files
Kigi-CLI/crates/codegen/kigi-tools/src/implementations/read_file/pptx.rs
T
雷电芽衣 10cbb8a5f1 fix(deps): move zip to 9.1 (RUSTSEC-2026-0367/0368/0369)
The workspace pin goes from 8 to 9, kigi-tools drops its separate zip 3
pin for the workspace dependency, and office_oxide 0.1.16 follows onto
zip 9. zip 9 decodes entry names on demand and can fail; the PPTX reader
and the Windows update unpacker now report that error. The zip unpacker
gains the two tests the tar path already had.
2026-10-31 02:18:43 -04:00

238 lines
9.4 KiB
Rust

//! PPTX text extraction shared by read tools.
//!
//! Minimal zip + quick-xml implementation that replaces `omniparse` (which
//! dragged an old `scraper 0.18` / `cssparser 0.31` / `selectors 0.25` /
//! `calamine` line into the shipped pager binary for what is, here, just
//! "unzip and read the DrawingML text runs").
//!
//! Output format is compatible with the previous omniparse-based extraction:
//! `--- Slide N ---` headers, slide body text with one line per paragraph,
//! and a `Speaker Notes:` section when the slide has notes. Two omniparse
//! bugs are deliberately fixed rather than replicated: slides are ordered
//! numerically (slide2 before slide10, not lexicographically), and each
//! slide's notes are matched by the slide's own number instead of its
//! position in the sorted list.
use std::io::{Cursor, Read};
use quick_xml::Reader;
use quick_xml::events::Event;
use zip::ZipArchive;
/// Cap on the decompressed size of any single XML entry we read, guarding
/// against zip bombs (the compressed input is already capped by the caller).
const MAX_XML_ENTRY_BYTES: u64 = 64 * 1024 * 1024;
/// Extract plain text from PPTX bytes.
///
/// Returns the concatenated slide texts, or an error string suitable for
/// `ReadFileOutput::FileReadError`.
pub(crate) fn extract_pptx_text_from_bytes(bytes: &[u8]) -> Result<String, String> {
let mut archive = ZipArchive::new(Cursor::new(bytes))
.map_err(|e| format!("Failed to open PPTX archive: {e}"))?;
let names = archive
.file_names()
.collect::<Result<Vec<_>, _>>()
.map_err(|e| format!("Failed to read PPTX entry names: {e}"))?;
// Collect slide numbers from `ppt/slides/slideN.xml` entries and sort
// numerically so slide ordering matches the presentation.
let mut slide_numbers: Vec<u32> = names
.iter()
.filter_map(|name| {
name.strip_prefix("ppt/slides/slide")?
.strip_suffix(".xml")?
.parse()
.ok()
})
.collect();
slide_numbers.sort_unstable();
if slide_numbers.is_empty() {
return Err("No slides found in PPTX".to_string());
}
let mut all_text = String::new();
for number in slide_numbers {
let slide_xml = read_entry(&mut archive, &format!("ppt/slides/slide{number}.xml"))?
.ok_or_else(|| format!("Failed to read slide {number}"))?;
let slide_text = extract_drawingml_text(&slide_xml)
.map_err(|e| format!("Error parsing slide {number}: {e}"))?;
// Notes are optional; a parse failure there shouldn't sink the slide.
let notes_text = read_entry(
&mut archive,
&format!("ppt/notesSlides/notesSlide{number}.xml"),
)
.ok()
.flatten()
.and_then(|xml| extract_drawingml_text(&xml).ok())
.unwrap_or_default();
if !all_text.is_empty() {
all_text.push_str("\n\n");
}
all_text.push_str(&format!("--- Slide {number} ---\n"));
all_text.push_str(&slide_text);
if !notes_text.is_empty() {
all_text.push_str("\n\nSpeaker Notes:\n");
all_text.push_str(&notes_text);
}
}
Ok(all_text)
}
/// Read a zip entry to a string, `Ok(None)` if the entry does not exist.
fn read_entry(
archive: &mut ZipArchive<Cursor<&[u8]>>,
name: &str,
) -> Result<Option<String>, String> {
let file = match archive.by_name(name) {
Ok(file) => file,
Err(zip::result::ZipError::FileNotFound) => return Ok(None),
Err(e) => return Err(format!("Failed to open {name}: {e}")),
};
let mut content = String::new();
file.take(MAX_XML_ENTRY_BYTES)
.read_to_string(&mut content)
.map_err(|e| format!("Failed to read {name}: {e}"))?;
if content.len() as u64 == MAX_XML_ENTRY_BYTES {
return Err(format!("{name} exceeds the decompressed size limit"));
}
Ok(Some(content))
}
/// Extract text from DrawingML: the character content of `<a:t>` runs,
/// concatenated per paragraph, one line per `<a:p>` paragraph.
fn extract_drawingml_text(xml: &str) -> Result<String, String> {
// No `trim_text`: whitespace inside `<a:t>` runs is significant (runs are
// frequently split mid-sentence), and text outside runs is already
// excluded by the `in_text_run` gate below.
let mut reader = Reader::from_str(xml);
let mut text = String::new();
let mut in_text_run = false;
loop {
match reader.read_event() {
Ok(Event::Start(ref e)) if e.local_name().as_ref() == b"t" => in_text_run = true,
Ok(Event::Text(e)) if in_text_run => {
let content = e
.xml_content(quick_xml::XmlVersion::Implicit1_0)
.map_err(|e| e.to_string())?;
text.push_str(&content);
}
// quick-xml ≥0.37 emits `&amp;` / `&#233;` as separate events
// instead of unescaping them inside `Event::Text`.
Ok(Event::GeneralRef(e)) if in_text_run => {
if let Some(ch) = e.resolve_char_ref().map_err(|e| e.to_string())? {
text.push(ch);
} else {
let name = e.decode().map_err(|e| e.to_string())?;
match quick_xml::escape::resolve_predefined_entity(&name) {
Some(resolved) => text.push_str(resolved),
// Unknown entity: keep the raw reference visible.
None => {
text.push('&');
text.push_str(&name);
text.push(';');
}
}
}
}
Ok(Event::End(ref e)) => match e.local_name().as_ref() {
b"t" => in_text_run = false,
b"p" if !text.is_empty() && !text.ends_with('\n') => text.push('\n'),
_ => {}
},
Ok(Event::Eof) => break,
Err(e) => return Err(e.to_string()),
_ => {}
}
}
Ok(text.trim().to_string())
}
#[cfg(test)]
mod tests {
use std::io::Write as _;
use super::*;
/// Build an in-memory PPTX-shaped zip from (entry name, XML) pairs.
fn build_zip(entries: &[(&str, &str)]) -> Vec<u8> {
let mut writer = zip::ZipWriter::new(Cursor::new(Vec::new()));
let options = zip::write::SimpleFileOptions::default();
for (name, content) in entries {
writer.start_file(*name, options).unwrap();
writer.write_all(content.as_bytes()).unwrap();
}
writer.finish().unwrap().into_inner()
}
fn slide_xml(paragraphs: &[&str]) -> String {
let body: String = paragraphs
.iter()
.map(|p| format!("<a:p><a:r><a:t>{p}</a:t></a:r></a:p>"))
.collect();
format!(
r#"<?xml version="1.0"?><p:sld xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main"><p:cSld><p:spTree><p:sp><p:txBody>{body}</p:txBody></p:sp></p:spTree></p:cSld></p:sld>"#
)
}
#[test]
fn extracts_slides_in_numeric_order_with_notes() {
let s1 = slide_xml(&["Title", "Body line"]);
let s2 = slide_xml(&["Second"]);
let s10 = slide_xml(&["Tenth"]);
let notes2 = slide_xml(&["A note"]);
let bytes = build_zip(&[
("[Content_Types].xml", "<Types/>"),
// Deliberately out of order; slide10 sorts before slide2 lexically.
("ppt/slides/slide10.xml", &s10),
("ppt/slides/slide1.xml", &s1),
("ppt/slides/slide2.xml", &s2),
("ppt/notesSlides/notesSlide2.xml", &notes2),
]);
let text = extract_pptx_text_from_bytes(&bytes).unwrap();
assert_eq!(
text,
"--- Slide 1 ---\nTitle\nBody line\n\n--- Slide 2 ---\nSecond\n\nSpeaker Notes:\nA note\n\n--- Slide 10 ---\nTenth"
);
}
#[test]
fn split_text_runs_concatenate_without_injected_spaces() {
// PowerPoint frequently splits a word across runs (e.g. spell-check
// boundaries); the run texts must be joined without separators.
let slide = r#"<p:sld xmlns:a="a" xmlns:p="p"><a:p><a:r><a:t>Hel</a:t></a:r><a:r><a:t>lo &amp; bye</a:t></a:r></a:p></p:sld>"#;
let bytes = build_zip(&[("ppt/slides/slide1.xml", slide)]);
let text = extract_pptx_text_from_bytes(&bytes).unwrap();
assert_eq!(text, "--- Slide 1 ---\nHello & bye");
}
#[test]
fn empty_text_elements_do_not_leak_surrounding_text() {
// A self-closing <a:t/> must not flip the in-run flag on (omniparse
// treated Empty like Start and then captured unrelated text nodes).
let slide = r#"<p:sld xmlns:a="a" xmlns:p="p"><a:p><a:r><a:t/></a:r>stray<a:r><a:t>kept</a:t></a:r></a:p></p:sld>"#;
let bytes = build_zip(&[("ppt/slides/slide1.xml", slide)]);
let text = extract_pptx_text_from_bytes(&bytes).unwrap();
assert_eq!(text, "--- Slide 1 ---\nkept");
}
#[test]
fn not_a_zip_is_an_error() {
let err = extract_pptx_text_from_bytes(b"plainly not a zip").unwrap_err();
assert!(err.contains("Failed to open PPTX archive"), "{err}");
}
#[test]
fn zip_without_slides_is_an_error() {
let bytes = build_zip(&[("[Content_Types].xml", "<Types/>")]);
let err = extract_pptx_text_from_bytes(&bytes).unwrap_err();
assert_eq!(err, "No slides found in PPTX");
}
}