Skip to main content

merman_render/
block.rs

1use crate::config::{config_f64, config_f64_css_px};
2use crate::model::{BlockDiagramLayout, Bounds, LayoutEdge, LayoutLabel, LayoutNode, LayoutPoint};
3use crate::text::{TextMeasurer, TextStyle, WrapMode};
4use crate::{Error, Result};
5use merman_core::MAX_DIAGRAM_NESTING_DEPTH;
6use serde_json::Value;
7use std::collections::HashMap;
8
9pub(crate) type BlockDiagramModel = merman_core::diagrams::block::BlockDiagramRenderModel;
10pub(crate) type BlockNode = merman_core::diagrams::block::BlockNodeRenderModel;
11
12#[derive(Debug, Clone)]
13struct SizedBlock {
14    id: String,
15    block_type: String,
16    children: Vec<SizedBlock>,
17    columns: i64,
18    width_in_columns: i64,
19    width: f64,
20    height: f64,
21    label_width: f64,
22    label_height: f64,
23    x: f64,
24    y: f64,
25}
26
27fn config_string(cfg: &Value, path: &[&str]) -> Option<String> {
28    let mut cur = cfg;
29    for key in path {
30        cur = cur.get(*key)?;
31    }
32    cur.as_str().map(|s| s.to_string()).or_else(|| {
33        cur.as_array()
34            .and_then(|values| values.first()?.as_str())
35            .map(|s| s.to_string())
36    })
37}
38
39fn decode_block_label_html(raw: &str) -> String {
40    raw.replace("&nbsp;", "\u{00A0}")
41}
42
43pub(crate) fn block_label_is_effectively_empty(text: &str) -> bool {
44    !text.is_empty()
45        && text
46            .chars()
47            .all(|ch| ch != '\u{00A0}' && ch.is_whitespace())
48}
49
50fn block_html_label_metrics_px(
51    text: &str,
52    measurer: &dyn TextMeasurer,
53    style: &TextStyle,
54) -> (f64, f64) {
55    let html_metrics = measurer.measure_wrapped(text, style, None, WrapMode::HtmlLike);
56    let svg_metrics = measurer.measure_wrapped(text, style, None, WrapMode::SvgLike);
57    let width =
58        crate::generated::block_text_overrides_11_12_2::lookup_html_width_px(style.font_size, text)
59            .unwrap_or(html_metrics.width)
60            .max(0.0);
61    let height = crate::generated::block_text_overrides_11_12_2::lookup_html_height_px(
62        style.font_size,
63        text,
64    )
65    .unwrap_or(svg_metrics.height)
66    .max(0.0);
67    (width, height)
68}
69
70#[derive(Debug, Clone, Copy)]
71pub(crate) struct BlockArrowPoint {
72    pub(crate) x: f64,
73    pub(crate) y: f64,
74}
75
76pub(crate) fn block_arrow_points(
77    directions: &[String],
78    bbox_w: f64,
79    bbox_h: f64,
80    node_padding: f64,
81) -> Vec<BlockArrowPoint> {
82    fn expand_and_dedup(directions: &[String]) -> std::collections::BTreeSet<String> {
83        let mut out = std::collections::BTreeSet::new();
84        for d in directions {
85            match d.trim() {
86                "x" => {
87                    out.insert("right".to_string());
88                    out.insert("left".to_string());
89                }
90                "y" => {
91                    out.insert("up".to_string());
92                    out.insert("down".to_string());
93                }
94                other if !other.is_empty() => {
95                    out.insert(other.to_string());
96                }
97                _ => {}
98            }
99        }
100        out
101    }
102
103    let dirs = expand_and_dedup(directions);
104    let height = bbox_h + 2.0 * node_padding;
105    let midpoint = height / 2.0;
106    let width = bbox_w + 2.0 * midpoint + node_padding;
107    let pad = node_padding / 2.0;
108
109    let has = |name: &str| dirs.contains(name);
110
111    if has("right") && has("left") && has("up") && has("down") {
112        return vec![
113            BlockArrowPoint { x: 0.0, y: 0.0 },
114            BlockArrowPoint {
115                x: midpoint,
116                y: 0.0,
117            },
118            BlockArrowPoint {
119                x: width / 2.0,
120                y: 2.0 * pad,
121            },
122            BlockArrowPoint {
123                x: width - midpoint,
124                y: 0.0,
125            },
126            BlockArrowPoint { x: width, y: 0.0 },
127            BlockArrowPoint {
128                x: width,
129                y: -height / 3.0,
130            },
131            BlockArrowPoint {
132                x: width + 2.0 * pad,
133                y: -height / 2.0,
134            },
135            BlockArrowPoint {
136                x: width,
137                y: (-2.0 * height) / 3.0,
138            },
139            BlockArrowPoint {
140                x: width,
141                y: -height,
142            },
143            BlockArrowPoint {
144                x: width - midpoint,
145                y: -height,
146            },
147            BlockArrowPoint {
148                x: width / 2.0,
149                y: -height - 2.0 * pad,
150            },
151            BlockArrowPoint {
152                x: midpoint,
153                y: -height,
154            },
155            BlockArrowPoint { x: 0.0, y: -height },
156            BlockArrowPoint {
157                x: 0.0,
158                y: (-2.0 * height) / 3.0,
159            },
160            BlockArrowPoint {
161                x: -2.0 * pad,
162                y: -height / 2.0,
163            },
164            BlockArrowPoint {
165                x: 0.0,
166                y: -height / 3.0,
167            },
168        ];
169    }
170    if has("right") && has("left") && has("up") {
171        return vec![
172            BlockArrowPoint {
173                x: midpoint,
174                y: 0.0,
175            },
176            BlockArrowPoint {
177                x: width - midpoint,
178                y: 0.0,
179            },
180            BlockArrowPoint {
181                x: width,
182                y: -height / 2.0,
183            },
184            BlockArrowPoint {
185                x: width - midpoint,
186                y: -height,
187            },
188            BlockArrowPoint {
189                x: midpoint,
190                y: -height,
191            },
192            BlockArrowPoint {
193                x: 0.0,
194                y: -height / 2.0,
195            },
196        ];
197    }
198    if has("right") && has("left") && has("down") {
199        return vec![
200            BlockArrowPoint { x: 0.0, y: 0.0 },
201            BlockArrowPoint {
202                x: midpoint,
203                y: -height,
204            },
205            BlockArrowPoint {
206                x: width - midpoint,
207                y: -height,
208            },
209            BlockArrowPoint { x: width, y: 0.0 },
210        ];
211    }
212    if has("right") && has("up") && has("down") {
213        return vec![
214            BlockArrowPoint { x: 0.0, y: 0.0 },
215            BlockArrowPoint {
216                x: width,
217                y: -midpoint,
218            },
219            BlockArrowPoint {
220                x: width,
221                y: -height + midpoint,
222            },
223            BlockArrowPoint { x: 0.0, y: -height },
224        ];
225    }
226    if has("left") && has("up") && has("down") {
227        return vec![
228            BlockArrowPoint { x: width, y: 0.0 },
229            BlockArrowPoint {
230                x: 0.0,
231                y: -midpoint,
232            },
233            BlockArrowPoint {
234                x: 0.0,
235                y: -height + midpoint,
236            },
237            BlockArrowPoint {
238                x: width,
239                y: -height,
240            },
241        ];
242    }
243    if has("right") && has("left") {
244        return vec![
245            BlockArrowPoint {
246                x: midpoint,
247                y: 0.0,
248            },
249            BlockArrowPoint {
250                x: midpoint,
251                y: -pad,
252            },
253            BlockArrowPoint {
254                x: width - midpoint,
255                y: -pad,
256            },
257            BlockArrowPoint {
258                x: width - midpoint,
259                y: 0.0,
260            },
261            BlockArrowPoint {
262                x: width,
263                y: -height / 2.0,
264            },
265            BlockArrowPoint {
266                x: width - midpoint,
267                y: -height,
268            },
269            BlockArrowPoint {
270                x: width - midpoint,
271                y: -height + pad,
272            },
273            BlockArrowPoint {
274                x: midpoint,
275                y: -height + pad,
276            },
277            BlockArrowPoint {
278                x: midpoint,
279                y: -height,
280            },
281            BlockArrowPoint {
282                x: 0.0,
283                y: -height / 2.0,
284            },
285        ];
286    }
287    if has("up") && has("down") {
288        return vec![
289            BlockArrowPoint {
290                x: width / 2.0,
291                y: 0.0,
292            },
293            BlockArrowPoint { x: 0.0, y: -pad },
294            BlockArrowPoint {
295                x: midpoint,
296                y: -pad,
297            },
298            BlockArrowPoint {
299                x: midpoint,
300                y: -height + pad,
301            },
302            BlockArrowPoint {
303                x: 0.0,
304                y: -height + pad,
305            },
306            BlockArrowPoint {
307                x: width / 2.0,
308                y: -height,
309            },
310            BlockArrowPoint {
311                x: width,
312                y: -height + pad,
313            },
314            BlockArrowPoint {
315                x: width - midpoint,
316                y: -height + pad,
317            },
318            BlockArrowPoint {
319                x: width - midpoint,
320                y: -pad,
321            },
322            BlockArrowPoint { x: width, y: -pad },
323        ];
324    }
325    if has("right") && has("up") {
326        return vec![
327            BlockArrowPoint { x: 0.0, y: 0.0 },
328            BlockArrowPoint {
329                x: width,
330                y: -midpoint,
331            },
332            BlockArrowPoint { x: 0.0, y: -height },
333        ];
334    }
335    if has("right") && has("down") {
336        return vec![
337            BlockArrowPoint { x: 0.0, y: 0.0 },
338            BlockArrowPoint { x: width, y: 0.0 },
339            BlockArrowPoint { x: 0.0, y: -height },
340        ];
341    }
342    if has("left") && has("up") {
343        return vec![
344            BlockArrowPoint { x: width, y: 0.0 },
345            BlockArrowPoint {
346                x: 0.0,
347                y: -midpoint,
348            },
349            BlockArrowPoint {
350                x: width,
351                y: -height,
352            },
353        ];
354    }
355    if has("left") && has("down") {
356        return vec![
357            BlockArrowPoint { x: width, y: 0.0 },
358            BlockArrowPoint { x: 0.0, y: 0.0 },
359            BlockArrowPoint {
360                x: width,
361                y: -height,
362            },
363        ];
364    }
365    if has("right") {
366        return vec![
367            BlockArrowPoint {
368                x: midpoint,
369                y: -pad,
370            },
371            BlockArrowPoint {
372                x: midpoint,
373                y: -pad,
374            },
375            BlockArrowPoint {
376                x: width - midpoint,
377                y: -pad,
378            },
379            BlockArrowPoint {
380                x: width - midpoint,
381                y: 0.0,
382            },
383            BlockArrowPoint {
384                x: width,
385                y: -height / 2.0,
386            },
387            BlockArrowPoint {
388                x: width - midpoint,
389                y: -height,
390            },
391            BlockArrowPoint {
392                x: width - midpoint,
393                y: -height + pad,
394            },
395            BlockArrowPoint {
396                x: midpoint,
397                y: -height + pad,
398            },
399            BlockArrowPoint {
400                x: midpoint,
401                y: -height + pad,
402            },
403        ];
404    }
405    if has("left") {
406        return vec![
407            BlockArrowPoint {
408                x: midpoint,
409                y: 0.0,
410            },
411            BlockArrowPoint {
412                x: midpoint,
413                y: -pad,
414            },
415            BlockArrowPoint {
416                x: width - midpoint,
417                y: -pad,
418            },
419            BlockArrowPoint {
420                x: width - midpoint,
421                y: -height + pad,
422            },
423            BlockArrowPoint {
424                x: midpoint,
425                y: -height + pad,
426            },
427            BlockArrowPoint {
428                x: midpoint,
429                y: -height,
430            },
431            BlockArrowPoint {
432                x: 0.0,
433                y: -height / 2.0,
434            },
435        ];
436    }
437    if has("up") {
438        return vec![
439            BlockArrowPoint {
440                x: midpoint,
441                y: -pad,
442            },
443            BlockArrowPoint {
444                x: midpoint,
445                y: -height + pad,
446            },
447            BlockArrowPoint {
448                x: 0.0,
449                y: -height + pad,
450            },
451            BlockArrowPoint {
452                x: width / 2.0,
453                y: -height,
454            },
455            BlockArrowPoint {
456                x: width,
457                y: -height + pad,
458            },
459            BlockArrowPoint {
460                x: width - midpoint,
461                y: -height + pad,
462            },
463            BlockArrowPoint {
464                x: width - midpoint,
465                y: -pad,
466            },
467        ];
468    }
469    if has("down") {
470        return vec![
471            BlockArrowPoint {
472                x: width / 2.0,
473                y: 0.0,
474            },
475            BlockArrowPoint { x: 0.0, y: -pad },
476            BlockArrowPoint {
477                x: midpoint,
478                y: -pad,
479            },
480            BlockArrowPoint {
481                x: midpoint,
482                y: -height + pad,
483            },
484            BlockArrowPoint {
485                x: width - midpoint,
486                y: -height + pad,
487            },
488            BlockArrowPoint {
489                x: width - midpoint,
490                y: -pad,
491            },
492            BlockArrowPoint { x: width, y: -pad },
493        ];
494    }
495
496    vec![BlockArrowPoint { x: 0.0, y: 0.0 }]
497}
498
499fn polygon_bounds(points: &[BlockArrowPoint]) -> (f64, f64) {
500    if points.is_empty() {
501        return (0.0, 0.0);
502    }
503
504    let mut min_x = points[0].x;
505    let mut max_x = points[0].x;
506    let mut min_y = points[0].y;
507    let mut max_y = points[0].y;
508    for point in &points[1..] {
509        min_x = min_x.min(point.x);
510        max_x = max_x.max(point.x);
511        min_y = min_y.min(point.y);
512        max_y = max_y.max(point.y);
513    }
514
515    ((max_x - min_x).max(0.0), (max_y - min_y).max(0.0))
516}
517
518fn block_shape_size(
519    block_type: &str,
520    directions: &[String],
521    label_width: f64,
522    label_height: f64,
523    padding: f64,
524    has_label: bool,
525) -> Option<(f64, f64)> {
526    let rect_w = (label_width + padding).max(1.0);
527    let rect_h = (label_height + padding).max(1.0);
528
529    match block_type {
530        "composite" => has_label.then(|| (label_width.max(1.0), (label_height + padding).max(1.0))),
531        "group" => has_label.then_some((rect_w, rect_h)),
532        "space" => None,
533        "circle" => Some((rect_w, rect_w)),
534        "doublecircle" => {
535            let outer_diameter = rect_w + 10.0;
536            Some((outer_diameter, outer_diameter))
537        }
538        "stadium" => Some(((label_width + rect_h / 4.0 + padding).max(1.0), rect_h)),
539        "cylinder" => {
540            let rx = rect_w / 2.0;
541            let ry = rx / (2.5 + rect_w / 50.0);
542            let body_h = (label_height + ry + padding).max(1.0);
543            Some((rect_w, body_h + 2.0 * ry))
544        }
545        "diamond" => {
546            let side = (rect_w + rect_h).max(1.0);
547            Some((side, side))
548        }
549        "hexagon" => {
550            let shoulder = rect_h / 4.0;
551            Some(((label_width + 2.0 * shoulder + padding).max(1.0), rect_h))
552        }
553        "rect_left_inv_arrow" => Some((rect_w + rect_h / 2.0, rect_h)),
554        "subroutine" => Some((rect_w + 16.0, rect_h)),
555        "lean_right" | "trapezoid" | "inv_trapezoid" => {
556            Some((rect_w + (2.0 * rect_h) / 3.0, rect_h))
557        }
558        "lean_left" => Some((rect_w + rect_h / 3.0, rect_h)),
559        "block_arrow" => Some(polygon_bounds(&block_arrow_points(
560            directions,
561            label_width,
562            label_height,
563            padding,
564        ))),
565        _ => Some((rect_w, rect_h)),
566    }
567}
568
569fn to_sized_block(
570    node: &BlockNode,
571    padding: f64,
572    measurer: &dyn TextMeasurer,
573    text_style: &TextStyle,
574) -> SizedBlock {
575    let columns = node.columns.unwrap_or(-1);
576    let width_in_columns = node.width_in_columns.unwrap_or(1).max(1);
577
578    let mut width = 0.0;
579    let mut height = 0.0;
580
581    // Mermaid renders block diagram labels via `labelHelper(...)`, which decodes HTML entities
582    // and measures the resulting HTML content (`getBoundingClientRect()` for width/height).
583    //
584    // Block diagrams frequently use `&nbsp;` placeholders (notably for block arrows), so we must
585    // decode those before measuring; otherwise node widths drift drastically.
586    let label_decoded = decode_block_label_html(&node.label);
587    let label_effectively_empty = block_label_is_effectively_empty(&label_decoded);
588    let (label_width, label_height) = if label_effectively_empty {
589        (0.0, 0.0)
590    } else {
591        block_html_label_metrics_px(&label_decoded, measurer, text_style)
592    };
593    let shape_label_height = label_height;
594
595    if let Some((computed_width, computed_height)) = block_shape_size(
596        node.block_type.as_str(),
597        &node.directions,
598        label_width,
599        shape_label_height,
600        padding,
601        !label_effectively_empty && !label_decoded.trim().is_empty(),
602    ) {
603        width = computed_width;
604        height = computed_height;
605    }
606
607    let children = node
608        .children
609        .iter()
610        .map(|c| to_sized_block(c, padding, measurer, text_style))
611        .collect::<Vec<_>>();
612
613    SizedBlock {
614        id: node.id.clone(),
615        block_type: node.block_type.clone(),
616        children,
617        columns,
618        width_in_columns,
619        width,
620        height,
621        label_width,
622        label_height,
623        x: 0.0,
624        y: 0.0,
625    }
626}
627
628fn get_max_child_size(block: &SizedBlock) -> (f64, f64) {
629    let mut max_width = 0.0;
630    let mut max_height = 0.0;
631    for child in &block.children {
632        if child.block_type == "space" {
633            continue;
634        }
635        if child.width > max_width {
636            max_width = child.width / (block.width_in_columns as f64);
637        }
638        if child.height > max_height {
639            max_height = child.height;
640        }
641    }
642    (max_width, max_height)
643}
644
645fn set_block_sizes(block: &mut SizedBlock, padding: f64, sibling_width: f64, sibling_height: f64) {
646    if block.width <= 0.0 {
647        block.width = sibling_width;
648        block.height = sibling_height;
649        block.x = 0.0;
650        block.y = 0.0;
651    }
652
653    if block.children.is_empty() {
654        return;
655    }
656
657    for child in &mut block.children {
658        set_block_sizes(child, padding, 0.0, 0.0);
659    }
660
661    let (mut max_width, mut max_height) = get_max_child_size(block);
662
663    for child in &mut block.children {
664        child.width = max_width * (child.width_in_columns as f64)
665            + padding * ((child.width_in_columns as f64) - 1.0);
666        child.height = max_height;
667        child.x = 0.0;
668        child.y = 0.0;
669    }
670
671    for child in &mut block.children {
672        set_block_sizes(child, padding, max_width, max_height);
673    }
674
675    let columns = block.columns;
676    let mut num_items = 0i64;
677    for child in &block.children {
678        num_items += child.width_in_columns.max(1);
679    }
680
681    let mut x_size = block.children.len() as i64;
682    if columns > 0 && columns < num_items {
683        x_size = columns;
684    }
685    let y_size = ((num_items as f64) / (x_size.max(1) as f64)).ceil() as i64;
686
687    let mut width = (x_size as f64) * (max_width + padding) + padding;
688    let mut height = (y_size as f64) * (max_height + padding) + padding;
689
690    if width < sibling_width {
691        width = sibling_width;
692        height = sibling_height;
693
694        let child_width = (sibling_width - (x_size as f64) * padding - padding) / (x_size as f64);
695        let child_height = (sibling_height - (y_size as f64) * padding - padding) / (y_size as f64);
696        for child in &mut block.children {
697            child.width = child_width;
698            child.height = child_height;
699            child.x = 0.0;
700            child.y = 0.0;
701        }
702    }
703
704    if width < block.width {
705        width = block.width;
706        let num = if columns > 0 {
707            (block.children.len() as i64).min(columns)
708        } else {
709            block.children.len() as i64
710        };
711        if num > 0 {
712            let child_width = (width - (num as f64) * padding - padding) / (num as f64);
713            for child in &mut block.children {
714                child.width = child_width;
715            }
716        }
717    }
718
719    block.width = width;
720    block.height = height;
721    block.x = 0.0;
722    block.y = 0.0;
723
724    // Keep behavior consistent with Mermaid even when all children were `space`.
725    max_width = max_width.max(0.0);
726    max_height = max_height.max(0.0);
727    let _ = (max_width, max_height);
728}
729
730fn calculate_block_position(columns: i64, position: i64) -> (i64, i64) {
731    if columns < 0 {
732        return (position, 0);
733    }
734    if columns == 1 {
735        return (0, position);
736    }
737    (position % columns, position / columns)
738}
739
740fn layout_blocks(block: &mut SizedBlock, padding: f64) {
741    if block.children.is_empty() {
742        return;
743    }
744
745    let columns = block.columns;
746    let mut column_pos = 0i64;
747
748    // JS truthiness: treat `0` as falsy (Mermaid uses `block?.size?.x ? ... : -padding`).
749    let mut starting_pos_x = if block.x != 0.0 {
750        block.x + (-block.width / 2.0)
751    } else {
752        -padding
753    };
754    let mut row_pos = 0i64;
755
756    for child in &mut block.children {
757        let (px, py) = calculate_block_position(columns, column_pos);
758
759        if py != row_pos {
760            row_pos = py;
761            starting_pos_x = if block.x != 0.0 {
762                block.x + (-block.width / 2.0)
763            } else {
764                -padding
765            };
766        }
767
768        let half_width = child.width / 2.0;
769        child.x = starting_pos_x + padding + half_width;
770        starting_pos_x = child.x + half_width;
771
772        child.y = block.y - block.height / 2.0
773            + (py as f64) * (child.height + padding)
774            + child.height / 2.0
775            + padding;
776
777        if !child.children.is_empty() {
778            layout_blocks(child, padding);
779        }
780
781        let mut columns_filled = child.width_in_columns.max(1);
782        if columns > 0 {
783            let rem = columns - (column_pos % columns);
784            columns_filled = columns_filled.min(rem.max(1));
785        }
786        column_pos += columns_filled;
787
788        let _ = px;
789    }
790}
791
792fn find_bounds(block: &SizedBlock, b: &mut Bounds) {
793    if block.id != "root" {
794        b.min_x = b.min_x.min(block.x - block.width / 2.0);
795        b.min_y = b.min_y.min(block.y - block.height / 2.0);
796        b.max_x = b.max_x.max(block.x + block.width / 2.0);
797        b.max_y = b.max_y.max(block.y + block.height / 2.0);
798    }
799    for child in &block.children {
800        find_bounds(child, b);
801    }
802}
803
804fn collect_nodes(block: &SizedBlock, out: &mut Vec<LayoutNode>) {
805    if block.id != "root" && block.block_type != "space" {
806        out.push(LayoutNode {
807            id: block.id.clone(),
808            x: block.x,
809            y: block.y,
810            width: block.width,
811            height: block.height,
812            is_cluster: false,
813            label_width: Some(block.label_width.max(0.0)),
814            label_height: Some(block.label_height.max(0.0)),
815        });
816    }
817    for child in &block.children {
818        collect_nodes(child, out);
819    }
820}
821
822pub fn layout_block_diagram(
823    semantic: &Value,
824    effective_config: &Value,
825    measurer: &dyn TextMeasurer,
826) -> Result<BlockDiagramLayout> {
827    let model: BlockDiagramModel = crate::json::from_value_ref(semantic)?;
828    layout_block_diagram_typed(&model, effective_config, measurer)
829}
830
831pub fn layout_block_diagram_typed(
832    model: &merman_core::diagrams::block::BlockDiagramRenderModel,
833    effective_config: &Value,
834    measurer: &dyn TextMeasurer,
835) -> Result<BlockDiagramLayout> {
836    validate_block_model_depth(model)?;
837    let padding = config_f64(effective_config, &["block", "padding"]).unwrap_or(8.0);
838    let text_style = crate::text::TextStyle {
839        font_family: config_string(effective_config, &["themeVariables", "fontFamily"])
840            .or_else(|| config_string(effective_config, &["fontFamily"]))
841            .or_else(|| Some("\"trebuchet ms\", verdana, arial, sans-serif".to_string())),
842        font_size: config_f64_css_px(effective_config, &["themeVariables", "fontSize"])
843            .or_else(|| config_f64_css_px(effective_config, &["fontSize"]))
844            .unwrap_or(16.0)
845            .max(1.0),
846        font_weight: None,
847    };
848
849    let root = model
850        .blocks_flat
851        .iter()
852        .find(|b| b.id == "root" && b.block_type == "composite")
853        .ok_or_else(|| Error::InvalidModel {
854            message: "missing block root composite".to_string(),
855        })?;
856
857    let mut root = to_sized_block(root, padding, measurer, &text_style);
858    set_block_sizes(&mut root, padding, 0.0, 0.0);
859    layout_blocks(&mut root, padding);
860
861    let mut nodes: Vec<LayoutNode> = Vec::new();
862    collect_nodes(&root, &mut nodes);
863
864    let mut bounds = Bounds {
865        min_x: 0.0,
866        min_y: 0.0,
867        max_x: 0.0,
868        max_y: 0.0,
869    };
870    find_bounds(&root, &mut bounds);
871    let bounds = if nodes.is_empty() { None } else { Some(bounds) };
872
873    let nodes_by_id: HashMap<String, LayoutNode> =
874        nodes.iter().cloned().map(|n| (n.id.clone(), n)).collect();
875
876    let mut edges: Vec<LayoutEdge> = Vec::new();
877    for e in &model.edges {
878        let Some(from) = nodes_by_id.get(&e.start) else {
879            continue;
880        };
881        let Some(to) = nodes_by_id.get(&e.end) else {
882            continue;
883        };
884
885        let start = LayoutPoint {
886            x: from.x,
887            y: from.y,
888        };
889        let end = LayoutPoint { x: to.x, y: to.y };
890        let mid = LayoutPoint {
891            x: start.x + (end.x - start.x) / 2.0,
892            y: start.y + (end.y - start.y) / 2.0,
893        };
894
895        let label = if e.label.trim().is_empty() {
896            None
897        } else {
898            let edge_label = decode_block_label_html(&e.label);
899            let (label_width, label_height) =
900                block_html_label_metrics_px(&edge_label, measurer, &text_style);
901            Some(LayoutLabel {
902                x: mid.x,
903                y: mid.y,
904                width: label_width.max(1.0),
905                height: label_height.max(1.0),
906            })
907        };
908
909        edges.push(LayoutEdge {
910            id: e.id.clone(),
911            from: e.start.clone(),
912            to: e.end.clone(),
913            from_cluster: None,
914            to_cluster: None,
915            points: vec![start, mid, end],
916            label,
917            start_label_left: None,
918            start_label_right: None,
919            end_label_left: None,
920            end_label_right: None,
921            start_marker: e.arrow_type_start.clone(),
922            end_marker: e.arrow_type_end.clone(),
923            stroke_dasharray: None,
924        });
925    }
926
927    Ok(BlockDiagramLayout {
928        nodes,
929        edges,
930        bounds,
931    })
932}
933
934fn validate_block_model_depth(
935    model: &merman_core::diagrams::block::BlockDiagramRenderModel,
936) -> Result<()> {
937    let mut stack: Vec<(&BlockNode, usize)> =
938        model.blocks_flat.iter().map(|block| (block, 1)).collect();
939    while let Some((block, depth)) = stack.pop() {
940        if depth > MAX_DIAGRAM_NESTING_DEPTH {
941            return Err(Error::InvalidModel {
942                message: format!(
943                    "block diagram nesting depth exceeds maximum of {MAX_DIAGRAM_NESTING_DEPTH}"
944                ),
945            });
946        }
947        for child in &block.children {
948            stack.push((child, depth + 1));
949        }
950    }
951    Ok(())
952}
953
954#[cfg(test)]
955mod tests {
956    use crate::text::{TextStyle, VendoredFontMetricsTextMeasurer};
957
958    fn default_style(font_size: f64) -> TextStyle {
959        TextStyle {
960            font_family: Some("\"trebuchet ms\", verdana, arial, sans-serif".to_string()),
961            font_size,
962            font_weight: None,
963        }
964    }
965
966    #[test]
967    fn block_label_metrics_use_block_owned_width_and_height_overrides() {
968        let measurer = VendoredFontMetricsTextMeasurer::default();
969        let style = default_style(24.0);
970
971        let (width, height) = super::block_html_label_metrics_px(
972            "Font size precedence should widen this block",
973            &measurer,
974            &style,
975        );
976
977        assert_eq!(width, 487.890625);
978        assert_eq!(height, 28.0);
979    }
980}