merman-render 0.5.0

Headless layout + SVG renderer for Mermaid (parity-focused; upstream SVG goldens).
Documentation
use merman_core::{Engine, ParseOptions};
use merman_render::{LayoutOptions, layout_parsed};
use std::path::PathBuf;

fn workspace_root() -> PathBuf {
    PathBuf::from(env!("CARGO_MANIFEST_DIR"))
        .join("..")
        .join("..")
}

fn load_class_layout_fixture(name: &str) -> merman_render::model::ClassDiagramV2Layout {
    let path = workspace_root()
        .join("fixtures")
        .join("class")
        .join(format!("{name}.mmd"));
    let text = std::fs::read_to_string(&path).expect("fixture");

    let engine = Engine::new();
    let parsed = futures::executor::block_on(engine.parse_diagram(&text, ParseOptions::default()))
        .expect("parse ok")
        .expect("diagram detected");

    let out = layout_parsed(&parsed, &LayoutOptions::default()).expect("layout ok");
    let merman_render::model::LayoutDiagram::ClassDiagramV2(layout) = out.layout else {
        panic!("expected ClassDiagramV2 layout");
    };
    *layout
}

fn rect_from_node(n: &merman_render::model::LayoutNode) -> (f64, f64, f64, f64) {
    let hw = n.width / 2.0;
    let hh = n.height / 2.0;
    (n.x - hw, n.y - hh, n.x + hw, n.y + hh)
}

fn rect_from_cluster(c: &merman_render::model::LayoutCluster) -> (f64, f64, f64, f64) {
    let hw = c.width / 2.0;
    let hh = c.height / 2.0;
    (c.x - hw, c.y - hh, c.x + hw, c.y + hh)
}

fn rect_contains(outer: (f64, f64, f64, f64), inner: (f64, f64, f64, f64), eps: f64) -> bool {
    let (omin_x, omin_y, omax_x, omax_y) = outer;
    let (imin_x, imin_y, imax_x, imax_y) = inner;
    imin_x + eps >= omin_x
        && imax_x <= omax_x + eps
        && imin_y + eps >= omin_y
        && imax_y <= omax_y + eps
}

#[test]
fn class_layout_produces_positions_and_routes() {
    let path = workspace_root()
        .join("fixtures")
        .join("class")
        .join("basic.mmd");
    let text = std::fs::read_to_string(&path).expect("fixture");

    let engine = Engine::new();
    let parsed = futures::executor::block_on(engine.parse_diagram(&text, ParseOptions::default()))
        .expect("parse ok")
        .expect("diagram detected");

    let out = layout_parsed(&parsed, &LayoutOptions::default()).expect("layout ok");
    let merman_render::model::LayoutDiagram::ClassDiagramV2(layout) = out.layout else {
        panic!("expected ClassDiagramV2 layout");
    };

    assert!(layout.nodes.len() >= 2);
    assert!(!layout.edges.is_empty());

    for n in &layout.nodes {
        assert!(n.width.is_finite() && n.width > 0.0);
        assert!(n.height.is_finite() && n.height > 0.0);
        assert!(n.x.is_finite() && n.y.is_finite());
    }

    for e in &layout.edges {
        assert!(
            e.points.len() >= 2,
            "edge {} should have at least two points",
            e.id
        );
        for p in &e.points {
            assert!(p.x.is_finite() && p.y.is_finite());
        }
    }
}

#[test]
fn class_namespaces_contain_member_classes() {
    let path = workspace_root()
        .join("fixtures")
        .join("class")
        .join("upstream_namespaces_and_generics.mmd");
    let text = std::fs::read_to_string(&path).expect("fixture");

    let engine = Engine::new();
    let parsed = futures::executor::block_on(engine.parse_diagram(&text, ParseOptions::default()))
        .expect("parse ok")
        .expect("diagram detected");

    let out = layout_parsed(&parsed, &LayoutOptions::default()).expect("layout ok");
    let merman_render::model::LayoutDiagram::ClassDiagramV2(layout) = out.layout else {
        panic!("expected ClassDiagramV2 layout");
    };

    let mut node_by_id = std::collections::HashMap::new();
    for n in &layout.nodes {
        if !n.is_cluster {
            node_by_id.insert(n.id.as_str(), n);
        }
    }
    let mut cluster_by_id = std::collections::HashMap::new();
    for c in &layout.clusters {
        cluster_by_id.insert(c.id.as_str(), c);
    }

    let semantic = &out.semantic;
    let Some(classes) = semantic.get("classes").and_then(|v| v.as_object()) else {
        panic!("missing semantic.classes");
    };

    for (id, cls) in classes {
        let parent = cls.get("parent").and_then(|v| v.as_str()).unwrap_or("");
        if parent.is_empty() {
            continue;
        }
        let Some(node) = node_by_id.get(id.as_str()) else {
            continue;
        };
        let Some(cluster) = cluster_by_id.get(parent) else {
            panic!("missing cluster {parent}");
        };
        assert!(
            rect_contains(rect_from_cluster(cluster), rect_from_node(node), 0.01),
            "cluster {parent} should contain {id}"
        );
    }
}

#[test]
fn class_layout_dense_namespaces_follow_declaration_order() {
    let layout = load_class_layout_fixture("stress_class_dense_namespaces_generics_001");

    let cluster_ids = layout
        .clusters
        .iter()
        .map(|cluster| cluster.id.as_str())
        .collect::<Vec<_>>();
    assert_eq!(cluster_ids, vec!["Core", "API"]);
}

#[test]
fn class_terminal_labels_exist_for_cardinalities_fixture() {
    let path = workspace_root()
        .join("fixtures")
        .join("class")
        .join("upstream_relation_types_and_cardinalities_spec.mmd");
    let text = std::fs::read_to_string(&path).expect("fixture");

    let engine = Engine::new();
    let parsed = futures::executor::block_on(engine.parse_diagram(&text, ParseOptions::default()))
        .expect("parse ok")
        .expect("diagram detected");

    let out = layout_parsed(&parsed, &LayoutOptions::default()).expect("layout ok");
    let merman_render::model::LayoutDiagram::ClassDiagramV2(layout) = out.layout else {
        panic!("expected ClassDiagramV2 layout");
    };

    let has_terminal = layout.edges.iter().any(|e| {
        e.start_label_left.is_some()
            || e.start_label_right.is_some()
            || e.end_label_left.is_some()
            || e.end_label_right.is_some()
    });
    assert!(has_terminal, "expected at least one terminal label");
}

fn point_inside(rect: (f64, f64, f64, f64), x: f64, y: f64, eps: f64) -> bool {
    let (min_x, min_y, max_x, max_y) = rect;
    x >= min_x - eps && x <= max_x + eps && y >= min_y - eps && y <= max_y + eps
}

#[test]
fn class_note_heavy_tb_layout_prefers_mermaid_leftward_rank_order() {
    let layout = load_class_layout_fixture("stress_class_notes_wrap_positions_014");

    let node_a = layout.nodes.iter().find(|n| n.id == "A").expect("class A");
    let node_b = layout.nodes.iter().find(|n| n.id == "B").expect("class B");
    let node_c = layout.nodes.iter().find(|n| n.id == "C").expect("class C");
    let note_a = layout
        .nodes
        .iter()
        .find(|n| n.id == "note0")
        .expect("note for A");
    let note_b = layout
        .nodes
        .iter()
        .find(|n| n.id == "note1")
        .expect("note for B");
    let note_c = layout
        .nodes
        .iter()
        .find(|n| n.id == "note2")
        .expect("note for C");

    assert!(
        node_a.x > node_b.x && node_b.x > node_c.x,
        "expected TB note-heavy classes to lean left, got A={}, B={}, C={}",
        node_a.x,
        node_b.x,
        node_c.x
    );
    assert!(
        (note_a.x - node_a.x).abs() <= 0.01,
        "expected note for A to stay centered over A, got note={}, class={}",
        note_a.x,
        node_a.x
    );
    assert!(
        note_b.x < node_b.x,
        "expected note for B to stay on the left, got note={}, class={}",
        note_b.x,
        node_b.x
    );
    assert!(
        note_c.x < node_c.x,
        "expected note for C to stay on the left, got note={}, class={}",
        note_c.x,
        node_c.x
    );
}

#[test]
fn class_two_note_tb_layout_keeps_secondary_note_left_of_target() {
    let layout = load_class_layout_fixture("stress_class_notes_and_keywords_003");

    let node_a = layout.nodes.iter().find(|n| n.id == "A").expect("class A");
    let node_b = layout.nodes.iter().find(|n| n.id == "B").expect("class B");
    let note_b = layout
        .nodes
        .iter()
        .find(|n| n.id == "note1")
        .expect("note for B");

    assert!(
        node_a.x > node_b.x,
        "expected A to remain to the right of B for the mirrored note-heavy solution, got A={}, B={}",
        node_a.x,
        node_b.x
    );
    assert!(
        note_b.x < node_b.x,
        "expected note for B to stay left of B, got note={}, class={}",
        note_b.x,
        node_b.x
    );
}

#[test]
fn class_terminal_labels_are_outside_endpoint_nodes_for_cardinalities_fixture() {
    let path = workspace_root()
        .join("fixtures")
        .join("class")
        .join("upstream_relation_types_and_cardinalities_spec.mmd");
    let text = std::fs::read_to_string(&path).expect("fixture");

    let engine = Engine::new();
    let parsed = futures::executor::block_on(engine.parse_diagram(&text, ParseOptions::default()))
        .expect("parse ok")
        .expect("diagram detected");

    let out = layout_parsed(&parsed, &LayoutOptions::default()).expect("layout ok");
    let merman_render::model::LayoutDiagram::ClassDiagramV2(layout) = out.layout else {
        panic!("expected ClassDiagramV2 layout");
    };

    let mut node_rect_by_id = std::collections::HashMap::new();
    for n in &layout.nodes {
        if n.is_cluster {
            continue;
        }
        node_rect_by_id.insert(n.id.as_str(), rect_from_node(n));
    }

    let eps = 0.01;
    let mut checked = 0usize;
    for e in &layout.edges {
        let Some(from_rect) = node_rect_by_id.get(e.from.as_str()) else {
            continue;
        };
        let Some(to_rect) = node_rect_by_id.get(e.to.as_str()) else {
            continue;
        };

        for lbl in [
            e.start_label_left.as_ref(),
            e.start_label_right.as_ref(),
            e.end_label_left.as_ref(),
            e.end_label_right.as_ref(),
        ] {
            let Some(lbl) = lbl else {
                continue;
            };
            checked += 1;
            assert!(
                !point_inside(*from_rect, lbl.x, lbl.y, eps),
                "terminal label center should not be inside start node for edge {}",
                e.id
            );
            assert!(
                !point_inside(*to_rect, lbl.x, lbl.y, eps),
                "terminal label center should not be inside end node for edge {}",
                e.id
            );
        }
    }
    assert!(checked > 0, "expected to check at least one terminal label");
}

#[test]
fn class_single_glyph_svg_titles_use_upstream_bbox_width() {
    let text = r#"---
config:
  htmlLabels: false
---
classDiagram
A <|-- B
"#;

    let engine = Engine::new();
    let parsed = futures::executor::block_on(engine.parse_diagram(text, ParseOptions::default()))
        .expect("parse ok")
        .expect("diagram detected");

    let out = layout_parsed(
        &parsed,
        &LayoutOptions {
            text_measurer: std::sync::Arc::new(
                merman_render::text::VendoredFontMetricsTextMeasurer::default(),
            ),
            ..LayoutOptions::default()
        },
    )
    .expect("layout ok");
    let merman_render::model::LayoutDiagram::ClassDiagramV2(layout) = out.layout else {
        panic!("expected ClassDiagramV2 layout");
    };

    let node_a = layout
        .nodes
        .iter()
        .find(|n| n.id == "A")
        .expect("class A node");
    let node_b = layout
        .nodes
        .iter()
        .find(|n| n.id == "B")
        .expect("class B node");

    let eps = 1e-6;
    assert!(
        (node_a.width - 34.140625).abs() <= eps,
        "unexpected A width: {}",
        node_a.width
    );
    assert!(
        (node_b.width - 33.53125).abs() <= eps,
        "unexpected B width: {}",
        node_b.width
    );
}