Some checks failed
CI / Lint & TypeCheck (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Build Frontend (push) Has been cancelled
CI / Rust Check (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
P0 修复:
- viking_commands.rs: URI 路径构建 unwrap → ok_or_else 错误传播
- clip.rs: 临时文件路径 unwrap → ok_or_else (防 Windows 中文路径 panic)
P1 修复:
- personality_detector.rs: Mutex lock unwrap → unwrap_or_else 防中毒传播
- pptx.rs: HashMap.get unwrap → expect (来自 keys() 迭代)
P2 修复:
- 4 处 SystemTime.unwrap → expect("system clock is valid")
- 4 处 dev_server URL.parse.unwrap → expect("hardcoded URL is valid")
- 9 处 nl_schedule Regex.unwrap → expect("static regex is valid")
- 5 处 data_masking Regex.unwrap → expect("static regex is valid")
- 2 处 pipeline/state Regex.unwrap → expect("static regex is valid")
全量测试通过: 719 passed, 0 failed
642 lines
21 KiB
Rust
642 lines
21 KiB
Rust
//! 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<Vec<u8>> {
|
|
let mut files: HashMap<String, Vec<u8>> = 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#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
|
<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">
|
|
<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>
|
|
<Default Extension="xml" ContentType="application/xml"/>
|
|
<Override PartName="/ppt/presentation.xml" ContentType="application/vnd.openxmlformats-officedocument.presentationml.presentation.main+xml"/>
|
|
<Override PartName="/docProps/core.xml" ContentType="application/vnd.openxmlformats-package.core-properties+xml"/>
|
|
<Override PartName="/docProps/app.xml" ContentType="application/vnd.openxmlformats-officedocument.extended-properties+xml"/>
|
|
<Override PartName="/ppt/slide1.xml" ContentType="application/vnd.openxmlformats-officedocument.presentationml.slide+xml"/>
|
|
</Types>"#.to_string()
|
|
}
|
|
|
|
/// Generate _rels/.rels
|
|
fn generate_rels(&self) -> String {
|
|
r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
|
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
|
|
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="ppt/presentation.xml"/>
|
|
<Relationship Id="rId2" Type="http://schemas.openxmlformats.org/package/2006/relationships/metadata/core-properties" Target="docProps/core.xml"/>
|
|
<Relationship Id="rId3" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/extended-properties" Target="docProps/app.xml"/>
|
|
</Relationships>"#.to_string()
|
|
}
|
|
|
|
/// Generate docProps/app.xml
|
|
fn generate_app_xml(&self, classroom: &Classroom) -> String {
|
|
format!(
|
|
r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
|
<Properties xmlns="http://schemas.openxmlformats.org/officeDocument/2006/extended-properties">
|
|
<Application>ZCLAW Classroom Generator</Application>
|
|
<Slides>{}</Slides>
|
|
<Title>{}</Title>
|
|
<Subject>{}</Subject>
|
|
</Properties>"#,
|
|
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#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
|
<cp:coreProperties xmlns:cp="http://schemas.openxmlformats.org/package/2006/metadata/core-properties" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:dcterms="http://purl.org/dc/terms/" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
|
|
<dc:title>{}</dc:title>
|
|
<dc:subject>{}</dc:subject>
|
|
<dc:description>{}</dc:description>
|
|
<dcterms:created xsi:type="dcterms:W3CDTF">{}</dcterms:created>
|
|
<cp:revision>1</cp:revision>
|
|
</cp:coreProperties>"#,
|
|
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#" <p:sldId id="{}" r:id="rId{}"/>"#, 255 + i, i))
|
|
.collect::<Vec<_>>()
|
|
.join("\n");
|
|
|
|
format!(
|
|
r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
|
<p:presentation xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main">
|
|
<p:sldIdLst>
|
|
{}
|
|
</p:sldIdLst>
|
|
<p:sldSz cx="9144000" cy="6858000"/>
|
|
<p:notesSz cx="6858000" cy="9144000"/>
|
|
</p:presentation>"#,
|
|
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#" <Relationship Id="rId{}" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/slide" Target="slides/slide{}.xml"/>"#,
|
|
i, i
|
|
)
|
|
})
|
|
.collect::<Vec<_>>()
|
|
.join("\n");
|
|
|
|
format!(
|
|
r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
|
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
|
|
{}
|
|
</Relationships>"#,
|
|
relationships
|
|
)
|
|
}
|
|
|
|
/// Generate all slide files
|
|
fn generate_slides(&self, classroom: &Classroom, options: &ExportOptions) -> HashMap<String, Vec<u8>> {
|
|
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::<Vec<_>>()
|
|
.join("\n");
|
|
|
|
format!(
|
|
r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
|
<p:sld xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main">
|
|
<p:cSld>
|
|
<p:spTree>
|
|
<p:nvGrpSpPr>
|
|
<p:cNvPr id="1" name=""/>
|
|
<p:nvPr/>
|
|
</p:nvGrpSpPr>
|
|
<p:grpSpPr>
|
|
<a:xfrm>
|
|
<a:off x="0" y="0"/>
|
|
<a:ext cx="0" cy="0"/>
|
|
<a:chOff x="0" y="0"/>
|
|
<a:chExt cx="0" cy="0"/>
|
|
</a:xfrm>
|
|
</p:grpSpPr>
|
|
<p:sp>
|
|
<p:nvSpPr>
|
|
<p:cNvPr id="2" name="Title"/>
|
|
<p:nvPr>
|
|
<p:ph type="ctrTitle"/>
|
|
</p:nvPr>
|
|
</p:nvSpPr>
|
|
<p:spPr>
|
|
<a:xfrm>
|
|
<a:off x="457200" y="2746388"/>
|
|
<a:ext cx="8229600" cy="1143000"/>
|
|
</a:xfrm>
|
|
</p:spPr>
|
|
<p:txBody>
|
|
<a:bodyPr/>
|
|
<a:p>
|
|
<a:r>
|
|
<a:rPr lang="zh-CN"/>
|
|
<a:t>{}</a:t>
|
|
</a:r>
|
|
</a:p>
|
|
</p:txBody>
|
|
</p:sp>
|
|
<p:sp>
|
|
<p:nvSpPr>
|
|
<p:cNvPr id="3" name="Subtitle"/>
|
|
<p:nvPr>
|
|
<p:ph type="subTitle"/>
|
|
</p:nvPr>
|
|
</p:nvSpPr>
|
|
<p:spPr>
|
|
<a:xfrm>
|
|
<a:off x="457200" y="4039388"/>
|
|
<a:ext cx="8229600" cy="609600"/>
|
|
</a:xfrm>
|
|
</p:spPr>
|
|
<p:txBody>
|
|
<a:bodyPr/>
|
|
<a:p>
|
|
<a:r>
|
|
<a:rPr lang="zh-CN"/>
|
|
<a:t>{}</a:t>
|
|
</a:r>
|
|
</a:p>
|
|
<a:p>
|
|
<a:r>
|
|
<a:rPr lang="zh-CN"/>
|
|
<a:t>Duration: {}</a:t>
|
|
</a:r>
|
|
</a:p>
|
|
</p:txBody>
|
|
</p:sp>
|
|
</p:spTree>
|
|
</p:cSld>
|
|
</p:sld>"#,
|
|
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 version="1.0" encoding="UTF-8" standalone="yes"?>
|
|
<p:sld xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main">
|
|
<p:cSld>
|
|
<p:spTree>
|
|
<p:nvGrpSpPr>
|
|
<p:cNvPr id="1" name=""/>
|
|
<p:nvPr/>
|
|
</p:nvGrpSpPr>
|
|
<p:grpSpPr>
|
|
<a:xfrm>
|
|
<a:off x="0" y="0"/>
|
|
<a:ext cx="0" cy="0"/>
|
|
<a:chOff x="0" y="0"/>
|
|
<a:chExt cx="0" cy="0"/>
|
|
</a:xfrm>
|
|
</p:grpSpPr>
|
|
<p:sp>
|
|
<p:nvSpPr>
|
|
<p:cNvPr id="2" name="Title"/>
|
|
<p:nvPr>
|
|
<p:ph type="title"/>
|
|
</p:nvPr>
|
|
</p:nvSpPr>
|
|
<p:spPr>
|
|
<a:xfrm>
|
|
<a:off x="457200" y="274638"/>
|
|
<a:ext cx="8229600" cy="1143000"/>
|
|
</a:xfrm>
|
|
</p:spPr>
|
|
<p:txBody>
|
|
<a:bodyPr/>
|
|
<a:p>
|
|
<a:r>
|
|
<a:rPr lang="zh-CN"/>
|
|
<a:t>{}</a:t>
|
|
</a:r>
|
|
</a:p>
|
|
</p:txBody>
|
|
</p:sp>
|
|
<p:sp>
|
|
<p:nvSpPr>
|
|
<p:cNvPr id="3" name="Content"/>
|
|
<p:nvPr>
|
|
<p:ph type="body"/>
|
|
</p:nvPr>
|
|
</p:nvSpPr>
|
|
<p:spPr>
|
|
<a:xfrm>
|
|
<a:off x="457200" y="1600200"/>
|
|
<a:ext cx="8229600" cy="4572000"/>
|
|
</a:xfrm>
|
|
</p:spPr>
|
|
<p:txBody>
|
|
<a:bodyPr/>
|
|
{}
|
|
</p:txBody>
|
|
</p:sp>
|
|
</p:spTree>
|
|
</p:cSld>
|
|
{}
|
|
</p:sld>"#,
|
|
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#" <a:p>
|
|
<a:r>
|
|
<a:rPr lang="zh-CN"/>
|
|
<a:t>{}</a:t>
|
|
</a:r>
|
|
</a:p>
|
|
"#,
|
|
xml_escape(line.trim())
|
|
)
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
/// Create bullet point paragraph
|
|
fn bullet_point_paragraph(&self, text: &str) -> String {
|
|
format!(
|
|
r#" <a:p>
|
|
<a:pPr lvl="1">
|
|
<a:buFont typeface="Arial"/>
|
|
<a:buChar char="•"/>
|
|
</a:pPr>
|
|
<a:r>
|
|
<a:rPr lang="zh-CN"/>
|
|
<a:t>{}</a:t>
|
|
</a:r>
|
|
</a:p>
|
|
"#,
|
|
xml_escape(text)
|
|
)
|
|
}
|
|
|
|
/// Generate speaker notes XML
|
|
fn generate_notes(&self, notes: &str) -> String {
|
|
format!(
|
|
r#" <p:notes>
|
|
<p:cSld>
|
|
<p:spTree>
|
|
<p:sp>
|
|
<p:txBody>
|
|
<a:bodyPr/>
|
|
<a:p>
|
|
<a:r>
|
|
<a:t>{}</a:t>
|
|
</a:r>
|
|
</a:p>
|
|
</p:txBody>
|
|
</p:sp>
|
|
</p:spTree>
|
|
</p:cSld>
|
|
</p:notes>"#,
|
|
xml_escape(notes)
|
|
)
|
|
}
|
|
|
|
/// Generate slide relationships
|
|
fn generate_slide_rels(&self, _slide_num: usize) -> String {
|
|
r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
|
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
|
|
</Relationships>"#.to_string()
|
|
}
|
|
|
|
/// Create ZIP archive from files
|
|
fn create_zip_archive(&self, files: HashMap<String, Vec<u8>>) -> Result<Vec<u8>> {
|
|
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<ExportResult> {
|
|
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 <World>"), "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);
|
|
}
|
|
}
|