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