merman-render 0.4.2

Headless layout + SVG renderer for Mermaid (parity-focused; upstream SVG goldens).
Documentation
use crate::Result;
use crate::generated::kanban_text_overrides_11_12_2 as kanban_text_overrides;
use crate::model::{Bounds, KanbanDiagramLayout, KanbanItemLayout, KanbanSectionLayout};
use crate::text::{TextMeasurer, TextStyle, WrapMode};
use serde::Deserialize;

#[derive(Debug, Clone, Deserialize)]
struct KanbanNode {
    id: String,
    label: String,
    #[serde(default, rename = "isGroup")]
    is_group: bool,
    #[serde(default, rename = "parentId")]
    parent_id: Option<String>,
    #[serde(default)]
    ticket: Option<String>,
    #[serde(default)]
    priority: Option<String>,
    #[serde(default)]
    assigned: Option<String>,
    #[serde(default)]
    icon: Option<String>,
}

#[derive(Debug, Clone, Deserialize)]
struct KanbanModel {
    #[serde(default)]
    nodes: Vec<KanbanNode>,
    #[serde(rename = "type")]
    diagram_type: String,
}

fn cfg_f64(cfg: &serde_json::Value, path: &[&str]) -> Option<f64> {
    let mut cur = cfg;
    for k in path {
        cur = cur.get(*k)?;
    }
    cur.as_f64()
}

fn cfg_string(cfg: &serde_json::Value, path: &[&str]) -> Option<String> {
    let mut cur = cfg;
    for k in path {
        cur = cur.get(*k)?;
    }
    cur.as_str().map(|s| s.to_string())
}

fn parse_css_px_to_f64(s: &str) -> Option<f64> {
    let s = s.trim();
    let raw = s.strip_suffix("px").unwrap_or(s).trim();
    raw.parse::<f64>().ok().filter(|value| value.is_finite())
}

fn json_f64_css_px(v: &serde_json::Value) -> Option<f64> {
    v.as_f64()
        .or_else(|| v.as_i64().map(|n| n as f64))
        .or_else(|| v.as_u64().map(|n| n as f64))
        .or_else(|| v.as_str().and_then(parse_css_px_to_f64))
}

fn cfg_font_size(cfg: &serde_json::Value) -> f64 {
    cfg.get("themeVariables")
        .and_then(|v| v.get("fontSize"))
        .and_then(json_f64_css_px)
        .or_else(|| cfg.get("fontSize").and_then(json_f64_css_px))
        .unwrap_or(16.0)
        .max(1.0)
}

fn kanban_text_style(effective_config: &serde_json::Value) -> TextStyle {
    let font_family = cfg_string(effective_config, &["fontFamily"])
        .or_else(|| cfg_string(effective_config, &["themeVariables", "fontFamily"]))
        .or_else(|| Some("\"trebuchet ms\", verdana, arial, sans-serif".to_string()));
    let font_size = cfg_font_size(effective_config);
    TextStyle {
        font_family,
        font_size,
        font_weight: None,
    }
}

pub fn layout_kanban_diagram(
    semantic: &serde_json::Value,
    effective_config: &serde_json::Value,
    measurer: &dyn TextMeasurer,
) -> Result<KanbanDiagramLayout> {
    let model: KanbanModel = crate::json::from_value_ref(semantic)?;
    let _ = model.diagram_type.as_str();

    let section_width = cfg_f64(effective_config, &["kanban", "sectionWidth"])
        .unwrap_or(200.0)
        .max(1.0);

    // Mermaid 11.12.2 has a bug: `kanbanRenderer` uses `conf.mindmap.padding`/`useMaxWidth` when
    // calling `setupGraphViewbox`. Mirror that behavior for parity.
    let viewbox_padding = cfg_f64(effective_config, &["mindmap", "padding"])
        .or_else(|| cfg_f64(effective_config, &["kanban", "padding"]))
        .unwrap_or(8.0)
        .max(0.0);

    let padding = kanban_text_overrides::kanban_section_padding_px();
    let section_rect_y = -(section_width * 3.0) / 2.0;

    let legend_style = kanban_text_style(effective_config);
    let font_scale = legend_style.font_size / 16.0;
    let section_label_height_baseline =
        kanban_text_overrides::kanban_section_label_height_baseline_px() * font_scale;
    let section_label_fo_height =
        kanban_text_overrides::kanban_label_foreign_object_height_px() * font_scale;
    let item_one_row_height = kanban_text_overrides::kanban_item_one_row_height_px() * font_scale;
    let item_two_row_height = kanban_text_overrides::kanban_item_two_row_height_px() * font_scale;
    let item_label_line_height =
        kanban_text_overrides::kanban_item_label_line_height_px() * font_scale;

    let mut max_label_height = section_label_height_baseline;
    let mut sections: Vec<KanbanSectionLayout> = Vec::new();
    let mut items: Vec<KanbanItemLayout> = Vec::new();

    let section_nodes: Vec<&KanbanNode> = model.nodes.iter().filter(|n| n.is_group).collect();
    for (i, section) in section_nodes.iter().enumerate() {
        let index = (i + 1) as i64;
        let center_x = section_width * (index as f64) + ((index - 1) as f64 * padding) / 2.0;
        let center_y = 0.0;

        let label_metrics = measurer.measure_wrapped(
            &section.label,
            &legend_style,
            Some(section_width),
            WrapMode::HtmlLike,
        );
        let label_height = label_metrics.height.max(section_label_fo_height);
        max_label_height = max_label_height.max(label_height);

        sections.push(KanbanSectionLayout {
            id: section.id.clone(),
            label: section.label.clone(),
            index,
            center_x,
            center_y,
            width: section_width,
            rect_y: section_rect_y,
            rect_height: (section_width * 3.0).max(1.0),
            rx: 5.0,
            ry: 5.0,
            label_width: label_metrics.width.max(0.0),
            label_height,
        });
    }

    for section in sections.iter_mut() {
        let top = section_rect_y + max_label_height;
        let mut y = top;

        let section_items: Vec<&KanbanNode> = model
            .nodes
            .iter()
            .filter(|n| n.parent_id.as_deref() == Some(section.id.as_str()))
            .collect();

        for item in section_items {
            let width = (section_width - 1.5 * padding).max(1.0);
            let inner_max_w =
                (width - kanban_text_overrides::kanban_item_label_inset_x_px()).max(0.0);

            // Mermaid's kanban items are rendered via `kanbanItem.ts`, which uses HTML labels for
            // the title and applies `max-width` clamping when the content needs wrapping. Mirror
            // that behavior so item heights match the upstream bbox-based layout.
            let item_label_style = legend_style.clone();
            let raw_title_metrics =
                measurer.measure_wrapped(&item.label, &item_label_style, None, WrapMode::HtmlLike);
            let title_metrics = if inner_max_w > 0.0 && raw_title_metrics.width > inner_max_w {
                measurer.measure_wrapped(
                    &item.label,
                    &item_label_style,
                    Some(inner_max_w),
                    WrapMode::HtmlLike,
                )
            } else {
                raw_title_metrics
            };

            let has_details_row = item.ticket.is_some() || item.assigned.is_some();
            let base_height = if has_details_row {
                item_two_row_height
            } else {
                item_one_row_height
            };
            let extra_title_height = (title_metrics.height - item_label_line_height).max(0.0);
            let height = base_height + extra_title_height;

            let center_x = section.center_x;
            let center_y = y + height / 2.0;

            items.push(KanbanItemLayout {
                id: item.id.clone(),
                label: item.label.clone(),
                parent_id: section.id.clone(),
                center_x,
                center_y,
                width,
                height: height.max(1.0),
                rx: 5.0,
                ry: 5.0,
                ticket: item.ticket.clone(),
                assigned: item.assigned.clone(),
                priority: item.priority.clone(),
                icon: item.icon.clone(),
            });

            y = center_y + height / 2.0 + padding / 2.0;
        }

        let min_section_height = 50.0 * font_scale;
        let height = (y - top + 3.0 * padding).max(min_section_height)
            + (max_label_height - section_label_height_baseline);
        section.rect_height = height.max(1.0);
    }

    let mut min_x = f64::INFINITY;
    let mut min_y = f64::INFINITY;
    let mut max_x = f64::NEG_INFINITY;
    let mut max_y = f64::NEG_INFINITY;

    for s in &sections {
        let left = s.center_x - s.width / 2.0;
        let right = left + s.width;
        let top = s.rect_y;
        let bottom = s.rect_y + s.rect_height;
        min_x = min_x.min(left);
        min_y = min_y.min(top);
        max_x = max_x.max(right);
        max_y = max_y.max(bottom);
    }
    for n in &items {
        let left = n.center_x - n.width / 2.0;
        let right = n.center_x + n.width / 2.0;
        let top = n.center_y - n.height / 2.0;
        let bottom = n.center_y + n.height / 2.0;
        min_x = min_x.min(left);
        min_y = min_y.min(top);
        max_x = max_x.max(right);
        max_y = max_y.max(bottom);
    }

    let bounds = if min_x.is_finite() && min_y.is_finite() && max_x.is_finite() && max_y.is_finite()
    {
        Some(Bounds {
            min_x: min_x - viewbox_padding,
            min_y: min_y - viewbox_padding,
            max_x: max_x + viewbox_padding,
            max_y: max_y + viewbox_padding,
        })
    } else {
        None
    };

    Ok(KanbanDiagramLayout {
        bounds,
        section_width,
        padding,
        max_label_height,
        viewbox_padding,
        sections,
        items,
    })
}

#[cfg(test)]
mod tests {
    use super::layout_kanban_diagram;
    use crate::generated::kanban_text_overrides_11_12_2 as kanban_text_overrides;
    use crate::text::DeterministicTextMeasurer;
    use serde_json::json;

    #[test]
    fn kanban_text_constants_are_generated() {
        assert_eq!(
            kanban_text_overrides::kanban_section_label_height_baseline_px(),
            25.0
        );
        assert_eq!(kanban_text_overrides::kanban_section_padding_px(), 10.0);
        assert_eq!(
            kanban_text_overrides::kanban_label_foreign_object_height_px(),
            24.0
        );
        assert_eq!(kanban_text_overrides::kanban_item_one_row_height_px(), 44.0);
        assert_eq!(kanban_text_overrides::kanban_item_label_inset_x_px(), 10.0);
        assert_eq!(kanban_text_overrides::kanban_item_two_row_height_px(), 56.0);
        assert_eq!(
            kanban_text_overrides::kanban_item_label_line_height_px(),
            24.0
        );
    }

    #[test]
    fn kanban_layout_uses_generated_padding() {
        let semantic = json!({
            "type": "kanban",
            "nodes": [
                {"id": "todo", "label": "Todo", "isGroup": true},
                {"id": "doing", "label": "Doing", "isGroup": true},
                {"id": "task-1", "label": "Task", "parentId": "todo"}
            ]
        });
        let measurer = DeterministicTextMeasurer {
            char_width_factor: 8.0,
            line_height_factor: 16.0,
        };

        let layout = layout_kanban_diagram(&semantic, &json!({}), &measurer).unwrap();

        assert_eq!(
            layout.padding,
            kanban_text_overrides::kanban_section_padding_px()
        );
        assert_eq!(
            layout.items[0].width,
            layout.section_width - 1.5 * kanban_text_overrides::kanban_section_padding_px()
        );
    }
}