//! PPTX Exporter - PowerPoint presentation export //! //! Generates a .pptx file (Office Open XML format) containing: //! - Title slide //! - Content slides for each scene //! - Speaker notes (optional) //! - Quiz slides //! //! Note: This is a simplified implementation that creates a valid PPTX structure //! without external dependencies. For more advanced features, consider using //! a dedicated library like `pptx-rs` or `office` crate. use crate::generation::{Classroom, GeneratedScene, SceneContent, SceneAction}; use super::{ExportOptions, ExportResult, Exporter, sanitize_filename}; use zclaw_types::{Result, ZclawError}; use std::collections::HashMap; /// PPTX exporter pub struct PptxExporter; impl PptxExporter { /// Create new PPTX exporter pub fn new() -> Self { Self } /// Generate PPTX content (as bytes) fn generate_pptx(&self, classroom: &Classroom, options: &ExportOptions) -> Result> { let mut files: HashMap> = HashMap::new(); // [Content_Types].xml files.insert( "[Content_Types].xml".to_string(), self.generate_content_types().into_bytes(), ); // _rels/.rels files.insert( "_rels/.rels".to_string(), self.generate_rels().into_bytes(), ); // docProps/app.xml files.insert( "docProps/app.xml".to_string(), self.generate_app_xml(classroom).into_bytes(), ); // docProps/core.xml files.insert( "docProps/core.xml".to_string(), self.generate_core_xml(classroom).into_bytes(), ); // ppt/presentation.xml files.insert( "ppt/presentation.xml".to_string(), self.generate_presentation_xml(classroom).into_bytes(), ); // ppt/_rels/presentation.xml.rels files.insert( "ppt/_rels/presentation.xml.rels".to_string(), self.generate_presentation_rels(classroom, options).into_bytes(), ); // Generate slides let mut slide_files = self.generate_slides(classroom, options); for (path, content) in slide_files.drain() { files.insert(path, content); } // Create ZIP archive self.create_zip_archive(files) } /// Generate [Content_Types].xml fn generate_content_types(&self) -> String { r#" "#.to_string() } /// Generate _rels/.rels fn generate_rels(&self) -> String { r#" "#.to_string() } /// Generate docProps/app.xml fn generate_app_xml(&self, classroom: &Classroom) -> String { format!( r#" ZCLAW Classroom Generator {} {} {} "#, classroom.scenes.len() + 1, // +1 for title slide xml_escape(&classroom.title), xml_escape(&classroom.topic) ) } /// Generate docProps/core.xml fn generate_core_xml(&self, classroom: &Classroom) -> String { let created = chrono::DateTime::from_timestamp_millis(classroom.metadata.generated_at) .map(|dt| dt.format("%Y-%m-%dT%H:%M:%SZ").to_string()) .unwrap_or_else(|| "2024-01-01T00:00:00Z".to_string()); format!( r#" {} {} {} {} 1 "#, xml_escape(&classroom.title), xml_escape(&classroom.topic), xml_escape(&classroom.description), created ) } /// Generate ppt/presentation.xml fn generate_presentation_xml(&self, classroom: &Classroom) -> String { let slide_count = classroom.scenes.len() + 1; // +1 for title slide let slide_ids: String = (1..=slide_count) .map(|i| format!(r#" "#, 255 + i, i)) .collect::>() .join("\n"); format!( r#" {} "#, slide_ids ) } /// Generate ppt/_rels/presentation.xml.rels fn generate_presentation_rels(&self, classroom: &Classroom, _options: &ExportOptions) -> String { let slide_count = classroom.scenes.len() + 1; let relationships: String = (1..=slide_count) .map(|i| { format!( r#" "#, i, i ) }) .collect::>() .join("\n"); format!( r#" {} "#, relationships ) } /// Generate all slide files fn generate_slides(&self, classroom: &Classroom, options: &ExportOptions) -> HashMap> { let mut files = HashMap::new(); // Title slide (slide1.xml) let title_slide = self.generate_title_slide(classroom); files.insert("ppt/slides/slide1.xml".to_string(), title_slide.into_bytes()); // Content slides for (i, scene) in classroom.scenes.iter().enumerate() { let slide_num = i + 2; // Start from 2 (1 is title) let slide_xml = self.generate_content_slide(scene, options); files.insert( format!("ppt/slides/slide{}.xml", slide_num), slide_xml.into_bytes(), ); } // Slide relationships let slide_count = classroom.scenes.len() + 1; for i in 1..=slide_count { let rels = self.generate_slide_rels(i); files.insert( format!("ppt/slides/_rels/slide{}.xml.rels", i), rels.into_bytes(), ); } files } /// Generate title slide XML fn generate_title_slide(&self, classroom: &Classroom) -> String { let _objectives = classroom.objectives.iter() .map(|o| format!("- {}", o)) .collect::>() .join("\n"); format!( r#" {} {} Duration: {} "#, xml_escape(&classroom.title), xml_escape(&classroom.description), format_duration(classroom.total_duration) ) } /// Generate content slide XML fn generate_content_slide(&self, scene: &GeneratedScene, options: &ExportOptions) -> String { let content_text = self.extract_scene_content(&scene.content); let notes = if options.include_notes { scene.content.notes.as_ref() .map(|n| self.generate_notes(n)) .unwrap_or_default() } else { String::new() }; format!( r#" {} {} {} "#, xml_escape(&scene.content.title), content_text, notes ) } /// Extract scene content as PPTX paragraphs fn extract_scene_content(&self, content: &SceneContent) -> String { let mut paragraphs = String::new(); // Add description if let Some(desc) = content.content.get("description").and_then(|v| v.as_str()) { paragraphs.push_str(&self.text_to_paragraphs(desc)); } // Add key points if let Some(points) = content.content.get("key_points").and_then(|v| v.as_array()) { for point in points { if let Some(text) = point.as_str() { paragraphs.push_str(&self.bullet_point_paragraph(text)); } } } // Add speech content for action in &content.actions { if let SceneAction::Speech { text, agent_role } = action { let prefix = if agent_role != "teacher" { format!("[{}]: ", agent_role) } else { String::new() }; paragraphs.push_str(&self.text_to_paragraphs(&format!("{}{}", prefix, text))); } } if paragraphs.is_empty() { paragraphs.push_str(&self.text_to_paragraphs("Content for this scene.")); } paragraphs } /// Convert text to PPTX paragraphs fn text_to_paragraphs(&self, text: &str) -> String { text.lines() .filter(|l| !l.trim().is_empty()) .map(|line| { format!( r#" {} "#, xml_escape(line.trim()) ) }) .collect() } /// Create bullet point paragraph fn bullet_point_paragraph(&self, text: &str) -> String { format!( r#" {} "#, xml_escape(text) ) } /// Generate speaker notes XML fn generate_notes(&self, notes: &str) -> String { format!( r#" {} "#, xml_escape(notes) ) } /// Generate slide relationships fn generate_slide_rels(&self, _slide_num: usize) -> String { r#" "#.to_string() } /// Create ZIP archive from files fn create_zip_archive(&self, files: HashMap>) -> Result> { use std::io::{Cursor, Write}; let mut buffer = Cursor::new(Vec::new()); { let mut writer = ZipWriter::new(&mut buffer); // Add files in sorted order (required by ZIP spec for deterministic output) let mut paths: Vec<_> = files.keys().collect(); paths.sort(); for path in paths { let content = files.get(path).expect("path comes from files.keys(), must exist"); let options = SimpleFileOptions::default() .compression_method(zip::CompressionMethod::Deflated); writer.start_file(path, options) .map_err(|e| ZclawError::ExportError(e.to_string()))?; writer.write_all(content) .map_err(|e| ZclawError::ExportError(e.to_string()))?; } writer.finish() .map_err(|e| ZclawError::ExportError(e.to_string()))?; } Ok(buffer.into_inner()) } } impl Default for PptxExporter { fn default() -> Self { Self::new() } } impl Exporter for PptxExporter { fn export(&self, classroom: &Classroom, options: &ExportOptions) -> Result { let content = self.generate_pptx(classroom, options)?; let filename = format!("{}.pptx", sanitize_filename(&classroom.title)); Ok(ExportResult { content, mime_type: "application/vnd.openxmlformats-officedocument.presentationml.presentation".to_string(), filename, extension: "pptx".to_string(), }) } fn format(&self) -> super::ExportFormat { super::ExportFormat::Pptx } fn extension(&self) -> &str { "pptx" } fn mime_type(&self) -> &str { "application/vnd.openxmlformats-officedocument.presentationml.presentation" } } // Helper functions /// Escape XML special characters fn xml_escape(s: &str) -> String { s.replace('&', "&") .replace('<', "<") .replace('>', ">") .replace('"', """) .replace('\'', "'") } /// Format duration fn format_duration(seconds: u32) -> String { let minutes = seconds / 60; let secs = seconds % 60; if secs > 0 { format!("{}m {}s", minutes, secs) } else { format!("{}m", minutes) } } // ZIP writing (minimal implementation) use zip::{ZipWriter, write::SimpleFileOptions}; #[cfg(test)] mod tests { use super::*; use crate::generation::{ClassroomMetadata, TeachingStyle, DifficultyLevel, SceneType}; fn create_test_classroom() -> Classroom { Classroom { id: "test-1".to_string(), title: "Test Classroom".to_string(), description: "A test classroom".to_string(), topic: "Testing".to_string(), style: TeachingStyle::Lecture, level: DifficultyLevel::Beginner, total_duration: 1800, objectives: vec!["Learn A".to_string(), "Learn B".to_string()], scenes: vec![ GeneratedScene { id: "scene-1".to_string(), outline_id: "outline-1".to_string(), content: SceneContent { title: "Introduction".to_string(), scene_type: SceneType::Slide, content: serde_json::json!({ "description": "Intro slide content", "key_points": ["Point 1", "Point 2"] }), actions: vec![SceneAction::Speech { text: "Welcome!".to_string(), agent_role: "teacher".to_string(), }], duration_seconds: 600, notes: Some("Speaker notes here".to_string()), }, order: 0, }, ], agents: vec![], metadata: ClassroomMetadata::default(), } } #[test] fn test_pptx_export() { let exporter = PptxExporter::new(); let classroom = create_test_classroom(); let options = ExportOptions::default(); let result = exporter.export(&classroom, &options).unwrap(); assert_eq!(result.extension, "pptx"); assert!(result.filename.ends_with(".pptx")); assert!(!result.content.is_empty()); // Verify it's a valid ZIP file let cursor = std::io::Cursor::new(&result.content); let mut archive = zip::ZipArchive::new(cursor).unwrap(); assert!(archive.by_name("[Content_Types].xml").is_ok()); assert!(archive.by_name("ppt/presentation.xml").is_ok()); } #[test] fn test_xml_escape() { assert_eq!(xml_escape("Hello "), "Hello <World>"); assert_eq!(xml_escape("A & B"), "A & B"); assert_eq!(xml_escape("Say \"Hi\""), "Say "Hi""); } #[test] fn test_pptx_format() { let exporter = PptxExporter::new(); assert_eq!(exporter.extension(), "pptx"); assert_eq!(exporter.format(), super::super::ExportFormat::Pptx); } }