From b48870989c8c139e03936a61f950d33cf1a8cc09 Mon Sep 17 00:00:00 2001 From: wurstbrotdlx Date: Fri, 9 Oct 2026 16:28:45 +0200 Subject: [PATCH] PPTX: recognise packages whose [Content_Types].xml is not in the first 4 KB (#19) `sniff` searched only the leading 4096 bytes for the content-types part, but OOXML writers may place that part anywhere in the archive. Recent PowerPoint builds (16.x) store it as the last zip entry, so every deck saved by them was rejected with "not a presentation DeckCraft can read" although it imports fine. In one real folder 169 of 300 decks were affected. Look the part up in the zip's central directory instead. The regression test re-packs the sample deck with the part last behind 64 KB of padding and checks that it is both recognised and imported. Co-authored-by: Matthias Weber Co-authored-by: Claude Fable 5.1 --- crates/pptx/src/lib.rs | 8 +++++- crates/pptx/tests/sniff.rs | 58 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 65 insertions(+), 1 deletion(-) create mode 100644 crates/pptx/tests/sniff.rs diff --git a/crates/pptx/src/lib.rs b/crates/pptx/src/lib.rs index a6018b71..92274b62 100644 --- a/crates/pptx/src/lib.rs +++ b/crates/pptx/src/lib.rs @@ -34,8 +34,14 @@ pub enum PptxError { } /// Does this look like an OOXML package (zip with `[Content_Types].xml`)? +/// +/// The part is looked up in the zip's central directory, not in the leading bytes: writers may +/// store it anywhere in the archive (recent PowerPoint builds put it last, megabytes in). pub fn sniff(bytes: &[u8]) -> bool { - bytes.starts_with(b"PK") && bytes.windows(19).take(4096).any(|w| w == b"[Content_Types].xml") + if !bytes.starts_with(b"PK") { + return false; + } + zip::ZipArchive::new(std::io::Cursor::new(bytes)).map(|z| z.index_for_name("[Content_Types].xml").is_some()).unwrap_or(false) } /// Read a `.pptx` / `.potx` / `.ppsx` package. diff --git a/crates/pptx/tests/sniff.rs b/crates/pptx/tests/sniff.rs new file mode 100644 index 00000000..69d9c961 --- /dev/null +++ b/crates/pptx/tests/sniff.rs @@ -0,0 +1,58 @@ +//! `sniff` must recognise a package wherever the writer put `[Content_Types].xml`: recent +//! PowerPoint builds store it as the last zip entry, well past the first few kilobytes. + +use std::io::{Cursor, Read, Write}; + +use deckcraft_engine::Session; +use zip::write::SimpleFileOptions; + +fn sample_pptx() -> Vec { + let mut s = Session::new(); + deckcraft_engine::sample::open_sample(&mut s).expect("sample"); + let p = s.doc().expect("doc").doc.as_ref().clone(); + deckcraft_pptx::export(&p).expect("export") +} + +/// Re-pack `bytes` with `[Content_Types].xml` moved to the end and a padding part in front, so +/// the content-types name first appears far beyond any fixed-size prefix. +fn content_types_last(bytes: &[u8]) -> Vec { + let mut src = zip::ZipArchive::new(Cursor::new(bytes)).expect("zip"); + let mut out = zip::ZipWriter::new(Cursor::new(Vec::new())); + let stored = SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored); + out.start_file("ppt/media/padding.bin", stored).expect("start"); + out.write_all(&vec![0u8; 64 * 1024]).expect("padding"); + let names: Vec = (0..src.len()).map(|i| src.by_index(i).expect("entry").name().to_string()).collect(); + for name in names.iter().filter(|n| *n != "[Content_Types].xml").chain(std::iter::once(&"[Content_Types].xml".to_string())) { + let mut data = Vec::new(); + src.by_name(name).expect("by_name").read_to_end(&mut data).expect("read"); + out.start_file(name, SimpleFileOptions::default()).expect("start"); + out.write_all(&data).expect("write"); + } + out.finish().expect("finish").into_inner() +} + +#[test] +fn sniff_accepts_content_types_anywhere_in_the_archive() { + let bytes = sample_pptx(); + assert!(deckcraft_pptx::sniff(&bytes)); + + let moved = content_types_last(&bytes); + let first_4k = &moved[..4096.min(moved.len())]; + assert!(!first_4k.windows(19).any(|w| w == b"[Content_Types].xml"), "test setup: name must not be in the prefix"); + assert!(deckcraft_pptx::sniff(&moved)); + let p = deckcraft_pptx::import(&moved).expect("import"); + assert!(!p.slides.is_empty()); +} + +#[test] +fn sniff_rejects_non_packages() { + assert!(!deckcraft_pptx::sniff(b"")); + assert!(!deckcraft_pptx::sniff(b"PK")); + assert!(!deckcraft_pptx::sniff(b"%PDF-1.7 [Content_Types].xml")); + + let mut out = zip::ZipWriter::new(Cursor::new(Vec::new())); + out.start_file("hello.txt", SimpleFileOptions::default()).expect("start"); + out.write_all(b"not an office package").expect("write"); + let plain_zip = out.finish().expect("finish").into_inner(); + assert!(!deckcraft_pptx::sniff(&plain_zip)); +}