merman-render 0.8.0-alpha.4

Headless layout + SVG renderer for Mermaid (parity-focused; upstream SVG goldens).
Documentation
use super::*;
use crate::model::RailroadElementLayout;
use merman_core::diagrams::railroad::RailroadDiagramRenderModel;

pub(crate) fn render_railroad_diagram_svg_model(
    layout: &RailroadDiagramLayout,
    model: &RailroadDiagramRenderModel,
    effective_config: &serde_json::Value,
    measurer: &dyn TextMeasurer,
    options: &SvgExecution<'_>,
) -> Result<root_svg::RootedSvg> {
    let diagram_id = options.diagram_id.as_deref().unwrap_or("railroad");
    let diagram_id_esc = escape_xml(diagram_id);
    let acc_title = model
        .acc_title
        .as_deref()
        .map(str::trim)
        .filter(|value| !value.is_empty());
    let acc_descr = model
        .acc_descr
        .as_deref()
        .map(str::trim)
        .filter(|value| !value.is_empty());
    let aria_labelledby = acc_title.map(|_| format!("chart-title-{diagram_id}"));
    let aria_describedby = acc_descr.map(|_| format!("chart-desc-{diagram_id}"));
    let root_bounds = root_svg::DiagramBounds::from_view_box(0.0, 0.0, layout.width, layout.height);
    let root_spec = root_svg::RootViewportSpec::mermaid(root_bounds, layout.use_max_width);
    let style = crate::railroad::railroad_style(effective_config);

    let mut out = String::new();
    let mut root_chrome = root_svg::RootChrome::new(diagram_id, &layout.diagram_type);
    root_chrome.class = Some("railroad-diagram");
    root_chrome.aria_labelledby = aria_labelledby.as_deref();
    root_chrome.aria_describedby = aria_describedby.as_deref();
    root_chrome.dom.trailing_newline = false;
    let root_document =
        root_svg::RootViewportContext::new(crate::family::RenderFamilyKind::Railroad, diagram_id)
            .write_open(&mut out, root_spec, root_chrome)?;

    if let Some(title) = acc_title {
        let _ = write!(
            &mut out,
            r#"<title id="chart-title-{}">{}</title>"#,
            diagram_id_esc,
            escape_xml_display(title)
        );
    }
    if let Some(descr) = acc_descr {
        let _ = write!(
            &mut out,
            r#"<desc id="chart-desc-{}">{}</desc>"#,
            diagram_id_esc,
            escape_xml_display(descr)
        );
    }
    let _ = write!(
        &mut out,
        "<style>{}</style>",
        railroad_css(&style, diagram_id)
    );
    out.push_str("<g/>");

    for (rule_index, rule) in layout.rules.iter().enumerate() {
        let model_rule = model
            .rules
            .get(rule_index)
            .ok_or_else(|| Error::InvalidModel {
                message: format!(
                    "railroad layout contains rule {} without a matching semantic rule",
                    rule.name
                ),
            })?;
        let _ = write!(
            &mut out,
            r#"<g class="railroad-rule" transform="translate({}, {})">"#,
            fmt(rule.x),
            fmt(rule.y)
        );
        let (render_node, definition_up) =
            crate::railroad::railroad_render_node(&model_rule.definition, &style, measurer);
        let _ = write!(
            &mut out,
            r#"<g transform="translate({}, {})">"#,
            fmt(rule.definition_x),
            fmt(rule.baseline_y - definition_up)
        );
        push_render_node(&mut out, &render_node);
        out.push_str("</g>");
        let _ = write!(
            &mut out,
            r#"<g class="railroad-rule-name-group"><text class="railroad-rule-name" x="0" y="{}">{} =</text></g>"#,
            fmt(rule.baseline_y),
            escape_xml_display(&rule.name)
        );
        let _ = write!(
            &mut out,
            r#"<g class="railroad-start"><circle cx="{}" cy="{}" r="{}"></circle></g><g class="railroad-end"><circle cx="{}" cy="{}" r="{}"></circle></g>"#,
            fmt(rule.start_marker_x),
            fmt(rule.baseline_y),
            fmt(rule.marker_radius),
            fmt(rule.end_marker_x),
            fmt(rule.baseline_y),
            fmt(rule.marker_radius)
        );
        for path in rule.paths.iter().rev().take(2).rev() {
            push_path(&mut out, path);
        }
        out.push_str("</g>");
    }

    out.push_str("</svg>\n");
    root_document.complete(out)
}

fn push_render_node(out: &mut String, node: &crate::railroad::RailroadRenderNode) {
    match node {
        crate::railroad::RailroadRenderNode::Group {
            class,
            transform,
            children,
        } => {
            let _ = write!(out, r#"<g class="{}""#, escape_attr_display(class));
            push_optional_transform(out, *transform);
            out.push('>');
            for child in children {
                push_render_node(out, child);
            }
            out.push_str("</g>");
        }
        crate::railroad::RailroadRenderNode::Element { layout, transform } => {
            push_element(out, layout, *transform);
        }
        crate::railroad::RailroadRenderNode::Path(path) => push_path(out, path),
    }
}

fn push_optional_transform(out: &mut String, transform: Option<(f64, f64)>) {
    if let Some((x, y)) = transform {
        let _ = write!(out, r#" transform="translate({}, {})""#, fmt(x), fmt(y));
    }
}

fn push_path(out: &mut String, path: &crate::model::RailroadPathLayout) {
    out.push_str(r#"<path class="railroad-line""#);
    if path.x != 0.0 || path.y != 0.0 {
        let _ = write!(
            out,
            r#" transform="translate({}, {})""#,
            fmt(path.x),
            fmt(path.y)
        );
    }
    let _ = write!(out, r#" d="{}"></path>"#, escape_attr_display(&path.d));
}

fn push_element(out: &mut String, element: &RailroadElementLayout, transform: Option<(f64, f64)>) {
    let class = match element.kind.as_str() {
        "terminal" => "railroad-terminal",
        "nonterminal" => "railroad-nonterminal",
        "special" => "railroad-special",
        _ => "railroad-group",
    };
    let _ = write!(out, r#"<g class="{}""#, class);
    push_optional_transform(out, transform);
    out.push('>');
    match element.kind.as_str() {
        "terminal" => {
            let _ = write!(
                out,
                r#"<rect x="0" y="0" width="{}" height="{}" rx="10" ry="10"></rect>"#,
                fmt(element.width),
                fmt(element.height)
            );
        }
        _ => {
            let _ = write!(
                out,
                r#"<rect x="0" y="0" width="{}" height="{}"></rect>"#,
                fmt(element.width),
                fmt(element.height)
            );
        }
    }
    let _ = write!(
        out,
        r#"<text x="{}" y="{}">{}</text></g>"#,
        fmt(element.text_x),
        fmt(element.text_y),
        escape_xml_display(&element.label)
    );
}

fn railroad_css_scope(diagram_id: &str) -> String {
    let mut scope = String::with_capacity(diagram_id.len() + 1);
    scope.push('#');
    for ch in diagram_id.chars() {
        if matches!(ch, '.' | ':') {
            scope.push('\\');
        }
        scope.push(ch);
    }
    scope
}

fn railroad_css(style: &crate::railroad::RailroadStyle, diagram_id: &str) -> String {
    let scope = railroad_css_scope(diagram_id);
    format!(
        "{scope} .railroad-diagram{{font-family:{};font-size:{}px;}}\
{scope} .railroad-terminal rect{{fill:{};stroke:{};stroke-width:{}px;}}\
{scope} .railroad-terminal text{{fill:{};font-family:{};font-size:{}px;text-anchor:middle;dominant-baseline:middle;}}\
{scope} .railroad-nonterminal rect{{fill:{};stroke:{};stroke-width:{}px;}}\
{scope} .railroad-nonterminal text{{fill:{};font-family:{};font-size:{}px;text-anchor:middle;dominant-baseline:middle;}}\
{scope} .railroad-line{{stroke:{};stroke-width:{}px;fill:none;}}\
{scope} .railroad-start circle,{scope} .railroad-end circle{{fill:{};}}\
{scope} .railroad-comment ellipse{{fill:{};stroke:{};stroke-width:{}px;}}\
{scope} .railroad-comment text{{fill:{};font-style:italic;font-family:{};font-size:{}px;text-anchor:middle;dominant-baseline:middle;}}\
{scope} .railroad-special rect{{fill:{};stroke:{};stroke-width:{}px;stroke-dasharray:5,3;}}\
{scope} .railroad-special text{{fill:{};font-family:{};font-size:{}px;text-anchor:middle;dominant-baseline:middle;}}\
{scope} .railroad-rule-name{{font-weight:bold;fill:{};font-family:{};font-size:{}px;}}\
{scope} .railroad-group{{}}",
        style.font_family,
        fmt(style.font_size),
        style.terminal_fill,
        style.terminal_stroke,
        fmt(style.stroke_width),
        style.terminal_text_color,
        style.font_family,
        fmt(style.font_size),
        style.non_terminal_fill,
        style.non_terminal_stroke,
        fmt(style.stroke_width),
        style.non_terminal_text_color,
        style.font_family,
        fmt(style.font_size),
        style.line_color,
        fmt(style.stroke_width),
        style.marker_fill,
        style.comment_fill,
        style.comment_stroke,
        fmt(style.stroke_width),
        style.comment_text_color,
        style.font_family,
        fmt(style.font_size),
        style.special_fill,
        style.special_stroke,
        fmt(style.stroke_width),
        style.non_terminal_text_color,
        style.font_family,
        fmt(style.font_size),
        style.rule_name_color,
        style.font_family,
        fmt(style.font_size)
    )
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn root_font_rule_matches_mermaid_namespacing() {
        let style = crate::railroad::railroad_style(&serde_json::json!({}));
        let css = railroad_css(&style, "railroad.fixture");

        assert!(css.starts_with("#railroad\\.fixture .railroad-diagram{"));
        assert!(!css.starts_with("#railroad\\.fixture.railroad-diagram{"));
    }
}