//! 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; /// HTML exporter pub struct HtmlExporter { /// Template name (reserved for future template support) #[allow(dead_code)] // TODO: Implement template-based HTML export template: String, } impl HtmlExporter { /// Create new HTML exporter pub fn new() -> Self { Self { template: "default".to_string(), } } /// Create with specific template #[allow(dead_code)] // Reserved for future template support pub fn with_template(template: &str) -> Self { Self { template: template.to_string(), } } /// Generate HTML content fn generate_html(&self, classroom: &Classroom, options: &ExportOptions) -> Result { let mut html = String::new(); // HTML header html.push_str(&self.generate_header(classroom, options)); // Body content html.push_str("\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("
\n"); for scene in &classroom.scenes { html.push_str(&self.generate_scene(scene, options)); } html.push_str("
\n"); // Footer html.push_str(&self.generate_footer(classroom)); html.push_str(&self.generate_body_end()); html.push_str("\n"); 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#" {title} "#, 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#" "#, title = html_escape(&classroom.title), total = classroom.scenes.len(), ) } /// Generate title slide fn generate_title_slide(&self, classroom: &Classroom) -> String { format!( r#"

{title}

{description}

{topic} {level} {duration}

Learning Objectives

    {objectives}
"#, 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!("
  • {}
  • ", html_escape(o))) .collect::>() .join("\n"), ) } /// Generate table of contents fn generate_toc(&self, classroom: &Classroom) -> String { let items: String = classroom.scenes.iter() .enumerate() .map(|(i, scene)| { format!( "
  • {}
  • ", i + 1, html_escape(&scene.content.title) ) }) .collect::>() .join("\n"); format!( r#" "#, 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#" "#, html_escape(n) )) .unwrap_or_default() } else { String::new() }; let actions_html = self.generate_actions(&scene.content.actions); format!( r#"

    {title}

    {type}
    {content} {actions}
    {notes}
    "#, 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!("

    {}

    ", 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!("
  • {}
  • ", html_escape(text))) }) .collect::>() .join("\n") }) .unwrap_or_default(); format!( r#"
      {}
    "#, questions ) } SceneType::Discussion => { if let Some(topic) = content.content.get("discussion_topic").and_then(|v| v.as_str()) { format!("

    Discussion: {}

    ", html_escape(topic)) } else { String::new() } } _ => { if let Some(desc) = content.content.get("description").and_then(|v| v.as_str()) { format!("

    {}

    ", 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#"
    {}

    {}

    "#, html_escape(agent_role), html_escape(agent_role), html_escape(text) )), SceneAction::WhiteboardDrawText { text, .. } => Some(format!( r#"
    Whiteboard: {}
    "#, html_escape(text) )), SceneAction::WhiteboardDrawShape { shape, .. } => Some(format!( r#"
    Draw: {}
    "#, html_escape(shape) )), SceneAction::QuizShow { quiz_id } => Some(format!( r#"
    Quiz: {}
    "#, html_escape(quiz_id), html_escape(quiz_id) )), SceneAction::Discussion { topic, duration_seconds } => Some(format!( r#"
    Discussion: {} ({}s)
    "#, html_escape(topic), duration_seconds.unwrap_or(300) )), _ => None, }) .collect(); if actions_html.is_empty() { String::new() } else { format!( r#"
    {}
    "#, actions_html ) } } /// Generate footer fn generate_footer(&self, classroom: &Classroom) -> String { format!( r#"

    Generated by ZCLAW

    Topic: {topic} | Duration: {duration} | Style: {style}

    "#, 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#" "#, 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 { 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("")); assert!(html.contains("Test Classroom")); assert!(html.contains("Introduction")); } #[test] fn test_html_escape() { assert_eq!(html_escape("Hello "), "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")); } }