osd_core/
renderer.rs

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