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