osd_core/
renderer.rs

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