katana-canvas-forge 0.1.7

Versioned diagram rendering and document export runtime for KatanA (Mermaid, Draw.io, HTML/PDF/PNG/JPEG).
Documentation
use super::html_uri::HtmlFileUri;
use crate::markdown::color_preset::DiagramColorPreset;

pub(crate) struct HtmlExportTemplate;

impl HtmlExportTemplate {
    const FONT_FAMILIES: &'static str = "-apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji'";
    const MONOSPACE_FAMILIES: &'static str =
        "SFMono-Regular, Consolas, 'Liberation Mono', Menlo, monospace";

    pub(crate) fn assemble_html_document(css: &str, body: &str) -> String {
        format!(
            "{}{}{}",
            Self::document_head(css),
            body,
            Self::document_tail()
        )
    }

    fn document_head(css: &str) -> String {
        format!(
            r#"<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Exported Document</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.11/dist/katex.min.css">
<style>
{css}
</style>
</head>
<body>
"#
        )
    }

    fn document_tail() -> &'static str {
        r#"
<script defer src="https://cdn.jsdelivr.net/npm/katex@0.16.11/dist/katex.min.js"></script>
<script defer src="https://cdn.jsdelivr.net/npm/katex@0.16.11/dist/contrib/auto-render.min.js"
  onload="renderMathInElement(document.body, {
    delimiters: [
      {left: '$$', right: '$$', display: true},
      {left: '$', right: '$', display: false}
    ],
    ignoredTags: ['script', 'noscript', 'style', 'textarea', 'pre', 'code', 'option', 'svg'],
    throwOnError: false
  });
  document.querySelectorAll('[data-math-style=display]').forEach(function(el) {
    var src = el.textContent;
    el.innerHTML = '';
    katex.render(src, el, {displayMode: true, throwOnError: false});
  });
  document.querySelectorAll('[data-math-style=inline]').forEach(function(el) {
    var src = el.textContent;
    el.innerHTML = '';
    katex.render(src, el, {displayMode: false, throwOnError: false});
  });
"></script>
</body>
</html>"#
    }

    pub(crate) fn resolve_relative_paths(html: &str, base_dir: &std::path::Path) -> String {
        let mut output = String::with_capacity(html.len());
        let mut remaining = html;
        while let Some(marker_start) = remaining.find(r#"src=""#) {
            let value_start = marker_start + r#"src=""#.len();
            output.push_str(&remaining[..value_start]);
            let Some(value_end) = remaining[value_start..].find('"') else {
                output.push_str(&remaining[value_start..]);
                return output;
            };
            let src = &remaining[value_start..value_start + value_end];
            output.push_str(&Self::resolve_src(src, base_dir));
            remaining = &remaining[value_start + value_end..];
        }
        output.push_str(remaining);
        output
    }

    fn resolve_src(src: &str, base_dir: &std::path::Path) -> String {
        if src.starts_with("http://")
            || src.starts_with("https://")
            || src.starts_with("data:")
            || src.starts_with("file://")
        {
            return src.to_string();
        }
        HtmlFileUri::from_path(&base_dir.join(src))
    }

    pub(crate) fn generate_css(preset: &DiagramColorPreset) -> String {
        let bg_color = Self::background_color(preset);
        let base = Self::generate_base_css(preset, bg_color);
        let elements = Self::generate_elements_css(preset, bg_color);
        format!("{base}{elements}")
    }

    fn background_color(preset: &DiagramColorPreset) -> &str {
        if preset.background == "transparent" {
            if preset.text == "#E0E0E0" {
                "#1e1e1e"
            } else {
                "#ffffff"
            }
        } else {
            preset.background.as_ref()
        }
    }

    fn generate_base_css(preset: &DiagramColorPreset, bg_color: &str) -> String {
        format!(
            r#"
body {{ font-family: {fonts}; background-color: {bg_color}; color: {text}; line-height: 1.6; max-width: 900px; margin: 0 auto; padding: 2rem; }}
h1, h2, h3, h4, h5, h6 {{ margin-top: 1.5em; margin-bottom: 0.5em; font-weight: 600; }}
h1 {{ border-bottom: 1px solid {stroke}; padding-bottom: 0.3em; }}
h2 {{ border-bottom: 1px solid {stroke}; padding-bottom: 0.3em; }}
a {{ color: #0366d6; text-decoration: none; }}
pre {{ background-color: {fill}; border: 1px solid {stroke}; border-radius: 6px; padding: 16px; overflow: auto; line-height: 1.5; }}
code {{ font-family: {monos}; background-color: {fill}; border-radius: 3px; padding: 0.2em 0.4em; font-size: 85%; }}
pre code {{ background-color: transparent; padding: 0; }}
"#,
            fonts = Self::FONT_FAMILIES,
            bg_color = bg_color,
            text = preset.text,
            stroke = preset.stroke,
            fill = preset.fill,
            monos = Self::MONOSPACE_FAMILIES
        )
    }

    fn generate_elements_css(preset: &DiagramColorPreset, bg_color: &str) -> String {
        let base = format!(
            r#"
blockquote {{ border-left: 0.25em solid {stroke}; color: {text}; opacity: 0.8; padding: 0 1em; margin: 0; }}
table {{ border-spacing: 0; border-collapse: collapse; margin-top: 0; margin-bottom: 16px; }}
table th, table td {{ padding: 6px 13px; border: 1px solid {stroke}; }}
img {{ max-width: 100%; box-sizing: content-box; background-color: {bg_color}; }}
.katana-diagram img {{ background-color: transparent; }}
hr {{ height: 0.25em; padding: 0; margin: 24px 0; background-color: {stroke}; border: 0; }}
"#,
            bg_color = bg_color,
            text = preset.text,
            stroke = preset.stroke
        );
        let alerts = Self::generate_alerts_css();
        let extras = Self::generate_extras_css(preset);
        format!("{base}{alerts}{extras}")
    }

    fn generate_alerts_css() -> String {
        /* WHY: GFM-compatible alert styling to match the preview pane's
        rendering of [!NOTE], [!TIP], [!IMPORTANT], [!WARNING], [!CAUTION]. */
        r#"
.markdown-alert { padding: 0.5rem 1rem; margin-bottom: 16px; border-left: 0.25em solid; border-radius: 4px; }
.markdown-alert-title { font-weight: 600; margin-bottom: 0.25rem; }
.markdown-alert-note { border-left-color: #539bf5; }
.markdown-alert-note .markdown-alert-title { color: #539bf5; }
.markdown-alert-tip { border-left-color: #57ab5a; }
.markdown-alert-tip .markdown-alert-title { color: #57ab5a; }
.markdown-alert-important { border-left-color: #986ee2; }
.markdown-alert-important .markdown-alert-title { color: #986ee2; }
.markdown-alert-warning { border-left-color: #c69026; }
.markdown-alert-warning .markdown-alert-title { color: #c69026; }
.markdown-alert-caution { border-left-color: #e5534b; }
.markdown-alert-caution .markdown-alert-title { color: #e5534b; }
"#.to_string()
    }

    fn generate_extras_css(preset: &DiagramColorPreset) -> String {
        /* WHY: Task list, footnote, math, and description list styles. */
        format!(
            r#"
ul.contains-task-list {{ list-style: none; padding-left: 1.5em; }}
input[type="checkbox"] {{ margin-right: 0.5em; }}
.footnotes {{ border-top: 1px solid {stroke}; margin-top: 2em; padding-top: 1em; font-size: 0.9em; }}
.footnote-ref {{ font-size: 0.75em; vertical-align: super; }}
math, .math-display, .math-inline {{ font-family: 'KaTeX_Main', 'Times New Roman', serif; }}
dt {{ font-weight: 600; margin-top: 0.5em; }}
dd {{ margin-left: 1.5em; margin-bottom: 0.5em; }}
"#,
            stroke = preset.stroke
        )
    }
}

#[cfg(test)]
#[path = "html_template_tests.rs"]
mod tests;