Skip to main content

merman_render/
block.rs

1use crate::model::{BlockDiagramLayout, Bounds, LayoutEdge, LayoutLabel, LayoutNode, LayoutPoint};
2use crate::text::{TextMeasurer, TextStyle, WrapMode};
3use crate::{Error, Result};
4use serde::Deserialize;
5use serde_json::Value;
6use std::collections::HashMap;
7
8#[derive(Debug, Clone, Deserialize)]
9pub(crate) struct BlockDiagramModel {
10    // Keep the full upstream semantic model shape for future parity work.
11    #[allow(dead_code)]
12    #[serde(default)]
13    pub blocks: Vec<BlockNode>,
14    #[serde(default, rename = "blocksFlat")]
15    pub blocks_flat: Vec<BlockNode>,
16    #[serde(default)]
17    pub edges: Vec<BlockEdge>,
18    #[allow(dead_code)]
19    #[serde(default)]
20    pub warnings: Vec<String>,
21    #[allow(dead_code)]
22    #[serde(default)]
23    pub classes: HashMap<String, BlockClassDef>,
24}
25
26#[derive(Debug, Clone, Deserialize)]
27pub(crate) struct BlockClassDef {
28    #[allow(dead_code)]
29    pub id: String,
30    #[allow(dead_code)]
31    #[serde(default)]
32    pub styles: Vec<String>,
33    #[allow(dead_code)]
34    #[serde(default, rename = "textStyles")]
35    pub text_styles: Vec<String>,
36}
37
38#[derive(Debug, Clone, Deserialize)]
39pub(crate) struct BlockNode {
40    pub id: String,
41    #[serde(default)]
42    pub label: String,
43    #[serde(default, rename = "type")]
44    pub block_type: String,
45    #[serde(default)]
46    pub children: Vec<BlockNode>,
47    #[serde(default)]
48    pub columns: Option<i64>,
49    #[serde(default, rename = "widthInColumns")]
50    pub width_in_columns: Option<i64>,
51    #[serde(default)]
52    pub width: Option<i64>,
53    #[serde(default)]
54    pub classes: Vec<String>,
55    #[allow(dead_code)]
56    #[serde(default)]
57    pub styles: Vec<String>,
58    #[serde(default)]
59    pub directions: Vec<String>,
60}
61
62#[derive(Debug, Clone, Deserialize)]
63pub(crate) struct BlockEdge {
64    pub id: String,
65    pub start: String,
66    pub end: String,
67    #[serde(default, rename = "arrowTypeEnd")]
68    pub arrow_type_end: Option<String>,
69    #[serde(default, rename = "arrowTypeStart")]
70    pub arrow_type_start: Option<String>,
71    #[serde(default)]
72    pub label: String,
73}
74
75#[derive(Debug, Clone)]
76struct SizedBlock {
77    id: String,
78    block_type: String,
79    children: Vec<SizedBlock>,
80    columns: i64,
81    width_in_columns: i64,
82    width: f64,
83    height: f64,
84    x: f64,
85    y: f64,
86}
87
88fn json_f64(v: &Value) -> Option<f64> {
89    v.as_f64()
90        .or_else(|| v.as_i64().map(|n| n as f64))
91        .or_else(|| v.as_u64().map(|n| n as f64))
92}
93
94fn config_f64(cfg: &Value, path: &[&str]) -> Option<f64> {
95    let mut cur = cfg;
96    for key in path {
97        cur = cur.get(*key)?;
98    }
99    json_f64(cur)
100}
101
102fn to_sized_block(
103    node: &BlockNode,
104    padding: f64,
105    measurer: &dyn TextMeasurer,
106    text_style: &TextStyle,
107) -> SizedBlock {
108    let columns = node.columns.unwrap_or(-1);
109    let width_in_columns = node.width_in_columns.or(node.width).unwrap_or(1).max(1);
110
111    let mut width = 0.0;
112    let mut height = 0.0;
113
114    // Mermaid renders block diagram labels via `labelHelper(...)`, which decodes HTML entities
115    // and measures the resulting HTML content (`getBoundingClientRect()` for width/height).
116    //
117    // Block diagrams frequently use `&nbsp;` placeholders (notably for block arrows), so we must
118    // decode those before measuring; otherwise node widths drift drastically.
119    let label_decoded = node.label.replace("&nbsp;", "\u{00A0}");
120    let label_bbox_html =
121        measurer.measure_wrapped(&label_decoded, text_style, None, WrapMode::HtmlLike);
122    let label_bbox_svg =
123        measurer.measure_wrapped(&label_decoded, text_style, None, WrapMode::SvgLike);
124
125    match node.block_type.as_str() {
126        // Composite/group blocks can become wider than their children due to their label; Mermaid's
127        // `setBlockSizes` grows children to fit when computed width is smaller than the pre-sized
128        // label width.
129        "composite" | "group" => {
130            if !label_decoded.trim().is_empty() {
131                // Mermaid uses the measured label helper bbox width directly for composite/group
132                // nodes (no extra padding on top of the HTML bbox).
133                width = label_bbox_html.width.max(1.0);
134                height = (label_bbox_svg.height + padding).max(1.0);
135            }
136        }
137        // Mermaid's dagre wrapper uses a dedicated sizing rule for block arrows:
138        // `h = bbox.height + 2 * padding; w = bbox.width + h + padding`.
139        "block_arrow" => {
140            let h = (label_bbox_svg.height + 2.0 * padding).max(1.0);
141            let w = (label_bbox_html.width + h + padding).max(1.0);
142            width = w;
143            height = h;
144        }
145        // Regular blocks: `w = bbox.width + padding; h = bbox.height + padding`.
146        t if t != "space" => {
147            width = (label_bbox_html.width + padding).max(1.0);
148            height = (label_bbox_svg.height + padding).max(1.0);
149        }
150        _ => {}
151    }
152
153    let children = node
154        .children
155        .iter()
156        .map(|c| to_sized_block(c, padding, measurer, text_style))
157        .collect::<Vec<_>>();
158
159    SizedBlock {
160        id: node.id.clone(),
161        block_type: node.block_type.clone(),
162        children,
163        columns,
164        width_in_columns,
165        width,
166        height,
167        x: 0.0,
168        y: 0.0,
169    }
170}
171
172fn get_max_child_size(block: &SizedBlock) -> (f64, f64) {
173    let mut max_width = 0.0;
174    let mut max_height = 0.0;
175    for child in &block.children {
176        if child.block_type == "space" {
177            continue;
178        }
179        if child.width > max_width {
180            max_width = child.width / (block.width_in_columns as f64);
181        }
182        if child.height > max_height {
183            max_height = child.height;
184        }
185    }
186    (max_width, max_height)
187}
188
189fn set_block_sizes(block: &mut SizedBlock, padding: f64, sibling_width: f64, sibling_height: f64) {
190    if block.width <= 0.0 {
191        block.width = sibling_width;
192        block.height = sibling_height;
193        block.x = 0.0;
194        block.y = 0.0;
195    }
196
197    if block.children.is_empty() {
198        return;
199    }
200
201    for child in &mut block.children {
202        set_block_sizes(child, padding, 0.0, 0.0);
203    }
204
205    let (mut max_width, mut max_height) = get_max_child_size(block);
206
207    for child in &mut block.children {
208        child.width = max_width * (child.width_in_columns as f64)
209            + padding * ((child.width_in_columns as f64) - 1.0);
210        child.height = max_height;
211        child.x = 0.0;
212        child.y = 0.0;
213    }
214
215    for child in &mut block.children {
216        set_block_sizes(child, padding, max_width, max_height);
217    }
218
219    let columns = block.columns;
220    let mut num_items = 0i64;
221    for child in &block.children {
222        num_items += child.width_in_columns.max(1);
223    }
224
225    let mut x_size = block.children.len() as i64;
226    if columns > 0 && columns < num_items {
227        x_size = columns;
228    }
229    let y_size = ((num_items as f64) / (x_size.max(1) as f64)).ceil() as i64;
230
231    let mut width = (x_size as f64) * (max_width + padding) + padding;
232    let mut height = (y_size as f64) * (max_height + padding) + padding;
233
234    if width < sibling_width {
235        width = sibling_width;
236        height = sibling_height;
237
238        let child_width = (sibling_width - (x_size as f64) * padding - padding) / (x_size as f64);
239        let child_height = (sibling_height - (y_size as f64) * padding - padding) / (y_size as f64);
240        for child in &mut block.children {
241            child.width = child_width;
242            child.height = child_height;
243            child.x = 0.0;
244            child.y = 0.0;
245        }
246    }
247
248    if width < block.width {
249        width = block.width;
250        let num = if columns > 0 {
251            (block.children.len() as i64).min(columns)
252        } else {
253            block.children.len() as i64
254        };
255        if num > 0 {
256            let child_width = (width - (num as f64) * padding - padding) / (num as f64);
257            for child in &mut block.children {
258                child.width = child_width;
259            }
260        }
261    }
262
263    block.width = width;
264    block.height = height;
265    block.x = 0.0;
266    block.y = 0.0;
267
268    // Keep behavior consistent with Mermaid even when all children were `space`.
269    max_width = max_width.max(0.0);
270    max_height = max_height.max(0.0);
271    let _ = (max_width, max_height);
272}
273
274fn calculate_block_position(columns: i64, position: i64) -> (i64, i64) {
275    if columns < 0 {
276        return (position, 0);
277    }
278    if columns == 1 {
279        return (0, position);
280    }
281    (position % columns, position / columns)
282}
283
284fn layout_blocks(block: &mut SizedBlock, padding: f64) {
285    if block.children.is_empty() {
286        return;
287    }
288
289    let columns = block.columns;
290    let mut column_pos = 0i64;
291
292    // JS truthiness: treat `0` as falsy (Mermaid uses `block?.size?.x ? ... : -padding`).
293    let mut starting_pos_x = if block.x != 0.0 {
294        block.x + (-block.width / 2.0)
295    } else {
296        -padding
297    };
298    let mut row_pos = 0i64;
299
300    for child in &mut block.children {
301        let (px, py) = calculate_block_position(columns, column_pos);
302
303        if py != row_pos {
304            row_pos = py;
305            starting_pos_x = if block.x != 0.0 {
306                block.x + (-block.width / 2.0)
307            } else {
308                -padding
309            };
310        }
311
312        let half_width = child.width / 2.0;
313        child.x = starting_pos_x + padding + half_width;
314        starting_pos_x = child.x + half_width;
315
316        child.y = block.y - block.height / 2.0
317            + (py as f64) * (child.height + padding)
318            + child.height / 2.0
319            + padding;
320
321        if !child.children.is_empty() {
322            layout_blocks(child, padding);
323        }
324
325        let mut columns_filled = child.width_in_columns.max(1);
326        if columns > 0 {
327            let rem = columns - (column_pos % columns);
328            columns_filled = columns_filled.min(rem.max(1));
329        }
330        column_pos += columns_filled;
331
332        let _ = px;
333    }
334}
335
336fn find_bounds(block: &SizedBlock, b: &mut Bounds) {
337    if block.id != "root" {
338        b.min_x = b.min_x.min(block.x - block.width / 2.0);
339        b.min_y = b.min_y.min(block.y - block.height / 2.0);
340        b.max_x = b.max_x.max(block.x + block.width / 2.0);
341        b.max_y = b.max_y.max(block.y + block.height / 2.0);
342    }
343    for child in &block.children {
344        find_bounds(child, b);
345    }
346}
347
348fn collect_nodes(block: &SizedBlock, out: &mut Vec<LayoutNode>) {
349    if block.id != "root" && block.block_type != "space" {
350        out.push(LayoutNode {
351            id: block.id.clone(),
352            x: block.x,
353            y: block.y,
354            width: block.width,
355            height: block.height,
356            is_cluster: false,
357            label_width: None,
358            label_height: None,
359        });
360    }
361    for child in &block.children {
362        collect_nodes(child, out);
363    }
364}
365
366pub fn layout_block_diagram(
367    semantic: &Value,
368    effective_config: &Value,
369    measurer: &dyn TextMeasurer,
370) -> Result<BlockDiagramLayout> {
371    let model: BlockDiagramModel = crate::json::from_value_ref(semantic)?;
372
373    let padding = config_f64(effective_config, &["block", "padding"]).unwrap_or(8.0);
374    let text_style = crate::text::TextStyle {
375        font_family: effective_config
376            .get("fontFamily")
377            .and_then(|v| v.as_str())
378            .or_else(|| {
379                effective_config
380                    .get("themeVariables")
381                    .and_then(|tv| tv.get("fontFamily"))
382                    .and_then(|v| v.as_str())
383            })
384            .map(|s| s.to_string())
385            .or_else(|| Some("\"trebuchet ms\", verdana, arial, sans-serif".to_string())),
386        font_size: effective_config
387            .get("fontSize")
388            .and_then(|v| v.as_f64())
389            .unwrap_or(16.0)
390            .max(1.0),
391        font_weight: None,
392    };
393
394    let root = model
395        .blocks_flat
396        .iter()
397        .find(|b| b.id == "root" && b.block_type == "composite")
398        .ok_or_else(|| Error::InvalidModel {
399            message: "missing block root composite".to_string(),
400        })?;
401
402    let mut root = to_sized_block(root, padding, measurer, &text_style);
403    set_block_sizes(&mut root, padding, 0.0, 0.0);
404    layout_blocks(&mut root, padding);
405
406    let mut nodes: Vec<LayoutNode> = Vec::new();
407    collect_nodes(&root, &mut nodes);
408
409    let mut bounds = Bounds {
410        min_x: 0.0,
411        min_y: 0.0,
412        max_x: 0.0,
413        max_y: 0.0,
414    };
415    find_bounds(&root, &mut bounds);
416    let bounds = if nodes.is_empty() { None } else { Some(bounds) };
417
418    let nodes_by_id: HashMap<String, LayoutNode> =
419        nodes.iter().cloned().map(|n| (n.id.clone(), n)).collect();
420
421    let mut edges: Vec<LayoutEdge> = Vec::new();
422    for e in &model.edges {
423        let Some(from) = nodes_by_id.get(&e.start) else {
424            continue;
425        };
426        let Some(to) = nodes_by_id.get(&e.end) else {
427            continue;
428        };
429
430        let start = LayoutPoint {
431            x: from.x,
432            y: from.y,
433        };
434        let end = LayoutPoint { x: to.x, y: to.y };
435        let mid = LayoutPoint {
436            x: start.x + (end.x - start.x) / 2.0,
437            y: start.y + (end.y - start.y) / 2.0,
438        };
439
440        let label = if e.label.trim().is_empty() {
441            None
442        } else {
443            let metrics =
444                measurer.measure_wrapped(&e.label, &TextStyle::default(), None, WrapMode::HtmlLike);
445            Some(LayoutLabel {
446                x: mid.x,
447                y: mid.y,
448                width: metrics.width.max(1.0),
449                height: metrics.height.max(1.0),
450            })
451        };
452
453        edges.push(LayoutEdge {
454            id: e.id.clone(),
455            from: e.start.clone(),
456            to: e.end.clone(),
457            from_cluster: None,
458            to_cluster: None,
459            points: vec![start, mid, end],
460            label,
461            start_label_left: None,
462            start_label_right: None,
463            end_label_left: None,
464            end_label_right: None,
465            start_marker: e.arrow_type_start.clone(),
466            end_marker: e.arrow_type_end.clone(),
467            stroke_dasharray: None,
468        });
469    }
470
471    Ok(BlockDiagramLayout {
472        nodes,
473        edges,
474        bounds,
475    })
476}