1use crate::entities::decode_entities_minimal;
2use crate::model::{
3 Bounds, ClassDiagramV2Layout, ClassNodeRowMetrics, LayoutCluster, LayoutEdge, LayoutLabel,
4 LayoutNode, LayoutPoint,
5};
6use crate::text::{TextMeasurer, TextStyle, WrapMode};
7use crate::{Error, Result};
8use dugong::graphlib::{Graph, GraphOptions};
9use dugong::{EdgeLabel, GraphLabel, LabelPos, NodeLabel, RankDir};
10use indexmap::IndexMap;
11use rustc_hash::FxHashMap;
12use serde_json::Value;
13use std::collections::{BTreeMap, HashMap, HashSet};
14use std::sync::Arc;
15
16type ClassDiagramModel = merman_core::models::class_diagram::ClassDiagram;
17type ClassNode = merman_core::models::class_diagram::ClassNode;
18
19fn json_f64(v: &Value) -> Option<f64> {
20 v.as_f64()
21 .or_else(|| v.as_i64().map(|n| n as f64))
22 .or_else(|| v.as_u64().map(|n| n as f64))
23}
24
25fn config_f64(cfg: &Value, path: &[&str]) -> Option<f64> {
26 let mut cur = cfg;
27 for key in path {
28 cur = cur.get(*key)?;
29 }
30 json_f64(cur)
31}
32
33fn config_bool(cfg: &Value, path: &[&str]) -> Option<bool> {
34 let mut cur = cfg;
35 for key in path {
36 cur = cur.get(*key)?;
37 }
38 cur.as_bool()
39}
40
41fn config_string(cfg: &Value, path: &[&str]) -> Option<String> {
42 let mut cur = cfg;
43 for key in path {
44 cur = cur.get(*key)?;
45 }
46 cur.as_str().map(|s| s.to_string())
47}
48
49fn normalize_dir(direction: &str) -> String {
50 match direction.trim().to_uppercase().as_str() {
51 "TB" | "TD" => "TB".to_string(),
52 "BT" => "BT".to_string(),
53 "LR" => "LR".to_string(),
54 "RL" => "RL".to_string(),
55 other => other.to_string(),
56 }
57}
58
59fn rank_dir_from(direction: &str) -> RankDir {
60 match normalize_dir(direction).as_str() {
61 "TB" => RankDir::TB,
62 "BT" => RankDir::BT,
63 "LR" => RankDir::LR,
64 "RL" => RankDir::RL,
65 _ => RankDir::TB,
66 }
67}
68
69type Rect = merman_core::geom::Box2;
70
71struct PreparedGraph {
72 graph: Graph<NodeLabel, EdgeLabel, GraphLabel>,
73 extracted: BTreeMap<String, PreparedGraph>,
74 prefer_dagreish_disconnected: bool,
75}
76
77fn extract_descendants(
78 graph: &Graph<NodeLabel, EdgeLabel, GraphLabel>,
79 id: &str,
80 out: &mut Vec<String>,
81) {
82 for child in graph.children(id) {
83 out.push(child.to_string());
84 extract_descendants(graph, child, out);
85 }
86}
87
88fn is_descendant(descendants: &HashMap<String, HashSet<String>>, id: &str, ancestor: &str) -> bool {
89 descendants
90 .get(ancestor)
91 .is_some_and(|set| set.contains(id))
92}
93
94fn prepare_graph(
95 mut graph: Graph<NodeLabel, EdgeLabel, GraphLabel>,
96 depth: usize,
97 prefer_dagreish_disconnected: bool,
98) -> Result<PreparedGraph> {
99 if depth > 10 {
100 return Ok(PreparedGraph {
101 graph,
102 extracted: BTreeMap::new(),
103 prefer_dagreish_disconnected,
104 });
105 }
106
107 let cluster_ids: Vec<String> = graph
118 .node_ids()
119 .into_iter()
120 .filter(|id| !graph.children(id).is_empty())
121 .collect();
122
123 let mut descendants: HashMap<String, HashSet<String>> = HashMap::new();
124 for id in &cluster_ids {
125 let mut vec: Vec<String> = Vec::new();
126 extract_descendants(&graph, id, &mut vec);
127 descendants.insert(id.clone(), vec.into_iter().collect());
128 }
129
130 let mut external: HashMap<String, bool> =
131 cluster_ids.iter().map(|id| (id.clone(), false)).collect();
132 for id in &cluster_ids {
133 for e in graph.edge_keys() {
134 if e.v == *id || e.w == *id {
138 continue;
139 }
140 let d1 = is_descendant(&descendants, &e.v, id);
141 let d2 = is_descendant(&descendants, &e.w, id);
142 if d1 ^ d2 {
143 external.insert(id.clone(), true);
144 break;
145 }
146 }
147 }
148
149 let mut extracted: BTreeMap<String, PreparedGraph> = BTreeMap::new();
150 let candidate_clusters: Vec<String> = graph
151 .node_ids()
152 .into_iter()
153 .filter(|id| !graph.children(id).is_empty() && !external.get(id).copied().unwrap_or(false))
154 .collect();
155
156 for cluster_id in candidate_clusters {
157 if graph.children(&cluster_id).is_empty() {
158 continue;
159 }
160 let parent_dir = graph.graph().rankdir;
161 let dir = if parent_dir == RankDir::TB {
162 RankDir::LR
163 } else {
164 RankDir::TB
165 };
166
167 let nodesep = graph.graph().nodesep;
168 let ranksep = graph.graph().ranksep;
169
170 let mut subgraph = extract_cluster_graph(&cluster_id, &mut graph)?;
171 subgraph.graph_mut().rankdir = dir;
172 subgraph.graph_mut().nodesep = nodesep;
173 subgraph.graph_mut().ranksep = ranksep + 25.0;
174 subgraph.graph_mut().marginx = 8.0;
175 subgraph.graph_mut().marginy = 8.0;
176
177 let prepared = prepare_graph(subgraph, depth + 1, prefer_dagreish_disconnected)?;
178 extracted.insert(cluster_id, prepared);
179 }
180
181 Ok(PreparedGraph {
182 graph,
183 extracted,
184 prefer_dagreish_disconnected,
185 })
186}
187
188fn extract_cluster_graph(
189 cluster_id: &str,
190 graph: &mut Graph<NodeLabel, EdgeLabel, GraphLabel>,
191) -> Result<Graph<NodeLabel, EdgeLabel, GraphLabel>> {
192 if graph.children(cluster_id).is_empty() {
193 return Err(Error::InvalidModel {
194 message: format!("cluster has no children: {cluster_id}"),
195 });
196 }
197
198 let mut descendants: Vec<String> = Vec::new();
199 extract_descendants(graph, cluster_id, &mut descendants);
200 descendants.sort();
201 descendants.dedup();
202
203 let moved_set: HashSet<String> = descendants.iter().cloned().collect();
204
205 let mut sub = Graph::<NodeLabel, EdgeLabel, GraphLabel>::new(GraphOptions {
206 directed: true,
207 multigraph: true,
208 compound: true,
209 });
210
211 sub.set_graph(graph.graph().clone());
213
214 for id in &descendants {
215 let Some(label) = graph.node(id).cloned() else {
216 continue;
217 };
218 sub.set_node(id.clone(), label);
219 }
220
221 for key in graph.edge_keys() {
222 if moved_set.contains(&key.v) && moved_set.contains(&key.w) {
223 if let Some(label) = graph.edge_by_key(&key).cloned() {
224 sub.set_edge_named(key.v.clone(), key.w.clone(), key.name.clone(), Some(label));
225 }
226 }
227 }
228
229 for id in &descendants {
230 let Some(parent) = graph.parent(id) else {
231 continue;
232 };
233 if moved_set.contains(parent) {
234 sub.set_parent(id.clone(), parent.to_string());
235 }
236 }
237
238 for id in &descendants {
239 let _ = graph.remove_node(id);
240 }
241
242 Ok(sub)
243}
244
245#[derive(Debug, Clone)]
246struct EdgeTerminalMetrics {
247 start_left: Option<(f64, f64)>,
248 start_right: Option<(f64, f64)>,
249 end_left: Option<(f64, f64)>,
250 end_right: Option<(f64, f64)>,
251 start_marker: f64,
252 end_marker: f64,
253}
254
255fn edge_terminal_metrics_from_extras(e: &EdgeLabel) -> EdgeTerminalMetrics {
256 let get_pair = |key: &str| -> Option<(f64, f64)> {
257 let obj = e.extras.get(key)?;
258 let w = obj.get("width").and_then(|v| v.as_f64()).unwrap_or(0.0);
259 let h = obj.get("height").and_then(|v| v.as_f64()).unwrap_or(0.0);
260 if w > 0.0 && h > 0.0 {
261 Some((w, h))
262 } else {
263 None
264 }
265 };
266 let start_marker = e
267 .extras
268 .get("startMarker")
269 .and_then(|v| v.as_f64())
270 .unwrap_or(0.0);
271 let end_marker = e
272 .extras
273 .get("endMarker")
274 .and_then(|v| v.as_f64())
275 .unwrap_or(0.0);
276 EdgeTerminalMetrics {
277 start_left: get_pair("startLeft"),
278 start_right: get_pair("startRight"),
279 end_left: get_pair("endLeft"),
280 end_right: get_pair("endRight"),
281 start_marker,
282 end_marker,
283 }
284}
285
286#[derive(Debug, Clone)]
287struct LayoutFragments {
288 nodes: IndexMap<String, LayoutNode>,
289 edges: Vec<(LayoutEdge, Option<EdgeTerminalMetrics>)>,
290}
291
292fn round_number(num: f64, precision: i32) -> f64 {
293 if !num.is_finite() {
294 return 0.0;
295 }
296 let factor = 10_f64.powi(precision);
297 (num * factor).round() / factor
298}
299
300fn distance(a: &LayoutPoint, b: Option<&LayoutPoint>) -> f64 {
301 let Some(b) = b else {
302 return 0.0;
303 };
304 let dx = a.x - b.x;
305 let dy = a.y - b.y;
306 (dx * dx + dy * dy).sqrt()
307}
308
309fn calculate_point(points: &[LayoutPoint], distance_to_traverse: f64) -> Option<LayoutPoint> {
310 if points.is_empty() {
311 return None;
312 }
313 let mut prev: Option<&LayoutPoint> = None;
314 let mut remaining = distance_to_traverse.max(0.0);
315 for p in points {
316 if let Some(prev_p) = prev {
317 let vector_distance = distance(p, Some(prev_p));
318 if vector_distance == 0.0 {
319 return Some(prev_p.clone());
320 }
321 if vector_distance < remaining {
322 remaining -= vector_distance;
323 } else {
324 let ratio = remaining / vector_distance;
325 if ratio <= 0.0 {
326 return Some(prev_p.clone());
327 }
328 if ratio >= 1.0 {
329 return Some(p.clone());
330 }
331 return Some(LayoutPoint {
332 x: round_number((1.0 - ratio) * prev_p.x + ratio * p.x, 5),
333 y: round_number((1.0 - ratio) * prev_p.y + ratio * p.y, 5),
334 });
335 }
336 }
337 prev = Some(p);
338 }
339 None
340}
341
342#[derive(Debug, Clone, Copy)]
343enum TerminalPos {
344 StartLeft,
345 StartRight,
346 EndLeft,
347 EndRight,
348}
349
350fn point_inside_rect(rect: Rect, x: f64, y: f64, eps: f64) -> bool {
351 x > rect.min_x() + eps
352 && x < rect.max_x() - eps
353 && y > rect.min_y() + eps
354 && y < rect.max_y() - eps
355}
356
357fn nudge_point_outside_rect(mut x: f64, mut y: f64, rect: Rect) -> (f64, f64) {
358 let eps = 0.01;
359 if !point_inside_rect(rect, x, y, eps) {
360 return (x, y);
361 }
362
363 let (cx, cy) = rect.center();
364 let mut dx = x - cx;
365 let mut dy = y - cy;
366 let len = (dx * dx + dy * dy).sqrt();
367 if len < 1e-9 {
368 dx = 1.0;
369 dy = 0.0;
370 } else {
371 dx /= len;
372 dy /= len;
373 }
374
375 let mut t_exit = f64::INFINITY;
376 if dx > 1e-9 {
377 t_exit = t_exit.min((rect.max_x() - x) / dx);
378 } else if dx < -1e-9 {
379 t_exit = t_exit.min((rect.min_x() - x) / dx);
380 }
381 if dy > 1e-9 {
382 t_exit = t_exit.min((rect.max_y() - y) / dy);
383 } else if dy < -1e-9 {
384 t_exit = t_exit.min((rect.min_y() - y) / dy);
385 }
386
387 if t_exit.is_finite() && t_exit >= 0.0 {
388 let margin = 0.5;
389 x += dx * (t_exit + margin);
390 y += dy * (t_exit + margin);
391 }
392
393 (x, y)
394}
395
396fn calc_terminal_label_position(
397 terminal_marker_size: f64,
398 position: TerminalPos,
399 points: &[LayoutPoint],
400) -> Option<(f64, f64)> {
401 if points.len() < 2 {
402 return None;
403 }
404
405 let mut pts = points.to_vec();
406 match position {
407 TerminalPos::StartLeft | TerminalPos::StartRight => {}
408 TerminalPos::EndLeft | TerminalPos::EndRight => pts.reverse(),
409 }
410
411 let distance_to_cardinality_point = 25.0 + terminal_marker_size;
412 let center = calculate_point(&pts, distance_to_cardinality_point)?;
413 let d = 10.0 + terminal_marker_size * 0.5;
414 let angle = (pts[0].y - center.y).atan2(pts[0].x - center.x);
415
416 let (x, y) = match position {
417 TerminalPos::StartLeft => {
418 let a = angle + std::f64::consts::PI;
419 (
420 a.sin() * d + (pts[0].x + center.x) / 2.0,
421 -a.cos() * d + (pts[0].y + center.y) / 2.0,
422 )
423 }
424 TerminalPos::StartRight => (
425 angle.sin() * d + (pts[0].x + center.x) / 2.0,
426 -angle.cos() * d + (pts[0].y + center.y) / 2.0,
427 ),
428 TerminalPos::EndLeft => (
429 angle.sin() * d + (pts[0].x + center.x) / 2.0 - 5.0,
430 -angle.cos() * d + (pts[0].y + center.y) / 2.0 - 5.0,
431 ),
432 TerminalPos::EndRight => {
433 let a = angle - std::f64::consts::PI;
434 (
435 a.sin() * d + (pts[0].x + center.x) / 2.0 - 5.0,
436 -a.cos() * d + (pts[0].y + center.y) / 2.0 - 5.0,
437 )
438 }
439 };
440 Some((x, y))
441}
442
443fn intersect_segment_with_rect(
444 p0: &LayoutPoint,
445 p1: &LayoutPoint,
446 rect: Rect,
447) -> Option<LayoutPoint> {
448 let dx = p1.x - p0.x;
449 let dy = p1.y - p0.y;
450 if dx == 0.0 && dy == 0.0 {
451 return None;
452 }
453
454 let mut candidates: Vec<(f64, LayoutPoint)> = Vec::new();
455 let eps = 1e-9;
456 let min_x = rect.min_x();
457 let max_x = rect.max_x();
458 let min_y = rect.min_y();
459 let max_y = rect.max_y();
460
461 if dx.abs() > eps {
462 for x_edge in [min_x, max_x] {
463 let t = (x_edge - p0.x) / dx;
464 if t < -eps || t > 1.0 + eps {
465 continue;
466 }
467 let y = p0.y + t * dy;
468 if y + eps >= min_y && y <= max_y + eps {
469 candidates.push((t, LayoutPoint { x: x_edge, y }));
470 }
471 }
472 }
473
474 if dy.abs() > eps {
475 for y_edge in [min_y, max_y] {
476 let t = (y_edge - p0.y) / dy;
477 if t < -eps || t > 1.0 + eps {
478 continue;
479 }
480 let x = p0.x + t * dx;
481 if x + eps >= min_x && x <= max_x + eps {
482 candidates.push((t, LayoutPoint { x, y: y_edge }));
483 }
484 }
485 }
486
487 candidates.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal));
488 candidates
489 .into_iter()
490 .find(|(t, _)| *t >= 0.0)
491 .map(|(_, p)| p)
492}
493
494fn terminal_path_for_edge(
495 points: &[LayoutPoint],
496 from_rect: Rect,
497 to_rect: Rect,
498) -> Vec<LayoutPoint> {
499 if points.len() < 2 {
500 return points.to_vec();
501 }
502 let mut out = points.to_vec();
503
504 if let Some(p) = intersect_segment_with_rect(&out[0], &out[1], from_rect) {
505 out[0] = p;
506 }
507 let last = out.len() - 1;
508 if let Some(p) = intersect_segment_with_rect(&out[last], &out[last - 1], to_rect) {
509 out[last] = p;
510 }
511
512 out
513}
514
515fn layout_prepared(
516 prepared: &mut PreparedGraph,
517 node_label_metrics_by_id: &HashMap<String, (f64, f64)>,
518) -> Result<(LayoutFragments, Rect)> {
519 let mut fragments = LayoutFragments {
520 nodes: IndexMap::new(),
521 edges: Vec::new(),
522 };
523
524 let extracted_ids: Vec<String> = prepared.extracted.keys().cloned().collect();
525 let mut extracted_fragments: BTreeMap<String, (LayoutFragments, Rect)> = BTreeMap::new();
526 for id in extracted_ids {
527 let sub = prepared.extracted.get_mut(&id).expect("exists");
528 let (sub_frag, sub_bounds) = layout_prepared(sub, node_label_metrics_by_id)?;
529
530 let pad = sub.graph.graph().ranksep.max(0.0);
539 let sub_bounds = Rect::from_min_max(
540 sub_bounds.min_x() - pad,
541 sub_bounds.min_y() - pad,
542 sub_bounds.max_x() + pad,
543 sub_bounds.max_y() + pad,
544 );
545
546 extracted_fragments.insert(id, (sub_frag, sub_bounds));
547 }
548
549 for (id, (_sub_frag, bounds)) in &extracted_fragments {
550 let Some(n) = prepared.graph.node_mut(id) else {
551 return Err(Error::InvalidModel {
552 message: format!("missing cluster placeholder node: {id}"),
553 });
554 };
555 n.width = bounds.width().max(1.0);
556 n.height = bounds.height().max(1.0);
557 }
558
559 dugong::layout_dagreish(&mut prepared.graph);
563
564 let mut dummy_nodes: HashSet<String> = HashSet::new();
568 for id in prepared.graph.node_ids() {
569 let Some(n) = prepared.graph.node(&id) else {
570 continue;
571 };
572 if n.dummy.is_some() {
573 dummy_nodes.insert(id);
574 continue;
575 }
576 let is_cluster =
577 !prepared.graph.children(&id).is_empty() || prepared.extracted.contains_key(&id);
578 let (label_width, label_height) = node_label_metrics_by_id
579 .get(id.as_str())
580 .copied()
581 .map(|(w, h)| (Some(w), Some(h)))
582 .unwrap_or((None, None));
583 fragments.nodes.insert(
584 id.clone(),
585 LayoutNode {
586 id: id.clone(),
587 x: n.x.unwrap_or(0.0),
588 y: n.y.unwrap_or(0.0),
589 width: n.width,
590 height: n.height,
591 is_cluster,
592 label_width,
593 label_height,
594 },
595 );
596 }
597
598 for key in prepared.graph.edge_keys() {
599 let Some(e) = prepared.graph.edge_by_key(&key) else {
600 continue;
601 };
602 if e.nesting_edge {
603 continue;
604 }
605 if dummy_nodes.contains(&key.v) || dummy_nodes.contains(&key.w) {
606 continue;
607 }
608 if !fragments.nodes.contains_key(&key.v) || !fragments.nodes.contains_key(&key.w) {
609 continue;
610 }
611 let id = key
612 .name
613 .clone()
614 .unwrap_or_else(|| format!("edge:{}:{}", key.v, key.w));
615
616 let label = if e.width > 0.0 && e.height > 0.0 {
617 Some(LayoutLabel {
618 x: e.x.unwrap_or(0.0),
619 y: e.y.unwrap_or(0.0),
620 width: e.width,
621 height: e.height,
622 })
623 } else {
624 None
625 };
626
627 let points = e
628 .points
629 .iter()
630 .map(|p| LayoutPoint { x: p.x, y: p.y })
631 .collect::<Vec<_>>();
632
633 let edge = LayoutEdge {
634 id,
635 from: key.v.clone(),
636 to: key.w.clone(),
637 from_cluster: None,
638 to_cluster: None,
639 points,
640 label,
641 start_label_left: None,
642 start_label_right: None,
643 end_label_left: None,
644 end_label_right: None,
645 start_marker: None,
646 end_marker: None,
647 stroke_dasharray: None,
648 };
649
650 let terminals = edge_terminal_metrics_from_extras(e);
651 let has_terminals = terminals.start_left.is_some()
652 || terminals.start_right.is_some()
653 || terminals.end_left.is_some()
654 || terminals.end_right.is_some();
655 let terminal_meta = if has_terminals { Some(terminals) } else { None };
656
657 fragments.edges.push((edge, terminal_meta));
658 }
659
660 for (cluster_id, (mut sub_frag, sub_bounds)) in extracted_fragments {
661 let Some(cluster_node) = fragments.nodes.get(&cluster_id).cloned() else {
662 return Err(Error::InvalidModel {
663 message: format!("missing cluster placeholder layout: {cluster_id}"),
664 });
665 };
666 let (sub_cx, sub_cy) = sub_bounds.center();
667 let dx = cluster_node.x - sub_cx;
668 let dy = cluster_node.y - sub_cy;
669
670 for n in sub_frag.nodes.values_mut() {
671 n.x += dx;
672 n.y += dy;
673 }
674 for (e, _t) in &mut sub_frag.edges {
675 for p in &mut e.points {
676 p.x += dx;
677 p.y += dy;
678 }
679 if let Some(l) = e.label.as_mut() {
680 l.x += dx;
681 l.y += dy;
682 }
683 }
684
685 let _ = sub_frag.nodes.swap_remove(&cluster_id);
689
690 fragments.nodes.extend(sub_frag.nodes);
691 fragments.edges.extend(sub_frag.edges);
692 }
693
694 let mut points: Vec<(f64, f64)> = Vec::new();
695 for n in fragments.nodes.values() {
696 let r = Rect::from_center(n.x, n.y, n.width, n.height);
697 points.push((r.min_x(), r.min_y()));
698 points.push((r.max_x(), r.max_y()));
699 }
700 for (e, _t) in &fragments.edges {
701 for p in &e.points {
702 points.push((p.x, p.y));
703 }
704 if let Some(l) = &e.label {
705 let r = Rect::from_center(l.x, l.y, l.width, l.height);
706 points.push((r.min_x(), r.min_y()));
707 points.push((r.max_x(), r.max_y()));
708 }
709 }
710 let bounds = Bounds::from_points(points)
711 .map(|b| Rect::from_min_max(b.min_x, b.min_y, b.max_x, b.max_y))
712 .unwrap_or_else(|| Rect::from_min_max(0.0, 0.0, 0.0, 0.0));
713
714 Ok((fragments, bounds))
715}
716
717fn class_text_style(effective_config: &Value) -> TextStyle {
718 let font_family = config_string(effective_config, &["fontFamily"])
721 .or_else(|| config_string(effective_config, &["themeVariables", "fontFamily"]))
722 .or_else(|| Some("\"trebuchet ms\", verdana, arial, sans-serif".to_string()));
723 let font_size = config_f64(effective_config, &["fontSize"])
726 .or_else(|| config_f64(effective_config, &["class", "fontSize"]))
727 .unwrap_or(16.0)
728 .max(1.0);
729 TextStyle {
730 font_family,
731 font_size,
732 font_weight: None,
733 }
734}
735
736fn class_box_dimensions(
737 node: &ClassNode,
738 measurer: &dyn TextMeasurer,
739 text_style: &TextStyle,
740 wrap_mode: WrapMode,
741 padding: f64,
742 hide_empty_members_box: bool,
743 capture_row_metrics: bool,
744) -> (f64, f64, Option<ClassNodeRowMetrics>) {
745 let use_html_labels = matches!(wrap_mode, WrapMode::HtmlLike);
751 let padding = padding.max(0.0);
752 let gap = padding;
753 let text_padding = if use_html_labels { 0.0 } else { 3.0 };
754
755 fn measure_label(
756 measurer: &dyn TextMeasurer,
757 text: &str,
758 style: &TextStyle,
759 wrap_mode: WrapMode,
760 ) -> crate::text::TextMetrics {
761 measurer.measure_wrapped(text, style, None, wrap_mode)
762 }
763
764 fn label_rect(m: crate::text::TextMetrics, y_offset: f64) -> Option<Rect> {
765 if !(m.width.is_finite() && m.height.is_finite()) {
766 return None;
767 }
768 let w = m.width.max(0.0);
769 let h = m.height.max(0.0);
770 if w <= 0.0 || h <= 0.0 {
771 return None;
772 }
773 let lines = m.line_count.max(1) as f64;
774 let y = y_offset - (h / (2.0 * lines));
775 Some(Rect::from_min_max(0.0, y, w, y + h))
776 }
777
778 let mut label_style_bold = text_style.clone();
779 label_style_bold.font_weight = Some("bolder".to_string());
780
781 let mut annotation_rect: Option<Rect> = None;
783 let mut annotation_group_height = 0.0;
784 if let Some(a) = node.annotations.first() {
785 let t = format!("\u{00AB}{}\u{00BB}", decode_entities_minimal(a.trim()));
786 let m = measure_label(measurer, &t, text_style, wrap_mode);
787 annotation_rect = label_rect(m, 0.0);
788 if let Some(r) = annotation_rect {
789 annotation_group_height = r.height().max(0.0);
790 }
791 }
792
793 let mut title_text = decode_entities_minimal(&node.text);
795 if !use_html_labels && title_text.starts_with('\\') {
796 title_text = title_text.trim_start_matches('\\').to_string();
797 }
798 let title_metrics = measure_label(measurer, &title_text, &label_style_bold, wrap_mode);
799 let title_rect = label_rect(title_metrics, 0.0);
800 let title_group_height = title_rect.map(|r| r.height()).unwrap_or(0.0);
801
802 let mut members_rect: Option<Rect> = None;
804 let mut members_metrics_out: Option<Vec<crate::text::TextMetrics>> =
805 capture_row_metrics.then(|| Vec::with_capacity(node.members.len()));
806 {
807 let mut y_offset = 0.0;
808 for m in &node.members {
809 let mut t = decode_entities_minimal(m.display_text.trim());
810 if !use_html_labels && t.starts_with('\\') {
811 t = t.trim_start_matches('\\').to_string();
812 }
813 let metrics = measure_label(measurer, &t, text_style, wrap_mode);
814 if let Some(out) = members_metrics_out.as_mut() {
815 out.push(metrics);
816 }
817 if let Some(r) = label_rect(metrics, y_offset) {
818 if let Some(ref mut cur) = members_rect {
819 cur.union(r);
820 } else {
821 members_rect = Some(r);
822 }
823 }
824 y_offset += metrics.height.max(0.0) + text_padding;
825 }
826 }
827 let mut members_group_height = members_rect.map(|r| r.height()).unwrap_or(0.0);
828 if members_group_height <= 0.0 {
829 members_group_height = (gap / 2.0).max(0.0);
831 }
832
833 let mut methods_rect: Option<Rect> = None;
835 let mut methods_metrics_out: Option<Vec<crate::text::TextMetrics>> =
836 capture_row_metrics.then(|| Vec::with_capacity(node.methods.len()));
837 {
838 let mut y_offset = 0.0;
839 for m in &node.methods {
840 let mut t = decode_entities_minimal(m.display_text.trim());
841 if !use_html_labels && t.starts_with('\\') {
842 t = t.trim_start_matches('\\').to_string();
843 }
844 let metrics = measure_label(measurer, &t, text_style, wrap_mode);
845 if let Some(out) = methods_metrics_out.as_mut() {
846 out.push(metrics);
847 }
848 if let Some(r) = label_rect(metrics, y_offset) {
849 if let Some(ref mut cur) = methods_rect {
850 cur.union(r);
851 } else {
852 methods_rect = Some(r);
853 }
854 }
855 y_offset += metrics.height.max(0.0) + text_padding;
856 }
857 }
858
859 let mut bbox_opt: Option<Rect> = None;
861
862 if let Some(mut r) = annotation_rect {
864 let w = r.width();
865 r.translate(-w / 2.0, 0.0);
866 bbox_opt = Some(if let Some(mut cur) = bbox_opt {
867 cur.union(r);
868 cur
869 } else {
870 r
871 });
872 }
873
874 if let Some(mut r) = title_rect {
876 let w = r.width();
877 r.translate(-w / 2.0, annotation_group_height);
878 bbox_opt = Some(if let Some(mut cur) = bbox_opt {
879 cur.union(r);
880 cur
881 } else {
882 r
883 });
884 }
885
886 if let Some(mut r) = members_rect {
888 let dy = annotation_group_height + title_group_height + gap * 2.0;
889 r.translate(0.0, dy);
890 bbox_opt = Some(if let Some(mut cur) = bbox_opt {
891 cur.union(r);
892 cur
893 } else {
894 r
895 });
896 }
897
898 if let Some(mut r) = methods_rect {
900 let dy = annotation_group_height + title_group_height + (members_group_height + gap * 4.0);
901 r.translate(0.0, dy);
902 bbox_opt = Some(if let Some(mut cur) = bbox_opt {
903 cur.union(r);
904 cur
905 } else {
906 r
907 });
908 }
909
910 let bbox = bbox_opt.unwrap_or_else(|| Rect::from_min_max(0.0, 0.0, 0.0, 0.0));
911 let w = bbox.width().max(0.0);
912 let mut h = bbox.height().max(0.0);
913
914 if node.members.is_empty() && node.methods.is_empty() {
916 h += gap;
917 } else if !node.members.is_empty() && node.methods.is_empty() {
918 h += gap * 2.0;
919 }
920
921 let render_extra_box =
922 node.members.is_empty() && node.methods.is_empty() && !hide_empty_members_box;
923
924 let mut rect_w = w + 2.0 * padding;
926 let mut rect_h = h + 2.0 * padding;
927 if render_extra_box {
928 rect_h += padding * 2.0;
929 } else if node.members.is_empty() && node.methods.is_empty() {
930 rect_h -= padding;
931 }
932
933 if node.type_param == "group" {
934 rect_w = rect_w.max(500.0);
935 }
936
937 let row_metrics = capture_row_metrics.then(|| ClassNodeRowMetrics {
938 members: members_metrics_out.unwrap_or_default(),
939 methods: methods_metrics_out.unwrap_or_default(),
940 });
941
942 (rect_w.max(1.0), rect_h.max(1.0), row_metrics)
943}
944
945fn note_dimensions(
946 text: &str,
947 measurer: &dyn TextMeasurer,
948 text_style: &TextStyle,
949 wrap_mode: WrapMode,
950 padding: f64,
951) -> (f64, f64, crate::text::TextMetrics) {
952 let p = padding.max(0.0);
953 let label = decode_entities_minimal(text);
954 let m = measurer.measure_wrapped(&label, text_style, None, wrap_mode);
955 (m.width + p, m.height + p, m)
956}
957
958fn label_metrics(
959 text: &str,
960 measurer: &dyn TextMeasurer,
961 text_style: &TextStyle,
962 wrap_mode: WrapMode,
963) -> (f64, f64) {
964 if text.trim().is_empty() {
965 return (0.0, 0.0);
966 }
967 let t = decode_entities_minimal(text);
968 let m = measurer.measure_wrapped(&t, text_style, None, wrap_mode);
969 (m.width.max(0.0), m.height.max(0.0))
970}
971
972fn set_extras_label_metrics(extras: &mut BTreeMap<String, Value>, key: &str, w: f64, h: f64) {
973 let obj = Value::Object(
974 [
975 ("width".to_string(), Value::from(w)),
976 ("height".to_string(), Value::from(h)),
977 ]
978 .into_iter()
979 .collect(),
980 );
981 extras.insert(key.to_string(), obj);
982}
983
984pub fn layout_class_diagram_v2(
985 semantic: &Value,
986 effective_config: &Value,
987 measurer: &dyn TextMeasurer,
988) -> Result<ClassDiagramV2Layout> {
989 let model: ClassDiagramModel = crate::json::from_value_ref(semantic)?;
990 layout_class_diagram_v2_typed(&model, effective_config, measurer)
991}
992
993pub fn layout_class_diagram_v2_typed(
994 model: &ClassDiagramModel,
995 effective_config: &Value,
996 measurer: &dyn TextMeasurer,
997) -> Result<ClassDiagramV2Layout> {
998 let diagram_dir = rank_dir_from(&model.direction);
999 let conf = effective_config
1000 .get("flowchart")
1001 .or_else(|| effective_config.get("class"))
1002 .unwrap_or(effective_config);
1003 let nodesep = config_f64(conf, &["nodeSpacing"]).unwrap_or(50.0);
1004 let ranksep = config_f64(conf, &["rankSpacing"]).unwrap_or(50.0);
1005
1006 let global_html_labels = config_bool(effective_config, &["htmlLabels"]).unwrap_or(true);
1007 let flowchart_html_labels =
1008 config_bool(effective_config, &["flowchart", "htmlLabels"]).unwrap_or(true);
1009 let wrap_mode_node = if global_html_labels {
1010 WrapMode::HtmlLike
1011 } else {
1012 WrapMode::SvgLike
1013 };
1014 let wrap_mode_label = if flowchart_html_labels {
1015 WrapMode::HtmlLike
1016 } else {
1017 WrapMode::SvgLike
1018 };
1019
1020 let class_padding = config_f64(effective_config, &["class", "padding"]).unwrap_or(12.0);
1022 let namespace_padding = config_f64(effective_config, &["flowchart", "padding"]).unwrap_or(15.0);
1023 let hide_empty_members_box =
1024 config_bool(effective_config, &["class", "hideEmptyMembersBox"]).unwrap_or(false);
1025
1026 let text_style = class_text_style(effective_config);
1027 let capture_row_metrics = matches!(wrap_mode_node, WrapMode::HtmlLike);
1028 let capture_label_metrics = matches!(wrap_mode_label, WrapMode::HtmlLike);
1029 let mut class_row_metrics_by_id: FxHashMap<String, Arc<ClassNodeRowMetrics>> =
1030 FxHashMap::default();
1031 let mut node_label_metrics_by_id: HashMap<String, (f64, f64)> = HashMap::new();
1032
1033 let mut g = Graph::<NodeLabel, EdgeLabel, GraphLabel>::new(GraphOptions {
1034 directed: true,
1035 multigraph: true,
1036 compound: true,
1037 });
1038 g.set_graph(GraphLabel {
1039 rankdir: diagram_dir,
1040 nodesep,
1041 ranksep,
1042 marginx: 0.0,
1046 marginy: 0.0,
1047 ..Default::default()
1048 });
1049
1050 for id in model.namespaces.keys() {
1051 let title = id.clone();
1055 let (tw, th) = label_metrics(&title, measurer, &text_style, wrap_mode_label);
1056 let w = (tw + 2.0 * namespace_padding).max(1.0);
1057 let h = (th + 2.0 * namespace_padding).max(1.0);
1058 g.set_node(
1059 id.clone(),
1060 NodeLabel {
1061 width: w,
1062 height: h,
1063 ..Default::default()
1064 },
1065 );
1066 }
1067
1068 for c in model.classes.values() {
1069 let (w, h, row_metrics) = class_box_dimensions(
1070 c,
1071 measurer,
1072 &text_style,
1073 wrap_mode_node,
1074 class_padding,
1075 hide_empty_members_box,
1076 capture_row_metrics,
1077 );
1078 if let Some(rm) = row_metrics {
1079 class_row_metrics_by_id.insert(c.id.clone(), Arc::new(rm));
1080 }
1081 g.set_node(
1082 c.id.clone(),
1083 NodeLabel {
1084 width: w,
1085 height: h,
1086 ..Default::default()
1087 },
1088 );
1089 }
1090
1091 for iface in &model.interfaces {
1093 let label = decode_entities_minimal(iface.label.trim());
1094 let (tw, th) = label_metrics(&label, measurer, &text_style, wrap_mode_label);
1095 if capture_label_metrics {
1096 node_label_metrics_by_id.insert(iface.id.clone(), (tw, th));
1097 }
1098 g.set_node(
1099 iface.id.clone(),
1100 NodeLabel {
1101 width: tw.max(1.0),
1102 height: th.max(1.0),
1103 ..Default::default()
1104 },
1105 );
1106 }
1107
1108 for n in &model.notes {
1109 let (w, h, metrics) = note_dimensions(
1110 &n.text,
1111 measurer,
1112 &text_style,
1113 wrap_mode_label,
1114 namespace_padding,
1115 );
1116 if capture_label_metrics {
1117 node_label_metrics_by_id.insert(
1118 n.id.clone(),
1119 (metrics.width.max(0.0), metrics.height.max(0.0)),
1120 );
1121 }
1122 g.set_node(
1123 n.id.clone(),
1124 NodeLabel {
1125 width: w.max(1.0),
1126 height: h.max(1.0),
1127 ..Default::default()
1128 },
1129 );
1130 }
1131
1132 if g.options().compound {
1133 for c in model.classes.values() {
1136 if let Some(parent) = c
1137 .parent
1138 .as_ref()
1139 .map(|s| s.trim())
1140 .filter(|s| !s.is_empty())
1141 {
1142 if model.namespaces.contains_key(parent) {
1143 g.set_parent(c.id.clone(), parent.to_string());
1144 }
1145 }
1146 }
1147
1148 for iface in &model.interfaces {
1150 let Some(cls) = model.classes.get(iface.class_id.as_str()) else {
1151 continue;
1152 };
1153 let Some(parent) = cls
1154 .parent
1155 .as_ref()
1156 .map(|s| s.trim())
1157 .filter(|s| !s.is_empty())
1158 else {
1159 continue;
1160 };
1161 if model.namespaces.contains_key(parent) {
1162 g.set_parent(iface.id.clone(), parent.to_string());
1163 }
1164 }
1165 }
1166
1167 for rel in &model.relations {
1168 let (lw, lh) = label_metrics(&rel.title, measurer, &text_style, wrap_mode_label);
1169 let start_text = if rel.relation_title_1 == "none" {
1170 String::new()
1171 } else {
1172 rel.relation_title_1.clone()
1173 };
1174 let end_text = if rel.relation_title_2 == "none" {
1175 String::new()
1176 } else {
1177 rel.relation_title_2.clone()
1178 };
1179
1180 let (srw, srh) = label_metrics(&start_text, measurer, &text_style, wrap_mode_label);
1181 let (elw, elh) = label_metrics(&end_text, measurer, &text_style, wrap_mode_label);
1182
1183 let start_marker = if rel.relation.type1 == -1 { 0.0 } else { 10.0 };
1184 let end_marker = if rel.relation.type2 == -1 { 0.0 } else { 10.0 };
1185
1186 let mut el = EdgeLabel {
1187 width: lw,
1188 height: lh,
1189 labelpos: LabelPos::C,
1190 labeloffset: 10.0,
1191 minlen: 1,
1192 weight: 1.0,
1193 ..Default::default()
1194 };
1195 if srw > 0.0 && srh > 0.0 {
1196 set_extras_label_metrics(&mut el.extras, "startRight", srw, srh);
1197 }
1198 if elw > 0.0 && elh > 0.0 {
1199 set_extras_label_metrics(&mut el.extras, "endLeft", elw, elh);
1200 }
1201 el.extras
1202 .insert("startMarker".to_string(), Value::from(start_marker));
1203 el.extras
1204 .insert("endMarker".to_string(), Value::from(end_marker));
1205
1206 g.set_edge_named(
1207 rel.id1.clone(),
1208 rel.id2.clone(),
1209 Some(rel.id.clone()),
1210 Some(el),
1211 );
1212 }
1213
1214 let start_note_edge_id = model.relations.len() + 1;
1215 for (i, note) in model.notes.iter().enumerate() {
1216 let Some(class_id) = note.class_id.as_ref() else {
1217 continue;
1218 };
1219 if !model.classes.contains_key(class_id) {
1220 continue;
1221 }
1222 let edge_id = format!("edgeNote{}", start_note_edge_id + i);
1223 let el = EdgeLabel {
1224 width: 0.0,
1225 height: 0.0,
1226 labelpos: LabelPos::C,
1227 labeloffset: 10.0,
1228 minlen: 1,
1229 weight: 1.0,
1230 ..Default::default()
1231 };
1232 g.set_edge_named(note.id.clone(), class_id.clone(), Some(edge_id), Some(el));
1233 }
1234
1235 let prefer_dagreish_disconnected = !model.interfaces.is_empty();
1236 let mut prepared = prepare_graph(g, 0, prefer_dagreish_disconnected)?;
1237 let (mut fragments, _bounds) = layout_prepared(&mut prepared, &node_label_metrics_by_id)?;
1238
1239 let mut node_rect_by_id: HashMap<String, Rect> = HashMap::new();
1240 for n in fragments.nodes.values() {
1241 node_rect_by_id.insert(n.id.clone(), Rect::from_center(n.x, n.y, n.width, n.height));
1242 }
1243
1244 for (edge, terminal_meta) in fragments.edges.iter_mut() {
1245 let Some(meta) = terminal_meta.clone() else {
1246 continue;
1247 };
1248 let (from_rect, to_rect, points) = if let (Some(from), Some(to)) = (
1249 node_rect_by_id.get(edge.from.as_str()).copied(),
1250 node_rect_by_id.get(edge.to.as_str()).copied(),
1251 ) {
1252 (
1253 Some(from),
1254 Some(to),
1255 terminal_path_for_edge(&edge.points, from, to),
1256 )
1257 } else {
1258 (None, None, edge.points.clone())
1259 };
1260
1261 if let Some((w, h)) = meta.start_left {
1262 if let Some((x, y)) =
1263 calc_terminal_label_position(meta.start_marker, TerminalPos::StartLeft, &points)
1264 {
1265 let (x, y) = from_rect
1266 .map(|r| nudge_point_outside_rect(x, y, r))
1267 .unwrap_or((x, y));
1268 edge.start_label_left = Some(LayoutLabel {
1269 x,
1270 y,
1271 width: w,
1272 height: h,
1273 });
1274 }
1275 }
1276 if let Some((w, h)) = meta.start_right {
1277 if let Some((x, y)) =
1278 calc_terminal_label_position(meta.start_marker, TerminalPos::StartRight, &points)
1279 {
1280 let (x, y) = from_rect
1281 .map(|r| nudge_point_outside_rect(x, y, r))
1282 .unwrap_or((x, y));
1283 edge.start_label_right = Some(LayoutLabel {
1284 x,
1285 y,
1286 width: w,
1287 height: h,
1288 });
1289 }
1290 }
1291 if let Some((w, h)) = meta.end_left {
1292 if let Some((x, y)) =
1293 calc_terminal_label_position(meta.end_marker, TerminalPos::EndLeft, &points)
1294 {
1295 let (x, y) = to_rect
1296 .map(|r| nudge_point_outside_rect(x, y, r))
1297 .unwrap_or((x, y));
1298 edge.end_label_left = Some(LayoutLabel {
1299 x,
1300 y,
1301 width: w,
1302 height: h,
1303 });
1304 }
1305 }
1306 if let Some((w, h)) = meta.end_right {
1307 if let Some((x, y)) =
1308 calc_terminal_label_position(meta.end_marker, TerminalPos::EndRight, &points)
1309 {
1310 let (x, y) = to_rect
1311 .map(|r| nudge_point_outside_rect(x, y, r))
1312 .unwrap_or((x, y));
1313 edge.end_label_right = Some(LayoutLabel {
1314 x,
1315 y,
1316 width: w,
1317 height: h,
1318 });
1319 }
1320 }
1321 }
1322
1323 let title_margin_top = config_f64(
1324 effective_config,
1325 &["flowchart", "subGraphTitleMargin", "top"],
1326 )
1327 .unwrap_or(0.0);
1328 let title_margin_bottom = config_f64(
1329 effective_config,
1330 &["flowchart", "subGraphTitleMargin", "bottom"],
1331 )
1332 .unwrap_or(0.0);
1333
1334 let mut clusters: Vec<LayoutCluster> = Vec::new();
1335 for id in model.namespaces.keys() {
1339 let Some(ns_node) = fragments.nodes.get(id.as_str()) else {
1340 continue;
1341 };
1342 let cx = ns_node.x;
1343 let cy = ns_node.y;
1344 let base_w = ns_node.width.max(1.0);
1345 let base_h = ns_node.height.max(1.0);
1346
1347 let title = id.clone();
1348 let (tw, th) = label_metrics(&title, measurer, &text_style, wrap_mode_label);
1349 let min_title_w = (tw + namespace_padding).max(1.0);
1350 let width = if base_w <= min_title_w {
1351 min_title_w
1352 } else {
1353 base_w
1354 };
1355 let diff = if base_w <= min_title_w {
1356 (width - base_w) / 2.0 - namespace_padding
1357 } else {
1358 -namespace_padding
1359 };
1360 let offset_y = th - namespace_padding / 2.0;
1361 let title_label = LayoutLabel {
1362 x: cx,
1363 y: (cy - base_h / 2.0) + title_margin_top + th / 2.0,
1364 width: tw,
1365 height: th,
1366 };
1367
1368 clusters.push(LayoutCluster {
1369 id: id.clone(),
1370 x: cx,
1371 y: cy,
1372 width,
1373 height: base_h,
1374 diff,
1375 offset_y,
1376 title: title.clone(),
1377 title_label,
1378 requested_dir: None,
1379 effective_dir: normalize_dir(&model.direction),
1380 padding: namespace_padding,
1381 title_margin_top,
1382 title_margin_bottom,
1383 });
1384 }
1385
1386 let mut nodes: Vec<LayoutNode> = fragments.nodes.into_values().collect();
1389 nodes.sort_by(|a, b| a.id.cmp(&b.id));
1390
1391 let mut edges: Vec<LayoutEdge> = fragments.edges.into_iter().map(|(e, _)| e).collect();
1392 edges.sort_by(|a, b| a.id.cmp(&b.id));
1393
1394 clusters.sort_by(|a, b| a.id.cmp(&b.id));
1395
1396 let bounds = compute_bounds(&nodes, &edges, &clusters);
1397
1398 Ok(ClassDiagramV2Layout {
1399 nodes,
1400 edges,
1401 clusters,
1402 bounds,
1403 class_row_metrics_by_id,
1404 })
1405}
1406
1407fn compute_bounds(
1408 nodes: &[LayoutNode],
1409 edges: &[LayoutEdge],
1410 clusters: &[LayoutCluster],
1411) -> Option<Bounds> {
1412 let mut points: Vec<(f64, f64)> = Vec::new();
1413
1414 for c in clusters {
1415 let r = Rect::from_center(c.x, c.y, c.width, c.height);
1416 points.push((r.min_x(), r.min_y()));
1417 points.push((r.max_x(), r.max_y()));
1418 let lr = Rect::from_center(
1419 c.title_label.x,
1420 c.title_label.y,
1421 c.title_label.width,
1422 c.title_label.height,
1423 );
1424 points.push((lr.min_x(), lr.min_y()));
1425 points.push((lr.max_x(), lr.max_y()));
1426 }
1427
1428 for n in nodes {
1429 let r = Rect::from_center(n.x, n.y, n.width, n.height);
1430 points.push((r.min_x(), r.min_y()));
1431 points.push((r.max_x(), r.max_y()));
1432 }
1433
1434 for e in edges {
1435 for p in &e.points {
1436 points.push((p.x, p.y));
1437 }
1438 for l in [
1439 e.label.as_ref(),
1440 e.start_label_left.as_ref(),
1441 e.start_label_right.as_ref(),
1442 e.end_label_left.as_ref(),
1443 e.end_label_right.as_ref(),
1444 ]
1445 .into_iter()
1446 .flatten()
1447 {
1448 let r = Rect::from_center(l.x, l.y, l.width, l.height);
1449 points.push((r.min_x(), r.min_y()));
1450 points.push((r.max_x(), r.max_y()));
1451 }
1452 }
1453
1454 Bounds::from_points(points)
1455}