merman-render 0.7.0-alpha.1

Headless layout + SVG renderer for Mermaid (parity-focused; upstream SVG goldens).
Documentation
use crate::Result;
use crate::config::{config_bool, config_f64, config_f64_css_px};
use crate::model::{Bounds, TreeViewDiagramLayout, TreeViewLineLayout, TreeViewNodeLayout};
use crate::text::{TextMeasurer, TextStyle};
use merman_core::diagrams::tree_view::{
    TreeViewDiagramRenderModel, TreeViewNodeRenderModel as TreeViewNode,
};
use serde_json::Value;

#[derive(Debug, Clone)]
struct TreeViewConfig {
    row_indent: f64,
    padding_x: f64,
    padding_y: f64,
    line_thickness: f64,
    use_max_width: bool,
    label_font_size: f64,
}

pub fn layout_tree_view_diagram(
    semantic: &Value,
    effective_config: &Value,
    measurer: &dyn TextMeasurer,
) -> Result<TreeViewDiagramLayout> {
    let model: TreeViewDiagramRenderModel = crate::json::from_value_ref(semantic)?;
    layout_tree_view_diagram_typed(&model, effective_config, measurer)
}

pub fn layout_tree_view_diagram_typed(
    model: &TreeViewDiagramRenderModel,
    effective_config: &Value,
    measurer: &dyn TextMeasurer,
) -> Result<TreeViewDiagramLayout> {
    let cfg = tree_view_config(effective_config);
    let style = TextStyle {
        font_size: cfg.label_font_size,
        ..Default::default()
    };
    let mut ctx = LayoutCtx {
        cfg: cfg.clone(),
        measurer,
        style,
        total_height: 0.0,
        total_width: 0.0,
        nodes: Vec::new(),
        lines: Vec::new(),
    };

    layout_node(&mut ctx, &model.root, 0)?;

    let min_x = -ctx.cfg.line_thickness / 2.0;
    let total_width = ctx.total_width.max(1.0);
    let total_height = ctx.total_height.max(1.0);
    Ok(TreeViewDiagramLayout {
        bounds: Some(Bounds {
            min_x,
            min_y: 0.0,
            max_x: total_width,
            max_y: total_height,
        }),
        total_width,
        total_height,
        row_indent: ctx.cfg.row_indent,
        padding_x: ctx.cfg.padding_x,
        padding_y: ctx.cfg.padding_y,
        line_thickness: ctx.cfg.line_thickness,
        use_max_width: ctx.cfg.use_max_width,
        label_font_size: ctx.cfg.label_font_size,
        nodes: ctx.nodes,
        lines: ctx.lines,
    })
}

struct LayoutCtx<'a> {
    cfg: TreeViewConfig,
    measurer: &'a dyn TextMeasurer,
    style: TextStyle,
    total_height: f64,
    total_width: f64,
    nodes: Vec<TreeViewNodeLayout>,
    lines: Vec<TreeViewLineLayout>,
}

fn layout_node(ctx: &mut LayoutCtx<'_>, node: &TreeViewNode, depth: usize) -> Result<usize> {
    let indent = depth as f64 * (ctx.cfg.row_indent + ctx.cfg.padding_x);
    let label_width = tree_view_label_bbox_width_px(ctx.measurer, &node.name, &ctx.style);
    let label_height = tree_view_label_bbox_height_px(ctx.cfg.label_font_size);
    let height = label_height + ctx.cfg.padding_y * 2.0;
    let width = label_width + ctx.cfg.padding_x * 2.0;
    let y = ctx.total_height;
    let idx = ctx.nodes.len();

    ctx.nodes.push(TreeViewNodeLayout {
        id: node.id,
        level: node.level,
        name: node.name.clone(),
        depth,
        x: indent,
        y,
        width,
        height,
        label_x: indent + ctx.cfg.padding_x,
        label_y: y + height / 2.0,
        label_width,
        label_height,
    });
    ctx.lines.push(TreeViewLineLayout {
        x1: indent - ctx.cfg.row_indent,
        y1: y + height / 2.0,
        x2: indent,
        y2: y + height / 2.0,
        stroke_width: ctx.cfg.line_thickness,
        kind: "horizontal".to_string(),
    });

    ctx.total_width = ctx.total_width.max(indent + width);
    ctx.total_height += height;

    let mut direct_child_layouts = Vec::new();
    for child in &node.children {
        direct_child_layouts.push(layout_node(ctx, child, depth + 1)?);
    }

    if let Some(last_child_idx) = direct_child_layouts.last().copied() {
        let current = &ctx.nodes[idx];
        let last_child = &ctx.nodes[last_child_idx];
        ctx.lines.push(TreeViewLineLayout {
            x1: current.x + ctx.cfg.padding_x,
            y1: current.y + current.height,
            x2: current.x + ctx.cfg.padding_x,
            y2: last_child.y + last_child.height / 2.0 + ctx.cfg.line_thickness / 2.0,
            stroke_width: ctx.cfg.line_thickness,
            kind: "vertical".to_string(),
        });
    }

    Ok(idx)
}

fn tree_view_config(effective_config: &Value) -> TreeViewConfig {
    TreeViewConfig {
        row_indent: config_f64(effective_config, &["treeView", "rowIndent"])
            .unwrap_or(10.0)
            .max(0.0),
        padding_x: config_f64(effective_config, &["treeView", "paddingX"])
            .unwrap_or(5.0)
            .max(0.0),
        padding_y: config_f64(effective_config, &["treeView", "paddingY"])
            .unwrap_or(5.0)
            .max(0.0),
        line_thickness: config_f64(effective_config, &["treeView", "lineThickness"])
            .unwrap_or(1.0)
            .max(0.0),
        use_max_width: config_bool(effective_config, &["treeView", "useMaxWidth"]).unwrap_or(true),
        label_font_size: config_f64_css_px(
            effective_config,
            &["themeVariables", "treeView", "labelFontSize"],
        )
        .unwrap_or(16.0)
        .max(1.0),
    }
}

fn tree_view_label_bbox_height_px(font_size: f64) -> f64 {
    (font_size.max(1.0) * 1.15).ceil()
}

fn tree_view_label_bbox_width_px(
    measurer: &dyn TextMeasurer,
    label: &str,
    style: &TextStyle,
) -> f64 {
    if style.font_size > 16.0 {
        measurer.measure(label, style).width.max(0.0)
    } else {
        measurer
            .measure_svg_raw_text_bbox_width_px(label, style)
            .max(0.0)
    }
}