1use crate::model::{Bounds, ErDiagramLayout, LayoutEdge, LayoutLabel, LayoutNode, LayoutPoint};
2use crate::text::{TextMeasurer, TextMetrics, TextStyle, WrapMode};
3use crate::{Error, Result};
4use dugong::graphlib::{Graph, GraphOptions};
5use dugong::{EdgeLabel, GraphLabel, LabelPos, NodeLabel, RankDir};
6use serde::Deserialize;
7use serde_json::Value;
8use std::collections::{BTreeMap, HashMap};
9
10#[derive(Debug, Clone, Deserialize)]
11pub(crate) struct ErModel {
12 #[serde(default, rename = "accTitle")]
13 pub acc_title: Option<String>,
14 #[serde(default, rename = "accDescr")]
15 pub acc_descr: Option<String>,
16 pub direction: String,
17 #[serde(default)]
18 #[allow(dead_code)]
19 pub classes: BTreeMap<String, ErClassDef>,
20 pub entities: BTreeMap<String, ErEntity>,
21 #[serde(default)]
22 pub relationships: Vec<ErRelationship>,
23}
24
25#[derive(Debug, Clone, Deserialize)]
26pub(crate) struct ErEntity {
27 pub id: String,
28 pub label: String,
29 #[serde(default)]
30 pub alias: String,
31 #[serde(default, rename = "cssClasses")]
32 pub css_classes: String,
33 #[serde(default, rename = "cssStyles")]
34 pub css_styles: Vec<String>,
35 #[serde(default)]
36 pub attributes: Vec<ErAttribute>,
37}
38
39#[derive(Debug, Clone, Deserialize)]
40pub(crate) struct ErAttribute {
41 #[serde(rename = "type")]
42 pub ty: String,
43 pub name: String,
44 #[serde(default)]
45 pub keys: Vec<String>,
46 #[serde(default)]
47 pub comment: String,
48}
49
50#[derive(Debug, Clone, Deserialize)]
51pub(crate) struct ErRelationship {
52 #[serde(rename = "entityA")]
53 pub entity_a: String,
54 #[serde(rename = "entityB")]
55 pub entity_b: String,
56 #[serde(rename = "roleA")]
57 pub role_a: String,
58 #[allow(dead_code)]
59 #[serde(rename = "relSpec")]
60 pub rel_spec: Value,
61}
62
63#[derive(Debug, Clone, Deserialize)]
64pub(crate) struct ErClassDef {
65 #[allow(dead_code)]
66 pub id: String,
67 #[serde(default)]
68 #[allow(dead_code)]
69 pub styles: Vec<String>,
70 #[serde(default, rename = "textStyles")]
71 #[allow(dead_code)]
72 pub text_styles: Vec<String>,
73}
74
75fn json_f64(v: &Value) -> Option<f64> {
76 v.as_f64()
77 .or_else(|| v.as_i64().map(|n| n as f64))
78 .or_else(|| v.as_u64().map(|n| n as f64))
79}
80
81fn config_f64(cfg: &Value, path: &[&str]) -> Option<f64> {
82 let mut cur = cfg;
83 for key in path {
84 cur = cur.get(*key)?;
85 }
86 json_f64(cur)
87}
88
89fn parse_css_px_to_f64(s: &str) -> Option<f64> {
90 let s = s.trim();
91 let raw = s.strip_suffix("px").unwrap_or(s).trim();
92 raw.parse::<f64>().ok().filter(|v| v.is_finite())
93}
94
95fn config_f64_css_px(cfg: &Value, path: &[&str]) -> Option<f64> {
96 config_f64(cfg, path).or_else(|| {
97 let s = config_string(cfg, path)?;
98 parse_css_px_to_f64(&s)
99 })
100}
101
102fn config_string(cfg: &Value, path: &[&str]) -> Option<String> {
103 let mut cur = cfg;
104 for key in path {
105 cur = cur.get(*key)?;
106 }
107 cur.as_str().map(|s| s.to_string())
108}
109
110fn normalize_dir(direction: &str) -> String {
111 match direction.trim().to_uppercase().as_str() {
112 "TB" | "TD" => "TB".to_string(),
113 "BT" => "BT".to_string(),
114 "LR" => "LR".to_string(),
115 "RL" => "RL".to_string(),
116 other => other.to_string(),
117 }
118}
119
120fn rank_dir_from(direction: &str) -> RankDir {
121 match normalize_dir(direction).as_str() {
122 "TB" => RankDir::TB,
123 "BT" => RankDir::BT,
124 "LR" => RankDir::LR,
125 "RL" => RankDir::RL,
126 _ => RankDir::TB,
127 }
128}
129
130pub(crate) fn parse_generic_types_like_mermaid(text: &str) -> String {
131 let mut out = String::with_capacity(text.len());
133 let mut it = text.split('~').peekable();
134 let mut open = false;
135 while let Some(part) = it.next() {
136 out.push_str(part);
137 if it.peek().is_none() {
138 break;
139 }
140 if !open {
141 out.push('<');
142 open = true;
143 } else {
144 out.push('>');
145 open = false;
146 }
147 }
148 if open {
149 out.push('>');
150 }
151 out
152}
153
154pub(crate) fn er_html_label_metrics(
155 text: &str,
156 measurer: &dyn TextMeasurer,
157 style: &TextStyle,
158) -> TextMetrics {
159 let text = text.trim();
160 if text.is_empty() {
161 return TextMetrics {
162 width: 0.0,
163 height: 0.0,
164 line_count: 0,
165 };
166 }
167
168 let lower = text.to_ascii_lowercase();
169 let has_inline_html =
170 lower.contains("<br") || lower.contains("<strong") || lower.contains("<em");
171
172 if (text.contains('<') || text.contains('>')) && !has_inline_html {
173 return measurer.measure_wrapped(text, style, None, WrapMode::HtmlLike);
174 }
175
176 let mut metrics = measurer.measure_wrapped(text, style, None, WrapMode::HtmlLike);
177 if text.contains('`') {
178 let svg_bbox_w = measurer.measure_svg_simple_text_bbox_width_px(text, style);
179 metrics.width = crate::text::round_to_1_64_px(metrics.width.max(svg_bbox_w));
180 }
181 metrics
182}
183
184pub(crate) fn calculate_text_width_like_mermaid_px(
185 measurer: &dyn TextMeasurer,
186 style: &TextStyle,
187 text: &str,
188) -> i64 {
189 if let Some(w) = crate::generated::er_text_overrides_11_12_2::lookup_calc_text_width_px(
190 style.font_size,
191 text,
192 ) {
193 return w;
194 }
195 let mut sans = style.clone();
200 sans.font_family = Some("sans-serif".to_string());
201 sans.font_weight = None;
202
203 let mut fam = style.clone();
204 fam.font_weight = None;
205
206 let w_fam = measurer.measure_svg_simple_text_bbox_width_px(text, &fam);
207 let w_sans = measurer.measure_svg_simple_text_bbox_width_px(text, &sans);
208 let w = match (
209 w_fam.is_finite() && w_fam > 0.0,
210 w_sans.is_finite() && w_sans > 0.0,
211 ) {
212 (true, true) => w_fam.max(w_sans),
213 (true, false) => w_fam,
214 (false, true) => w_sans,
215 (false, false) => 0.0,
216 };
217 if !w.is_finite() {
218 return 0;
219 }
220 (w + (1.0 / 512.0)).round() as i64
224}
225
226fn er_text_style(effective_config: &Value) -> TextStyle {
227 let font_family = config_string(effective_config, &["fontFamily"]);
228 let font_size = config_f64_css_px(effective_config, &["themeVariables", "fontSize"])
231 .or_else(|| config_f64_css_px(effective_config, &["fontSize"]))
232 .or_else(|| config_f64_css_px(effective_config, &["er", "fontSize"]))
233 .unwrap_or(16.0);
234 TextStyle {
235 font_family,
236 font_size,
237 font_weight: None,
238 }
239}
240
241#[derive(Debug, Clone)]
242pub(crate) struct ErEntityMeasureRow {
243 pub type_text: String,
244 pub name_text: String,
245 pub key_text: String,
246 pub comment_text: String,
247 pub height: f64,
248}
249
250#[derive(Debug, Clone)]
251pub(crate) struct ErEntityMeasure {
252 pub width: f64,
253 pub height: f64,
254 pub text_padding: f64,
255 pub label_text: String,
256 pub label_html_width: f64,
257 pub label_height: f64,
258 pub label_max_width_px: i64,
259 pub has_key: bool,
260 pub has_comment: bool,
261 pub type_col_w: f64,
262 pub name_col_w: f64,
263 pub key_col_w: f64,
264 pub comment_col_w: f64,
265 pub rows: Vec<ErEntityMeasureRow>,
266}
267
268pub(crate) fn measure_entity_box(
269 entity: &ErEntity,
270 measurer: &dyn TextMeasurer,
271 label_style: &TextStyle,
272 attr_style: &TextStyle,
273 effective_config: &Value,
274) -> ErEntityMeasure {
275 const ATTR_TEXT_WIDTH_SCALE: f64 = 1.0;
279
280 let html_labels_raw = config_bool(effective_config, &["htmlLabels"]).unwrap_or(false);
290
291 let mut padding = config_f64(effective_config, &["er", "diagramPadding"]).unwrap_or(20.0);
295 let mut text_padding = config_f64(effective_config, &["er", "entityPadding"]).unwrap_or(15.0);
296 let min_w = config_f64(effective_config, &["er", "minEntityWidth"]).unwrap_or(100.0);
297 let wrapping_width_px = config_f64(effective_config, &["flowchart", "wrappingWidth"])
298 .unwrap_or(200.0)
299 .round()
300 .max(0.0) as i64;
301
302 let label_text = if entity.alias.trim().is_empty() {
303 entity.label.as_str()
304 } else {
305 entity.alias.as_str()
306 }
307 .to_string();
308 let label_metrics = er_html_label_metrics(&label_text, measurer, label_style);
309 let label_html_width = crate::generated::er_text_overrides_11_12_2::lookup_html_width_px(
310 label_style.font_size,
311 &label_text,
312 )
313 .unwrap_or_else(|| label_metrics.width.max(0.0));
314
315 if entity.attributes.is_empty() {
317 let label_pad_x = padding;
318 let label_pad_y = padding * 1.5;
319 let calc_w = calculate_text_width_like_mermaid_px(measurer, label_style, &label_text);
324 let clamp_to_min_w = crate::generated::er_text_overrides_11_12_2::
325 lookup_entity_drawrect_clamp_to_min_entity_width(&label_text)
326 .unwrap_or((calc_w as f64 + label_pad_x * 2.0) < min_w);
327 let width = if clamp_to_min_w {
328 min_w
329 } else {
330 label_html_width + label_pad_x * 2.0
331 };
332 let height = label_metrics.height + label_pad_y * 2.0;
333 return ErEntityMeasure {
334 width: width.max(1.0),
335 height: height.max(1.0),
336 text_padding,
337 label_text,
338 label_html_width,
339 label_height: label_metrics.height.max(0.0),
340 label_max_width_px: if clamp_to_min_w {
341 min_w.round().max(0.0) as i64
342 } else {
343 wrapping_width_px
344 },
345 has_key: false,
346 has_comment: false,
347 type_col_w: 0.0,
348 name_col_w: 0.0,
349 key_col_w: 0.0,
350 comment_col_w: 0.0,
351 rows: Vec::new(),
352 };
353 }
354
355 if !html_labels_raw {
358 padding *= 1.25;
359 text_padding *= 1.25;
360 }
361
362 let mut rows: Vec<ErEntityMeasureRow> = Vec::new();
363
364 let mut max_type_raw_w: f64 = 0.0;
365 let mut max_name_raw_w: f64 = 0.0;
366 let mut max_keys_raw_w: f64 = 0.0;
367 let mut max_comment_raw_w: f64 = 0.0;
368
369 let mut max_type_col_w: f64 = 0.0;
370 let mut max_name_col_w: f64 = 0.0;
371 let mut max_keys_col_w: f64 = 0.0;
372 let mut max_comment_col_w: f64 = 0.0;
373
374 let mut total_rows_h = 0.0;
375
376 for a in &entity.attributes {
377 let ty = parse_generic_types_like_mermaid(&a.ty);
378 let type_m = er_html_label_metrics(&ty, measurer, attr_style);
379 let name_m = er_html_label_metrics(&a.name, measurer, attr_style);
380
381 let type_w = crate::generated::er_text_overrides_11_12_2::lookup_html_width_px(
382 attr_style.font_size,
383 &ty,
384 )
385 .unwrap_or(type_m.width)
386 * ATTR_TEXT_WIDTH_SCALE;
387 let name_w = crate::generated::er_text_overrides_11_12_2::lookup_html_width_px(
388 attr_style.font_size,
389 &a.name,
390 )
391 .unwrap_or(name_m.width)
392 * ATTR_TEXT_WIDTH_SCALE;
393 max_type_raw_w = max_type_raw_w.max(type_w);
394 max_name_raw_w = max_name_raw_w.max(name_w);
395 max_type_col_w = max_type_col_w.max(type_w + padding);
396 max_name_col_w = max_name_col_w.max(name_w + padding);
397
398 let key_text = a.keys.join(",");
399 let keys_m = er_html_label_metrics(&key_text, measurer, attr_style);
400 let keys_w = crate::generated::er_text_overrides_11_12_2::lookup_html_width_px(
401 attr_style.font_size,
402 &key_text,
403 )
404 .unwrap_or(keys_m.width)
405 * ATTR_TEXT_WIDTH_SCALE;
406 max_keys_raw_w = max_keys_raw_w.max(keys_w);
407 max_keys_col_w = max_keys_col_w.max(keys_w + padding);
408
409 let comment_text = a.comment.clone();
410 let comment_m = er_html_label_metrics(&comment_text, measurer, attr_style);
411 let comment_w = crate::generated::er_text_overrides_11_12_2::lookup_html_width_px(
412 attr_style.font_size,
413 &comment_text,
414 )
415 .unwrap_or(comment_m.width)
416 * ATTR_TEXT_WIDTH_SCALE;
417 max_comment_raw_w = max_comment_raw_w.max(comment_w);
418 max_comment_col_w = max_comment_col_w.max(comment_w + padding);
419
420 let row_h = type_m
421 .height
422 .max(name_m.height)
423 .max(keys_m.height)
424 .max(comment_m.height)
425 + text_padding;
426
427 rows.push(ErEntityMeasureRow {
428 type_text: ty,
429 name_text: a.name.clone(),
430 key_text,
431 comment_text,
432 height: row_h.max(1.0),
433 });
434 total_rows_h += row_h.max(1.0);
435 }
436
437 let mut total_width_sections = 4usize;
438 let mut has_key = true;
439 let mut has_comment = true;
440 if max_keys_col_w <= padding {
441 has_key = false;
442 max_keys_col_w = 0.0;
443 total_width_sections = total_width_sections.saturating_sub(1);
444 }
445 if max_comment_col_w <= padding {
446 has_comment = false;
447 max_comment_col_w = 0.0;
448 total_width_sections = total_width_sections.saturating_sub(1);
449 }
450
451 let name_w_min = label_html_width + padding * 2.0;
454 let mut max_width = max_type_col_w + max_name_col_w + max_keys_col_w + max_comment_col_w;
455 if name_w_min - max_width > 0.0 && total_width_sections > 0 {
456 let diff = name_w_min - max_width;
457 let per = diff / total_width_sections as f64;
458 max_type_col_w += per;
459 max_name_col_w += per;
460 if has_key {
461 max_keys_col_w += per;
462 }
463 if has_comment {
464 max_comment_col_w += per;
465 }
466 max_width = max_type_col_w + max_name_col_w + max_keys_col_w + max_comment_col_w;
467 }
468
469 let shape_bbox_w = label_html_width
470 .max(max_type_raw_w)
471 .max(max_name_raw_w)
472 .max(max_keys_raw_w)
473 .max(max_comment_raw_w);
474
475 let width = (shape_bbox_w + padding * 2.0).max(max_width);
476 let name_h = label_metrics.height + text_padding;
477 let height = total_rows_h + name_h;
478
479 ErEntityMeasure {
480 width: width.max(1.0),
481 height: height.max(1.0),
482 text_padding,
483 label_text,
484 label_html_width,
485 label_height: label_metrics.height.max(0.0),
486 label_max_width_px: wrapping_width_px,
487 has_key,
488 has_comment,
489 type_col_w: max_type_col_w.max(0.0),
490 name_col_w: max_name_col_w.max(0.0),
491 key_col_w: max_keys_col_w.max(0.0),
492 comment_col_w: max_comment_col_w.max(0.0),
493 rows,
494 }
495}
496
497fn entity_box_dimensions(
498 entity: &ErEntity,
499 measurer: &dyn TextMeasurer,
500 label_style: &TextStyle,
501 attr_style: &TextStyle,
502 effective_config: &Value,
503) -> (f64, f64) {
504 let m = measure_entity_box(entity, measurer, label_style, attr_style, effective_config);
505 (m.width, m.height)
506}
507
508fn edge_label_metrics(
509 text: &str,
510 measurer: &dyn TextMeasurer,
511 style: &TextStyle,
512 html_labels: bool,
513) -> (f64, f64) {
514 let text = text.trim();
515 if text.is_empty() {
516 return (0.0, 0.0);
517 }
518
519 let lower = text.to_ascii_lowercase();
520 let has_inline_html =
521 lower.contains("<br") || lower.contains("<strong") || lower.contains("<em");
522 let has_markdown = text.contains('*') || text.contains('_');
523 let wrap_mode = if html_labels {
524 WrapMode::HtmlLike
525 } else {
526 WrapMode::SvgLike
527 };
528
529 if has_markdown || has_inline_html {
535 let m = crate::text::measure_markdown_with_flowchart_bold_deltas(
536 measurer, text, style, None, wrap_mode,
537 );
538 return (m.width.max(0.0), m.height.max(0.0));
539 }
540
541 let m = measurer.measure_wrapped(text, style, None, wrap_mode);
542 let w = if html_labels {
543 crate::generated::er_text_overrides_11_12_2::lookup_html_width_px(style.font_size, text)
544 .unwrap_or(m.width)
545 } else {
546 m.width
547 };
548 (w.max(0.0), m.height.max(0.0))
549}
550
551fn parse_er_rel_idx_from_edge_name(name: &str) -> Option<usize> {
552 let rest = name.strip_prefix("er-rel-")?;
553 let mut end = 0usize;
554 for (idx, ch) in rest.char_indices() {
555 if !ch.is_ascii_digit() {
556 break;
557 }
558 end = idx + ch.len_utf8();
559 }
560 if end == 0 {
561 return None;
562 }
563 rest[..end].parse::<usize>().ok()
564}
565
566fn is_er_self_loop_dummy_node_id(id: &str) -> bool {
567 id.contains("---")
569}
570
571fn config_bool(cfg: &Value, path: &[&str]) -> Option<bool> {
572 let mut cur = cfg;
573 for key in path {
574 cur = cur.get(*key)?;
575 }
576 cur.as_bool()
577}
578
579pub(crate) fn er_relationship_html_labels(effective_config: &Value) -> bool {
580 config_bool(effective_config, &["htmlLabels"])
589 .or_else(|| config_bool(effective_config, &["flowchart", "htmlLabels"]))
590 .unwrap_or(true)
591}
592
593#[derive(Debug, Clone)]
594struct LayoutEdgeParts {
595 id: String,
596 from: String,
597 to: String,
598 points: Vec<LayoutPoint>,
599 label: Option<LayoutLabel>,
600 start_marker: Option<String>,
601 end_marker: Option<String>,
602 stroke_dasharray: Option<String>,
603}
604
605fn calc_label_position(points: &[LayoutPoint]) -> Option<(f64, f64)> {
606 if points.is_empty() {
607 return None;
608 }
609 if points.len() == 1 {
610 return Some((points[0].x, points[0].y));
611 }
612
613 let mut total = 0.0;
614 for i in 1..points.len() {
615 let dx = points[i].x - points[i - 1].x;
616 let dy = points[i].y - points[i - 1].y;
617 total += (dx * dx + dy * dy).sqrt();
618 }
619 let mut remaining = total / 2.0;
620 for i in 1..points.len() {
621 let p0 = &points[i - 1];
622 let p1 = &points[i];
623 let dx = p1.x - p0.x;
624 let dy = p1.y - p0.y;
625 let seg = (dx * dx + dy * dy).sqrt();
626 if seg == 0.0 {
627 continue;
628 }
629 if seg < remaining {
630 remaining -= seg;
631 continue;
632 }
633 let t = (remaining / seg).clamp(0.0, 1.0);
634 return Some((p0.x + t * dx, p0.y + t * dy));
635 }
636 Some((points.last()?.x, points.last()?.y))
637}
638
639type Rect = merman_core::geom::Box2;
640
641fn intersect_segment_with_rect(
642 p0: &LayoutPoint,
643 p1: &LayoutPoint,
644 rect: Rect,
645) -> Option<LayoutPoint> {
646 let dx = p1.x - p0.x;
647 let dy = p1.y - p0.y;
648 if dx == 0.0 && dy == 0.0 {
649 return None;
650 }
651
652 let mut candidates: Vec<(f64, LayoutPoint)> = Vec::new();
653 let eps = 1e-9;
654 let min_x = rect.min_x();
655 let max_x = rect.max_x();
656 let min_y = rect.min_y();
657 let max_y = rect.max_y();
658
659 if dx.abs() > eps {
660 for x_edge in [min_x, max_x] {
661 let t = (x_edge - p0.x) / dx;
662 if t < -eps || t > 1.0 + eps {
663 continue;
664 }
665 let y = p0.y + t * dy;
666 if y + eps >= min_y && y <= max_y + eps {
667 candidates.push((t, LayoutPoint { x: x_edge, y }));
668 }
669 }
670 }
671
672 if dy.abs() > eps {
673 for y_edge in [min_y, max_y] {
674 let t = (y_edge - p0.y) / dy;
675 if t < -eps || t > 1.0 + eps {
676 continue;
677 }
678 let x = p0.x + t * dx;
679 if x + eps >= min_x && x <= max_x + eps {
680 candidates.push((t, LayoutPoint { x, y: y_edge }));
681 }
682 }
683 }
684
685 candidates.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal));
686 candidates
687 .into_iter()
688 .find(|(t, _)| *t >= 0.0)
689 .map(|(_, p)| p)
690}
691
692fn clip_edge_endpoints(points: &mut [LayoutPoint], from: Rect, to: Rect) {
693 if points.len() < 2 {
694 return;
695 }
696 if from.contains_point(points[0].x, points[0].y) {
697 if let Some(p) = intersect_segment_with_rect(&points[0], &points[1], from) {
698 points[0] = p;
699 }
700 }
701 let last = points.len() - 1;
702 if to.contains_point(points[last].x, points[last].y) {
703 if let Some(p) = intersect_segment_with_rect(&points[last], &points[last - 1], to) {
704 points[last] = p;
705 }
706 }
707}
708
709fn er_marker_id(card: &str, suffix: &str) -> Option<String> {
710 match card {
711 "ONLY_ONE" => Some(format!("ONLY_ONE_{suffix}")),
712 "ZERO_OR_ONE" => Some(format!("ZERO_OR_ONE_{suffix}")),
713 "ONE_OR_MORE" => Some(format!("ONE_OR_MORE_{suffix}")),
714 "ZERO_OR_MORE" => Some(format!("ZERO_OR_MORE_{suffix}")),
715 "MD_PARENT" => None,
717 _ => None,
718 }
719}
720
721pub fn layout_er_diagram(
722 semantic: &Value,
723 effective_config: &Value,
724 measurer: &dyn TextMeasurer,
725) -> Result<ErDiagramLayout> {
726 let model: ErModel = crate::json::from_value_ref(semantic)?;
727
728 let nodesep = config_f64(effective_config, &["er", "nodeSpacing"]).unwrap_or(140.0);
729 let ranksep = config_f64(effective_config, &["er", "rankSpacing"]).unwrap_or(80.0);
730 let dir = rank_dir_from(&model.direction);
731
732 let label_style = er_text_style(effective_config);
733 let attr_style = TextStyle {
734 font_family: label_style.font_family.clone(),
735 font_size: label_style.font_size.max(1.0),
736 font_weight: None,
737 };
738 let rel_label_style = TextStyle {
739 font_family: label_style.font_family.clone(),
740 font_size: 14.0,
742 font_weight: None,
743 };
744 let rel_html_labels = er_relationship_html_labels(effective_config);
745
746 let mut g = Graph::<NodeLabel, EdgeLabel, GraphLabel>::new(GraphOptions {
747 directed: true,
748 multigraph: true,
749 compound: true,
752 });
753 g.set_graph(GraphLabel {
754 rankdir: dir,
755 nodesep,
756 ranksep,
757 acyclicer: Some("greedy".to_string()),
759 ..Default::default()
760 });
761
762 fn parse_entity_counter_from_id(id: &str) -> Option<usize> {
763 let (_prefix, tail) = id.rsplit_once('-')?;
764 tail.parse::<usize>().ok()
765 }
766
767 let mut entities_in_layout_order: Vec<&ErEntity> = model.entities.values().collect();
769 entities_in_layout_order.sort_by(|a, b| {
770 let a_key = (parse_entity_counter_from_id(&a.id), a.id.as_str());
771 let b_key = (parse_entity_counter_from_id(&b.id), b.id.as_str());
772 a_key.cmp(&b_key)
773 });
774
775 for e in entities_in_layout_order {
776 let (w, h) =
777 entity_box_dimensions(e, measurer, &label_style, &attr_style, effective_config);
778 g.set_node(
779 e.id.clone(),
780 NodeLabel {
781 width: w,
782 height: h,
783 ..Default::default()
784 },
785 );
786 }
787
788 for (idx, r) in model.relationships.iter().enumerate() {
792 if g.node(&r.entity_a).is_none() || g.node(&r.entity_b).is_none() {
793 return Err(Error::InvalidModel {
794 message: format!(
795 "relationship references missing entities: {} -> {}",
796 r.entity_a, r.entity_b
797 ),
798 });
799 }
800
801 if r.entity_a == r.entity_b {
806 let node_id = r.entity_a.as_str();
807 let special_1 = format!("{node_id}---{node_id}---1");
808 let special_2 = format!("{node_id}---{node_id}---2");
809
810 if g.node(&special_1).is_none() {
811 g.set_node(
812 special_1.clone(),
813 NodeLabel {
814 width: 0.1,
815 height: 0.1,
816 ..Default::default()
817 },
818 );
819 }
820 if g.node(&special_2).is_none() {
821 g.set_node(
822 special_2.clone(),
823 NodeLabel {
824 width: 0.1,
825 height: 0.1,
826 ..Default::default()
827 },
828 );
829 }
830
831 let (label_w, label_h) = if r.role_a.trim().is_empty() {
832 (0.0, 0.0)
833 } else {
834 edge_label_metrics(&r.role_a, measurer, &rel_label_style, rel_html_labels)
835 };
836
837 g.set_edge_named(
839 r.entity_a.clone(),
840 special_1.clone(),
841 Some(format!("er-rel-{idx}-cyclic-0")),
842 Some(EdgeLabel {
843 width: 0.0,
844 height: 0.0,
845 labelpos: LabelPos::C,
846 labeloffset: 10.0,
847 minlen: 1,
848 weight: 1.0,
849 ..Default::default()
850 }),
851 );
852
853 g.set_edge_named(
855 special_1.clone(),
856 special_2.clone(),
857 Some(format!("er-rel-{idx}")),
858 Some(EdgeLabel {
859 width: label_w.max(0.0),
860 height: label_h.max(0.0),
861 labelpos: LabelPos::C,
862 labeloffset: 10.0,
863 minlen: 1,
864 weight: 1.0,
865 ..Default::default()
866 }),
867 );
868
869 g.set_edge_named(
871 special_2.clone(),
872 r.entity_a.clone(),
873 Some(format!("er-rel-{idx}-cyclic-2")),
874 Some(EdgeLabel {
875 width: 0.0,
876 height: 0.0,
877 labelpos: LabelPos::C,
878 labeloffset: 10.0,
879 minlen: 1,
880 weight: 1.0,
881 ..Default::default()
882 }),
883 );
884
885 continue;
886 }
887
888 let name = format!("er-rel-{idx}");
889 let (label_w, label_h) = if r.role_a.trim().is_empty() {
890 (0.0, 0.0)
891 } else {
892 edge_label_metrics(&r.role_a, measurer, &rel_label_style, rel_html_labels)
893 };
894 g.set_edge_named(
895 r.entity_a.clone(),
896 r.entity_b.clone(),
897 Some(name),
898 Some(EdgeLabel {
899 width: label_w.max(0.0),
900 height: label_h.max(0.0),
901 labelpos: LabelPos::C,
902 labeloffset: 10.0,
903 minlen: 1,
904 weight: 1.0,
905 ..Default::default()
906 }),
907 );
908 }
909
910 dugong::layout_dagreish(&mut g);
911
912 let mut nodes: Vec<LayoutNode> = Vec::new();
913 for id in g.node_ids() {
914 let Some(n) = g.node(&id) else {
915 continue;
916 };
917 nodes.push(LayoutNode {
918 id: id.clone(),
919 x: n.x.unwrap_or(0.0),
920 y: n.y.unwrap_or(0.0),
921 width: n.width,
922 height: n.height,
923 is_cluster: false,
924 label_width: None,
925 label_height: None,
926 });
927 }
928 nodes.sort_by(|a, b| a.id.cmp(&b.id));
929
930 let mut node_rect_by_id: HashMap<String, Rect> = HashMap::new();
931 for n in &nodes {
932 node_rect_by_id.insert(n.id.clone(), Rect::from_center(n.x, n.y, n.width, n.height));
933 }
934
935 let mut edges: Vec<LayoutEdgeParts> = Vec::new();
936 for key in g.edge_keys() {
937 let Some(e) = g.edge_by_key(&key) else {
938 continue;
939 };
940 let mut points = e
941 .points
942 .iter()
943 .map(|p| LayoutPoint { x: p.x, y: p.y })
944 .collect::<Vec<_>>();
945
946 let id = key
947 .name
948 .clone()
949 .unwrap_or_else(|| format!("edge:{}:{}", key.v, key.w));
950
951 let rel_idx = key
952 .name
953 .as_ref()
954 .and_then(|name| parse_er_rel_idx_from_edge_name(name))
955 .and_then(|idx| model.relationships.get(idx).map(|_| idx));
956
957 let rel = rel_idx.and_then(|idx| model.relationships.get(idx));
958 let role = rel.map(|r| r.role_a.clone()).unwrap_or_default();
959
960 let (base_start_marker, base_end_marker, stroke_dasharray) = if let Some(rel) = rel {
961 let card_a = rel
962 .rel_spec
963 .get("cardA")
964 .and_then(Value::as_str)
965 .unwrap_or("");
966 let card_b = rel
967 .rel_spec
968 .get("cardB")
969 .and_then(Value::as_str)
970 .unwrap_or("");
971 let rel_type = rel
972 .rel_spec
973 .get("relType")
974 .and_then(Value::as_str)
975 .unwrap_or("");
976 let start_marker = er_marker_id(card_b, "START");
977 let end_marker = er_marker_id(card_a, "END");
978 let stroke_dasharray = if rel_type == "NON_IDENTIFYING" {
979 Some("8,8".to_string())
980 } else {
981 None
982 };
983 (start_marker, end_marker, stroke_dasharray)
984 } else {
985 (None, None, None)
986 };
987
988 if !is_er_self_loop_dummy_node_id(&key.v) && !is_er_self_loop_dummy_node_id(&key.w) {
989 if let (Some(from_rect), Some(to_rect)) = (
990 node_rect_by_id.get(&key.v).copied(),
991 node_rect_by_id.get(&key.w).copied(),
992 ) {
993 clip_edge_endpoints(&mut points, from_rect, to_rect);
994 }
995 }
996
997 let (start_marker, end_marker) =
998 if is_er_self_loop_dummy_node_id(&key.v) && is_er_self_loop_dummy_node_id(&key.w) {
999 (None, None)
1000 } else if id.ends_with("-cyclic-0") {
1001 (base_start_marker, None)
1002 } else if id.ends_with("-cyclic-2") {
1003 (None, base_end_marker)
1004 } else {
1005 (base_start_marker, base_end_marker)
1006 };
1007
1008 let label =
1009 if role.trim().is_empty() || id.ends_with("-cyclic-0") || id.ends_with("-cyclic-2") {
1010 None
1011 } else {
1012 let (w, h) = edge_label_metrics(&role, measurer, &rel_label_style, rel_html_labels);
1013 let (x, y) =
1016 e.x.zip(e.y)
1017 .or_else(|| calc_label_position(&points))
1018 .unwrap_or((0.0, 0.0));
1019 Some(LayoutLabel {
1020 x,
1021 y,
1022 width: w.max(1.0),
1023 height: h.max(1.0),
1024 })
1025 };
1026
1027 edges.push(LayoutEdgeParts {
1028 id,
1029 from: key.v.clone(),
1030 to: key.w.clone(),
1031 points,
1032 label,
1033 start_marker,
1034 end_marker,
1035 stroke_dasharray,
1036 });
1037 }
1038 edges.sort_by(|a, b| a.id.cmp(&b.id));
1039
1040 let mut out_edges: Vec<LayoutEdge> = Vec::new();
1041 for e in edges {
1042 out_edges.push(LayoutEdge {
1043 id: e.id,
1044 from: e.from,
1045 to: e.to,
1046 from_cluster: None,
1047 to_cluster: None,
1048 points: e.points,
1049 label: e.label,
1050 start_label_left: None,
1051 start_label_right: None,
1052 end_label_left: None,
1053 end_label_right: None,
1054 start_marker: e.start_marker,
1055 end_marker: e.end_marker,
1056 stroke_dasharray: e.stroke_dasharray,
1057 });
1058 }
1059
1060 let bounds = {
1061 let mut points: Vec<(f64, f64)> = Vec::new();
1062 for n in &nodes {
1063 let hw = n.width / 2.0;
1064 let hh = n.height / 2.0;
1065 points.push((n.x - hw, n.y - hh));
1066 points.push((n.x + hw, n.y + hh));
1067 }
1068 for e in &out_edges {
1069 for p in &e.points {
1070 points.push((p.x, p.y));
1071 }
1072 if let Some(l) = &e.label {
1073 let hw = l.width / 2.0;
1074 let hh = l.height / 2.0;
1075 points.push((l.x - hw, l.y - hh));
1076 points.push((l.x + hw, l.y + hh));
1077 }
1078 }
1079 Bounds::from_points(points)
1080 };
1081
1082 Ok(ErDiagramLayout {
1083 nodes,
1084 edges: out_edges,
1085 bounds,
1086 })
1087}
1088
1089#[cfg(test)]
1090mod tests {
1091 use serde_json::json;
1092
1093 #[test]
1094 fn er_drawrect_clamp_overrides_are_generated() {
1095 assert_eq!(
1096 crate::generated::er_text_overrides_11_12_2::
1097 lookup_entity_drawrect_clamp_to_min_entity_width("DRIVER"),
1098 Some(false)
1099 );
1100 assert_eq!(
1101 crate::generated::er_text_overrides_11_12_2::
1102 lookup_entity_drawrect_clamp_to_min_entity_width("UNKNOWN"),
1103 None
1104 );
1105 }
1106
1107 #[test]
1108 fn er_relationship_htmllabels_follow_root_then_flowchart_config() {
1109 assert!(super::er_relationship_html_labels(&json!({})));
1110 assert!(super::er_relationship_html_labels(&json!({
1111 "flowchart": { "htmlLabels": true }
1112 })));
1113 assert!(!super::er_relationship_html_labels(&json!({
1114 "flowchart": { "htmlLabels": false }
1115 })));
1116 assert!(super::er_relationship_html_labels(&json!({
1117 "htmlLabels": true,
1118 "flowchart": { "htmlLabels": false }
1119 })));
1120 assert!(!super::er_relationship_html_labels(&json!({
1121 "htmlLabels": false,
1122 "flowchart": { "htmlLabels": true }
1123 })));
1124 }
1125}