katana-canvas-forge 0.1.7

Versioned diagram rendering and document export runtime for KatanA (Mermaid, Draw.io, HTML/PDF/PNG/JPEG).
Documentation
const DEFAULT_BACKGROUND_COLOR: &str = "#ffffff";
const DEFAULT_TEXT_COLOR: &str = "#222222";

pub(crate) struct NativeDocumentStyle {
    background_color: String,
    text_color: String,
}

impl NativeDocumentStyle {
    pub(crate) fn parse(html: &str) -> Self {
        let Some(body_css) = body_css(html) else {
            return Self::default();
        };
        Self {
            background_color: css_color_value(&body_css, "background-color")
                .or_else(|| css_color_value(&body_css, "background"))
                .unwrap_or_else(|| DEFAULT_BACKGROUND_COLOR.to_string()),
            text_color: css_color_value(&body_css, "color")
                .unwrap_or_else(|| DEFAULT_TEXT_COLOR.to_string()),
        }
    }

    pub(crate) fn background_color(&self) -> &str {
        &self.background_color
    }

    pub(crate) fn text_color(&self) -> &str {
        &self.text_color
    }
}

impl Default for NativeDocumentStyle {
    fn default() -> Self {
        Self {
            background_color: DEFAULT_BACKGROUND_COLOR.to_string(),
            text_color: DEFAULT_TEXT_COLOR.to_string(),
        }
    }
}

fn body_css(html: &str) -> Option<String> {
    let style_regex = regex::Regex::new(r"(?is)<style[^>]*>(.*?)</style>").ok()?;
    style_regex
        .captures_iter(html)
        .filter_map(|captures| captures.get(1))
        .find_map(|it| body_rule_css(it.as_str()))
        .or_else(|| body_rule_css(html))
}

fn body_rule_css(css: &str) -> Option<String> {
    let regex = regex::Regex::new(r"(?is)([^{}]+)\{([^}]*)\}").ok()?;
    regex
        .captures_iter(css)
        .find(|captures| {
            captures
                .get(1)
                .is_some_and(|it| selector_targets_body(it.as_str()))
        })
        .and_then(|captures| captures.get(2))
        .map(|it| it.as_str().to_string())
}

fn selector_targets_body(selectors: &str) -> bool {
    selectors.split(',').any(selector_is_body)
}

fn selector_is_body(selector: &str) -> bool {
    let selector = selector.trim().to_ascii_lowercase();
    selector
        .strip_prefix("body")
        .is_some_and(|rest| rest.is_empty() || starts_with_body_selector_suffix(rest))
}

fn starts_with_body_selector_suffix(rest: &str) -> bool {
    rest.as_bytes()
        .first()
        .is_some_and(|it| matches!(it, b'.' | b'#' | b':' | b'[' | b' ' | b'\t' | b'\n' | b'\r'))
}

fn css_color_value(css: &str, name: &str) -> Option<String> {
    css_value(css, name).and_then(|it| first_css_color(&it))
}

fn first_css_color(value: &str) -> Option<String> {
    value
        .split_whitespace()
        .map(|it| it.trim_matches(','))
        .find(|it| it.starts_with('#') || it.to_ascii_lowercase().starts_with("rgb"))
        .map(str::to_string)
}

fn css_value(css: &str, name: &str) -> Option<String> {
    css.split(';')
        .filter_map(|declaration| declaration.split_once(':'))
        .find_map(|(property, value)| {
            property
                .trim()
                .eq_ignore_ascii_case(name)
                .then(|| value.trim().to_string())
        })
        .filter(|value| !value.is_empty())
}

#[cfg(test)]
mod tests {
    use super::{NativeDocumentStyle, body_css, css_color_value, css_value};

    #[test]
    fn parses_body_colors_case_insensitively() {
        let html = r#"
            <style>
            BODY {
                color: #eeeeee;
                background-color: #111111;
            }
            </style>
        "#;

        let style = NativeDocumentStyle::parse(html);

        assert_eq!(style.background_color(), "#111111");
        assert_eq!(style.text_color(), "#eeeeee");
    }

    #[test]
    fn defaults_when_body_style_is_missing() {
        let style = NativeDocumentStyle::parse("<html><body>plain</body></html>");

        assert_eq!(style.background_color(), "#ffffff");
        assert_eq!(style.text_color(), "#222222");
    }

    #[test]
    fn css_value_ignores_empty_values() {
        assert_eq!(css_value("color: ;", "color"), None);
        assert_eq!(
            css_value("color: #222222;", "color"),
            Some("#222222".to_string())
        );
        assert!(body_css("<style>main { color: red; }</style>").is_none());

        let missing_background =
            NativeDocumentStyle::parse("<style>body { color: #333333; }</style>");
        let missing_color =
            NativeDocumentStyle::parse("<style>body { background-color: #fafafa; }</style>");

        assert_eq!(missing_background.background_color(), "#ffffff");
        assert_eq!(missing_color.text_color(), "#222222");
    }

    #[test]
    fn parses_body_selector_lists_and_background_shorthand() {
        let style = NativeDocumentStyle::parse(
            "<style>html, body { background: #1e1e1e; color: #eeeeee; }</style>",
        );

        assert_eq!(style.background_color(), "#1e1e1e");
        assert_eq!(style.text_color(), "#eeeeee");
        assert_eq!(
            css_color_value("background: #111111 url(a.png);", "background"),
            Some("#111111".to_string())
        );
        assert!(body_css("<style>tbody { color: red; }</style>").is_none());
    }

    #[test]
    fn parses_body_selector_suffixes() {
        let selectors = [
            "body.dark",
            "body#preview",
            "body:hover",
            "body[data-theme=\"dark\"]",
            "body main",
        ];

        for selector in selectors {
            let html = format!("<style>{selector} {{ color: #eeeeee; }}</style>");
            assert!(body_css(&html).is_some());
        }
    }
}