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