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