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