Files
zclaw_openfang/crates/zclaw-kernel/src/export/pptx.rs
iven 4329bae1ea
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
fix(audit): Batch 2 生产代码 unwrap 替换 (20 处)
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
2026-04-19 08:38:09 +08:00

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('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;")
.replace('\'', "&apos;")
}
/// 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 &lt;World&gt;");
assert_eq!(xml_escape("A & B"), "A &amp; B");
assert_eq!(xml_escape("Say \"Hi\""), "Say &quot;Hi&quot;");
}
#[test]
fn test_pptx_format() {
let exporter = PptxExporter::new();
assert_eq!(exporter.extension(), "pptx");
assert_eq!(exporter.format(), super::super::ExportFormat::Pptx);
}
}