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 if text.contains('*') || text.contains('_') || text.contains('`') {
767 crate::text::measure_markdown_with_flowchart_bold_deltas(
768 measurer, text, style, None, wrap_mode,
769 )
770 } else {
771 measurer.measure_wrapped(text, style, None, wrap_mode)
772 }
773 }
774
775 fn label_rect(m: crate::text::TextMetrics, y_offset: f64) -> Option<Rect> {
776 if !(m.width.is_finite() && m.height.is_finite()) {
777 return None;
778 }
779 let w = m.width.max(0.0);
780 let h = m.height.max(0.0);
781 if w <= 0.0 || h <= 0.0 {
782 return None;
783 }
784 let lines = m.line_count.max(1) as f64;
785 let y = y_offset - (h / (2.0 * lines));
786 Some(Rect::from_min_max(0.0, y, w, y + h))
787 }
788
789 let mut annotation_rect: Option<Rect> = None;
791 let mut annotation_group_height = 0.0;
792 if let Some(a) = node.annotations.first() {
793 let t = format!("\u{00AB}{}\u{00BB}", decode_entities_minimal(a.trim()));
794 let m = measure_label(measurer, &t, text_style, wrap_mode);
795 annotation_rect = label_rect(m, 0.0);
796 if let Some(r) = annotation_rect {
797 annotation_group_height = r.height().max(0.0);
798 }
799 }
800
801 let mut title_text = decode_entities_minimal(&node.text);
803 if !use_html_labels && title_text.starts_with('\\') {
804 title_text = title_text.trim_start_matches('\\').to_string();
805 }
806 let title_md = format!("**{title_text}**");
810 let title_metrics = crate::text::measure_markdown_with_flowchart_bold_deltas(
811 measurer, &title_md, text_style, None, wrap_mode,
812 );
813 let title_rect = label_rect(title_metrics, 0.0);
814 let title_group_height = title_rect.map(|r| r.height()).unwrap_or(0.0);
815
816 let mut members_rect: Option<Rect> = None;
818 let mut members_metrics_out: Option<Vec<crate::text::TextMetrics>> =
819 capture_row_metrics.then(|| Vec::with_capacity(node.members.len()));
820 {
821 let mut y_offset = 0.0;
822 for m in &node.members {
823 let mut t = decode_entities_minimal(m.display_text.trim());
824 if !use_html_labels && t.starts_with('\\') {
825 t = t.trim_start_matches('\\').to_string();
826 }
827 let metrics = measure_label(measurer, &t, text_style, wrap_mode);
828 if let Some(out) = members_metrics_out.as_mut() {
829 out.push(metrics);
830 }
831 if let Some(r) = label_rect(metrics, y_offset) {
832 if let Some(ref mut cur) = members_rect {
833 cur.union(r);
834 } else {
835 members_rect = Some(r);
836 }
837 }
838 y_offset += metrics.height.max(0.0) + text_padding;
839 }
840 }
841 let mut members_group_height = members_rect.map(|r| r.height()).unwrap_or(0.0);
842 if members_group_height <= 0.0 {
843 members_group_height = (gap / 2.0).max(0.0);
845 }
846
847 let mut methods_rect: Option<Rect> = None;
849 let mut methods_metrics_out: Option<Vec<crate::text::TextMetrics>> =
850 capture_row_metrics.then(|| Vec::with_capacity(node.methods.len()));
851 {
852 let mut y_offset = 0.0;
853 for m in &node.methods {
854 let mut t = decode_entities_minimal(m.display_text.trim());
855 if !use_html_labels && t.starts_with('\\') {
856 t = t.trim_start_matches('\\').to_string();
857 }
858 let metrics = measure_label(measurer, &t, text_style, wrap_mode);
859 if let Some(out) = methods_metrics_out.as_mut() {
860 out.push(metrics);
861 }
862 if let Some(r) = label_rect(metrics, y_offset) {
863 if let Some(ref mut cur) = methods_rect {
864 cur.union(r);
865 } else {
866 methods_rect = Some(r);
867 }
868 }
869 y_offset += metrics.height.max(0.0) + text_padding;
870 }
871 }
872
873 let mut bbox_opt: Option<Rect> = None;
875
876 if let Some(mut r) = annotation_rect {
878 let w = r.width();
879 r.translate(-w / 2.0, 0.0);
880 bbox_opt = Some(if let Some(mut cur) = bbox_opt {
881 cur.union(r);
882 cur
883 } else {
884 r
885 });
886 }
887
888 if let Some(mut r) = title_rect {
890 let w = r.width();
891 r.translate(-w / 2.0, annotation_group_height);
892 bbox_opt = Some(if let Some(mut cur) = bbox_opt {
893 cur.union(r);
894 cur
895 } else {
896 r
897 });
898 }
899
900 if let Some(mut r) = members_rect {
902 let dy = annotation_group_height + title_group_height + gap * 2.0;
903 r.translate(0.0, dy);
904 bbox_opt = Some(if let Some(mut cur) = bbox_opt {
905 cur.union(r);
906 cur
907 } else {
908 r
909 });
910 }
911
912 if let Some(mut r) = methods_rect {
914 let dy = annotation_group_height + title_group_height + (members_group_height + gap * 4.0);
915 r.translate(0.0, dy);
916 bbox_opt = Some(if let Some(mut cur) = bbox_opt {
917 cur.union(r);
918 cur
919 } else {
920 r
921 });
922 }
923
924 let bbox = bbox_opt.unwrap_or_else(|| Rect::from_min_max(0.0, 0.0, 0.0, 0.0));
925 let w = bbox.width().max(0.0);
926 let mut h = bbox.height().max(0.0);
927
928 if node.members.is_empty() && node.methods.is_empty() {
930 h += gap;
931 } else if !node.members.is_empty() && node.methods.is_empty() {
932 h += gap * 2.0;
933 }
934
935 let render_extra_box =
936 node.members.is_empty() && node.methods.is_empty() && !hide_empty_members_box;
937
938 let mut rect_w = w + 2.0 * padding;
940 let mut rect_h = h + 2.0 * padding;
941 if render_extra_box {
942 rect_h += padding * 2.0;
943 } else if node.members.is_empty() && node.methods.is_empty() {
944 rect_h -= padding;
945 }
946
947 if node.type_param == "group" {
948 rect_w = rect_w.max(500.0);
949 }
950
951 let row_metrics = capture_row_metrics.then(|| ClassNodeRowMetrics {
952 members: members_metrics_out.unwrap_or_default(),
953 methods: methods_metrics_out.unwrap_or_default(),
954 });
955
956 (rect_w.max(1.0), rect_h.max(1.0), row_metrics)
957}
958
959fn note_dimensions(
960 text: &str,
961 measurer: &dyn TextMeasurer,
962 text_style: &TextStyle,
963 wrap_mode: WrapMode,
964 padding: f64,
965) -> (f64, f64, crate::text::TextMetrics) {
966 let p = padding.max(0.0);
967 let label = decode_entities_minimal(text);
968 let m = measurer.measure_wrapped(&label, text_style, None, wrap_mode);
969 (m.width + p, m.height + p, m)
970}
971
972fn label_metrics(
973 text: &str,
974 measurer: &dyn TextMeasurer,
975 text_style: &TextStyle,
976 wrap_mode: WrapMode,
977) -> (f64, f64) {
978 if text.trim().is_empty() {
979 return (0.0, 0.0);
980 }
981 let t = decode_entities_minimal(text);
982 let m = measurer.measure_wrapped(&t, text_style, None, wrap_mode);
983 (m.width.max(0.0), m.height.max(0.0))
984}
985
986fn set_extras_label_metrics(extras: &mut BTreeMap<String, Value>, key: &str, w: f64, h: f64) {
987 let obj = Value::Object(
988 [
989 ("width".to_string(), Value::from(w)),
990 ("height".to_string(), Value::from(h)),
991 ]
992 .into_iter()
993 .collect(),
994 );
995 extras.insert(key.to_string(), obj);
996}
997
998pub fn layout_class_diagram_v2(
999 semantic: &Value,
1000 effective_config: &Value,
1001 measurer: &dyn TextMeasurer,
1002) -> Result<ClassDiagramV2Layout> {
1003 let model: ClassDiagramModel = crate::json::from_value_ref(semantic)?;
1004 layout_class_diagram_v2_typed(&model, effective_config, measurer)
1005}
1006
1007pub fn layout_class_diagram_v2_typed(
1008 model: &ClassDiagramModel,
1009 effective_config: &Value,
1010 measurer: &dyn TextMeasurer,
1011) -> Result<ClassDiagramV2Layout> {
1012 let diagram_dir = rank_dir_from(&model.direction);
1013 let conf = effective_config
1014 .get("flowchart")
1015 .or_else(|| effective_config.get("class"))
1016 .unwrap_or(effective_config);
1017 let nodesep = config_f64(conf, &["nodeSpacing"]).unwrap_or(50.0);
1018 let ranksep = config_f64(conf, &["rankSpacing"]).unwrap_or(50.0);
1019
1020 let global_html_labels = config_bool(effective_config, &["htmlLabels"]).unwrap_or(true);
1021 let flowchart_html_labels =
1022 config_bool(effective_config, &["flowchart", "htmlLabels"]).unwrap_or(true);
1023 let wrap_mode_node = if global_html_labels {
1024 WrapMode::HtmlLike
1025 } else {
1026 WrapMode::SvgLike
1027 };
1028 let wrap_mode_label = if flowchart_html_labels {
1029 WrapMode::HtmlLike
1030 } else {
1031 WrapMode::SvgLike
1032 };
1033
1034 let class_padding = config_f64(effective_config, &["class", "padding"]).unwrap_or(12.0);
1036 let namespace_padding = config_f64(effective_config, &["flowchart", "padding"]).unwrap_or(15.0);
1037 let hide_empty_members_box =
1038 config_bool(effective_config, &["class", "hideEmptyMembersBox"]).unwrap_or(false);
1039
1040 let text_style = class_text_style(effective_config);
1041 let capture_row_metrics = matches!(wrap_mode_node, WrapMode::HtmlLike);
1042 let capture_label_metrics = matches!(wrap_mode_label, WrapMode::HtmlLike);
1043 let mut class_row_metrics_by_id: FxHashMap<String, Arc<ClassNodeRowMetrics>> =
1044 FxHashMap::default();
1045 let mut node_label_metrics_by_id: HashMap<String, (f64, f64)> = HashMap::new();
1046
1047 let mut g = Graph::<NodeLabel, EdgeLabel, GraphLabel>::new(GraphOptions {
1048 directed: true,
1049 multigraph: true,
1050 compound: true,
1051 });
1052 g.set_graph(GraphLabel {
1053 rankdir: diagram_dir,
1054 nodesep,
1055 ranksep,
1056 marginx: 0.0,
1060 marginy: 0.0,
1061 ..Default::default()
1062 });
1063
1064 for id in model.namespaces.keys() {
1065 let title = id.clone();
1069 let (tw, th) = label_metrics(&title, measurer, &text_style, wrap_mode_label);
1070 let w = (tw + 2.0 * namespace_padding).max(1.0);
1071 let h = (th + 2.0 * namespace_padding).max(1.0);
1072 g.set_node(
1073 id.clone(),
1074 NodeLabel {
1075 width: w,
1076 height: h,
1077 ..Default::default()
1078 },
1079 );
1080 }
1081
1082 for c in model.classes.values() {
1083 let (w, h, row_metrics) = class_box_dimensions(
1084 c,
1085 measurer,
1086 &text_style,
1087 wrap_mode_node,
1088 class_padding,
1089 hide_empty_members_box,
1090 capture_row_metrics,
1091 );
1092 if let Some(rm) = row_metrics {
1093 class_row_metrics_by_id.insert(c.id.clone(), Arc::new(rm));
1094 }
1095 g.set_node(
1096 c.id.clone(),
1097 NodeLabel {
1098 width: w,
1099 height: h,
1100 ..Default::default()
1101 },
1102 );
1103 }
1104
1105 for iface in &model.interfaces {
1107 let label = decode_entities_minimal(iface.label.trim());
1108 let (tw, th) = label_metrics(&label, measurer, &text_style, wrap_mode_label);
1109 if capture_label_metrics {
1110 node_label_metrics_by_id.insert(iface.id.clone(), (tw, th));
1111 }
1112 g.set_node(
1113 iface.id.clone(),
1114 NodeLabel {
1115 width: tw.max(1.0),
1116 height: th.max(1.0),
1117 ..Default::default()
1118 },
1119 );
1120 }
1121
1122 for n in &model.notes {
1123 let (w, h, metrics) = note_dimensions(
1124 &n.text,
1125 measurer,
1126 &text_style,
1127 wrap_mode_label,
1128 namespace_padding,
1129 );
1130 if capture_label_metrics {
1131 node_label_metrics_by_id.insert(
1132 n.id.clone(),
1133 (metrics.width.max(0.0), metrics.height.max(0.0)),
1134 );
1135 }
1136 g.set_node(
1137 n.id.clone(),
1138 NodeLabel {
1139 width: w.max(1.0),
1140 height: h.max(1.0),
1141 ..Default::default()
1142 },
1143 );
1144 }
1145
1146 if g.options().compound {
1147 for c in model.classes.values() {
1150 if let Some(parent) = c
1151 .parent
1152 .as_ref()
1153 .map(|s| s.trim())
1154 .filter(|s| !s.is_empty())
1155 {
1156 if model.namespaces.contains_key(parent) {
1157 g.set_parent(c.id.clone(), parent.to_string());
1158 }
1159 }
1160 }
1161
1162 for iface in &model.interfaces {
1164 let Some(cls) = model.classes.get(iface.class_id.as_str()) else {
1165 continue;
1166 };
1167 let Some(parent) = cls
1168 .parent
1169 .as_ref()
1170 .map(|s| s.trim())
1171 .filter(|s| !s.is_empty())
1172 else {
1173 continue;
1174 };
1175 if model.namespaces.contains_key(parent) {
1176 g.set_parent(iface.id.clone(), parent.to_string());
1177 }
1178 }
1179 }
1180
1181 for rel in &model.relations {
1182 let (lw, lh) = label_metrics(&rel.title, measurer, &text_style, wrap_mode_label);
1183 let start_text = if rel.relation_title_1 == "none" {
1184 String::new()
1185 } else {
1186 rel.relation_title_1.clone()
1187 };
1188 let end_text = if rel.relation_title_2 == "none" {
1189 String::new()
1190 } else {
1191 rel.relation_title_2.clone()
1192 };
1193
1194 let (srw, srh) = label_metrics(&start_text, measurer, &text_style, wrap_mode_label);
1195 let (elw, elh) = label_metrics(&end_text, measurer, &text_style, wrap_mode_label);
1196
1197 let start_marker = if rel.relation.type1 == -1 { 0.0 } else { 10.0 };
1198 let end_marker = if rel.relation.type2 == -1 { 0.0 } else { 10.0 };
1199
1200 let mut el = EdgeLabel {
1201 width: lw,
1202 height: lh,
1203 labelpos: LabelPos::C,
1204 labeloffset: 10.0,
1205 minlen: 1,
1206 weight: 1.0,
1207 ..Default::default()
1208 };
1209 if srw > 0.0 && srh > 0.0 {
1210 set_extras_label_metrics(&mut el.extras, "startRight", srw, srh);
1211 }
1212 if elw > 0.0 && elh > 0.0 {
1213 set_extras_label_metrics(&mut el.extras, "endLeft", elw, elh);
1214 }
1215 el.extras
1216 .insert("startMarker".to_string(), Value::from(start_marker));
1217 el.extras
1218 .insert("endMarker".to_string(), Value::from(end_marker));
1219
1220 g.set_edge_named(
1221 rel.id1.clone(),
1222 rel.id2.clone(),
1223 Some(rel.id.clone()),
1224 Some(el),
1225 );
1226 }
1227
1228 let start_note_edge_id = model.relations.len() + 1;
1229 for (i, note) in model.notes.iter().enumerate() {
1230 let Some(class_id) = note.class_id.as_ref() else {
1231 continue;
1232 };
1233 if !model.classes.contains_key(class_id) {
1234 continue;
1235 }
1236 let edge_id = format!("edgeNote{}", start_note_edge_id + i);
1237 let el = EdgeLabel {
1238 width: 0.0,
1239 height: 0.0,
1240 labelpos: LabelPos::C,
1241 labeloffset: 10.0,
1242 minlen: 1,
1243 weight: 1.0,
1244 ..Default::default()
1245 };
1246 g.set_edge_named(note.id.clone(), class_id.clone(), Some(edge_id), Some(el));
1247 }
1248
1249 let prefer_dagreish_disconnected = !model.interfaces.is_empty();
1250 let mut prepared = prepare_graph(g, 0, prefer_dagreish_disconnected)?;
1251 let (mut fragments, _bounds) = layout_prepared(&mut prepared, &node_label_metrics_by_id)?;
1252
1253 let mut node_rect_by_id: HashMap<String, Rect> = HashMap::new();
1254 for n in fragments.nodes.values() {
1255 node_rect_by_id.insert(n.id.clone(), Rect::from_center(n.x, n.y, n.width, n.height));
1256 }
1257
1258 for (edge, terminal_meta) in fragments.edges.iter_mut() {
1259 let Some(meta) = terminal_meta.clone() else {
1260 continue;
1261 };
1262 let (from_rect, to_rect, points) = if let (Some(from), Some(to)) = (
1263 node_rect_by_id.get(edge.from.as_str()).copied(),
1264 node_rect_by_id.get(edge.to.as_str()).copied(),
1265 ) {
1266 (
1267 Some(from),
1268 Some(to),
1269 terminal_path_for_edge(&edge.points, from, to),
1270 )
1271 } else {
1272 (None, None, edge.points.clone())
1273 };
1274
1275 if let Some((w, h)) = meta.start_left {
1276 if let Some((x, y)) =
1277 calc_terminal_label_position(meta.start_marker, TerminalPos::StartLeft, &points)
1278 {
1279 let (x, y) = from_rect
1280 .map(|r| nudge_point_outside_rect(x, y, r))
1281 .unwrap_or((x, y));
1282 edge.start_label_left = Some(LayoutLabel {
1283 x,
1284 y,
1285 width: w,
1286 height: h,
1287 });
1288 }
1289 }
1290 if let Some((w, h)) = meta.start_right {
1291 if let Some((x, y)) =
1292 calc_terminal_label_position(meta.start_marker, TerminalPos::StartRight, &points)
1293 {
1294 let (x, y) = from_rect
1295 .map(|r| nudge_point_outside_rect(x, y, r))
1296 .unwrap_or((x, y));
1297 edge.start_label_right = Some(LayoutLabel {
1298 x,
1299 y,
1300 width: w,
1301 height: h,
1302 });
1303 }
1304 }
1305 if let Some((w, h)) = meta.end_left {
1306 if let Some((x, y)) =
1307 calc_terminal_label_position(meta.end_marker, TerminalPos::EndLeft, &points)
1308 {
1309 let (x, y) = to_rect
1310 .map(|r| nudge_point_outside_rect(x, y, r))
1311 .unwrap_or((x, y));
1312 edge.end_label_left = Some(LayoutLabel {
1313 x,
1314 y,
1315 width: w,
1316 height: h,
1317 });
1318 }
1319 }
1320 if let Some((w, h)) = meta.end_right {
1321 if let Some((x, y)) =
1322 calc_terminal_label_position(meta.end_marker, TerminalPos::EndRight, &points)
1323 {
1324 let (x, y) = to_rect
1325 .map(|r| nudge_point_outside_rect(x, y, r))
1326 .unwrap_or((x, y));
1327 edge.end_label_right = Some(LayoutLabel {
1328 x,
1329 y,
1330 width: w,
1331 height: h,
1332 });
1333 }
1334 }
1335 }
1336
1337 let title_margin_top = config_f64(
1338 effective_config,
1339 &["flowchart", "subGraphTitleMargin", "top"],
1340 )
1341 .unwrap_or(0.0);
1342 let title_margin_bottom = config_f64(
1343 effective_config,
1344 &["flowchart", "subGraphTitleMargin", "bottom"],
1345 )
1346 .unwrap_or(0.0);
1347
1348 let mut clusters: Vec<LayoutCluster> = Vec::new();
1349 for id in model.namespaces.keys() {
1353 let Some(ns_node) = fragments.nodes.get(id.as_str()) else {
1354 continue;
1355 };
1356 let cx = ns_node.x;
1357 let cy = ns_node.y;
1358 let base_w = ns_node.width.max(1.0);
1359 let base_h = ns_node.height.max(1.0);
1360
1361 let title = id.clone();
1362 let (tw, th) = label_metrics(&title, measurer, &text_style, wrap_mode_label);
1363 let min_title_w = (tw + namespace_padding).max(1.0);
1364 let width = if base_w <= min_title_w {
1365 min_title_w
1366 } else {
1367 base_w
1368 };
1369 let diff = if base_w <= min_title_w {
1370 (width - base_w) / 2.0 - namespace_padding
1371 } else {
1372 -namespace_padding
1373 };
1374 let offset_y = th - namespace_padding / 2.0;
1375 let title_label = LayoutLabel {
1376 x: cx,
1377 y: (cy - base_h / 2.0) + title_margin_top + th / 2.0,
1378 width: tw,
1379 height: th,
1380 };
1381
1382 clusters.push(LayoutCluster {
1383 id: id.clone(),
1384 x: cx,
1385 y: cy,
1386 width,
1387 height: base_h,
1388 diff,
1389 offset_y,
1390 title: title.clone(),
1391 title_label,
1392 requested_dir: None,
1393 effective_dir: normalize_dir(&model.direction),
1394 padding: namespace_padding,
1395 title_margin_top,
1396 title_margin_bottom,
1397 });
1398 }
1399
1400 let mut nodes: Vec<LayoutNode> = fragments.nodes.into_values().collect();
1403 nodes.sort_by(|a, b| a.id.cmp(&b.id));
1404
1405 let mut edges: Vec<LayoutEdge> = fragments.edges.into_iter().map(|(e, _)| e).collect();
1406 edges.sort_by(|a, b| a.id.cmp(&b.id));
1407
1408 clusters.sort_by(|a, b| a.id.cmp(&b.id));
1409
1410 let bounds = compute_bounds(&nodes, &edges, &clusters);
1411
1412 Ok(ClassDiagramV2Layout {
1413 nodes,
1414 edges,
1415 clusters,
1416 bounds,
1417 class_row_metrics_by_id,
1418 })
1419}
1420
1421fn compute_bounds(
1422 nodes: &[LayoutNode],
1423 edges: &[LayoutEdge],
1424 clusters: &[LayoutCluster],
1425) -> Option<Bounds> {
1426 let mut points: Vec<(f64, f64)> = Vec::new();
1427
1428 for c in clusters {
1429 let r = Rect::from_center(c.x, c.y, c.width, c.height);
1430 points.push((r.min_x(), r.min_y()));
1431 points.push((r.max_x(), r.max_y()));
1432 let lr = Rect::from_center(
1433 c.title_label.x,
1434 c.title_label.y,
1435 c.title_label.width,
1436 c.title_label.height,
1437 );
1438 points.push((lr.min_x(), lr.min_y()));
1439 points.push((lr.max_x(), lr.max_y()));
1440 }
1441
1442 for n in nodes {
1443 let r = Rect::from_center(n.x, n.y, n.width, n.height);
1444 points.push((r.min_x(), r.min_y()));
1445 points.push((r.max_x(), r.max_y()));
1446 }
1447
1448 for e in edges {
1449 for p in &e.points {
1450 points.push((p.x, p.y));
1451 }
1452 for l in [
1453 e.label.as_ref(),
1454 e.start_label_left.as_ref(),
1455 e.start_label_right.as_ref(),
1456 e.end_label_left.as_ref(),
1457 e.end_label_right.as_ref(),
1458 ]
1459 .into_iter()
1460 .flatten()
1461 {
1462 let r = Rect::from_center(l.x, l.y, l.width, l.height);
1463 points.push((r.min_x(), r.min_y()));
1464 points.push((r.max_x(), r.max_y()));
1465 }
1466 }
1467
1468 Bounds::from_points(points)
1469}