merman-render 0.8.0-alpha.4

Headless layout + SVG renderer for Mermaid (parity-focused; upstream SVG goldens).
Documentation
use crate::model::{Bounds, LayoutCluster};
use crate::text::MERMAID_CREATE_TEXT_DEFAULT_WIDTH_PX;
use std::collections::HashMap;
use std::fmt::Write as _;

use super::super::timing::RenderTiming;
use super::super::{escape_attr_display, escape_xml_display, fmt};
use super::bounds::include_xywh;
use super::label::class_math_html_label;

#[derive(Debug, Clone, Copy)]
pub(super) struct ClassNamespaceClusterGroupContext<'a> {
    pub diagram_id: &'a str,
    pub content_tx: f64,
    pub content_ty: f64,
    pub bounds_dx: f64,
    pub bounds_dy: f64,
    pub look: &'a str,
    pub mermaid_config: Option<&'a merman_core::MermaidConfig>,
    pub math_renderer: Option<&'a (dyn crate::math::MathRenderer + Send + Sync)>,
    pub timing: RenderTiming,
}

pub(super) fn render_class_namespace_cluster_group(
    out: &mut String,
    content_bounds: &mut Option<Bounds>,
    clusters: &[LayoutCluster],
    ctx: ClassNamespaceClusterGroupContext<'_>,
) -> std::time::Duration {
    let clusters_start = ctx.timing.start();
    out.push_str(r#"<g class="clusters">"#);
    for c in clusters {
        render_class_namespace_cluster(out, content_bounds, c, ctx);
    }
    out.push_str("</g>");
    clusters_start
        .map(|start| start.elapsed())
        .unwrap_or_default()
}

pub(super) fn render_class_elk_subgraphs(
    out: &mut String,
    content_bounds: &mut Option<Bounds>,
    clusters: &[LayoutCluster],
    ctx: ClassNamespaceClusterGroupContext<'_>,
) -> std::time::Duration {
    let clusters_start = ctx.timing.start();
    out.push_str(r#"<g class="subgraphs">"#);
    for cluster in clusters {
        out.push_str(r#"<g class="subgraph">"#);
        render_class_namespace_cluster(out, content_bounds, cluster, ctx);
        out.push_str("</g>");
    }
    out.push_str("</g>");
    clusters_start
        .map(|start| start.elapsed())
        .unwrap_or_default()
}

fn render_class_namespace_cluster(
    out: &mut String,
    content_bounds: &mut Option<Bounds>,
    cluster: &LayoutCluster,
    ctx: ClassNamespaceClusterGroupContext<'_>,
) {
    let w = cluster.width.max(1.0);
    let h = cluster.height.max(1.0);
    let left = cluster.x - w / 2.0 + ctx.content_tx;
    let top = cluster.y - h / 2.0 + ctx.content_ty;
    include_xywh(
        content_bounds,
        left + ctx.bounds_dx,
        top + ctx.bounds_dy,
        w,
        h,
    );

    let label_w = cluster.title_label.width.max(0.0);
    let label_h = 24.0;
    let label_x = left + (w - label_w) / 2.0;
    let label_y = top + cluster.title_margin_top;
    include_xywh(
        content_bounds,
        label_x + ctx.bounds_dx,
        label_y + ctx.bounds_dy,
        label_w,
        label_h,
    );

    let title_html = class_namespace_title_html(&cluster.title, ctx);
    let _ = write!(
        out,
        r#"<g class="cluster undefined" id="{}-{}" data-look="{}"><rect x="{}" y="{}" width="{}" height="{}" style=""/><g class="cluster-label" transform="translate({}, {})"><foreignObject width="{}" height="24"><div xmlns="http://www.w3.org/1999/xhtml" style="display: table-cell; white-space: nowrap; line-height: 1.5; max-width: {}px; text-align: center;"><span class="nodeLabel">{}</span></div></foreignObject></g></g>"#,
        escape_attr_display(ctx.diagram_id),
        escape_attr_display(&cluster.id),
        escape_attr_display(ctx.look),
        fmt(left),
        fmt(top),
        fmt(w),
        fmt(h),
        fmt(label_x),
        fmt(label_y),
        fmt(label_w),
        MERMAID_CREATE_TEXT_DEFAULT_WIDTH_PX,
        title_html
    );
}

fn class_namespace_title_html(title: &str, ctx: ClassNamespaceClusterGroupContext<'_>) -> String {
    class_math_html_label(title, ctx.mermaid_config, ctx.math_renderer)
        .unwrap_or_else(|| format!("<p>{}</p>", escape_xml_display(title)))
}

pub(super) fn class_namespace_root_offset(c: &LayoutCluster) -> (f64, f64) {
    let w = c.width.max(1.0);
    let h = c.height.max(1.0);
    (c.x - w / 2.0 - 8.0, c.y - h / 2.0)
}

#[allow(clippy::too_many_arguments)]
pub(super) fn render_class_namespace_clusters_in_root(
    out: &mut String,
    content_bounds: &mut Option<Bounds>,
    clusters_by_id: &HashMap<&str, &LayoutCluster>,
    cluster_ids: &[&str],
    ctx: ClassNamespaceClusterGroupContext<'_>,
    root_ns_id: &str,
    root_dx: f64,
    root_dy: f64,
) {
    out.push_str(r#"<g class="clusters">"#);
    for ns_id in cluster_ids {
        let c = clusters_by_id
            .get(ns_id)
            .copied()
            .expect("validated Class render cluster id");

        let w = c.width.max(1.0);
        let h = c.height.max(1.0);
        let (left, top) = if *ns_id == root_ns_id {
            (8.0, 8.0)
        } else {
            (
                c.x - w / 2.0 - root_dx,
                c.y - h / 2.0 + ctx.content_ty - root_dy,
            )
        };
        include_xywh(
            content_bounds,
            left + root_dx + ctx.bounds_dx,
            top + root_dy + ctx.bounds_dy,
            w,
            h,
        );

        let label_w = c.title_label.width.max(0.0);
        let label_h = 24.0;
        let label_x = left + (w - label_w) / 2.0;
        let label_y = top + c.title_margin_top;
        include_xywh(
            content_bounds,
            label_x + root_dx + ctx.bounds_dx,
            label_y + root_dy + ctx.bounds_dy,
            label_w,
            label_h,
        );

        let title_html = class_namespace_title_html(&c.title, ctx);
        let _ = write!(
            out,
            r#"<g class="cluster undefined" id="{}-{}" data-look="{}"><rect x="{}" y="{}" width="{}" height="{}" style=""/><g class="cluster-label" transform="translate({}, {})"><foreignObject width="{}" height="24"><div xmlns="http://www.w3.org/1999/xhtml" style="display: table-cell; white-space: nowrap; line-height: 1.5; max-width: {}px; text-align: center;"><span class="nodeLabel">{}</span></div></foreignObject></g></g>"#,
            escape_attr_display(ctx.diagram_id),
            escape_attr_display(&c.id),
            escape_attr_display(ctx.look),
            fmt(left),
            fmt(top),
            fmt(w),
            fmt(h),
            fmt(label_x),
            fmt(label_y),
            fmt(label_w),
            MERMAID_CREATE_TEXT_DEFAULT_WIDTH_PX,
            title_html
        );
    }
    out.push_str("</g>");
}