pub const PPTX_OOXML_EXTS: &[&str] = &[".pptx", ".potx", ".potm", ".ppsx", ".ppsm"];
pub fn is_pptx_ooxml(path: &str) -> bool {
let lower = path.to_ascii_lowercase();
PPTX_OOXML_EXTS.iter().any(|ext| lower.ends_with(ext))
}
pub fn pptx_exts_display() -> String {
PPTX_OOXML_EXTS.join(", ")
}
pub use crate::shared::{has_keyword_overlap, split_sentences, tokenize_keywords};
pub const MAX_CHUNK_CHARS: usize = 1200;
pub const MIN_CHUNK_CHARS: usize = 350;
pub const CLASSIFY_LONG_CHARS: usize = 900;
pub const CLASSIFY_SHORT_CHARS: usize = 90;
pub use super::archive::*;
pub use super::classify::*;
pub use super::presentation::*;
pub use super::slide_images::*;
pub use super::slide_model::*;
pub use super::slide_xml::*;
pub use super::text_split::*;
pub use super::xml_util::*;
#[cfg(test)]
mod tests {
use super::*;
use std::io::{Cursor, Write};
fn slide_xml(title: &str, body: &str) -> String {
format!(
r#"<?xml version="1.0" encoding="UTF-8"?>
<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:nvSpPr><p:nvPr><p:ph type="title"/></p:nvPr></p:nvSpPr>
<p:txBody>
<a:p><a:r><a:t>{title}</a:t></a:r></a:p>
</p:txBody>
</p:sp>
<p:sp>
<p:nvSpPr><p:nvPr><p:ph idx="1"/></p:nvPr></p:nvSpPr>
<p:txBody>
<a:p><a:r><a:t>{body}</a:t></a:r></a:p>
</p:txBody>
</p:sp>
</p:spTree>
</p:cSld>
</p:sld>"#
)
}
fn make_pptx(slides: &[(&str, &str)]) -> Vec<u8> {
let cursor = Cursor::new(Vec::new());
let mut zip = zip::ZipWriter::new(cursor);
let opts = zip::write::FileOptions::<()>::default()
.compression_method(zip::CompressionMethod::Stored);
for (i, (title, body)) in slides.iter().enumerate() {
let path = format!("ppt/slides/slide{}.xml", i + 1);
zip.start_file(path, opts.clone()).unwrap();
zip.write_all(slide_xml(title, body).as_bytes()).unwrap();
}
zip.finish().unwrap().into_inner()
}
#[test]
fn parse_slide_xml_extracts_title() {
let xml = slide_xml("My Title", "Body text here.");
let slide = parse_slide_xml(xml.as_bytes()).unwrap();
assert_eq!(slide.title.as_deref(), Some("My Title"));
}
#[test]
fn parse_slide_xml_extracts_body() {
let xml = slide_xml("Title", "Body paragraph content.");
let slide = parse_slide_xml(xml.as_bytes()).unwrap();
assert!(!slide.body_paragraphs.is_empty());
assert!(slide.body_paragraphs[0].contains("Body paragraph"));
}
#[test]
fn parse_slide_xml_empty_body_is_section_divider() {
let xml = slide_xml("Section Title", "");
let slide = parse_slide_xml(xml.as_bytes()).unwrap();
assert!(
slide.is_section_divider(),
"title-only slide should be a section divider"
);
}
#[test]
fn parse_slide_xml_with_body_is_not_section_divider() {
let xml = slide_xml("Title", "Body content present.");
let slide = parse_slide_xml(xml.as_bytes()).unwrap();
assert!(!slide.is_section_divider());
}
#[test]
fn parse_slide_xml_invalid_xml_returns_error() {
let bad = b"not xml at all <<>>";
let _ = parse_slide_xml(bad);
}
#[test]
fn all_text_joins_title_and_body() {
let s = SlideContent {
title: Some("Title".to_string()),
body_paragraphs: vec!["Body".to_string()],
has_table: false,
notes_text: None,
..Default::default()
};
let t = s.all_text();
assert!(t.contains("Title"));
assert!(t.contains("Body"));
}
#[test]
fn all_text_appends_notes_with_separator() {
let s = SlideContent {
title: Some("Title".to_string()),
body_paragraphs: vec!["Body".to_string()],
has_table: false,
notes_text: Some("Speaker note text.".to_string()),
..Default::default()
};
let t = s.all_text();
assert!(t.contains("[Notes]"));
assert!(t.contains("Speaker note text."));
}
#[test]
fn all_text_table_prefixes_body_paragraph() {
let s = SlideContent {
title: Some("Title".to_string()),
body_paragraphs: vec!["Cell A | Cell B".to_string()],
has_table: true,
notes_text: None,
..Default::default()
};
let t = s.all_text();
assert!(
t.contains("Table:"),
"has_table should prefix body with 'Table:'"
);
}
#[test]
fn open_pptx_with_valid_zip_succeeds() {
let bytes = make_pptx(&[("Title", "Body")]);
assert!(open_pptx(&bytes).is_ok());
}
#[test]
fn open_pptx_with_invalid_bytes_returns_error() {
assert!(open_pptx(b"not a zip file").is_err());
}
#[test]
fn collect_slide_names_finds_slides_in_order() {
let bytes = make_pptx(&[
("Slide 1", "Body 1"),
("Slide 2", "Body 2"),
("Slide 3", "Body 3"),
]);
let archive = open_pptx(&bytes).unwrap();
let names = collect_slide_names(&archive);
assert_eq!(names.len(), 3);
assert_eq!(names[0].0, 1);
assert_eq!(names[1].0, 2);
assert_eq!(names[2].0, 3);
}
#[test]
fn collect_slide_names_excludes_layouts_and_masters() {
let cursor = Cursor::new(Vec::new());
let mut zip = zip::ZipWriter::new(cursor);
let opts = zip::write::FileOptions::<()>::default()
.compression_method(zip::CompressionMethod::Stored);
zip.start_file("ppt/slides/slideLayout1.xml", opts.clone())
.unwrap();
zip.write_all(b"<layout/>").unwrap();
zip.start_file("ppt/slides/slide1.xml", opts).unwrap();
zip.write_all(slide_xml("Title", "Body").as_bytes())
.unwrap();
let bytes = zip.finish().unwrap().into_inner();
let archive = open_pptx(&bytes).unwrap();
let names = collect_slide_names(&archive);
assert_eq!(
names.len(),
1,
"layout should be excluded, only slide1 present"
);
}
#[test]
fn classify_chunk_bullet_lines_is_list() {
let text = "- First bullet\n- Second bullet\n- Third bullet";
assert_eq!(classify_chunk(text).as_str(), "bullet_list");
}
#[test]
fn classify_chunk_table_prefix_is_table() {
let text = "Table: Cell A | Cell B\nRow 2 | Value 2";
assert_eq!(classify_chunk(text).as_str(), "table");
}
#[test]
fn classify_chunk_long_text_is_long_paragraph() {
let text = "x".repeat(CLASSIFY_LONG_CHARS + 1);
assert_eq!(
classify_chunk(text.as_str()).as_str(),
"long_single_paragraph"
);
}
#[test]
fn classify_chunk_short_text_is_short_paragraph() {
let text = "x".repeat(CLASSIFY_SHORT_CHARS - 1);
assert_eq!(
classify_chunk(text.as_str()).as_str(),
"short_disconnected_paragraph"
);
}
#[test]
fn is_heading_style_all_caps() {
assert!(is_heading_style("INTRODUCTION"));
}
#[test]
fn is_heading_style_ends_with_colon() {
assert!(is_heading_style("Summary:"));
}
#[test]
fn looks_like_sentence_long_text() {
assert!(looks_like_sentence(
"this is a sentence with eight words here"
));
}
#[test]
fn looks_like_sentence_ends_with_period() {
assert!(looks_like_sentence("Short text."));
}
#[test]
fn is_bullet_line_dash_and_star() {
assert!(is_bullet_line("- item"));
assert!(is_bullet_line("* item"));
}
#[test]
fn is_bullet_line_unicode_bullets() {
assert!(is_bullet_line("\u{2022} item"));
}
#[test]
fn is_numbered_line_dot_separator() {
assert!(is_numbered_line("1. item"));
assert!(is_numbered_line("10. item"));
}
#[test]
fn is_numbered_line_paren_separator() {
assert!(is_numbered_line("1) item"));
}
#[test]
fn is_numbered_line_too_many_digits_is_false() {
assert!(!is_numbered_line("1234. item"));
}
#[test]
fn split_large_text_short_input_unchanged() {
let t = "Short text.";
let parts = split_large_text(t, 1000);
assert_eq!(parts.len(), 1);
assert_eq!(parts[0], t);
}
#[test]
fn split_large_text_splits_long_input() {
let t = "Sentence one. ".repeat(100);
let parts = split_large_text(&t, 200);
assert!(parts.len() > 1, "long text should be split");
assert!(parts.iter().all(|p| !p.is_empty()));
}
}