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