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(" ", "\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 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 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 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}