aprender-viz 0.36.0

SIMD/GPU/WASM-accelerated visualization library for data science and ML
Documentation
//! HTML output encoder for standalone chart embedding.
//!
//! Generates self-contained HTML files with embedded SVG or PNG charts.
//! No external dependencies required - works offline.
//!
//! # Example
//!
//! ```ignore
//! use trueno_viz::output::{HtmlExporter, SvgEncoder};
//!
//! let svg = SvgEncoder::new(800, 600)
//!     .rect(10.0, 10.0, 100.0, 50.0, Rgba::BLUE);
//!
//! let html = HtmlExporter::from_svg(&svg)
//!     .title("My Chart")
//!     .to_string();
//!
//! std::fs::write("chart.html", html)?;
//! ```

use crate::error::Result;
use std::fs::File;
use std::io::Write;
use std::path::Path;

use super::SvgEncoder;

/// HTML exporter for self-contained chart files.
#[derive(Debug, Clone)]
pub struct HtmlExporter {
    /// Document title
    title: String,
    /// SVG content
    svg_content: String,
    /// Chart width
    width: u32,
    /// Chart height (reserved for future use)
    #[allow(dead_code)]
    height: u32,
    /// Enable dark mode support
    dark_mode: bool,
    /// Enable responsive scaling
    responsive: bool,
}

impl HtmlExporter {
    /// Create an HTML exporter from an SVG encoder.
    #[must_use]
    pub fn from_svg(svg: &SvgEncoder) -> Self {
        Self {
            title: "Chart".to_string(),
            svg_content: svg.to_string(),
            width: svg.width(),
            height: svg.height(),
            dark_mode: false,
            responsive: true,
        }
    }

    /// Create an HTML exporter from raw SVG string.
    #[must_use]
    pub fn from_svg_string(svg: String, width: u32, height: u32) -> Self {
        Self {
            title: "Chart".to_string(),
            svg_content: svg,
            width,
            height,
            dark_mode: false,
            responsive: true,
        }
    }

    /// Set the document title.
    #[must_use]
    pub fn title(mut self, title: impl Into<String>) -> Self {
        self.title = title.into();
        self
    }

    /// Enable or disable dark mode support.
    #[must_use]
    pub fn dark_mode(mut self, enabled: bool) -> Self {
        self.dark_mode = enabled;
        self
    }

    /// Enable or disable responsive scaling.
    #[must_use]
    pub fn responsive(mut self, enabled: bool) -> Self {
        self.responsive = enabled;
        self
    }

    /// Generate the HTML string.
    #[must_use]
    pub fn to_html(&self) -> String {
        let dark_mode_css = if self.dark_mode {
            r"
        @media (prefers-color-scheme: dark) {
            body {
                background-color: #1a1a1a;
                color: #e0e0e0;
            }
            .chart-container {
                background-color: #2d2d2d;
            }
        }
"
        } else {
            ""
        };

        let responsive_css = if self.responsive {
            r"
        .chart-container svg {
            max-width: 100%;
            height: auto;
        }
"
        } else {
            ""
        };

        format!(
            r#"<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>{title}</title>
    <style>
        * {{
            margin: 0;
            padding: 0;
            box-sizing: border-box;
        }}
        body {{
            font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
            display: flex;
            justify-content: center;
            align-items: center;
            min-height: 100vh;
            background-color: #f5f5f5;
            padding: 20px;
        }}
        .chart-container {{
            background: white;
            border-radius: 8px;
            box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
            padding: 20px;
            max-width: {width}px;
        }}
        .chart-title {{
            text-align: center;
            margin-bottom: 15px;
            font-size: 1.2em;
            font-weight: 600;
            color: #333;
        }}{dark_mode_css}{responsive_css}
    </style>
</head>
<body>
    <div class="chart-container">
        <h1 class="chart-title">{title}</h1>
        {svg}
    </div>
    <!-- Generated by trueno-viz -->
    <!-- https://github.com/paiml/trueno-viz -->
</body>
</html>
"#,
            title = self.title,
            width = self.width + 40, // padding
            svg = self.svg_content,
            dark_mode_css = dark_mode_css,
            responsive_css = responsive_css,
        )
    }

    /// Write the HTML to a file.
    ///
    /// # Errors
    ///
    /// Returns an error if the file cannot be written.
    pub fn save<P: AsRef<Path>>(&self, path: P) -> Result<()> {
        let html = self.to_html();
        let mut file = File::create(path)?;
        file.write_all(html.as_bytes())?;
        Ok(())
    }
}

impl std::fmt::Display for HtmlExporter {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        write!(f, "{}", self.to_html())
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::color::Rgba;

    #[test]
    fn test_html_exporter_basic() {
        let svg = SvgEncoder::new(400, 300).rect(10.0, 10.0, 100.0, 50.0, Rgba::BLUE);

        let html = HtmlExporter::from_svg(&svg).title("Test Chart").to_html();

        assert!(html.contains("<!DOCTYPE html>"));
        assert!(html.contains("<title>Test Chart</title>"));
        assert!(html.contains("<svg"));
        assert!(html.contains("trueno-viz"));
    }

    #[test]
    fn test_html_exporter_dark_mode() {
        let svg = SvgEncoder::new(400, 300);
        let html = HtmlExporter::from_svg(&svg).dark_mode(true).to_html();

        assert!(html.contains("prefers-color-scheme: dark"));
    }

    #[test]
    fn test_html_exporter_responsive() {
        let svg = SvgEncoder::new(400, 300);
        let html = HtmlExporter::from_svg(&svg).responsive(true).to_html();

        assert!(html.contains("max-width: 100%"));
    }

    #[test]
    fn test_html_exporter_display() {
        let svg = SvgEncoder::new(400, 300);
        let exporter = HtmlExporter::from_svg(&svg);
        let display_str = format!("{exporter}");

        assert!(display_str.contains("<!DOCTYPE html>"));
    }

    #[test]
    fn test_html_exporter_from_svg_string() {
        let svg_str =
            r#"<svg width="100" height="100"><rect x="0" y="0" width="100" height="100"/></svg>"#;
        let exporter = HtmlExporter::from_svg_string(svg_str.to_string(), 100, 100);

        let html = exporter.to_html();
        assert!(html.contains(svg_str));
    }

    #[test]
    fn test_html_exporter_dark_mode_disabled() {
        let svg = SvgEncoder::new(400, 300);
        let html = HtmlExporter::from_svg(&svg).dark_mode(false).to_html();

        // Should NOT contain dark mode CSS
        assert!(!html.contains("prefers-color-scheme: dark"));
    }

    #[test]
    fn test_html_exporter_responsive_disabled() {
        let svg = SvgEncoder::new(400, 300);
        let html = HtmlExporter::from_svg(&svg).responsive(false).to_html();

        // Should NOT contain responsive CSS
        assert!(!html.contains("max-width: 100%"));
        assert!(!html.contains("height: auto"));
    }

    #[test]
    fn test_html_exporter_save() {
        let svg = SvgEncoder::new(400, 300);
        let exporter = HtmlExporter::from_svg(&svg).title("Save Test");

        let temp_path = std::env::temp_dir().join("test_chart.html");
        let result = exporter.save(&temp_path);
        assert!(result.is_ok());

        // Verify file was written
        let content = std::fs::read_to_string(&temp_path).expect("file read should succeed");
        assert!(content.contains("Save Test"));

        // Cleanup
        let _ = std::fs::remove_file(&temp_path);
    }

    #[test]
    fn test_html_exporter_clone() {
        let svg = SvgEncoder::new(400, 300);
        let exporter =
            HtmlExporter::from_svg(&svg).title("Clone Test").dark_mode(true).responsive(false);

        let cloned = exporter.clone();
        assert_eq!(exporter.to_html(), cloned.to_html());
    }

    #[test]
    fn test_html_exporter_all_options() {
        let svg = SvgEncoder::new(800, 600).rect(0.0, 0.0, 800.0, 600.0, Rgba::WHITE);

        let html = HtmlExporter::from_svg(&svg)
            .title("Full Featured Chart")
            .dark_mode(true)
            .responsive(true)
            .to_html();

        assert!(html.contains("Full Featured Chart"));
        assert!(html.contains("prefers-color-scheme: dark"));
        assert!(html.contains("max-width: 100%"));
        assert!(html.contains("trueno-viz"));
    }
}