merman-render 0.8.0-alpha.6

Headless layout + SVG renderer for Mermaid (parity-focused; upstream SVG goldens).
Documentation
use super::super::*;
use super::SequenceEmitCheckpoints;
use super::actor_man::{render_sequence_actor_man_bottoms, render_sequence_actor_man_tops};
use super::actor_popup::{SequenceActorPopupOptions, render_sequence_actor_popup_menus};
use super::actors::{
    SequenceActorRenderContext, render_sequence_bottom_actors,
    render_sequence_top_actors_and_lifelines,
};
use super::frames::{SequenceFrameRenderOptions, render_sequence_box_frames_and_rect_blocks};
use super::interactions::{SequenceInteractionRenderContext, render_sequence_interaction_overlays};
use super::messages::{SequenceMessageRenderContext, render_sequence_messages};
use super::root::write_sequence_svg_root_open;
use super::settings::SequenceRenderSettings;
use rustc_hash::FxHashMap;

use super::css::sequence_css;
use super::model::*;

const PINNED_MERMAID_SEQUENCE_BASE_DEFS: &str = include_str!("sequence_base_defs_11_16_0.svgfrag");
const MERMAID_SEQUENCE_EXTRA_MARKER_DEFS_PINNED: &str = r#"<defs><marker id="solidTopArrowHead" refX="7.9" refY="7.25" markerUnits="userSpaceOnUse" markerWidth="12" markerHeight="12" orient="auto-start-reverse"><path d="M 0 0 L 10 8 L 0 8 z"/></marker></defs><defs><marker id="solidBottomArrowHead" refX="7.9" refY="0.75" markerUnits="userSpaceOnUse" markerWidth="12" markerHeight="12" orient="auto-start-reverse"><path d="M 0 0 L 10 0 L 0 8 z"/></marker></defs><defs><marker id="stickTopArrowHead" refX="7.5" refY="7" markerUnits="userSpaceOnUse" markerWidth="12" markerHeight="12" orient="auto-start-reverse"><path d="M 0 0 L 7 7" stroke="black" stroke-width="1.5" fill="none"/></marker></defs><defs><marker id="stickBottomArrowHead" refX="7.5" refY="0" markerUnits="userSpaceOnUse" markerWidth="12" markerHeight="12" orient="auto-start-reverse"><path d="M 0 7 L 7 0" stroke="black" stroke-width="1.5" fill="none"/></marker></defs>"#;

const SEQUENCE_SCOPED_BASE_DEF_IDS: [&str; 11] = [
    "computer",
    "database",
    "clock",
    "arrowhead",
    "crosshead",
    "filled-head",
    "sequencenumber",
    "solidTopArrowHead",
    "solidBottomArrowHead",
    "stickTopArrowHead",
    "stickBottomArrowHead",
];

fn write_scoped_sequence_base_defs_fragment(
    out: &mut String,
    fragment: &str,
    diagram_id: impl Copy + std::fmt::Display,
) {
    const ID_ATTRIBUTE_START: &str = "id=\"";

    let mut cursor = 0usize;
    while let Some(relative_start) = fragment[cursor..].find(ID_ATTRIBUTE_START) {
        let attribute_start = cursor + relative_start;
        let value_start = attribute_start + ID_ATTRIBUTE_START.len();
        let Some(relative_end) = fragment[value_start..].find('"') else {
            break;
        };
        let value_end = value_start + relative_end;
        let local_id = &fragment[value_start..value_end];

        out.push_str(&fragment[cursor..value_start]);
        if SEQUENCE_SCOPED_BASE_DEF_IDS.contains(&local_id) {
            let _ = write!(out, "{diagram_id}");
            out.push('-');
            out.push_str(local_id);
        } else {
            out.push_str(local_id);
        }
        cursor = value_end;
    }
    out.push_str(&fragment[cursor..]);
}

fn write_scoped_sequence_base_defs(out: &mut String, diagram_id: impl Copy + std::fmt::Display) {
    write_scoped_sequence_base_defs_fragment(out, PINNED_MERMAID_SEQUENCE_BASE_DEFS, diagram_id);
    write_scoped_sequence_base_defs_fragment(
        out,
        MERMAID_SEQUENCE_EXTRA_MARKER_DEFS_PINNED,
        diagram_id,
    );
}

pub(in crate::svg::parity) fn render_sequence_diagram_svg_model_with_config(
    prepared: &crate::sequence::SequencePreparedArtifact,
    model: &SequenceSvgModel,
    effective_config: &merman_core::MermaidConfig,
    diagram_title: Option<&str>,
    measurer: &dyn TextMeasurer,
    options: &SvgExecution<'_>,
) -> Result<root_svg::RootedSvg> {
    render_sequence_diagram_svg_inner(
        prepared,
        model,
        effective_config.as_value(),
        effective_config,
        diagram_title,
        measurer,
        options,
    )
}

fn render_sequence_diagram_svg_inner(
    prepared: &crate::sequence::SequencePreparedArtifact,
    model: &SequenceSvgModel,
    effective_config: &serde_json::Value,
    sanitize_config: &merman_core::MermaidConfig,
    diagram_title: Option<&str>,
    measurer: &dyn TextMeasurer,
    options: &SvgExecution<'_>,
) -> Result<root_svg::RootedSvg> {
    let checkpoints = SequenceEmitCheckpoints::new(options.work_meter());
    checkpoints.checkpoint()?;
    let layout = prepared.layout();
    let effective_title =
        crate::sequence::sequence_render_title(model.title.as_deref(), diagram_title);

    let diagram_id = options.diagram_id_or("merman");
    let settings = SequenceRenderSettings::from_effective_config(effective_config);

    let mut nodes_by_id: FxHashMap<&str, &LayoutNode> =
        FxHashMap::with_capacity_and_hasher(layout.nodes.len(), Default::default());
    for (node_index, node) in layout.nodes.iter().enumerate() {
        checkpoints.checkpoint_loop(node_index)?;
        nodes_by_id.insert(node.id.as_str(), node);
    }

    let mut edges_by_id: FxHashMap<&str, &crate::model::LayoutEdge> =
        FxHashMap::with_capacity_and_hasher(layout.edges.len(), Default::default());
    for (edge_index, edge) in layout.edges.iter().enumerate() {
        checkpoints.checkpoint_loop(edge_index)?;
        edges_by_id.insert(edge.id.as_str(), edge);
    }
    checkpoints.checkpoint()?;

    let mut out = String::new();
    checkpoints.checkpoint()?;
    let root_metrics = write_sequence_svg_root_open(&mut out, layout, model, diagram_id)?;
    checkpoints.checkpoint()?;

    render_sequence_box_frames_and_rect_blocks(
        &mut out,
        model,
        &nodes_by_id,
        SequenceFrameRenderOptions {
            actor_label_font_size: settings.actor_label_font_size,
            box_margin: settings.box_margin,
            box_text_margin: settings.box_text_margin,
            rect_default_fill: &settings.rect_default_fill,
        },
        checkpoints,
    )?;

    let actor_ctx = SequenceActorRenderContext {
        model,
        nodes_by_id: &nodes_by_id,
        edges_by_id: &edges_by_id,
        sanitize_config,
        math_renderer: options.math_renderer(),
        actor_wrap_width: settings.actor_wrap_width,
        actor_height: settings.actor_height,
        label_box_height: settings.label_box_height,
        measurer,
        loop_text_style: &settings.loop_text_style,
        checkpoints,
    };

    if settings.mirror_actors {
        render_sequence_bottom_actors(&mut out, &actor_ctx)?;
    }

    // Top actors + lifelines.
    render_sequence_top_actors_and_lifelines(&mut out, &actor_ctx)?;

    checkpoints.checkpoint()?;
    let _ = write!(
        &mut out,
        r#"<style>{}</style><g/>"#,
        sequence_css(diagram_id, settings.actor_label_font_size, effective_config)
    );

    // Mermaid's sequence output includes a shared set of <defs> for icons/markers.
    checkpoints.checkpoint()?;
    write_scoped_sequence_base_defs(&mut out, diagram_id);
    checkpoints.checkpoint()?;

    render_sequence_actor_man_tops(
        &mut out,
        model,
        &nodes_by_id,
        settings.actor_height,
        diagram_id,
        checkpoints,
    )?;

    let block_widths_by_id = crate::sequence::sequence_block_widths_for_render(
        model,
        prepared,
        &nodes_by_id,
        sanitize_config,
        measurer,
        options.math_renderer(),
        options.work_meter(),
    )?;

    let interaction_ctx = SequenceInteractionRenderContext {
        model,
        block_widths_by_id: &block_widths_by_id,
        block_layouts_by_id: &layout.block_layouts_by_id,
        nodes_by_id: &nodes_by_id,
        edges_by_id: &edges_by_id,
        sanitize_config,
        math_renderer: options.math_renderer(),
        settings: &settings,
        measurer,
        checkpoints,
    };
    render_sequence_interaction_overlays(&mut out, &interaction_ctx)?;

    let message_ctx = SequenceMessageRenderContext {
        model,
        nodes_by_id: &nodes_by_id,
        edges_by_id: &edges_by_id,
        sanitize_config,
        math_renderer: options.math_renderer(),
        measurer,
        message_align: settings.message_align.as_str(),
        diagram_id,
        actor_height: settings.actor_height,
        actor_label_font_size: settings.actor_label_font_size,
        sequence_width: settings.sequence_width,
        activation_width: settings.activation_width,
        wrap_padding: settings.wrap_padding,
        right_angles: settings.right_angles,
        loop_text_style: &settings.loop_text_style,
        checkpoints,
    };
    render_sequence_messages(&mut out, &message_ctx)?;

    render_sequence_actor_popup_menus(
        &mut out,
        model,
        &nodes_by_id,
        sanitize_config,
        SequenceActorPopupOptions {
            force_menus: settings.force_menus,
            mirror_actors: settings.mirror_actors,
            actor_height: settings.actor_height,
        },
        checkpoints,
    )?;

    if settings.mirror_actors {
        render_sequence_actor_man_bottoms(
            &mut out,
            model,
            &nodes_by_id,
            settings.actor_height,
            settings.label_box_height,
            diagram_id,
            checkpoints,
        )?;
    }

    if let Some(title) = effective_title {
        // Mermaid sequence titles are currently emitted as a plain `<text>` node.
        // Mermaid positions the title using the inner (content) box width:
        // `x = (box.stopx - box.startx) / 2 - 2 * diagramMarginX`.
        let title_x = ((root_metrics.viewbox_width - 2.0 * settings.diagram_margin_x) / 2.0)
            - 2.0 * settings.diagram_margin_x;
        checkpoints.checkpoint()?;
        let _ = write!(
            &mut out,
            r#"<text x="{x}" y="-25">{text}</text>"#,
            x = fmt(title_x),
            text = escape_xml(title)
        );
    }

    checkpoints.checkpoint()?;
    out.push_str("</svg>\n");
    checkpoints.checkpoint()?;
    let rooted = root_metrics.document.complete(out)?;
    checkpoints.checkpoint()?;
    Ok(rooted)
}

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

    #[test]
    fn sequence_defs_are_scoped_in_one_output_pass() {
        let mut defs = String::new();
        write_scoped_sequence_base_defs(&mut defs, "sequence-root");

        for local_id in SEQUENCE_SCOPED_BASE_DEF_IDS {
            assert!(
                defs.contains(&format!(r#"id="sequence-root-{local_id}""#)),
                "missing scoped definition {local_id}"
            );
            assert!(
                !defs.contains(&format!(r#"id="{local_id}""#)),
                "bare definition {local_id} survived scoping"
            );
        }
    }
}