merman-render 0.8.0-alpha.6

Headless layout + SVG renderer for Mermaid (parity-focused; upstream SVG goldens).
Documentation
//! Flowchart SVG defs and marker emission.

use std::fmt::Write as _;

use super::super::SvgDiagramId;
use super::super::markers::push_base_edge_markers;
use super::super::util::{escape_xml, escape_xml_display};
use super::{FlowchartRenderCtx, flowchart_config_look, flowchart_resolve_stroke_for_marker};

pub(in crate::svg::parity::flowchart) struct FlowchartDefs<'a> {
    diagram_id: SvgDiagramId<'a>,
    diagram_type: &'a str,
    extra_marker_colors: Vec<String>,
    hand_drawn: bool,
    security_level_loose: bool,
}

pub(in crate::svg::parity::flowchart) fn prepare_flowchart_defs<'a>(
    diagram_id: SvgDiagramId<'a>,
    diagram_type: &'a str,
    ctx: &FlowchartRenderCtx<'_>,
) -> FlowchartDefs<'a> {
    FlowchartDefs {
        diagram_id,
        diagram_type,
        extra_marker_colors: collect_edge_marker_colors(ctx),
        hand_drawn: flowchart_config_look(ctx.config) == "handDrawn",
        security_level_loose: ctx.security_level_loose,
    }
}

impl FlowchartDefs<'_> {
    pub(in crate::svg::parity::flowchart) fn push_base_markers(&self, out: &mut String) {
        push_base_edge_markers(out, self.diagram_id, self.diagram_type);
    }

    pub(in crate::svg::parity::flowchart) fn push_extra_markers(&self, out: &mut String) {
        push_extra_markers(
            out,
            self.diagram_id,
            self.diagram_type,
            &self.extra_marker_colors,
            self.hand_drawn,
            self.security_level_loose,
        );
    }
}

fn marker_color_id(color: &str) -> String {
    // Mermaid's DOM marker id coloring logic (Mermaid@11.12.2) uses:
    // `strokeColor.replace(/[^\dA-Za-z]/g, '_')`
    //
    // Important: this does not trim whitespace. As a result, values like `" orange"` (leading
    // space captured from `style="...stroke: orange;..."`) produce a leading `_` in the color id,
    // which in turn yields a `__orange` suffix in the final marker id.
    let raw = color.trim_end_matches(';');
    if raw.trim().is_empty() {
        return String::new();
    }
    let mut out = String::with_capacity(raw.len());
    for ch in raw.chars() {
        if ch.is_ascii_alphanumeric() {
            out.push(ch);
        } else {
            out.push('_');
        }
    }
    out
}

#[inline]
pub(in crate::svg::parity::flowchart) fn write_flowchart_marker_id_xml(
    out: &mut String,
    diagram_id: SvgDiagramId<'_>,
    diagram_type: &str,
    base: &str,
    color: Option<&str>,
) {
    let _ = write!(out, "{diagram_id}");
    out.push('_');
    let _ = write!(out, "{}", escape_xml_display(diagram_type));
    out.push('-');
    out.push_str(base);

    let Some(color) = color else {
        return;
    };
    let raw = color.trim_end_matches(';');
    if raw.trim().is_empty() {
        return;
    }
    out.push('_');
    for ch in raw.chars() {
        if ch.is_ascii_alphanumeric() {
            out.push(ch);
        } else {
            out.push('_');
        }
    }
}

fn push_extra_markers(
    out: &mut String,
    diagram_id: SvgDiagramId<'_>,
    diagram_type: &str,
    colors: &[String],
    hand_drawn: bool,
    security_level_loose: bool,
) {
    for c in colors {
        let cid = marker_color_id(c);
        if cid.is_empty() {
            continue;
        }
        let color = c.trim_end_matches(';');
        let color = if security_level_loose {
            color
        } else {
            // Mermaid writes the regex capture verbatim, then DOMPurify trims ordinary SVG
            // attribute values for strict and sandbox output.
            color.trim()
        };

        if hand_drawn {
            let _ = write!(
                out,
                r#"<marker id="{}_{}-pointEnd_{}" class="marker {}" viewBox="0 0 10 10" refX="5" refY="5" markerUnits="userSpaceOnUse" markerWidth="8" markerHeight="8" orient="auto"><path d="M 0 0 L 10 5 L 0 10 z" class="arrowMarkerPath" style="stroke-width: 1; stroke-dasharray: 1, 0;"/></marker>"#,
                diagram_id,
                escape_xml(diagram_type),
                escape_xml(&cid),
                escape_xml(diagram_type)
            );
        } else {
            let _ = write!(
                out,
                r#"<marker id="{}_{}-pointEnd_{}" class="marker {}" viewBox="0 0 10 10" refX="5" refY="5" markerUnits="userSpaceOnUse" markerWidth="8" markerHeight="8" orient="auto"><path d="M 0 0 L 10 5 L 0 10 z" class="arrowMarkerPath" style="stroke-width: 1; stroke-dasharray: 1, 0;" stroke="{}" fill="{}"/></marker>"#,
                diagram_id,
                escape_xml(diagram_type),
                escape_xml(&cid),
                escape_xml(diagram_type),
                escape_xml_display(color),
                escape_xml_display(color)
            );
        }
    }
}

fn collect_edge_marker_colors(ctx: &FlowchartRenderCtx<'_>) -> Vec<String> {
    let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
    let mut out: Vec<String> = Vec::new();
    let hand_drawn = flowchart_config_look(ctx.config) == "handDrawn";

    for e in ctx.edges_by_id.values() {
        let mut found: Option<String> = None;
        for raw in ctx.default_edge_style.iter().chain(e.style.iter()) {
            // Mirror upstream behavior: handDrawn keeps the full `stroke:...` style token, while
            // classic/neo extracts only the captured color value from final pathStyle.
            //
            // Our style declarations may include a leading space (e.g. ` stroke: orange`), so we
            // only trim the key side.
            let s = raw.trim_start();
            let Some(rest) = s.strip_prefix("stroke:") else {
                continue;
            };
            let marker_source = if hand_drawn { s } else { rest };
            let cid = marker_color_id(marker_source);
            if cid.is_empty() {
                continue;
            }
            if seen.insert(cid) {
                found = Some(marker_source.to_string());
            }
            break;
        }

        if !hand_drawn && found.is_none() && !e.classes.is_empty() {
            let stroke = flowchart_resolve_stroke_for_marker(
                ctx.class_defs,
                &e.classes,
                &ctx.default_edge_style,
                &e.style,
            );
            if let Some(stroke) = stroke {
                let cid = marker_color_id(&stroke);
                if !cid.is_empty() && seen.insert(cid) {
                    found = Some(stroke);
                }
            }
        }

        if let Some(v) = found {
            out.push(v);
        }
    }

    out.sort();
    out
}