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