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