osd_core/
renderer.rs

1//! SVG renderer for sequence diagrams
2
3use crate::ast::*;
4use crate::theme::{LifelineStyle, ParticipantShape, Theme};
5use std::collections::HashMap;
6use std::fmt::Write;
7
8/// Rendering configuration
9#[derive(Debug, Clone)]
10pub struct Config {
11    /// Padding around the diagram
12    pub padding: f64,
13    /// Left margin for diagram content
14    pub left_margin: f64,
15    /// Right margin for diagram content
16    pub right_margin: f64,
17    /// Space between participants
18    pub participant_gap: f64,
19    /// Height of participant header/footer box
20    pub header_height: f64,
21    /// Height of each row (message, note, etc.)
22    pub row_height: f64,
23    /// Width of participant box
24    pub participant_width: f64,
25    /// Font size
26    pub font_size: f64,
27    /// Activation box width
28    pub activation_width: f64,
29    /// Note padding
30    pub note_padding: f64,
31    /// Block margin
32    pub block_margin: f64,
33    /// Title height (when title exists)
34    pub title_height: f64,
35    /// Theme for styling
36    pub theme: Theme,
37}
38
39impl Default for Config {
40    fn default() -> Self {
41        // Values calibrated to match websequencediagrams.com output exactly
42        // Reference: WSD Ultimate Stress Test produces:
43        //   - First participant box: x=78.5, y=110.5
44        //   - Box height: 46px (1 line), 108px (2+ lines)
45        //   - Box width: dynamic based on text
46        Self {
47            padding: 10.5,           // WSD: padding from SVG edge
48            left_margin: 66.0,       // WSD: Mobile lifeline at 124.5, box left edge at 78.5
49            right_margin: 10.0,      // WSD: minimal right margin, dynamically expanded for notes
50            participant_gap: 85.0,   // WSD: minimum gap for participants with no messages between
51            header_height: 46.0,     // WSD: participant box height = 46px (single line)
52            row_height: 32.0,        // WSD: actual row height = 32px
53            participant_width: 92.0, // WSD: minimum participant width = 92px
54            font_size: 14.0,         // WSD: uses 14px font
55            activation_width: 8.0,   // WSD: narrower activation bars
56            note_padding: 6.0,
57            block_margin: 5.0,
58            title_height: 100.0,     // WSD: title + space before participant boxes (y=110.5)
59            theme: Theme::default(),
60        }
61    }
62}
63
64impl Config {
65    /// Set the theme
66    pub fn with_theme(mut self, theme: Theme) -> Self {
67        self.theme = theme;
68        self
69    }
70}
71
72/// Block background info for deferred rendering
73#[derive(Debug, Clone)]
74struct BlockBackground {
75    x: f64,
76    y: f64,
77    width: f64,
78    height: f64,
79}
80
81/// Block label info for deferred rendering (rendered above activations/lifelines)
82#[derive(Debug, Clone)]
83struct BlockLabel {
84    x1: f64,
85    start_y: f64,
86    end_y: f64,
87    x2: f64,
88    kind: String,
89    label: String,
90    else_y: Option<f64>,
91    else_label: Option<String>,
92}
93
94#[derive(Debug, Clone)]
95struct LabelBox {
96    x_min: f64,
97    x_max: f64,
98    y_min: f64,
99    y_max: f64,
100}
101
102/// Render state
103struct RenderState {
104    config: Config,
105    participants: Vec<Participant>,
106    participant_x: HashMap<String, f64>,
107    participant_widths: HashMap<String, f64>,
108    current_y: f64,
109    activations: HashMap<String, Vec<(f64, Option<f64>)>>,
110    autonumber: Option<u32>,
111    destroyed: HashMap<String, f64>,
112    has_title: bool,
113    total_width: f64,
114    /// Collected block backgrounds for deferred rendering
115    block_backgrounds: Vec<BlockBackground>,
116    /// Collected block labels for deferred rendering (above activations/lifelines)
117    block_labels: Vec<BlockLabel>,
118    /// Footer style from diagram options
119    footer_style: FooterStyle,
120    /// Tracks whether the first return message in an else branch needs extra spacing
121    else_return_pending: Vec<bool>,
122    /// Tracks whether a serial block needs extra spacing after its first row
123    serial_first_row_pending: Vec<bool>,
124    /// Tracks nested parallel depth for serial row spacing
125    parallel_depth: usize,
126    /// Tracks message label bounding boxes to avoid overlap
127    message_label_boxes: Vec<LabelBox>,
128}
129
130// ============================================
131// Text width calculation
132// ============================================
133const TEXT_WIDTH_PADDING: f64 = 41.0;
134const TEXT_WIDTH_SCALE: f64 = 1.3;
135const MESSAGE_WIDTH_PADDING: f64 = 4.0;
136const MESSAGE_WIDTH_SCALE: f64 = 0.82;
137
138// ============================================
139// Group Spacing (unified element spacing)
140// ============================================
141// All element spacing is controlled by this single function.
142// "Group" = visual unit (text + arrow/loop/box)
143// Spacing = gap between visual bottom of one group and visual top of next group
144fn group_spacing(config: &Config) -> f64 {
145    config.row_height  // 32px by default
146}
147
148// ============================================
149// Message
150// ============================================
151const MESSAGE_TEXT_ABOVE_ARROW: f64 = 6.0;       // Text is rendered 6px above arrow
152const DELAY_UNIT: f64 = 18.0;                    // Pixels per delay unit
153
154// ============================================
155// Block (alt, opt, loop, etc.)
156// ============================================
157const BLOCK_LABEL_HEIGHT: f64 = 22.0;            // Pentagon label height
158const ELEMENT_PADDING: f64 = 8.0;                // Unified internal padding for all elements
159
160// ============================================
161// Note
162// ============================================
163const NOTE_MARGIN: f64 = 10.0;                   // Margin between note and lifeline
164const NOTE_FOLD_SIZE: f64 = 8.0;                 // Corner fold size
165const NOTE_CHAR_WIDTH: f64 = 7.0;                // Estimated character width
166const NOTE_LINE_HEIGHT: f64 = 17.0;              // Line height (font 13px + 4px)
167const NOTE_MIN_WIDTH: f64 = 50.0;                // Minimum width
168
169// ============================================
170// Other elements
171// ============================================
172const STATE_LINE_HEIGHT_EXTRA: f64 = 11.0;       // Extra line height for state
173const REF_LINE_HEIGHT_EXTRA: f64 = 16.0;         // Extra line height for ref
174
175// ============================================
176// Message label collision avoidance
177// ============================================
178const MESSAGE_LABEL_COLLISION_PADDING: f64 = 2.0;
179const MESSAGE_LABEL_COLLISION_STEP_RATIO: f64 = 0.9;
180const MESSAGE_LABEL_ASCENT_FACTOR: f64 = 0.8;
181const MESSAGE_LABEL_DESCENT_FACTOR: f64 = 0.2;
182
183fn block_header_space(config: &Config, _depth: usize) -> f64 {
184    // Pentagon height + group spacing + text offset absorption
185    BLOCK_LABEL_HEIGHT + group_spacing(config) + MESSAGE_TEXT_ABOVE_ARROW
186}
187
188fn block_frame_shift(_depth: usize) -> f64 {
189    0.0
190}
191
192fn block_footer_padding(_config: &Config, _depth: usize) -> f64 {
193    ELEMENT_PADDING
194}
195
196fn block_else_before(_config: &Config, _depth: usize) -> f64 {
197    ELEMENT_PADDING
198}
199
200fn block_else_after(config: &Config, _depth: usize) -> f64 {
201    // Space after else line to match alt header spacing
202    // Else label text is rendered at else_y + 16 (label_text_offset)
203    // Need space for label text height + group spacing + message text offset
204    16.0 + group_spacing(config) + MESSAGE_TEXT_ABOVE_ARROW
205}
206
207fn self_message_spacing(config: &Config, lines: usize) -> f64 {
208    let line_height = config.font_size + 4.0;
209    let text_block_height = lines as f64 * line_height;
210    let loop_height = text_block_height.max(25.0);
211    // Group height (loop) + group spacing + next message text offset
212    // Next message text is rendered MESSAGE_TEXT_ABOVE_ARROW above arrow,
213    // so we add that offset to maintain consistent visual spacing
214    loop_height + group_spacing(config) + MESSAGE_TEXT_ABOVE_ARROW
215}
216
217fn note_line_height(_config: &Config) -> f64 {
218    // Simplified: fixed value (font 13px + padding 4px = 17px)
219    NOTE_LINE_HEIGHT
220}
221
222fn note_padding(_config: &Config) -> f64 {
223    ELEMENT_PADDING
224}
225
226fn item_pre_gap(config: &Config) -> f64 {
227    config.font_size + 1.0
228}
229
230fn item_pre_shift(config: &Config) -> f64 {
231    (config.row_height - item_pre_gap(config)).max(0.0)
232}
233
234fn label_boxes_overlap(x_min: f64, x_max: f64, y_min: f64, y_max: f64, other: &LabelBox) -> bool {
235    let x_overlap = x_max >= other.x_min - MESSAGE_LABEL_COLLISION_PADDING
236        && x_min <= other.x_max + MESSAGE_LABEL_COLLISION_PADDING;
237    let y_overlap = y_max >= other.y_min - MESSAGE_LABEL_COLLISION_PADDING
238        && y_min <= other.y_max + MESSAGE_LABEL_COLLISION_PADDING;
239    x_overlap && y_overlap
240}
241
242fn actor_footer_extra(_participants: &[Participant], _config: &Config) -> f64 {
243    // Actor names are now rendered within the header, so no extra footer space needed
244    0.0
245}
246
247fn serial_first_row_gap(_parallel_depth: usize) -> f64 {
248    // Simplified: always 0 (no fine-tuning needed)
249    0.0
250}
251
252fn state_line_height(config: &Config) -> f64 {
253    config.font_size + STATE_LINE_HEIGHT_EXTRA
254}
255
256fn ref_line_height(config: &Config) -> f64 {
257    config.font_size + REF_LINE_HEIGHT_EXTRA
258}
259
260// ============================================
261// Common Y advancement functions
262// These are the single source of truth for Y position calculations.
263// Used by: collect_block_backgrounds, calculate_height, render_*
264// ============================================
265
266/// Calculate Y advancement for a regular (non-self) message
267fn regular_message_y_advance(config: &Config, line_count: usize, delay_offset: f64) -> f64 {
268    // Use actual line_height (font_size + 4) to match text rendering
269    let line_height = config.font_size + 4.0;
270    let extra_height = if line_count > 1 {
271        // Extra lines + MESSAGE_TEXT_ABOVE_ARROW offset (text is rendered above arrow)
272        (line_count - 1) as f64 * line_height + MESSAGE_TEXT_ABOVE_ARROW
273    } else {
274        0.0
275    };
276    // Group spacing + multiline extra + delay
277    // For regular messages, text is above arrow on both sides, so offsets cancel out
278    group_spacing(config) + extra_height + delay_offset
279}
280
281/// Calculate Y advancement for a self-message
282fn self_message_y_advance(config: &Config, line_count: usize) -> f64 {
283    self_message_spacing(config, line_count)
284}
285
286/// Calculate Y advancement for a note
287fn note_y_advance(config: &Config, line_count: usize) -> f64 {
288    let note_height = note_padding(config) * 2.0 + line_count as f64 * note_line_height(config);
289    // Group height (note box) + group spacing + next message text offset
290    note_height.max(group_spacing(config)) + group_spacing(config) + MESSAGE_TEXT_ABOVE_ARROW
291}
292
293/// Calculate Y advancement for a state box
294fn state_y_advance(config: &Config, line_count: usize) -> f64 {
295    let box_height = ELEMENT_PADDING * 2.0 + line_count as f64 * state_line_height(config);
296    // Group height (state box) + group spacing + next message text offset
297    box_height + group_spacing(config) + MESSAGE_TEXT_ABOVE_ARROW
298}
299
300/// Calculate Y advancement for a ref box
301fn ref_y_advance(config: &Config, line_count: usize) -> f64 {
302    let box_height = ELEMENT_PADDING * 2.0 + line_count as f64 * ref_line_height(config);
303    // Group height (ref box) + group spacing + next message text offset
304    box_height + group_spacing(config) + MESSAGE_TEXT_ABOVE_ARROW
305}
306
307/// Calculate Y advancement for a description
308fn description_y_advance(config: &Config, line_count: usize) -> f64 {
309    let line_height = config.font_size + 4.0;
310    // Group height (text block) + group spacing + next message text offset
311    line_count as f64 * line_height + group_spacing(config) + MESSAGE_TEXT_ABOVE_ARROW
312}
313
314/// Calculate Y advancement for a block end (footer + row margin)
315fn block_end_y_advance(config: &Config, depth: usize) -> f64 {
316    block_footer_padding(config, depth) + group_spacing(config)
317}
318
319/// Arrowhead size constant
320const ARROWHEAD_SIZE: f64 = 10.0;
321
322/// Generate arrowhead polygon points for a given end position and direction
323fn arrowhead_points(x: f64, y: f64, direction: f64) -> String {
324    let size = ARROWHEAD_SIZE;
325    let half_width = size * 0.35;
326
327    // Tip of the arrow
328    let tip_x = x;
329    let tip_y = y;
330
331    // Back points of the arrow (rotated by direction)
332    let back_x = x - size * direction.cos();
333    let back_y = y - size * direction.sin();
334
335    // Perpendicular offset for the two back points
336    let perp_x = -direction.sin() * half_width;
337    let perp_y = direction.cos() * half_width;
338
339    format!(
340        "{:.1},{:.1} {:.1},{:.1} {:.1},{:.1}",
341        back_x + perp_x,
342        back_y + perp_y,
343        tip_x,
344        tip_y,
345        back_x - perp_x,
346        back_y - perp_y
347    )
348}
349
350/// Calculate direction angle from (x1, y1) to (x2, y2)
351fn arrow_direction(x1: f64, y1: f64, x2: f64, y2: f64) -> f64 {
352    (y2 - y1).atan2(x2 - x1)
353}
354
355fn block_has_frame(kind: &BlockKind) -> bool {
356    !matches!(kind, BlockKind::Parallel | BlockKind::Serial)
357}
358
359fn block_is_parallel(kind: &BlockKind) -> bool {
360    matches!(kind, BlockKind::Parallel)
361}
362
363fn parallel_needs_gap(items: &[Item]) -> bool {
364    items.iter().any(|item| matches!(item, Item::Block { .. }))
365}
366
367fn text_char_weight(c: char) -> f64 {
368    if c.is_ascii() {
369        if c.is_uppercase() {
370            0.7
371        } else {
372            0.5
373        }
374    } else {
375        1.0 // CJK and other characters are wider
376    }
377}
378
379/// Character width for participant box calculation (WSD proportional font metrics)
380/// Based on analysis of WSD SVG glyph definitions and actual output comparison
381fn participant_char_width(c: char) -> f64 {
382    match c {
383        // Very wide: W, M, m, w, @
384        'W' | 'w' => 14.0,
385        'M' | 'm' => 12.5,
386        '@' | '%' => 14.0,
387        // Wide uppercase
388        'A' | 'B' | 'C' | 'D' | 'E' | 'G' | 'H' | 'K' | 'N' | 'O' | 'P' | 'Q' | 'R' | 'S' | 'T' | 'U' | 'V' | 'X' | 'Y' | 'Z' => 12.0,
389        // Narrow uppercase
390        'F' | 'I' | 'J' | 'L' => 7.0,
391        // Wide lowercase
392        'o' | 'e' | 'a' | 'n' | 'u' | 'v' | 'x' | 'z' | 'b' | 'd' | 'g' | 'h' | 'k' | 'p' | 'q' | 's' | 'c' | 'y' => 8.5,
393        // Narrow lowercase
394        'i' | 'j' | 'l' => 4.0,
395        't' | 'f' | 'r' => 6.0,
396        // Punctuation and special chars (WSD uses wider glyphs for these)
397        ':' => 6.5,
398        '-' | '_' => 7.0,
399        '[' | ']' | '(' | ')' | '{' | '}' => 7.0,
400        '.' | ',' | '\'' | '`' | ';' => 4.0,
401        ' ' => 5.0,
402        // Numbers
403        '0'..='9' => 9.0,
404        // Default for other ASCII
405        _ if c.is_ascii() => 8.5,
406        // CJK and other characters
407        _ => 14.0,
408    }
409}
410
411/// Calculate participant box width based on WSD proportional font metrics
412fn calculate_participant_width(name: &str, min_width: f64) -> f64 {
413    let lines: Vec<&str> = name.split("\\n").collect();
414    let max_line_width = lines
415        .iter()
416        .map(|line| line.chars().map(participant_char_width).sum::<f64>())
417        .fold(0.0_f64, |a, b| a.max(b));
418
419    // WSD uses consistent padding for all participant boxes
420    let padding = 50.0;
421
422    (max_line_width + padding).max(min_width)
423}
424
425fn max_weighted_line(text: &str) -> f64 {
426    text.split("\\n")
427        .map(|line| line.chars().map(text_char_weight).sum::<f64>())
428        .fold(0.0_f64, |a, b| a.max(b))
429}
430
431/// Estimate text width in pixels (rough approximation)
432fn estimate_text_width(text: &str, font_size: f64) -> f64 {
433    let weighted = max_weighted_line(text);
434    weighted * font_size * TEXT_WIDTH_SCALE + TEXT_WIDTH_PADDING
435}
436
437fn estimate_message_width(text: &str, font_size: f64) -> f64 {
438    let weighted = max_weighted_line(text);
439    weighted * font_size * MESSAGE_WIDTH_SCALE + MESSAGE_WIDTH_PADDING
440}
441
442fn block_tab_width(kind: &str) -> f64 {
443    (kind.chars().count() as f64 * 12.0 + 21.0).max(57.0)
444}
445
446/// Calculate note width based on text content
447fn calculate_note_width(text: &str, _config: &Config) -> f64 {
448    let lines: Vec<&str> = text.split("\\n").collect();
449    let max_line_len = lines.iter().map(|l| l.chars().count()).max().unwrap_or(5);
450    let text_width = max_line_len as f64 * NOTE_CHAR_WIDTH;
451    (ELEMENT_PADDING * 2.0 + text_width).max(NOTE_MIN_WIDTH)
452}
453
454/// Calculate required right margin based on right-side notes on the rightmost participant only
455fn calculate_right_margin(
456    participants: &[Participant],
457    items: &[Item],
458    config: &Config,
459) -> f64 {
460    let rightmost_id = match participants.last() {
461        Some(p) => p.id().to_string(),
462        None => return config.right_margin,
463    };
464    let mut max_right_note_width: f64 = 0.0;
465
466    fn process_items_for_right_notes(
467        items: &[Item],
468        rightmost_id: &str,
469        max_width: &mut f64,
470        config: &Config,
471    ) {
472        for item in items {
473            match item {
474                Item::Note {
475                    position: NotePosition::Right,
476                    participants,
477                    text,
478                } => {
479                    // Only consider notes on the rightmost participant
480                    if participants.first().map(|s| s.as_str()) == Some(rightmost_id) {
481                        let note_width = calculate_note_width(text, config);
482                        if note_width > *max_width {
483                            *max_width = note_width;
484                        }
485                    }
486                }
487                Item::Block {
488                    items, else_items, ..
489                } => {
490                    process_items_for_right_notes(items, rightmost_id, max_width, config);
491                    if let Some(else_items) = else_items {
492                        process_items_for_right_notes(else_items, rightmost_id, max_width, config);
493                    }
494                }
495                _ => {}
496            }
497        }
498    }
499
500    process_items_for_right_notes(items, &rightmost_id, &mut max_right_note_width, config);
501
502    // right_margin needs to accommodate: NOTE_MARGIN + note_width
503    if max_right_note_width > 0.0 {
504        (max_right_note_width + NOTE_MARGIN).max(config.right_margin)
505    } else {
506        config.right_margin
507    }
508}
509
510/// Calculate required left margin based on left-side notes on the leftmost participant
511fn calculate_left_margin(
512    participants: &[Participant],
513    items: &[Item],
514    config: &Config,
515) -> f64 {
516    let leftmost_id = match participants.first() {
517        Some(p) => p.id().to_string(),
518        None => return config.padding,
519    };
520    let mut max_left_note_width: f64 = 0.0;
521
522    fn process_items_for_left_notes(
523        items: &[Item],
524        leftmost_id: &str,
525        max_width: &mut f64,
526        config: &Config,
527    ) {
528        for item in items {
529            match item {
530                Item::Note {
531                    position: NotePosition::Left,
532                    participants,
533                    text,
534                } => {
535                    // Only consider notes on the leftmost participant
536                    if participants.first().map(|s| s.as_str()) == Some(leftmost_id) {
537                        let note_width = calculate_note_width(text, config);
538                        if note_width > *max_width {
539                            *max_width = note_width;
540                        }
541                    }
542                }
543                Item::Block {
544                    items, else_items, ..
545                } => {
546                    process_items_for_left_notes(items, leftmost_id, max_width, config);
547                    if let Some(else_items) = else_items {
548                        process_items_for_left_notes(else_items, leftmost_id, max_width, config);
549                    }
550                }
551                _ => {}
552            }
553        }
554    }
555
556    process_items_for_left_notes(items, &leftmost_id, &mut max_left_note_width, config);
557
558    // left_margin needs to accommodate: note_width + NOTE_MARGIN
559    if max_left_note_width > 0.0 {
560        (max_left_note_width + NOTE_MARGIN).max(config.padding)
561    } else {
562        config.padding
563    }
564}
565
566/// Calculate dynamic gaps between participants based on message text lengths
567fn calculate_participant_gaps(
568    participants: &[Participant],
569    items: &[Item],
570    config: &Config,
571) -> Vec<f64> {
572    if participants.len() <= 1 {
573        return vec![];
574    }
575
576    // Create a map from participant id to index
577    let mut participant_index: HashMap<String, usize> = HashMap::new();
578    for (i, p) in participants.iter().enumerate() {
579        participant_index.insert(p.id().to_string(), i);
580    }
581
582    // Initialize gaps with WSD-compatible minimum gap
583    // WSD uses ~59px center-to-center for simple diagrams
584    let min_gap = config.participant_gap;
585    let mut gaps: Vec<f64> = vec![min_gap; participants.len() - 1];
586
587    // Calculate max text width for each adjacent pair
588    fn process_items(
589        items: &[Item],
590        participant_index: &HashMap<String, usize>,
591        gaps: &mut Vec<f64>,
592        config: &Config,
593    ) {
594        for item in items {
595            match item {
596                Item::Message { from, to, text, arrow, .. } => {
597                    if let (Some(&from_idx), Some(&to_idx)) =
598                        (participant_index.get(from), participant_index.get(to))
599                    {
600                        if from_idx != to_idx {
601                            let (min_idx, max_idx) = if from_idx < to_idx {
602                                (from_idx, to_idx)
603                            } else {
604                                (to_idx, from_idx)
605                            };
606
607                            let text_width = estimate_message_width(text, config.font_size);
608
609                            // WSD: delay messages need extra horizontal space for diagonal lines
610                            // Delay coefficient 86.4 for WSD gap matching (645px for delay(7))
611                            let delay_extra = arrow.delay.map(|d| d as f64 * 86.4).unwrap_or(0.0);
612
613                            // WSD: distribute text width across gaps with appropriate spacing
614                            let gap_count = (max_idx - min_idx) as f64;
615                            let needed_gap = if gap_count == 1.0 {
616                                // Adjacent: text width minus overlap allowance
617                                text_width - 36.0 + delay_extra
618                            } else {
619                                // Non-adjacent: distribute evenly with margin
620                                text_width / gap_count - 20.0 + delay_extra
621                            };
622
623                            // Update gaps between the participants
624                            for gap_idx in min_idx..max_idx {
625                                if needed_gap > gaps[gap_idx] {
626                                    gaps[gap_idx] = needed_gap;
627                                }
628                            }
629                        }
630                    }
631                }
632                Item::Note {
633                    position,
634                    participants: note_participants,
635                    text,
636                } => {
637                    // Calculate note width
638                    let note_width = calculate_note_width(text, config);
639
640                    if let Some(participant) = note_participants.first() {
641                        if let Some(&idx) = participant_index.get(participant) {
642                            match position {
643                                NotePosition::Left => {
644                                    // Left note: needs space between left neighbor
645                                    if idx > 0 {
646                                        // Need gap for note width + margins
647                                        let needed_gap = note_width + NOTE_MARGIN * 2.0;
648                                        if needed_gap > gaps[idx - 1] {
649                                            gaps[idx - 1] = needed_gap;
650                                        }
651                                    }
652                                }
653                                NotePosition::Right => {
654                                    // Right note: needs space between right neighbor
655                                    if idx < gaps.len() {
656                                        let needed_gap = note_width + NOTE_MARGIN * 2.0;
657                                        if needed_gap > gaps[idx] {
658                                            gaps[idx] = needed_gap;
659                                        }
660                                    }
661                                }
662                                NotePosition::Over => {
663                                    // Over note: only process if spanning multiple participants
664                                    // Single participant case is fine as long as width doesn't exceed
665                                }
666                            }
667                        }
668                    }
669                }
670                Item::Block {
671                    items, else_items, ..
672                } => {
673                    process_items(items, participant_index, gaps, config);
674                    if let Some(else_items) = else_items {
675                        process_items(else_items, participant_index, gaps, config);
676                    }
677                }
678                _ => {}
679            }
680        }
681    }
682
683    process_items(items, &participant_index, &mut gaps, config);
684
685    // WSD: participant name lengths don't directly increase gaps
686    // The participant box widths (already calculated elsewhere) handle this
687    // No additional gap increase needed for names
688
689    // Cap maximum gap (WSD allows up to ~645px for long messages)
690    let max_gap = 645.0;
691    for gap in &mut gaps {
692        if *gap > max_gap {
693            *gap = max_gap;
694        }
695    }
696
697    gaps
698}
699
700impl RenderState {
701    fn new(
702        config: Config,
703        participants: Vec<Participant>,
704        items: &[Item],
705        has_title: bool,
706        footer_style: FooterStyle,
707    ) -> Self {
708        let mut config = config;
709        // WSD header height calculation:
710        // - 1 line: 46px
711        // - 2+ lines: 108px (WSD caps at 108px regardless of line count)
712        // - Actor: ~108px for 2-line names
713        let mut required_header_height = config.header_height;
714        for p in &participants {
715            let lines = p.name.split("\\n").count();
716            let needed = match p.kind {
717                ParticipantKind::Participant => {
718                    // WSD: 46px for 1 line, 108px for 2+ lines (capped)
719                    if lines <= 1 {
720                        46.0
721                    } else {
722                        108.0 // WSD uses fixed 108px for multi-line
723                    }
724                }
725                ParticipantKind::Actor => {
726                    // WSD: Actor has stick figure + name below
727                    // ~85px for 1-line, ~108px for 2+ lines
728                    if lines <= 1 {
729                        85.0
730                    } else {
731                        108.0
732                    }
733                }
734            };
735            if needed > required_header_height {
736                required_header_height = needed;
737            }
738        }
739        if required_header_height > config.header_height {
740            config.header_height = required_header_height;
741        }
742        // Calculate individual participant widths based on their names
743        // Using WSD proportional font metrics for accurate box widths
744        let mut participant_widths: HashMap<String, f64> = HashMap::new();
745        let min_width = config.participant_width;
746
747        for p in &participants {
748            let width = calculate_participant_width(&p.name, min_width);
749            participant_widths.insert(p.id().to_string(), width);
750        }
751
752        let gaps = calculate_participant_gaps(&participants, items, &config);
753
754        // Left margin for notes on leftmost participant (dynamic)
755        let left_margin = calculate_left_margin(&participants, items, &config);
756        // Right margin for self-loops and notes on rightmost participant (dynamic)
757        let right_margin = calculate_right_margin(&participants, items, &config);
758
759        let mut participant_x = HashMap::new();
760        let first_width = participants
761            .first()
762            .map(|p| *participant_widths.get(p.id()).unwrap_or(&min_width))
763            .unwrap_or(min_width);
764        let mut current_x = config.padding + left_margin + first_width / 2.0;
765
766        for (i, p) in participants.iter().enumerate() {
767            participant_x.insert(p.id().to_string(), current_x);
768            if i < gaps.len() {
769                let current_width = *participant_widths.get(p.id()).unwrap_or(&min_width);
770                let next_p = participants.get(i + 1);
771                let next_width = next_p
772                    .map(|np| *participant_widths.get(np.id()).unwrap_or(&min_width))
773                    .unwrap_or(min_width);
774
775                // WSD: Actor doesn't have a header box, so it takes less horizontal space
776                // Reduce gap when current or next participant is an Actor
777                let current_is_actor = p.kind == ParticipantKind::Actor;
778                let next_is_actor = next_p.map(|np| np.kind == ParticipantKind::Actor).unwrap_or(false);
779
780                // Note: Actor gap reduction disabled - it changes total width
781                // WSD and OSD have different actor placement algorithms
782                let actor_gap_reduction = 0.0;
783                let _ = (current_is_actor, next_is_actor); // suppress warnings
784
785                // WSD: edge-to-edge gap varies by message density
786                // Variable edge padding: more messages = more edge padding
787                let calculated_gap = gaps[i] - actor_gap_reduction;
788
789                // Determine edge padding based on message density and participant types
790                // WSD uses variable edge padding based on content
791                let half_widths = (current_width + next_width) / 2.0;
792                let neither_is_actor = !current_is_actor && !next_is_actor;
793
794                let either_is_actor = current_is_actor || next_is_actor;
795                let edge_padding = if calculated_gap > 500.0 {
796                    // Very high (delay messages): minimal extra padding
797                    10.0
798                } else if either_is_actor && calculated_gap > 130.0 {
799                    // Actor-adjacent gaps: WSD uses tighter spacing around actors
800                    33.0
801                } else if neither_is_actor && half_widths > 155.0 && calculated_gap > 130.0 {
802                    // Two large normal boxes with medium traffic: extra padding
803                    90.0
804                } else if calculated_gap > 130.0 {
805                    // Medium-high traffic: WSD uses ~49px for these gaps
806                    49.0
807                } else if calculated_gap > config.participant_gap {
808                    // Medium traffic: moderate padding
809                    25.0
810                } else {
811                    // Low traffic: edge_padding depends on individual participant widths
812                    let max_width = current_width.max(next_width);
813                    let min_width_val = current_width.min(next_width);
814                    let width_diff = max_width - min_width_val;
815
816                    if max_width > 160.0 && min_width_val > 160.0 {
817                        // Both participants are very wide (>160): small positive padding
818                        // WSD UserDB→Cache: both 161.2, gap=163, ep≈1.8
819                        1.8
820                    } else if max_width > 160.0 && min_width_val > 140.0 {
821                        // One very wide, one large: negative padding
822                        // WSD ML→Notify: max=161.2, min=149.6, gap=148.5, ep≈-7
823                        -7.0
824                    } else if max_width > 160.0 && min_width_val < 110.0 {
825                        // One very wide, one small: large positive padding
826                        // WSD Cache→Kafka: max=161.2, min=103.2, gap=143.5, ep≈11.3
827                        11.3
828                    } else if max_width > 160.0 && width_diff > 45.0 {
829                        // One very wide, one medium-small: negative padding
830                        // WSD Notify→Payment: max=161.2, min=114.8, diff=46.4, gap=132, ep≈-6
831                        -6.0
832                    } else if min_width_val < 115.0 {
833                        // One small participant: moderate padding
834                        // WSD Kafka→ML, Payment→Worker
835                        10.0
836                    } else {
837                        // Medium participants: moderate padding
838                        11.0
839                    }
840                };
841
842                let min_center_gap = (current_width + next_width) / 2.0 + edge_padding - actor_gap_reduction;
843                let actual_gap = calculated_gap.max(min_center_gap).max(60.0);
844                current_x += actual_gap;
845            }
846        }
847
848        let last_width = participants
849            .last()
850            .map(|p| *participant_widths.get(p.id()).unwrap_or(&min_width))
851            .unwrap_or(min_width);
852        let total_width = current_x + last_width / 2.0 + right_margin + config.padding;
853
854        Self {
855            config,
856            participants,
857            participant_x,
858            participant_widths,
859            current_y: 0.0,
860            activations: HashMap::new(),
861            autonumber: None,
862            destroyed: HashMap::new(),
863            has_title,
864            total_width,
865            block_backgrounds: Vec::new(),
866            block_labels: Vec::new(),
867            footer_style,
868            else_return_pending: Vec::new(),
869            serial_first_row_pending: Vec::new(),
870            parallel_depth: 0,
871            message_label_boxes: Vec::new(),
872        }
873    }
874
875    fn get_participant_width(&self, name: &str) -> f64 {
876        *self
877            .participant_widths
878            .get(name)
879            .unwrap_or(&self.config.participant_width)
880    }
881
882    fn get_x(&self, name: &str) -> f64 {
883        *self.participant_x.get(name).unwrap_or(&0.0)
884    }
885
886    fn push_else_return_pending(&mut self) {
887        self.else_return_pending.push(true);
888    }
889
890    fn pop_else_return_pending(&mut self) {
891        self.else_return_pending.pop();
892    }
893
894    fn apply_else_return_gap(&mut self, arrow: &Arrow) {
895        if let Some(pending) = self.else_return_pending.last_mut() {
896            if *pending && matches!(arrow.line, LineStyle::Dashed) {
897                // Simplified: removed fine-tuning after else return
898                *pending = false;
899            }
900        }
901    }
902
903    fn push_serial_first_row_pending(&mut self) {
904        self.serial_first_row_pending.push(true);
905    }
906
907    fn pop_serial_first_row_pending(&mut self) {
908        self.serial_first_row_pending.pop();
909    }
910
911    fn apply_serial_first_row_gap(&mut self) {
912        if let Some(pending) = self.serial_first_row_pending.last_mut() {
913            if *pending {
914                self.current_y += serial_first_row_gap(self.parallel_depth);
915                *pending = false;
916            }
917        }
918    }
919
920    fn reserve_message_label(
921        &mut self,
922        x_min: f64,
923        x_max: f64,
924        mut y_min: f64,
925        mut y_max: f64,
926        step: f64,
927    ) -> f64 {
928        // Only check collision with boxes that could actually overlap
929        // Skip boxes whose y_max is significantly above our y_min (they're in previous rows)
930        let relevance_threshold = step * 2.0;
931        let relevant_boxes: Vec<&LabelBox> = self
932            .message_label_boxes
933            .iter()
934            .filter(|b| b.y_max + relevance_threshold >= y_min)
935            .collect();
936
937        let mut offset = 0.0;
938        let mut attempts = 0;
939        while relevant_boxes
940            .iter()
941            .any(|b| label_boxes_overlap(x_min, x_max, y_min, y_max, b))
942            && attempts < 20
943        {
944            y_min += step;
945            y_max += step;
946            offset += step;
947            attempts += 1;
948        }
949        self.message_label_boxes.push(LabelBox {
950            x_min,
951            x_max,
952            y_min,
953            y_max,
954        });
955        offset
956    }
957
958    fn push_parallel(&mut self) {
959        self.parallel_depth += 1;
960    }
961
962    fn pop_parallel(&mut self) {
963        if self.parallel_depth > 0 {
964            self.parallel_depth -= 1;
965        }
966    }
967
968    /// Check if a participant has an active activation at the given Y position
969    fn is_participant_active_at(&self, participant: &str, y: f64) -> bool {
970        if let Some(acts) = self.activations.get(participant) {
971            acts.iter().any(|(start_y, end_y)| {
972                *start_y <= y && end_y.map_or(true, |end| y <= end)
973            })
974        } else {
975            false
976        }
977    }
978
979    /// Get arrow start X position, accounting for activation bar
980    fn get_arrow_start_x(&self, participant: &str, y: f64, going_right: bool) -> f64 {
981        let x = self.get_x(participant);
982        if self.is_participant_active_at(participant, y) {
983            let half_width = self.config.activation_width / 2.0;
984            if going_right {
985                x + half_width // Arrow starts from right edge of activation bar
986            } else {
987                x - half_width // Arrow starts from left edge of activation bar
988            }
989        } else {
990            x
991        }
992    }
993
994    /// Get arrow end X position, accounting for activation bar
995    fn get_arrow_end_x(&self, participant: &str, y: f64, coming_from_right: bool) -> f64 {
996        let x = self.get_x(participant);
997        if self.is_participant_active_at(participant, y) {
998            let half_width = self.config.activation_width / 2.0;
999            if coming_from_right {
1000                x + half_width // Arrow ends at right edge of activation bar
1001            } else {
1002                x - half_width // Arrow ends at left edge of activation bar
1003            }
1004        } else {
1005            x
1006        }
1007    }
1008
1009    fn diagram_width(&self) -> f64 {
1010        self.total_width
1011    }
1012
1013    /// Get the x position of the leftmost participant
1014    fn leftmost_x(&self) -> f64 {
1015        self.participants
1016            .first()
1017            .map(|p| self.get_x(p.id()))
1018            .unwrap_or(self.config.padding)
1019    }
1020
1021    /// Get the x position of the rightmost participant
1022    fn rightmost_x(&self) -> f64 {
1023        self.participants
1024            .last()
1025            .map(|p| self.get_x(p.id()))
1026            .unwrap_or(self.total_width - self.config.padding)
1027    }
1028
1029    /// Get block left boundary (based on leftmost participant)
1030    fn block_left(&self) -> f64 {
1031        let leftmost_width = self
1032            .participants
1033            .first()
1034            .map(|p| self.get_participant_width(p.id()))
1035            .unwrap_or(self.config.participant_width);
1036        self.leftmost_x() - leftmost_width / 2.0 - self.config.block_margin
1037    }
1038
1039    /// Get block right boundary (based on rightmost participant)
1040    fn block_right(&self) -> f64 {
1041        let rightmost_width = self
1042            .participants
1043            .last()
1044            .map(|p| self.get_participant_width(p.id()))
1045            .unwrap_or(self.config.participant_width);
1046        self.rightmost_x() + rightmost_width / 2.0 + self.config.block_margin
1047    }
1048
1049    fn header_top(&self) -> f64 {
1050        if self.has_title {
1051            self.config.padding + self.config.title_height
1052        } else {
1053            self.config.padding
1054        }
1055    }
1056
1057    fn content_start(&self) -> f64 {
1058        // WSD first message Y: 250.5
1059        // header_top (110.5) + header_height (108) + row_height (32) = 250.5
1060        self.header_top() + self.config.header_height + self.config.row_height
1061    }
1062
1063    fn next_number(&mut self) -> Option<u32> {
1064        self.autonumber.map(|n| {
1065            self.autonumber = Some(n + 1);
1066            n
1067        })
1068    }
1069
1070    /// Add a block background to be rendered later
1071    fn add_block_background(&mut self, x: f64, y: f64, width: f64, height: f64) {
1072        self.block_backgrounds.push(BlockBackground {
1073            x,
1074            y,
1075            width,
1076            height,
1077        });
1078    }
1079
1080    /// Add a block label to be rendered later (above activations/lifelines)
1081    fn add_block_label(
1082        &mut self,
1083        x1: f64,
1084        start_y: f64,
1085        end_y: f64,
1086        x2: f64,
1087        kind: &str,
1088        label: &str,
1089        else_y: Option<f64>,
1090        else_label: Option<String>,
1091    ) {
1092        self.block_labels.push(BlockLabel {
1093            x1,
1094            start_y,
1095            end_y,
1096            x2,
1097            kind: kind.to_string(),
1098            label: label.to_string(),
1099            else_y,
1100            else_label,
1101        });
1102    }
1103}
1104
1105/// Find participants involved in a list of items (returns min/max edges and whether leftmost is included)
1106fn find_involved_participants(items: &[Item], state: &RenderState) -> Option<(f64, f64, bool)> {
1107    let mut min_left: Option<f64> = None;
1108    let mut max_right: Option<f64> = None;
1109    let leftmost_id = state.participants.first().map(|p| p.id()).unwrap_or("");
1110    let mut includes_leftmost = false;
1111
1112    fn update_bounds(
1113        participant: &str,
1114        state: &RenderState,
1115        min_left: &mut Option<f64>,
1116        max_right: &mut Option<f64>,
1117        includes_leftmost: &mut bool,
1118        leftmost_id: &str,
1119    ) {
1120        let x = state.get_x(participant);
1121        if x > 0.0 {
1122            let width = state.get_participant_width(participant);
1123            let left = x - width / 2.0;
1124            let right = x + width / 2.0;
1125            *min_left = Some(min_left.map_or(left, |m| m.min(left)));
1126            *max_right = Some(max_right.map_or(right, |m| m.max(right)));
1127            if participant == leftmost_id {
1128                *includes_leftmost = true;
1129            }
1130        }
1131    }
1132
1133    fn process_items(
1134        items: &[Item],
1135        state: &RenderState,
1136        min_left: &mut Option<f64>,
1137        max_right: &mut Option<f64>,
1138        includes_leftmost: &mut bool,
1139        leftmost_id: &str,
1140    ) {
1141        for item in items {
1142            match item {
1143                Item::Message { from, to, .. } => {
1144                    update_bounds(
1145                        from,
1146                        state,
1147                        min_left,
1148                        max_right,
1149                        includes_leftmost,
1150                        leftmost_id,
1151                    );
1152                    update_bounds(
1153                        to,
1154                        state,
1155                        min_left,
1156                        max_right,
1157                        includes_leftmost,
1158                        leftmost_id,
1159                    );
1160                }
1161                Item::Note { participants, .. } => {
1162                    for p in participants {
1163                        update_bounds(
1164                            p,
1165                            state,
1166                            min_left,
1167                            max_right,
1168                            includes_leftmost,
1169                            leftmost_id,
1170                        );
1171                    }
1172                }
1173                Item::Block {
1174                    items, else_items, ..
1175                } => {
1176                    process_items(
1177                        items,
1178                        state,
1179                        min_left,
1180                        max_right,
1181                        includes_leftmost,
1182                        leftmost_id,
1183                    );
1184                    if let Some(else_items) = else_items {
1185                        process_items(
1186                            else_items,
1187                            state,
1188                            min_left,
1189                            max_right,
1190                            includes_leftmost,
1191                            leftmost_id,
1192                        );
1193                    }
1194                }
1195                Item::Activate { participant }
1196                | Item::Deactivate { participant }
1197                | Item::Destroy { participant } => {
1198                    update_bounds(
1199                        participant,
1200                        state,
1201                        min_left,
1202                        max_right,
1203                        includes_leftmost,
1204                        leftmost_id,
1205                    );
1206                }
1207                _ => {}
1208            }
1209        }
1210    }
1211
1212    process_items(
1213        items,
1214        state,
1215        &mut min_left,
1216        &mut max_right,
1217        &mut includes_leftmost,
1218        leftmost_id,
1219    );
1220
1221    match (min_left, max_right) {
1222        (Some(min), Some(max)) => Some((min, max, includes_leftmost)),
1223        _ => None,
1224    }
1225}
1226
1227/// Calculate block x boundaries based on involved participants and label length
1228fn calculate_block_bounds_with_label(
1229    items: &[Item],
1230    else_items: Option<&[Item]>,
1231    label: &str,
1232    kind: &str,
1233    depth: usize,
1234    state: &RenderState,
1235) -> (f64, f64) {
1236    let mut all_items: Vec<&Item> = items.iter().collect();
1237    if let Some(else_items) = else_items {
1238        all_items.extend(else_items.iter());
1239    }
1240
1241    // Convert Vec<&Item> to slice for find_involved_participants
1242    let items_slice: Vec<Item> = all_items.into_iter().cloned().collect();
1243
1244    let (base_x1, base_x2) =
1245        if let Some((min_left, max_right, _includes_leftmost)) =
1246            find_involved_participants(&items_slice, state)
1247        {
1248            let margin = state.config.block_margin;
1249            (min_left - margin, max_right + margin)
1250        } else {
1251            // Fallback to full width if no participants found
1252            (state.block_left(), state.block_right())
1253        };
1254
1255    // Calculate minimum width needed for label
1256    // Pentagon width + gap + condition label width + right margin
1257    let pentagon_width = block_tab_width(kind);
1258    let label_font_size = state.config.font_size - 1.0;
1259    let label_padding_x = 6.0;
1260    let condition_width = if label.is_empty() {
1261        0.0
1262    } else {
1263        let condition_text = format!("[{}]", label);
1264        let base_width =
1265            (estimate_text_width(&condition_text, label_font_size) - TEXT_WIDTH_PADDING).max(0.0);
1266        base_width + label_padding_x * 2.0
1267    };
1268    let min_label_width = pentagon_width + 8.0 + condition_width + 20.0; // Extra right margin
1269
1270    // Ensure block is wide enough for the label
1271    let current_width = base_x2 - base_x1;
1272    let (mut x1, mut x2) = if current_width < min_label_width {
1273        // Extend the right side to accommodate the label
1274        (base_x1, base_x1 + min_label_width)
1275    } else {
1276        (base_x1, base_x2)
1277    };
1278
1279    // Inset nested blocks so they sit inside their parent with padding.
1280    let nested_padding = depth as f64 * 20.0;
1281    if nested_padding > 0.0 {
1282        let available = x2 - x1;
1283        let max_padding = ((available - min_label_width) / 2.0).max(0.0);
1284        let inset = nested_padding.min(max_padding);
1285        x1 += inset;
1286        x2 -= inset;
1287    }
1288
1289    // Even when including leftmost participant, keep moderate margin from participant box
1290    // (don't extend to padding)
1291    // Note: WSD places blocks close to participants
1292
1293    (x1, x2)
1294}
1295
1296/// Pre-calculate block backgrounds by doing a dry run
1297fn collect_block_backgrounds(
1298    state: &mut RenderState,
1299    items: &[Item],
1300    depth: usize,
1301    active_activation_count: &mut usize,
1302) {
1303    for item in items {
1304        match item {
1305            Item::Message {
1306                text,
1307                from,
1308                to,
1309                arrow,
1310                activate,
1311                deactivate,
1312                create,
1313                ..
1314            } => {
1315                state.apply_else_return_gap(arrow);
1316                let is_self = from == to;
1317                let line_count = text.split("\\n").count();
1318                let delay_offset = arrow.delay.map(|d| d as f64 * DELAY_UNIT).unwrap_or(0.0);
1319
1320                if is_self {
1321                    state.current_y += self_message_y_advance(&state.config, line_count);
1322                } else {
1323                    state.current_y += regular_message_y_advance(&state.config, line_count, delay_offset);
1324                }
1325
1326                if *create {
1327                    state.current_y += state.config.row_height;
1328                }
1329
1330                state.apply_serial_first_row_gap();
1331                if *activate {
1332                    *active_activation_count += 1;
1333                }
1334                if *deactivate && *active_activation_count > 0 {
1335                    *active_activation_count -= 1;
1336                }
1337            }
1338            Item::Note { text, .. } => {
1339                let line_count = text.split("\\n").count();
1340                state.current_y += note_y_advance(&state.config, line_count);
1341            }
1342            Item::State { text, .. } => {
1343                let line_count = text.split("\\n").count();
1344                state.current_y += state_y_advance(&state.config, line_count);
1345            }
1346            Item::Ref { text, .. } => {
1347                let line_count = text.split("\\n").count();
1348                state.current_y += ref_y_advance(&state.config, line_count);
1349            }
1350            Item::Description { text } => {
1351                let line_count = text.split("\\n").count();
1352                state.current_y += description_y_advance(&state.config, line_count);
1353            }
1354            Item::Destroy { .. } => {
1355                state.current_y += state.config.row_height;
1356            }
1357            Item::Activate { .. } => {
1358                *active_activation_count += 1;
1359            }
1360            Item::Deactivate { .. } => {
1361                if *active_activation_count > 0 {
1362                    *active_activation_count -= 1;
1363                }
1364            }
1365            Item::Block {
1366                kind,
1367                label,
1368                items,
1369                else_items,
1370                else_label,
1371            } => {
1372                if block_is_parallel(kind) {
1373                    state.push_parallel();
1374                    let start_y = state.current_y;
1375                    let mut max_end_y = start_y;
1376                    let start_activation_count = *active_activation_count;
1377                    for item in items {
1378                        state.current_y = start_y;
1379                        *active_activation_count = start_activation_count;
1380                        collect_block_backgrounds(
1381                            state,
1382                            std::slice::from_ref(item),
1383                            depth,
1384                            active_activation_count,
1385                        );
1386                        if state.current_y > max_end_y {
1387                            max_end_y = state.current_y;
1388                        }
1389                    }
1390                    *active_activation_count = start_activation_count;
1391                    let gap = if parallel_needs_gap(items) {
1392                        state.config.row_height
1393                    } else {
1394                        0.0
1395                    };
1396                    state.current_y = max_end_y + gap;
1397                    state.pop_parallel();
1398                    continue;
1399                }
1400
1401                if matches!(kind, BlockKind::Serial) {
1402                    state.push_serial_first_row_pending();
1403                    collect_block_backgrounds(state, items, depth, active_activation_count);
1404                    if let Some(else_items) = else_items {
1405                        collect_block_backgrounds(
1406                            state,
1407                            else_items,
1408                            depth,
1409                            active_activation_count,
1410                        );
1411                    }
1412                    state.pop_serial_first_row_pending();
1413                    continue;
1414                }
1415
1416                if !block_has_frame(kind) {
1417                    collect_block_backgrounds(state, items, depth, active_activation_count);
1418                    if let Some(else_items) = else_items {
1419                        collect_block_backgrounds(
1420                            state,
1421                            else_items,
1422                            depth,
1423                            active_activation_count,
1424                        );
1425                    }
1426                    continue;
1427                }
1428
1429                let start_y = state.current_y;
1430                let frame_shift = block_frame_shift(depth);
1431                let frame_start_y = start_y - frame_shift;
1432
1433                // Calculate bounds based on involved participants and label width
1434                let (x1, x2) = calculate_block_bounds_with_label(
1435                    items,
1436                    else_items.as_deref(),
1437                    label,
1438                    kind.as_str(),
1439                    depth,
1440                    state,
1441                );
1442
1443                state.current_y += block_header_space(&state.config, depth);
1444                collect_block_backgrounds(state, items, depth + 1, active_activation_count);
1445
1446                // Add padding before else line (small)
1447                let else_y = if else_items.is_some() {
1448                    state.current_y += block_else_before(&state.config, depth);
1449                    Some(state.current_y)
1450                } else {
1451                    None
1452                };
1453
1454                if let Some(else_items) = else_items {
1455                    state.push_else_return_pending();
1456                    // Add padding after else line (sufficient gap)
1457                    state.current_y += block_else_after(&state.config, depth);
1458                    collect_block_backgrounds(
1459                        state,
1460                        else_items,
1461                        depth + 1,
1462                        active_activation_count,
1463                    );
1464                    state.pop_else_return_pending();
1465                }
1466
1467                // Block bottom = current Y + footer padding
1468                // (prevent messages from overflowing block)
1469                let end_y = state.current_y + block_footer_padding(&state.config, depth);
1470                let frame_end_y = end_y - frame_shift;
1471                state.current_y = end_y + state.config.row_height;
1472
1473                // Collect this block's background
1474                state.add_block_background(x1, frame_start_y, x2 - x1, frame_end_y - frame_start_y);
1475                // Collect this block's label for rendering above activations/lifelines
1476                state.add_block_label(
1477                    x1,
1478                    frame_start_y,
1479                    frame_end_y,
1480                    x2,
1481                    kind.as_str(),
1482                    label,
1483                    else_y,
1484                    else_label.clone(),
1485                );
1486            }
1487            _ => {}
1488        }
1489    }
1490}
1491
1492/// Render all collected block backgrounds
1493fn render_block_backgrounds(svg: &mut String, state: &RenderState) {
1494    let theme = &state.config.theme;
1495    for bg in &state.block_backgrounds {
1496        writeln!(
1497            svg,
1498            r##"<rect x="{x}" y="{y}" width="{w}" height="{h}" fill="{fill}" stroke="none"/>"##,
1499            x = bg.x,
1500            y = bg.y,
1501            w = bg.width,
1502            h = bg.height,
1503            fill = theme.block_fill
1504        )
1505        .unwrap();
1506    }
1507}
1508
1509/// Render all collected block labels (frame, pentagon, condition text, else divider)
1510/// This is called AFTER activations are drawn so labels appear on top
1511fn render_block_labels(svg: &mut String, state: &RenderState) {
1512    let theme = &state.config.theme;
1513
1514    for bl in &state.block_labels {
1515        let x1 = bl.x1;
1516        let x2 = bl.x2;
1517        let start_y = bl.start_y;
1518        let end_y = bl.end_y;
1519
1520        // Draw block frame
1521        writeln!(
1522            svg,
1523            r#"<rect x="{x}" y="{y}" width="{w}" height="{h}" class="block"/>"#,
1524            x = x1,
1525            y = start_y,
1526            w = x2 - x1,
1527            h = end_y - start_y
1528        )
1529        .unwrap();
1530
1531        // Pentagon/tab-shaped label (WSD style)
1532        let label_text = &bl.kind;
1533        let label_width = block_tab_width(label_text);
1534        let label_height = BLOCK_LABEL_HEIGHT;
1535        let label_text_offset = 16.0;
1536        let notch_size = 5.0;
1537
1538        // Pentagon path
1539        let pentagon_path = format!(
1540            "M {x1} {y1} L {x2} {y1} L {x2} {y2} L {x3} {y3} L {x1} {y3} Z",
1541            x1 = x1,
1542            y1 = start_y,
1543            x2 = x1 + label_width,
1544            y2 = start_y + label_height - notch_size,
1545            x3 = x1 + label_width - notch_size,
1546            y3 = start_y + label_height
1547        );
1548
1549        writeln!(
1550            svg,
1551            r##"<path d="{path}" fill="{fill}" stroke="{stroke}"/>"##,
1552            path = pentagon_path,
1553            fill = theme.block_label_fill,
1554            stroke = theme.block_stroke
1555        )
1556        .unwrap();
1557
1558        // Block type label text
1559        writeln!(
1560            svg,
1561            r#"<text x="{x}" y="{y}" class="block-label">{kind}</text>"#,
1562            x = x1 + 5.0,
1563            y = start_y + label_text_offset,
1564            kind = label_text
1565        )
1566        .unwrap();
1567
1568        // Condition label (text only, no background per WSD style)
1569        if !bl.label.is_empty() {
1570            let condition_text = format!("[{}]", bl.label);
1571            let text_x = x1 + label_width + 8.0;
1572            let text_y = start_y + label_text_offset;
1573
1574            writeln!(
1575                svg,
1576                r#"<text x="{x}" y="{y}" class="block-label">{label}</text>"#,
1577                x = text_x,
1578                y = text_y,
1579                label = escape_xml(&condition_text)
1580            )
1581            .unwrap();
1582        }
1583
1584        // Else separator (dashed line + else label text)
1585        if let Some(else_y) = bl.else_y {
1586            // Draw dashed line
1587            writeln!(
1588                svg,
1589                r##"<line x1="{x1}" y1="{y}" x2="{x2}" y2="{y}" stroke="{c}" stroke-dasharray="5,3"/>"##,
1590                x1 = x1,
1591                y = else_y,
1592                x2 = x2,
1593                c = theme.block_stroke
1594            )
1595            .unwrap();
1596
1597            // Draw else label text at same X position as alt condition label
1598            if let Some(else_label_text) = &bl.else_label {
1599                let condition_text = format!("[{}]", else_label_text);
1600                let text_x = x1 + label_width + 8.0; // Same X as alt's [Success case]
1601                let text_y = else_y + label_text_offset; // Below the dashed line
1602
1603                writeln!(
1604                    svg,
1605                    r#"<text x="{x}" y="{y}" class="block-label">{label}</text>"#,
1606                    x = text_x,
1607                    y = text_y,
1608                    label = escape_xml(&condition_text)
1609                )
1610                .unwrap();
1611            }
1612        }
1613    }
1614}
1615
1616/// Render a diagram to SVG
1617pub fn render(diagram: &Diagram) -> String {
1618    render_with_config(diagram, Config::default())
1619}
1620
1621/// Render a diagram to SVG with custom config
1622pub fn render_with_config(diagram: &Diagram, config: Config) -> String {
1623    let participants = diagram.participants();
1624    let has_title = diagram.title.is_some();
1625    let footer_style = diagram.options.footer;
1626    let mut state = RenderState::new(
1627        config,
1628        participants,
1629        &diagram.items,
1630        has_title,
1631        footer_style,
1632    );
1633    let mut svg = String::new();
1634
1635    // Pre-calculate height
1636    let content_height = calculate_height(&diagram.items, &state.config, 0);
1637    let title_space = if has_title {
1638        state.config.title_height
1639    } else {
1640        0.0
1641    };
1642    let footer_space = match footer_style {
1643        FooterStyle::Box => state.config.header_height,
1644        FooterStyle::Bar | FooterStyle::None => 0.0,
1645    };
1646    let footer_label_extra = match footer_style {
1647        FooterStyle::Box => actor_footer_extra(&state.participants, &state.config),
1648        FooterStyle::Bar | FooterStyle::None => 0.0,
1649    };
1650    let footer_margin = state.config.row_height; // Space between content and footer
1651    let base_total_height = state.config.padding * 2.0
1652        + title_space
1653        + state.config.header_height
1654        + content_height
1655        + footer_margin
1656        + footer_space;
1657    let total_height = base_total_height + footer_label_extra;
1658    let total_width = state.diagram_width();
1659
1660    // SVG header
1661    writeln!(
1662        &mut svg,
1663        r#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 {w} {h}" width="{w}" height="{h}">"#,
1664        w = total_width,
1665        h = total_height
1666    )
1667    .unwrap();
1668
1669    // Styles based on theme
1670    let theme = &state.config.theme;
1671    let lifeline_dash = match theme.lifeline_style {
1672        LifelineStyle::Dashed => "stroke-dasharray: 5,5;",
1673        LifelineStyle::Solid => "",
1674    };
1675
1676    svg.push_str("<defs>\n");
1677    svg.push_str("<style>\n");
1678    writeln!(
1679        &mut svg,
1680        ".participant {{ fill: {fill}; stroke: {stroke}; stroke-width: 2; }}",
1681        fill = theme.participant_fill,
1682        stroke = theme.participant_stroke
1683    )
1684    .unwrap();
1685    writeln!(
1686        &mut svg,
1687        ".participant-text {{ font-family: {f}; font-size: {s}px; text-anchor: middle; dominant-baseline: middle; fill: {c}; }}",
1688        f = theme.font_family,
1689        s = state.config.font_size,
1690        c = theme.participant_text
1691    )
1692    .unwrap();
1693    writeln!(
1694        &mut svg,
1695        ".lifeline {{ stroke: {c}; stroke-width: 1; {dash} }}",
1696        c = theme.lifeline_color,
1697        dash = lifeline_dash
1698    )
1699    .unwrap();
1700    writeln!(
1701        &mut svg,
1702        ".message {{ stroke: {c}; stroke-width: 1.5; fill: none; }}",
1703        c = theme.message_color
1704    )
1705    .unwrap();
1706    writeln!(
1707        &mut svg,
1708        ".message-dashed {{ stroke: {c}; stroke-width: 1.5; fill: none; stroke-dasharray: 5,3; }}",
1709        c = theme.message_color
1710    )
1711    .unwrap();
1712    writeln!(
1713        &mut svg,
1714        ".message-text {{ font-family: {f}; font-size: {s}px; fill: {c}; stroke: none; }}",
1715        f = theme.font_family,
1716        s = state.config.font_size,
1717        c = theme.message_text_color
1718    )
1719    .unwrap();
1720    writeln!(
1721        &mut svg,
1722        ".note {{ fill: {fill}; stroke: {stroke}; stroke-width: 1; }}",
1723        fill = theme.note_fill,
1724        stroke = theme.note_stroke
1725    )
1726    .unwrap();
1727    writeln!(
1728        &mut svg,
1729        ".note-text {{ font-family: {f}; font-size: {s}px; fill: {c}; }}",
1730        f = theme.font_family,
1731        s = state.config.font_size - 1.0,
1732        c = theme.note_text_color
1733    )
1734    .unwrap();
1735    writeln!(
1736        &mut svg,
1737        ".block {{ fill: none; stroke: {c}; stroke-width: 1; }}",
1738        c = theme.block_stroke
1739    )
1740    .unwrap();
1741    writeln!(
1742        &mut svg,
1743        ".block-label {{ font-family: {f}; font-size: {s}px; font-weight: bold; fill: {c}; }}",
1744        f = theme.font_family,
1745        s = state.config.font_size - 1.0,
1746        c = theme.message_text_color
1747    )
1748    .unwrap();
1749    writeln!(
1750        &mut svg,
1751        ".activation {{ fill: {fill}; stroke: {stroke}; stroke-width: 1; }}",
1752        fill = theme.activation_fill,
1753        stroke = theme.activation_stroke
1754    )
1755    .unwrap();
1756    writeln!(
1757        &mut svg,
1758        ".actor-head {{ fill: {fill}; stroke: {stroke}; stroke-width: 2; }}",
1759        fill = theme.actor_fill,
1760        stroke = theme.actor_stroke
1761    )
1762    .unwrap();
1763    writeln!(
1764        &mut svg,
1765        ".actor-body {{ stroke: {c}; stroke-width: 2; fill: none; }}",
1766        c = theme.actor_stroke
1767    )
1768    .unwrap();
1769    writeln!(
1770        &mut svg,
1771        ".title {{ font-family: {f}; font-size: {s}px; font-weight: bold; text-anchor: middle; fill: {c}; }}",
1772        f = theme.font_family,
1773        s = state.config.font_size + 4.0,
1774        c = theme.message_text_color
1775    )
1776    .unwrap();
1777    // Arrowhead styles
1778    writeln!(
1779        &mut svg,
1780        ".arrowhead {{ fill: {c}; stroke: none; }}",
1781        c = theme.message_color
1782    )
1783    .unwrap();
1784    writeln!(
1785        &mut svg,
1786        ".arrowhead-open {{ fill: none; stroke: {c}; stroke-width: 1; }}",
1787        c = theme.message_color
1788    )
1789    .unwrap();
1790    svg.push_str("</style>\n");
1791    svg.push_str("</defs>\n");
1792
1793    // Background with theme color
1794    writeln!(
1795        &mut svg,
1796        r##"<rect width="100%" height="100%" fill="{bg}"/>"##,
1797        bg = theme.background
1798    )
1799    .unwrap();
1800
1801    // Title
1802    if let Some(title) = &diagram.title {
1803        let title_y = state.config.padding + state.config.font_size + 7.36; // WSD: 31.86
1804        writeln!(
1805            &mut svg,
1806            r#"<text x="{x}" y="{y}" class="title">{t}</text>"#,
1807            x = total_width / 2.0,
1808            y = title_y,
1809            t = escape_xml(title)
1810        )
1811        .unwrap();
1812    }
1813
1814    // Calculate footer position
1815    let header_y = state.header_top();
1816    let footer_y = match footer_style {
1817        FooterStyle::Box => base_total_height - state.config.padding - state.config.header_height,
1818        FooterStyle::Bar | FooterStyle::None => total_height - state.config.padding,
1819    };
1820
1821    // Pre-calculate block backgrounds (dry run)
1822    state.current_y = state.content_start();
1823    let mut active_activation_count = 0;
1824    collect_block_backgrounds(&mut state, &diagram.items, 0, &mut active_activation_count);
1825
1826    // Draw block backgrounds FIRST (behind lifelines)
1827    render_block_backgrounds(&mut svg, &state);
1828
1829    // Reset current_y for actual rendering
1830    state.current_y = state.content_start();
1831
1832    // Draw lifelines (behind messages but above block backgrounds)
1833    let lifeline_start = header_y + state.config.header_height;
1834    let lifeline_end = footer_y;
1835
1836    for p in &state.participants {
1837        let x = state.get_x(p.id());
1838        writeln!(
1839            &mut svg,
1840            r#"<line x1="{x}" y1="{y1}" x2="{x}" y2="{y2}" class="lifeline"/>"#,
1841            x = x,
1842            y1 = lifeline_start,
1843            y2 = lifeline_end
1844        )
1845        .unwrap();
1846    }
1847
1848    // Draw participant headers
1849    render_participant_headers(&mut svg, &state, header_y);
1850
1851    // Render items
1852    state.current_y = state.content_start();
1853    render_items(&mut svg, &mut state, &diagram.items, 0);
1854
1855    // Draw activation bars
1856    render_activations(&mut svg, &mut state, footer_y);
1857
1858    // Draw block labels AFTER activations so they appear on top
1859    render_block_labels(&mut svg, &state);
1860
1861    // Draw participant footers based on footer style option
1862    match state.footer_style {
1863        FooterStyle::Box => {
1864            render_participant_headers(&mut svg, &state, footer_y);
1865        }
1866        FooterStyle::Bar => {
1867            // Draw simple horizontal line across all participants
1868            let left = state.leftmost_x()
1869                - state.get_participant_width(
1870                    state.participants.first().map(|p| p.id()).unwrap_or(""),
1871                ) / 2.0;
1872            let right = state.rightmost_x()
1873                + state
1874                    .get_participant_width(state.participants.last().map(|p| p.id()).unwrap_or(""))
1875                    / 2.0;
1876            writeln!(
1877                &mut svg,
1878                r##"<line x1="{x1}" y1="{y}" x2="{x2}" y2="{y}" stroke="{c}" stroke-width="1"/>"##,
1879                x1 = left,
1880                y = footer_y,
1881                x2 = right,
1882                c = state.config.theme.lifeline_color
1883            )
1884            .unwrap();
1885        }
1886        FooterStyle::None => {
1887            // No footer at all
1888        }
1889    }
1890
1891    svg.push_str("</svg>\n");
1892    svg
1893}
1894
1895fn calculate_height(items: &[Item], config: &Config, depth: usize) -> f64 {
1896    fn inner(
1897        items: &[Item],
1898        config: &Config,
1899        depth: usize,
1900        else_pending: &mut Vec<bool>,
1901        serial_pending: &mut Vec<bool>,
1902        active_activation_count: &mut usize,
1903        parallel_depth: &mut usize,
1904    ) -> f64 {
1905        let mut height = 0.0;
1906        for item in items {
1907            match item {
1908                Item::Message {
1909                    from,
1910                    to,
1911                    text,
1912                    arrow,
1913                    create,
1914                    activate,
1915                    deactivate,
1916                    ..
1917                } => {
1918                    if let Some(pending) = else_pending.last_mut() {
1919                        if *pending && matches!(arrow.line, LineStyle::Dashed) {
1920                            *pending = false;
1921                        }
1922                    }
1923                    let is_self = from == to;
1924                    let line_count = text.split("\\n").count();
1925                    let delay_offset = arrow.delay.map(|d| d as f64 * DELAY_UNIT).unwrap_or(0.0);
1926                    if is_self {
1927                        height += self_message_y_advance(config, line_count);
1928                    } else {
1929                        height += regular_message_y_advance(config, line_count, delay_offset);
1930                    }
1931                    if *create {
1932                        height += config.row_height;
1933                    }
1934                    if let Some(pending) = serial_pending.last_mut() {
1935                        if *pending {
1936                            height += serial_first_row_gap(*parallel_depth);
1937                            *pending = false;
1938                        }
1939                    }
1940                    if *activate {
1941                        *active_activation_count += 1;
1942                    }
1943                    if *deactivate && *active_activation_count > 0 {
1944                        *active_activation_count -= 1;
1945                    }
1946                }
1947                Item::Note { text, .. } => {
1948                    let line_count = text.split("\\n").count();
1949                    height += note_y_advance(config, line_count);
1950                }
1951                Item::State { text, .. } => {
1952                    let line_count = text.split("\\n").count();
1953                    height += state_y_advance(config, line_count);
1954                }
1955                Item::Ref { text, .. } => {
1956                    let line_count = text.split("\\n").count();
1957                    height += ref_y_advance(config, line_count);
1958                }
1959                Item::Description { text } => {
1960                    let line_count = text.split("\\n").count();
1961                    height += description_y_advance(config, line_count);
1962                }
1963                Item::Block {
1964                    kind,
1965                    items,
1966                    else_items,
1967                    ..
1968                } => {
1969                    if block_is_parallel(kind) {
1970                        let mut max_branch_height = 0.0;
1971                        let base_activation_count = *active_activation_count;
1972                        *parallel_depth += 1;
1973                        for item in items {
1974                            *active_activation_count = base_activation_count;
1975                            let branch_height = inner(
1976                                std::slice::from_ref(item),
1977                                config,
1978                                depth,
1979                                else_pending,
1980                                serial_pending,
1981                                active_activation_count,
1982                                parallel_depth,
1983                            );
1984                            if branch_height > max_branch_height {
1985                                max_branch_height = branch_height;
1986                            }
1987                        }
1988                        *active_activation_count = base_activation_count;
1989                        if *parallel_depth > 0 {
1990                            *parallel_depth -= 1;
1991                        }
1992                        let gap = if parallel_needs_gap(items) {
1993                            config.row_height
1994                        } else {
1995                            0.0
1996                        };
1997                        height += max_branch_height + gap;
1998                        continue;
1999                    }
2000
2001                    if matches!(kind, BlockKind::Serial) {
2002                        serial_pending.push(true);
2003                        height += inner(
2004                            items,
2005                            config,
2006                            depth,
2007                            else_pending,
2008                            serial_pending,
2009                            active_activation_count,
2010                            parallel_depth,
2011                        );
2012                        if let Some(else_items) = else_items {
2013                            height += inner(
2014                                else_items,
2015                                config,
2016                                depth,
2017                                else_pending,
2018                                serial_pending,
2019                                active_activation_count,
2020                                parallel_depth,
2021                            );
2022                        }
2023                        serial_pending.pop();
2024                    } else if !block_has_frame(kind) {
2025                        height += inner(
2026                            items,
2027                            config,
2028                            depth,
2029                            else_pending,
2030                            serial_pending,
2031                            active_activation_count,
2032                            parallel_depth,
2033                        );
2034                        if let Some(else_items) = else_items {
2035                            height += inner(
2036                                else_items,
2037                                config,
2038                                depth,
2039                                else_pending,
2040                                serial_pending,
2041                                active_activation_count,
2042                                parallel_depth,
2043                            );
2044                        }
2045                    } else {
2046                        height += block_header_space(config, depth);
2047                        height += inner(
2048                            items,
2049                            config,
2050                            depth + 1,
2051                            else_pending,
2052                            serial_pending,
2053                            active_activation_count,
2054                            parallel_depth,
2055                        );
2056                        if let Some(else_items) = else_items {
2057                            else_pending.push(true);
2058                            // Padding before and after else line
2059                            height += block_else_before(config, depth) + block_else_after(config, depth);
2060                            height += inner(
2061                                else_items,
2062                                config,
2063                                depth + 1,
2064                                else_pending,
2065                                serial_pending,
2066                                active_activation_count,
2067                                parallel_depth,
2068                            );
2069                            else_pending.pop();
2070                        }
2071                        // Block bottom and trailing margin
2072                        height += block_end_y_advance(config, depth);
2073                    }
2074                }
2075                Item::Activate { .. } => {
2076                    *active_activation_count += 1;
2077                }
2078                Item::Deactivate { .. } => {
2079                    if *active_activation_count > 0 {
2080                        *active_activation_count -= 1;
2081                    }
2082                }
2083                Item::Destroy { .. } => {
2084                    height += config.row_height;
2085                }
2086                Item::ParticipantDecl { .. } => {}
2087                Item::Autonumber { .. } => {}
2088                Item::DiagramOption { .. } => {} // Options don't take space
2089            }
2090        }
2091        height
2092    }
2093
2094    let mut else_pending = Vec::new();
2095    let mut serial_pending = Vec::new();
2096    let mut active_activation_count = 0;
2097    let mut parallel_depth = 0;
2098    inner(
2099        items,
2100        config,
2101        depth,
2102        &mut else_pending,
2103        &mut serial_pending,
2104        &mut active_activation_count,
2105        &mut parallel_depth,
2106    )
2107}
2108
2109fn render_participant_headers(svg: &mut String, state: &RenderState, y: f64) {
2110    let shape = state.config.theme.participant_shape;
2111
2112    for p in &state.participants {
2113        let x = state.get_x(p.id());
2114        let p_width = state.get_participant_width(p.id());
2115        let box_x = x - p_width / 2.0;
2116
2117        match p.kind {
2118            ParticipantKind::Participant => {
2119                // Draw shape based on theme
2120                match shape {
2121                    ParticipantShape::Rectangle => {
2122                        writeln!(
2123                            svg,
2124                            r#"<rect x="{x}" y="{y}" width="{w}" height="{h}" class="participant"/>"#,
2125                            x = box_x,
2126                            y = y,
2127                            w = p_width,
2128                            h = state.config.header_height
2129                        )
2130                        .unwrap();
2131                    }
2132                    ParticipantShape::RoundedRect => {
2133                        writeln!(
2134                            svg,
2135                            r#"<rect x="{x}" y="{y}" width="{w}" height="{h}" rx="8" ry="8" class="participant"/>"#,
2136                            x = box_x,
2137                            y = y,
2138                            w = p_width,
2139                            h = state.config.header_height
2140                        )
2141                        .unwrap();
2142                    }
2143                    ParticipantShape::Circle => {
2144                        // Draw ellipse/circle that fits in the header area
2145                        let rx = p_width / 2.0 - 5.0;
2146                        let ry = state.config.header_height / 2.0 - 2.0;
2147                        writeln!(
2148                            svg,
2149                            r#"<ellipse cx="{cx}" cy="{cy}" rx="{rx}" ry="{ry}" class="participant"/>"#,
2150                            cx = x,
2151                            cy = y + state.config.header_height / 2.0,
2152                            rx = rx,
2153                            ry = ry
2154                        )
2155                        .unwrap();
2156                    }
2157                }
2158                // Name centered in box (handle multiline with \n)
2159                let lines: Vec<&str> = p.name.split("\\n").collect();
2160                if lines.len() == 1 {
2161                    writeln!(
2162                        svg,
2163                        r#"<text x="{x}" y="{y}" class="participant-text">{name}</text>"#,
2164                        x = x,
2165                        y = y + state.config.header_height / 2.0 + 5.0,
2166                        name = escape_xml(&p.name)
2167                    )
2168                    .unwrap();
2169                } else {
2170                    let line_height = state.config.font_size + 2.0;
2171                    let total_height = lines.len() as f64 * line_height;
2172                    let start_y = y + state.config.header_height / 2.0 - total_height / 2.0
2173                        + line_height * 0.8;
2174                    write!(svg, r#"<text x="{x}" class="participant-text">"#, x = x).unwrap();
2175                    for (i, line) in lines.iter().enumerate() {
2176                        let dy = if i == 0 { start_y } else { line_height };
2177                        if i == 0 {
2178                            writeln!(
2179                                svg,
2180                                r#"<tspan x="{x}" y="{y}">{text}</tspan>"#,
2181                                x = x,
2182                                y = dy,
2183                                text = escape_xml(line)
2184                            )
2185                            .unwrap();
2186                        } else {
2187                            writeln!(
2188                                svg,
2189                                r#"<tspan x="{x}" dy="{dy}">{text}</tspan>"#,
2190                                x = x,
2191                                dy = dy,
2192                                text = escape_xml(line)
2193                            )
2194                            .unwrap();
2195                        }
2196                    }
2197                    writeln!(svg, "</text>").unwrap();
2198                }
2199            }
2200            ParticipantKind::Actor => {
2201                // Stick figure at top of header area, name below within header
2202                let head_r = 8.0;
2203                let body_len = 12.0;
2204                let arm_len = 10.0;
2205                let leg_len = 10.0;
2206                let figure_height = 38.0; // head(16) + body(12) + legs(10)
2207
2208                // Position figure at top with small margin
2209                let fig_top = y + 8.0;
2210                let fig_center_y = fig_top + head_r + body_len / 2.0;
2211                let arm_y = fig_center_y + 2.0;
2212
2213                // Head
2214                writeln!(
2215                    svg,
2216                    r#"<circle cx="{x}" cy="{cy}" r="{r}" class="actor-head"/>"#,
2217                    x = x,
2218                    cy = fig_center_y - body_len / 2.0 - head_r,
2219                    r = head_r
2220                )
2221                .unwrap();
2222                // Body
2223                writeln!(
2224                    svg,
2225                    r#"<line x1="{x}" y1="{y1}" x2="{x}" y2="{y2}" class="actor-body"/>"#,
2226                    x = x,
2227                    y1 = fig_center_y - body_len / 2.0,
2228                    y2 = fig_center_y + body_len / 2.0
2229                )
2230                .unwrap();
2231                // Arms
2232                writeln!(
2233                    svg,
2234                    r#"<line x1="{x1}" y1="{y}" x2="{x2}" y2="{y}" class="actor-body"/>"#,
2235                    x1 = x - arm_len,
2236                    y = arm_y,
2237                    x2 = x + arm_len
2238                )
2239                .unwrap();
2240                // Left leg
2241                writeln!(
2242                    svg,
2243                    r#"<line x1="{x}" y1="{y1}" x2="{x2}" y2="{y2}" class="actor-body"/>"#,
2244                    x = x,
2245                    y1 = fig_center_y + body_len / 2.0,
2246                    x2 = x - leg_len * 0.6,
2247                    y2 = fig_center_y + body_len / 2.0 + leg_len
2248                )
2249                .unwrap();
2250                // Right leg
2251                writeln!(
2252                    svg,
2253                    r#"<line x1="{x}" y1="{y1}" x2="{x2}" y2="{y2}" class="actor-body"/>"#,
2254                    x = x,
2255                    y1 = fig_center_y + body_len / 2.0,
2256                    x2 = x + leg_len * 0.6,
2257                    y2 = fig_center_y + body_len / 2.0 + leg_len
2258                )
2259                .unwrap();
2260                // Name below figure (within header)
2261                let name_lines: Vec<&str> = p.name.split("\\n").collect();
2262                let name_start_y = fig_top + figure_height + 5.0;
2263                if name_lines.len() == 1 {
2264                    writeln!(
2265                        svg,
2266                        r#"<text x="{x}" y="{y}" class="participant-text">{name}</text>"#,
2267                        x = x,
2268                        y = name_start_y + state.config.font_size,
2269                        name = escape_xml(&p.name)
2270                    )
2271                    .unwrap();
2272                } else {
2273                    // Multiline actor name using tspan
2274                    let line_height = state.config.font_size + 2.0;
2275                    writeln!(svg, r#"<text x="{x}" class="participant-text">"#, x = x).unwrap();
2276                    for (i, line) in name_lines.iter().enumerate() {
2277                        if i == 0 {
2278                            writeln!(
2279                                svg,
2280                                r#"<tspan x="{x}" y="{y}">{text}</tspan>"#,
2281                                x = x,
2282                                y = name_start_y + state.config.font_size,
2283                                text = escape_xml(line)
2284                            )
2285                            .unwrap();
2286                        } else {
2287                            writeln!(
2288                                svg,
2289                                r#"<tspan x="{x}" dy="{dy}">{text}</tspan>"#,
2290                                x = x,
2291                                dy = line_height,
2292                                text = escape_xml(line)
2293                            )
2294                            .unwrap();
2295                        }
2296                    }
2297                    writeln!(svg, "</text>").unwrap();
2298                }
2299            }
2300        }
2301    }
2302}
2303
2304fn render_items(svg: &mut String, state: &mut RenderState, items: &[Item], depth: usize) {
2305    for item in items {
2306        match item {
2307            Item::Message {
2308                from,
2309                to,
2310                text,
2311                arrow,
2312                activate,
2313                deactivate,
2314                create,
2315                ..
2316            } => {
2317                render_message(
2318                    svg,
2319                    state,
2320                    from,
2321                    to,
2322                    text,
2323                    arrow,
2324                    *activate,
2325                    *deactivate,
2326                    *create,
2327                    depth,
2328                );
2329            }
2330            Item::Note {
2331                position,
2332                participants,
2333                text,
2334            } => {
2335                render_note(svg, state, position, participants, text);
2336            }
2337            Item::Block {
2338                kind,
2339                label,
2340                items,
2341                else_items,
2342                else_label,
2343            } => {
2344                render_block(svg, state, kind, label, items, else_items.as_deref(), else_label.as_deref(), depth);
2345            }
2346            Item::Activate { participant } => {
2347                let y = state.current_y;
2348                state
2349                    .activations
2350                    .entry(participant.clone())
2351                    .or_default()
2352                    .push((y, None));
2353            }
2354            Item::Deactivate { participant } => {
2355                if let Some(acts) = state.activations.get_mut(participant) {
2356                    if let Some(act) = acts.last_mut() {
2357                        if act.1.is_none() {
2358                            act.1 = Some(state.current_y);
2359                        }
2360                    }
2361                }
2362            }
2363            Item::Destroy { participant } => {
2364                // X mark should be at the previous message's arrow position (WSD compatible)
2365                // After a message, current_y is incremented by row_height, so we subtract it back
2366                let destroy_y = state.current_y - state.config.row_height;
2367                state.destroyed.insert(participant.clone(), destroy_y);
2368                // Draw X mark on the lifeline
2369                let x = state.get_x(participant);
2370                let y = destroy_y;
2371                let size = 15.0; // WSD uses 15px for X mark size
2372                let theme = &state.config.theme;
2373                writeln!(
2374                    svg,
2375                    r#"<line x1="{x1}" y1="{y1}" x2="{x2}" y2="{y2}" stroke="{stroke}" stroke-width="2"/>"#,
2376                    x1 = x - size,
2377                    y1 = y - size,
2378                    x2 = x + size,
2379                    y2 = y + size,
2380                    stroke = theme.message_color
2381                )
2382                .unwrap();
2383                writeln!(
2384                    svg,
2385                    r#"<line x1="{x1}" y1="{y1}" x2="{x2}" y2="{y2}" stroke="{stroke}" stroke-width="2"/>"#,
2386                    x1 = x + size,
2387                    y1 = y - size,
2388                    x2 = x - size,
2389                    y2 = y + size,
2390                    stroke = theme.message_color
2391                )
2392                .unwrap();
2393                state.current_y += state.config.row_height;
2394            }
2395            Item::Autonumber { enabled, start } => {
2396                if *enabled {
2397                    state.autonumber = Some(start.unwrap_or(1));
2398                } else {
2399                    state.autonumber = None;
2400                }
2401            }
2402            Item::ParticipantDecl { .. } => {
2403                // Already processed
2404            }
2405            Item::State { participants, text } => {
2406                render_state(svg, state, participants, text);
2407            }
2408            Item::Ref {
2409                participants,
2410                text,
2411                input_from,
2412                input_label,
2413                output_to,
2414                output_label,
2415            } => {
2416                render_ref(
2417                    svg,
2418                    state,
2419                    participants,
2420                    text,
2421                    input_from.as_deref(),
2422                    input_label.as_deref(),
2423                    output_to.as_deref(),
2424                    output_label.as_deref(),
2425                );
2426            }
2427            Item::DiagramOption { .. } => {
2428                // Options are processed at render start, not during item rendering
2429            }
2430            Item::Description { text } => {
2431                render_description(svg, state, text);
2432            }
2433        }
2434    }
2435}
2436
2437fn render_message(
2438    svg: &mut String,
2439    state: &mut RenderState,
2440    from: &str,
2441    to: &str,
2442    text: &str,
2443    arrow: &Arrow,
2444    activate: bool,
2445    deactivate: bool,
2446    create: bool,
2447    _depth: usize,
2448) {
2449    // Get base lifeline positions (used for text centering and direction calculation)
2450    let base_x1 = state.get_x(from);
2451    let base_x2 = state.get_x(to);
2452
2453    state.apply_else_return_gap(arrow);
2454
2455    let is_self = from == to;
2456    let line_class = match arrow.line {
2457        LineStyle::Solid => "message",
2458        LineStyle::Dashed => "message-dashed",
2459    };
2460    let is_filled = matches!(arrow.head, ArrowHead::Filled);
2461
2462    // Get autonumber prefix
2463    let num_prefix = state
2464        .next_number()
2465        .map(|n| format!("{}. ", n))
2466        .unwrap_or_default();
2467
2468    // Calculate text lines and height
2469    let display_text = format!("{}{}", num_prefix, text);
2470    let lines: Vec<&str> = display_text.split("\\n").collect();
2471    let line_height = state.config.font_size + 4.0;
2472    let extra_height = if !is_self && lines.len() > 1 {
2473        // Use actual line_height (not spacing_line_height) to match text rendering
2474        // Extra lines + MESSAGE_TEXT_ABOVE_ARROW offset (text is rendered above arrow)
2475        (lines.len() - 1) as f64 * line_height + MESSAGE_TEXT_ABOVE_ARROW
2476    } else {
2477        0.0
2478    };
2479
2480    // Add space BEFORE the message for multiline text (text is rendered above arrow)
2481    if !is_self && lines.len() > 1 {
2482        state.current_y += extra_height;
2483    }
2484
2485    let y = state.current_y;
2486    let has_label_text = lines.iter().any(|line| !line.trim().is_empty());
2487
2488    // Calculate activation-aware arrow endpoints
2489    let going_right = base_x2 > base_x1;
2490    let x1 = state.get_arrow_start_x(from, y, going_right);
2491    let x2 = state.get_arrow_end_x(to, y, !going_right);
2492
2493    // Open message group
2494    writeln!(svg, r#"<g class="message">"#).unwrap();
2495
2496    if is_self {
2497        // Self message - loop back
2498        let loop_width = 40.0;
2499        let text_block_height = lines.len() as f64 * line_height;
2500        // WSD: loop height equals text block height, no extra padding
2501        let loop_height = text_block_height.max(25.0);
2502        let arrow_end_x = x1;
2503        let arrow_end_y = y + loop_height;
2504        // Arrowhead points left (PI radians)
2505        let direction = std::f64::consts::PI;
2506        let arrow_points = arrowhead_points(arrow_end_x, arrow_end_y, direction);
2507
2508        writeln!(
2509            svg,
2510            r#"  <path d="M {x1} {y} L {x2} {y} L {x2} {y2} L {arrow_x} {y2}" class="{cls}"/>"#,
2511            x1 = x1,
2512            y = y,
2513            x2 = x1 + loop_width,
2514            y2 = y + loop_height,
2515            arrow_x = arrow_end_x + ARROWHEAD_SIZE,
2516            cls = line_class
2517        )
2518        .unwrap();
2519
2520        // Draw arrowhead as polygon or polyline
2521        if is_filled {
2522            writeln!(
2523                svg,
2524                r#"  <polygon points="{points}" class="arrowhead"/>"#,
2525                points = arrow_points
2526            )
2527            .unwrap();
2528        } else {
2529            writeln!(
2530                svg,
2531                r#"  <polyline points="{points}" class="arrowhead-open"/>"#,
2532                points = arrow_points
2533            )
2534            .unwrap();
2535        }
2536
2537        // Text - multiline support
2538        // Self-messages don't need collision detection - they're positioned to the right
2539        // of the loop and won't collide with regular centered messages
2540        let text_x = x1 + loop_width + 5.0;
2541        for (i, line) in lines.iter().enumerate() {
2542            let line_y = y + 4.0 + (i as f64 + 0.5) * line_height;
2543            writeln!(
2544                svg,
2545                r#"  <text x="{x}" y="{y}" class="message-text">{t}</text>"#,
2546                x = text_x,
2547                y = line_y,
2548                t = escape_xml(line)
2549            )
2550            .unwrap();
2551        }
2552
2553        // Close message group
2554        writeln!(svg, r#"</g>"#).unwrap();
2555
2556        let spacing = self_message_spacing(&state.config, lines.len());
2557        state.current_y += spacing;
2558    } else {
2559        // Regular message - check for delay
2560        let delay_offset = arrow.delay.map(|d| d as f64 * DELAY_UNIT).unwrap_or(0.0);
2561        let y2 = y + delay_offset;
2562
2563        // Text is centered between lifelines (not activation bar edges)
2564        let text_x = (base_x1 + base_x2) / 2.0;
2565        let text_y = (y + y2) / 2.0 - 6.0;  // WSD: label slightly above arrow
2566
2567        // Calculate arrowhead direction and shorten line to not overlap with arrowhead
2568        let direction = arrow_direction(x1, y, x2, y2);
2569        let arrow_points = arrowhead_points(x2, y2, direction);
2570
2571        // Shorten the line so it doesn't overlap with the arrowhead
2572        let line_end_x = x2 - ARROWHEAD_SIZE * direction.cos();
2573        let line_end_y = y2 - ARROWHEAD_SIZE * direction.sin();
2574
2575        // Draw arrow line (slanted if delay)
2576        writeln!(
2577            svg,
2578            r#"  <line x1="{x1}" y1="{y1}" x2="{lx2}" y2="{ly2}" class="{cls}"/>"#,
2579            x1 = x1,
2580            y1 = y,
2581            lx2 = line_end_x,
2582            ly2 = line_end_y,
2583            cls = line_class
2584        )
2585        .unwrap();
2586
2587        // Draw arrowhead as polygon or polyline
2588        if is_filled {
2589            writeln!(
2590                svg,
2591                r#"  <polygon points="{points}" class="arrowhead"/>"#,
2592                points = arrow_points
2593            )
2594            .unwrap();
2595        } else {
2596            writeln!(
2597                svg,
2598                r#"  <polyline points="{points}" class="arrowhead-open"/>"#,
2599                points = arrow_points
2600            )
2601            .unwrap();
2602        }
2603
2604        // Text with multiline support (positioned at midpoint of slanted line)
2605        let max_width = lines
2606            .iter()
2607            .map(|line| estimate_message_width(line, state.config.font_size))
2608            .fold(0.0, f64::max);
2609        let top_line_y = text_y - (lines.len() as f64 - 1.0) * line_height;
2610        let bottom_line_y = text_y;
2611        let label_offset = if has_label_text {
2612            let label_y_min = top_line_y - line_height * MESSAGE_LABEL_ASCENT_FACTOR;
2613            let label_y_max = bottom_line_y + line_height * MESSAGE_LABEL_DESCENT_FACTOR;
2614            let label_x_min = text_x - max_width / 2.0;
2615            let label_x_max = text_x + max_width / 2.0;
2616            let step = line_height * MESSAGE_LABEL_COLLISION_STEP_RATIO;
2617            let raw_offset = state.reserve_message_label(label_x_min, label_x_max, label_y_min, label_y_max, step);
2618            // Limit offset to prevent text from going below the arrow
2619            let max_offset = y - MESSAGE_TEXT_ABOVE_ARROW - bottom_line_y;
2620            raw_offset.min(max_offset.max(0.0))
2621        } else {
2622            0.0
2623        };
2624        // Calculate rotation angle for delayed messages (slanted arrow)
2625        let rotation = if delay_offset > 0.0 {
2626            let dx = x2 - x1;
2627            let dy = delay_offset;
2628            let angle_rad = dy.atan2(dx.abs());
2629            let angle_deg = angle_rad.to_degrees();
2630            // Rotate in the direction of the arrow
2631            if dx < 0.0 { -angle_deg } else { angle_deg }
2632        } else {
2633            0.0
2634        };
2635
2636        for (i, line) in lines.iter().enumerate() {
2637            let line_y = text_y - (lines.len() - 1 - i) as f64 * line_height + label_offset;
2638            if rotation.abs() > 0.1 {
2639                // Apply rotation transform for delayed messages
2640                writeln!(
2641                    svg,
2642                    r#"  <text x="{x}" y="{y}" class="message-text" text-anchor="middle" transform="rotate({rot},{cx},{cy})">{t}</text>"#,
2643                    x = text_x,
2644                    y = line_y,
2645                    rot = rotation,
2646                    cx = text_x,
2647                    cy = line_y,
2648                    t = escape_xml(line)
2649                )
2650                .unwrap();
2651            } else {
2652                writeln!(
2653                    svg,
2654                    r#"  <text x="{x}" y="{y}" class="message-text" text-anchor="middle">{t}</text>"#,
2655                    x = text_x,
2656                    y = line_y,
2657                    t = escape_xml(line)
2658                )
2659                .unwrap();
2660            }
2661        }
2662
2663        // Close message group
2664        writeln!(svg, r#"</g>"#).unwrap();
2665
2666        // Add row_height plus delay offset
2667        state.current_y += state.config.row_height + delay_offset;
2668    }
2669
2670    if create {
2671        state.current_y += state.config.row_height;
2672    }
2673
2674    state.apply_serial_first_row_gap();
2675
2676    // Handle activation
2677    if activate {
2678        state
2679            .activations
2680            .entry(to.to_string())
2681            .or_default()
2682            .push((y, None));
2683    }
2684    if deactivate {
2685        if let Some(acts) = state.activations.get_mut(from) {
2686            if let Some(act) = acts.last_mut() {
2687                if act.1.is_none() {
2688                    act.1 = Some(y);
2689                }
2690            }
2691        }
2692    }
2693}
2694
2695fn render_note(
2696    svg: &mut String,
2697    state: &mut RenderState,
2698    position: &NotePosition,
2699    participants: &[String],
2700    text: &str,
2701) {
2702    let lines: Vec<&str> = text.split("\\n").collect();
2703    let line_height = note_line_height(&state.config);
2704
2705    // Calculate note size (same padding on all sides)
2706    let max_line_len = lines.iter().map(|l| l.chars().count()).max().unwrap_or(5);
2707    let text_width = max_line_len as f64 * NOTE_CHAR_WIDTH;
2708    let content_width = (ELEMENT_PADDING * 2.0 + text_width).max(NOTE_MIN_WIDTH);
2709    let note_height = ELEMENT_PADDING * 2.0 + lines.len() as f64 * line_height;
2710
2711    let (x, note_width, text_anchor) = match position {
2712        NotePosition::Left => {
2713            let px = state.get_x(&participants[0]);
2714            // Note right edge = px - NOTE_MARGIN
2715            let x = (px - NOTE_MARGIN - content_width).max(state.config.padding);
2716            (x, content_width, "start")
2717        }
2718        NotePosition::Right => {
2719            let px = state.get_x(&participants[0]);
2720            // Note left edge = px + NOTE_MARGIN
2721            (px + NOTE_MARGIN, content_width, "start")
2722        }
2723        NotePosition::Over => {
2724            if participants.len() == 1 {
2725                let px = state.get_x(&participants[0]);
2726                // Center on lifeline
2727                let x = (px - content_width / 2.0).max(state.config.padding);
2728                (x, content_width, "middle")
2729            } else {
2730                // Span multiple participants
2731                let x1 = state.get_x(&participants[0]);
2732                let x2 = state.get_x(participants.last().unwrap());
2733                let span_width = (x2 - x1).abs() + NOTE_MARGIN * 2.0;
2734                let w = span_width.max(content_width);
2735                let x = (x1 - NOTE_MARGIN).max(state.config.padding);
2736                (x, w, "middle")
2737            }
2738        }
2739    };
2740
2741    let y = state.current_y;
2742    let fold_size = NOTE_FOLD_SIZE;
2743
2744    // Note background with dog-ear (folded corner) effect
2745    // Path: start at top-left, go right (leaving space for fold), diagonal fold, down, left, up
2746    let note_path = format!(
2747        "M {x} {y} L {x2} {y} L {x3} {y2} L {x3} {y3} L {x} {y3} Z",
2748        x = x,
2749        y = y,
2750        x2 = x + note_width - fold_size,
2751        x3 = x + note_width,
2752        y2 = y + fold_size,
2753        y3 = y + note_height
2754    );
2755
2756    writeln!(svg, r#"<path d="{path}" class="note"/>"#, path = note_path).unwrap();
2757
2758    // Draw the fold triangle (represents the folded corner)
2759    let theme = &state.config.theme;
2760    // Triangle: from fold start, to diagonal corner, to bottom of fold
2761    let fold_path = format!(
2762        "M {x1} {y1} L {x2} {y2} L {x1} {y2} Z",
2763        x1 = x + note_width - fold_size,
2764        y1 = y,
2765        x2 = x + note_width,
2766        y2 = y + fold_size
2767    );
2768
2769    writeln!(
2770        svg,
2771        r##"<path d="{path}" fill="none" stroke="{stroke}" stroke-width="1"/>"##,
2772        path = fold_path,
2773        stroke = theme.note_stroke
2774    )
2775    .unwrap();
2776
2777    // Text position (same padding on all sides)
2778    let text_x = match text_anchor {
2779        "middle" => x + note_width / 2.0,
2780        _ => x + ELEMENT_PADDING,
2781    };
2782    let text_anchor_attr = if *position == NotePosition::Over { "middle" } else { "start" };
2783
2784    for (i, line) in lines.iter().enumerate() {
2785        let text_y = y + ELEMENT_PADDING + (i as f64 + 0.8) * line_height;
2786        writeln!(
2787            svg,
2788            r#"<text x="{x}" y="{y}" class="note-text" text-anchor="{anchor}">{t}</text>"#,
2789            x = text_x,
2790            y = text_y,
2791            anchor = text_anchor_attr,
2792            t = escape_xml(line)
2793        )
2794        .unwrap();
2795    }
2796
2797    // Add spacing between elements
2798    state.current_y += note_y_advance(&state.config, lines.len());
2799}
2800
2801/// Render a state box (rounded rectangle)
2802fn render_state(svg: &mut String, state: &mut RenderState, participants: &[String], text: &str) {
2803    let theme = &state.config.theme;
2804    let lines: Vec<&str> = text.split("\\n").collect();
2805    let line_height = state_line_height(&state.config);
2806    let box_height = state.config.note_padding * 2.0 + lines.len() as f64 * line_height;
2807
2808    // Calculate box position and width
2809    let (x, box_width) = if participants.len() == 1 {
2810        let px = state.get_x(&participants[0]);
2811        let max_line_len = lines.iter().map(|l| l.chars().count()).max().unwrap_or(8);
2812        let w = (max_line_len as f64 * 8.0 + state.config.note_padding * 2.0).max(60.0);
2813        (px - w / 2.0, w)
2814    } else {
2815        let x1 = state.get_x(&participants[0]);
2816        let x2 = state.get_x(participants.last().unwrap());
2817        let span_width = (x2 - x1).abs() + state.config.participant_width * 0.6;
2818        let center = (x1 + x2) / 2.0;
2819        (center - span_width / 2.0, span_width)
2820    };
2821
2822    let shift = item_pre_shift(&state.config);
2823    let y = (state.current_y - shift).max(state.content_start());
2824
2825    // Draw rounded rectangle
2826    writeln!(
2827        svg,
2828        r##"<rect x="{x}" y="{y}" width="{w}" height="{h}" rx="8" ry="8" fill="{fill}" stroke="{stroke}" stroke-width="1.5"/>"##,
2829        x = x,
2830        y = y,
2831        w = box_width,
2832        h = box_height,
2833        fill = theme.state_fill,
2834        stroke = theme.state_stroke
2835    )
2836    .unwrap();
2837
2838    // Draw text
2839    let text_x = x + box_width / 2.0;
2840    for (i, line) in lines.iter().enumerate() {
2841        let text_y = y + state.config.note_padding + (i as f64 + 0.8) * line_height;
2842        writeln!(
2843            svg,
2844            r##"<text x="{x}" y="{y}" text-anchor="middle" fill="{fill}" font-family="{font}" font-size="{size}px">{t}</text>"##,
2845            x = text_x,
2846            y = text_y,
2847            fill = theme.state_text_color,
2848            font = theme.font_family,
2849            size = state.config.font_size,
2850            t = escape_xml(line)
2851        )
2852        .unwrap();
2853    }
2854
2855    state.current_y = y + box_height + state.config.row_height;
2856}
2857
2858/// Render a ref box (hexagon-like shape)
2859fn render_ref(
2860    svg: &mut String,
2861    state: &mut RenderState,
2862    participants: &[String],
2863    text: &str,
2864    input_from: Option<&str>,
2865    input_label: Option<&str>,
2866    output_to: Option<&str>,
2867    output_label: Option<&str>,
2868) {
2869    let theme = &state.config.theme;
2870    let lines: Vec<&str> = text.split("\\n").collect();
2871    let line_height = ref_line_height(&state.config);
2872    let box_height = state.config.note_padding * 2.0 + lines.len() as f64 * line_height;
2873    let notch_size = 10.0;
2874
2875    // Calculate box position and width
2876    let (x, box_width) = if participants.len() == 1 {
2877        let px = state.get_x(&participants[0]);
2878        let max_line_len = lines.iter().map(|l| l.chars().count()).max().unwrap_or(15);
2879        let w = (max_line_len as f64 * 8.0 + state.config.note_padding * 2.0 + notch_size * 2.0)
2880            .max(100.0);
2881        (px - w / 2.0, w)
2882    } else {
2883        let x1 = state.get_x(&participants[0]);
2884        let x2 = state.get_x(participants.last().unwrap());
2885        let span_width = (x2 - x1).abs() + state.config.participant_width * 0.8;
2886        let center = (x1 + x2) / 2.0;
2887        (center - span_width / 2.0, span_width)
2888    };
2889
2890    let shift = item_pre_shift(&state.config);
2891    let y = (state.current_y - shift).max(state.content_start());
2892    let input_offset = state.config.note_padding + state.config.font_size + 1.0;
2893    let output_padding = state.config.note_padding + 3.0;
2894
2895    // Draw input signal arrow if present
2896    if let Some(from) = input_from {
2897        let from_x = state.get_x(from);
2898        let to_x = x; // Left edge of ref box
2899        let arrow_y = y + input_offset;
2900
2901        // Calculate arrowhead
2902        let direction = arrow_direction(from_x, arrow_y, to_x, arrow_y);
2903        let arrow_points = arrowhead_points(to_x, arrow_y, direction);
2904        let line_end_x = to_x - ARROWHEAD_SIZE * direction.cos();
2905
2906        // Draw arrow line
2907        writeln!(
2908            svg,
2909            r##"<line x1="{x1}" y1="{y}" x2="{x2}" y2="{y}" class="message"/>"##,
2910            x1 = from_x,
2911            y = arrow_y,
2912            x2 = line_end_x
2913        )
2914        .unwrap();
2915
2916        // Draw arrowhead
2917        writeln!(
2918            svg,
2919            r#"<polygon points="{points}" class="arrowhead"/>"#,
2920            points = arrow_points
2921        )
2922        .unwrap();
2923
2924        // Draw label if present
2925        if let Some(label) = input_label {
2926            let text_x = (from_x + to_x) / 2.0;
2927            writeln!(
2928                svg,
2929                r##"<text x="{x}" y="{y}" class="message-text" text-anchor="middle">{t}</text>"##,
2930                x = text_x,
2931                y = arrow_y - 8.0,
2932                t = escape_xml(label)
2933            )
2934            .unwrap();
2935        }
2936    }
2937
2938    // Draw hexagon-like shape (ref box in WSD style)
2939    // Left side has a notch cut
2940    let ref_path = format!(
2941        "M {x1} {y1} L {x2} {y1} L {x2} {y2} L {x1} {y2} L {x3} {y3} Z",
2942        x1 = x + notch_size,
2943        y1 = y,
2944        x2 = x + box_width,
2945        y2 = y + box_height,
2946        x3 = x,
2947        y3 = y + box_height / 2.0
2948    );
2949
2950    writeln!(
2951        svg,
2952        r##"<path d="{path}" fill="{fill}" stroke="{stroke}" stroke-width="1.5"/>"##,
2953        path = ref_path,
2954        fill = theme.ref_fill,
2955        stroke = theme.ref_stroke
2956    )
2957    .unwrap();
2958
2959    // Add "ref" label in top-left
2960    writeln!(
2961        svg,
2962        r##"<text x="{x}" y="{y}" fill="{fill}" font-family="{font}" font-size="{size}px" font-weight="bold">ref</text>"##,
2963        x = x + notch_size + 4.0,
2964        y = y + state.config.font_size,
2965        fill = theme.ref_text_color,
2966        font = theme.font_family,
2967        size = state.config.font_size - 2.0
2968    )
2969    .unwrap();
2970
2971    // Draw text centered
2972    let text_x = x + box_width / 2.0;
2973    for (i, line) in lines.iter().enumerate() {
2974        let text_y = y + state.config.note_padding + (i as f64 + 0.8) * line_height;
2975        writeln!(
2976            svg,
2977            r##"<text x="{x}" y="{y}" text-anchor="middle" fill="{fill}" font-family="{font}" font-size="{size}px">{t}</text>"##,
2978            x = text_x,
2979            y = text_y,
2980            fill = theme.ref_text_color,
2981            font = theme.font_family,
2982            size = state.config.font_size,
2983            t = escape_xml(line)
2984        )
2985        .unwrap();
2986    }
2987
2988    // Draw output signal arrow if present
2989    if let Some(to) = output_to {
2990        let from_x = x + box_width; // Right edge of ref box
2991        let to_x = state.get_x(to);
2992        let arrow_y = y + box_height - output_padding;
2993
2994        // Calculate arrowhead
2995        let direction = arrow_direction(from_x, arrow_y, to_x, arrow_y);
2996        let arrow_points = arrowhead_points(to_x, arrow_y, direction);
2997        let line_end_x = to_x - ARROWHEAD_SIZE * direction.cos();
2998
2999        // Draw dashed arrow line (response style)
3000        writeln!(
3001            svg,
3002            r##"<line x1="{x1}" y1="{y}" x2="{x2}" y2="{y}" class="message-dashed"/>"##,
3003            x1 = from_x,
3004            y = arrow_y,
3005            x2 = line_end_x
3006        )
3007        .unwrap();
3008
3009        // Draw arrowhead
3010        writeln!(
3011            svg,
3012            r#"<polygon points="{points}" class="arrowhead"/>"#,
3013            points = arrow_points
3014        )
3015        .unwrap();
3016
3017        // Draw label if present
3018        if let Some(label) = output_label {
3019            let text_x = (from_x + to_x) / 2.0;
3020            writeln!(
3021                svg,
3022                r##"<text x="{x}" y="{y}" class="message-text" text-anchor="middle">{t}</text>"##,
3023                x = text_x,
3024                y = arrow_y - 8.0,
3025                t = escape_xml(label)
3026            )
3027            .unwrap();
3028        }
3029    }
3030
3031    state.current_y = y + box_height + state.config.row_height;
3032}
3033
3034/// Render a description (extended text explanation)
3035fn render_description(svg: &mut String, state: &mut RenderState, text: &str) {
3036    let theme = &state.config.theme;
3037    let lines: Vec<&str> = text.split("\\n").collect();
3038    let line_height = state.config.font_size + 4.0;
3039
3040    // Draw text on the left side of the diagram
3041    let x = state.config.padding + 10.0;
3042    let y = state.current_y;
3043
3044    for (i, line) in lines.iter().enumerate() {
3045        let text_y = y + (i as f64 + 0.8) * line_height;
3046        writeln!(
3047            svg,
3048            r##"<text x="{x}" y="{y}" fill="{fill}" font-family="{font}" font-size="{size}px" font-style="italic">{t}</text>"##,
3049            x = x,
3050            y = text_y,
3051            fill = theme.description_text_color,
3052            font = theme.font_family,
3053            size = state.config.font_size - 1.0,
3054            t = escape_xml(line)
3055        )
3056        .unwrap();
3057    }
3058
3059    state.current_y += description_y_advance(&state.config, lines.len());
3060}
3061
3062fn render_block(
3063    svg: &mut String,
3064    state: &mut RenderState,
3065    kind: &BlockKind,
3066    _label: &str,
3067    items: &[Item],
3068    else_items: Option<&[Item]>,
3069    _else_label: Option<&str>,
3070    depth: usize,
3071) {
3072    if block_is_parallel(kind) {
3073        state.push_parallel();
3074        let start_y = state.current_y;
3075        let mut max_end_y = start_y;
3076        for item in items {
3077            state.current_y = start_y;
3078            render_items(svg, state, std::slice::from_ref(item), depth);
3079            if state.current_y > max_end_y {
3080                max_end_y = state.current_y;
3081            }
3082        }
3083        let gap = if parallel_needs_gap(items) {
3084            state.config.row_height
3085        } else {
3086            0.0
3087        };
3088        state.current_y = max_end_y + gap;
3089        state.pop_parallel();
3090        return;
3091    }
3092
3093    if matches!(kind, BlockKind::Serial) {
3094        state.push_serial_first_row_pending();
3095        render_items(svg, state, items, depth);
3096        if let Some(else_items) = else_items {
3097            render_items(svg, state, else_items, depth);
3098        }
3099        state.pop_serial_first_row_pending();
3100        return;
3101    }
3102
3103    if !block_has_frame(kind) {
3104        render_items(svg, state, items, depth);
3105        if let Some(else_items) = else_items {
3106            render_items(svg, state, else_items, depth);
3107        }
3108        return;
3109    }
3110
3111    // Note: Block frame, labels, and else separators are rendered by render_block_labels()
3112    // This function only handles Y position tracking and rendering of inner items
3113    // svg is still used for rendering inner items via render_items()
3114
3115    state.current_y += block_header_space(&state.config, depth);
3116
3117    // Render items
3118    render_items(svg, state, items, depth + 1);
3119
3120    // Render else items if present
3121    if let Some(else_items) = else_items {
3122        state.push_else_return_pending();
3123        // Padding before else line (same as collect_block_backgrounds)
3124        state.current_y += block_else_before(&state.config, depth);
3125        // Padding after else line
3126        state.current_y += block_else_after(&state.config, depth);
3127        render_items(svg, state, else_items, depth + 1);
3128        state.pop_else_return_pending();
3129    }
3130
3131    // Block bottom = current Y + footer padding
3132    // (ensures messages don't overflow outside the block)
3133    let end_y = state.current_y + block_footer_padding(&state.config, depth);
3134
3135    // Set current_y to end of block + margin
3136    state.current_y = end_y + state.config.row_height;
3137
3138    // Block frame, labels, and else separators are rendered later by render_block_labels()
3139    // which is called after activations are drawn, so labels appear on top
3140}
3141
3142fn render_activations(svg: &mut String, state: &mut RenderState, footer_y: f64) {
3143    for (participant, activations) in &state.activations {
3144        let x = state.get_x(participant);
3145        let box_x = x - state.config.activation_width / 2.0;
3146
3147        for (start_y, end_y) in activations {
3148            // If no end_y, extend to footer
3149            let end = end_y.unwrap_or(footer_y);
3150            let height = end - start_y;
3151
3152            if height > 0.0 {
3153                writeln!(
3154                    svg,
3155                    r#"<rect x="{x}" y="{y}" width="{w}" height="{h}" class="activation"/>"#,
3156                    x = box_x,
3157                    y = start_y,
3158                    w = state.config.activation_width,
3159                    h = height
3160                )
3161                .unwrap();
3162            }
3163        }
3164    }
3165}
3166
3167fn escape_xml(s: &str) -> String {
3168    s.replace('&', "&amp;")
3169        .replace('<', "&lt;")
3170        .replace('>', "&gt;")
3171        .replace('"', "&quot;")
3172        .replace('\'', "&apos;")
3173}
3174
3175#[cfg(test)]
3176mod tests {
3177    use super::*;
3178    use crate::parser::parse;
3179
3180    #[test]
3181    fn test_render_simple() {
3182        let diagram = parse("Alice->Bob: Hello").unwrap();
3183        let svg = render(&diagram);
3184        assert!(svg.contains("<svg"));
3185        assert!(svg.contains("Alice"));
3186        assert!(svg.contains("Bob"));
3187        assert!(svg.contains("Hello"));
3188    }
3189
3190    #[test]
3191    fn test_render_with_note() {
3192        let diagram = parse("Alice->Bob: Hello\nnote over Alice: Thinking").unwrap();
3193        let svg = render(&diagram);
3194        assert!(svg.contains("Thinking"));
3195    }
3196}