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