use anstyle::Style;
use merman::ascii::{AsciiRenderOptions, HeadlessAsciiRenderer};
use merman::render::{
HeadlessRenderer, HostThemeOutput, HostThemePipelinePreset, HostThemeProfile,
HostThemeRootBackground,
};
use crate::resources::svg::render_svg_to_png_scaled;
use crate::terminal::TerminalSize;
use crate::theme::MermaidPalette;
use super::math::{png_dimensions, style_to_ratex_color};
pub(crate) struct MermaidImage {
pub png: Vec<u8>,
pub width_columns: u16,
pub height_rows: u16,
}
const MERMAID_RASTER_SCALE_DEFAULT: f32 = 1.3;
const MERMAID_BASE_FONT_SIZE: f64 = 16.0;
fn diagram_type(input: &str) -> &str {
let first_line = input
.lines()
.find(|line| !line.trim().is_empty())
.unwrap_or("");
first_line
.trim()
.split(|c: char| c.is_whitespace())
.next()
.unwrap_or("")
}
fn raster_scale_for(diagram_type: &str) -> f32 {
match diagram_type {
"classDiagram" => 1.6,
"stateDiagram" | "stateDiagram-v2" => 1.55,
"mindmap" => 1.5,
"gitGraph" => 1.95,
_ => MERMAID_RASTER_SCALE_DEFAULT,
}
}
fn style_to_css_hex(style: &Style) -> String {
let color = style_to_ratex_color(style);
format!(
"#{:02x}{:02x}{:02x}",
(color.r * 255.0).round().clamp(0.0, 255.0) as u8,
(color.g * 255.0).round().clamp(0.0, 255.0) as u8,
(color.b * 255.0).round().clamp(0.0, 255.0) as u8,
)
}
fn apply_diagram_specific_config(profile: &mut HostThemeProfile, diagram_type: &str) {
match diagram_type {
"gantt" => {
profile.site_config.insert(
"gantt".to_string(),
serde_json::json!({
"fontSize": MERMAID_BASE_FONT_SIZE,
"sectionFontSize": MERMAID_BASE_FONT_SIZE,
}),
);
}
"quadrantChart" => {
profile.site_config.insert(
"quadrantChart".to_string(),
serde_json::json!({
"titleFontSize": MERMAID_BASE_FONT_SIZE * 1.25,
"xAxisLabelFontSize": MERMAID_BASE_FONT_SIZE,
"yAxisLabelFontSize": MERMAID_BASE_FONT_SIZE,
"quadrantLabelFontSize": MERMAID_BASE_FONT_SIZE,
"pointLabelFontSize": MERMAID_BASE_FONT_SIZE * 0.85,
}),
);
}
"journey" => {
profile
.theme_variables
.insert("textColor".to_string(), serde_json::json!("#1f2937"));
}
_ => {}
}
}
fn base_profile_for(palette: MermaidPalette, is_dark: bool) -> HostThemeProfile {
match palette {
MermaidPalette::Generic if is_dark => HostThemeProfile::editor_dark(),
MermaidPalette::Generic => HostThemeProfile::editor_light(),
MermaidPalette::OneDark => HostThemeProfile::one_dark(),
MermaidPalette::GruvboxDark => HostThemeProfile::gruvbox_dark(),
MermaidPalette::GruvboxLight => HostThemeProfile::gruvbox_light(),
MermaidPalette::AyuDark => HostThemeProfile::ayu_dark(),
MermaidPalette::AyuLight => HostThemeProfile::ayu_light(),
}
}
fn host_theme_profile(
mermaid_style: &Style,
is_dark: bool,
mermaid_palette: MermaidPalette,
diagram_type: &str,
) -> (HostThemeProfile, String) {
let mut profile = base_profile_for(mermaid_palette, is_dark);
let canvas_text_color = profile
.roles
.text
.clone()
.unwrap_or_else(|| if is_dark { "#e5e7eb" } else { "#0f172a" }.to_string());
profile.roles.line = Some(style_to_css_hex(mermaid_style));
profile.output = HostThemeOutput {
pipeline: HostThemePipelinePreset::ResvgSafe,
root_background: HostThemeRootBackground::None,
..profile.output
};
extend_series_palette(&mut profile);
apply_diagram_specific_config(&mut profile, diagram_type);
(profile, canvas_text_color)
}
const SERIES_PALETTE_MIN_LEN: usize = 12;
fn extend_series_palette(profile: &mut HostThemeProfile) {
if profile.series_palette.is_empty() {
return;
}
let original_len = profile.series_palette.len();
while profile.series_palette.len() < SERIES_PALETTE_MIN_LEN {
let next = profile.series_palette[profile.series_palette.len() % original_len].clone();
profile.series_palette.push(next);
}
}
fn fix_negative_stroke_widths(svg: &str) -> String {
let mut out = String::with_capacity(svg.len());
let mut rest = svg;
while let Some(idx) = rest.find("stroke-width:-") {
out.push_str(&rest[..idx]);
out.push_str("stroke-width:1");
let after = &rest[idx + "stroke-width:-".len()..];
let end = after
.find(|c: char| !(c.is_ascii_digit() || c == '.'))
.unwrap_or(after.len());
rest = &after[end..];
}
out.push_str(rest);
out
}
fn replace_css_value(svg: &str, prefix: &str, new_value: &str) -> String {
let mut out = String::with_capacity(svg.len());
let mut rest = svg;
while let Some(idx) = rest.find(prefix) {
out.push_str(&rest[..idx]);
out.push_str(prefix);
out.push_str(new_value);
let after = &rest[idx + prefix.len()..];
let end = after.find([';', '}']).unwrap_or(after.len());
rest = &after[end..];
}
out.push_str(rest);
out
}
fn fix_hardcoded_strokes(svg: &str, accent: &str) -> String {
let svg = svg.replace("stroke=\"black\"", &format!("stroke=\"{accent}\""));
svg.replace("stroke=\"#000\"", &format!("stroke=\"{accent}\""))
}
fn fix_unstyled_arrowhead_marker(svg: &str, accent: &str) -> String {
const MARKER: &str = "<marker id=\"merman-arrowhead\"";
let Some(marker_idx) = svg.find(MARKER) else {
return svg.to_string();
};
let Some(path_rel_idx) = svg[marker_idx..].find("<path ") else {
return svg.to_string();
};
let insert_at = marker_idx + path_rel_idx + "<path ".len();
let mut out = String::with_capacity(svg.len() + accent.len() + 10);
out.push_str(&svg[..insert_at]);
out.push_str(&format!("fill=\"{accent}\" "));
out.push_str(&svg[insert_at..]);
out
}
fn fix_line_wrapper_stroke(svg: &str, accent: &str) -> String {
replace_css_value(svg, ".lineWrapper line{stroke:", accent)
}
fn fix_class_diagram_font_sizes(svg: &str, diagram_type: &str) -> String {
if diagram_type != "classDiagram" {
return svg.to_string();
}
let body = format!("font-size:{MERMAID_BASE_FONT_SIZE}px;");
let title = format!("font-size:{}px;", MERMAID_BASE_FONT_SIZE * 1.25);
let svg = svg.replace("font-size:10px;", &body);
let svg = svg.replace("font-size:11px;", &body);
svg.replace("font-size:18px;", &title)
}
fn fix_journey_line_stroke(svg: &str, accent: &str, diagram_type: &str) -> String {
if diagram_type != "journey" {
return svg.to_string();
}
replace_css_value(svg, "line{stroke:", accent)
}
fn fix_journey_title_and_legend_fill(
svg: &str,
canvas_text_color: &str,
diagram_type: &str,
) -> String {
if diagram_type != "journey" {
return svg.to_string();
}
let svg = replace_css_value(svg, ".legend{fill:", canvas_text_color);
fix_journey_title_fill(&svg, canvas_text_color)
}
fn fix_journey_title_fill(svg: &str, canvas_text_color: &str) -> String {
const MARKER: &str = "font-size=\"4ex\"";
let Some(marker_idx) = svg.find(MARKER) else {
return svg.to_string();
};
let insert_at = marker_idx + MARKER.len();
let mut out = String::with_capacity(svg.len() + canvas_text_color.len() + 20);
out.push_str(&svg[..insert_at]);
out.push_str(&format!(" style=\"fill:{canvas_text_color}\""));
out.push_str(&svg[insert_at..]);
out
}
fn render_mermaid_svg_fixed<'a>(
input: &'a str,
mermaid_style: &Style,
is_dark: bool,
mermaid_palette: MermaidPalette,
) -> Option<(String, &'a str)> {
let diagram_type = diagram_type(input);
let (profile, canvas_text_color) =
host_theme_profile(mermaid_style, is_dark, mermaid_palette, diagram_type);
let svg = HeadlessRenderer::new()
.with_host_theme(&profile)
.render_svg_sync(input)
.ok()??;
let accent = style_to_css_hex(mermaid_style);
let svg = fix_hardcoded_strokes(&svg, &accent);
let svg = fix_negative_stroke_widths(&svg);
let svg = fix_unstyled_arrowhead_marker(&svg, &accent);
let svg = fix_line_wrapper_stroke(&svg, &accent);
let svg = fix_journey_line_stroke(&svg, &accent, diagram_type);
let svg = fix_journey_title_and_legend_fill(&svg, &canvas_text_color, diagram_type);
let svg = fix_class_diagram_font_sizes(&svg, diagram_type);
Some((svg, diagram_type))
}
pub(crate) fn render_mermaid_png(
input: &str,
terminal_size: &TerminalSize,
mermaid_style: &Style,
is_dark: bool,
mermaid_palette: MermaidPalette,
) -> Option<MermaidImage> {
let (svg, diagram_type) =
render_mermaid_svg_fixed(input, mermaid_style, is_dark, mermaid_palette)?;
let png = render_svg_to_png_scaled(svg.as_bytes(), raster_scale_for(diagram_type)).ok()?;
let (px_w, px_h) = png_dimensions(&png);
let (width_columns, height_rows) = match terminal_size.cell {
Some(cell) => (
((px_w as f32 / cell.x as f32).ceil() as u16).max(1),
((px_h as f32 / cell.y as f32).ceil() as u16).max(1),
),
None => (1, 1),
};
Some(MermaidImage {
png,
width_columns,
height_rows,
})
}
pub(crate) fn render_mermaid_text(input: &str) -> Option<String> {
HeadlessAsciiRenderer::new()
.with_ascii_options(AsciiRenderOptions::unicode())
.render_ascii_sync(input)
.ok()?
}
#[cfg(test)]
mod tests {
use super::*;
fn yellow() -> Style {
Style::new().fg_color(Some(anstyle::AnsiColor::Yellow.into()))
}
#[test]
#[cfg(feature = "svg")]
fn render_png_simple_flowchart() {
let terminal_size = crate::terminal::TerminalSize {
columns: 80,
rows: 24,
pixels: Some(crate::terminal::PixelSize { x: 800, y: 480 }),
cell: Some(crate::terminal::PixelSize { x: 10, y: 20 }),
};
let img = render_mermaid_png(
"flowchart TD\nA[Start] --> B[Done]",
&terminal_size,
&yellow(),
true,
MermaidPalette::Generic,
);
assert!(img.is_some());
let img = img.unwrap();
assert!(img.png.len() > 100);
assert_eq!(&img.png[1..4], b"PNG");
assert!(img.width_columns > 0);
assert!(img.height_rows > 0);
}
#[test]
fn render_png_invalid_diagram_returns_none() {
let terminal_size = crate::terminal::TerminalSize::default();
assert!(render_mermaid_png(
"not a mermaid diagram at all {{{",
&terminal_size,
&yellow(),
true,
MermaidPalette::Generic,
)
.is_none());
}
#[test]
fn style_to_css_hex_converts_rgb() {
let style = Style::new().fg_color(Some(anstyle::RgbColor(0x1a, 0x2b, 0x3c).into()));
assert_eq!(style_to_css_hex(&style), "#1a2b3c");
}
#[test]
fn render_text_simple_flowchart() {
let text = render_mermaid_text("flowchart TD\nA[Start] --> B[Done]");
assert!(text.is_some());
assert!(!text.unwrap().is_empty());
}
#[test]
fn render_text_invalid_diagram_returns_none() {
assert!(render_mermaid_text("not a mermaid diagram at all {{{").is_none());
}
#[test]
fn fix_negative_stroke_widths_clamps_to_one() {
let svg = ".edge-depth-5{stroke-width:-1;}.edge-depth-8{stroke-width:-10.5;}";
assert_eq!(
fix_negative_stroke_widths(svg),
".edge-depth-5{stroke-width:1;}.edge-depth-8{stroke-width:1;}"
);
}
#[test]
fn fix_negative_stroke_widths_leaves_positive_values_alone() {
let svg = ".edge-depth-1{stroke-width:11;}";
assert_eq!(fix_negative_stroke_widths(svg), svg);
}
#[test]
fn fix_unstyled_arrowhead_marker_adds_fill() {
let svg =
"<marker id=\"merman-arrowhead\" refX=\"5\"><path d=\"M 0,0 V 4 L6,2 Z\"/></marker>";
assert_eq!(
fix_unstyled_arrowhead_marker(svg, "#cccc00"),
"<marker id=\"merman-arrowhead\" refX=\"5\"><path fill=\"#cccc00\" d=\"M 0,0 V 4 L6,2 Z\"/></marker>"
);
}
#[test]
fn fix_unstyled_arrowhead_marker_leaves_other_svgs_alone() {
let svg = "<marker id=\"other-arrowhead\"><path d=\"M 0,0\"/></marker>";
assert_eq!(fix_unstyled_arrowhead_marker(svg, "#cccc00"), svg);
}
#[test]
fn fix_line_wrapper_stroke_retints_black_and_white() {
let svg = ".lineWrapper line{stroke:#000000;}.other{color:red;}.lineWrapper line{stroke:#ffffff;}";
assert_eq!(
fix_line_wrapper_stroke(svg, "#cccc00"),
".lineWrapper line{stroke:#cccc00;}.other{color:red;}.lineWrapper line{stroke:#cccc00;}"
);
}
#[test]
fn fix_journey_line_stroke_retints_unscoped_rule_for_journey_only() {
let svg = "#merman .mouth{stroke:#666;}#merman line{stroke:#1f2937;}";
assert_eq!(
fix_journey_line_stroke(svg, "#cccc00", "journey"),
"#merman .mouth{stroke:#666;}#merman line{stroke:#cccc00;}"
);
assert_eq!(fix_journey_line_stroke(svg, "#cccc00", "timeline"), svg);
}
#[test]
fn extend_series_palette_cycles_to_minimum_length() {
let mut profile = HostThemeProfile::editor_dark();
let original = profile.series_palette.clone();
extend_series_palette(&mut profile);
assert_eq!(profile.series_palette.len(), SERIES_PALETTE_MIN_LEN);
assert_eq!(&profile.series_palette[..original.len()], &original[..]);
assert_eq!(profile.series_palette[original.len()], original[0]);
}
#[test]
fn extend_series_palette_leaves_empty_palette_alone() {
let mut profile = HostThemeProfile::editor_dark();
profile.series_palette.clear();
extend_series_palette(&mut profile);
assert!(profile.series_palette.is_empty());
}
#[test]
fn fix_class_diagram_font_sizes_retints_all_four_literals() {
let svg = "g.classGroup text{fill:#475569;font-size:10px;}.classLabel .label{font-size:10px;}.edgeTerminals{font-size:11px;}.classTitleText,.classDiagramTitleText{font-size:18px;}";
let fixed = fix_class_diagram_font_sizes(svg, "classDiagram");
assert!(!fixed.contains("font-size:10px"));
assert!(!fixed.contains("font-size:11px"));
assert!(!fixed.contains("font-size:18px"));
assert!(fixed.contains("font-size:16px"));
}
#[test]
fn fix_class_diagram_font_sizes_ignores_other_diagram_types() {
let svg = "g.classGroup text{font-size:10px;}";
assert_eq!(fix_class_diagram_font_sizes(svg, "flowchart"), svg);
}
fn rendered_svg(input: &str) -> String {
render_mermaid_svg_fixed(input, &yellow(), true, MermaidPalette::Generic)
.expect("diagram should render")
.0
}
#[test]
fn class_diagram_never_has_hardcoded_small_font_sizes() {
let svg = rendered_svg(
"classDiagram\n Animal <|-- Dog\n Animal : +String name\n Dog : +bark()",
);
assert!(!svg.contains("font-size:10px"));
assert!(!svg.contains("font-size:11px"));
assert!(!svg.contains("font-size:18px"));
}
#[test]
fn timeline_axis_never_has_hardcoded_black_stroke() {
let svg = rendered_svg("timeline\n title t\n 2020 : a\n 2023 : b\n 2026 : c");
assert!(!svg.contains("stroke=\"black\""));
assert!(!svg.contains(".lineWrapper line{stroke:#000000"));
assert!(!svg.contains(".lineWrapper line{stroke:#ffffff"));
}
#[test]
fn journey_never_has_hardcoded_black_stroke() {
let svg = rendered_svg("journey\n title t\n section S\n Task: 5: Me");
assert!(!svg.contains("stroke=\"black\""));
assert!(!svg.contains("stroke=\"#000\""));
}
#[test]
fn journey_axis_line_is_never_the_dark_label_text_color() {
let svg = rendered_svg("journey\n title t\n section S\n Task: 5: Me");
assert!(!svg.contains("line{stroke:#1f2937"));
}
#[test]
fn mindmap_never_has_negative_stroke_width() {
let svg = rendered_svg(
"mindmap\n root((r))\n a\n b\n c\n d",
);
assert!(!svg.contains("stroke-width:-"));
}
#[test]
fn journey_title_and_legend_never_use_the_dark_task_label_text_color() {
let svg =
rendered_svg("journey\n title Buying coffee\n section S\n Task: 5: Me");
assert!(!svg.contains(".legend{fill:#1f2937"));
assert!(!svg.contains("font-size=\"4ex\" style=\"fill:#1f2937\""));
}
}