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