merman-render 0.6.0

Headless layout + SVG renderer for Mermaid (parity-focused; upstream SVG goldens).
Documentation
use crate::Result;
use regex::{Captures, Regex};
use std::borrow::Cow;
use std::sync::OnceLock;

use super::util::{find_matching_brace, find_tag_end};
use crate::svg::pipeline::{SvgPostprocessContext, SvgPostprocessor};

#[derive(Debug, Clone, Copy, Default)]
pub struct SanitizeCssPostprocessor;

impl SvgPostprocessor for SanitizeCssPostprocessor {
    fn name(&self) -> &'static str {
        "sanitize-css"
    }

    fn process<'a>(
        &self,
        svg: Cow<'a, str>,
        _ctx: &SvgPostprocessContext<'_>,
    ) -> Result<Cow<'a, str>> {
        if !svg.contains("<style") && !svg.contains("style=\"") {
            return Ok(svg);
        }
        Ok(Cow::Owned(sanitize_style_elements(&svg)))
    }
}

pub(crate) fn sanitize_style_elements(svg: &str) -> String {
    let mut out = String::with_capacity(svg.len());
    let mut cursor = 0;

    while let Some(rel_start) = svg[cursor..].find("<style") {
        let start = cursor + rel_start;
        out.push_str(&svg[cursor..start]);

        let Some(open_end) = find_tag_end(svg, start) else {
            out.push_str(&svg[start..]);
            return out;
        };

        let content_start = open_end + 1;
        let Some(rel_close_start) = svg[content_start..].find("</style") else {
            out.push_str(&svg[start..]);
            return out;
        };
        let close_start = content_start + rel_close_start;
        let Some(close_end) = find_tag_end(svg, close_start) else {
            out.push_str(&svg[start..]);
            return out;
        };

        out.push_str(&svg[start..=open_end]);
        out.push_str(&sanitize_css(&svg[content_start..close_start]));
        out.push_str(&svg[close_start..=close_end]);
        cursor = close_end + 1;
    }

    out.push_str(&svg[cursor..]);
    out
}

pub(crate) fn sanitize_css(css: &str) -> String {
    let css = strip_unsupported_css_rules(css);
    let css = strip_animation_declarations(&css);
    strip_css_deg_units(&css)
}

fn strip_unsupported_css_rules(css: &str) -> String {
    let mut out = String::with_capacity(css.len());
    let mut cursor = 0;

    while let Some(rel_open) = css[cursor..].find('{') {
        let open = cursor + rel_open;
        let selector = &css[cursor..open];
        let Some(close) = find_matching_brace(css, open) else {
            out.push_str(&css[cursor..]);
            return out;
        };

        let selector_lower = selector.to_ascii_lowercase();
        let unsupported = selector_lower.contains("@keyframes")
            || selector_lower.contains("@-webkit-keyframes")
            || selector_lower.contains(":root");

        if !unsupported {
            out.push_str(&css[cursor..=close]);
        }
        cursor = close + 1;
    }

    out.push_str(&css[cursor..]);
    out
}

fn strip_animation_declarations(css: &str) -> String {
    static RE: OnceLock<Regex> = OnceLock::new();
    let re = RE.get_or_init(|| {
        Regex::new(r"(?i)(^|[;{])\s*animation(?:-[a-z-]+)?\s*:[^;}]*;?")
            .expect("valid animation declaration regex")
    });

    re.replace_all(css, |caps: &Captures<'_>| caps[1].to_string())
        .into_owned()
}

pub(crate) fn strip_css_deg_units(css: &str) -> String {
    static RE: OnceLock<Regex> = OnceLock::new();
    let re = RE
        .get_or_init(|| Regex::new(r"(?i)(-?\d+(?:\.\d+)?)deg\b").expect("valid CSS degree regex"));

    re.replace_all(css, "$1").into_owned()
}