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