Skip to main content

merman_render/
er.rs

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    // Mermaid `parseGenericTypes` turns `Foo~T~` into `Foo<T>` for display.
132    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    // Mermaid `calculateTextWidth` uses SVG `drawSimpleText(...).getBBox().width` and rounds to
196    // integers. It probes both `sans-serif` and the configured `fontFamily`, but typically
197    // takes the larger width to avoid underestimation when the configured family cannot render
198    // in the current user agent.
199    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    // Our headless SVG bbox approximation uses a power-of-two grid internally. Nudge by half of a
221    // 1/256px step to avoid systematic round-down at the `.5` boundary that can affect
222    // `minEntityWidth` clamping in Mermaid's `erBox.ts` `drawRect` branch.
223    (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    // Mermaid ER unified renderer inherits the root SVG font-size, so `themeVariables.fontSize`
229    // wins when present (including Mermaid's common `"NNpx"` form).
230    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    // Mermaid measures ER attribute table text via HTML labels (`foreignObject`) and browser font
276    // metrics. Our headless measurer is an approximation; keep the math as close as possible to
277    // upstream and avoid introducing arbitrary scaling factors.
278    const ATTR_TEXT_WIDTH_SCALE: f64 = 1.0;
279
280    // Mermaid's ER renderer (erBox.ts) uses `config.htmlLabels` inconsistently:
281    // - It passes `useHtmlLabels: config.htmlLabels` into `createText`, where `undefined`
282    //   effectively behaves as `true` due to JS default parameters.
283    // - It uses `if (!config.htmlLabels) { PADDING *= 1.25; TEXT_PADDING *= 1.25; }`, where
284    //   `undefined` behaves as `false` and triggers the multiplier even when HTML labels are used.
285    //
286    // Upstream SVG fixtures at Mermaid@11.12.2 reflect this quirk. The padding multiplier still
287    // keys off the raw truthiness (`undefined` behaves like `false`) even though the rendered labels
288    // use HTML `<foreignObject>` output by default.
289    let html_labels_raw = config_bool(effective_config, &["htmlLabels"]).unwrap_or(false);
290
291    // Mermaid ER unified shape (`erBox.ts`) uses:
292    // - PADDING = config.er.diagramPadding (default 20 in Mermaid 11.12.2 schema defaults)
293    // - TEXT_PADDING = config.er.entityPadding (default 15)
294    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    // No attributes: use `drawRect`-like padding rules from Mermaid erBox.ts.
316    if entity.attributes.is_empty() {
317        let label_pad_x = padding;
318        let label_pad_y = padding * 1.5;
319        // Mermaid's `drawRect` branch clamps to `minEntityWidth` based on `calculateTextWidth()`,
320        // not on the HTML label bbox. Preserve that quirk: upstream can end up with nodes that are
321        // narrower than `minEntityWidth` when `calculateTextWidth()` is larger than the HTML bbox
322        // used by `drawRect`.
323        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    // Mermaid erBox.ts only applies the `* 1.25` multiplier after the "drawRect" early-return.
356    // Keep that behavior: nodes without an attribute table should *not* inherit the multiplier.
357    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    // Mermaid adds extra padding to attribute components to accommodate the entity name width.
452    // Mermaid uses the HTML label bbox (`getBoundingClientRect`) as `nameBBox.width`.
453    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    // Mermaid ER relationship labels follow Mermaid's effective HTML-label resolution:
530    // root `htmlLabels` first, then `flowchart.htmlLabels`, then default `true`.
531    // - HTML mode uses the generic HTML edge-label path (`foreignObject`, line-height 1.5)
532    // - SVG mode uses `createFormattedText(...)` (`<text>/<tspan>`, line-height 1.1)
533    // Markdown emphasis is tokenized in both branches before the final DOM shape is emitted.
534    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    // Mermaid's dagre renderer creates self-loop helper nodes using `${nodeId}---${nodeId}---{1|2}`.
568    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    // Mermaid ER relationship labels follow `getEffectiveHtmlLabels(config)` from
581    // `rendering-elements/edges.js`:
582    // - root `htmlLabels` wins when explicitly set
583    // - otherwise `flowchart.htmlLabels` is used
584    // - otherwise the default is `true`
585    //
586    // This intentionally differs from the entity padding quirk, which keys off raw
587    // `!config.htmlLabels` in `erBox.ts`.
588    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        // Mermaid CLI ER output does not emit a dedicated MD_PARENT marker.
716        "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        // Mermaid ER relationship labels stay at a fixed 14px in the emitted stylesheet.
741        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        // Mermaid's dagre adapter always enables `compound: true` (even if there are no clusters).
750        // This also makes the ranker behavior match upstream for disconnected ER graphs.
751        compound: true,
752    });
753    g.set_graph(GraphLabel {
754        rankdir: dir,
755        nodesep,
756        ranksep,
757        // Dagre's default `acyclicer` is "greedy" (Mermaid relies on this default).
758        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    // Nodes.
768    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    // Edges. Mermaid ER uses edge labels ("roleA") and the unified renderer routes through the
789    // generic dagre pipeline, which accounts for label bbox in spacing. Mirror that by giving
790    // dagre real label sizes here.
791    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        // Mermaid's dagre renderer splits self-loops into three edges and introduces two helper
802        // nodes (labelRect). Mermaid initializes them at 10x10, but after `updateNodeBounds(...)`
803        // an empty labelRect collapses to ~0.1x0.1 and that is what Dagre uses for spacing.
804        // Match that here for layout parity.
805        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            // First segment: keep start marker, no label.
838            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            // Mid segment: carries the relationship label, no markers.
854            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            // Last segment: keep end marker, no label.
870            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                // Mermaid uses Dagre's computed edge label center (`edge.x/edge.y`) rather than a
1014                // polyline midpoint. Prefer those coordinates when present.
1015                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}