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        // Consider block labels that may extend beyond participant-based width
1011        let max_block_x2 = self
1012            .block_labels
1013            .iter()
1014            .map(|bl| bl.x2)
1015            .fold(0.0f64, |a, b| a.max(b));
1016        // Add right margin for blocks
1017        let block_width = if max_block_x2 > 0.0 {
1018            max_block_x2 + self.config.padding
1019        } else {
1020            0.0
1021        };
1022        self.total_width.max(block_width)
1023    }
1024
1025    /// Get the x position of the leftmost participant
1026    fn leftmost_x(&self) -> f64 {
1027        self.participants
1028            .first()
1029            .map(|p| self.get_x(p.id()))
1030            .unwrap_or(self.config.padding)
1031    }
1032
1033    /// Get the x position of the rightmost participant
1034    fn rightmost_x(&self) -> f64 {
1035        self.participants
1036            .last()
1037            .map(|p| self.get_x(p.id()))
1038            .unwrap_or(self.total_width - self.config.padding)
1039    }
1040
1041    /// Get block left boundary (based on leftmost participant)
1042    fn block_left(&self) -> f64 {
1043        let leftmost_width = self
1044            .participants
1045            .first()
1046            .map(|p| self.get_participant_width(p.id()))
1047            .unwrap_or(self.config.participant_width);
1048        self.leftmost_x() - leftmost_width / 2.0 - self.config.block_margin
1049    }
1050
1051    /// Get block right boundary (based on rightmost participant)
1052    fn block_right(&self) -> f64 {
1053        let rightmost_width = self
1054            .participants
1055            .last()
1056            .map(|p| self.get_participant_width(p.id()))
1057            .unwrap_or(self.config.participant_width);
1058        self.rightmost_x() + rightmost_width / 2.0 + self.config.block_margin
1059    }
1060
1061    fn header_top(&self) -> f64 {
1062        if self.has_title {
1063            self.config.padding + self.config.title_height
1064        } else {
1065            self.config.padding
1066        }
1067    }
1068
1069    fn content_start(&self) -> f64 {
1070        // WSD first message Y: 250.5
1071        // header_top (110.5) + header_height (108) + row_height (32) = 250.5
1072        self.header_top() + self.config.header_height + self.config.row_height
1073    }
1074
1075    fn next_number(&mut self) -> Option<u32> {
1076        self.autonumber.map(|n| {
1077            self.autonumber = Some(n + 1);
1078            n
1079        })
1080    }
1081
1082    /// Add a block background to be rendered later
1083    fn add_block_background(&mut self, x: f64, y: f64, width: f64, height: f64) {
1084        self.block_backgrounds.push(BlockBackground {
1085            x,
1086            y,
1087            width,
1088            height,
1089        });
1090    }
1091
1092    /// Add a block label to be rendered later (above activations/lifelines)
1093    fn add_block_label(
1094        &mut self,
1095        x1: f64,
1096        start_y: f64,
1097        end_y: f64,
1098        x2: f64,
1099        kind: &str,
1100        label: &str,
1101        else_y: Option<f64>,
1102        else_label: Option<String>,
1103    ) {
1104        self.block_labels.push(BlockLabel {
1105            x1,
1106            start_y,
1107            end_y,
1108            x2,
1109            kind: kind.to_string(),
1110            label: label.to_string(),
1111            else_y,
1112            else_label,
1113        });
1114    }
1115}
1116
1117/// Find participants involved in a list of items (returns min/max edges and whether leftmost is included)
1118fn find_involved_participants(items: &[Item], state: &RenderState) -> Option<(f64, f64, bool)> {
1119    let mut min_left: Option<f64> = None;
1120    let mut max_right: Option<f64> = None;
1121    let leftmost_id = state.participants.first().map(|p| p.id()).unwrap_or("");
1122    let mut includes_leftmost = false;
1123
1124    fn update_bounds(
1125        participant: &str,
1126        state: &RenderState,
1127        min_left: &mut Option<f64>,
1128        max_right: &mut Option<f64>,
1129        includes_leftmost: &mut bool,
1130        leftmost_id: &str,
1131    ) {
1132        let x = state.get_x(participant);
1133        if x > 0.0 {
1134            let width = state.get_participant_width(participant);
1135            let left = x - width / 2.0;
1136            let right = x + width / 2.0;
1137            *min_left = Some(min_left.map_or(left, |m| m.min(left)));
1138            *max_right = Some(max_right.map_or(right, |m| m.max(right)));
1139            if participant == leftmost_id {
1140                *includes_leftmost = true;
1141            }
1142        }
1143    }
1144
1145    fn process_items(
1146        items: &[Item],
1147        state: &RenderState,
1148        min_left: &mut Option<f64>,
1149        max_right: &mut Option<f64>,
1150        includes_leftmost: &mut bool,
1151        leftmost_id: &str,
1152    ) {
1153        for item in items {
1154            match item {
1155                Item::Message { from, to, .. } => {
1156                    update_bounds(
1157                        from,
1158                        state,
1159                        min_left,
1160                        max_right,
1161                        includes_leftmost,
1162                        leftmost_id,
1163                    );
1164                    update_bounds(
1165                        to,
1166                        state,
1167                        min_left,
1168                        max_right,
1169                        includes_leftmost,
1170                        leftmost_id,
1171                    );
1172                }
1173                Item::Note { participants, .. } => {
1174                    for p in participants {
1175                        update_bounds(
1176                            p,
1177                            state,
1178                            min_left,
1179                            max_right,
1180                            includes_leftmost,
1181                            leftmost_id,
1182                        );
1183                    }
1184                }
1185                Item::Block {
1186                    items, else_items, ..
1187                } => {
1188                    process_items(
1189                        items,
1190                        state,
1191                        min_left,
1192                        max_right,
1193                        includes_leftmost,
1194                        leftmost_id,
1195                    );
1196                    if let Some(else_items) = else_items {
1197                        process_items(
1198                            else_items,
1199                            state,
1200                            min_left,
1201                            max_right,
1202                            includes_leftmost,
1203                            leftmost_id,
1204                        );
1205                    }
1206                }
1207                Item::Activate { participant }
1208                | Item::Deactivate { participant }
1209                | Item::Destroy { participant } => {
1210                    update_bounds(
1211                        participant,
1212                        state,
1213                        min_left,
1214                        max_right,
1215                        includes_leftmost,
1216                        leftmost_id,
1217                    );
1218                }
1219                _ => {}
1220            }
1221        }
1222    }
1223
1224    process_items(
1225        items,
1226        state,
1227        &mut min_left,
1228        &mut max_right,
1229        &mut includes_leftmost,
1230        leftmost_id,
1231    );
1232
1233    match (min_left, max_right) {
1234        (Some(min), Some(max)) => Some((min, max, includes_leftmost)),
1235        _ => None,
1236    }
1237}
1238
1239/// Calculate block x boundaries based on involved participants and label length
1240fn calculate_block_bounds_with_label(
1241    items: &[Item],
1242    else_items: Option<&[Item]>,
1243    label: &str,
1244    else_label: Option<&str>,
1245    kind: &str,
1246    depth: usize,
1247    state: &RenderState,
1248) -> (f64, f64) {
1249    let mut all_items: Vec<&Item> = items.iter().collect();
1250    if let Some(else_items) = else_items {
1251        all_items.extend(else_items.iter());
1252    }
1253
1254    // Convert Vec<&Item> to slice for find_involved_participants
1255    let items_slice: Vec<Item> = all_items.into_iter().cloned().collect();
1256
1257    let (base_x1, base_x2) =
1258        if let Some((min_left, max_right, _includes_leftmost)) =
1259            find_involved_participants(&items_slice, state)
1260        {
1261            let margin = state.config.block_margin;
1262            (min_left - margin, max_right + margin)
1263        } else {
1264            // Fallback to full width if no participants found
1265            (state.block_left(), state.block_right())
1266        };
1267
1268    // Calculate minimum width needed for label
1269    // Pentagon width + gap + condition label width + right margin
1270    let pentagon_width = block_tab_width(kind);
1271    let label_font_size = state.config.font_size - 1.0;
1272    let label_padding_x = 6.0;
1273    let condition_width = if label.is_empty() {
1274        0.0
1275    } else {
1276        let condition_text = format!("[{}]", label);
1277        let base_width =
1278            (estimate_text_width(&condition_text, label_font_size) - TEXT_WIDTH_PADDING).max(0.0);
1279        base_width + label_padding_x * 2.0
1280    };
1281
1282    // Calculate else label width (else label starts at same X as condition label)
1283    let else_label_width = if let Some(el) = else_label {
1284        if !el.is_empty() {
1285            let else_text = format!("[{}]", el);
1286            let base_width =
1287                (estimate_text_width(&else_text, label_font_size) - TEXT_WIDTH_PADDING).max(0.0);
1288            base_width + label_padding_x * 2.0
1289        } else {
1290            0.0
1291        }
1292    } else {
1293        0.0
1294    };
1295
1296    // Use the wider of the two labels
1297    let max_label_content_width = condition_width.max(else_label_width);
1298    let min_label_width = pentagon_width + 8.0 + max_label_content_width + 20.0; // Extra right margin
1299
1300    // Ensure block is wide enough for the label
1301    let current_width = base_x2 - base_x1;
1302    let (mut x1, mut x2) = if current_width < min_label_width {
1303        // Extend the right side to accommodate the label
1304        (base_x1, base_x1 + min_label_width)
1305    } else {
1306        (base_x1, base_x2)
1307    };
1308
1309    // Inset nested blocks so they sit inside their parent with padding.
1310    let nested_padding = depth as f64 * 20.0;
1311    if nested_padding > 0.0 {
1312        let available = x2 - x1;
1313        let max_padding = ((available - min_label_width) / 2.0).max(0.0);
1314        let inset = nested_padding.min(max_padding);
1315        x1 += inset;
1316        x2 -= inset;
1317    }
1318
1319    // Even when including leftmost participant, keep moderate margin from participant box
1320    // (don't extend to padding)
1321    // Note: WSD places blocks close to participants
1322
1323    (x1, x2)
1324}
1325
1326/// Pre-calculate block backgrounds by doing a dry run
1327fn collect_block_backgrounds(
1328    state: &mut RenderState,
1329    items: &[Item],
1330    depth: usize,
1331    active_activation_count: &mut usize,
1332) {
1333    for item in items {
1334        match item {
1335            Item::Message {
1336                text,
1337                from,
1338                to,
1339                arrow,
1340                activate,
1341                deactivate,
1342                create,
1343                ..
1344            } => {
1345                state.apply_else_return_gap(arrow);
1346                let is_self = from == to;
1347                let line_count = text.split("\\n").count();
1348                let delay_offset = arrow.delay.map(|d| d as f64 * DELAY_UNIT).unwrap_or(0.0);
1349
1350                if is_self {
1351                    state.current_y += self_message_y_advance(&state.config, line_count);
1352                } else {
1353                    state.current_y += regular_message_y_advance(&state.config, line_count, delay_offset);
1354                }
1355
1356                if *create {
1357                    state.current_y += state.config.row_height;
1358                }
1359
1360                state.apply_serial_first_row_gap();
1361                if *activate {
1362                    *active_activation_count += 1;
1363                }
1364                if *deactivate && *active_activation_count > 0 {
1365                    *active_activation_count -= 1;
1366                }
1367            }
1368            Item::Note { text, .. } => {
1369                let line_count = text.split("\\n").count();
1370                state.current_y += note_y_advance(&state.config, line_count);
1371            }
1372            Item::State { text, .. } => {
1373                let line_count = text.split("\\n").count();
1374                state.current_y += state_y_advance(&state.config, line_count);
1375            }
1376            Item::Ref { text, .. } => {
1377                let line_count = text.split("\\n").count();
1378                state.current_y += ref_y_advance(&state.config, line_count);
1379            }
1380            Item::Description { text } => {
1381                let line_count = text.split("\\n").count();
1382                state.current_y += description_y_advance(&state.config, line_count);
1383            }
1384            Item::Destroy { .. } => {
1385                state.current_y += state.config.row_height;
1386            }
1387            Item::Activate { .. } => {
1388                *active_activation_count += 1;
1389            }
1390            Item::Deactivate { .. } => {
1391                if *active_activation_count > 0 {
1392                    *active_activation_count -= 1;
1393                }
1394            }
1395            Item::Block {
1396                kind,
1397                label,
1398                items,
1399                else_items,
1400                else_label,
1401            } => {
1402                if block_is_parallel(kind) {
1403                    state.push_parallel();
1404                    let start_y = state.current_y;
1405                    let mut max_end_y = start_y;
1406                    let start_activation_count = *active_activation_count;
1407                    for item in items {
1408                        state.current_y = start_y;
1409                        *active_activation_count = start_activation_count;
1410                        collect_block_backgrounds(
1411                            state,
1412                            std::slice::from_ref(item),
1413                            depth,
1414                            active_activation_count,
1415                        );
1416                        if state.current_y > max_end_y {
1417                            max_end_y = state.current_y;
1418                        }
1419                    }
1420                    *active_activation_count = start_activation_count;
1421                    let gap = if parallel_needs_gap(items) {
1422                        state.config.row_height
1423                    } else {
1424                        0.0
1425                    };
1426                    state.current_y = max_end_y + gap;
1427                    state.pop_parallel();
1428                    continue;
1429                }
1430
1431                if matches!(kind, BlockKind::Serial) {
1432                    state.push_serial_first_row_pending();
1433                    collect_block_backgrounds(state, items, depth, active_activation_count);
1434                    if let Some(else_items) = else_items {
1435                        collect_block_backgrounds(
1436                            state,
1437                            else_items,
1438                            depth,
1439                            active_activation_count,
1440                        );
1441                    }
1442                    state.pop_serial_first_row_pending();
1443                    continue;
1444                }
1445
1446                if !block_has_frame(kind) {
1447                    collect_block_backgrounds(state, items, depth, active_activation_count);
1448                    if let Some(else_items) = else_items {
1449                        collect_block_backgrounds(
1450                            state,
1451                            else_items,
1452                            depth,
1453                            active_activation_count,
1454                        );
1455                    }
1456                    continue;
1457                }
1458
1459                let start_y = state.current_y;
1460                let frame_shift = block_frame_shift(depth);
1461                let frame_start_y = start_y - frame_shift;
1462
1463                // Calculate bounds based on involved participants and label width
1464                let (x1, x2) = calculate_block_bounds_with_label(
1465                    items,
1466                    else_items.as_deref(),
1467                    label,
1468                    else_label.as_deref(),
1469                    kind.as_str(),
1470                    depth,
1471                    state,
1472                );
1473
1474                state.current_y += block_header_space(&state.config, depth);
1475                collect_block_backgrounds(state, items, depth + 1, active_activation_count);
1476
1477                // Add padding before else line (small)
1478                let else_y = if else_items.is_some() {
1479                    state.current_y += block_else_before(&state.config, depth);
1480                    Some(state.current_y)
1481                } else {
1482                    None
1483                };
1484
1485                if let Some(else_items) = else_items {
1486                    state.push_else_return_pending();
1487                    // Add padding after else line (sufficient gap)
1488                    state.current_y += block_else_after(&state.config, depth);
1489                    collect_block_backgrounds(
1490                        state,
1491                        else_items,
1492                        depth + 1,
1493                        active_activation_count,
1494                    );
1495                    state.pop_else_return_pending();
1496                }
1497
1498                // Block bottom = current Y + footer padding
1499                // (prevent messages from overflowing block)
1500                let end_y = state.current_y + block_footer_padding(&state.config, depth);
1501                let frame_end_y = end_y - frame_shift;
1502                state.current_y = end_y + state.config.row_height;
1503
1504                // Collect this block's background
1505                state.add_block_background(x1, frame_start_y, x2 - x1, frame_end_y - frame_start_y);
1506                // Collect this block's label for rendering above activations/lifelines
1507                state.add_block_label(
1508                    x1,
1509                    frame_start_y,
1510                    frame_end_y,
1511                    x2,
1512                    kind.as_str(),
1513                    label,
1514                    else_y,
1515                    else_label.clone(),
1516                );
1517            }
1518            _ => {}
1519        }
1520    }
1521}
1522
1523/// Render all collected block backgrounds
1524fn render_block_backgrounds(svg: &mut String, state: &RenderState) {
1525    let theme = &state.config.theme;
1526    for bg in &state.block_backgrounds {
1527        writeln!(
1528            svg,
1529            r##"<rect x="{x}" y="{y}" width="{w}" height="{h}" fill="{fill}" stroke="none"/>"##,
1530            x = bg.x,
1531            y = bg.y,
1532            w = bg.width,
1533            h = bg.height,
1534            fill = theme.block_fill
1535        )
1536        .unwrap();
1537    }
1538}
1539
1540/// Render all collected block labels (frame, pentagon, condition text, else divider)
1541/// This is called AFTER activations are drawn so labels appear on top
1542fn render_block_labels(svg: &mut String, state: &RenderState) {
1543    let theme = &state.config.theme;
1544
1545    for bl in &state.block_labels {
1546        let x1 = bl.x1;
1547        let x2 = bl.x2;
1548        let start_y = bl.start_y;
1549        let end_y = bl.end_y;
1550
1551        // Draw block frame
1552        writeln!(
1553            svg,
1554            r#"<rect x="{x}" y="{y}" width="{w}" height="{h}" class="block"/>"#,
1555            x = x1,
1556            y = start_y,
1557            w = x2 - x1,
1558            h = end_y - start_y
1559        )
1560        .unwrap();
1561
1562        // Pentagon/tab-shaped label (WSD style)
1563        let label_text = &bl.kind;
1564        let label_width = block_tab_width(label_text);
1565        let label_height = BLOCK_LABEL_HEIGHT;
1566        let label_text_offset = 16.0;
1567        let notch_size = 5.0;
1568
1569        // Pentagon path
1570        let pentagon_path = format!(
1571            "M {x1} {y1} L {x2} {y1} L {x2} {y2} L {x3} {y3} L {x1} {y3} Z",
1572            x1 = x1,
1573            y1 = start_y,
1574            x2 = x1 + label_width,
1575            y2 = start_y + label_height - notch_size,
1576            x3 = x1 + label_width - notch_size,
1577            y3 = start_y + label_height
1578        );
1579
1580        writeln!(
1581            svg,
1582            r##"<path d="{path}" fill="{fill}" stroke="{stroke}"/>"##,
1583            path = pentagon_path,
1584            fill = theme.block_label_fill,
1585            stroke = theme.block_stroke
1586        )
1587        .unwrap();
1588
1589        // Block type label text
1590        writeln!(
1591            svg,
1592            r#"<text x="{x}" y="{y}" class="block-label">{kind}</text>"#,
1593            x = x1 + 5.0,
1594            y = start_y + label_text_offset,
1595            kind = label_text
1596        )
1597        .unwrap();
1598
1599        // Condition label (text only, no background per WSD style)
1600        if !bl.label.is_empty() {
1601            let condition_text = format!("[{}]", bl.label);
1602            let text_x = x1 + label_width + 8.0;
1603            let text_y = start_y + label_text_offset;
1604
1605            writeln!(
1606                svg,
1607                r#"<text x="{x}" y="{y}" class="block-label">{label}</text>"#,
1608                x = text_x,
1609                y = text_y,
1610                label = escape_xml(&condition_text)
1611            )
1612            .unwrap();
1613        }
1614
1615        // Else separator (dashed line + else label text)
1616        if let Some(else_y) = bl.else_y {
1617            // Draw dashed line
1618            writeln!(
1619                svg,
1620                r##"<line x1="{x1}" y1="{y}" x2="{x2}" y2="{y}" stroke="{c}" stroke-dasharray="5,3"/>"##,
1621                x1 = x1,
1622                y = else_y,
1623                x2 = x2,
1624                c = theme.block_stroke
1625            )
1626            .unwrap();
1627
1628            // Draw else label text at same X position as alt condition label
1629            if let Some(else_label_text) = &bl.else_label {
1630                let condition_text = format!("[{}]", else_label_text);
1631                let text_x = x1 + label_width + 8.0; // Same X as alt's [Success case]
1632                let text_y = else_y + label_text_offset; // Below the dashed line
1633
1634                writeln!(
1635                    svg,
1636                    r#"<text x="{x}" y="{y}" class="block-label">{label}</text>"#,
1637                    x = text_x,
1638                    y = text_y,
1639                    label = escape_xml(&condition_text)
1640                )
1641                .unwrap();
1642            }
1643        }
1644    }
1645}
1646
1647/// Render a diagram to SVG
1648pub fn render(diagram: &Diagram) -> String {
1649    render_with_config(diagram, Config::default())
1650}
1651
1652/// Render a diagram to SVG with custom config
1653pub fn render_with_config(diagram: &Diagram, config: Config) -> String {
1654    let participants = diagram.participants();
1655    let has_title = diagram.title.is_some();
1656    let footer_style = diagram.options.footer;
1657    let mut state = RenderState::new(
1658        config,
1659        participants,
1660        &diagram.items,
1661        has_title,
1662        footer_style,
1663    );
1664    let mut svg = String::new();
1665
1666    // Pre-calculate height
1667    let content_height = calculate_height(&diagram.items, &state.config, 0);
1668    let title_space = if has_title {
1669        state.config.title_height
1670    } else {
1671        0.0
1672    };
1673    let footer_space = match footer_style {
1674        FooterStyle::Box => state.config.header_height,
1675        FooterStyle::Bar | FooterStyle::None => 0.0,
1676    };
1677    let footer_label_extra = match footer_style {
1678        FooterStyle::Box => actor_footer_extra(&state.participants, &state.config),
1679        FooterStyle::Bar | FooterStyle::None => 0.0,
1680    };
1681    let footer_margin = state.config.row_height; // Space between content and footer
1682    let base_total_height = state.config.padding * 2.0
1683        + title_space
1684        + state.config.header_height
1685        + content_height
1686        + footer_margin
1687        + footer_space;
1688    let total_height = base_total_height + footer_label_extra;
1689
1690    // Pre-calculate block backgrounds to determine max width (before SVG header)
1691    state.current_y = state.content_start();
1692    let mut active_activation_count = 0;
1693    collect_block_backgrounds(&mut state, &diagram.items, 0, &mut active_activation_count);
1694
1695    let total_width = state.diagram_width();
1696
1697    // SVG header
1698    writeln!(
1699        &mut svg,
1700        r#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 {w} {h}" width="{w}" height="{h}">"#,
1701        w = total_width,
1702        h = total_height
1703    )
1704    .unwrap();
1705
1706    // Styles based on theme
1707    let theme = &state.config.theme;
1708    let lifeline_dash = match theme.lifeline_style {
1709        LifelineStyle::Dashed => "stroke-dasharray: 5,5;",
1710        LifelineStyle::Solid => "",
1711    };
1712
1713    svg.push_str("<defs>\n");
1714    svg.push_str("<style>\n");
1715    writeln!(
1716        &mut svg,
1717        ".participant {{ fill: {fill}; stroke: {stroke}; stroke-width: 2; }}",
1718        fill = theme.participant_fill,
1719        stroke = theme.participant_stroke
1720    )
1721    .unwrap();
1722    writeln!(
1723        &mut svg,
1724        ".participant-text {{ font-family: {f}; font-size: {s}px; text-anchor: middle; dominant-baseline: middle; fill: {c}; }}",
1725        f = theme.font_family,
1726        s = state.config.font_size,
1727        c = theme.participant_text
1728    )
1729    .unwrap();
1730    writeln!(
1731        &mut svg,
1732        ".lifeline {{ stroke: {c}; stroke-width: 1; {dash} }}",
1733        c = theme.lifeline_color,
1734        dash = lifeline_dash
1735    )
1736    .unwrap();
1737    writeln!(
1738        &mut svg,
1739        ".message {{ stroke: {c}; stroke-width: 1.5; fill: none; }}",
1740        c = theme.message_color
1741    )
1742    .unwrap();
1743    writeln!(
1744        &mut svg,
1745        ".message-dashed {{ stroke: {c}; stroke-width: 1.5; fill: none; stroke-dasharray: 5,3; }}",
1746        c = theme.message_color
1747    )
1748    .unwrap();
1749    writeln!(
1750        &mut svg,
1751        ".message-text {{ font-family: {f}; font-size: {s}px; fill: {c}; stroke: none; }}",
1752        f = theme.font_family,
1753        s = state.config.font_size,
1754        c = theme.message_text_color
1755    )
1756    .unwrap();
1757    writeln!(
1758        &mut svg,
1759        ".note {{ fill: {fill}; stroke: {stroke}; stroke-width: 1; }}",
1760        fill = theme.note_fill,
1761        stroke = theme.note_stroke
1762    )
1763    .unwrap();
1764    writeln!(
1765        &mut svg,
1766        ".note-text {{ font-family: {f}; font-size: {s}px; fill: {c}; }}",
1767        f = theme.font_family,
1768        s = state.config.font_size - 1.0,
1769        c = theme.note_text_color
1770    )
1771    .unwrap();
1772    writeln!(
1773        &mut svg,
1774        ".block {{ fill: none; stroke: {c}; stroke-width: 1; }}",
1775        c = theme.block_stroke
1776    )
1777    .unwrap();
1778    writeln!(
1779        &mut svg,
1780        ".block-label {{ font-family: {f}; font-size: {s}px; font-weight: bold; fill: {c}; }}",
1781        f = theme.font_family,
1782        s = state.config.font_size - 1.0,
1783        c = theme.message_text_color
1784    )
1785    .unwrap();
1786    writeln!(
1787        &mut svg,
1788        ".activation {{ fill: {fill}; stroke: {stroke}; stroke-width: 1; }}",
1789        fill = theme.activation_fill,
1790        stroke = theme.activation_stroke
1791    )
1792    .unwrap();
1793    writeln!(
1794        &mut svg,
1795        ".actor-head {{ fill: {fill}; stroke: {stroke}; stroke-width: 2; }}",
1796        fill = theme.actor_fill,
1797        stroke = theme.actor_stroke
1798    )
1799    .unwrap();
1800    writeln!(
1801        &mut svg,
1802        ".actor-body {{ stroke: {c}; stroke-width: 2; fill: none; }}",
1803        c = theme.actor_stroke
1804    )
1805    .unwrap();
1806    writeln!(
1807        &mut svg,
1808        ".title {{ font-family: {f}; font-size: {s}px; font-weight: bold; text-anchor: middle; fill: {c}; }}",
1809        f = theme.font_family,
1810        s = state.config.font_size + 4.0,
1811        c = theme.message_text_color
1812    )
1813    .unwrap();
1814    // Arrowhead styles
1815    writeln!(
1816        &mut svg,
1817        ".arrowhead {{ fill: {c}; stroke: none; }}",
1818        c = theme.message_color
1819    )
1820    .unwrap();
1821    writeln!(
1822        &mut svg,
1823        ".arrowhead-open {{ fill: none; stroke: {c}; stroke-width: 1; }}",
1824        c = theme.message_color
1825    )
1826    .unwrap();
1827    svg.push_str("</style>\n");
1828    svg.push_str("</defs>\n");
1829
1830    // Background with theme color
1831    writeln!(
1832        &mut svg,
1833        r##"<rect width="100%" height="100%" fill="{bg}"/>"##,
1834        bg = theme.background
1835    )
1836    .unwrap();
1837
1838    // Title
1839    if let Some(title) = &diagram.title {
1840        let title_y = state.config.padding + state.config.font_size + 7.36; // WSD: 31.86
1841        writeln!(
1842            &mut svg,
1843            r#"<text x="{x}" y="{y}" class="title">{t}</text>"#,
1844            x = total_width / 2.0,
1845            y = title_y,
1846            t = escape_xml(title)
1847        )
1848        .unwrap();
1849    }
1850
1851    // Calculate footer position
1852    let header_y = state.header_top();
1853    let footer_y = match footer_style {
1854        FooterStyle::Box => base_total_height - state.config.padding - state.config.header_height,
1855        FooterStyle::Bar | FooterStyle::None => total_height - state.config.padding,
1856    };
1857
1858    // Draw block backgrounds FIRST (behind lifelines)
1859    // (block_backgrounds already calculated above for width calculation)
1860    render_block_backgrounds(&mut svg, &state);
1861
1862    // Reset current_y for actual rendering
1863    state.current_y = state.content_start();
1864
1865    // Draw lifelines (behind messages but above block backgrounds)
1866    let lifeline_start = header_y + state.config.header_height;
1867    let lifeline_end = footer_y;
1868
1869    for p in &state.participants {
1870        let x = state.get_x(p.id());
1871        writeln!(
1872            &mut svg,
1873            r#"<line x1="{x}" y1="{y1}" x2="{x}" y2="{y2}" class="lifeline"/>"#,
1874            x = x,
1875            y1 = lifeline_start,
1876            y2 = lifeline_end
1877        )
1878        .unwrap();
1879    }
1880
1881    // Draw participant headers
1882    render_participant_headers(&mut svg, &state, header_y);
1883
1884    // Render items
1885    state.current_y = state.content_start();
1886    render_items(&mut svg, &mut state, &diagram.items, 0);
1887
1888    // Draw activation bars
1889    render_activations(&mut svg, &mut state, footer_y);
1890
1891    // Draw block labels AFTER activations so they appear on top
1892    render_block_labels(&mut svg, &state);
1893
1894    // Draw participant footers based on footer style option
1895    match state.footer_style {
1896        FooterStyle::Box => {
1897            render_participant_headers(&mut svg, &state, footer_y);
1898        }
1899        FooterStyle::Bar => {
1900            // Draw simple horizontal line across all participants
1901            let left = state.leftmost_x()
1902                - state.get_participant_width(
1903                    state.participants.first().map(|p| p.id()).unwrap_or(""),
1904                ) / 2.0;
1905            let right = state.rightmost_x()
1906                + state
1907                    .get_participant_width(state.participants.last().map(|p| p.id()).unwrap_or(""))
1908                    / 2.0;
1909            writeln!(
1910                &mut svg,
1911                r##"<line x1="{x1}" y1="{y}" x2="{x2}" y2="{y}" stroke="{c}" stroke-width="1"/>"##,
1912                x1 = left,
1913                y = footer_y,
1914                x2 = right,
1915                c = state.config.theme.lifeline_color
1916            )
1917            .unwrap();
1918        }
1919        FooterStyle::None => {
1920            // No footer at all
1921        }
1922    }
1923
1924    svg.push_str("</svg>\n");
1925    svg
1926}
1927
1928fn calculate_height(items: &[Item], config: &Config, depth: usize) -> f64 {
1929    fn inner(
1930        items: &[Item],
1931        config: &Config,
1932        depth: usize,
1933        else_pending: &mut Vec<bool>,
1934        serial_pending: &mut Vec<bool>,
1935        active_activation_count: &mut usize,
1936        parallel_depth: &mut usize,
1937    ) -> f64 {
1938        let mut height = 0.0;
1939        for item in items {
1940            match item {
1941                Item::Message {
1942                    from,
1943                    to,
1944                    text,
1945                    arrow,
1946                    create,
1947                    activate,
1948                    deactivate,
1949                    ..
1950                } => {
1951                    if let Some(pending) = else_pending.last_mut() {
1952                        if *pending && matches!(arrow.line, LineStyle::Dashed) {
1953                            *pending = false;
1954                        }
1955                    }
1956                    let is_self = from == to;
1957                    let line_count = text.split("\\n").count();
1958                    let delay_offset = arrow.delay.map(|d| d as f64 * DELAY_UNIT).unwrap_or(0.0);
1959                    if is_self {
1960                        height += self_message_y_advance(config, line_count);
1961                    } else {
1962                        height += regular_message_y_advance(config, line_count, delay_offset);
1963                    }
1964                    if *create {
1965                        height += config.row_height;
1966                    }
1967                    if let Some(pending) = serial_pending.last_mut() {
1968                        if *pending {
1969                            height += serial_first_row_gap(*parallel_depth);
1970                            *pending = false;
1971                        }
1972                    }
1973                    if *activate {
1974                        *active_activation_count += 1;
1975                    }
1976                    if *deactivate && *active_activation_count > 0 {
1977                        *active_activation_count -= 1;
1978                    }
1979                }
1980                Item::Note { text, .. } => {
1981                    let line_count = text.split("\\n").count();
1982                    height += note_y_advance(config, line_count);
1983                }
1984                Item::State { text, .. } => {
1985                    let line_count = text.split("\\n").count();
1986                    height += state_y_advance(config, line_count);
1987                }
1988                Item::Ref { text, .. } => {
1989                    let line_count = text.split("\\n").count();
1990                    height += ref_y_advance(config, line_count);
1991                }
1992                Item::Description { text } => {
1993                    let line_count = text.split("\\n").count();
1994                    height += description_y_advance(config, line_count);
1995                }
1996                Item::Block {
1997                    kind,
1998                    items,
1999                    else_items,
2000                    ..
2001                } => {
2002                    if block_is_parallel(kind) {
2003                        let mut max_branch_height = 0.0;
2004                        let base_activation_count = *active_activation_count;
2005                        *parallel_depth += 1;
2006                        for item in items {
2007                            *active_activation_count = base_activation_count;
2008                            let branch_height = inner(
2009                                std::slice::from_ref(item),
2010                                config,
2011                                depth,
2012                                else_pending,
2013                                serial_pending,
2014                                active_activation_count,
2015                                parallel_depth,
2016                            );
2017                            if branch_height > max_branch_height {
2018                                max_branch_height = branch_height;
2019                            }
2020                        }
2021                        *active_activation_count = base_activation_count;
2022                        if *parallel_depth > 0 {
2023                            *parallel_depth -= 1;
2024                        }
2025                        let gap = if parallel_needs_gap(items) {
2026                            config.row_height
2027                        } else {
2028                            0.0
2029                        };
2030                        height += max_branch_height + gap;
2031                        continue;
2032                    }
2033
2034                    if matches!(kind, BlockKind::Serial) {
2035                        serial_pending.push(true);
2036                        height += inner(
2037                            items,
2038                            config,
2039                            depth,
2040                            else_pending,
2041                            serial_pending,
2042                            active_activation_count,
2043                            parallel_depth,
2044                        );
2045                        if let Some(else_items) = else_items {
2046                            height += inner(
2047                                else_items,
2048                                config,
2049                                depth,
2050                                else_pending,
2051                                serial_pending,
2052                                active_activation_count,
2053                                parallel_depth,
2054                            );
2055                        }
2056                        serial_pending.pop();
2057                    } else if !block_has_frame(kind) {
2058                        height += inner(
2059                            items,
2060                            config,
2061                            depth,
2062                            else_pending,
2063                            serial_pending,
2064                            active_activation_count,
2065                            parallel_depth,
2066                        );
2067                        if let Some(else_items) = else_items {
2068                            height += inner(
2069                                else_items,
2070                                config,
2071                                depth,
2072                                else_pending,
2073                                serial_pending,
2074                                active_activation_count,
2075                                parallel_depth,
2076                            );
2077                        }
2078                    } else {
2079                        height += block_header_space(config, depth);
2080                        height += inner(
2081                            items,
2082                            config,
2083                            depth + 1,
2084                            else_pending,
2085                            serial_pending,
2086                            active_activation_count,
2087                            parallel_depth,
2088                        );
2089                        if let Some(else_items) = else_items {
2090                            else_pending.push(true);
2091                            // Padding before and after else line
2092                            height += block_else_before(config, depth) + block_else_after(config, depth);
2093                            height += inner(
2094                                else_items,
2095                                config,
2096                                depth + 1,
2097                                else_pending,
2098                                serial_pending,
2099                                active_activation_count,
2100                                parallel_depth,
2101                            );
2102                            else_pending.pop();
2103                        }
2104                        // Block bottom and trailing margin
2105                        height += block_end_y_advance(config, depth);
2106                    }
2107                }
2108                Item::Activate { .. } => {
2109                    *active_activation_count += 1;
2110                }
2111                Item::Deactivate { .. } => {
2112                    if *active_activation_count > 0 {
2113                        *active_activation_count -= 1;
2114                    }
2115                }
2116                Item::Destroy { .. } => {
2117                    height += config.row_height;
2118                }
2119                Item::ParticipantDecl { .. } => {}
2120                Item::Autonumber { .. } => {}
2121                Item::DiagramOption { .. } => {} // Options don't take space
2122            }
2123        }
2124        height
2125    }
2126
2127    let mut else_pending = Vec::new();
2128    let mut serial_pending = Vec::new();
2129    let mut active_activation_count = 0;
2130    let mut parallel_depth = 0;
2131    inner(
2132        items,
2133        config,
2134        depth,
2135        &mut else_pending,
2136        &mut serial_pending,
2137        &mut active_activation_count,
2138        &mut parallel_depth,
2139    )
2140}
2141
2142fn render_participant_headers(svg: &mut String, state: &RenderState, y: f64) {
2143    let shape = state.config.theme.participant_shape;
2144
2145    for p in &state.participants {
2146        let x = state.get_x(p.id());
2147        let p_width = state.get_participant_width(p.id());
2148        let box_x = x - p_width / 2.0;
2149
2150        match p.kind {
2151            ParticipantKind::Participant => {
2152                // Draw shape based on theme
2153                match shape {
2154                    ParticipantShape::Rectangle => {
2155                        writeln!(
2156                            svg,
2157                            r#"<rect x="{x}" y="{y}" width="{w}" height="{h}" class="participant"/>"#,
2158                            x = box_x,
2159                            y = y,
2160                            w = p_width,
2161                            h = state.config.header_height
2162                        )
2163                        .unwrap();
2164                    }
2165                    ParticipantShape::RoundedRect => {
2166                        writeln!(
2167                            svg,
2168                            r#"<rect x="{x}" y="{y}" width="{w}" height="{h}" rx="8" ry="8" class="participant"/>"#,
2169                            x = box_x,
2170                            y = y,
2171                            w = p_width,
2172                            h = state.config.header_height
2173                        )
2174                        .unwrap();
2175                    }
2176                    ParticipantShape::Circle => {
2177                        // Draw ellipse/circle that fits in the header area
2178                        let rx = p_width / 2.0 - 5.0;
2179                        let ry = state.config.header_height / 2.0 - 2.0;
2180                        writeln!(
2181                            svg,
2182                            r#"<ellipse cx="{cx}" cy="{cy}" rx="{rx}" ry="{ry}" class="participant"/>"#,
2183                            cx = x,
2184                            cy = y + state.config.header_height / 2.0,
2185                            rx = rx,
2186                            ry = ry
2187                        )
2188                        .unwrap();
2189                    }
2190                }
2191                // Name centered in box (handle multiline with \n)
2192                let lines: Vec<&str> = p.name.split("\\n").collect();
2193                if lines.len() == 1 {
2194                    writeln!(
2195                        svg,
2196                        r#"<text x="{x}" y="{y}" class="participant-text">{name}</text>"#,
2197                        x = x,
2198                        y = y + state.config.header_height / 2.0 + 5.0,
2199                        name = escape_xml(&p.name)
2200                    )
2201                    .unwrap();
2202                } else {
2203                    let line_height = state.config.font_size + 2.0;
2204                    let total_height = lines.len() as f64 * line_height;
2205                    let start_y = y + state.config.header_height / 2.0 - total_height / 2.0
2206                        + line_height * 0.8;
2207                    write!(svg, r#"<text x="{x}" class="participant-text">"#, x = x).unwrap();
2208                    for (i, line) in lines.iter().enumerate() {
2209                        let dy = if i == 0 { start_y } else { line_height };
2210                        if i == 0 {
2211                            writeln!(
2212                                svg,
2213                                r#"<tspan x="{x}" y="{y}">{text}</tspan>"#,
2214                                x = x,
2215                                y = dy,
2216                                text = escape_xml(line)
2217                            )
2218                            .unwrap();
2219                        } else {
2220                            writeln!(
2221                                svg,
2222                                r#"<tspan x="{x}" dy="{dy}">{text}</tspan>"#,
2223                                x = x,
2224                                dy = dy,
2225                                text = escape_xml(line)
2226                            )
2227                            .unwrap();
2228                        }
2229                    }
2230                    writeln!(svg, "</text>").unwrap();
2231                }
2232            }
2233            ParticipantKind::Actor => {
2234                // Stick figure at top of header area, name below within header
2235                let head_r = 8.0;
2236                let body_len = 12.0;
2237                let arm_len = 10.0;
2238                let leg_len = 10.0;
2239                let figure_height = 38.0; // head(16) + body(12) + legs(10)
2240
2241                // Position figure at top with small margin
2242                let fig_top = y + 8.0;
2243                let fig_center_y = fig_top + head_r + body_len / 2.0;
2244                let arm_y = fig_center_y + 2.0;
2245
2246                // Head
2247                writeln!(
2248                    svg,
2249                    r#"<circle cx="{x}" cy="{cy}" r="{r}" class="actor-head"/>"#,
2250                    x = x,
2251                    cy = fig_center_y - body_len / 2.0 - head_r,
2252                    r = head_r
2253                )
2254                .unwrap();
2255                // Body
2256                writeln!(
2257                    svg,
2258                    r#"<line x1="{x}" y1="{y1}" x2="{x}" y2="{y2}" class="actor-body"/>"#,
2259                    x = x,
2260                    y1 = fig_center_y - body_len / 2.0,
2261                    y2 = fig_center_y + body_len / 2.0
2262                )
2263                .unwrap();
2264                // Arms
2265                writeln!(
2266                    svg,
2267                    r#"<line x1="{x1}" y1="{y}" x2="{x2}" y2="{y}" class="actor-body"/>"#,
2268                    x1 = x - arm_len,
2269                    y = arm_y,
2270                    x2 = x + arm_len
2271                )
2272                .unwrap();
2273                // Left leg
2274                writeln!(
2275                    svg,
2276                    r#"<line x1="{x}" y1="{y1}" x2="{x2}" y2="{y2}" class="actor-body"/>"#,
2277                    x = x,
2278                    y1 = fig_center_y + body_len / 2.0,
2279                    x2 = x - leg_len * 0.6,
2280                    y2 = fig_center_y + body_len / 2.0 + leg_len
2281                )
2282                .unwrap();
2283                // Right leg
2284                writeln!(
2285                    svg,
2286                    r#"<line x1="{x}" y1="{y1}" x2="{x2}" y2="{y2}" class="actor-body"/>"#,
2287                    x = x,
2288                    y1 = fig_center_y + body_len / 2.0,
2289                    x2 = x + leg_len * 0.6,
2290                    y2 = fig_center_y + body_len / 2.0 + leg_len
2291                )
2292                .unwrap();
2293                // Name below figure (within header)
2294                let name_lines: Vec<&str> = p.name.split("\\n").collect();
2295                let name_start_y = fig_top + figure_height + 5.0;
2296                if name_lines.len() == 1 {
2297                    writeln!(
2298                        svg,
2299                        r#"<text x="{x}" y="{y}" class="participant-text">{name}</text>"#,
2300                        x = x,
2301                        y = name_start_y + state.config.font_size,
2302                        name = escape_xml(&p.name)
2303                    )
2304                    .unwrap();
2305                } else {
2306                    // Multiline actor name using tspan
2307                    let line_height = state.config.font_size + 2.0;
2308                    writeln!(svg, r#"<text x="{x}" class="participant-text">"#, x = x).unwrap();
2309                    for (i, line) in name_lines.iter().enumerate() {
2310                        if i == 0 {
2311                            writeln!(
2312                                svg,
2313                                r#"<tspan x="{x}" y="{y}">{text}</tspan>"#,
2314                                x = x,
2315                                y = name_start_y + state.config.font_size,
2316                                text = escape_xml(line)
2317                            )
2318                            .unwrap();
2319                        } else {
2320                            writeln!(
2321                                svg,
2322                                r#"<tspan x="{x}" dy="{dy}">{text}</tspan>"#,
2323                                x = x,
2324                                dy = line_height,
2325                                text = escape_xml(line)
2326                            )
2327                            .unwrap();
2328                        }
2329                    }
2330                    writeln!(svg, "</text>").unwrap();
2331                }
2332            }
2333        }
2334    }
2335}
2336
2337fn render_items(svg: &mut String, state: &mut RenderState, items: &[Item], depth: usize) {
2338    for item in items {
2339        match item {
2340            Item::Message {
2341                from,
2342                to,
2343                text,
2344                arrow,
2345                activate,
2346                deactivate,
2347                create,
2348                ..
2349            } => {
2350                render_message(
2351                    svg,
2352                    state,
2353                    from,
2354                    to,
2355                    text,
2356                    arrow,
2357                    *activate,
2358                    *deactivate,
2359                    *create,
2360                    depth,
2361                );
2362            }
2363            Item::Note {
2364                position,
2365                participants,
2366                text,
2367            } => {
2368                render_note(svg, state, position, participants, text);
2369            }
2370            Item::Block {
2371                kind,
2372                label,
2373                items,
2374                else_items,
2375                else_label,
2376            } => {
2377                render_block(svg, state, kind, label, items, else_items.as_deref(), else_label.as_deref(), depth);
2378            }
2379            Item::Activate { participant } => {
2380                let y = state.current_y;
2381                state
2382                    .activations
2383                    .entry(participant.clone())
2384                    .or_default()
2385                    .push((y, None));
2386            }
2387            Item::Deactivate { participant } => {
2388                if let Some(acts) = state.activations.get_mut(participant) {
2389                    if let Some(act) = acts.last_mut() {
2390                        if act.1.is_none() {
2391                            act.1 = Some(state.current_y);
2392                        }
2393                    }
2394                }
2395            }
2396            Item::Destroy { participant } => {
2397                // X mark should be at the previous message's arrow position (WSD compatible)
2398                // After a message, current_y is incremented by row_height, so we subtract it back
2399                let destroy_y = state.current_y - state.config.row_height;
2400                state.destroyed.insert(participant.clone(), destroy_y);
2401                // Draw X mark on the lifeline
2402                let x = state.get_x(participant);
2403                let y = destroy_y;
2404                let size = 15.0; // WSD uses 15px for X mark size
2405                let theme = &state.config.theme;
2406                writeln!(
2407                    svg,
2408                    r#"<line x1="{x1}" y1="{y1}" x2="{x2}" y2="{y2}" stroke="{stroke}" stroke-width="2"/>"#,
2409                    x1 = x - size,
2410                    y1 = y - size,
2411                    x2 = x + size,
2412                    y2 = y + size,
2413                    stroke = theme.message_color
2414                )
2415                .unwrap();
2416                writeln!(
2417                    svg,
2418                    r#"<line x1="{x1}" y1="{y1}" x2="{x2}" y2="{y2}" stroke="{stroke}" stroke-width="2"/>"#,
2419                    x1 = x + size,
2420                    y1 = y - size,
2421                    x2 = x - size,
2422                    y2 = y + size,
2423                    stroke = theme.message_color
2424                )
2425                .unwrap();
2426                state.current_y += state.config.row_height;
2427            }
2428            Item::Autonumber { enabled, start } => {
2429                if *enabled {
2430                    state.autonumber = Some(start.unwrap_or(1));
2431                } else {
2432                    state.autonumber = None;
2433                }
2434            }
2435            Item::ParticipantDecl { .. } => {
2436                // Already processed
2437            }
2438            Item::State { participants, text } => {
2439                render_state(svg, state, participants, text);
2440            }
2441            Item::Ref {
2442                participants,
2443                text,
2444                input_from,
2445                input_label,
2446                output_to,
2447                output_label,
2448            } => {
2449                render_ref(
2450                    svg,
2451                    state,
2452                    participants,
2453                    text,
2454                    input_from.as_deref(),
2455                    input_label.as_deref(),
2456                    output_to.as_deref(),
2457                    output_label.as_deref(),
2458                );
2459            }
2460            Item::DiagramOption { .. } => {
2461                // Options are processed at render start, not during item rendering
2462            }
2463            Item::Description { text } => {
2464                render_description(svg, state, text);
2465            }
2466        }
2467    }
2468}
2469
2470fn render_message(
2471    svg: &mut String,
2472    state: &mut RenderState,
2473    from: &str,
2474    to: &str,
2475    text: &str,
2476    arrow: &Arrow,
2477    activate: bool,
2478    deactivate: bool,
2479    create: bool,
2480    _depth: usize,
2481) {
2482    // Get base lifeline positions (used for text centering and direction calculation)
2483    let base_x1 = state.get_x(from);
2484    let base_x2 = state.get_x(to);
2485
2486    state.apply_else_return_gap(arrow);
2487
2488    let is_self = from == to;
2489    let line_class = match arrow.line {
2490        LineStyle::Solid => "message",
2491        LineStyle::Dashed => "message-dashed",
2492    };
2493    let is_filled = matches!(arrow.head, ArrowHead::Filled);
2494
2495    // Get autonumber prefix
2496    let num_prefix = state
2497        .next_number()
2498        .map(|n| format!("{}. ", n))
2499        .unwrap_or_default();
2500
2501    // Calculate text lines and height
2502    let display_text = format!("{}{}", num_prefix, text);
2503    let lines: Vec<&str> = display_text.split("\\n").collect();
2504    let line_height = state.config.font_size + 4.0;
2505    let extra_height = if !is_self && lines.len() > 1 {
2506        // Use actual line_height (not spacing_line_height) to match text rendering
2507        // Extra lines + MESSAGE_TEXT_ABOVE_ARROW offset (text is rendered above arrow)
2508        (lines.len() - 1) as f64 * line_height + MESSAGE_TEXT_ABOVE_ARROW
2509    } else {
2510        0.0
2511    };
2512
2513    // Add space BEFORE the message for multiline text (text is rendered above arrow)
2514    if !is_self && lines.len() > 1 {
2515        state.current_y += extra_height;
2516    }
2517
2518    let y = state.current_y;
2519    let has_label_text = lines.iter().any(|line| !line.trim().is_empty());
2520
2521    // Calculate activation-aware arrow endpoints
2522    let going_right = base_x2 > base_x1;
2523    let x1 = state.get_arrow_start_x(from, y, going_right);
2524    let x2 = state.get_arrow_end_x(to, y, !going_right);
2525
2526    // Open message group
2527    writeln!(svg, r#"<g class="message">"#).unwrap();
2528
2529    if is_self {
2530        // Self message - loop back
2531        let loop_width = 40.0;
2532        let text_block_height = lines.len() as f64 * line_height;
2533        // WSD: loop height equals text block height, no extra padding
2534        let loop_height = text_block_height.max(25.0);
2535        let arrow_end_x = x1;
2536        let arrow_end_y = y + loop_height;
2537        // Arrowhead points left (PI radians)
2538        let direction = std::f64::consts::PI;
2539        let arrow_points = arrowhead_points(arrow_end_x, arrow_end_y, direction);
2540
2541        writeln!(
2542            svg,
2543            r#"  <path d="M {x1} {y} L {x2} {y} L {x2} {y2} L {arrow_x} {y2}" class="{cls}"/>"#,
2544            x1 = x1,
2545            y = y,
2546            x2 = x1 + loop_width,
2547            y2 = y + loop_height,
2548            arrow_x = arrow_end_x + ARROWHEAD_SIZE,
2549            cls = line_class
2550        )
2551        .unwrap();
2552
2553        // Draw arrowhead as polygon or polyline
2554        if is_filled {
2555            writeln!(
2556                svg,
2557                r#"  <polygon points="{points}" class="arrowhead"/>"#,
2558                points = arrow_points
2559            )
2560            .unwrap();
2561        } else {
2562            writeln!(
2563                svg,
2564                r#"  <polyline points="{points}" class="arrowhead-open"/>"#,
2565                points = arrow_points
2566            )
2567            .unwrap();
2568        }
2569
2570        // Text - multiline support
2571        // Self-messages don't need collision detection - they're positioned to the right
2572        // of the loop and won't collide with regular centered messages
2573        let text_x = x1 + loop_width + 5.0;
2574        for (i, line) in lines.iter().enumerate() {
2575            let line_y = y + 4.0 + (i as f64 + 0.5) * line_height;
2576            writeln!(
2577                svg,
2578                r#"  <text x="{x}" y="{y}" class="message-text">{t}</text>"#,
2579                x = text_x,
2580                y = line_y,
2581                t = escape_xml(line)
2582            )
2583            .unwrap();
2584        }
2585
2586        // Close message group
2587        writeln!(svg, r#"</g>"#).unwrap();
2588
2589        let spacing = self_message_spacing(&state.config, lines.len());
2590        state.current_y += spacing;
2591    } else {
2592        // Regular message - check for delay
2593        let delay_offset = arrow.delay.map(|d| d as f64 * DELAY_UNIT).unwrap_or(0.0);
2594        let y2 = y + delay_offset;
2595
2596        // Text is centered between lifelines (not activation bar edges)
2597        let text_x = (base_x1 + base_x2) / 2.0;
2598        let text_y = (y + y2) / 2.0 - 6.0;  // WSD: label slightly above arrow
2599
2600        // Calculate arrowhead direction and shorten line to not overlap with arrowhead
2601        let direction = arrow_direction(x1, y, x2, y2);
2602        let arrow_points = arrowhead_points(x2, y2, direction);
2603
2604        // Shorten the line so it doesn't overlap with the arrowhead
2605        let line_end_x = x2 - ARROWHEAD_SIZE * direction.cos();
2606        let line_end_y = y2 - ARROWHEAD_SIZE * direction.sin();
2607
2608        // Draw arrow line (slanted if delay)
2609        writeln!(
2610            svg,
2611            r#"  <line x1="{x1}" y1="{y1}" x2="{lx2}" y2="{ly2}" class="{cls}"/>"#,
2612            x1 = x1,
2613            y1 = y,
2614            lx2 = line_end_x,
2615            ly2 = line_end_y,
2616            cls = line_class
2617        )
2618        .unwrap();
2619
2620        // Draw arrowhead as polygon or polyline
2621        if is_filled {
2622            writeln!(
2623                svg,
2624                r#"  <polygon points="{points}" class="arrowhead"/>"#,
2625                points = arrow_points
2626            )
2627            .unwrap();
2628        } else {
2629            writeln!(
2630                svg,
2631                r#"  <polyline points="{points}" class="arrowhead-open"/>"#,
2632                points = arrow_points
2633            )
2634            .unwrap();
2635        }
2636
2637        // Text with multiline support (positioned at midpoint of slanted line)
2638        let max_width = lines
2639            .iter()
2640            .map(|line| estimate_message_width(line, state.config.font_size))
2641            .fold(0.0, f64::max);
2642        let top_line_y = text_y - (lines.len() as f64 - 1.0) * line_height;
2643        let bottom_line_y = text_y;
2644        let label_offset = if has_label_text {
2645            let label_y_min = top_line_y - line_height * MESSAGE_LABEL_ASCENT_FACTOR;
2646            let label_y_max = bottom_line_y + line_height * MESSAGE_LABEL_DESCENT_FACTOR;
2647            let label_x_min = text_x - max_width / 2.0;
2648            let label_x_max = text_x + max_width / 2.0;
2649            let step = line_height * MESSAGE_LABEL_COLLISION_STEP_RATIO;
2650            let raw_offset = state.reserve_message_label(label_x_min, label_x_max, label_y_min, label_y_max, step);
2651            // Limit offset to prevent text from going below the arrow
2652            let max_offset = y - MESSAGE_TEXT_ABOVE_ARROW - bottom_line_y;
2653            raw_offset.min(max_offset.max(0.0))
2654        } else {
2655            0.0
2656        };
2657        // Calculate rotation angle for delayed messages (slanted arrow)
2658        let rotation = if delay_offset > 0.0 {
2659            let dx = x2 - x1;
2660            let dy = delay_offset;
2661            let angle_rad = dy.atan2(dx.abs());
2662            let angle_deg = angle_rad.to_degrees();
2663            // Rotate in the direction of the arrow
2664            if dx < 0.0 { -angle_deg } else { angle_deg }
2665        } else {
2666            0.0
2667        };
2668
2669        for (i, line) in lines.iter().enumerate() {
2670            let line_y = text_y - (lines.len() - 1 - i) as f64 * line_height + label_offset;
2671            if rotation.abs() > 0.1 {
2672                // Apply rotation transform for delayed messages
2673                writeln!(
2674                    svg,
2675                    r#"  <text x="{x}" y="{y}" class="message-text" text-anchor="middle" transform="rotate({rot},{cx},{cy})">{t}</text>"#,
2676                    x = text_x,
2677                    y = line_y,
2678                    rot = rotation,
2679                    cx = text_x,
2680                    cy = line_y,
2681                    t = escape_xml(line)
2682                )
2683                .unwrap();
2684            } else {
2685                writeln!(
2686                    svg,
2687                    r#"  <text x="{x}" y="{y}" class="message-text" text-anchor="middle">{t}</text>"#,
2688                    x = text_x,
2689                    y = line_y,
2690                    t = escape_xml(line)
2691                )
2692                .unwrap();
2693            }
2694        }
2695
2696        // Close message group
2697        writeln!(svg, r#"</g>"#).unwrap();
2698
2699        // Add row_height plus delay offset
2700        state.current_y += state.config.row_height + delay_offset;
2701    }
2702
2703    if create {
2704        state.current_y += state.config.row_height;
2705    }
2706
2707    state.apply_serial_first_row_gap();
2708
2709    // Handle activation
2710    if activate {
2711        state
2712            .activations
2713            .entry(to.to_string())
2714            .or_default()
2715            .push((y, None));
2716    }
2717    if deactivate {
2718        if let Some(acts) = state.activations.get_mut(from) {
2719            if let Some(act) = acts.last_mut() {
2720                if act.1.is_none() {
2721                    act.1 = Some(y);
2722                }
2723            }
2724        }
2725    }
2726}
2727
2728fn render_note(
2729    svg: &mut String,
2730    state: &mut RenderState,
2731    position: &NotePosition,
2732    participants: &[String],
2733    text: &str,
2734) {
2735    let lines: Vec<&str> = text.split("\\n").collect();
2736    let line_height = note_line_height(&state.config);
2737
2738    // Calculate note size (same padding on all sides)
2739    let max_line_len = lines.iter().map(|l| l.chars().count()).max().unwrap_or(5);
2740    let text_width = max_line_len as f64 * NOTE_CHAR_WIDTH;
2741    let content_width = (ELEMENT_PADDING * 2.0 + text_width).max(NOTE_MIN_WIDTH);
2742    let note_height = ELEMENT_PADDING * 2.0 + lines.len() as f64 * line_height;
2743
2744    let (x, note_width, text_anchor) = match position {
2745        NotePosition::Left => {
2746            let px = state.get_x(&participants[0]);
2747            // Note right edge = px - NOTE_MARGIN
2748            let x = (px - NOTE_MARGIN - content_width).max(state.config.padding);
2749            (x, content_width, "start")
2750        }
2751        NotePosition::Right => {
2752            let px = state.get_x(&participants[0]);
2753            // Note left edge = px + NOTE_MARGIN
2754            (px + NOTE_MARGIN, content_width, "start")
2755        }
2756        NotePosition::Over => {
2757            if participants.len() == 1 {
2758                let px = state.get_x(&participants[0]);
2759                // Center on lifeline
2760                let x = (px - content_width / 2.0).max(state.config.padding);
2761                (x, content_width, "middle")
2762            } else {
2763                // Span multiple participants
2764                let x1 = state.get_x(&participants[0]);
2765                let x2 = state.get_x(participants.last().unwrap());
2766                let span_width = (x2 - x1).abs() + NOTE_MARGIN * 2.0;
2767                let w = span_width.max(content_width);
2768                let x = (x1 - NOTE_MARGIN).max(state.config.padding);
2769                (x, w, "middle")
2770            }
2771        }
2772    };
2773
2774    let y = state.current_y;
2775    let fold_size = NOTE_FOLD_SIZE;
2776
2777    // Note background with dog-ear (folded corner) effect
2778    // Path: start at top-left, go right (leaving space for fold), diagonal fold, down, left, up
2779    let note_path = format!(
2780        "M {x} {y} L {x2} {y} L {x3} {y2} L {x3} {y3} L {x} {y3} Z",
2781        x = x,
2782        y = y,
2783        x2 = x + note_width - fold_size,
2784        x3 = x + note_width,
2785        y2 = y + fold_size,
2786        y3 = y + note_height
2787    );
2788
2789    writeln!(svg, r#"<path d="{path}" class="note"/>"#, path = note_path).unwrap();
2790
2791    // Draw the fold triangle (represents the folded corner)
2792    let theme = &state.config.theme;
2793    // Triangle: from fold start, to diagonal corner, to bottom of fold
2794    let fold_path = format!(
2795        "M {x1} {y1} L {x2} {y2} L {x1} {y2} Z",
2796        x1 = x + note_width - fold_size,
2797        y1 = y,
2798        x2 = x + note_width,
2799        y2 = y + fold_size
2800    );
2801
2802    writeln!(
2803        svg,
2804        r##"<path d="{path}" fill="none" stroke="{stroke}" stroke-width="1"/>"##,
2805        path = fold_path,
2806        stroke = theme.note_stroke
2807    )
2808    .unwrap();
2809
2810    // Text position (same padding on all sides)
2811    let text_x = match text_anchor {
2812        "middle" => x + note_width / 2.0,
2813        _ => x + ELEMENT_PADDING,
2814    };
2815    let text_anchor_attr = if *position == NotePosition::Over { "middle" } else { "start" };
2816
2817    for (i, line) in lines.iter().enumerate() {
2818        let text_y = y + ELEMENT_PADDING + (i as f64 + 0.8) * line_height;
2819        writeln!(
2820            svg,
2821            r#"<text x="{x}" y="{y}" class="note-text" text-anchor="{anchor}">{t}</text>"#,
2822            x = text_x,
2823            y = text_y,
2824            anchor = text_anchor_attr,
2825            t = escape_xml(line)
2826        )
2827        .unwrap();
2828    }
2829
2830    // Add spacing between elements
2831    state.current_y += note_y_advance(&state.config, lines.len());
2832}
2833
2834/// Render a state box (rounded rectangle)
2835fn render_state(svg: &mut String, state: &mut RenderState, participants: &[String], text: &str) {
2836    let theme = &state.config.theme;
2837    let lines: Vec<&str> = text.split("\\n").collect();
2838    let line_height = state_line_height(&state.config);
2839    let box_height = state.config.note_padding * 2.0 + lines.len() as f64 * line_height;
2840
2841    // Calculate box position and width
2842    let (x, box_width) = if participants.len() == 1 {
2843        let px = state.get_x(&participants[0]);
2844        let max_line_len = lines.iter().map(|l| l.chars().count()).max().unwrap_or(8);
2845        let w = (max_line_len as f64 * 8.0 + state.config.note_padding * 2.0).max(60.0);
2846        (px - w / 2.0, w)
2847    } else {
2848        let x1 = state.get_x(&participants[0]);
2849        let x2 = state.get_x(participants.last().unwrap());
2850        let span_width = (x2 - x1).abs() + state.config.participant_width * 0.6;
2851        let center = (x1 + x2) / 2.0;
2852        (center - span_width / 2.0, span_width)
2853    };
2854
2855    let shift = item_pre_shift(&state.config);
2856    let y = (state.current_y - shift).max(state.content_start());
2857
2858    // Draw rounded rectangle
2859    writeln!(
2860        svg,
2861        r##"<rect x="{x}" y="{y}" width="{w}" height="{h}" rx="8" ry="8" fill="{fill}" stroke="{stroke}" stroke-width="1.5"/>"##,
2862        x = x,
2863        y = y,
2864        w = box_width,
2865        h = box_height,
2866        fill = theme.state_fill,
2867        stroke = theme.state_stroke
2868    )
2869    .unwrap();
2870
2871    // Draw text
2872    let text_x = x + box_width / 2.0;
2873    for (i, line) in lines.iter().enumerate() {
2874        let text_y = y + state.config.note_padding + (i as f64 + 0.8) * line_height;
2875        writeln!(
2876            svg,
2877            r##"<text x="{x}" y="{y}" text-anchor="middle" fill="{fill}" font-family="{font}" font-size="{size}px">{t}</text>"##,
2878            x = text_x,
2879            y = text_y,
2880            fill = theme.state_text_color,
2881            font = theme.font_family,
2882            size = state.config.font_size,
2883            t = escape_xml(line)
2884        )
2885        .unwrap();
2886    }
2887
2888    state.current_y = y + box_height + state.config.row_height;
2889}
2890
2891/// Render a ref box (hexagon-like shape)
2892fn render_ref(
2893    svg: &mut String,
2894    state: &mut RenderState,
2895    participants: &[String],
2896    text: &str,
2897    input_from: Option<&str>,
2898    input_label: Option<&str>,
2899    output_to: Option<&str>,
2900    output_label: Option<&str>,
2901) {
2902    let theme = &state.config.theme;
2903    let lines: Vec<&str> = text.split("\\n").collect();
2904    let line_height = ref_line_height(&state.config);
2905    let box_height = state.config.note_padding * 2.0 + lines.len() as f64 * line_height;
2906    let notch_size = 10.0;
2907
2908    // Calculate box position and width
2909    let (x, box_width) = if participants.len() == 1 {
2910        let px = state.get_x(&participants[0]);
2911        let max_line_len = lines.iter().map(|l| l.chars().count()).max().unwrap_or(15);
2912        let w = (max_line_len as f64 * 8.0 + state.config.note_padding * 2.0 + notch_size * 2.0)
2913            .max(100.0);
2914        (px - w / 2.0, w)
2915    } else {
2916        let x1 = state.get_x(&participants[0]);
2917        let x2 = state.get_x(participants.last().unwrap());
2918        let span_width = (x2 - x1).abs() + state.config.participant_width * 0.8;
2919        let center = (x1 + x2) / 2.0;
2920        (center - span_width / 2.0, span_width)
2921    };
2922
2923    let shift = item_pre_shift(&state.config);
2924    let y = (state.current_y - shift).max(state.content_start());
2925    let input_offset = state.config.note_padding + state.config.font_size + 1.0;
2926    let output_padding = state.config.note_padding + 3.0;
2927
2928    // Draw input signal arrow if present
2929    if let Some(from) = input_from {
2930        let from_x = state.get_x(from);
2931        let to_x = x; // Left edge of ref box
2932        let arrow_y = y + input_offset;
2933
2934        // Calculate arrowhead
2935        let direction = arrow_direction(from_x, arrow_y, to_x, arrow_y);
2936        let arrow_points = arrowhead_points(to_x, arrow_y, direction);
2937        let line_end_x = to_x - ARROWHEAD_SIZE * direction.cos();
2938
2939        // Draw arrow line
2940        writeln!(
2941            svg,
2942            r##"<line x1="{x1}" y1="{y}" x2="{x2}" y2="{y}" class="message"/>"##,
2943            x1 = from_x,
2944            y = arrow_y,
2945            x2 = line_end_x
2946        )
2947        .unwrap();
2948
2949        // Draw arrowhead
2950        writeln!(
2951            svg,
2952            r#"<polygon points="{points}" class="arrowhead"/>"#,
2953            points = arrow_points
2954        )
2955        .unwrap();
2956
2957        // Draw label if present
2958        if let Some(label) = input_label {
2959            let text_x = (from_x + to_x) / 2.0;
2960            writeln!(
2961                svg,
2962                r##"<text x="{x}" y="{y}" class="message-text" text-anchor="middle">{t}</text>"##,
2963                x = text_x,
2964                y = arrow_y - 8.0,
2965                t = escape_xml(label)
2966            )
2967            .unwrap();
2968        }
2969    }
2970
2971    // Draw hexagon-like shape (ref box in WSD style)
2972    // Left side has a notch cut
2973    let ref_path = format!(
2974        "M {x1} {y1} L {x2} {y1} L {x2} {y2} L {x1} {y2} L {x3} {y3} Z",
2975        x1 = x + notch_size,
2976        y1 = y,
2977        x2 = x + box_width,
2978        y2 = y + box_height,
2979        x3 = x,
2980        y3 = y + box_height / 2.0
2981    );
2982
2983    writeln!(
2984        svg,
2985        r##"<path d="{path}" fill="{fill}" stroke="{stroke}" stroke-width="1.5"/>"##,
2986        path = ref_path,
2987        fill = theme.ref_fill,
2988        stroke = theme.ref_stroke
2989    )
2990    .unwrap();
2991
2992    // Add "ref" label in top-left
2993    writeln!(
2994        svg,
2995        r##"<text x="{x}" y="{y}" fill="{fill}" font-family="{font}" font-size="{size}px" font-weight="bold">ref</text>"##,
2996        x = x + notch_size + 4.0,
2997        y = y + state.config.font_size,
2998        fill = theme.ref_text_color,
2999        font = theme.font_family,
3000        size = state.config.font_size - 2.0
3001    )
3002    .unwrap();
3003
3004    // Draw text centered
3005    let text_x = x + box_width / 2.0;
3006    for (i, line) in lines.iter().enumerate() {
3007        let text_y = y + state.config.note_padding + (i as f64 + 0.8) * line_height;
3008        writeln!(
3009            svg,
3010            r##"<text x="{x}" y="{y}" text-anchor="middle" fill="{fill}" font-family="{font}" font-size="{size}px">{t}</text>"##,
3011            x = text_x,
3012            y = text_y,
3013            fill = theme.ref_text_color,
3014            font = theme.font_family,
3015            size = state.config.font_size,
3016            t = escape_xml(line)
3017        )
3018        .unwrap();
3019    }
3020
3021    // Draw output signal arrow if present
3022    if let Some(to) = output_to {
3023        let from_x = x + box_width; // Right edge of ref box
3024        let to_x = state.get_x(to);
3025        let arrow_y = y + box_height - output_padding;
3026
3027        // Calculate arrowhead
3028        let direction = arrow_direction(from_x, arrow_y, to_x, arrow_y);
3029        let arrow_points = arrowhead_points(to_x, arrow_y, direction);
3030        let line_end_x = to_x - ARROWHEAD_SIZE * direction.cos();
3031
3032        // Draw dashed arrow line (response style)
3033        writeln!(
3034            svg,
3035            r##"<line x1="{x1}" y1="{y}" x2="{x2}" y2="{y}" class="message-dashed"/>"##,
3036            x1 = from_x,
3037            y = arrow_y,
3038            x2 = line_end_x
3039        )
3040        .unwrap();
3041
3042        // Draw arrowhead
3043        writeln!(
3044            svg,
3045            r#"<polygon points="{points}" class="arrowhead"/>"#,
3046            points = arrow_points
3047        )
3048        .unwrap();
3049
3050        // Draw label if present
3051        if let Some(label) = output_label {
3052            let text_x = (from_x + to_x) / 2.0;
3053            writeln!(
3054                svg,
3055                r##"<text x="{x}" y="{y}" class="message-text" text-anchor="middle">{t}</text>"##,
3056                x = text_x,
3057                y = arrow_y - 8.0,
3058                t = escape_xml(label)
3059            )
3060            .unwrap();
3061        }
3062    }
3063
3064    state.current_y = y + box_height + state.config.row_height;
3065}
3066
3067/// Render a description (extended text explanation)
3068fn render_description(svg: &mut String, state: &mut RenderState, text: &str) {
3069    let theme = &state.config.theme;
3070    let lines: Vec<&str> = text.split("\\n").collect();
3071    let line_height = state.config.font_size + 4.0;
3072
3073    // Draw text on the left side of the diagram
3074    let x = state.config.padding + 10.0;
3075    let y = state.current_y;
3076
3077    for (i, line) in lines.iter().enumerate() {
3078        let text_y = y + (i as f64 + 0.8) * line_height;
3079        writeln!(
3080            svg,
3081            r##"<text x="{x}" y="{y}" fill="{fill}" font-family="{font}" font-size="{size}px" font-style="italic">{t}</text>"##,
3082            x = x,
3083            y = text_y,
3084            fill = theme.description_text_color,
3085            font = theme.font_family,
3086            size = state.config.font_size - 1.0,
3087            t = escape_xml(line)
3088        )
3089        .unwrap();
3090    }
3091
3092    state.current_y += description_y_advance(&state.config, lines.len());
3093}
3094
3095fn render_block(
3096    svg: &mut String,
3097    state: &mut RenderState,
3098    kind: &BlockKind,
3099    _label: &str,
3100    items: &[Item],
3101    else_items: Option<&[Item]>,
3102    _else_label: Option<&str>,
3103    depth: usize,
3104) {
3105    if block_is_parallel(kind) {
3106        state.push_parallel();
3107        let start_y = state.current_y;
3108        let mut max_end_y = start_y;
3109        for item in items {
3110            state.current_y = start_y;
3111            render_items(svg, state, std::slice::from_ref(item), depth);
3112            if state.current_y > max_end_y {
3113                max_end_y = state.current_y;
3114            }
3115        }
3116        let gap = if parallel_needs_gap(items) {
3117            state.config.row_height
3118        } else {
3119            0.0
3120        };
3121        state.current_y = max_end_y + gap;
3122        state.pop_parallel();
3123        return;
3124    }
3125
3126    if matches!(kind, BlockKind::Serial) {
3127        state.push_serial_first_row_pending();
3128        render_items(svg, state, items, depth);
3129        if let Some(else_items) = else_items {
3130            render_items(svg, state, else_items, depth);
3131        }
3132        state.pop_serial_first_row_pending();
3133        return;
3134    }
3135
3136    if !block_has_frame(kind) {
3137        render_items(svg, state, items, depth);
3138        if let Some(else_items) = else_items {
3139            render_items(svg, state, else_items, depth);
3140        }
3141        return;
3142    }
3143
3144    // Note: Block frame, labels, and else separators are rendered by render_block_labels()
3145    // This function only handles Y position tracking and rendering of inner items
3146    // svg is still used for rendering inner items via render_items()
3147
3148    state.current_y += block_header_space(&state.config, depth);
3149
3150    // Render items
3151    render_items(svg, state, items, depth + 1);
3152
3153    // Render else items if present
3154    if let Some(else_items) = else_items {
3155        state.push_else_return_pending();
3156        // Padding before else line (same as collect_block_backgrounds)
3157        state.current_y += block_else_before(&state.config, depth);
3158        // Padding after else line
3159        state.current_y += block_else_after(&state.config, depth);
3160        render_items(svg, state, else_items, depth + 1);
3161        state.pop_else_return_pending();
3162    }
3163
3164    // Block bottom = current Y + footer padding
3165    // (ensures messages don't overflow outside the block)
3166    let end_y = state.current_y + block_footer_padding(&state.config, depth);
3167
3168    // Set current_y to end of block + margin
3169    state.current_y = end_y + state.config.row_height;
3170
3171    // Block frame, labels, and else separators are rendered later by render_block_labels()
3172    // which is called after activations are drawn, so labels appear on top
3173}
3174
3175fn render_activations(svg: &mut String, state: &mut RenderState, footer_y: f64) {
3176    for (participant, activations) in &state.activations {
3177        let x = state.get_x(participant);
3178        let box_x = x - state.config.activation_width / 2.0;
3179
3180        for (start_y, end_y) in activations {
3181            // If no end_y, extend to footer
3182            let end = end_y.unwrap_or(footer_y);
3183            let height = end - start_y;
3184
3185            if height > 0.0 {
3186                writeln!(
3187                    svg,
3188                    r#"<rect x="{x}" y="{y}" width="{w}" height="{h}" class="activation"/>"#,
3189                    x = box_x,
3190                    y = start_y,
3191                    w = state.config.activation_width,
3192                    h = height
3193                )
3194                .unwrap();
3195            }
3196        }
3197    }
3198}
3199
3200fn escape_xml(s: &str) -> String {
3201    s.replace('&', "&amp;")
3202        .replace('<', "&lt;")
3203        .replace('>', "&gt;")
3204        .replace('"', "&quot;")
3205        .replace('\'', "&apos;")
3206}
3207
3208#[cfg(test)]
3209mod tests {
3210    use super::*;
3211    use crate::parser::parse;
3212
3213    #[test]
3214    fn test_render_simple() {
3215        let diagram = parse("Alice->Bob: Hello").unwrap();
3216        let svg = render(&diagram);
3217        assert!(svg.contains("<svg"));
3218        assert!(svg.contains("Alice"));
3219        assert!(svg.contains("Bob"));
3220        assert!(svg.contains("Hello"));
3221    }
3222
3223    #[test]
3224    fn test_render_with_note() {
3225        let diagram = parse("Alice->Bob: Hello\nnote over Alice: Thinking").unwrap();
3226        let svg = render(&diagram);
3227        assert!(svg.contains("Thinking"));
3228    }
3229}