191 lines · 7.9 KB · Rust
RawDownload
use std::sync::atomic::{AtomicU64, Ordering}; use anyhow::{Context as _, Result, anyhow}; use crate::{MermaidTheme, css_color}; pub(super) fn render_mermaid(source: &str, theme: &MermaidTheme) -> Result<String> {    static COUNTER: AtomicU64 = AtomicU64::new(0);    let id = COUNTER.fetch_add(1, Ordering::Relaxed);    let diagram_id = format!("merman-{id}");     let config = to_merman_config(theme);    let renderer = merman::render::HeadlessRenderer::new()        .with_site_config(config)        .with_vendored_text_measurer()        .with_diagram_id(&diagram_id);    // Apply merman's raster-safe pipeline before Zed-specific styling. The    // pipeline handles generic rasterizer compatibility cleanup: foreignObject    // fallback text, unsupported CSS removal, and invalid SVG attribute cleanup.    // Zed also strips merman's existing `!important` declarations before    // injecting its own theme CSS so host styling wins consistently in usvg/resvg.    let pipeline = merman::render::SvgPipeline::resvg_safe()        .with_postprocessor(merman::render::CssOverridePostprocessor::strip_existing_important());     let svg = renderer        .render_svg_with_pipeline_sync(source, &pipeline)        .context("merman render failed")?        .ok_or_else(|| anyhow!("merman returned no SVG for the given input"))?;     Ok(svg)} fn to_merman_config(theme: &MermaidTheme) -> merman::MermaidConfig {    let primary = css_color(theme.primary_color);    let primary_text = css_color(theme.primary_text_color);    let primary_border = css_color(theme.primary_border_color);    let line = css_color(theme.line_color);    let secondary = css_color(theme.secondary_color);    let tertiary = css_color(theme.tertiary_color);    let background = css_color(theme.background);    let cluster_bg = css_color(theme.cluster_background);    let cluster_border = css_color(theme.cluster_border);    let edge_label_bg = css_color(theme.edge_label_background);    let text = css_color(theme.text_color);    let note_bg = css_color(theme.note_background);    let note_border = css_color(theme.note_border);    let actor_bg = css_color(theme.actor_background);    let actor_border = css_color(theme.actor_border);    let activation_bg = css_color(theme.activation_background);    let activation_border = css_color(theme.activation_border);    let er_odd = css_color(theme.er_attr_bg_odd);    let er_even = css_color(theme.er_attr_bg_even);    let git: [String; 8] = theme.git_branch_colors.map(css_color);    let git_lbl: [String; 8] = theme.git_branch_label_colors.map(css_color);     let mut theme_vars = serde_json::json!({        "primaryColor": primary,        "primaryTextColor": primary_text,        "primaryBorderColor": primary_border,        "lineColor": line,        "secondaryColor": secondary,        "secondaryTextColor": text,        "tertiaryColor": tertiary,        "tertiaryTextColor": text,        "background": background,        "mainBkg": primary,        "nodeBorder": primary_border,        "nodeTextColor": primary_text,        "clusterBkg": cluster_bg,        "clusterBorder": cluster_border,        "titleColor": text,        "edgeLabelBackground": edge_label_bg,        "textColor": text,        "fontFamily": theme.font_family,        "noteBkgColor": note_bg,        "noteBorderColor": note_border,        "noteTextColor": text,        "actorBkg": actor_bg,        "actorBorder": actor_border,        "actorTextColor": primary_text,        "labelTextColor": text,        "loopTextColor": text,        "signalColor": text,        "signalTextColor": text,        "activationBkgColor": activation_bg,        "activationBorderColor": activation_border,        "classText": text,        "labelColor": primary_text,        "attributeBackgroundColorOdd": er_odd,        "attributeBackgroundColorEven": er_even,        "pieTitleTextColor": text,        "pieSectionTextColor": text,        "pieLegendTextColor": text,        "pieStrokeColor": primary_border,        "pieOuterStrokeColor": primary_border,        "quadrant1Fill": primary,        "quadrant2Fill": primary,        "quadrant3Fill": primary,        "quadrant4Fill": primary,        "quadrant1TextFill": text,        "quadrant2TextFill": text,        "quadrant3TextFill": text,        "quadrant4TextFill": text,        "quadrantPointFill": line,        "quadrantPointTextFill": text,        "quadrantTitleFill": text,        "quadrantXAxisTextFill": text,        "quadrantYAxisTextFill": text,        "quadrantExternalBorderStrokeFill": primary_border,        "quadrantInternalBorderStrokeFill": primary_border,    });     if let Some(map) = theme_vars.as_object_mut() {        for (((i, color), label), pie_number) in git.iter().enumerate().zip(&git_lbl).zip(1..) {            map.insert(format!("cScale{i}"), color.clone().into());            map.insert(format!("cScaleLabel{i}"), label.clone().into());            map.insert(format!("pie{pie_number}"), color.clone().into());        }    }     merman::MermaidConfig::from_value(serde_json::json!({        "theme": "base",        "darkMode": theme.dark_mode,        "fontFamily": theme.font_family,        // resvg can't rasterize HTML `<foreignObject>` labels, and the        // fallback that replaces them loses soft wrapping, so emit native SVG        // text labels. Nodes read the top-level key; edges read `flowchart`.        "htmlLabels": false,        "flowchart": {            "htmlLabels": false,            "padding": 16,        },        "themeVariables": theme_vars,    }))} #[cfg(test)]mod tests {    use super::*;     #[test]    fn render_stage_applies_resvg_safe_pipeline() {        let html_label_source =            "classDiagram\n    class Shelter {\n        -List~Animal~ animals\n    }";        let html_label_svg =            render_mermaid(html_label_source, &MermaidTheme::default()).expect("render failed");         assert!(            !html_label_svg.contains("<foreignObject"),            "got: {html_label_svg}"        );        assert!(            !html_label_svg.contains("&amp;lt;"),            "got: {html_label_svg}"        );         let css_source = "sequenceDiagram\n    Alice->>Bob: Hello\n    Bob-->>Alice: Hi";        let css_svg = render_mermaid(css_source, &MermaidTheme::default()).expect("render failed");         assert!(!css_svg.contains("@keyframes"), "got: {css_svg}");        assert!(!css_svg.contains("@-webkit-keyframes"), "got: {css_svg}");        assert!(!css_svg.contains(":root"), "got: {css_svg}");        assert!(!css_svg.contains("animation:"), "got: {css_svg}");        assert!(!css_svg.contains("animation-name:"), "got: {css_svg}");        assert!(!css_svg.contains("!important"), "got: {css_svg}");    }     /// Soft-wrapped labels must render as native SVG `<tspan>` lines rather    /// than the resvg-safe pipeline's single-line `<foreignObject>` fallback,    /// which overflows the node box (see the `htmlLabels` comment in    /// [`to_merman_config`]). If the fallback marker reappears after a merman    /// upgrade, the config has stopped disabling HTML labels.    #[test]    fn long_labels_render_as_wrapped_svg_text() {        let source = "flowchart TD\n    \            A[\"Pass 2: search transcript with annotation blocks excised, \            map offsets back to buffer space\"] --> \            |ambiguous or zero| B[\"Error describing where matches were found\"]";        let svg = render_mermaid(source, &MermaidTheme::default()).expect("render failed");         assert!(            !svg.contains(r#"data-merman-foreignobject="fallback""#),            "labels went through the foreignObject fallback, which loses soft wrapping: {svg}"        );        let wrapped_line_count = svg.matches("text-outer-tspan").count();        assert!(            wrapped_line_count > 3,            "expected long labels to wrap onto multiple tspan lines, got {wrapped_line_count}: {svg}"        );    }}