Skip to main content

merman_render/
sequence.rs

1#![allow(clippy::too_many_arguments)]
2
3use crate::model::{
4    Bounds, LayoutCluster, LayoutEdge, LayoutLabel, LayoutNode, LayoutPoint, SequenceDiagramLayout,
5};
6use crate::text::{
7    TextMeasurer, TextStyle, WrapMode, split_html_br_lines, wrap_label_like_mermaid_lines,
8    wrap_label_like_mermaid_lines_floored_bbox,
9};
10use crate::{Error, Result};
11use serde::Deserialize;
12use serde_json::Value;
13
14#[derive(Debug, Clone, Deserialize)]
15struct SequenceActor {
16    #[allow(dead_code)]
17    name: String,
18    description: String,
19    #[serde(rename = "type")]
20    actor_type: String,
21    #[allow(dead_code)]
22    wrap: bool,
23}
24
25#[derive(Debug, Clone, Deserialize)]
26struct SequenceMessage {
27    id: String,
28    #[serde(default)]
29    from: Option<String>,
30    #[serde(default)]
31    to: Option<String>,
32    #[serde(rename = "type")]
33    message_type: i32,
34    message: Value,
35    #[allow(dead_code)]
36    wrap: bool,
37    activate: bool,
38    #[serde(default)]
39    placement: Option<i32>,
40}
41
42#[derive(Debug, Clone, Deserialize)]
43struct SequenceBox {
44    #[serde(rename = "actorKeys")]
45    actor_keys: Vec<String>,
46    #[allow(dead_code)]
47    fill: String,
48    name: Option<String>,
49    #[allow(dead_code)]
50    wrap: bool,
51}
52
53#[derive(Debug, Clone, Deserialize)]
54struct SequenceModel {
55    #[serde(rename = "actorOrder")]
56    actor_order: Vec<String>,
57    actors: std::collections::BTreeMap<String, SequenceActor>,
58    #[serde(default)]
59    boxes: Vec<SequenceBox>,
60    messages: Vec<SequenceMessage>,
61    title: Option<String>,
62    #[serde(rename = "createdActors", default)]
63    created_actors: std::collections::BTreeMap<String, usize>,
64    #[serde(rename = "destroyedActors", default)]
65    destroyed_actors: std::collections::BTreeMap<String, usize>,
66}
67
68fn config_f64(cfg: &Value, path: &[&str]) -> Option<f64> {
69    let mut cur = cfg;
70    for key in path {
71        cur = cur.get(*key)?;
72    }
73    cur.as_f64()
74        .or_else(|| cur.as_i64().map(|n| n as f64))
75        .or_else(|| cur.as_u64().map(|n| n as f64))
76}
77
78fn config_string(cfg: &Value, path: &[&str]) -> Option<String> {
79    let mut cur = cfg;
80    for key in path {
81        cur = cur.get(*key)?;
82    }
83    cur.as_str().map(|s| s.to_string())
84}
85
86fn measure_svg_like_with_html_br(
87    measurer: &dyn TextMeasurer,
88    text: &str,
89    style: &TextStyle,
90) -> (f64, f64) {
91    let lines = split_html_br_lines(text);
92    let default_line_height = (style.font_size.max(1.0) * 1.1).max(1.0);
93    if lines.len() <= 1 {
94        let metrics = measurer.measure_wrapped(text, style, None, WrapMode::SvgLikeSingleRun);
95        let h = if metrics.height > 0.0 {
96            metrics.height
97        } else {
98            default_line_height
99        };
100        return (metrics.width.max(0.0), h.max(0.0));
101    }
102    let mut max_w: f64 = 0.0;
103    let mut line_h: f64 = 0.0;
104    for line in &lines {
105        let metrics = measurer.measure_wrapped(line, style, None, WrapMode::SvgLikeSingleRun);
106        max_w = max_w.max(metrics.width.max(0.0));
107        let h = if metrics.height > 0.0 {
108            metrics.height
109        } else {
110            default_line_height
111        };
112        line_h = line_h.max(h.max(0.0));
113    }
114    (
115        max_w,
116        (line_h * lines.len() as f64).max(default_line_height),
117    )
118}
119
120// Mermaid wraps Sequence notes via `utils.wrapLabel(...)`, which relies on DOM `getBBox()` probes.
121// Our vendored text metrics are deterministic but can be slightly more conservative for some
122// phrases, changing wrap breakpoints and note heights. Give note wrapping a small amount of
123// horizontal slack so greedy line breaks match the upstream Mermaid SVG baselines (11.12.3).
124const SEQUENCE_NOTE_WRAP_SLACK_PX: f64 = 12.0;
125
126fn sequence_actor_visual_height(
127    actor_type: &str,
128    base_width: f64,
129    base_height: f64,
130    label_box_height: f64,
131) -> f64 {
132    match actor_type {
133        // Mermaid (11.12.2) derives these from the actor-type glyph bbox + label box height.
134        // These heights are used by the footer actor rendering and affect the final SVG viewBox.
135        "boundary" => (60.0 + label_box_height).max(1.0),
136        // Mermaid's database actor updates the actor height after the top render.
137        // The footer render uses that updated height: ≈ width/4 + labelBoxHeight.
138        "database" => ((base_width / 4.0) + label_box_height).max(1.0),
139        "entity" => (36.0 + label_box_height).max(1.0),
140        // Control uses an extra label-box height in Mermaid.
141        "control" => (36.0 + 2.0 * label_box_height).max(1.0),
142        _ => base_height.max(1.0),
143    }
144}
145
146fn sequence_actor_lifeline_start_y(
147    actor_type: &str,
148    base_height: f64,
149    box_text_margin: f64,
150) -> f64 {
151    match actor_type {
152        // Hard-coded in Mermaid's sequence svgDraw.js for these actor types.
153        "actor" | "boundary" => 80.0,
154        "control" | "entity" => 75.0,
155        // For database, Mermaid starts the lifeline slightly below the actor box.
156        "database" => base_height + 2.0 * box_text_margin,
157        _ => base_height,
158    }
159}
160
161pub fn layout_sequence_diagram(
162    semantic: &Value,
163    effective_config: &Value,
164    measurer: &dyn TextMeasurer,
165) -> Result<SequenceDiagramLayout> {
166    let model: SequenceModel = crate::json::from_value_ref(semantic)?;
167
168    let seq_cfg = effective_config.get("sequence").unwrap_or(&Value::Null);
169    let diagram_margin_x = config_f64(seq_cfg, &["diagramMarginX"]).unwrap_or(50.0);
170    let diagram_margin_y = config_f64(seq_cfg, &["diagramMarginY"]).unwrap_or(10.0);
171    let bottom_margin_adj = config_f64(seq_cfg, &["bottomMarginAdj"]).unwrap_or(1.0);
172    let box_margin = config_f64(seq_cfg, &["boxMargin"]).unwrap_or(10.0);
173    let actor_margin = config_f64(seq_cfg, &["actorMargin"]).unwrap_or(50.0);
174    let actor_width_min = config_f64(seq_cfg, &["width"]).unwrap_or(150.0);
175    let actor_height = config_f64(seq_cfg, &["height"]).unwrap_or(65.0);
176    let message_margin = config_f64(seq_cfg, &["messageMargin"]).unwrap_or(35.0);
177    let wrap_padding = config_f64(seq_cfg, &["wrapPadding"]).unwrap_or(10.0);
178    let box_text_margin = config_f64(seq_cfg, &["boxTextMargin"]).unwrap_or(5.0);
179    let label_box_height = config_f64(seq_cfg, &["labelBoxHeight"]).unwrap_or(20.0);
180    let mirror_actors = seq_cfg
181        .get("mirrorActors")
182        .and_then(|v| v.as_bool())
183        .unwrap_or(true);
184
185    // Mermaid's `sequenceRenderer.setConf(...)` overrides per-sequence font settings whenever the
186    // global `fontFamily` / `fontSize` / `fontWeight` are present (defaults are always present).
187    let global_font_family = config_string(effective_config, &["fontFamily"]);
188    let global_font_size = config_f64(effective_config, &["fontSize"]);
189    let global_font_weight = config_string(effective_config, &["fontWeight"]);
190
191    let message_font_family = global_font_family
192        .clone()
193        .or_else(|| config_string(seq_cfg, &["messageFontFamily"]));
194    let message_font_size = global_font_size
195        .or_else(|| config_f64(seq_cfg, &["messageFontSize"]))
196        .unwrap_or(16.0);
197    let message_font_weight = global_font_weight
198        .clone()
199        .or_else(|| config_string(seq_cfg, &["messageFontWeight"]));
200
201    let actor_font_family = global_font_family
202        .clone()
203        .or_else(|| config_string(seq_cfg, &["actorFontFamily"]));
204    let actor_font_size = global_font_size
205        .or_else(|| config_f64(seq_cfg, &["actorFontSize"]))
206        .unwrap_or(16.0);
207    let actor_font_weight = global_font_weight
208        .clone()
209        .or_else(|| config_string(seq_cfg, &["actorFontWeight"]));
210
211    // Upstream sequence uses `calculateTextDimensions(...).width` (SVG `getBBox`) when computing
212    // message widths for spacing. Keep this scale at 1.0 and handle any residual differences via
213    // the SVG-backed `TextMeasurer` implementation.
214    let message_width_scale = 1.0;
215
216    let actor_text_style = TextStyle {
217        font_family: actor_font_family,
218        font_size: actor_font_size,
219        font_weight: actor_font_weight,
220    };
221    let note_font_family = global_font_family
222        .clone()
223        .or_else(|| config_string(seq_cfg, &["noteFontFamily"]));
224    let note_font_size = global_font_size
225        .or_else(|| config_f64(seq_cfg, &["noteFontSize"]))
226        .unwrap_or(16.0);
227    let note_font_weight = global_font_weight
228        .clone()
229        .or_else(|| config_string(seq_cfg, &["noteFontWeight"]));
230    let note_text_style = TextStyle {
231        font_family: note_font_family,
232        font_size: note_font_size,
233        font_weight: note_font_weight,
234    };
235    let msg_text_style = TextStyle {
236        font_family: message_font_family,
237        font_size: message_font_size,
238        font_weight: message_font_weight,
239    };
240
241    let has_boxes = !model.boxes.is_empty();
242    let has_box_titles = model
243        .boxes
244        .iter()
245        .any(|b| b.name.as_deref().is_some_and(|s| !s.trim().is_empty()));
246
247    // Mermaid uses `utils.calculateTextDimensions(...).height` for box titles and stores the max
248    // across boxes in `box.textMaxHeight` (used for bumping actor `starty` when any title exists).
249    //
250    // In Mermaid 11.12.2 with 16px fonts, this height comes out as 17px (not the larger SVG
251    // `getBBox()` height used elsewhere). Keep this model-level constant to match upstream DOM.
252    fn mermaid_text_dimensions_height_px(font_size: f64) -> f64 {
253        // 16px -> 17px in upstream.
254        (font_size.max(1.0) * (17.0 / 16.0)).max(1.0)
255    }
256
257    let max_box_title_height = if has_box_titles {
258        let line_h = mermaid_text_dimensions_height_px(message_font_size);
259        model
260            .boxes
261            .iter()
262            .filter_map(|b| b.name.as_deref())
263            .map(|s| split_html_br_lines(s).len().max(1) as f64 * line_h)
264            .fold(0.0, f64::max)
265    } else {
266        0.0
267    };
268
269    if model.actor_order.is_empty() {
270        return Err(Error::InvalidModel {
271            message: "sequence model has no actorOrder".to_string(),
272        });
273    }
274
275    // Measure participant boxes.
276    let mut actor_widths: Vec<f64> = Vec::with_capacity(model.actor_order.len());
277    let mut actor_base_heights: Vec<f64> = Vec::with_capacity(model.actor_order.len());
278    for id in &model.actor_order {
279        let a = model.actors.get(id).ok_or_else(|| Error::InvalidModel {
280            message: format!("missing actor {id}"),
281        })?;
282        if a.wrap {
283            // Upstream wraps actor descriptions to `conf.width - 2*wrapPadding` and clamps the
284            // actor box width to `conf.width`.
285            let wrap_w = (actor_width_min - 2.0 * wrap_padding).max(1.0);
286            let wrapped_lines =
287                wrap_label_like_mermaid_lines(&a.description, measurer, &actor_text_style, wrap_w);
288            let line_count = wrapped_lines.len().max(1) as f64;
289            let text_h = mermaid_text_dimensions_height_px(actor_font_size) * line_count;
290            actor_base_heights.push(actor_height.max(text_h).max(1.0));
291            actor_widths.push(actor_width_min.max(1.0));
292        } else {
293            let (w0, _h0) =
294                measure_svg_like_with_html_br(measurer, &a.description, &actor_text_style);
295            let w = (w0 + 2.0 * wrap_padding).max(actor_width_min);
296            actor_base_heights.push(actor_height.max(1.0));
297            actor_widths.push(w.max(1.0));
298        }
299    }
300
301    // Determine the per-actor margins using Mermaid's `getMaxMessageWidthPerActor(...)` rules,
302    // then compute actor x positions from those margins (see upstream `boundActorData`).
303    let mut actor_index: std::collections::HashMap<&str, usize> = std::collections::HashMap::new();
304    for (i, id) in model.actor_order.iter().enumerate() {
305        actor_index.insert(id.as_str(), i);
306    }
307
308    let mut actor_to_message_width: Vec<f64> = vec![0.0; model.actor_order.len()];
309    for msg in &model.messages {
310        let (Some(from), Some(to)) = (msg.from.as_deref(), msg.to.as_deref()) else {
311            continue;
312        };
313        let Some(&from_idx) = actor_index.get(from) else {
314            continue;
315        };
316        let Some(&to_idx) = actor_index.get(to) else {
317            continue;
318        };
319
320        let placement = msg.placement;
321        // If this is the first actor, and the note is left of it, no need to calculate the margin.
322        if placement == Some(0) && to_idx == 0 {
323            continue;
324        }
325        // If this is the last actor, and the note is right of it, no need to calculate the margin.
326        if placement == Some(1) && to_idx + 1 == model.actor_order.len() {
327            continue;
328        }
329
330        let is_note = placement.is_some();
331        let is_message = !is_note;
332        let style = if is_note {
333            &note_text_style
334        } else {
335            &msg_text_style
336        };
337        let text = msg.message.as_str().unwrap_or_default();
338        if text.is_empty() {
339            continue;
340        }
341
342        let measured_text = if msg.wrap {
343            // Upstream uses `wrapLabel(message, conf.width - 2*wrapPadding, ...)` when computing
344            // max per-actor message widths for spacing.
345            let wrap_w = (actor_width_min - 2.0 * wrap_padding).max(1.0);
346            let lines = wrap_label_like_mermaid_lines(text, measurer, style, wrap_w);
347            lines.join("<br>")
348        } else {
349            text.to_string()
350        };
351        let (w0, _h0) = measure_svg_like_with_html_br(measurer, &measured_text, style);
352        let w0 = w0 * message_width_scale;
353        let message_w = (w0 + 2.0 * wrap_padding).max(0.0);
354
355        let prev_idx = if to_idx > 0 { Some(to_idx - 1) } else { None };
356        let next_idx = if to_idx + 1 < model.actor_order.len() {
357            Some(to_idx + 1)
358        } else {
359            None
360        };
361
362        if is_message && next_idx.is_some_and(|n| n == from_idx) {
363            actor_to_message_width[to_idx] = actor_to_message_width[to_idx].max(message_w);
364        } else if is_message && prev_idx.is_some_and(|p| p == from_idx) {
365            actor_to_message_width[from_idx] = actor_to_message_width[from_idx].max(message_w);
366        } else if is_message && from_idx == to_idx {
367            let half = message_w / 2.0;
368            actor_to_message_width[from_idx] = actor_to_message_width[from_idx].max(half);
369            actor_to_message_width[to_idx] = actor_to_message_width[to_idx].max(half);
370        } else if placement == Some(1) {
371            // RIGHTOF
372            actor_to_message_width[from_idx] = actor_to_message_width[from_idx].max(message_w);
373        } else if placement == Some(0) {
374            // LEFTOF
375            if let Some(p) = prev_idx {
376                actor_to_message_width[p] = actor_to_message_width[p].max(message_w);
377            }
378        } else if placement == Some(2) {
379            // OVER
380            if let Some(p) = prev_idx {
381                actor_to_message_width[p] = actor_to_message_width[p].max(message_w / 2.0);
382            }
383            if next_idx.is_some() {
384                actor_to_message_width[from_idx] =
385                    actor_to_message_width[from_idx].max(message_w / 2.0);
386            }
387        }
388    }
389
390    let mut actor_margins: Vec<f64> = vec![actor_margin; model.actor_order.len()];
391    for i in 0..model.actor_order.len() {
392        let msg_w = actor_to_message_width[i];
393        if msg_w <= 0.0 {
394            continue;
395        }
396        let w0 = actor_widths[i];
397        let actor_w = if i + 1 < model.actor_order.len() {
398            let w1 = actor_widths[i + 1];
399            msg_w + actor_margin - (w0 / 2.0) - (w1 / 2.0)
400        } else {
401            msg_w + actor_margin - (w0 / 2.0)
402        };
403        actor_margins[i] = actor_w.max(actor_margin);
404    }
405
406    // Mermaid's `calculateActorMargins(...)` computes per-box `box.margin` based on total actor
407    // widths/margins and the box title width. For totalWidth, Mermaid only counts `actor.margin`
408    // if it was set (actors without messages have `margin === undefined` until render-time).
409    let mut box_margins: Vec<f64> = vec![box_text_margin; model.boxes.len()];
410    for (box_idx, b) in model.boxes.iter().enumerate() {
411        let mut total_width = 0.0;
412        for actor_key in &b.actor_keys {
413            let Some(&i) = actor_index.get(actor_key.as_str()) else {
414                continue;
415            };
416            let actor_margin_for_box = if actor_to_message_width[i] > 0.0 {
417                actor_margins[i]
418            } else {
419                0.0
420            };
421            total_width += actor_widths[i] + actor_margin_for_box;
422        }
423
424        total_width += box_margin * 8.0;
425        total_width -= 2.0 * box_text_margin;
426
427        let Some(name) = b.name.as_deref().filter(|s| !s.trim().is_empty()) else {
428            continue;
429        };
430
431        let (text_w, _text_h) = measure_svg_like_with_html_br(measurer, name, &msg_text_style);
432        let min_width = total_width.max(text_w + 2.0 * wrap_padding);
433        if total_width < min_width {
434            box_margins[box_idx] += (min_width - total_width) / 2.0;
435        }
436    }
437
438    // Actors start lower when boxes exist, to make room for box headers.
439    let mut actor_top_offset_y = 0.0;
440    if has_boxes {
441        actor_top_offset_y += box_margin;
442        if has_box_titles {
443            actor_top_offset_y += max_box_title_height;
444        }
445    }
446
447    // Assign each actor to at most one box (Mermaid's db assigns a single `actor.box` reference).
448    let mut actor_box: Vec<Option<usize>> = vec![None; model.actor_order.len()];
449    for (box_idx, b) in model.boxes.iter().enumerate() {
450        for actor_key in &b.actor_keys {
451            let Some(&i) = actor_index.get(actor_key.as_str()) else {
452                continue;
453            };
454            actor_box[i] = Some(box_idx);
455        }
456    }
457
458    let mut actor_left_x: Vec<f64> = Vec::with_capacity(model.actor_order.len());
459    let mut prev_width = 0.0;
460    let mut prev_margin = 0.0;
461    let mut prev_box: Option<usize> = None;
462    for i in 0..model.actor_order.len() {
463        let w = actor_widths[i];
464        let cur_box = actor_box[i];
465
466        // end of box
467        if prev_box.is_some() && prev_box != cur_box {
468            if let Some(prev) = prev_box {
469                prev_margin += box_margin + box_margins[prev];
470            }
471        }
472
473        // new box
474        if cur_box.is_some() && cur_box != prev_box {
475            if let Some(bi) = cur_box {
476                prev_margin += box_margins[bi];
477            }
478        }
479
480        // Mermaid widens the margin before a created actor by `actor.width / 2`.
481        if model.created_actors.contains_key(&model.actor_order[i]) {
482            prev_margin += w / 2.0;
483        }
484        let x = prev_width + prev_margin;
485        actor_left_x.push(x);
486        prev_width += w + prev_margin;
487        prev_margin = actor_margins[i];
488        prev_box = cur_box;
489    }
490
491    let mut actor_centers_x: Vec<f64> = Vec::with_capacity(model.actor_order.len());
492    for i in 0..model.actor_order.len() {
493        actor_centers_x.push(actor_left_x[i] + actor_widths[i] / 2.0);
494    }
495
496    let message_step = message_margin + (message_font_size / 2.0) + bottom_margin_adj;
497    let msg_label_offset = (message_step - message_font_size) + bottom_margin_adj;
498
499    let mut edges: Vec<LayoutEdge> = Vec::new();
500    let mut nodes: Vec<LayoutNode> = Vec::new();
501    let clusters: Vec<LayoutCluster> = Vec::new();
502
503    // Actor boxes: Mermaid renders both a "top" and "bottom" actor box.
504    // The bottom boxes start after all messages are placed. Created actors will have their `y`
505    // adjusted later once we know the creation message position.
506    let mut max_actor_visual_height: f64 = 0.0;
507    for (idx, id) in model.actor_order.iter().enumerate() {
508        let w = actor_widths[idx];
509        let cx = actor_centers_x[idx];
510        let base_h = actor_base_heights[idx];
511        let actor_type = model
512            .actors
513            .get(id)
514            .map(|a| a.actor_type.as_str())
515            .unwrap_or("participant");
516        let visual_h = sequence_actor_visual_height(actor_type, w, base_h, label_box_height);
517        max_actor_visual_height = max_actor_visual_height.max(visual_h.max(1.0));
518        let top_y = actor_top_offset_y + visual_h / 2.0;
519        nodes.push(LayoutNode {
520            id: format!("actor-top-{id}"),
521            x: cx,
522            y: top_y,
523            width: w,
524            height: visual_h,
525            is_cluster: false,
526            label_width: None,
527            label_height: None,
528        });
529    }
530
531    // Message edges.
532
533    fn bracketize(s: &str) -> String {
534        let t = s.trim();
535        if t.is_empty() {
536            return "\u{200B}".to_string();
537        }
538        if t.starts_with('[') && t.ends_with(']') {
539            return t.to_string();
540        }
541        format!("[{t}]")
542    }
543
544    fn block_label_text(raw_label: &str) -> String {
545        bracketize(raw_label)
546    }
547
548    // Mermaid advances the "cursor" for sequence blocks (loop/alt/opt/par/break/critical) even
549    // though these directives are not message edges. The cursor increment depends on the wrapped
550    // block label height; precompute these increments per directive message id.
551    // `adjustLoopHeightForWrap(...)` advances the Mermaid bounds cursor by:
552    // - `preMargin` (either `boxMargin` or `boxMargin + boxTextMargin`)
553    // - plus `heightAdjust`, where `heightAdjust` is:
554    //   - `postMargin` when the block label is empty
555    //   - `postMargin + max(labelTextHeight, labelBoxHeight)` when the label is present
556    //
557    // For the common 1-line label case, this reduces to:
558    //   preMargin + postMargin + labelBoxHeight
559    //
560    // We model this as a base step and subtract `labelBoxHeight` for empty labels.
561    let block_base_step = (2.0 * box_margin + box_text_margin + label_box_height).max(0.0);
562    let block_base_step_empty = (block_base_step - label_box_height).max(0.0);
563    let line_step = message_font_size * 1.1875;
564    let block_extra_per_line = (line_step - box_text_margin).max(0.0);
565    let block_end_step = 10.0;
566
567    let mut msg_by_id: std::collections::HashMap<&str, &SequenceMessage> =
568        std::collections::HashMap::new();
569    for msg in &model.messages {
570        msg_by_id.insert(msg.id.as_str(), msg);
571    }
572
573    fn is_self_message_id(
574        msg_id: &str,
575        msg_by_id: &std::collections::HashMap<&str, &SequenceMessage>,
576    ) -> bool {
577        let Some(msg) = msg_by_id.get(msg_id).copied() else {
578            return false;
579        };
580        // Notes can use `from==to` for `rightOf`/`leftOf`; do not treat them as self-messages.
581        if msg.message_type == 2 {
582            return false;
583        }
584        msg.from
585            .as_deref()
586            .is_some_and(|from| Some(from) == msg.to.as_deref())
587    }
588
589    fn message_span_x(
590        msg: &SequenceMessage,
591        actor_index: &std::collections::HashMap<&str, usize>,
592        actor_centers_x: &[f64],
593        measurer: &dyn TextMeasurer,
594        msg_text_style: &TextStyle,
595        message_width_scale: f64,
596    ) -> Option<(f64, f64)> {
597        let (Some(from), Some(to)) = (msg.from.as_deref(), msg.to.as_deref()) else {
598            return None;
599        };
600        let (Some(fi), Some(ti)) = (actor_index.get(from).copied(), actor_index.get(to).copied())
601        else {
602            return None;
603        };
604        let from_x = actor_centers_x[fi];
605        let to_x = actor_centers_x[ti];
606        let sign = if to_x >= from_x { 1.0 } else { -1.0 };
607        let x1 = from_x + sign * 1.0;
608        let x2 = if from == to { x1 } else { to_x - sign * 4.0 };
609        let cx = (x1 + x2) / 2.0;
610
611        let text = msg.message.as_str().unwrap_or_default();
612        let w = if text.is_empty() {
613            1.0
614        } else {
615            let (w, _h) = measure_svg_like_with_html_br(measurer, text, msg_text_style);
616            (w * message_width_scale).max(1.0)
617        };
618        Some((cx - w / 2.0, cx + w / 2.0))
619    }
620
621    fn block_frame_width(
622        message_ids: &[String],
623        msg_by_id: &std::collections::HashMap<&str, &SequenceMessage>,
624        actor_index: &std::collections::HashMap<&str, usize>,
625        actor_centers_x: &[f64],
626        actor_widths: &[f64],
627        message_margin: f64,
628        box_text_margin: f64,
629        bottom_margin_adj: f64,
630        measurer: &dyn TextMeasurer,
631        msg_text_style: &TextStyle,
632        message_width_scale: f64,
633    ) -> Option<f64> {
634        let mut actor_idxs: Vec<usize> = Vec::new();
635        for msg_id in message_ids {
636            let Some(msg) = msg_by_id.get(msg_id.as_str()).copied() else {
637                continue;
638            };
639            let (Some(from), Some(to)) = (msg.from.as_deref(), msg.to.as_deref()) else {
640                continue;
641            };
642            if let Some(i) = actor_index.get(from).copied() {
643                actor_idxs.push(i);
644            }
645            if let Some(i) = actor_index.get(to).copied() {
646                actor_idxs.push(i);
647            }
648        }
649        actor_idxs.sort();
650        actor_idxs.dedup();
651        if actor_idxs.is_empty() {
652            return None;
653        }
654
655        if actor_idxs.len() == 1 {
656            let i = actor_idxs[0];
657            let actor_w = actor_widths.get(i).copied().unwrap_or(150.0);
658            let half_width =
659                actor_w / 2.0 + (message_margin / 2.0) + box_text_margin + bottom_margin_adj;
660            let w = (2.0 * half_width).max(1.0);
661            return Some(w);
662        }
663
664        let min_i = actor_idxs.first().copied()?;
665        let max_i = actor_idxs.last().copied()?;
666        let mut x1 = actor_centers_x[min_i] - 11.0;
667        let mut x2 = actor_centers_x[max_i] + 11.0;
668
669        // Expand multi-actor blocks to include overflowing message labels (e.g. long self messages).
670        for msg_id in message_ids {
671            let Some(msg) = msg_by_id.get(msg_id.as_str()).copied() else {
672                continue;
673            };
674            let Some((l, r)) = message_span_x(
675                msg,
676                actor_index,
677                actor_centers_x,
678                measurer,
679                msg_text_style,
680                message_width_scale,
681            ) else {
682                continue;
683            };
684            if l < x1 {
685                x1 = l.floor();
686            }
687            if r > x2 {
688                x2 = r.ceil();
689            }
690        }
691
692        Some((x2 - x1).max(1.0))
693    }
694
695    #[derive(Debug, Clone)]
696    enum BlockStackEntry {
697        Loop {
698            start_id: String,
699            raw_label: String,
700            messages: Vec<String>,
701        },
702        Opt {
703            start_id: String,
704            raw_label: String,
705            messages: Vec<String>,
706        },
707        Break {
708            start_id: String,
709            raw_label: String,
710            messages: Vec<String>,
711        },
712        Alt {
713            section_directives: Vec<(String, String)>,
714            sections: Vec<Vec<String>>,
715        },
716        Par {
717            section_directives: Vec<(String, String)>,
718            sections: Vec<Vec<String>>,
719        },
720        Critical {
721            section_directives: Vec<(String, String)>,
722            sections: Vec<Vec<String>>,
723        },
724    }
725
726    let mut directive_steps: std::collections::HashMap<String, f64> =
727        std::collections::HashMap::new();
728    let mut stack: Vec<BlockStackEntry> = Vec::new();
729    for msg in &model.messages {
730        let raw_label = msg.message.as_str().unwrap_or_default();
731        match msg.message_type {
732            // loop start/end
733            10 => stack.push(BlockStackEntry::Loop {
734                start_id: msg.id.clone(),
735                raw_label: raw_label.to_string(),
736                messages: Vec::new(),
737            }),
738            11 => {
739                if let Some(BlockStackEntry::Loop {
740                    start_id,
741                    raw_label,
742                    messages,
743                }) = stack.pop()
744                {
745                    let loop_has_self_message = messages
746                        .iter()
747                        .any(|msg_id| is_self_message_id(msg_id.as_str(), &msg_by_id));
748                    let loop_end_step = if loop_has_self_message {
749                        40.0
750                    } else {
751                        block_end_step
752                    };
753
754                    if raw_label.trim().is_empty() {
755                        directive_steps.insert(start_id, block_base_step_empty);
756                    } else if let Some(w) = block_frame_width(
757                        &messages,
758                        &msg_by_id,
759                        &actor_index,
760                        &actor_centers_x,
761                        &actor_widths,
762                        message_margin,
763                        box_text_margin,
764                        bottom_margin_adj,
765                        measurer,
766                        &msg_text_style,
767                        message_width_scale,
768                    ) {
769                        let label = block_label_text(&raw_label);
770                        let metrics = measurer.measure_wrapped(
771                            &label,
772                            &msg_text_style,
773                            Some(w),
774                            WrapMode::SvgLikeSingleRun,
775                        );
776                        let extra =
777                            (metrics.line_count.saturating_sub(1) as f64) * block_extra_per_line;
778                        directive_steps.insert(start_id, block_base_step + extra);
779                    } else {
780                        directive_steps.insert(start_id, block_base_step);
781                    }
782
783                    directive_steps.insert(msg.id.clone(), loop_end_step);
784                }
785            }
786            // opt start/end
787            15 => stack.push(BlockStackEntry::Opt {
788                start_id: msg.id.clone(),
789                raw_label: raw_label.to_string(),
790                messages: Vec::new(),
791            }),
792            16 => {
793                let mut end_step = block_end_step;
794                if let Some(BlockStackEntry::Opt {
795                    start_id,
796                    raw_label,
797                    messages,
798                }) = stack.pop()
799                {
800                    let has_self = messages
801                        .iter()
802                        .any(|msg_id| is_self_message_id(msg_id.as_str(), &msg_by_id));
803                    end_step = if has_self { 40.0 } else { block_end_step };
804                    if raw_label.trim().is_empty() {
805                        directive_steps.insert(start_id, block_base_step_empty);
806                    } else if let Some(w) = block_frame_width(
807                        &messages,
808                        &msg_by_id,
809                        &actor_index,
810                        &actor_centers_x,
811                        &actor_widths,
812                        message_margin,
813                        box_text_margin,
814                        bottom_margin_adj,
815                        measurer,
816                        &msg_text_style,
817                        message_width_scale,
818                    ) {
819                        let label = block_label_text(&raw_label);
820                        let metrics = measurer.measure_wrapped(
821                            &label,
822                            &msg_text_style,
823                            Some(w),
824                            WrapMode::SvgLikeSingleRun,
825                        );
826                        let extra =
827                            (metrics.line_count.saturating_sub(1) as f64) * block_extra_per_line;
828                        directive_steps.insert(start_id, block_base_step + extra);
829                    } else {
830                        directive_steps.insert(start_id, block_base_step);
831                    }
832                }
833                directive_steps.insert(msg.id.clone(), end_step);
834            }
835            // break start/end
836            30 => stack.push(BlockStackEntry::Break {
837                start_id: msg.id.clone(),
838                raw_label: raw_label.to_string(),
839                messages: Vec::new(),
840            }),
841            31 => {
842                let mut end_step = block_end_step;
843                if let Some(BlockStackEntry::Break {
844                    start_id,
845                    raw_label,
846                    messages,
847                }) = stack.pop()
848                {
849                    let has_self = messages
850                        .iter()
851                        .any(|msg_id| is_self_message_id(msg_id.as_str(), &msg_by_id));
852                    end_step = if has_self { 40.0 } else { block_end_step };
853                    if raw_label.trim().is_empty() {
854                        directive_steps.insert(start_id, block_base_step_empty);
855                    } else if let Some(w) = block_frame_width(
856                        &messages,
857                        &msg_by_id,
858                        &actor_index,
859                        &actor_centers_x,
860                        &actor_widths,
861                        message_margin,
862                        box_text_margin,
863                        bottom_margin_adj,
864                        measurer,
865                        &msg_text_style,
866                        message_width_scale,
867                    ) {
868                        let label = block_label_text(&raw_label);
869                        let metrics = measurer.measure_wrapped(
870                            &label,
871                            &msg_text_style,
872                            Some(w),
873                            WrapMode::SvgLikeSingleRun,
874                        );
875                        let extra =
876                            (metrics.line_count.saturating_sub(1) as f64) * block_extra_per_line;
877                        directive_steps.insert(start_id, block_base_step + extra);
878                    } else {
879                        directive_steps.insert(start_id, block_base_step);
880                    }
881                }
882                directive_steps.insert(msg.id.clone(), end_step);
883            }
884            // alt start/else/end
885            12 => stack.push(BlockStackEntry::Alt {
886                section_directives: vec![(msg.id.clone(), raw_label.to_string())],
887                sections: vec![Vec::new()],
888            }),
889            13 => {
890                if let Some(BlockStackEntry::Alt {
891                    section_directives,
892                    sections,
893                }) = stack.last_mut()
894                {
895                    section_directives.push((msg.id.clone(), raw_label.to_string()));
896                    sections.push(Vec::new());
897                }
898            }
899            14 => {
900                let mut end_step = block_end_step;
901                if let Some(BlockStackEntry::Alt {
902                    section_directives,
903                    sections,
904                }) = stack.pop()
905                {
906                    let has_self = sections
907                        .iter()
908                        .flatten()
909                        .any(|msg_id| is_self_message_id(msg_id.as_str(), &msg_by_id));
910                    end_step = if has_self { 40.0 } else { block_end_step };
911                    let mut message_ids: Vec<String> = Vec::new();
912                    for sec in &sections {
913                        message_ids.extend(sec.iter().cloned());
914                    }
915                    if let Some(w) = block_frame_width(
916                        &message_ids,
917                        &msg_by_id,
918                        &actor_index,
919                        &actor_centers_x,
920                        &actor_widths,
921                        message_margin,
922                        box_text_margin,
923                        bottom_margin_adj,
924                        measurer,
925                        &msg_text_style,
926                        message_width_scale,
927                    ) {
928                        for (idx, (id, raw)) in section_directives.into_iter().enumerate() {
929                            let is_empty = raw.trim().is_empty();
930                            if is_empty {
931                                directive_steps.insert(id, block_base_step_empty);
932                                continue;
933                            }
934                            let _ = idx;
935                            let label = block_label_text(&raw);
936                            let metrics = measurer.measure_wrapped(
937                                &label,
938                                &msg_text_style,
939                                Some(w),
940                                WrapMode::SvgLikeSingleRun,
941                            );
942                            let extra = (metrics.line_count.saturating_sub(1) as f64)
943                                * block_extra_per_line;
944                            directive_steps.insert(id, block_base_step + extra);
945                        }
946                    } else {
947                        for (id, raw) in section_directives {
948                            let step = if raw.trim().is_empty() {
949                                block_base_step_empty
950                            } else {
951                                block_base_step
952                            };
953                            directive_steps.insert(id, step);
954                        }
955                    }
956                }
957                directive_steps.insert(msg.id.clone(), end_step);
958            }
959            // par start/and/end
960            19 | 32 => stack.push(BlockStackEntry::Par {
961                section_directives: vec![(msg.id.clone(), raw_label.to_string())],
962                sections: vec![Vec::new()],
963            }),
964            20 => {
965                if let Some(BlockStackEntry::Par {
966                    section_directives,
967                    sections,
968                }) = stack.last_mut()
969                {
970                    section_directives.push((msg.id.clone(), raw_label.to_string()));
971                    sections.push(Vec::new());
972                }
973            }
974            21 => {
975                let mut end_step = block_end_step;
976                if let Some(BlockStackEntry::Par {
977                    section_directives,
978                    sections,
979                }) = stack.pop()
980                {
981                    let has_self = sections
982                        .iter()
983                        .flatten()
984                        .any(|msg_id| is_self_message_id(msg_id.as_str(), &msg_by_id));
985                    end_step = if has_self { 40.0 } else { block_end_step };
986                    let mut message_ids: Vec<String> = Vec::new();
987                    for sec in &sections {
988                        message_ids.extend(sec.iter().cloned());
989                    }
990                    if let Some(w) = block_frame_width(
991                        &message_ids,
992                        &msg_by_id,
993                        &actor_index,
994                        &actor_centers_x,
995                        &actor_widths,
996                        message_margin,
997                        box_text_margin,
998                        bottom_margin_adj,
999                        measurer,
1000                        &msg_text_style,
1001                        message_width_scale,
1002                    ) {
1003                        for (idx, (id, raw)) in section_directives.into_iter().enumerate() {
1004                            let is_empty = raw.trim().is_empty();
1005                            if is_empty {
1006                                directive_steps.insert(id, block_base_step_empty);
1007                                continue;
1008                            }
1009                            let _ = idx;
1010                            let label = block_label_text(&raw);
1011                            let metrics = measurer.measure_wrapped(
1012                                &label,
1013                                &msg_text_style,
1014                                Some(w),
1015                                WrapMode::SvgLikeSingleRun,
1016                            );
1017                            let extra = (metrics.line_count.saturating_sub(1) as f64)
1018                                * block_extra_per_line;
1019                            directive_steps.insert(id, block_base_step + extra);
1020                        }
1021                    } else {
1022                        for (id, raw) in section_directives {
1023                            let step = if raw.trim().is_empty() {
1024                                block_base_step_empty
1025                            } else {
1026                                block_base_step
1027                            };
1028                            directive_steps.insert(id, step);
1029                        }
1030                    }
1031                }
1032                directive_steps.insert(msg.id.clone(), end_step);
1033            }
1034            // critical start/option/end
1035            27 => stack.push(BlockStackEntry::Critical {
1036                section_directives: vec![(msg.id.clone(), raw_label.to_string())],
1037                sections: vec![Vec::new()],
1038            }),
1039            28 => {
1040                if let Some(BlockStackEntry::Critical {
1041                    section_directives,
1042                    sections,
1043                }) = stack.last_mut()
1044                {
1045                    section_directives.push((msg.id.clone(), raw_label.to_string()));
1046                    sections.push(Vec::new());
1047                }
1048            }
1049            29 => {
1050                let mut end_step = block_end_step;
1051                if let Some(BlockStackEntry::Critical {
1052                    section_directives,
1053                    sections,
1054                }) = stack.pop()
1055                {
1056                    let has_self = sections
1057                        .iter()
1058                        .flatten()
1059                        .any(|msg_id| is_self_message_id(msg_id.as_str(), &msg_by_id));
1060                    end_step = if has_self { 40.0 } else { block_end_step };
1061                    let mut message_ids: Vec<String> = Vec::new();
1062                    for sec in &sections {
1063                        message_ids.extend(sec.iter().cloned());
1064                    }
1065                    if let Some(w) = block_frame_width(
1066                        &message_ids,
1067                        &msg_by_id,
1068                        &actor_index,
1069                        &actor_centers_x,
1070                        &actor_widths,
1071                        message_margin,
1072                        box_text_margin,
1073                        bottom_margin_adj,
1074                        measurer,
1075                        &msg_text_style,
1076                        message_width_scale,
1077                    ) {
1078                        for (idx, (id, raw)) in section_directives.into_iter().enumerate() {
1079                            let is_empty = raw.trim().is_empty();
1080                            if is_empty {
1081                                directive_steps.insert(id, block_base_step_empty);
1082                                continue;
1083                            }
1084                            let _ = idx;
1085                            let label = block_label_text(&raw);
1086                            let metrics = measurer.measure_wrapped(
1087                                &label,
1088                                &msg_text_style,
1089                                Some(w),
1090                                WrapMode::SvgLikeSingleRun,
1091                            );
1092                            let extra = (metrics.line_count.saturating_sub(1) as f64)
1093                                * block_extra_per_line;
1094                            directive_steps.insert(id, block_base_step + extra);
1095                        }
1096                    } else {
1097                        for (id, raw) in section_directives {
1098                            let step = if raw.trim().is_empty() {
1099                                block_base_step_empty
1100                            } else {
1101                                block_base_step
1102                            };
1103                            directive_steps.insert(id, step);
1104                        }
1105                    }
1106                }
1107                directive_steps.insert(msg.id.clone(), end_step);
1108            }
1109            _ => {
1110                // If this is a "real" message edge, attach it to all active block scopes so block
1111                // width computations can account for overflowing message labels.
1112                if msg.from.is_some() && msg.to.is_some() {
1113                    for entry in stack.iter_mut() {
1114                        match entry {
1115                            BlockStackEntry::Alt { sections, .. }
1116                            | BlockStackEntry::Par { sections, .. }
1117                            | BlockStackEntry::Critical { sections, .. } => {
1118                                if let Some(cur) = sections.last_mut() {
1119                                    cur.push(msg.id.clone());
1120                                }
1121                            }
1122                            BlockStackEntry::Loop { messages, .. }
1123                            | BlockStackEntry::Opt { messages, .. }
1124                            | BlockStackEntry::Break { messages, .. } => {
1125                                messages.push(msg.id.clone());
1126                            }
1127                        }
1128                    }
1129                }
1130            }
1131        }
1132    }
1133
1134    #[derive(Debug, Clone)]
1135    struct RectOpen {
1136        start_id: String,
1137        top_y: f64,
1138        bounds: Option<merman_core::geom::Box2>,
1139    }
1140
1141    impl RectOpen {
1142        fn include_min_max(&mut self, min_x: f64, max_x: f64, max_y: f64) {
1143            let r = merman_core::geom::Box2::from_min_max(min_x, self.top_y, max_x, max_y);
1144            if let Some(ref mut cur) = self.bounds {
1145                cur.union(r);
1146            } else {
1147                self.bounds = Some(r);
1148            }
1149        }
1150    }
1151
1152    // Mermaid's sequence renderer advances a "cursor" even for non-message directives (notes,
1153    // rect blocks). To avoid overlapping bottom actors and to match upstream viewBox sizes, we
1154    // model these increments in headless layout as well.
1155    let note_width_single = actor_width_min;
1156    let rect_step_start = 20.0;
1157    let rect_step_end = 10.0;
1158    let note_gap = 10.0;
1159    // Mermaid note boxes use 10px vertical padding on both sides (20px total), on top of the
1160    // SVG `getBBox().height` of the note text.
1161    let note_text_pad_total = 20.0;
1162    let note_top_offset = message_step - note_gap;
1163
1164    let mut cursor_y = actor_top_offset_y + max_actor_visual_height + message_step;
1165    let mut rect_stack: Vec<RectOpen> = Vec::new();
1166    let activation_width = config_f64(seq_cfg, &["activationWidth"])
1167        .unwrap_or(10.0)
1168        .max(1.0);
1169    let mut activation_stacks: std::collections::BTreeMap<&str, Vec<f64>> =
1170        std::collections::BTreeMap::new();
1171
1172    // Mermaid adjusts created/destroyed actors while processing messages:
1173    // - created actor: `starty = lineStartY - actor.height/2`
1174    // - destroyed actor: `stopy = lineStartY - actor.height/2`
1175    // It also bumps the cursor by `actor.height/2` to avoid overlaps.
1176    let mut created_actor_top_center_y: std::collections::BTreeMap<String, f64> =
1177        std::collections::BTreeMap::new();
1178    let mut destroyed_actor_bottom_top_y: std::collections::BTreeMap<String, f64> =
1179        std::collections::BTreeMap::new();
1180
1181    let actor_visual_height_for_id = |actor_id: &str| -> f64 {
1182        let Some(idx) = actor_index.get(actor_id).copied() else {
1183            return actor_height.max(1.0);
1184        };
1185        let w = actor_widths.get(idx).copied().unwrap_or(actor_width_min);
1186        let base_h = actor_base_heights.get(idx).copied().unwrap_or(actor_height);
1187        model
1188            .actors
1189            .get(actor_id)
1190            .map(|a| a.actor_type.as_str())
1191            .map(|t| sequence_actor_visual_height(t, w, base_h, label_box_height))
1192            .unwrap_or(base_h.max(1.0))
1193    };
1194    let actor_is_type_width_limited = |actor_id: &str| -> bool {
1195        model
1196            .actors
1197            .get(actor_id)
1198            .map(|a| {
1199                matches!(
1200                    a.actor_type.as_str(),
1201                    "actor" | "control" | "entity" | "database"
1202                )
1203            })
1204            .unwrap_or(false)
1205    };
1206
1207    for (msg_idx, msg) in model.messages.iter().enumerate() {
1208        match msg.message_type {
1209            // ACTIVE_START
1210            17 => {
1211                let Some(actor_id) = msg.from.as_deref() else {
1212                    continue;
1213                };
1214                let Some(&idx) = actor_index.get(actor_id) else {
1215                    continue;
1216                };
1217                let cx = actor_centers_x[idx];
1218                let stack = activation_stacks.entry(actor_id).or_default();
1219                let stacked_size = stack.len();
1220                let startx = cx + (((stacked_size as f64) - 1.0) * activation_width) / 2.0;
1221                stack.push(startx);
1222                continue;
1223            }
1224            // ACTIVE_END
1225            18 => {
1226                let Some(actor_id) = msg.from.as_deref() else {
1227                    continue;
1228                };
1229                if let Some(stack) = activation_stacks.get_mut(actor_id) {
1230                    let _ = stack.pop();
1231                }
1232                continue;
1233            }
1234            _ => {}
1235        }
1236
1237        if let Some(step) = directive_steps.get(msg.id.as_str()).copied() {
1238            cursor_y += step;
1239            continue;
1240        }
1241        match msg.message_type {
1242            // rect start: advances cursor but draws later as a background `<rect>`.
1243            22 => {
1244                rect_stack.push(RectOpen {
1245                    start_id: msg.id.clone(),
1246                    top_y: cursor_y - note_top_offset,
1247                    bounds: None,
1248                });
1249                cursor_y += rect_step_start;
1250                continue;
1251            }
1252            // rect end
1253            23 => {
1254                if let Some(open) = rect_stack.pop() {
1255                    let rect_left = open.bounds.map(|b| b.min_x()).unwrap_or_else(|| {
1256                        actor_centers_x
1257                            .iter()
1258                            .copied()
1259                            .fold(f64::INFINITY, f64::min)
1260                            - 11.0
1261                    });
1262                    let rect_right = open.bounds.map(|b| b.max_x()).unwrap_or_else(|| {
1263                        actor_centers_x
1264                            .iter()
1265                            .copied()
1266                            .fold(f64::NEG_INFINITY, f64::max)
1267                            + 11.0
1268                    });
1269                    let rect_bottom = open
1270                        .bounds
1271                        .map(|b| b.max_y() + 10.0)
1272                        .unwrap_or(open.top_y + 10.0);
1273                    let rect_w = (rect_right - rect_left).max(1.0);
1274                    let rect_h = (rect_bottom - open.top_y).max(1.0);
1275
1276                    nodes.push(LayoutNode {
1277                        id: format!("rect-{}", open.start_id),
1278                        x: rect_left + rect_w / 2.0,
1279                        y: open.top_y + rect_h / 2.0,
1280                        width: rect_w,
1281                        height: rect_h,
1282                        is_cluster: false,
1283                        label_width: None,
1284                        label_height: None,
1285                    });
1286
1287                    if let Some(parent) = rect_stack.last_mut() {
1288                        parent.include_min_max(rect_left - 10.0, rect_right + 10.0, rect_bottom);
1289                    }
1290                }
1291                cursor_y += rect_step_end;
1292                continue;
1293            }
1294            _ => {}
1295        }
1296
1297        // Notes (type=2) are laid out as nodes, not message edges.
1298        if msg.message_type == 2 {
1299            let (Some(from), Some(to)) = (msg.from.as_deref(), msg.to.as_deref()) else {
1300                continue;
1301            };
1302            let (Some(fi), Some(ti)) =
1303                (actor_index.get(from).copied(), actor_index.get(to).copied())
1304            else {
1305                continue;
1306            };
1307            let fx = actor_centers_x[fi];
1308            let tx = actor_centers_x[ti];
1309
1310            let placement = msg.placement.unwrap_or(2);
1311            let (mut note_x, mut note_w) = match placement {
1312                // leftOf
1313                0 => (fx - 25.0 - note_width_single, note_width_single),
1314                // rightOf
1315                1 => (fx + 25.0, note_width_single),
1316                // over
1317                _ => {
1318                    if (fx - tx).abs() < 0.0001 {
1319                        // Mermaid's `buildNoteModel(...)` widens "over self" notes when `wrap: true`:
1320                        //   noteModel.width = max(conf.width, fromActor.width)
1321                        //
1322                        // This is observable in upstream SVG baselines for participants with
1323                        // type-driven widths (e.g. `queue`), where the note box matches the actor
1324                        // width rather than the configured default `conf.width`.
1325                        let mut w = note_width_single;
1326                        if msg.wrap {
1327                            w = w.max(actor_widths.get(fi).copied().unwrap_or(note_width_single));
1328                        }
1329                        (fx - (w / 2.0), w)
1330                    } else {
1331                        let left = fx.min(tx) - 25.0;
1332                        let right = fx.max(tx) + 25.0;
1333                        let w = (right - left).max(note_width_single);
1334                        (left, w)
1335                    }
1336                }
1337            };
1338
1339            let text = msg.message.as_str().unwrap_or_default();
1340            let (text_w, h) = if msg.wrap {
1341                // Mermaid Sequence notes are wrapped via `wrapLabel(...)`, then measured via SVG
1342                // bbox probes (not HTML wrapping). Model this by producing wrapped `<br/>` lines
1343                // and then measuring them.
1344                //
1345                // Important: Mermaid widens *leftOf* wrapped notes based on the initially wrapped
1346                // text width (+ margins) before re-wrapping to the final width. This affects the
1347                // final wrap width and thus the rendered line breaks.
1348                let w0 = {
1349                    let init_lines = wrap_label_like_mermaid_lines_floored_bbox(
1350                        text,
1351                        measurer,
1352                        &note_text_style,
1353                        (note_width_single + SEQUENCE_NOTE_WRAP_SLACK_PX).max(1.0),
1354                    );
1355                    let init_wrapped = init_lines.join("<br/>");
1356                    let (w, _h) =
1357                        measure_svg_like_with_html_br(measurer, &init_wrapped, &note_text_style);
1358                    w.max(0.0)
1359                };
1360
1361                if placement == 0 {
1362                    // Mermaid (LEFTOF + wrap): `noteModel.width = max(conf.width, textWidth + 2*noteMargin)`.
1363                    // Our note padding total is `2*noteMargin`/`2*wrapPadding` in the default config.
1364                    note_w = note_w.max((w0 + note_text_pad_total).round().max(1.0));
1365                    note_x = fx - 25.0 - note_w;
1366                }
1367
1368                let wrap_w = (note_w - note_text_pad_total).max(1.0);
1369                let lines = wrap_label_like_mermaid_lines_floored_bbox(
1370                    text,
1371                    measurer,
1372                    &note_text_style,
1373                    (wrap_w + SEQUENCE_NOTE_WRAP_SLACK_PX).max(1.0),
1374                );
1375                let wrapped = lines.join("<br/>");
1376                let (w, h) = measure_svg_like_with_html_br(measurer, &wrapped, &note_text_style);
1377                (w.max(0.0), h.max(0.0))
1378            } else {
1379                measure_svg_like_with_html_br(measurer, text, &note_text_style)
1380            };
1381
1382            // Mermaid's `buildNoteModel(...)` widens the note box when the text would overflow the
1383            // configured default width. This is observable in strict SVG XML baselines when the
1384            // note contains literal `<br ...>` markup that is *not* treated as a line break.
1385            let padded_w = (text_w + note_text_pad_total).round().max(1.0);
1386            if !msg.wrap {
1387                match placement {
1388                    // leftOf / rightOf notes clamp width to fit label text.
1389                    0 | 1 => {
1390                        note_w = note_w.max(padded_w);
1391                    }
1392                    // over: only clamp when the note is over a single actor (`from == to`).
1393                    _ => {
1394                        if (fx - tx).abs() < 0.0001 {
1395                            note_w = note_w.max(padded_w);
1396                        }
1397                    }
1398                }
1399            }
1400            let note_h = (h + note_text_pad_total).round().max(1.0);
1401            let note_y = (cursor_y - note_top_offset).round();
1402
1403            nodes.push(LayoutNode {
1404                id: format!("note-{}", msg.id),
1405                x: note_x + note_w / 2.0,
1406                y: note_y + note_h / 2.0,
1407                width: note_w.max(1.0),
1408                height: note_h,
1409                is_cluster: false,
1410                label_width: None,
1411                label_height: None,
1412            });
1413
1414            for open in rect_stack.iter_mut() {
1415                open.include_min_max(note_x - 10.0, note_x + note_w + 10.0, note_y + note_h);
1416            }
1417
1418            cursor_y += note_h + note_gap;
1419            continue;
1420        }
1421
1422        // Regular message edges.
1423        let (Some(from), Some(to)) = (msg.from.as_deref(), msg.to.as_deref()) else {
1424            continue;
1425        };
1426        let (Some(fi), Some(ti)) = (actor_index.get(from).copied(), actor_index.get(to).copied())
1427        else {
1428            continue;
1429        };
1430        let from_x = actor_centers_x[fi];
1431        let to_x = actor_centers_x[ti];
1432
1433        let (from_left, from_right) = activation_stacks
1434            .get(from)
1435            .and_then(|s| s.last().copied())
1436            .map(|startx| (startx, startx + activation_width))
1437            .unwrap_or((from_x - 1.0, from_x + 1.0));
1438
1439        let (to_left, to_right) = activation_stacks
1440            .get(to)
1441            .and_then(|s| s.last().copied())
1442            .map(|startx| (startx, startx + activation_width))
1443            .unwrap_or((to_x - 1.0, to_x + 1.0));
1444
1445        let is_arrow_to_right = from_left <= to_left;
1446        let mut startx = if is_arrow_to_right {
1447            from_right
1448        } else {
1449            from_left
1450        };
1451        let mut stopx = if is_arrow_to_right { to_left } else { to_right };
1452
1453        let adjust_value = |v: f64| if is_arrow_to_right { -v } else { v };
1454        let is_arrow_to_activation = (to_left - to_right).abs() > 2.0;
1455
1456        let is_self = from == to;
1457        if is_self {
1458            stopx = startx;
1459        } else {
1460            if msg.activate && !is_arrow_to_activation {
1461                stopx += adjust_value(activation_width / 2.0 - 1.0);
1462            }
1463
1464            if !matches!(msg.message_type, 5 | 6) {
1465                stopx += adjust_value(3.0);
1466            }
1467
1468            if matches!(msg.message_type, 33 | 34) {
1469                startx -= adjust_value(3.0);
1470            }
1471        }
1472
1473        if !is_self {
1474            // Mermaid adjusts creating/destroying messages so arrowheads land outside the actor box.
1475            const ACTOR_TYPE_WIDTH_HALF: f64 = 18.0;
1476            if model
1477                .created_actors
1478                .get(to)
1479                .is_some_and(|&idx| idx == msg_idx)
1480            {
1481                let adjustment = if actor_is_type_width_limited(to) {
1482                    ACTOR_TYPE_WIDTH_HALF + 3.0
1483                } else {
1484                    actor_widths[ti] / 2.0 + 3.0
1485                };
1486                if to_x < from_x {
1487                    stopx += adjustment;
1488                } else {
1489                    stopx -= adjustment;
1490                }
1491            } else if model
1492                .destroyed_actors
1493                .get(from)
1494                .is_some_and(|&idx| idx == msg_idx)
1495            {
1496                let adjustment = if actor_is_type_width_limited(from) {
1497                    ACTOR_TYPE_WIDTH_HALF
1498                } else {
1499                    actor_widths[fi] / 2.0
1500                };
1501                if from_x < to_x {
1502                    startx += adjustment;
1503                } else {
1504                    startx -= adjustment;
1505                }
1506            } else if model
1507                .destroyed_actors
1508                .get(to)
1509                .is_some_and(|&idx| idx == msg_idx)
1510            {
1511                let adjustment = if actor_is_type_width_limited(to) {
1512                    ACTOR_TYPE_WIDTH_HALF + 3.0
1513                } else {
1514                    actor_widths[ti] / 2.0 + 3.0
1515                };
1516                if to_x < from_x {
1517                    stopx += adjustment;
1518                } else {
1519                    stopx -= adjustment;
1520                }
1521            }
1522        }
1523
1524        let text = msg.message.as_str().unwrap_or_default();
1525        let bounded_width = (startx - stopx).abs().max(0.0);
1526        let wrapped_text = if !text.is_empty() && msg.wrap {
1527            // Upstream wraps message labels to `max(boundedWidth + 2*wrapPadding, conf.width)`.
1528            // Note: a small extra margin helps keep wrap breakpoints aligned with upstream SVG
1529            // baselines for long sentences under our vendored metrics.
1530            let wrap_w = (bounded_width + 3.0 * wrap_padding)
1531                .max(actor_width_min)
1532                .max(1.0);
1533            let lines =
1534                wrap_label_like_mermaid_lines_floored_bbox(text, measurer, &msg_text_style, wrap_w);
1535            Some(lines.join("<br>"))
1536        } else {
1537            None
1538        };
1539        let effective_text = wrapped_text.as_deref().unwrap_or(text);
1540
1541        let (line_y, label_base_y, cursor_step) = if effective_text.is_empty() {
1542            // Mermaid's `boundMessage(...)` uses the measured text bbox height. For empty labels
1543            // (trailing colon `Alice->Bob:`) the bbox height becomes 0, collapsing the extra
1544            // vertical offset and producing a much earlier message line.
1545            //
1546            // Our cursor model uses `message_step` (a typical 1-line height) as the baseline.
1547            // Shift the line up and only advance by `boxMargin` to match the upstream footer actor
1548            // placement and overall viewBox height.
1549            let line_y = cursor_y - (message_step - box_margin);
1550            (line_y, cursor_y, box_margin)
1551        } else {
1552            // Mermaid's `boundMessage(...)` uses `common.splitBreaks(message)` to derive a
1553            // `lines` count and adjusts the message line y-position and cursor increment by the
1554            // per-line height. This applies both to explicit `<br>` breaks and to `wrap: true`
1555            // labels (which are wrapped via `wrapLabel(...)` and stored with `<br/>` separators).
1556            let lines = split_html_br_lines(effective_text).len().max(1);
1557            // Mermaid's `calculateTextDimensions(...).height` is consistently ~2px smaller per
1558            // line than the rendered `drawText(...)` getBBox, so use a bbox-like per-line height
1559            // for the cursor math here.
1560            let bbox_line_h = (message_font_size + bottom_margin_adj).max(0.0);
1561            let extra = (lines.saturating_sub(1) as f64) * bbox_line_h;
1562            (cursor_y + extra, cursor_y, message_step + extra)
1563        };
1564
1565        let x1 = startx;
1566        let x2 = stopx;
1567
1568        let label = if effective_text.is_empty() {
1569            // Mermaid renders an (empty) message text node even when the label is empty (e.g.
1570            // trailing colon `Alice->Bob:`). Keep a placeholder label to preserve DOM structure.
1571            Some(LayoutLabel {
1572                x: ((x1 + x2) / 2.0).round(),
1573                y: (label_base_y - msg_label_offset).round(),
1574                width: 1.0,
1575                height: message_font_size.max(1.0),
1576            })
1577        } else {
1578            let (w, h) = measure_svg_like_with_html_br(measurer, effective_text, &msg_text_style);
1579            Some(LayoutLabel {
1580                x: ((x1 + x2) / 2.0).round(),
1581                y: (label_base_y - msg_label_offset).round(),
1582                width: (w * message_width_scale).max(1.0),
1583                height: h.max(1.0),
1584            })
1585        };
1586
1587        edges.push(LayoutEdge {
1588            id: format!("msg-{}", msg.id),
1589            from: from.to_string(),
1590            to: to.to_string(),
1591            from_cluster: None,
1592            to_cluster: None,
1593            points: vec![
1594                LayoutPoint { x: x1, y: line_y },
1595                LayoutPoint { x: x2, y: line_y },
1596            ],
1597            label,
1598            start_label_left: None,
1599            start_label_right: None,
1600            end_label_left: None,
1601            end_label_right: None,
1602            start_marker: None,
1603            end_marker: None,
1604            stroke_dasharray: None,
1605        });
1606
1607        for open in rect_stack.iter_mut() {
1608            let lx = from_x.min(to_x) - 11.0;
1609            let rx = from_x.max(to_x) + 11.0;
1610            open.include_min_max(lx, rx, line_y);
1611        }
1612
1613        cursor_y += cursor_step;
1614        if is_self {
1615            // Mermaid adds extra vertical space for self-messages to accommodate the loop curve.
1616            cursor_y += 30.0;
1617        }
1618
1619        // Apply Mermaid's created/destroyed actor y adjustments and spacing bumps.
1620        if model
1621            .created_actors
1622            .get(to)
1623            .is_some_and(|&idx| idx == msg_idx)
1624        {
1625            let h = actor_visual_height_for_id(to);
1626            created_actor_top_center_y.insert(to.to_string(), line_y);
1627            cursor_y += h / 2.0;
1628        } else if model
1629            .destroyed_actors
1630            .get(from)
1631            .is_some_and(|&idx| idx == msg_idx)
1632        {
1633            let h = actor_visual_height_for_id(from);
1634            destroyed_actor_bottom_top_y.insert(from.to_string(), line_y - h / 2.0);
1635            cursor_y += h / 2.0;
1636        } else if model
1637            .destroyed_actors
1638            .get(to)
1639            .is_some_and(|&idx| idx == msg_idx)
1640        {
1641            let h = actor_visual_height_for_id(to);
1642            destroyed_actor_bottom_top_y.insert(to.to_string(), line_y - h / 2.0);
1643            cursor_y += h / 2.0;
1644        }
1645    }
1646
1647    let bottom_margin = message_margin - message_font_size + bottom_margin_adj;
1648    let bottom_box_top_y = (cursor_y - message_step) + bottom_margin;
1649
1650    // Apply created-actor `starty` overrides now that we know the creation message y.
1651    for n in nodes.iter_mut() {
1652        let Some(actor_id) = n.id.strip_prefix("actor-top-") else {
1653            continue;
1654        };
1655        if let Some(y) = created_actor_top_center_y.get(actor_id).copied() {
1656            n.y = y;
1657        }
1658    }
1659
1660    for (idx, id) in model.actor_order.iter().enumerate() {
1661        let w = actor_widths[idx];
1662        let cx = actor_centers_x[idx];
1663        let base_h = actor_base_heights[idx];
1664        let actor_type = model
1665            .actors
1666            .get(id)
1667            .map(|a| a.actor_type.as_str())
1668            .unwrap_or("participant");
1669        let visual_h = sequence_actor_visual_height(actor_type, w, base_h, label_box_height);
1670        let bottom_top_y = destroyed_actor_bottom_top_y
1671            .get(id)
1672            .copied()
1673            .unwrap_or(bottom_box_top_y);
1674        let bottom_visual_h = if mirror_actors { visual_h } else { 0.0 };
1675        nodes.push(LayoutNode {
1676            id: format!("actor-bottom-{id}"),
1677            x: cx,
1678            y: bottom_top_y + bottom_visual_h / 2.0,
1679            width: w,
1680            height: bottom_visual_h,
1681            is_cluster: false,
1682            label_width: None,
1683            label_height: None,
1684        });
1685
1686        let top_center_y = created_actor_top_center_y
1687            .get(id)
1688            .copied()
1689            .unwrap_or(actor_top_offset_y + visual_h / 2.0);
1690        let top_left_y = top_center_y - visual_h / 2.0;
1691        let lifeline_start_y =
1692            top_left_y + sequence_actor_lifeline_start_y(actor_type, base_h, box_text_margin);
1693
1694        edges.push(LayoutEdge {
1695            id: format!("lifeline-{id}"),
1696            from: format!("actor-top-{id}"),
1697            to: format!("actor-bottom-{id}"),
1698            from_cluster: None,
1699            to_cluster: None,
1700            points: vec![
1701                LayoutPoint {
1702                    x: cx,
1703                    y: lifeline_start_y,
1704                },
1705                LayoutPoint {
1706                    x: cx,
1707                    y: bottom_top_y,
1708                },
1709            ],
1710            label: None,
1711            start_label_left: None,
1712            start_label_right: None,
1713            end_label_left: None,
1714            end_label_right: None,
1715            start_marker: None,
1716            end_marker: None,
1717            stroke_dasharray: None,
1718        });
1719    }
1720
1721    // Mermaid's SVG `viewBox` is derived from `svg.getBBox()` plus diagram margins. Block frames
1722    // (`alt`, `par`, `loop`, `opt`, `break`, `critical`) can extend beyond the node/edge graph we
1723    // model in headless layout. Capture their extents so we can expand bounds before emitting the
1724    // final `viewBox`.
1725    let block_bounds = {
1726        use std::collections::HashMap;
1727
1728        let nodes_by_id: HashMap<&str, &LayoutNode> = nodes
1729            .iter()
1730            .map(|n| (n.id.as_str(), n))
1731            .collect::<HashMap<_, _>>();
1732        let edges_by_id: HashMap<&str, &LayoutEdge> = edges
1733            .iter()
1734            .map(|e| (e.id.as_str(), e))
1735            .collect::<HashMap<_, _>>();
1736
1737        let mut msg_endpoints: HashMap<&str, (&str, &str)> = HashMap::new();
1738        for msg in &model.messages {
1739            let (Some(from), Some(to)) = (msg.from.as_deref(), msg.to.as_deref()) else {
1740                continue;
1741            };
1742            msg_endpoints.insert(msg.id.as_str(), (from, to));
1743        }
1744
1745        fn item_y_range(
1746            item_id: &str,
1747            nodes_by_id: &HashMap<&str, &LayoutNode>,
1748            edges_by_id: &HashMap<&str, &LayoutEdge>,
1749            msg_endpoints: &HashMap<&str, (&str, &str)>,
1750        ) -> Option<(f64, f64)> {
1751            // Mermaid's self-message branch expands bounds by 60px below the message line y
1752            // coordinate (see the `+ 30 + totalOffset` bottom coordinate, where `totalOffset`
1753            // already includes a `+30` bump).
1754            const SELF_MESSAGE_EXTRA_Y: f64 = 60.0;
1755            let edge_id = format!("msg-{item_id}");
1756            if let Some(e) = edges_by_id.get(edge_id.as_str()).copied() {
1757                let y = e.points.first()?.y;
1758                let extra = msg_endpoints
1759                    .get(item_id)
1760                    .copied()
1761                    .filter(|(from, to)| from == to)
1762                    .map(|_| SELF_MESSAGE_EXTRA_Y)
1763                    .unwrap_or(0.0);
1764                return Some((y, y + extra));
1765            }
1766
1767            let node_id = format!("note-{item_id}");
1768            let n = nodes_by_id.get(node_id.as_str()).copied()?;
1769            let top = n.y - n.height / 2.0;
1770            let bottom = n.y + n.height / 2.0;
1771            Some((top, bottom))
1772        }
1773
1774        fn frame_x_from_item_ids<'a>(
1775            item_ids: impl IntoIterator<Item = &'a String>,
1776            nodes_by_id: &HashMap<&str, &LayoutNode>,
1777            edges_by_id: &HashMap<&str, &LayoutEdge>,
1778            msg_endpoints: &HashMap<&str, (&str, &str)>,
1779        ) -> Option<(f64, f64, f64)> {
1780            const SIDE_PAD: f64 = 11.0;
1781            const GEOM_PAD: f64 = 10.0;
1782            let mut min_cx = f64::INFINITY;
1783            let mut max_cx = f64::NEG_INFINITY;
1784            let mut min_left = f64::INFINITY;
1785            let mut geom_min_x = f64::INFINITY;
1786            let mut geom_max_x = f64::NEG_INFINITY;
1787
1788            for id in item_ids {
1789                // Notes contribute directly via their node bounds.
1790                let note_id = format!("note-{id}");
1791                if let Some(n) = nodes_by_id.get(note_id.as_str()).copied() {
1792                    geom_min_x = geom_min_x.min(n.x - n.width / 2.0 - GEOM_PAD);
1793                    geom_max_x = geom_max_x.max(n.x + n.width / 2.0 + GEOM_PAD);
1794                }
1795
1796                let Some((from, to)) = msg_endpoints.get(id.as_str()).copied() else {
1797                    continue;
1798                };
1799                for actor_id in [from, to] {
1800                    let actor_node_id = format!("actor-top-{actor_id}");
1801                    let Some(n) = nodes_by_id.get(actor_node_id.as_str()).copied() else {
1802                        continue;
1803                    };
1804                    min_cx = min_cx.min(n.x);
1805                    max_cx = max_cx.max(n.x);
1806                    min_left = min_left.min(n.x - n.width / 2.0);
1807                }
1808
1809                // Message edges can overflow via label widths.
1810                let edge_id = format!("msg-{id}");
1811                if let Some(e) = edges_by_id.get(edge_id.as_str()).copied() {
1812                    for p in &e.points {
1813                        geom_min_x = geom_min_x.min(p.x);
1814                        geom_max_x = geom_max_x.max(p.x);
1815                    }
1816                    if let Some(label) = e.label.as_ref() {
1817                        geom_min_x = geom_min_x.min(label.x - (label.width / 2.0) - GEOM_PAD);
1818                        geom_max_x = geom_max_x.max(label.x + (label.width / 2.0) + GEOM_PAD);
1819                    }
1820                }
1821            }
1822
1823            if !min_cx.is_finite() || !max_cx.is_finite() {
1824                return None;
1825            }
1826            let mut x1 = min_cx - SIDE_PAD;
1827            let mut x2 = max_cx + SIDE_PAD;
1828            if geom_min_x.is_finite() {
1829                x1 = x1.min(geom_min_x);
1830            }
1831            if geom_max_x.is_finite() {
1832                x2 = x2.max(geom_max_x);
1833            }
1834            Some((x1, x2, min_left))
1835        }
1836
1837        #[derive(Debug)]
1838        enum BlockStackEntry {
1839            Loop { items: Vec<String> },
1840            Opt { items: Vec<String> },
1841            Break { items: Vec<String> },
1842            Alt { sections: Vec<Vec<String>> },
1843            Par { sections: Vec<Vec<String>> },
1844            Critical { sections: Vec<Vec<String>> },
1845        }
1846
1847        let mut block_min_x = f64::INFINITY;
1848        let mut block_min_y = f64::INFINITY;
1849        let mut block_max_x = f64::NEG_INFINITY;
1850        let mut block_max_y = f64::NEG_INFINITY;
1851
1852        let mut stack: Vec<BlockStackEntry> = Vec::new();
1853        for msg in &model.messages {
1854            let msg_id = msg.id.clone();
1855            match msg.message_type {
1856                10 => stack.push(BlockStackEntry::Loop { items: Vec::new() }),
1857                11 => {
1858                    if let Some(BlockStackEntry::Loop { items }) = stack.pop() {
1859                        if let (Some((x1, x2, _min_left)), Some((y0, y1))) = (
1860                            frame_x_from_item_ids(
1861                                &items,
1862                                &nodes_by_id,
1863                                &edges_by_id,
1864                                &msg_endpoints,
1865                            ),
1866                            items
1867                                .iter()
1868                                .filter_map(|id| {
1869                                    item_y_range(id, &nodes_by_id, &edges_by_id, &msg_endpoints)
1870                                })
1871                                .reduce(|a, b| (a.0.min(b.0), a.1.max(b.1))),
1872                        ) {
1873                            let frame_y1 = y0 - 79.0;
1874                            let frame_y2 = y1 + 10.0;
1875                            block_min_x = block_min_x.min(x1);
1876                            block_max_x = block_max_x.max(x2);
1877                            block_min_y = block_min_y.min(frame_y1);
1878                            block_max_y = block_max_y.max(frame_y2);
1879                        }
1880                    }
1881                }
1882                15 => stack.push(BlockStackEntry::Opt { items: Vec::new() }),
1883                16 => {
1884                    if let Some(BlockStackEntry::Opt { items }) = stack.pop() {
1885                        if let (Some((x1, x2, _min_left)), Some((y0, y1))) = (
1886                            frame_x_from_item_ids(
1887                                &items,
1888                                &nodes_by_id,
1889                                &edges_by_id,
1890                                &msg_endpoints,
1891                            ),
1892                            items
1893                                .iter()
1894                                .filter_map(|id| {
1895                                    item_y_range(id, &nodes_by_id, &edges_by_id, &msg_endpoints)
1896                                })
1897                                .reduce(|a, b| (a.0.min(b.0), a.1.max(b.1))),
1898                        ) {
1899                            let frame_y1 = y0 - 79.0;
1900                            let frame_y2 = y1 + 10.0;
1901                            block_min_x = block_min_x.min(x1);
1902                            block_max_x = block_max_x.max(x2);
1903                            block_min_y = block_min_y.min(frame_y1);
1904                            block_max_y = block_max_y.max(frame_y2);
1905                        }
1906                    }
1907                }
1908                30 => stack.push(BlockStackEntry::Break { items: Vec::new() }),
1909                31 => {
1910                    if let Some(BlockStackEntry::Break { items }) = stack.pop() {
1911                        if let (Some((x1, x2, _min_left)), Some((y0, y1))) = (
1912                            frame_x_from_item_ids(
1913                                &items,
1914                                &nodes_by_id,
1915                                &edges_by_id,
1916                                &msg_endpoints,
1917                            ),
1918                            items
1919                                .iter()
1920                                .filter_map(|id| {
1921                                    item_y_range(id, &nodes_by_id, &edges_by_id, &msg_endpoints)
1922                                })
1923                                .reduce(|a, b| (a.0.min(b.0), a.1.max(b.1))),
1924                        ) {
1925                            let frame_y1 = y0 - 93.0;
1926                            let frame_y2 = y1 + 10.0;
1927                            block_min_x = block_min_x.min(x1);
1928                            block_max_x = block_max_x.max(x2);
1929                            block_min_y = block_min_y.min(frame_y1);
1930                            block_max_y = block_max_y.max(frame_y2);
1931                        }
1932                    }
1933                }
1934                12 => stack.push(BlockStackEntry::Alt {
1935                    sections: vec![Vec::new()],
1936                }),
1937                13 => {
1938                    if let Some(BlockStackEntry::Alt { sections }) = stack.last_mut() {
1939                        sections.push(Vec::new());
1940                    }
1941                }
1942                14 => {
1943                    if let Some(BlockStackEntry::Alt { sections }) = stack.pop() {
1944                        let items: Vec<String> = sections.into_iter().flatten().collect();
1945                        if let (Some((x1, x2, _min_left)), Some((y0, y1))) = (
1946                            frame_x_from_item_ids(
1947                                &items,
1948                                &nodes_by_id,
1949                                &edges_by_id,
1950                                &msg_endpoints,
1951                            ),
1952                            items
1953                                .iter()
1954                                .filter_map(|id| {
1955                                    item_y_range(id, &nodes_by_id, &edges_by_id, &msg_endpoints)
1956                                })
1957                                .reduce(|a, b| (a.0.min(b.0), a.1.max(b.1))),
1958                        ) {
1959                            let frame_y1 = y0 - 79.0;
1960                            let frame_y2 = y1 + 10.0;
1961                            block_min_x = block_min_x.min(x1);
1962                            block_max_x = block_max_x.max(x2);
1963                            block_min_y = block_min_y.min(frame_y1);
1964                            block_max_y = block_max_y.max(frame_y2);
1965                        }
1966                    }
1967                }
1968                19 | 32 => stack.push(BlockStackEntry::Par {
1969                    sections: vec![Vec::new()],
1970                }),
1971                20 => {
1972                    if let Some(BlockStackEntry::Par { sections }) = stack.last_mut() {
1973                        sections.push(Vec::new());
1974                    }
1975                }
1976                21 => {
1977                    if let Some(BlockStackEntry::Par { sections }) = stack.pop() {
1978                        let items: Vec<String> = sections.into_iter().flatten().collect();
1979                        if let (Some((x1, x2, _min_left)), Some((y0, y1))) = (
1980                            frame_x_from_item_ids(
1981                                &items,
1982                                &nodes_by_id,
1983                                &edges_by_id,
1984                                &msg_endpoints,
1985                            ),
1986                            items
1987                                .iter()
1988                                .filter_map(|id| {
1989                                    item_y_range(id, &nodes_by_id, &edges_by_id, &msg_endpoints)
1990                                })
1991                                .reduce(|a, b| (a.0.min(b.0), a.1.max(b.1))),
1992                        ) {
1993                            let frame_y1 = y0 - 79.0;
1994                            let frame_y2 = y1 + 10.0;
1995                            block_min_x = block_min_x.min(x1);
1996                            block_max_x = block_max_x.max(x2);
1997                            block_min_y = block_min_y.min(frame_y1);
1998                            block_max_y = block_max_y.max(frame_y2);
1999                        }
2000                    }
2001                }
2002                27 => stack.push(BlockStackEntry::Critical {
2003                    sections: vec![Vec::new()],
2004                }),
2005                28 => {
2006                    if let Some(BlockStackEntry::Critical { sections }) = stack.last_mut() {
2007                        sections.push(Vec::new());
2008                    }
2009                }
2010                29 => {
2011                    if let Some(BlockStackEntry::Critical { sections }) = stack.pop() {
2012                        let section_count = sections.len();
2013                        let items: Vec<String> = sections.into_iter().flatten().collect();
2014                        if let (Some((mut x1, x2, min_left)), Some((y0, y1))) = (
2015                            frame_x_from_item_ids(
2016                                &items,
2017                                &nodes_by_id,
2018                                &edges_by_id,
2019                                &msg_endpoints,
2020                            ),
2021                            items
2022                                .iter()
2023                                .filter_map(|id| {
2024                                    item_y_range(id, &nodes_by_id, &edges_by_id, &msg_endpoints)
2025                                })
2026                                .reduce(|a, b| (a.0.min(b.0), a.1.max(b.1))),
2027                        ) {
2028                            if min_left.is_finite() && !items.is_empty() && section_count > 1 {
2029                                x1 = x1.min(min_left - 9.0);
2030                            }
2031                            let frame_y1 = y0 - 79.0;
2032                            let frame_y2 = y1 + 10.0;
2033                            block_min_x = block_min_x.min(x1);
2034                            block_max_x = block_max_x.max(x2);
2035                            block_min_y = block_min_y.min(frame_y1);
2036                            block_max_y = block_max_y.max(frame_y2);
2037                        }
2038                    }
2039                }
2040                2 => {
2041                    for entry in stack.iter_mut() {
2042                        match entry {
2043                            BlockStackEntry::Alt { sections }
2044                            | BlockStackEntry::Par { sections }
2045                            | BlockStackEntry::Critical { sections } => {
2046                                if let Some(cur) = sections.last_mut() {
2047                                    cur.push(msg_id.clone());
2048                                }
2049                            }
2050                            BlockStackEntry::Loop { items }
2051                            | BlockStackEntry::Opt { items }
2052                            | BlockStackEntry::Break { items } => {
2053                                items.push(msg_id.clone());
2054                            }
2055                        }
2056                    }
2057                }
2058                _ => {
2059                    if msg.from.is_some() && msg.to.is_some() {
2060                        for entry in stack.iter_mut() {
2061                            match entry {
2062                                BlockStackEntry::Alt { sections }
2063                                | BlockStackEntry::Par { sections }
2064                                | BlockStackEntry::Critical { sections } => {
2065                                    if let Some(cur) = sections.last_mut() {
2066                                        cur.push(msg_id.clone());
2067                                    }
2068                                }
2069                                BlockStackEntry::Loop { items }
2070                                | BlockStackEntry::Opt { items }
2071                                | BlockStackEntry::Break { items } => {
2072                                    items.push(msg_id.clone());
2073                                }
2074                            }
2075                        }
2076                    }
2077                }
2078            }
2079        }
2080
2081        if block_min_x.is_finite() && block_min_y.is_finite() {
2082            Some((block_min_x, block_min_y, block_max_x, block_max_y))
2083        } else {
2084            None
2085        }
2086    };
2087
2088    let mut content_min_x = f64::INFINITY;
2089    let mut content_max_x = f64::NEG_INFINITY;
2090    let mut content_max_y = f64::NEG_INFINITY;
2091    for n in &nodes {
2092        let left = n.x - n.width / 2.0;
2093        let right = n.x + n.width / 2.0;
2094        let bottom = n.y + n.height / 2.0;
2095        content_min_x = content_min_x.min(left);
2096        content_max_x = content_max_x.max(right);
2097        content_max_y = content_max_y.max(bottom);
2098    }
2099    if !content_min_x.is_finite() {
2100        content_min_x = 0.0;
2101        content_max_x = actor_width_min.max(1.0);
2102        content_max_y = (bottom_box_top_y + actor_height).max(1.0);
2103    }
2104
2105    if let Some((min_x, _min_y, max_x, max_y)) = block_bounds {
2106        content_min_x = content_min_x.min(min_x);
2107        content_max_x = content_max_x.max(max_x);
2108        content_max_y = content_max_y.max(max_y);
2109    }
2110
2111    // Mermaid (11.12.2) expands the viewBox vertically when a sequence title is present.
2112    // See `sequenceRenderer.ts`: `extraVertForTitle = title ? 40 : 0`.
2113    let extra_vert_for_title = if model.title.is_some() { 40.0 } else { 0.0 };
2114
2115    // Mermaid's sequence renderer sets the viewBox y origin to `-(diagramMarginY + extraVertForTitle)`
2116    // regardless of diagram contents.
2117    let vb_min_y = -(diagram_margin_y + extra_vert_for_title);
2118
2119    // Mermaid's sequence renderer uses a bounds box with `starty = 0` and computes `height` from
2120    // `stopy - starty`. Our headless layout models message spacing in content coordinates, but for
2121    // viewBox parity we must follow the upstream formula.
2122    //
2123    // When boxes exist, Mermaid's bounds logic ends up extending the vertical bounds by `boxMargin`
2124    // (diagramMarginY covers the remaining box padding), so include it here.
2125    let mut bounds_box_stopy = (content_max_y + bottom_margin_adj).max(0.0);
2126    if has_boxes {
2127        bounds_box_stopy += box_margin;
2128    }
2129
2130    // Mermaid's bounds box includes the per-box inner margins (`box.margin`) when boxes exist.
2131    // Approximate this by extending actor bounds by their enclosing box margin.
2132    let mut bounds_box_startx = content_min_x;
2133    let mut bounds_box_stopx = content_max_x;
2134    for i in 0..model.actor_order.len() {
2135        let left = actor_left_x[i];
2136        let right = left + actor_widths[i];
2137        if let Some(bi) = actor_box[i] {
2138            let m = box_margins[bi];
2139            bounds_box_startx = bounds_box_startx.min(left - m);
2140            bounds_box_stopx = bounds_box_stopx.max(right + m);
2141        } else {
2142            bounds_box_startx = bounds_box_startx.min(left);
2143            bounds_box_stopx = bounds_box_stopx.max(right);
2144        }
2145    }
2146
2147    // Mermaid's self-message bounds insert expands horizontally by `dx = max(textWidth/2, conf.width/2)`,
2148    // where `conf.width` is the configured actor width (150 by default). This can increase `box.stopx`
2149    // by ~1px due to `from_x + 1` rounding behavior in message geometry, affecting viewBox width.
2150    for msg in &model.messages {
2151        let (Some(from), Some(to)) = (msg.from.as_deref(), msg.to.as_deref()) else {
2152            continue;
2153        };
2154        if from != to {
2155            continue;
2156        }
2157        // Notes can use `from==to` for `rightOf`/`leftOf`; ignore them here.
2158        if msg.message_type == 2 {
2159            continue;
2160        }
2161        let Some(&i) = actor_index.get(from) else {
2162            continue;
2163        };
2164        let center_x = actor_centers_x[i] + 1.0;
2165        let text = msg.message.as_str().unwrap_or_default();
2166        let (text_w, _text_h) = if text.is_empty() {
2167            (1.0, 1.0)
2168        } else {
2169            measure_svg_like_with_html_br(measurer, text, &msg_text_style)
2170        };
2171        let dx = (text_w.max(1.0) / 2.0).max(actor_width_min / 2.0);
2172        bounds_box_startx = bounds_box_startx.min(center_x - dx);
2173        bounds_box_stopx = bounds_box_stopx.max(center_x + dx);
2174    }
2175
2176    let bounds = Some(Bounds {
2177        min_x: bounds_box_startx - diagram_margin_x,
2178        min_y: vb_min_y,
2179        max_x: bounds_box_stopx + diagram_margin_x,
2180        max_y: bounds_box_stopy + diagram_margin_y,
2181    });
2182
2183    Ok(SequenceDiagramLayout {
2184        nodes,
2185        edges,
2186        clusters,
2187        bounds,
2188    })
2189}