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("&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}" ); }}
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("&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}"
);
}
}