release(v0.2.0): streaming, MCP protocol, Browser Hand, security enhancements
## Major Features ### Streaming Response System - Implement LlmDriver trait with `stream()` method returning async Stream - Add SSE parsing for Anthropic and OpenAI API streaming - Integrate Tauri event system for frontend streaming (`stream:chunk` events) - Add StreamChunk types: Delta, ToolStart, ToolEnd, Complete, Error ### MCP Protocol Implementation - Add MCP JSON-RPC 2.0 types (mcp_types.rs) - Implement stdio-based MCP transport (mcp_transport.rs) - Support tool discovery, execution, and resource operations ### Browser Hand Implementation - Complete browser automation with Playwright-style actions - Support Navigate, Click, Type, Scrape, Screenshot, Wait actions - Add educational Hands: Whiteboard, Slideshow, Speech, Quiz ### Security Enhancements - Implement command whitelist/blacklist for shell_exec tool - Add SSRF protection with private IP blocking - Create security.toml configuration file ## Test Improvements - Fix test import paths (security-utils, setup) - Fix vi.mock hoisting issues with vi.hoisted() - Update test expectations for validateUrl and sanitizeFilename - Add getUnsupportedLocalGatewayStatus mock ## Documentation Updates - Update architecture documentation - Improve configuration reference - Add quick-start guide updates Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
822
crates/zclaw-kernel/src/export/html.rs
Normal file
822
crates/zclaw-kernel/src/export/html.rs
Normal file
@@ -0,0 +1,822 @@
|
||||
//! HTML Exporter - Interactive web-based classroom export
|
||||
//!
|
||||
//! Generates a self-contained HTML file with:
|
||||
//! - Responsive layout
|
||||
//! - Scene navigation
|
||||
//! - Speaker notes toggle
|
||||
//! - Table of contents
|
||||
//! - Embedded CSS/JS
|
||||
|
||||
use crate::generation::{Classroom, GeneratedScene, SceneContent, SceneType, SceneAction};
|
||||
use super::{ExportOptions, ExportResult, Exporter, sanitize_filename};
|
||||
use zclaw_types::Result;
|
||||
use zclaw_types::ZclawError;
|
||||
|
||||
/// HTML exporter
|
||||
pub struct HtmlExporter {
|
||||
/// Template name
|
||||
template: String,
|
||||
}
|
||||
|
||||
impl HtmlExporter {
|
||||
/// Create new HTML exporter
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
template: "default".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Create with specific template
|
||||
pub fn with_template(template: &str) -> Self {
|
||||
Self {
|
||||
template: template.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Generate HTML content
|
||||
fn generate_html(&self, classroom: &Classroom, options: &ExportOptions) -> Result<String> {
|
||||
let mut html = String::new();
|
||||
|
||||
// HTML header
|
||||
html.push_str(&self.generate_header(classroom, options));
|
||||
|
||||
// Body content
|
||||
html.push_str("<body>\n");
|
||||
html.push_str(&self.generate_body_start(classroom, options));
|
||||
|
||||
// Title slide
|
||||
if options.title_slide {
|
||||
html.push_str(&self.generate_title_slide(classroom));
|
||||
}
|
||||
|
||||
// Table of contents
|
||||
if options.table_of_contents {
|
||||
html.push_str(&self.generate_toc(classroom));
|
||||
}
|
||||
|
||||
// Scenes
|
||||
html.push_str("<main class=\"scenes\">\n");
|
||||
for scene in &classroom.scenes {
|
||||
html.push_str(&self.generate_scene(scene, options));
|
||||
}
|
||||
html.push_str("</main>\n");
|
||||
|
||||
// Footer
|
||||
html.push_str(&self.generate_footer(classroom));
|
||||
|
||||
html.push_str(&self.generate_body_end());
|
||||
html.push_str("</body>\n</html>");
|
||||
|
||||
Ok(html)
|
||||
}
|
||||
|
||||
/// Generate HTML header with embedded CSS
|
||||
fn generate_header(&self, classroom: &Classroom, options: &ExportOptions) -> String {
|
||||
let custom_css = options.custom_css.as_deref().unwrap_or("");
|
||||
|
||||
format!(
|
||||
r#"<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{title}</title>
|
||||
<style>
|
||||
{default_css}
|
||||
{custom_css}
|
||||
</style>
|
||||
</head>
|
||||
"#,
|
||||
title = html_escape(&classroom.title),
|
||||
default_css = get_default_css(),
|
||||
custom_css = custom_css,
|
||||
)
|
||||
}
|
||||
|
||||
/// Generate body start with navigation
|
||||
fn generate_body_start(&self, classroom: &Classroom, _options: &ExportOptions) -> String {
|
||||
format!(
|
||||
r#"
|
||||
<nav class="top-nav">
|
||||
<div class="nav-brand">{title}</div>
|
||||
<div class="nav-controls">
|
||||
<button id="toggle-notes" class="btn">Notes</button>
|
||||
<button id="toggle-toc" class="btn">Contents</button>
|
||||
<button id="prev-scene" class="btn">← Prev</button>
|
||||
<span id="scene-counter">1 / {total}</span>
|
||||
<button id="next-scene" class="btn">Next →</button>
|
||||
</div>
|
||||
</nav>
|
||||
"#,
|
||||
title = html_escape(&classroom.title),
|
||||
total = classroom.scenes.len(),
|
||||
)
|
||||
}
|
||||
|
||||
/// Generate title slide
|
||||
fn generate_title_slide(&self, classroom: &Classroom) -> String {
|
||||
format!(
|
||||
r#"
|
||||
<section class="scene title-slide" id="scene-0">
|
||||
<div class="scene-content">
|
||||
<h1>{title}</h1>
|
||||
<p class="description">{description}</p>
|
||||
<div class="meta">
|
||||
<span class="topic">{topic}</span>
|
||||
<span class="level">{level}</span>
|
||||
<span class="duration">{duration}</span>
|
||||
</div>
|
||||
<div class="objectives">
|
||||
<h3>Learning Objectives</h3>
|
||||
<ul>
|
||||
{objectives}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
"#,
|
||||
title = html_escape(&classroom.title),
|
||||
description = html_escape(&classroom.description),
|
||||
topic = html_escape(&classroom.topic),
|
||||
level = format_level(&classroom.level),
|
||||
duration = format_duration(classroom.total_duration),
|
||||
objectives = classroom.objectives.iter()
|
||||
.map(|o| format!(" <li>{}</li>", html_escape(o)))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n"),
|
||||
)
|
||||
}
|
||||
|
||||
/// Generate table of contents
|
||||
fn generate_toc(&self, classroom: &Classroom) -> String {
|
||||
let items: String = classroom.scenes.iter()
|
||||
.enumerate()
|
||||
.map(|(i, scene)| {
|
||||
format!(
|
||||
" <li><a href=\"#scene-{}\">{}</a></li>",
|
||||
i + 1,
|
||||
html_escape(&scene.content.title)
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
|
||||
format!(
|
||||
r#"
|
||||
<aside class="toc" id="toc-panel">
|
||||
<h2>Contents</h2>
|
||||
<ol>
|
||||
{}
|
||||
</ol>
|
||||
</aside>
|
||||
"#,
|
||||
items
|
||||
)
|
||||
}
|
||||
|
||||
/// Generate a single scene
|
||||
fn generate_scene(&self, scene: &GeneratedScene, options: &ExportOptions) -> String {
|
||||
let notes_html = if options.include_notes {
|
||||
scene.content.notes.as_ref()
|
||||
.map(|n| format!(
|
||||
r#" <aside class="speaker-notes">{}</aside>"#,
|
||||
html_escape(n)
|
||||
))
|
||||
.unwrap_or_default()
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
|
||||
let actions_html = self.generate_actions(&scene.content.actions);
|
||||
|
||||
format!(
|
||||
r#"
|
||||
<section class="scene scene-{type}" id="scene-{order}" data-duration="{duration}">
|
||||
<div class="scene-header">
|
||||
<h2>{title}</h2>
|
||||
<span class="scene-type">{type}</span>
|
||||
</div>
|
||||
<div class="scene-body">
|
||||
{content}
|
||||
{actions}
|
||||
</div>
|
||||
{notes}
|
||||
</section>
|
||||
"#,
|
||||
type = format_scene_type(&scene.content.scene_type),
|
||||
order = scene.order + 1,
|
||||
duration = scene.content.duration_seconds,
|
||||
title = html_escape(&scene.content.title),
|
||||
content = self.format_scene_content(&scene.content),
|
||||
actions = actions_html,
|
||||
notes = notes_html,
|
||||
)
|
||||
}
|
||||
|
||||
/// Format scene content based on type
|
||||
fn format_scene_content(&self, content: &SceneContent) -> String {
|
||||
match content.scene_type {
|
||||
SceneType::Slide => {
|
||||
if let Some(desc) = content.content.get("description").and_then(|v| v.as_str()) {
|
||||
format!("<p class=\"slide-description\">{}</p>", html_escape(desc))
|
||||
} else {
|
||||
String::new()
|
||||
}
|
||||
}
|
||||
SceneType::Quiz => {
|
||||
let questions = content.content.get("questions")
|
||||
.and_then(|v| v.as_array())
|
||||
.map(|arr| {
|
||||
arr.iter()
|
||||
.filter_map(|q| {
|
||||
let text = q.get("text").and_then(|t| t.as_str()).unwrap_or("");
|
||||
Some(format!("<li>{}</li>", html_escape(text)))
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
format!(
|
||||
r#"<div class="quiz-questions"><ol>{}</ol></div>"#,
|
||||
questions
|
||||
)
|
||||
}
|
||||
SceneType::Discussion => {
|
||||
if let Some(topic) = content.content.get("discussion_topic").and_then(|v| v.as_str()) {
|
||||
format!("<p class=\"discussion-topic\">Discussion: {}</p>", html_escape(topic))
|
||||
} else {
|
||||
String::new()
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
if let Some(desc) = content.content.get("description").and_then(|v| v.as_str()) {
|
||||
format!("<p>{}</p>", html_escape(desc))
|
||||
} else {
|
||||
String::new()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Generate actions section
|
||||
fn generate_actions(&self, actions: &[SceneAction]) -> String {
|
||||
if actions.is_empty() {
|
||||
return String::new();
|
||||
}
|
||||
|
||||
let actions_html: String = actions.iter()
|
||||
.filter_map(|action| match action {
|
||||
SceneAction::Speech { text, agent_role } => Some(format!(
|
||||
r#" <div class="action speech" data-role="{}">
|
||||
<span class="role">{}</span>
|
||||
<p>{}</p>
|
||||
</div>"#,
|
||||
html_escape(agent_role),
|
||||
html_escape(agent_role),
|
||||
html_escape(text)
|
||||
)),
|
||||
SceneAction::WhiteboardDrawText { text, .. } => Some(format!(
|
||||
r#" <div class="action whiteboard-text">
|
||||
<span class="label">Whiteboard:</span>
|
||||
<code>{}</code>
|
||||
</div>"#,
|
||||
html_escape(text)
|
||||
)),
|
||||
SceneAction::WhiteboardDrawShape { shape, .. } => Some(format!(
|
||||
r#" <div class="action whiteboard-shape">
|
||||
<span class="label">Draw:</span>
|
||||
<span>{}</span>
|
||||
</div>"#,
|
||||
html_escape(shape)
|
||||
)),
|
||||
SceneAction::QuizShow { quiz_id } => Some(format!(
|
||||
r#" <div class="action quiz-show" data-quiz-id="{}">
|
||||
<span class="label">Quiz:</span>
|
||||
<span>{}</span>
|
||||
</div>"#,
|
||||
html_escape(quiz_id),
|
||||
html_escape(quiz_id)
|
||||
)),
|
||||
SceneAction::Discussion { topic, duration_seconds } => Some(format!(
|
||||
r#" <div class="action discussion">
|
||||
<span class="label">Discussion:</span>
|
||||
<span>{}</span>
|
||||
<span class="duration">({}s)</span>
|
||||
</div>"#,
|
||||
html_escape(topic),
|
||||
duration_seconds.unwrap_or(300)
|
||||
)),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
|
||||
if actions_html.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!(
|
||||
r#"<div class="actions">
|
||||
{}
|
||||
</div>"#,
|
||||
actions_html
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Generate footer
|
||||
fn generate_footer(&self, classroom: &Classroom) -> String {
|
||||
format!(
|
||||
r#"
|
||||
<footer class="classroom-footer">
|
||||
<p>Generated by ZCLAW</p>
|
||||
<p>Topic: {topic} | Duration: {duration} | Style: {style}</p>
|
||||
</footer>
|
||||
"#,
|
||||
topic = html_escape(&classroom.topic),
|
||||
duration = format_duration(classroom.total_duration),
|
||||
style = format_style(&classroom.style),
|
||||
)
|
||||
}
|
||||
|
||||
/// Generate body end with JavaScript
|
||||
fn generate_body_end(&self) -> String {
|
||||
format!(
|
||||
r#"
|
||||
<script>
|
||||
{js}
|
||||
</script>
|
||||
"#,
|
||||
js = get_default_js()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for HtmlExporter {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl Exporter for HtmlExporter {
|
||||
fn export(&self, classroom: &Classroom, options: &ExportOptions) -> Result<ExportResult> {
|
||||
let html = self.generate_html(classroom, options)?;
|
||||
let filename = format!("{}.html", sanitize_filename(&classroom.title));
|
||||
|
||||
Ok(ExportResult {
|
||||
content: html.into_bytes(),
|
||||
mime_type: "text/html".to_string(),
|
||||
filename,
|
||||
extension: "html".to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
fn format(&self) -> super::ExportFormat {
|
||||
super::ExportFormat::Html
|
||||
}
|
||||
|
||||
fn extension(&self) -> &str {
|
||||
"html"
|
||||
}
|
||||
|
||||
fn mime_type(&self) -> &str {
|
||||
"text/html"
|
||||
}
|
||||
}
|
||||
|
||||
// Helper functions
|
||||
|
||||
/// Escape HTML special characters
|
||||
fn html_escape(s: &str) -> String {
|
||||
s.replace('&', "&")
|
||||
.replace('<', "<")
|
||||
.replace('>', ">")
|
||||
.replace('"', """)
|
||||
.replace('\'', "'")
|
||||
}
|
||||
|
||||
/// Format duration in minutes
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
/// Format difficulty level
|
||||
fn format_level(level: &crate::generation::DifficultyLevel) -> String {
|
||||
match level {
|
||||
crate::generation::DifficultyLevel::Beginner => "Beginner",
|
||||
crate::generation::DifficultyLevel::Intermediate => "Intermediate",
|
||||
crate::generation::DifficultyLevel::Advanced => "Advanced",
|
||||
crate::generation::DifficultyLevel::Expert => "Expert",
|
||||
}.to_string()
|
||||
}
|
||||
|
||||
/// Format teaching style
|
||||
fn format_style(style: &crate::generation::TeachingStyle) -> String {
|
||||
match style {
|
||||
crate::generation::TeachingStyle::Lecture => "Lecture",
|
||||
crate::generation::TeachingStyle::Discussion => "Discussion",
|
||||
crate::generation::TeachingStyle::Pbl => "Project-Based",
|
||||
crate::generation::TeachingStyle::Flipped => "Flipped Classroom",
|
||||
crate::generation::TeachingStyle::Socratic => "Socratic",
|
||||
}.to_string()
|
||||
}
|
||||
|
||||
/// Format scene type
|
||||
fn format_scene_type(scene_type: &SceneType) -> String {
|
||||
match scene_type {
|
||||
SceneType::Slide => "slide",
|
||||
SceneType::Quiz => "quiz",
|
||||
SceneType::Interactive => "interactive",
|
||||
SceneType::Pbl => "pbl",
|
||||
SceneType::Discussion => "discussion",
|
||||
SceneType::Media => "media",
|
||||
SceneType::Text => "text",
|
||||
}.to_string()
|
||||
}
|
||||
|
||||
/// Get default CSS styles
|
||||
fn get_default_css() -> &'static str {
|
||||
r#"
|
||||
:root {
|
||||
--primary: #3b82f6;
|
||||
--secondary: #64748b;
|
||||
--background: #f8fafc;
|
||||
--surface: #ffffff;
|
||||
--text: #1e293b;
|
||||
--border: #e2e8f0;
|
||||
--accent: #10b981;
|
||||
}
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
background: var(--background);
|
||||
color: var(--text);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.top-nav {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 60px;
|
||||
background: var(--surface);
|
||||
border-bottom: 1px solid var(--border);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 24px;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.nav-brand {
|
||||
font-weight: 600;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.nav-controls {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 8px 16px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--surface);
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
background: var(--background);
|
||||
}
|
||||
|
||||
.scenes {
|
||||
margin-top: 80px;
|
||||
padding: 24px;
|
||||
max-width: 900px;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.scene {
|
||||
background: var(--surface);
|
||||
border-radius: 12px;
|
||||
padding: 32px;
|
||||
margin-bottom: 24px;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.title-slide {
|
||||
text-align: center;
|
||||
padding: 64px 32px;
|
||||
}
|
||||
|
||||
.title-slide h1 {
|
||||
font-size: 36px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.scene-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 24px;
|
||||
padding-bottom: 16px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.scene-header h2 {
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.scene-type {
|
||||
padding: 4px 12px;
|
||||
background: var(--primary);
|
||||
color: white;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.scene-body {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.actions {
|
||||
margin-top: 24px;
|
||||
padding: 16px;
|
||||
background: var(--background);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.action {
|
||||
padding: 12px;
|
||||
margin-bottom: 8px;
|
||||
background: var(--surface);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.action .role {
|
||||
font-weight: 600;
|
||||
color: var(--primary);
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
.speaker-notes {
|
||||
margin-top: 24px;
|
||||
padding: 16px;
|
||||
background: #fef3c7;
|
||||
border-left: 4px solid #f59e0b;
|
||||
border-radius: 4px;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.speaker-notes.visible {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.toc {
|
||||
position: fixed;
|
||||
top: 60px;
|
||||
right: -300px;
|
||||
width: 280px;
|
||||
height: calc(100vh - 60px);
|
||||
background: var(--surface);
|
||||
border-left: 1px solid var(--border);
|
||||
padding: 24px;
|
||||
overflow-y: auto;
|
||||
transition: right 0.3s ease;
|
||||
z-index: 99;
|
||||
}
|
||||
|
||||
.toc.visible {
|
||||
right: 0;
|
||||
}
|
||||
|
||||
.toc ol {
|
||||
list-style: decimal;
|
||||
padding-left: 20px;
|
||||
}
|
||||
|
||||
.toc li {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.toc a {
|
||||
color: var(--text);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.toc a:hover {
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.classroom-footer {
|
||||
text-align: center;
|
||||
padding: 32px;
|
||||
color: var(--secondary);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.meta {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
justify-content: center;
|
||||
margin: 16px 0;
|
||||
}
|
||||
|
||||
.meta span {
|
||||
padding: 4px 12px;
|
||||
background: var(--background);
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.objectives {
|
||||
text-align: left;
|
||||
max-width: 500px;
|
||||
margin: 24px auto;
|
||||
}
|
||||
|
||||
.objectives ul {
|
||||
list-style: disc;
|
||||
padding-left: 24px;
|
||||
}
|
||||
|
||||
.objectives li {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
"#
|
||||
}
|
||||
|
||||
/// Get default JavaScript
|
||||
fn get_default_js() -> &'static str {
|
||||
r#"
|
||||
let currentScene = 0;
|
||||
const scenes = document.querySelectorAll('.scene');
|
||||
const totalScenes = scenes.length;
|
||||
|
||||
function showScene(index) {
|
||||
scenes.forEach((s, i) => {
|
||||
s.style.display = i === index ? 'block' : 'none';
|
||||
});
|
||||
document.getElementById('scene-counter').textContent = `${index + 1} / ${totalScenes}`;
|
||||
}
|
||||
|
||||
document.getElementById('prev-scene').addEventListener('click', () => {
|
||||
if (currentScene > 0) {
|
||||
currentScene--;
|
||||
showScene(currentScene);
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('next-scene').addEventListener('click', () => {
|
||||
if (currentScene < totalScenes - 1) {
|
||||
currentScene++;
|
||||
showScene(currentScene);
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('toggle-notes').addEventListener('click', () => {
|
||||
document.querySelectorAll('.speaker-notes').forEach(n => {
|
||||
n.classList.toggle('visible');
|
||||
});
|
||||
});
|
||||
|
||||
document.getElementById('toggle-toc').addEventListener('click', () => {
|
||||
document.getElementById('toc-panel').classList.toggle('visible');
|
||||
});
|
||||
|
||||
// Initialize
|
||||
showScene(0);
|
||||
|
||||
// Keyboard navigation
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'ArrowRight' || e.key === ' ') {
|
||||
if (currentScene < totalScenes - 1) {
|
||||
currentScene++;
|
||||
showScene(currentScene);
|
||||
}
|
||||
} else if (e.key === 'ArrowLeft') {
|
||||
if (currentScene > 0) {
|
||||
currentScene--;
|
||||
showScene(currentScene);
|
||||
}
|
||||
}
|
||||
});
|
||||
"#
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::generation::{ClassroomMetadata, TeachingStyle, DifficultyLevel};
|
||||
|
||||
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"}),
|
||||
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,
|
||||
},
|
||||
],
|
||||
metadata: ClassroomMetadata::default(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_html_export() {
|
||||
let exporter = HtmlExporter::new();
|
||||
let classroom = create_test_classroom();
|
||||
let options = ExportOptions::default();
|
||||
|
||||
let result = exporter.export(&classroom, &options).unwrap();
|
||||
|
||||
assert_eq!(result.extension, "html");
|
||||
assert_eq!(result.mime_type, "text/html");
|
||||
assert!(result.filename.ends_with(".html"));
|
||||
|
||||
let html = String::from_utf8(result.content).unwrap();
|
||||
assert!(html.contains("<!DOCTYPE html>"));
|
||||
assert!(html.contains("Test Classroom"));
|
||||
assert!(html.contains("Introduction"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_html_escape() {
|
||||
assert_eq!(html_escape("Hello <World>"), "Hello <World>");
|
||||
assert_eq!(html_escape("A & B"), "A & B");
|
||||
assert_eq!(html_escape("Say \"Hi\""), "Say "Hi"");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_duration() {
|
||||
assert_eq!(format_duration(1800), "30m");
|
||||
assert_eq!(format_duration(3665), "61m 5s");
|
||||
assert_eq!(format_duration(60), "1m");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_level() {
|
||||
assert_eq!(format_level(&DifficultyLevel::Beginner), "Beginner");
|
||||
assert_eq!(format_level(&DifficultyLevel::Expert), "Expert");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_include_notes() {
|
||||
let exporter = HtmlExporter::new();
|
||||
let classroom = create_test_classroom();
|
||||
|
||||
let options_with_notes = ExportOptions {
|
||||
include_notes: true,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let result = exporter.export(&classroom, &options_with_notes).unwrap();
|
||||
let html = String::from_utf8(result.content).unwrap();
|
||||
assert!(html.contains("Speaker notes here"));
|
||||
|
||||
let options_no_notes = ExportOptions {
|
||||
include_notes: false,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let result = exporter.export(&classroom, &options_no_notes).unwrap();
|
||||
let html = String::from_utf8(result.content).unwrap();
|
||||
assert!(!html.contains("Speaker notes here"));
|
||||
}
|
||||
}
|
||||
677
crates/zclaw-kernel/src/export/markdown.rs
Normal file
677
crates/zclaw-kernel/src/export/markdown.rs
Normal file
@@ -0,0 +1,677 @@
|
||||
//! Markdown Exporter - Plain text documentation export
|
||||
//!
|
||||
//! Generates a Markdown file containing:
|
||||
//! - Title and metadata
|
||||
//! - Table of contents
|
||||
//! - Scene content with formatting
|
||||
//! - Speaker notes (optional)
|
||||
//! - Quiz questions (optional)
|
||||
|
||||
use crate::generation::{Classroom, GeneratedScene, SceneContent, SceneType, SceneAction};
|
||||
use super::{ExportOptions, ExportResult, Exporter, sanitize_filename};
|
||||
use zclaw_types::Result;
|
||||
|
||||
/// Markdown exporter
|
||||
pub struct MarkdownExporter {
|
||||
/// Include front matter
|
||||
include_front_matter: bool,
|
||||
}
|
||||
|
||||
impl MarkdownExporter {
|
||||
/// Create new Markdown exporter
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
include_front_matter: true,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create without front matter
|
||||
pub fn without_front_matter() -> Self {
|
||||
Self {
|
||||
include_front_matter: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Generate Markdown content
|
||||
fn generate_markdown(&self, classroom: &Classroom, options: &ExportOptions) -> String {
|
||||
let mut md = String::new();
|
||||
|
||||
// Front matter
|
||||
if self.include_front_matter {
|
||||
md.push_str(&self.generate_front_matter(classroom));
|
||||
}
|
||||
|
||||
// Title
|
||||
md.push_str(&format!("# {}\n\n", &classroom.title));
|
||||
|
||||
// Metadata
|
||||
md.push_str(&self.generate_metadata_section(classroom));
|
||||
|
||||
// Learning objectives
|
||||
md.push_str(&self.generate_objectives_section(classroom));
|
||||
|
||||
// Table of contents
|
||||
if options.table_of_contents {
|
||||
md.push_str(&self.generate_toc(classroom));
|
||||
}
|
||||
|
||||
// Scenes
|
||||
md.push_str("\n---\n\n");
|
||||
for scene in &classroom.scenes {
|
||||
md.push_str(&self.generate_scene(scene, options));
|
||||
md.push_str("\n---\n\n");
|
||||
}
|
||||
|
||||
// Footer
|
||||
md.push_str(&self.generate_footer(classroom));
|
||||
|
||||
md
|
||||
}
|
||||
|
||||
/// Generate YAML front matter
|
||||
fn generate_front_matter(&self, classroom: &Classroom) -> String {
|
||||
let created = chrono::DateTime::from_timestamp_millis(classroom.metadata.generated_at)
|
||||
.map(|dt| dt.format("%Y-%m-%d %H:%M:%S").to_string())
|
||||
.unwrap_or_else(|| "Unknown".to_string());
|
||||
|
||||
format!(
|
||||
r#"---
|
||||
title: "{}"
|
||||
topic: "{}"
|
||||
style: "{}"
|
||||
level: "{}"
|
||||
duration: "{}"
|
||||
generated: "{}"
|
||||
version: "{}"
|
||||
---
|
||||
|
||||
"#,
|
||||
escape_yaml_string(&classroom.title),
|
||||
escape_yaml_string(&classroom.topic),
|
||||
format_style(&classroom.style),
|
||||
format_level(&classroom.level),
|
||||
format_duration(classroom.total_duration),
|
||||
created,
|
||||
classroom.metadata.version
|
||||
)
|
||||
}
|
||||
|
||||
/// Generate metadata section
|
||||
fn generate_metadata_section(&self, classroom: &Classroom) -> String {
|
||||
format!(
|
||||
r#"> **Topic**: {} | **Level**: {} | **Duration**: {} | **Style**: {}
|
||||
|
||||
"#,
|
||||
&classroom.topic,
|
||||
format_level(&classroom.level),
|
||||
format_duration(classroom.total_duration),
|
||||
format_style(&classroom.style)
|
||||
)
|
||||
}
|
||||
|
||||
/// Generate learning objectives section
|
||||
fn generate_objectives_section(&self, classroom: &Classroom) -> String {
|
||||
if classroom.objectives.is_empty() {
|
||||
return String::new();
|
||||
}
|
||||
|
||||
let objectives: String = classroom.objectives.iter()
|
||||
.map(|o| format!("- {}\n", o))
|
||||
.collect();
|
||||
|
||||
format!(
|
||||
r#"## Learning Objectives
|
||||
|
||||
{}
|
||||
|
||||
"#,
|
||||
objectives
|
||||
)
|
||||
}
|
||||
|
||||
/// Generate table of contents
|
||||
fn generate_toc(&self, classroom: &Classroom) -> String {
|
||||
let mut toc = String::from("## Table of Contents\n\n");
|
||||
|
||||
for (i, scene) in classroom.scenes.iter().enumerate() {
|
||||
toc.push_str(&format!(
|
||||
"{}. [{}](#scene-{}-{})\n",
|
||||
i + 1,
|
||||
&scene.content.title,
|
||||
i + 1,
|
||||
slugify(&scene.content.title)
|
||||
));
|
||||
}
|
||||
|
||||
toc.push_str("\n");
|
||||
|
||||
toc
|
||||
}
|
||||
|
||||
/// Generate a single scene
|
||||
fn generate_scene(&self, scene: &GeneratedScene, options: &ExportOptions) -> String {
|
||||
let mut md = String::new();
|
||||
|
||||
// Scene header
|
||||
md.push_str(&format!(
|
||||
"## Scene {}: {}\n\n",
|
||||
scene.order + 1,
|
||||
&scene.content.title
|
||||
));
|
||||
|
||||
// Scene metadata
|
||||
md.push_str(&format!(
|
||||
"> **Type**: {} | **Duration**: {}\n\n",
|
||||
format_scene_type(&scene.content.scene_type),
|
||||
format_duration(scene.content.duration_seconds)
|
||||
));
|
||||
|
||||
// Scene content based on type
|
||||
md.push_str(&self.format_scene_content(&scene.content, options));
|
||||
|
||||
// Actions
|
||||
if !scene.content.actions.is_empty() {
|
||||
md.push_str("\n### Actions\n\n");
|
||||
md.push_str(&self.format_actions(&scene.content.actions, options));
|
||||
}
|
||||
|
||||
// Speaker notes
|
||||
if options.include_notes {
|
||||
if let Some(notes) = &scene.content.notes {
|
||||
md.push_str(&format!(
|
||||
"\n> **Speaker Notes**: {}\n",
|
||||
notes
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
md
|
||||
}
|
||||
|
||||
/// Format scene content based on type
|
||||
fn format_scene_content(&self, content: &SceneContent, options: &ExportOptions) -> String {
|
||||
let mut md = String::new();
|
||||
|
||||
// Add description
|
||||
if let Some(desc) = content.content.get("description").and_then(|v| v.as_str()) {
|
||||
md.push_str(&format!("{}\n\n", desc));
|
||||
}
|
||||
|
||||
// Add key points
|
||||
if let Some(points) = content.content.get("key_points").and_then(|v| v.as_array()) {
|
||||
md.push_str("**Key Points:**\n\n");
|
||||
for point in points {
|
||||
if let Some(text) = point.as_str() {
|
||||
md.push_str(&format!("- {}\n", text));
|
||||
}
|
||||
}
|
||||
md.push_str("\n");
|
||||
}
|
||||
|
||||
// Type-specific content
|
||||
match content.scene_type {
|
||||
SceneType::Slide => {
|
||||
if let Some(slides) = content.content.get("slides").and_then(|v| v.as_array()) {
|
||||
for (i, slide) in slides.iter().enumerate() {
|
||||
if let (Some(title), Some(slide_content)) = (
|
||||
slide.get("title").and_then(|t| t.as_str()),
|
||||
slide.get("content").and_then(|c| c.as_str())
|
||||
) {
|
||||
md.push_str(&format!("#### Slide {}: {}\n\n{}\n\n", i + 1, title, slide_content));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
SceneType::Quiz => {
|
||||
md.push_str(&self.format_quiz_content(&content.content, options));
|
||||
}
|
||||
SceneType::Discussion => {
|
||||
if let Some(topic) = content.content.get("discussion_topic").and_then(|v| v.as_str()) {
|
||||
md.push_str(&format!("**Discussion Topic:** {}\n\n", topic));
|
||||
}
|
||||
if let Some(prompts) = content.content.get("discussion_prompts").and_then(|v| v.as_array()) {
|
||||
md.push_str("**Discussion Prompts:**\n\n");
|
||||
for prompt in prompts {
|
||||
if let Some(text) = prompt.as_str() {
|
||||
md.push_str(&format!("> {}\n\n", text));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
SceneType::Pbl => {
|
||||
if let Some(problem) = content.content.get("problem_statement").and_then(|v| v.as_str()) {
|
||||
md.push_str(&format!("**Problem Statement:**\n\n{}\n\n", problem));
|
||||
}
|
||||
if let Some(tasks) = content.content.get("tasks").and_then(|v| v.as_array()) {
|
||||
md.push_str("**Tasks:**\n\n");
|
||||
for (i, task) in tasks.iter().enumerate() {
|
||||
if let Some(text) = task.as_str() {
|
||||
md.push_str(&format!("{}. {}\n", i + 1, text));
|
||||
}
|
||||
}
|
||||
md.push_str("\n");
|
||||
}
|
||||
}
|
||||
SceneType::Interactive => {
|
||||
if let Some(instructions) = content.content.get("instructions").and_then(|v| v.as_str()) {
|
||||
md.push_str(&format!("**Instructions:**\n\n{}\n\n", instructions));
|
||||
}
|
||||
}
|
||||
SceneType::Media => {
|
||||
if let Some(url) = content.content.get("media_url").and_then(|v| v.as_str()) {
|
||||
md.push_str(&format!("**Media:** [View Media]({})\n\n", url));
|
||||
}
|
||||
}
|
||||
SceneType::Text => {
|
||||
if let Some(text) = content.content.get("text_content").and_then(|v| v.as_str()) {
|
||||
md.push_str(&format!("```\n{}\n```\n\n", text));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
md
|
||||
}
|
||||
|
||||
/// Format quiz content
|
||||
fn format_quiz_content(&self, content: &serde_json::Value, options: &ExportOptions) -> String {
|
||||
let mut md = String::new();
|
||||
|
||||
if let Some(questions) = content.get("questions").and_then(|v| v.as_array()) {
|
||||
md.push_str("### Quiz Questions\n\n");
|
||||
|
||||
for (i, q) in questions.iter().enumerate() {
|
||||
if let Some(text) = q.get("text").and_then(|t| t.as_str()) {
|
||||
md.push_str(&format!("**Q{}:** {}\n\n", i + 1, text));
|
||||
|
||||
// Options
|
||||
if let Some(options_arr) = q.get("options").and_then(|o| o.as_array()) {
|
||||
for (j, opt) in options_arr.iter().enumerate() {
|
||||
if let Some(opt_text) = opt.as_str() {
|
||||
let letter = (b'A' + j as u8) as char;
|
||||
md.push_str(&format!("- {} {}\n", letter, opt_text));
|
||||
}
|
||||
}
|
||||
md.push_str("\n");
|
||||
}
|
||||
|
||||
// Answer (if include_answers is true)
|
||||
if options.include_answers {
|
||||
if let Some(answer) = q.get("correct_answer").and_then(|a| a.as_str()) {
|
||||
md.push_str(&format!("*Answer: {}*\n\n", answer));
|
||||
} else if let Some(idx) = q.get("correct_index").and_then(|i| i.as_u64()) {
|
||||
let letter = (b'A' + idx as u8) as char;
|
||||
md.push_str(&format!("*Answer: {}*\n\n", letter));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
md
|
||||
}
|
||||
|
||||
/// Format actions
|
||||
fn format_actions(&self, actions: &[SceneAction], _options: &ExportOptions) -> String {
|
||||
let mut md = String::new();
|
||||
|
||||
for action in actions {
|
||||
match action {
|
||||
SceneAction::Speech { text, agent_role } => {
|
||||
md.push_str(&format!(
|
||||
"> **{}**: \"{}\"\n\n",
|
||||
capitalize_first(agent_role),
|
||||
text
|
||||
));
|
||||
}
|
||||
SceneAction::WhiteboardDrawText { text, x, y, font_size, color } => {
|
||||
md.push_str(&format!(
|
||||
"- Whiteboard Text: \"{}\" at ({}, {})",
|
||||
text, x, y
|
||||
));
|
||||
if let Some(size) = font_size {
|
||||
md.push_str(&format!(" [size: {}]", size));
|
||||
}
|
||||
if let Some(c) = color {
|
||||
md.push_str(&format!(" [color: {}]", c));
|
||||
}
|
||||
md.push_str("\n");
|
||||
}
|
||||
SceneAction::WhiteboardDrawShape { shape, x, y, width, height, fill } => {
|
||||
md.push_str(&format!(
|
||||
"- Draw {}: ({}, {}) {}x{}",
|
||||
shape, x, y, width, height
|
||||
));
|
||||
if let Some(f) = fill {
|
||||
md.push_str(&format!(" [fill: {}]", f));
|
||||
}
|
||||
md.push_str("\n");
|
||||
}
|
||||
SceneAction::WhiteboardDrawChart { chart_type, x, y, width, height, .. } => {
|
||||
md.push_str(&format!(
|
||||
"- Chart ({}): ({}, {}) {}x{}\n",
|
||||
chart_type, x, y, width, height
|
||||
));
|
||||
}
|
||||
SceneAction::WhiteboardDrawLatex { latex, x, y } => {
|
||||
md.push_str(&format!(
|
||||
"- LaTeX: `{}` at ({}, {})\n",
|
||||
latex, x, y
|
||||
));
|
||||
}
|
||||
SceneAction::WhiteboardClear => {
|
||||
md.push_str("- Clear whiteboard\n");
|
||||
}
|
||||
SceneAction::SlideshowSpotlight { element_id } => {
|
||||
md.push_str(&format!("- Spotlight: {}\n", element_id));
|
||||
}
|
||||
SceneAction::SlideshowNext => {
|
||||
md.push_str("- Next slide\n");
|
||||
}
|
||||
SceneAction::QuizShow { quiz_id } => {
|
||||
md.push_str(&format!("- Show quiz: {}\n", quiz_id));
|
||||
}
|
||||
SceneAction::Discussion { topic, duration_seconds } => {
|
||||
md.push_str(&format!(
|
||||
"- Discussion: \"{}\" ({}s)\n",
|
||||
topic,
|
||||
duration_seconds.unwrap_or(300)
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
md
|
||||
}
|
||||
|
||||
/// Generate footer
|
||||
fn generate_footer(&self, classroom: &Classroom) -> String {
|
||||
format!(
|
||||
r#"---
|
||||
|
||||
*Generated by ZCLAW Classroom Generator*
|
||||
*Topic: {} | Total Duration: {}*
|
||||
"#,
|
||||
&classroom.topic,
|
||||
format_duration(classroom.total_duration)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for MarkdownExporter {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl Exporter for MarkdownExporter {
|
||||
fn export(&self, classroom: &Classroom, options: &ExportOptions) -> Result<ExportResult> {
|
||||
let markdown = self.generate_markdown(classroom, options);
|
||||
let filename = format!("{}.md", sanitize_filename(&classroom.title));
|
||||
|
||||
Ok(ExportResult {
|
||||
content: markdown.into_bytes(),
|
||||
mime_type: "text/markdown".to_string(),
|
||||
filename,
|
||||
extension: "md".to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
fn format(&self) -> super::ExportFormat {
|
||||
super::ExportFormat::Markdown
|
||||
}
|
||||
|
||||
fn extension(&self) -> &str {
|
||||
"md"
|
||||
}
|
||||
|
||||
fn mime_type(&self) -> &str {
|
||||
"text/markdown"
|
||||
}
|
||||
}
|
||||
|
||||
// Helper functions
|
||||
|
||||
/// Escape YAML string
|
||||
fn escape_yaml_string(s: &str) -> String {
|
||||
if s.contains('"') || s.contains('\\') || s.contains('\n') {
|
||||
format!("\"{}\"", s.replace('\\', "\\\\").replace('"', "\\\""))
|
||||
} else {
|
||||
s.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
/// 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)
|
||||
}
|
||||
}
|
||||
|
||||
/// Format difficulty level
|
||||
fn format_level(level: &crate::generation::DifficultyLevel) -> String {
|
||||
match level {
|
||||
crate::generation::DifficultyLevel::Beginner => "Beginner",
|
||||
crate::generation::DifficultyLevel::Intermediate => "Intermediate",
|
||||
crate::generation::DifficultyLevel::Advanced => "Advanced",
|
||||
crate::generation::DifficultyLevel::Expert => "Expert",
|
||||
}.to_string()
|
||||
}
|
||||
|
||||
/// Format teaching style
|
||||
fn format_style(style: &crate::generation::TeachingStyle) -> String {
|
||||
match style {
|
||||
crate::generation::TeachingStyle::Lecture => "Lecture",
|
||||
crate::generation::TeachingStyle::Discussion => "Discussion",
|
||||
crate::generation::TeachingStyle::Pbl => "Project-Based",
|
||||
crate::generation::TeachingStyle::Flipped => "Flipped Classroom",
|
||||
crate::generation::TeachingStyle::Socratic => "Socratic",
|
||||
}.to_string()
|
||||
}
|
||||
|
||||
/// Format scene type
|
||||
fn format_scene_type(scene_type: &SceneType) -> String {
|
||||
match scene_type {
|
||||
SceneType::Slide => "Slide",
|
||||
SceneType::Quiz => "Quiz",
|
||||
SceneType::Interactive => "Interactive",
|
||||
SceneType::Pbl => "Project-Based Learning",
|
||||
SceneType::Discussion => "Discussion",
|
||||
SceneType::Media => "Media",
|
||||
SceneType::Text => "Text",
|
||||
}.to_string()
|
||||
}
|
||||
|
||||
/// Convert string to URL slug
|
||||
fn slugify(s: &str) -> String {
|
||||
s.to_lowercase()
|
||||
.replace(' ', "-")
|
||||
.replace(|c: char| !c.is_alphanumeric() && c != '-', "")
|
||||
.trim_matches('-')
|
||||
.to_string()
|
||||
}
|
||||
|
||||
/// Capitalize first letter
|
||||
fn capitalize_first(s: &str) -> String {
|
||||
let mut chars = s.chars();
|
||||
match chars.next() {
|
||||
Some(c) => c.to_uppercase().collect::<String>() + chars.as_str(),
|
||||
None => String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::generation::{ClassroomMetadata, TeachingStyle, DifficultyLevel};
|
||||
|
||||
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,
|
||||
},
|
||||
GeneratedScene {
|
||||
id: "scene-2".to_string(),
|
||||
outline_id: "outline-2".to_string(),
|
||||
content: SceneContent {
|
||||
title: "Quiz Time".to_string(),
|
||||
scene_type: SceneType::Quiz,
|
||||
content: serde_json::json!({
|
||||
"questions": [
|
||||
{
|
||||
"text": "What is 2+2?",
|
||||
"options": ["3", "4", "5", "6"],
|
||||
"correct_index": 1
|
||||
}
|
||||
]
|
||||
}),
|
||||
actions: vec![SceneAction::QuizShow {
|
||||
quiz_id: "quiz-1".to_string(),
|
||||
}],
|
||||
duration_seconds: 300,
|
||||
notes: None,
|
||||
},
|
||||
order: 1,
|
||||
},
|
||||
],
|
||||
metadata: ClassroomMetadata::default(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_markdown_export() {
|
||||
let exporter = MarkdownExporter::new();
|
||||
let classroom = create_test_classroom();
|
||||
let options = ExportOptions::default();
|
||||
|
||||
let result = exporter.export(&classroom, &options).unwrap();
|
||||
|
||||
assert_eq!(result.extension, "md");
|
||||
assert_eq!(result.mime_type, "text/markdown");
|
||||
assert!(result.filename.ends_with(".md"));
|
||||
|
||||
let md = String::from_utf8(result.content).unwrap();
|
||||
assert!(md.contains("# Test Classroom"));
|
||||
assert!(md.contains("Introduction"));
|
||||
assert!(md.contains("Quiz Time"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_include_answers() {
|
||||
let exporter = MarkdownExporter::new();
|
||||
let classroom = create_test_classroom();
|
||||
|
||||
let options_with_answers = ExportOptions {
|
||||
include_answers: true,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let result = exporter.export(&classroom, &options_with_answers).unwrap();
|
||||
let md = String::from_utf8(result.content).unwrap();
|
||||
assert!(md.contains("Answer:"));
|
||||
|
||||
let options_no_answers = ExportOptions {
|
||||
include_answers: false,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let result = exporter.export(&classroom, &options_no_answers).unwrap();
|
||||
let md = String::from_utf8(result.content).unwrap();
|
||||
assert!(!md.contains("Answer:"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_slugify() {
|
||||
assert_eq!(slugify("Hello World"), "hello-world");
|
||||
assert_eq!(slugify("Test 123!"), "test-123");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_capitalize_first() {
|
||||
assert_eq!(capitalize_first("teacher"), "Teacher");
|
||||
assert_eq!(capitalize_first("STUDENT"), "STUDENT");
|
||||
assert_eq!(capitalize_first(""), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_duration() {
|
||||
assert_eq!(format_duration(1800), "30m");
|
||||
assert_eq!(format_duration(3665), "61m 5s");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_include_notes() {
|
||||
let exporter = MarkdownExporter::new();
|
||||
let classroom = create_test_classroom();
|
||||
|
||||
let options_with_notes = ExportOptions {
|
||||
include_notes: true,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let result = exporter.export(&classroom, &options_with_notes).unwrap();
|
||||
let md = String::from_utf8(result.content).unwrap();
|
||||
assert!(md.contains("Speaker Notes"));
|
||||
|
||||
let options_no_notes = ExportOptions {
|
||||
include_notes: false,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let result = exporter.export(&classroom, &options_no_notes).unwrap();
|
||||
let md = String::from_utf8(result.content).unwrap();
|
||||
assert!(!md.contains("Speaker Notes"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_table_of_contents() {
|
||||
let exporter = MarkdownExporter::new();
|
||||
let classroom = create_test_classroom();
|
||||
|
||||
let options_with_toc = ExportOptions {
|
||||
table_of_contents: true,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let result = exporter.export(&classroom, &options_with_toc).unwrap();
|
||||
let md = String::from_utf8(result.content).unwrap();
|
||||
assert!(md.contains("Table of Contents"));
|
||||
|
||||
let options_no_toc = ExportOptions {
|
||||
table_of_contents: false,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let result = exporter.export(&classroom, &options_no_toc).unwrap();
|
||||
let md = String::from_utf8(result.content).unwrap();
|
||||
assert!(!md.contains("Table of Contents"));
|
||||
}
|
||||
}
|
||||
178
crates/zclaw-kernel/src/export/mod.rs
Normal file
178
crates/zclaw-kernel/src/export/mod.rs
Normal file
@@ -0,0 +1,178 @@
|
||||
//! Export functionality for ZCLAW classroom content
|
||||
//!
|
||||
//! This module provides export capabilities for:
|
||||
//! - HTML: Interactive web-based classroom
|
||||
//! - PPTX: PowerPoint presentation
|
||||
//! - Markdown: Plain text documentation
|
||||
//! - JSON: Raw data export
|
||||
|
||||
mod html;
|
||||
mod pptx;
|
||||
mod markdown;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use zclaw_types::Result;
|
||||
|
||||
use crate::generation::Classroom;
|
||||
|
||||
/// Export format
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum ExportFormat {
|
||||
#[default]
|
||||
Html,
|
||||
Pptx,
|
||||
Markdown,
|
||||
Json,
|
||||
}
|
||||
|
||||
/// Export options
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ExportOptions {
|
||||
/// Output format
|
||||
pub format: ExportFormat,
|
||||
/// Include speaker notes
|
||||
#[serde(default = "default_true")]
|
||||
pub include_notes: bool,
|
||||
/// Include quiz answers
|
||||
#[serde(default)]
|
||||
pub include_answers: bool,
|
||||
/// Theme for HTML export
|
||||
#[serde(default)]
|
||||
pub theme: Option<String>,
|
||||
/// Custom CSS (for HTML)
|
||||
#[serde(default)]
|
||||
pub custom_css: Option<String>,
|
||||
/// Title slide
|
||||
#[serde(default = "default_true")]
|
||||
pub title_slide: bool,
|
||||
/// Table of contents
|
||||
#[serde(default = "default_true")]
|
||||
pub table_of_contents: bool,
|
||||
}
|
||||
|
||||
fn default_true() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
impl Default for ExportOptions {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
format: ExportFormat::default(),
|
||||
include_notes: true,
|
||||
include_answers: false,
|
||||
theme: None,
|
||||
custom_css: None,
|
||||
title_slide: true,
|
||||
table_of_contents: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Export result
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ExportResult {
|
||||
/// Output content (as bytes for binary formats)
|
||||
pub content: Vec<u8>,
|
||||
/// MIME type
|
||||
pub mime_type: String,
|
||||
/// Suggested filename
|
||||
pub filename: String,
|
||||
/// File extension
|
||||
pub extension: String,
|
||||
}
|
||||
|
||||
/// Exporter trait
|
||||
pub trait Exporter: Send + Sync {
|
||||
/// Export a classroom
|
||||
fn export(&self, classroom: &Classroom, options: &ExportOptions) -> Result<ExportResult>;
|
||||
|
||||
/// Get supported format
|
||||
fn format(&self) -> ExportFormat;
|
||||
|
||||
/// Get file extension
|
||||
fn extension(&self) -> &str;
|
||||
|
||||
/// Get MIME type
|
||||
fn mime_type(&self) -> &str;
|
||||
}
|
||||
|
||||
/// Export a classroom
|
||||
pub fn export_classroom(
|
||||
classroom: &Classroom,
|
||||
options: &ExportOptions,
|
||||
) -> Result<ExportResult> {
|
||||
let exporter: Box<dyn Exporter> = match options.format {
|
||||
ExportFormat::Html => Box::new(html::HtmlExporter::new()),
|
||||
ExportFormat::Pptx => Box::new(pptx::PptxExporter::new()),
|
||||
ExportFormat::Markdown => Box::new(markdown::MarkdownExporter::new()),
|
||||
ExportFormat::Json => Box::new(JsonExporter::new()),
|
||||
};
|
||||
|
||||
exporter.export(classroom, options)
|
||||
}
|
||||
|
||||
/// JSON exporter (simple passthrough)
|
||||
pub struct JsonExporter;
|
||||
|
||||
impl JsonExporter {
|
||||
pub fn new() -> Self {
|
||||
Self
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for JsonExporter {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl Exporter for JsonExporter {
|
||||
fn export(&self, classroom: &Classroom, _options: &ExportOptions) -> Result<ExportResult> {
|
||||
let content = serde_json::to_string_pretty(classroom)?;
|
||||
let filename = format!("{}.json", sanitize_filename(&classroom.title));
|
||||
|
||||
Ok(ExportResult {
|
||||
content: content.into_bytes(),
|
||||
mime_type: "application/json".to_string(),
|
||||
filename,
|
||||
extension: "json".to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
fn format(&self) -> ExportFormat {
|
||||
ExportFormat::Json
|
||||
}
|
||||
|
||||
fn extension(&self) -> &str {
|
||||
"json"
|
||||
}
|
||||
|
||||
fn mime_type(&self) -> &str {
|
||||
"application/json"
|
||||
}
|
||||
}
|
||||
|
||||
/// Sanitize filename
|
||||
pub fn sanitize_filename(name: &str) -> String {
|
||||
name.chars()
|
||||
.map(|c| match c {
|
||||
'a'..='z' | 'A'..='Z' | '0'..='9' | '-' | '_' | '.' => c,
|
||||
' ' => '_',
|
||||
_ => '_',
|
||||
})
|
||||
.take(100)
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_sanitize_filename() {
|
||||
assert_eq!(sanitize_filename("Hello World"), "Hello_World");
|
||||
assert_eq!(sanitize_filename("Test@123!"), "Test_123_");
|
||||
assert_eq!(sanitize_filename("Simple"), "Simple");
|
||||
}
|
||||
}
|
||||
640
crates/zclaw-kernel/src/export/pptx.rs
Normal file
640
crates/zclaw-kernel/src/export/pptx.rs
Normal file
@@ -0,0 +1,640 @@
|
||||
//! 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, SceneType, 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).unwrap();
|
||||
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};
|
||||
|
||||
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,
|
||||
},
|
||||
],
|
||||
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user