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: 68.0, right_margin: 68.0, participant_gap: 122.0, header_height: 46.0, row_height: 26.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 = 8.0;
132const MESSAGE_WIDTH_SCALE: f64 = 0.9;
133const DELAY_UNIT: f64 = 18.0;
134const BLOCK_LABEL_HEIGHT: f64 = 24.0;
135const BLOCK_FOOTER_PADDING_LEVEL1: f64 = 1.5;
136const BLOCK_FOOTER_PADDING_DEEP: f64 = 1.5;
137const BLOCK_FOOTER_PADDING_TOP_FACTOR: f64 = 1.8;
138const BLOCK_ELSE_SPACING_LEVEL1: f64 = 1.1875;
139const BLOCK_ELSE_SPACING_DEEP: f64 = 1.15625;
140const BLOCK_ELSE_TOP_SPACING_FACTOR: f64 = 1.15625;
141const BLOCK_NESTED_HEADER_ADJUST: f64 = 18.0;
142const BLOCK_NESTED_FRAME_SHIFT: f64 = 18.0;
143const PARALLEL_BLOCK_GAP: f64 = 22.0;
144const MESSAGE_SPACING_MULT: f64 = 0.5625;
145const SELF_MESSAGE_MIN_SPACING: f64 = 78.0;
146const SELF_MESSAGE_GAP: f64 = 16.0;
147const CREATE_MESSAGE_SPACING: f64 = 41.0;
148const DESTROY_SPACING: f64 = 15.0;
149const NOTE_PADDING: f64 = 9.5;
150const NOTE_LINE_HEIGHT_EXTRA: f64 = 6.0;
151const NOTE_MARGIN: f64 = 16.0; 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 max_weighted_line(text: &str) -> f64 {
330 text.split("\\n")
331 .map(|line| line.chars().map(text_char_weight).sum::<f64>())
332 .fold(0.0_f64, |a, b| a.max(b))
333}
334
335fn estimate_text_width(text: &str, font_size: f64) -> f64 {
337 let weighted = max_weighted_line(text);
338 weighted * font_size * TEXT_WIDTH_SCALE + TEXT_WIDTH_PADDING
339}
340
341fn estimate_message_width(text: &str, font_size: f64) -> f64 {
342 let weighted = max_weighted_line(text);
343 weighted * font_size * MESSAGE_WIDTH_SCALE + MESSAGE_WIDTH_PADDING
344}
345
346fn block_tab_width(kind: &str) -> f64 {
347 (kind.chars().count() as f64 * 12.0 + 21.0).max(57.0)
348}
349
350fn calculate_note_width(text: &str, config: &Config) -> f64 {
352 let lines: Vec<&str> = text.split("\\n").collect();
353 let max_line_len = lines.iter().map(|l| l.chars().count()).max().unwrap_or(10);
354 let padding = config.note_padding;
355 (max_line_len as f64 * 10.0 + padding * 2.0).max(80.0).min(300.0)
356}
357
358fn calculate_right_margin(
360 participants: &[Participant],
361 items: &[Item],
362 config: &Config,
363) -> f64 {
364 let rightmost_id = match participants.last() {
365 Some(p) => p.id().to_string(),
366 None => return config.right_margin,
367 };
368 let mut max_right_note_width: f64 = 0.0;
369
370 fn process_items_for_right_notes(
371 items: &[Item],
372 rightmost_id: &str,
373 max_width: &mut f64,
374 config: &Config,
375 ) {
376 for item in items {
377 match item {
378 Item::Note {
379 position: NotePosition::Right,
380 participants,
381 text,
382 } => {
383 if participants.first().map(|s| s.as_str()) == Some(rightmost_id) {
385 let note_width = calculate_note_width(text, config);
386 if note_width > *max_width {
387 *max_width = note_width;
388 }
389 }
390 }
391 Item::Block {
392 items, else_items, ..
393 } => {
394 process_items_for_right_notes(items, rightmost_id, max_width, config);
395 if let Some(else_items) = else_items {
396 process_items_for_right_notes(else_items, rightmost_id, max_width, config);
397 }
398 }
399 _ => {}
400 }
401 }
402 }
403
404 process_items_for_right_notes(items, &rightmost_id, &mut max_right_note_width, config);
405
406 if max_right_note_width > 0.0 {
408 (max_right_note_width + 10.0).max(config.right_margin)
409 } else {
410 config.right_margin
411 }
412}
413
414fn calculate_participant_gaps(
416 participants: &[Participant],
417 items: &[Item],
418 config: &Config,
419) -> Vec<f64> {
420 if participants.len() <= 1 {
421 return vec![];
422 }
423
424 let mut participant_index: HashMap<String, usize> = HashMap::new();
426 for (i, p) in participants.iter().enumerate() {
427 participant_index.insert(p.id().to_string(), i);
428 }
429
430 let min_gap = config.participant_gap;
433 let mut gaps: Vec<f64> = vec![min_gap; participants.len() - 1];
434
435 fn process_items(
437 items: &[Item],
438 participant_index: &HashMap<String, usize>,
439 gaps: &mut Vec<f64>,
440 config: &Config,
441 ) {
442 for item in items {
443 match item {
444 Item::Message { from, to, text, .. } => {
445 if let (Some(&from_idx), Some(&to_idx)) =
446 (participant_index.get(from), participant_index.get(to))
447 {
448 if from_idx != to_idx {
449 let (min_idx, max_idx) = if from_idx < to_idx {
450 (from_idx, to_idx)
451 } else {
452 (to_idx, from_idx)
453 };
454
455 let text_width = estimate_message_width(text, config.font_size);
456
457 let gap_count = (max_idx - min_idx) as f64;
460 let needed_gap = text_width / gap_count + 10.0;
461
462 for gap_idx in min_idx..max_idx {
464 if needed_gap > gaps[gap_idx] {
465 gaps[gap_idx] = needed_gap;
466 }
467 }
468 }
469 }
470 }
471 Item::Note {
472 position: NotePosition::Right,
473 participants: note_participants,
474 ..
475 } => {
476 if let Some(participant) = note_participants.first() {
479 if let Some(&idx) = participant_index.get(participant) {
480 if idx < gaps.len() {
481 let needed_gap = config.participant_gap * 1.3;
484 if needed_gap > gaps[idx] {
485 gaps[idx] = needed_gap;
486 }
487 }
488 }
489 }
490 }
491 Item::Block {
492 items, else_items, ..
493 } => {
494 process_items(items, participant_index, gaps, config);
495 if let Some(else_items) = else_items {
496 process_items(else_items, participant_index, gaps, config);
497 }
498 }
499 _ => {}
500 }
501 }
502 }
503
504 process_items(items, &participant_index, &mut gaps, config);
505
506 let max_gap = config.participant_gap * 5.0;
512 for gap in &mut gaps {
513 if *gap > max_gap {
514 *gap = max_gap;
515 }
516 }
517
518 gaps
519}
520
521impl RenderState {
522 fn new(
523 config: Config,
524 participants: Vec<Participant>,
525 items: &[Item],
526 has_title: bool,
527 footer_style: FooterStyle,
528 ) -> Self {
529 let mut config = config;
530 let mut required_header_height = config.header_height;
535 for p in &participants {
536 let lines = p.name.split("\\n").count();
537 let needed = match p.kind {
538 ParticipantKind::Participant => {
539 if lines <= 1 {
541 46.0
542 } else {
543 108.0 }
545 }
546 ParticipantKind::Actor => {
547 if lines <= 1 {
550 85.0
551 } else {
552 108.0
553 }
554 }
555 };
556 if needed > required_header_height {
557 required_header_height = needed;
558 }
559 }
560 if required_header_height > config.header_height {
561 config.header_height = required_header_height;
562 }
563 let mut participant_widths: HashMap<String, f64> = HashMap::new();
565 let min_width = config.participant_width;
566
567 for p in &participants {
568 let lines: Vec<&str> = p.name.split("\\n").collect();
572 let max_chars = lines.iter().map(|l| l.chars().count()).max().unwrap_or(1);
573 let width = if lines.len() > 1 {
574 (max_chars as f64 * 11.6 + 22.0).max(min_width)
576 } else {
577 (max_chars as f64 * 9.7 + 22.0).max(min_width)
579 };
580 participant_widths.insert(p.id().to_string(), width);
581 }
582
583 let gaps = calculate_participant_gaps(&participants, items, &config);
584
585 let left_margin = config.left_margin;
587 let right_margin = calculate_right_margin(&participants, items, &config);
590
591 let mut participant_x = HashMap::new();
592 let first_width = participants
593 .first()
594 .map(|p| *participant_widths.get(p.id()).unwrap_or(&min_width))
595 .unwrap_or(min_width);
596 let mut current_x = config.padding + left_margin + first_width / 2.0;
597
598 for (i, p) in participants.iter().enumerate() {
599 participant_x.insert(p.id().to_string(), current_x);
600 if i < gaps.len() {
601 let current_width = *participant_widths.get(p.id()).unwrap_or(&min_width);
602 let next_width = participants
603 .get(i + 1)
604 .map(|np| *participant_widths.get(np.id()).unwrap_or(&min_width))
605 .unwrap_or(min_width);
606 let min_center_gap = (current_width + next_width) / 2.0 + 20.0;
609 let actual_gap = gaps[i].max(min_center_gap);
610 current_x += actual_gap;
611 }
612 }
613
614 let last_width = participants
615 .last()
616 .map(|p| *participant_widths.get(p.id()).unwrap_or(&min_width))
617 .unwrap_or(min_width);
618 let total_width = current_x + last_width / 2.0 + right_margin + config.padding;
619
620 Self {
621 config,
622 participants,
623 participant_x,
624 participant_widths,
625 current_y: 0.0,
626 activations: HashMap::new(),
627 autonumber: None,
628 destroyed: HashMap::new(),
629 has_title,
630 total_width,
631 block_backgrounds: Vec::new(),
632 block_labels: Vec::new(),
633 footer_style,
634 else_return_pending: Vec::new(),
635 serial_first_row_pending: Vec::new(),
636 parallel_depth: 0,
637 message_label_boxes: Vec::new(),
638 }
639 }
640
641 fn get_participant_width(&self, name: &str) -> f64 {
642 *self
643 .participant_widths
644 .get(name)
645 .unwrap_or(&self.config.participant_width)
646 }
647
648 fn get_x(&self, name: &str) -> f64 {
649 *self.participant_x.get(name).unwrap_or(&0.0)
650 }
651
652 fn push_else_return_pending(&mut self) {
653 self.else_return_pending.push(true);
654 }
655
656 fn pop_else_return_pending(&mut self) {
657 self.else_return_pending.pop();
658 }
659
660 fn apply_else_return_gap(&mut self, arrow: &Arrow) {
661 if let Some(pending) = self.else_return_pending.last_mut() {
662 if *pending && matches!(arrow.line, LineStyle::Dashed) {
663 self.current_y += ELSE_RETURN_GAP;
664 *pending = false;
665 }
666 }
667 }
668
669 fn push_serial_first_row_pending(&mut self) {
670 self.serial_first_row_pending.push(true);
671 }
672
673 fn pop_serial_first_row_pending(&mut self) {
674 self.serial_first_row_pending.pop();
675 }
676
677 fn in_serial_block(&self) -> bool {
678 !self.serial_first_row_pending.is_empty()
679 }
680
681 fn apply_serial_first_row_gap(&mut self) {
682 if let Some(pending) = self.serial_first_row_pending.last_mut() {
683 if *pending {
684 self.current_y += serial_first_row_gap(self.parallel_depth);
685 *pending = false;
686 }
687 }
688 }
689
690 fn reserve_message_label(
691 &mut self,
692 x_min: f64,
693 x_max: f64,
694 mut y_min: f64,
695 mut y_max: f64,
696 step: f64,
697 ) -> f64 {
698 let mut offset = 0.0;
699 let mut attempts = 0;
700 while self
701 .message_label_boxes
702 .iter()
703 .any(|b| label_boxes_overlap(x_min, x_max, y_min, y_max, b))
704 && attempts < 20
705 {
706 y_min += step;
707 y_max += step;
708 offset += step;
709 attempts += 1;
710 }
711 self.message_label_boxes.push(LabelBox {
712 x_min,
713 x_max,
714 y_min,
715 y_max,
716 });
717 offset
718 }
719
720 fn push_parallel(&mut self) {
721 self.parallel_depth += 1;
722 }
723
724 fn pop_parallel(&mut self) {
725 if self.parallel_depth > 0 {
726 self.parallel_depth -= 1;
727 }
728 }
729
730 fn active_activation_count(&self) -> usize {
731 self.activations
732 .values()
733 .map(|acts| acts.iter().filter(|(_, end)| end.is_none()).count())
734 .sum()
735 }
736
737 fn diagram_width(&self) -> f64 {
738 self.total_width
739 }
740
741 fn leftmost_x(&self) -> f64 {
743 self.participants
744 .first()
745 .map(|p| self.get_x(p.id()))
746 .unwrap_or(self.config.padding)
747 }
748
749 fn rightmost_x(&self) -> f64 {
751 self.participants
752 .last()
753 .map(|p| self.get_x(p.id()))
754 .unwrap_or(self.total_width - self.config.padding)
755 }
756
757 fn block_left(&self) -> f64 {
759 let leftmost_width = self
760 .participants
761 .first()
762 .map(|p| self.get_participant_width(p.id()))
763 .unwrap_or(self.config.participant_width);
764 self.leftmost_x() - leftmost_width / 2.0 - self.config.block_margin
765 }
766
767 fn block_right(&self) -> f64 {
769 let rightmost_width = self
770 .participants
771 .last()
772 .map(|p| self.get_participant_width(p.id()))
773 .unwrap_or(self.config.participant_width);
774 self.rightmost_x() + rightmost_width / 2.0 + self.config.block_margin
775 }
776
777 fn header_top(&self) -> f64 {
778 if self.has_title {
779 self.config.padding + self.config.title_height
780 } else {
781 self.config.padding
782 }
783 }
784
785 fn content_start(&self) -> f64 {
786 self.header_top() + self.config.header_height + self.config.row_height
787 }
788
789 fn next_number(&mut self) -> Option<u32> {
790 self.autonumber.map(|n| {
791 self.autonumber = Some(n + 1);
792 n
793 })
794 }
795
796 fn add_block_background(&mut self, x: f64, y: f64, width: f64, height: f64) {
798 self.block_backgrounds.push(BlockBackground {
799 x,
800 y,
801 width,
802 height,
803 });
804 }
805
806 fn add_block_label(
808 &mut self,
809 x1: f64,
810 start_y: f64,
811 end_y: f64,
812 x2: f64,
813 kind: &str,
814 label: &str,
815 else_y: Option<f64>,
816 ) {
817 self.block_labels.push(BlockLabel {
818 x1,
819 start_y,
820 end_y,
821 x2,
822 kind: kind.to_string(),
823 label: label.to_string(),
824 else_y,
825 });
826 }
827}
828
829fn find_involved_participants(items: &[Item], state: &RenderState) -> Option<(f64, f64, bool)> {
831 let mut min_left: Option<f64> = None;
832 let mut max_right: Option<f64> = None;
833 let leftmost_id = state.participants.first().map(|p| p.id()).unwrap_or("");
834 let mut includes_leftmost = false;
835
836 fn update_bounds(
837 participant: &str,
838 state: &RenderState,
839 min_left: &mut Option<f64>,
840 max_right: &mut Option<f64>,
841 includes_leftmost: &mut bool,
842 leftmost_id: &str,
843 ) {
844 let x = state.get_x(participant);
845 if x > 0.0 {
846 let width = state.get_participant_width(participant);
847 let left = x - width / 2.0;
848 let right = x + width / 2.0;
849 *min_left = Some(min_left.map_or(left, |m| m.min(left)));
850 *max_right = Some(max_right.map_or(right, |m| m.max(right)));
851 if participant == leftmost_id {
852 *includes_leftmost = true;
853 }
854 }
855 }
856
857 fn process_items(
858 items: &[Item],
859 state: &RenderState,
860 min_left: &mut Option<f64>,
861 max_right: &mut Option<f64>,
862 includes_leftmost: &mut bool,
863 leftmost_id: &str,
864 ) {
865 for item in items {
866 match item {
867 Item::Message { from, to, .. } => {
868 update_bounds(
869 from,
870 state,
871 min_left,
872 max_right,
873 includes_leftmost,
874 leftmost_id,
875 );
876 update_bounds(
877 to,
878 state,
879 min_left,
880 max_right,
881 includes_leftmost,
882 leftmost_id,
883 );
884 }
885 Item::Note { participants, .. } => {
886 for p in participants {
887 update_bounds(
888 p,
889 state,
890 min_left,
891 max_right,
892 includes_leftmost,
893 leftmost_id,
894 );
895 }
896 }
897 Item::Block {
898 items, else_items, ..
899 } => {
900 process_items(
901 items,
902 state,
903 min_left,
904 max_right,
905 includes_leftmost,
906 leftmost_id,
907 );
908 if let Some(else_items) = else_items {
909 process_items(
910 else_items,
911 state,
912 min_left,
913 max_right,
914 includes_leftmost,
915 leftmost_id,
916 );
917 }
918 }
919 Item::Activate { participant }
920 | Item::Deactivate { participant }
921 | Item::Destroy { participant } => {
922 update_bounds(
923 participant,
924 state,
925 min_left,
926 max_right,
927 includes_leftmost,
928 leftmost_id,
929 );
930 }
931 _ => {}
932 }
933 }
934 }
935
936 process_items(
937 items,
938 state,
939 &mut min_left,
940 &mut max_right,
941 &mut includes_leftmost,
942 leftmost_id,
943 );
944
945 match (min_left, max_right) {
946 (Some(min), Some(max)) => Some((min, max, includes_leftmost)),
947 _ => None,
948 }
949}
950
951fn calculate_block_bounds_with_label(
953 items: &[Item],
954 else_items: Option<&[Item]>,
955 label: &str,
956 kind: &str,
957 depth: usize,
958 state: &RenderState,
959) -> (f64, f64) {
960 let mut all_items: Vec<&Item> = items.iter().collect();
961 if let Some(else_items) = else_items {
962 all_items.extend(else_items.iter());
963 }
964
965 let items_slice: Vec<Item> = all_items.into_iter().cloned().collect();
967
968 let (base_x1, base_x2, includes_leftmost) =
969 if let Some((min_left, max_right, includes_leftmost)) =
970 find_involved_participants(&items_slice, state)
971 {
972 let margin = state.config.block_margin;
973 (min_left - margin, max_right + margin, includes_leftmost)
974 } else {
975 (state.block_left(), state.block_right(), false)
977 };
978
979 let pentagon_width = block_tab_width(kind);
982 let label_font_size = state.config.font_size - 1.0;
983 let label_padding_x = 6.0;
984 let condition_width = if label.is_empty() {
985 0.0
986 } else {
987 let condition_text = format!("[{}]", label);
988 let base_width =
989 (estimate_text_width(&condition_text, label_font_size) - TEXT_WIDTH_PADDING).max(0.0);
990 base_width + label_padding_x * 2.0
991 };
992 let min_label_width = pentagon_width + 8.0 + condition_width + 20.0; let current_width = base_x2 - base_x1;
996 let (mut x1, mut x2) = if current_width < min_label_width {
997 (base_x1, base_x1 + min_label_width)
999 } else {
1000 (base_x1, base_x2)
1001 };
1002
1003 let nested_padding = depth as f64 * 20.0;
1005 if nested_padding > 0.0 {
1006 let available = x2 - x1;
1007 let max_padding = ((available - min_label_width) / 2.0).max(0.0);
1008 let inset = nested_padding.min(max_padding);
1009 x1 += inset;
1010 x2 -= inset;
1011 }
1012
1013 if depth == 0 && includes_leftmost {
1014 x1 = x1.min(state.config.padding);
1015 }
1016
1017 (x1, x2)
1018}
1019
1020fn collect_block_backgrounds(
1022 state: &mut RenderState,
1023 items: &[Item],
1024 depth: usize,
1025 active_activation_count: &mut usize,
1026) {
1027 for item in items {
1028 match item {
1029 Item::Message {
1030 text,
1031 from,
1032 to,
1033 arrow,
1034 activate,
1035 deactivate,
1036 create,
1037 ..
1038 } => {
1039 state.apply_else_return_gap(arrow);
1040 let chain_gap = if *activate && depth == 0 && *active_activation_count == 1 {
1041 ACTIVATION_CHAIN_GAP
1042 } else {
1043 0.0
1044 };
1045 let is_self = from == to;
1046 let lines: Vec<&str> = text.split("\\n").collect();
1047 let delay_offset = arrow.delay.map(|d| d as f64 * DELAY_UNIT).unwrap_or(0.0);
1048
1049 if is_self {
1050 let mut spacing = self_message_spacing(&state.config, lines.len());
1051 if state.in_serial_block() {
1052 spacing -= SERIAL_SELF_MESSAGE_ADJUST;
1053 }
1054 if *active_activation_count > 0 {
1055 spacing -= SELF_MESSAGE_ACTIVE_ADJUST;
1056 }
1057 state.current_y += spacing;
1058 } else {
1059 let spacing_line_height = message_spacing_line_height(&state.config);
1060 let extra_height = if lines.len() > 1 {
1061 (lines.len() - 1) as f64 * spacing_line_height
1062 } else {
1063 0.0
1064 };
1065 if lines.len() > 1 {
1066 state.current_y += extra_height;
1067 }
1068 state.current_y += state.config.row_height + delay_offset;
1069 }
1070
1071 if *create {
1072 state.current_y += CREATE_MESSAGE_SPACING;
1073 }
1074
1075 state.apply_serial_first_row_gap();
1076
1077 if *activate && depth == 0 {
1078 state.current_y += ACTIVATION_START_GAP;
1079 }
1080 if chain_gap > 0.0 {
1081 state.current_y += chain_gap;
1082 }
1083 if *activate {
1084 *active_activation_count += 1;
1085 }
1086 if *deactivate && *active_activation_count > 0 {
1087 *active_activation_count -= 1;
1088 }
1089 }
1090 Item::Note { text, .. } => {
1091 let lines: Vec<&str> = text.split("\\n").collect();
1092 let line_height = note_line_height(&state.config);
1093 let note_height =
1094 note_padding(&state.config) * 2.0 + lines.len() as f64 * line_height;
1095 state.current_y += note_height.max(state.config.row_height) + NOTE_MARGIN;
1096 }
1097 Item::State { text, .. } => {
1098 let lines: Vec<&str> = text.split("\\n").collect();
1099 let line_height = state_line_height(&state.config);
1100 let box_height = state.config.note_padding * 2.0 + lines.len() as f64 * line_height;
1101 state.current_y += box_height + item_pre_gap(&state.config) + STATE_EXTRA_GAP;
1102 }
1103 Item::Ref { text, .. } => {
1104 let lines: Vec<&str> = text.split("\\n").collect();
1105 let line_height = ref_line_height(&state.config);
1106 let box_height = state.config.note_padding * 2.0 + lines.len() as f64 * line_height;
1107 state.current_y += box_height + item_pre_gap(&state.config) + REF_EXTRA_GAP;
1108 }
1109 Item::Description { text } => {
1110 let lines: Vec<&str> = text.split("\\n").collect();
1111 let line_height = state.config.font_size + 4.0;
1112 state.current_y += lines.len() as f64 * line_height + 10.0;
1113 }
1114 Item::Destroy { .. } => {
1115 state.current_y += DESTROY_SPACING;
1116 }
1117 Item::Activate { .. } => {
1118 *active_activation_count += 1;
1119 }
1120 Item::Deactivate { .. } => {
1121 if *active_activation_count > 0 {
1122 *active_activation_count -= 1;
1123 }
1124 }
1125 Item::Block {
1126 kind,
1127 label,
1128 items,
1129 else_items,
1130 } => {
1131 if block_is_parallel(kind) {
1132 state.push_parallel();
1133 let start_y = state.current_y;
1134 let mut max_end_y = start_y;
1135 let start_activation_count = *active_activation_count;
1136 for item in items {
1137 state.current_y = start_y;
1138 *active_activation_count = start_activation_count;
1139 collect_block_backgrounds(
1140 state,
1141 std::slice::from_ref(item),
1142 depth,
1143 active_activation_count,
1144 );
1145 if state.current_y > max_end_y {
1146 max_end_y = state.current_y;
1147 }
1148 }
1149 *active_activation_count = start_activation_count;
1150 let gap = if parallel_needs_gap(items) {
1151 PARALLEL_BLOCK_GAP
1152 } else {
1153 0.0
1154 };
1155 state.current_y = max_end_y + gap;
1156 state.pop_parallel();
1157 continue;
1158 }
1159
1160 if matches!(kind, BlockKind::Serial) {
1161 state.push_serial_first_row_pending();
1162 collect_block_backgrounds(state, items, depth, active_activation_count);
1163 if let Some(else_items) = else_items {
1164 collect_block_backgrounds(
1165 state,
1166 else_items,
1167 depth,
1168 active_activation_count,
1169 );
1170 }
1171 state.pop_serial_first_row_pending();
1172 continue;
1173 }
1174
1175 if !block_has_frame(kind) {
1176 collect_block_backgrounds(state, items, depth, active_activation_count);
1177 if let Some(else_items) = else_items {
1178 collect_block_backgrounds(
1179 state,
1180 else_items,
1181 depth,
1182 active_activation_count,
1183 );
1184 }
1185 continue;
1186 }
1187
1188 let start_y = state.current_y;
1189 let frame_shift = block_frame_shift(depth);
1190 let frame_start_y = start_y - frame_shift;
1191
1192 let (x1, x2) = calculate_block_bounds_with_label(
1194 items,
1195 else_items.as_deref(),
1196 label,
1197 kind.as_str(),
1198 depth,
1199 state,
1200 );
1201
1202 state.current_y += block_header_space(&state.config, depth);
1203 collect_block_backgrounds(state, items, depth + 1, active_activation_count);
1204
1205 let else_y = if else_items.is_some() {
1206 Some(state.current_y)
1207 } else {
1208 None
1209 };
1210
1211 if let Some(else_items) = else_items {
1212 state.push_else_return_pending();
1213 state.current_y += block_else_spacing(&state.config, depth);
1214 collect_block_backgrounds(
1215 state,
1216 else_items,
1217 depth + 1,
1218 active_activation_count,
1219 );
1220 state.pop_else_return_pending();
1221 }
1222
1223 let end_y = state.current_y - state.config.row_height
1224 + block_footer_padding(&state.config, depth);
1225 let frame_end_y = end_y - frame_shift;
1226 state.current_y = end_y + state.config.row_height * 1.0;
1227
1228 state.add_block_background(x1, frame_start_y, x2 - x1, frame_end_y - frame_start_y);
1230 state.add_block_label(
1232 x1,
1233 frame_start_y,
1234 frame_end_y,
1235 x2,
1236 kind.as_str(),
1237 label,
1238 else_y,
1239 );
1240 }
1241 _ => {}
1242 }
1243 }
1244}
1245
1246fn render_block_backgrounds(svg: &mut String, state: &RenderState) {
1248 let theme = &state.config.theme;
1249 for bg in &state.block_backgrounds {
1250 writeln!(
1251 svg,
1252 r##"<rect x="{x}" y="{y}" width="{w}" height="{h}" fill="{fill}" stroke="none"/>"##,
1253 x = bg.x,
1254 y = bg.y,
1255 w = bg.width,
1256 h = bg.height,
1257 fill = theme.block_fill
1258 )
1259 .unwrap();
1260 }
1261}
1262
1263fn render_block_labels(svg: &mut String, state: &RenderState) {
1266 let theme = &state.config.theme;
1267
1268 for bl in &state.block_labels {
1269 let x1 = bl.x1;
1270 let x2 = bl.x2;
1271 let start_y = bl.start_y;
1272 let end_y = bl.end_y;
1273
1274 writeln!(
1276 svg,
1277 r#"<rect x="{x}" y="{y}" width="{w}" height="{h}" class="block"/>"#,
1278 x = x1,
1279 y = start_y,
1280 w = x2 - x1,
1281 h = end_y - start_y
1282 )
1283 .unwrap();
1284
1285 let label_text = &bl.kind;
1287 let label_width = block_tab_width(label_text);
1288 let label_height = BLOCK_LABEL_HEIGHT;
1289 let label_text_offset = 16.0;
1290 let notch_size = 5.0;
1291 let label_font_size = state.config.font_size - 1.0;
1292 let label_padding_x = 6.0;
1293
1294 let pentagon_path = format!(
1296 "M {x1} {y1} L {x2} {y1} L {x2} {y2} L {x3} {y3} L {x1} {y3} Z",
1297 x1 = x1,
1298 y1 = start_y,
1299 x2 = x1 + label_width,
1300 y2 = start_y + label_height - notch_size,
1301 x3 = x1 + label_width - notch_size,
1302 y3 = start_y + label_height
1303 );
1304
1305 writeln!(
1306 svg,
1307 r##"<path d="{path}" fill="{fill}" stroke="{stroke}"/>"##,
1308 path = pentagon_path,
1309 fill = theme.block_label_fill,
1310 stroke = theme.block_stroke
1311 )
1312 .unwrap();
1313
1314 writeln!(
1316 svg,
1317 r#"<text x="{x}" y="{y}" class="block-label">{kind}</text>"#,
1318 x = x1 + 5.0,
1319 y = start_y + label_text_offset,
1320 kind = label_text
1321 )
1322 .unwrap();
1323
1324 if !bl.label.is_empty() {
1326 let condition_text = format!("[{}]", bl.label);
1327 let text_x = x1 + label_width + 8.0;
1328 let text_y = start_y + label_text_offset;
1329 let base_width = (estimate_text_width(&condition_text, label_font_size)
1330 - TEXT_WIDTH_PADDING)
1331 .max(0.0);
1332 let bg_width = base_width + label_padding_x * 2.0;
1333
1334 writeln!(
1335 svg,
1336 r##"<rect x="{x}" y="{y}" width="{w}" height="{h}" fill="{fill}" stroke="{stroke}" stroke-width="1"/>"##,
1337 x = text_x - label_padding_x,
1338 y = start_y,
1339 w = bg_width,
1340 h = label_height,
1341 fill = theme.block_label_fill,
1342 stroke = theme.block_stroke
1343 )
1344 .unwrap();
1345
1346 writeln!(
1347 svg,
1348 r#"<text x="{x}" y="{y}" class="block-label">{label}</text>"#,
1349 x = text_x,
1350 y = text_y,
1351 label = escape_xml(&condition_text)
1352 )
1353 .unwrap();
1354 }
1355
1356 if let Some(else_y) = bl.else_y {
1358 writeln!(
1359 svg,
1360 r##"<line x1="{x1}" y1="{y}" x2="{x2}" y2="{y}" stroke="{c}" stroke-dasharray="5,3"/>"##,
1361 x1 = x1,
1362 y = else_y,
1363 x2 = x2,
1364 c = theme.block_stroke
1365 )
1366 .unwrap();
1367
1368 let else_text = "[else]";
1369 let else_text_x = x1 + 4.0;
1370 let else_base_width =
1371 (estimate_text_width(else_text, label_font_size) - TEXT_WIDTH_PADDING).max(0.0);
1372 let else_bg_width = else_base_width + label_padding_x * 2.0;
1373 let else_rect_y = else_y - label_height;
1374 let else_text_y = else_rect_y + label_text_offset;
1375
1376 writeln!(
1377 svg,
1378 r##"<rect x="{x}" y="{y}" width="{w}" height="{h}" fill="{fill}" stroke="{stroke}" stroke-width="1"/>"##,
1379 x = else_text_x - label_padding_x,
1380 y = else_rect_y,
1381 w = else_bg_width,
1382 h = label_height,
1383 fill = theme.block_label_fill,
1384 stroke = theme.block_stroke
1385 )
1386 .unwrap();
1387
1388 writeln!(
1389 svg,
1390 r#"<text x="{x}" y="{y}" class="block-label">{label}</text>"#,
1391 x = else_text_x,
1392 y = else_text_y,
1393 label = else_text
1394 )
1395 .unwrap();
1396 }
1397 }
1398}
1399
1400pub fn render(diagram: &Diagram) -> String {
1402 render_with_config(diagram, Config::default())
1403}
1404
1405pub fn render_with_config(diagram: &Diagram, config: Config) -> String {
1407 let participants = diagram.participants();
1408 let has_title = diagram.title.is_some();
1409 let footer_style = diagram.options.footer;
1410 let mut state = RenderState::new(
1411 config,
1412 participants,
1413 &diagram.items,
1414 has_title,
1415 footer_style,
1416 );
1417 let mut svg = String::new();
1418
1419 let content_height = calculate_height(&diagram.items, &state.config, 0);
1421 let title_space = if has_title {
1422 state.config.title_height
1423 } else {
1424 0.0
1425 };
1426 let footer_space = match footer_style {
1427 FooterStyle::Box => state.config.header_height,
1428 FooterStyle::Bar | FooterStyle::None => 0.0,
1429 };
1430 let footer_label_extra = match footer_style {
1431 FooterStyle::Box => actor_footer_extra(&state.participants, &state.config),
1432 FooterStyle::Bar | FooterStyle::None => 0.0,
1433 };
1434 let footer_margin = state.config.row_height; let base_total_height = state.config.padding * 2.0
1436 + title_space
1437 + state.config.header_height
1438 + content_height
1439 + footer_margin
1440 + footer_space;
1441 let total_height = base_total_height + footer_label_extra;
1442 let total_width = state.diagram_width();
1443
1444 writeln!(
1446 &mut svg,
1447 r#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 {w} {h}" width="{w}" height="{h}">"#,
1448 w = total_width,
1449 h = total_height
1450 )
1451 .unwrap();
1452
1453 let theme = &state.config.theme;
1455 let lifeline_dash = match theme.lifeline_style {
1456 LifelineStyle::Dashed => "stroke-dasharray: 5,5;",
1457 LifelineStyle::Solid => "",
1458 };
1459
1460 svg.push_str("<defs>\n");
1461 svg.push_str("<style>\n");
1462 writeln!(
1463 &mut svg,
1464 ".participant {{ fill: {fill}; stroke: {stroke}; stroke-width: 2; }}",
1465 fill = theme.participant_fill,
1466 stroke = theme.participant_stroke
1467 )
1468 .unwrap();
1469 writeln!(
1470 &mut svg,
1471 ".participant-text {{ font-family: {f}; font-size: {s}px; text-anchor: middle; dominant-baseline: middle; fill: {c}; }}",
1472 f = theme.font_family,
1473 s = state.config.font_size,
1474 c = theme.participant_text
1475 )
1476 .unwrap();
1477 writeln!(
1478 &mut svg,
1479 ".lifeline {{ stroke: {c}; stroke-width: 1; {dash} }}",
1480 c = theme.lifeline_color,
1481 dash = lifeline_dash
1482 )
1483 .unwrap();
1484 writeln!(
1485 &mut svg,
1486 ".message {{ stroke: {c}; stroke-width: 1.5; fill: none; }}",
1487 c = theme.message_color
1488 )
1489 .unwrap();
1490 writeln!(
1491 &mut svg,
1492 ".message-dashed {{ stroke: {c}; stroke-width: 1.5; fill: none; stroke-dasharray: 5,3; }}",
1493 c = theme.message_color
1494 )
1495 .unwrap();
1496 writeln!(
1497 &mut svg,
1498 ".message-text {{ font-family: {f}; font-size: {s}px; fill: {c}; stroke: none; }}",
1499 f = theme.font_family,
1500 s = state.config.font_size,
1501 c = theme.message_text_color
1502 )
1503 .unwrap();
1504 writeln!(
1505 &mut svg,
1506 ".note {{ fill: {fill}; stroke: {stroke}; stroke-width: 1; }}",
1507 fill = theme.note_fill,
1508 stroke = theme.note_stroke
1509 )
1510 .unwrap();
1511 writeln!(
1512 &mut svg,
1513 ".note-text {{ font-family: {f}; font-size: {s}px; fill: {c}; }}",
1514 f = theme.font_family,
1515 s = state.config.font_size - 1.0,
1516 c = theme.note_text_color
1517 )
1518 .unwrap();
1519 writeln!(
1520 &mut svg,
1521 ".block {{ fill: none; stroke: {c}; stroke-width: 1; }}",
1522 c = theme.block_stroke
1523 )
1524 .unwrap();
1525 writeln!(
1526 &mut svg,
1527 ".block-label {{ font-family: {f}; font-size: {s}px; font-weight: bold; fill: {c}; }}",
1528 f = theme.font_family,
1529 s = state.config.font_size - 1.0,
1530 c = theme.message_text_color
1531 )
1532 .unwrap();
1533 writeln!(
1534 &mut svg,
1535 ".activation {{ fill: {fill}; stroke: {stroke}; stroke-width: 1; }}",
1536 fill = theme.activation_fill,
1537 stroke = theme.activation_stroke
1538 )
1539 .unwrap();
1540 writeln!(
1541 &mut svg,
1542 ".actor-head {{ fill: {fill}; stroke: {stroke}; stroke-width: 2; }}",
1543 fill = theme.actor_fill,
1544 stroke = theme.actor_stroke
1545 )
1546 .unwrap();
1547 writeln!(
1548 &mut svg,
1549 ".actor-body {{ stroke: {c}; stroke-width: 2; fill: none; }}",
1550 c = theme.actor_stroke
1551 )
1552 .unwrap();
1553 writeln!(
1554 &mut svg,
1555 ".title {{ font-family: {f}; font-size: {s}px; font-weight: bold; text-anchor: middle; fill: {c}; }}",
1556 f = theme.font_family,
1557 s = state.config.font_size + 4.0,
1558 c = theme.message_text_color
1559 )
1560 .unwrap();
1561 writeln!(
1563 &mut svg,
1564 ".arrowhead {{ fill: {c}; stroke: none; }}",
1565 c = theme.message_color
1566 )
1567 .unwrap();
1568 writeln!(
1569 &mut svg,
1570 ".arrowhead-open {{ fill: none; stroke: {c}; stroke-width: 1; }}",
1571 c = theme.message_color
1572 )
1573 .unwrap();
1574 svg.push_str("</style>\n");
1575 svg.push_str("</defs>\n");
1576
1577 writeln!(
1579 &mut svg,
1580 r##"<rect width="100%" height="100%" fill="{bg}"/>"##,
1581 bg = theme.background
1582 )
1583 .unwrap();
1584
1585 if let Some(title) = &diagram.title {
1587 let title_y = state.config.padding + state.config.font_size + 10.0;
1588 writeln!(
1589 &mut svg,
1590 r#"<text x="{x}" y="{y}" class="title">{t}</text>"#,
1591 x = total_width / 2.0,
1592 y = title_y,
1593 t = escape_xml(title)
1594 )
1595 .unwrap();
1596 }
1597
1598 let header_y = state.header_top();
1600 let footer_y = match footer_style {
1601 FooterStyle::Box => base_total_height - state.config.padding - state.config.header_height,
1602 FooterStyle::Bar | FooterStyle::None => total_height - state.config.padding,
1603 };
1604
1605 state.current_y = state.content_start();
1607 let mut active_activation_count = 0;
1608 collect_block_backgrounds(&mut state, &diagram.items, 0, &mut active_activation_count);
1609
1610 render_block_backgrounds(&mut svg, &state);
1612
1613 state.current_y = state.content_start();
1615
1616 let lifeline_start = header_y + state.config.header_height;
1618 let lifeline_end = footer_y;
1619
1620 for p in &state.participants {
1621 let x = state.get_x(p.id());
1622 writeln!(
1623 &mut svg,
1624 r#"<line x1="{x}" y1="{y1}" x2="{x}" y2="{y2}" class="lifeline"/>"#,
1625 x = x,
1626 y1 = lifeline_start,
1627 y2 = lifeline_end
1628 )
1629 .unwrap();
1630 }
1631
1632 render_participant_headers(&mut svg, &state, header_y);
1634
1635 state.current_y = state.content_start();
1637 render_items(&mut svg, &mut state, &diagram.items, 0);
1638
1639 render_activations(&mut svg, &mut state, footer_y);
1641
1642 render_block_labels(&mut svg, &state);
1644
1645 match state.footer_style {
1647 FooterStyle::Box => {
1648 render_participant_headers(&mut svg, &state, footer_y);
1649 }
1650 FooterStyle::Bar => {
1651 let left = state.leftmost_x()
1653 - state.get_participant_width(
1654 state.participants.first().map(|p| p.id()).unwrap_or(""),
1655 ) / 2.0;
1656 let right = state.rightmost_x()
1657 + state
1658 .get_participant_width(state.participants.last().map(|p| p.id()).unwrap_or(""))
1659 / 2.0;
1660 writeln!(
1661 &mut svg,
1662 r##"<line x1="{x1}" y1="{y}" x2="{x2}" y2="{y}" stroke="{c}" stroke-width="1"/>"##,
1663 x1 = left,
1664 y = footer_y,
1665 x2 = right,
1666 c = state.config.theme.lifeline_color
1667 )
1668 .unwrap();
1669 }
1670 FooterStyle::None => {
1671 }
1673 }
1674
1675 svg.push_str("</svg>\n");
1676 svg
1677}
1678
1679fn calculate_height(items: &[Item], config: &Config, depth: usize) -> f64 {
1680 fn inner(
1681 items: &[Item],
1682 config: &Config,
1683 depth: usize,
1684 else_pending: &mut Vec<bool>,
1685 serial_pending: &mut Vec<bool>,
1686 active_activation_count: &mut usize,
1687 parallel_depth: &mut usize,
1688 ) -> f64 {
1689 let mut height = 0.0;
1690 let line_height = config.font_size + 4.0;
1691 for item in items {
1692 match item {
1693 Item::Message {
1694 from,
1695 to,
1696 text,
1697 arrow,
1698 create,
1699 activate,
1700 deactivate,
1701 ..
1702 } => {
1703 if let Some(pending) = else_pending.last_mut() {
1704 if *pending && matches!(arrow.line, LineStyle::Dashed) {
1705 height += ELSE_RETURN_GAP;
1706 *pending = false;
1707 }
1708 }
1709 let chain_gap = if *activate && depth == 0 && *active_activation_count == 1 {
1710 ACTIVATION_CHAIN_GAP
1711 } else {
1712 0.0
1713 };
1714 let is_self = from == to;
1715 let lines = text.split("\\n").count();
1716 let delay_offset = arrow.delay.map(|d| d as f64 * DELAY_UNIT).unwrap_or(0.0);
1717 if is_self {
1718 let mut spacing = self_message_spacing(config, lines);
1719 if !serial_pending.is_empty() {
1720 spacing -= SERIAL_SELF_MESSAGE_ADJUST;
1721 }
1722 if *active_activation_count > 0 {
1723 spacing -= SELF_MESSAGE_ACTIVE_ADJUST;
1724 }
1725 height += spacing;
1726 } else {
1727 let spacing_line_height = message_spacing_line_height(config);
1728 height += config.row_height
1729 + (lines.saturating_sub(1)) as f64 * spacing_line_height
1730 + delay_offset;
1731 }
1732 if *create {
1733 height += CREATE_MESSAGE_SPACING;
1734 }
1735 if let Some(pending) = serial_pending.last_mut() {
1736 if *pending {
1737 height += serial_first_row_gap(*parallel_depth);
1738 *pending = false;
1739 }
1740 }
1741 if *activate && depth == 0 {
1742 height += ACTIVATION_START_GAP;
1743 }
1744 height += chain_gap;
1745 if *activate {
1746 *active_activation_count += 1;
1747 }
1748 if *deactivate && *active_activation_count > 0 {
1749 *active_activation_count -= 1;
1750 }
1751 }
1752 Item::Note { text, .. } => {
1753 let lines = text.split("\\n").count();
1754 let note_height =
1755 note_padding(config) * 2.0 + lines as f64 * note_line_height(config);
1756 height += note_height.max(config.row_height) + NOTE_MARGIN;
1757 }
1758 Item::State { text, .. } => {
1759 let lines = text.split("\\n").count();
1760 let box_height =
1761 config.note_padding * 2.0 + lines as f64 * state_line_height(config);
1762 height += box_height + item_pre_gap(config) + STATE_EXTRA_GAP;
1763 }
1764 Item::Ref { text, .. } => {
1765 let lines = text.split("\\n").count();
1766 let box_height =
1767 config.note_padding * 2.0 + lines as f64 * ref_line_height(config);
1768 height += box_height + item_pre_gap(config) + REF_EXTRA_GAP;
1769 }
1770 Item::Description { text } => {
1771 let lines = text.split("\\n").count();
1772 height += lines as f64 * line_height + 10.0;
1773 }
1774 Item::Block {
1775 kind,
1776 items,
1777 else_items,
1778 ..
1779 } => {
1780 if block_is_parallel(kind) {
1781 let mut max_branch_height = 0.0;
1782 let base_activation_count = *active_activation_count;
1783 *parallel_depth += 1;
1784 for item in items {
1785 *active_activation_count = base_activation_count;
1786 let branch_height = inner(
1787 std::slice::from_ref(item),
1788 config,
1789 depth,
1790 else_pending,
1791 serial_pending,
1792 active_activation_count,
1793 parallel_depth,
1794 );
1795 if branch_height > max_branch_height {
1796 max_branch_height = branch_height;
1797 }
1798 }
1799 *active_activation_count = base_activation_count;
1800 if *parallel_depth > 0 {
1801 *parallel_depth -= 1;
1802 }
1803 let gap = if parallel_needs_gap(items) {
1804 PARALLEL_BLOCK_GAP
1805 } else {
1806 0.0
1807 };
1808 height += max_branch_height + gap;
1809 continue;
1810 }
1811
1812 if matches!(kind, BlockKind::Serial) {
1813 serial_pending.push(true);
1814 height += inner(
1815 items,
1816 config,
1817 depth,
1818 else_pending,
1819 serial_pending,
1820 active_activation_count,
1821 parallel_depth,
1822 );
1823 if let Some(else_items) = else_items {
1824 height += inner(
1825 else_items,
1826 config,
1827 depth,
1828 else_pending,
1829 serial_pending,
1830 active_activation_count,
1831 parallel_depth,
1832 );
1833 }
1834 serial_pending.pop();
1835 } else if !block_has_frame(kind) {
1836 height += inner(
1837 items,
1838 config,
1839 depth,
1840 else_pending,
1841 serial_pending,
1842 active_activation_count,
1843 parallel_depth,
1844 );
1845 if let Some(else_items) = else_items {
1846 height += inner(
1847 else_items,
1848 config,
1849 depth,
1850 else_pending,
1851 serial_pending,
1852 active_activation_count,
1853 parallel_depth,
1854 );
1855 }
1856 } else {
1857 height += block_header_space(config, depth);
1858 height += inner(
1859 items,
1860 config,
1861 depth + 1,
1862 else_pending,
1863 serial_pending,
1864 active_activation_count,
1865 parallel_depth,
1866 );
1867 if let Some(else_items) = else_items {
1868 else_pending.push(true);
1869 height += block_else_spacing(config, depth);
1870 height += inner(
1871 else_items,
1872 config,
1873 depth + 1,
1874 else_pending,
1875 serial_pending,
1876 active_activation_count,
1877 parallel_depth,
1878 );
1879 else_pending.pop();
1880 }
1881 height += block_footer_padding(config, depth) + config.row_height * 1.0
1882 - config.row_height;
1883 }
1884 }
1885 Item::Activate { .. } => {
1886 *active_activation_count += 1;
1887 }
1888 Item::Deactivate { .. } => {
1889 if *active_activation_count > 0 {
1890 *active_activation_count -= 1;
1891 }
1892 }
1893 Item::Destroy { .. } => {
1894 height += DESTROY_SPACING;
1895 }
1896 Item::ParticipantDecl { .. } => {}
1897 Item::Autonumber { .. } => {}
1898 Item::DiagramOption { .. } => {} }
1900 }
1901 height
1902 }
1903
1904 let mut else_pending = Vec::new();
1905 let mut serial_pending = Vec::new();
1906 let mut active_activation_count = 0;
1907 let mut parallel_depth = 0;
1908 inner(
1909 items,
1910 config,
1911 depth,
1912 &mut else_pending,
1913 &mut serial_pending,
1914 &mut active_activation_count,
1915 &mut parallel_depth,
1916 )
1917}
1918
1919fn render_participant_headers(svg: &mut String, state: &RenderState, y: f64) {
1920 let shape = state.config.theme.participant_shape;
1921
1922 for p in &state.participants {
1923 let x = state.get_x(p.id());
1924 let p_width = state.get_participant_width(p.id());
1925 let box_x = x - p_width / 2.0;
1926
1927 match p.kind {
1928 ParticipantKind::Participant => {
1929 match shape {
1931 ParticipantShape::Rectangle => {
1932 writeln!(
1933 svg,
1934 r#"<rect x="{x}" y="{y}" width="{w}" height="{h}" class="participant"/>"#,
1935 x = box_x,
1936 y = y,
1937 w = p_width,
1938 h = state.config.header_height
1939 )
1940 .unwrap();
1941 }
1942 ParticipantShape::RoundedRect => {
1943 writeln!(
1944 svg,
1945 r#"<rect x="{x}" y="{y}" width="{w}" height="{h}" rx="8" ry="8" class="participant"/>"#,
1946 x = box_x,
1947 y = y,
1948 w = p_width,
1949 h = state.config.header_height
1950 )
1951 .unwrap();
1952 }
1953 ParticipantShape::Circle => {
1954 let rx = p_width / 2.0 - 5.0;
1956 let ry = state.config.header_height / 2.0 - 2.0;
1957 writeln!(
1958 svg,
1959 r#"<ellipse cx="{cx}" cy="{cy}" rx="{rx}" ry="{ry}" class="participant"/>"#,
1960 cx = x,
1961 cy = y + state.config.header_height / 2.0,
1962 rx = rx,
1963 ry = ry
1964 )
1965 .unwrap();
1966 }
1967 }
1968 let lines: Vec<&str> = p.name.split("\\n").collect();
1970 if lines.len() == 1 {
1971 writeln!(
1972 svg,
1973 r#"<text x="{x}" y="{y}" class="participant-text">{name}</text>"#,
1974 x = x,
1975 y = y + state.config.header_height / 2.0 + 5.0,
1976 name = escape_xml(&p.name)
1977 )
1978 .unwrap();
1979 } else {
1980 let line_height = state.config.font_size + 2.0;
1981 let total_height = lines.len() as f64 * line_height;
1982 let start_y = y + state.config.header_height / 2.0 - total_height / 2.0
1983 + line_height * 0.8;
1984 write!(svg, r#"<text x="{x}" class="participant-text">"#, x = x).unwrap();
1985 for (i, line) in lines.iter().enumerate() {
1986 let dy = if i == 0 { start_y } else { line_height };
1987 if i == 0 {
1988 writeln!(
1989 svg,
1990 r#"<tspan x="{x}" y="{y}">{text}</tspan>"#,
1991 x = x,
1992 y = dy,
1993 text = escape_xml(line)
1994 )
1995 .unwrap();
1996 } else {
1997 writeln!(
1998 svg,
1999 r#"<tspan x="{x}" dy="{dy}">{text}</tspan>"#,
2000 x = x,
2001 dy = dy,
2002 text = escape_xml(line)
2003 )
2004 .unwrap();
2005 }
2006 }
2007 writeln!(svg, "</text>").unwrap();
2008 }
2009 }
2010 ParticipantKind::Actor => {
2011 let head_r = 8.0;
2013 let body_len = 12.0;
2014 let arm_len = 10.0;
2015 let leg_len = 10.0;
2016 let figure_height = 38.0; let fig_top = y + 8.0;
2020 let fig_center_y = fig_top + head_r + body_len / 2.0;
2021 let arm_y = fig_center_y + 2.0;
2022
2023 writeln!(
2025 svg,
2026 r#"<circle cx="{x}" cy="{cy}" r="{r}" class="actor-head"/>"#,
2027 x = x,
2028 cy = fig_center_y - body_len / 2.0 - head_r,
2029 r = head_r
2030 )
2031 .unwrap();
2032 writeln!(
2034 svg,
2035 r#"<line x1="{x}" y1="{y1}" x2="{x}" y2="{y2}" class="actor-body"/>"#,
2036 x = x,
2037 y1 = fig_center_y - body_len / 2.0,
2038 y2 = fig_center_y + body_len / 2.0
2039 )
2040 .unwrap();
2041 writeln!(
2043 svg,
2044 r#"<line x1="{x1}" y1="{y}" x2="{x2}" y2="{y}" class="actor-body"/>"#,
2045 x1 = x - arm_len,
2046 y = arm_y,
2047 x2 = x + arm_len
2048 )
2049 .unwrap();
2050 writeln!(
2052 svg,
2053 r#"<line x1="{x}" y1="{y1}" x2="{x2}" y2="{y2}" class="actor-body"/>"#,
2054 x = x,
2055 y1 = fig_center_y + body_len / 2.0,
2056 x2 = x - leg_len * 0.6,
2057 y2 = fig_center_y + body_len / 2.0 + leg_len
2058 )
2059 .unwrap();
2060 writeln!(
2062 svg,
2063 r#"<line x1="{x}" y1="{y1}" x2="{x2}" y2="{y2}" class="actor-body"/>"#,
2064 x = x,
2065 y1 = fig_center_y + body_len / 2.0,
2066 x2 = x + leg_len * 0.6,
2067 y2 = fig_center_y + body_len / 2.0 + leg_len
2068 )
2069 .unwrap();
2070 let name_lines: Vec<&str> = p.name.split("\\n").collect();
2072 let name_start_y = fig_top + figure_height + 5.0;
2073 if name_lines.len() == 1 {
2074 writeln!(
2075 svg,
2076 r#"<text x="{x}" y="{y}" class="participant-text">{name}</text>"#,
2077 x = x,
2078 y = name_start_y + state.config.font_size,
2079 name = escape_xml(&p.name)
2080 )
2081 .unwrap();
2082 } else {
2083 let line_height = state.config.font_size + 2.0;
2085 writeln!(svg, r#"<text x="{x}" class="participant-text">"#, x = x).unwrap();
2086 for (i, line) in name_lines.iter().enumerate() {
2087 if i == 0 {
2088 writeln!(
2089 svg,
2090 r#"<tspan x="{x}" y="{y}">{text}</tspan>"#,
2091 x = x,
2092 y = name_start_y + state.config.font_size,
2093 text = escape_xml(line)
2094 )
2095 .unwrap();
2096 } else {
2097 writeln!(
2098 svg,
2099 r#"<tspan x="{x}" dy="{dy}">{text}</tspan>"#,
2100 x = x,
2101 dy = line_height,
2102 text = escape_xml(line)
2103 )
2104 .unwrap();
2105 }
2106 }
2107 writeln!(svg, "</text>").unwrap();
2108 }
2109 }
2110 }
2111 }
2112}
2113
2114fn render_items(svg: &mut String, state: &mut RenderState, items: &[Item], depth: usize) {
2115 for item in items {
2116 match item {
2117 Item::Message {
2118 from,
2119 to,
2120 text,
2121 arrow,
2122 activate,
2123 deactivate,
2124 create,
2125 ..
2126 } => {
2127 render_message(
2128 svg,
2129 state,
2130 from,
2131 to,
2132 text,
2133 arrow,
2134 *activate,
2135 *deactivate,
2136 *create,
2137 depth,
2138 );
2139 }
2140 Item::Note {
2141 position,
2142 participants,
2143 text,
2144 } => {
2145 render_note(svg, state, position, participants, text);
2146 }
2147 Item::Block {
2148 kind,
2149 label,
2150 items,
2151 else_items,
2152 } => {
2153 render_block(svg, state, kind, label, items, else_items.as_deref(), depth);
2154 }
2155 Item::Activate { participant } => {
2156 let y = state.current_y;
2157 state
2158 .activations
2159 .entry(participant.clone())
2160 .or_default()
2161 .push((y, None));
2162 }
2163 Item::Deactivate { participant } => {
2164 if let Some(acts) = state.activations.get_mut(participant) {
2165 if let Some(act) = acts.last_mut() {
2166 if act.1.is_none() {
2167 act.1 = Some(state.current_y);
2168 }
2169 }
2170 }
2171 }
2172 Item::Destroy { participant } => {
2173 state.destroyed.insert(participant.clone(), state.current_y);
2174 let x = state.get_x(participant);
2176 let y = state.current_y;
2177 let size = 12.0;
2178 let theme = &state.config.theme;
2179 writeln!(
2180 svg,
2181 r#"<line x1="{x1}" y1="{y1}" x2="{x2}" y2="{y2}" stroke="{stroke}" stroke-width="2"/>"#,
2182 x1 = x - size,
2183 y1 = y - size,
2184 x2 = x + size,
2185 y2 = y + size,
2186 stroke = theme.message_color
2187 )
2188 .unwrap();
2189 writeln!(
2190 svg,
2191 r#"<line x1="{x1}" y1="{y1}" x2="{x2}" y2="{y2}" stroke="{stroke}" stroke-width="2"/>"#,
2192 x1 = x + size,
2193 y1 = y - size,
2194 x2 = x - size,
2195 y2 = y + size,
2196 stroke = theme.message_color
2197 )
2198 .unwrap();
2199 state.current_y += DESTROY_SPACING;
2200 }
2201 Item::Autonumber { enabled, start } => {
2202 if *enabled {
2203 state.autonumber = Some(start.unwrap_or(1));
2204 } else {
2205 state.autonumber = None;
2206 }
2207 }
2208 Item::ParticipantDecl { .. } => {
2209 }
2211 Item::State { participants, text } => {
2212 render_state(svg, state, participants, text);
2213 }
2214 Item::Ref {
2215 participants,
2216 text,
2217 input_from,
2218 input_label,
2219 output_to,
2220 output_label,
2221 } => {
2222 render_ref(
2223 svg,
2224 state,
2225 participants,
2226 text,
2227 input_from.as_deref(),
2228 input_label.as_deref(),
2229 output_to.as_deref(),
2230 output_label.as_deref(),
2231 );
2232 }
2233 Item::DiagramOption { .. } => {
2234 }
2236 Item::Description { text } => {
2237 render_description(svg, state, text);
2238 }
2239 }
2240 }
2241}
2242
2243fn render_message(
2244 svg: &mut String,
2245 state: &mut RenderState,
2246 from: &str,
2247 to: &str,
2248 text: &str,
2249 arrow: &Arrow,
2250 activate: bool,
2251 deactivate: bool,
2252 create: bool,
2253 depth: usize,
2254) {
2255 let x1 = state.get_x(from);
2256 let x2 = state.get_x(to);
2257
2258 state.apply_else_return_gap(arrow);
2259 let active_count = state.active_activation_count();
2260 let chain_gap = if activate && depth == 0 && active_count == 1 {
2261 ACTIVATION_CHAIN_GAP
2262 } else {
2263 0.0
2264 };
2265
2266 let is_self = from == to;
2267 let line_class = match arrow.line {
2268 LineStyle::Solid => "message",
2269 LineStyle::Dashed => "message-dashed",
2270 };
2271 let is_filled = matches!(arrow.head, ArrowHead::Filled);
2272
2273 let num_prefix = state
2275 .next_number()
2276 .map(|n| format!("{}. ", n))
2277 .unwrap_or_default();
2278
2279 let display_text = format!("{}{}", num_prefix, text);
2281 let lines: Vec<&str> = display_text.split("\\n").collect();
2282 let line_height = state.config.font_size + 4.0;
2283 let extra_height = if !is_self && lines.len() > 1 {
2284 let spacing_line_height = message_spacing_line_height(&state.config);
2285 (lines.len() - 1) as f64 * spacing_line_height
2286 } else {
2287 0.0
2288 };
2289
2290 if !is_self && lines.len() > 1 {
2292 state.current_y += extra_height;
2293 }
2294
2295 let y = state.current_y;
2296 let has_label_text = lines.iter().any(|line| !line.trim().is_empty());
2297
2298 writeln!(svg, r#"<g class="message">"#).unwrap();
2300
2301 if is_self {
2302 let loop_width = 40.0;
2304 let text_block_height = lines.len() as f64 * line_height;
2305 let loop_height = (text_block_height + 10.0).max(25.0);
2306 let arrow_end_x = x1;
2307 let arrow_end_y = y + loop_height;
2308 let direction = std::f64::consts::PI;
2310 let arrow_points = arrowhead_points(arrow_end_x, arrow_end_y, direction);
2311
2312 writeln!(
2313 svg,
2314 r#" <path d="M {x1} {y} L {x2} {y} L {x2} {y2} L {arrow_x} {y2}" class="{cls}"/>"#,
2315 x1 = x1,
2316 y = y,
2317 x2 = x1 + loop_width,
2318 y2 = y + loop_height,
2319 arrow_x = arrow_end_x + ARROWHEAD_SIZE,
2320 cls = line_class
2321 )
2322 .unwrap();
2323
2324 if is_filled {
2326 writeln!(
2327 svg,
2328 r#" <polygon points="{points}" class="arrowhead"/>"#,
2329 points = arrow_points
2330 )
2331 .unwrap();
2332 } else {
2333 writeln!(
2334 svg,
2335 r#" <polyline points="{points}" class="arrowhead-open"/>"#,
2336 points = arrow_points
2337 )
2338 .unwrap();
2339 }
2340
2341 let text_x = x1 + loop_width + 5.0;
2343 let max_width = lines
2344 .iter()
2345 .map(|line| estimate_message_width(line, state.config.font_size))
2346 .fold(0.0, f64::max);
2347 let top_line_y = y + 4.0 + 0.5 * line_height;
2348 let bottom_line_y = y + 4.0 + (lines.len() as f64 - 0.5) * line_height;
2349 let label_y_min = top_line_y - line_height * MESSAGE_LABEL_ASCENT_FACTOR;
2350 let label_y_max = bottom_line_y + line_height * MESSAGE_LABEL_DESCENT_FACTOR;
2351 let label_x_min = text_x;
2352 let label_x_max = text_x + max_width;
2353 let label_offset = if has_label_text {
2354 let step = line_height * MESSAGE_LABEL_COLLISION_STEP_RATIO;
2355 state.reserve_message_label(label_x_min, label_x_max, label_y_min, label_y_max, step)
2356 } else {
2357 0.0
2358 };
2359 for (i, line) in lines.iter().enumerate() {
2360 let line_y = y + 4.0 + (i as f64 + 0.5) * line_height + label_offset;
2361 writeln!(
2362 svg,
2363 r#" <text x="{x}" y="{y}" class="message-text">{t}</text>"#,
2364 x = text_x,
2365 y = line_y,
2366 t = escape_xml(line)
2367 )
2368 .unwrap();
2369 }
2370
2371 writeln!(svg, r#"</g>"#).unwrap();
2373
2374 let mut spacing = self_message_spacing(&state.config, lines.len());
2375 if state.in_serial_block() {
2376 spacing -= SERIAL_SELF_MESSAGE_ADJUST;
2377 }
2378 if active_count > 0 {
2379 spacing -= SELF_MESSAGE_ACTIVE_ADJUST;
2380 }
2381 state.current_y += spacing;
2382 } else {
2383 let delay_offset = arrow.delay.map(|d| d as f64 * DELAY_UNIT).unwrap_or(0.0);
2385 let y2 = y + delay_offset;
2386
2387 let text_x = (x1 + x2) / 2.0;
2388 let text_y = (y + y2) / 2.0 - 8.0;
2389
2390 let direction = arrow_direction(x1, y, x2, y2);
2392 let arrow_points = arrowhead_points(x2, y2, direction);
2393
2394 let line_end_x = x2 - ARROWHEAD_SIZE * direction.cos();
2396 let line_end_y = y2 - ARROWHEAD_SIZE * direction.sin();
2397
2398 writeln!(
2400 svg,
2401 r#" <line x1="{x1}" y1="{y1}" x2="{lx2}" y2="{ly2}" class="{cls}"/>"#,
2402 x1 = x1,
2403 y1 = y,
2404 lx2 = line_end_x,
2405 ly2 = line_end_y,
2406 cls = line_class
2407 )
2408 .unwrap();
2409
2410 if is_filled {
2412 writeln!(
2413 svg,
2414 r#" <polygon points="{points}" class="arrowhead"/>"#,
2415 points = arrow_points
2416 )
2417 .unwrap();
2418 } else {
2419 writeln!(
2420 svg,
2421 r#" <polyline points="{points}" class="arrowhead-open"/>"#,
2422 points = arrow_points
2423 )
2424 .unwrap();
2425 }
2426
2427 let max_width = lines
2429 .iter()
2430 .map(|line| estimate_message_width(line, state.config.font_size))
2431 .fold(0.0, f64::max);
2432 let top_line_y = text_y - (lines.len() as f64 - 1.0) * line_height;
2433 let bottom_line_y = text_y;
2434 let label_offset = if has_label_text {
2435 let label_y_min = top_line_y - line_height * MESSAGE_LABEL_ASCENT_FACTOR;
2436 let label_y_max = bottom_line_y + line_height * MESSAGE_LABEL_DESCENT_FACTOR;
2437 let label_x_min = text_x - max_width / 2.0;
2438 let label_x_max = text_x + max_width / 2.0;
2439 let step = line_height * MESSAGE_LABEL_COLLISION_STEP_RATIO;
2440 state.reserve_message_label(label_x_min, label_x_max, label_y_min, label_y_max, step)
2441 } else {
2442 0.0
2443 };
2444 for (i, line) in lines.iter().enumerate() {
2445 let line_y = text_y - (lines.len() - 1 - i) as f64 * line_height + label_offset;
2446 writeln!(
2447 svg,
2448 r#" <text x="{x}" y="{y}" class="message-text" text-anchor="middle">{t}</text>"#,
2449 x = text_x,
2450 y = line_y,
2451 t = escape_xml(line)
2452 )
2453 .unwrap();
2454 }
2455
2456 writeln!(svg, r#"</g>"#).unwrap();
2458
2459 state.current_y += state.config.row_height + delay_offset;
2461 }
2462
2463 if create {
2464 state.current_y += CREATE_MESSAGE_SPACING;
2465 }
2466
2467 state.apply_serial_first_row_gap();
2468
2469 if activate && depth == 0 {
2470 state.current_y += ACTIVATION_START_GAP;
2471 }
2472 if chain_gap > 0.0 {
2473 state.current_y += chain_gap;
2474 }
2475
2476 if activate {
2478 state
2479 .activations
2480 .entry(to.to_string())
2481 .or_default()
2482 .push((y, None));
2483 }
2484 if deactivate {
2485 if let Some(acts) = state.activations.get_mut(from) {
2486 if let Some(act) = acts.last_mut() {
2487 if act.1.is_none() {
2488 act.1 = Some(y);
2489 }
2490 }
2491 }
2492 }
2493}
2494
2495fn render_note(
2496 svg: &mut String,
2497 state: &mut RenderState,
2498 position: &NotePosition,
2499 participants: &[String],
2500 text: &str,
2501) {
2502 let lines: Vec<&str> = text.split("\\n").collect();
2503 let line_height = note_line_height(&state.config);
2504 let padding = note_padding(&state.config);
2505 let note_height = padding * 2.0 + lines.len() as f64 * line_height;
2506
2507 let max_line_len = lines.iter().map(|l| l.chars().count()).max().unwrap_or(10);
2510 let content_width = (max_line_len as f64 * 10.0 + padding * 2.0).max(80.0);
2511
2512 let (x, note_width, text_anchor) = match position {
2513 NotePosition::Left => {
2514 let px = state.get_x(&participants[0]);
2515 let p_width = state.get_participant_width(&participants[0]);
2516 let w = content_width.min(300.0);
2517 let x = (px - p_width / 2.0 - w - 10.0).max(state.config.padding);
2519 (x, w, "start")
2520 }
2521 NotePosition::Right => {
2522 let px = state.get_x(&participants[0]);
2523 let p_width = state.get_participant_width(&participants[0]);
2524 let w = content_width.min(300.0);
2525 (px + p_width / 2.0 + 5.0, w, "start")
2526 }
2527 NotePosition::Over => {
2528 if participants.len() == 1 {
2529 let px = state.get_x(&participants[0]);
2530 let w = content_width;
2532 let x = (px - w / 2.0).max(state.config.padding);
2534 (x, w, "middle")
2535 } else {
2536 let x1 = state.get_x(&participants[0]);
2538 let x2 = state.get_x(participants.last().unwrap());
2539 let p1_width = state.get_participant_width(&participants[0]);
2540 let p2_width = state.get_participant_width(participants.last().unwrap());
2541 let span_width = (x2 - x1).abs() + (p1_width + p2_width) / 2.0 * 0.8;
2542 let w = span_width.max(content_width);
2543 let center = (x1 + x2) / 2.0;
2544 let x = (center - w / 2.0).max(state.config.padding);
2546 (x, w, "middle")
2547 }
2548 }
2549 };
2550
2551 let y = state.current_y;
2552 let fold_size = 8.0; let note_path = format!(
2557 "M {x} {y} L {x2} {y} L {x3} {y2} L {x3} {y3} L {x} {y3} Z",
2558 x = x,
2559 y = y,
2560 x2 = x + note_width - fold_size,
2561 x3 = x + note_width,
2562 y2 = y + fold_size,
2563 y3 = y + note_height
2564 );
2565
2566 writeln!(svg, r#"<path d="{path}" class="note"/>"#, path = note_path).unwrap();
2567
2568 let theme = &state.config.theme;
2570 let fold_path = format!(
2572 "M {x1} {y1} L {x2} {y2} L {x1} {y2} Z",
2573 x1 = x + note_width - fold_size,
2574 y1 = y,
2575 x2 = x + note_width,
2576 y2 = y + fold_size
2577 );
2578
2579 writeln!(
2580 svg,
2581 r##"<path d="{path}" fill="#e0e0a0" stroke="{stroke}" stroke-width="1"/>"##,
2582 path = fold_path,
2583 stroke = theme.note_stroke
2584 )
2585 .unwrap();
2586
2587 let text_x = match text_anchor {
2589 "middle" => x + note_width / 2.0,
2590 "start" => x + padding,
2591 _ => x + note_width - padding,
2592 };
2593
2594 for (i, line) in lines.iter().enumerate() {
2595 let text_y = y + padding + (i as f64 + 0.8) * line_height;
2596 writeln!(
2597 svg,
2598 r#"<text x="{x}" y="{y}" class="note-text" text-anchor="{anchor}">{t}</text>"#,
2599 x = text_x,
2600 y = text_y,
2601 anchor = if *position == NotePosition::Over {
2602 "middle"
2603 } else {
2604 "start"
2605 },
2606 t = escape_xml(line)
2607 )
2608 .unwrap();
2609 }
2610
2611 state.current_y += note_height.max(state.config.row_height) + NOTE_MARGIN;
2613}
2614
2615fn render_state(svg: &mut String, state: &mut RenderState, participants: &[String], text: &str) {
2617 let theme = &state.config.theme;
2618 let lines: Vec<&str> = text.split("\\n").collect();
2619 let line_height = state_line_height(&state.config);
2620 let box_height = state.config.note_padding * 2.0 + lines.len() as f64 * line_height;
2621
2622 let (x, box_width) = if participants.len() == 1 {
2624 let px = state.get_x(&participants[0]);
2625 let max_line_len = lines.iter().map(|l| l.chars().count()).max().unwrap_or(8);
2626 let w = (max_line_len as f64 * 8.0 + state.config.note_padding * 2.0).max(60.0);
2627 (px - w / 2.0, w)
2628 } else {
2629 let x1 = state.get_x(&participants[0]);
2630 let x2 = state.get_x(participants.last().unwrap());
2631 let span_width = (x2 - x1).abs() + state.config.participant_width * 0.6;
2632 let center = (x1 + x2) / 2.0;
2633 (center - span_width / 2.0, span_width)
2634 };
2635
2636 let shift = item_pre_shift(&state.config);
2637 let y = (state.current_y - shift).max(state.content_start());
2638
2639 writeln!(
2641 svg,
2642 r##"<rect x="{x}" y="{y}" width="{w}" height="{h}" rx="8" ry="8" fill="{fill}" stroke="{stroke}" stroke-width="1.5"/>"##,
2643 x = x,
2644 y = y,
2645 w = box_width,
2646 h = box_height,
2647 fill = theme.state_fill,
2648 stroke = theme.state_stroke
2649 )
2650 .unwrap();
2651
2652 let text_x = x + box_width / 2.0;
2654 for (i, line) in lines.iter().enumerate() {
2655 let text_y = y + state.config.note_padding + (i as f64 + 0.8) * line_height;
2656 writeln!(
2657 svg,
2658 r##"<text x="{x}" y="{y}" text-anchor="middle" fill="{fill}" font-family="{font}" font-size="{size}px">{t}</text>"##,
2659 x = text_x,
2660 y = text_y,
2661 fill = theme.state_text_color,
2662 font = theme.font_family,
2663 size = state.config.font_size,
2664 t = escape_xml(line)
2665 )
2666 .unwrap();
2667 }
2668
2669 state.current_y = y + box_height + state.config.row_height + REF_EXTRA_GAP;
2670}
2671
2672fn render_ref(
2674 svg: &mut String,
2675 state: &mut RenderState,
2676 participants: &[String],
2677 text: &str,
2678 input_from: Option<&str>,
2679 input_label: Option<&str>,
2680 output_to: Option<&str>,
2681 output_label: Option<&str>,
2682) {
2683 let theme = &state.config.theme;
2684 let lines: Vec<&str> = text.split("\\n").collect();
2685 let line_height = ref_line_height(&state.config);
2686 let box_height = state.config.note_padding * 2.0 + lines.len() as f64 * line_height;
2687 let notch_size = 10.0;
2688
2689 let (x, box_width) = if participants.len() == 1 {
2691 let px = state.get_x(&participants[0]);
2692 let max_line_len = lines.iter().map(|l| l.chars().count()).max().unwrap_or(15);
2693 let w = (max_line_len as f64 * 8.0 + state.config.note_padding * 2.0 + notch_size * 2.0)
2694 .max(100.0);
2695 (px - w / 2.0, w)
2696 } else {
2697 let x1 = state.get_x(&participants[0]);
2698 let x2 = state.get_x(participants.last().unwrap());
2699 let span_width = (x2 - x1).abs() + state.config.participant_width * 0.8;
2700 let center = (x1 + x2) / 2.0;
2701 (center - span_width / 2.0, span_width)
2702 };
2703
2704 let shift = item_pre_shift(&state.config);
2705 let y = (state.current_y - shift).max(state.content_start());
2706 let input_offset = state.config.note_padding + state.config.font_size + 1.0;
2707 let output_padding = state.config.note_padding + 3.0;
2708
2709 if let Some(from) = input_from {
2711 let from_x = state.get_x(from);
2712 let to_x = x; let arrow_y = y + input_offset;
2714
2715 let direction = arrow_direction(from_x, arrow_y, to_x, arrow_y);
2717 let arrow_points = arrowhead_points(to_x, arrow_y, direction);
2718 let line_end_x = to_x - ARROWHEAD_SIZE * direction.cos();
2719
2720 writeln!(
2722 svg,
2723 r##"<line x1="{x1}" y1="{y}" x2="{x2}" y2="{y}" class="message"/>"##,
2724 x1 = from_x,
2725 y = arrow_y,
2726 x2 = line_end_x
2727 )
2728 .unwrap();
2729
2730 writeln!(
2732 svg,
2733 r#"<polygon points="{points}" class="arrowhead"/>"#,
2734 points = arrow_points
2735 )
2736 .unwrap();
2737
2738 if let Some(label) = input_label {
2740 let text_x = (from_x + to_x) / 2.0;
2741 writeln!(
2742 svg,
2743 r##"<text x="{x}" y="{y}" class="message-text" text-anchor="middle">{t}</text>"##,
2744 x = text_x,
2745 y = arrow_y - 8.0,
2746 t = escape_xml(label)
2747 )
2748 .unwrap();
2749 }
2750 }
2751
2752 let ref_path = format!(
2755 "M {x1} {y1} L {x2} {y1} L {x2} {y2} L {x1} {y2} L {x3} {y3} Z",
2756 x1 = x + notch_size,
2757 y1 = y,
2758 x2 = x + box_width,
2759 y2 = y + box_height,
2760 x3 = x,
2761 y3 = y + box_height / 2.0
2762 );
2763
2764 writeln!(
2765 svg,
2766 r##"<path d="{path}" fill="{fill}" stroke="{stroke}" stroke-width="1.5"/>"##,
2767 path = ref_path,
2768 fill = theme.ref_fill,
2769 stroke = theme.ref_stroke
2770 )
2771 .unwrap();
2772
2773 writeln!(
2775 svg,
2776 r##"<text x="{x}" y="{y}" fill="{fill}" font-family="{font}" font-size="{size}px" font-weight="bold">ref</text>"##,
2777 x = x + notch_size + 4.0,
2778 y = y + state.config.font_size,
2779 fill = theme.ref_text_color,
2780 font = theme.font_family,
2781 size = state.config.font_size - 2.0
2782 )
2783 .unwrap();
2784
2785 let text_x = x + box_width / 2.0;
2787 for (i, line) in lines.iter().enumerate() {
2788 let text_y = y + state.config.note_padding + (i as f64 + 0.8) * line_height;
2789 writeln!(
2790 svg,
2791 r##"<text x="{x}" y="{y}" text-anchor="middle" fill="{fill}" font-family="{font}" font-size="{size}px">{t}</text>"##,
2792 x = text_x,
2793 y = text_y,
2794 fill = theme.ref_text_color,
2795 font = theme.font_family,
2796 size = state.config.font_size,
2797 t = escape_xml(line)
2798 )
2799 .unwrap();
2800 }
2801
2802 if let Some(to) = output_to {
2804 let from_x = x + box_width; let to_x = state.get_x(to);
2806 let arrow_y = y + box_height - output_padding;
2807
2808 let direction = arrow_direction(from_x, arrow_y, to_x, arrow_y);
2810 let arrow_points = arrowhead_points(to_x, arrow_y, direction);
2811 let line_end_x = to_x - ARROWHEAD_SIZE * direction.cos();
2812
2813 writeln!(
2815 svg,
2816 r##"<line x1="{x1}" y1="{y}" x2="{x2}" y2="{y}" class="message-dashed"/>"##,
2817 x1 = from_x,
2818 y = arrow_y,
2819 x2 = line_end_x
2820 )
2821 .unwrap();
2822
2823 writeln!(
2825 svg,
2826 r#"<polygon points="{points}" class="arrowhead"/>"#,
2827 points = arrow_points
2828 )
2829 .unwrap();
2830
2831 if let Some(label) = output_label {
2833 let text_x = (from_x + to_x) / 2.0;
2834 writeln!(
2835 svg,
2836 r##"<text x="{x}" y="{y}" class="message-text" text-anchor="middle">{t}</text>"##,
2837 x = text_x,
2838 y = arrow_y - 8.0,
2839 t = escape_xml(label)
2840 )
2841 .unwrap();
2842 }
2843 }
2844
2845 state.current_y = y + box_height + state.config.row_height + STATE_EXTRA_GAP;
2846}
2847
2848fn render_description(svg: &mut String, state: &mut RenderState, text: &str) {
2850 let theme = &state.config.theme;
2851 let lines: Vec<&str> = text.split("\\n").collect();
2852 let line_height = state.config.font_size + 4.0;
2853
2854 let x = state.config.padding + 10.0;
2856 let y = state.current_y;
2857
2858 for (i, line) in lines.iter().enumerate() {
2859 let text_y = y + (i as f64 + 0.8) * line_height;
2860 writeln!(
2861 svg,
2862 r##"<text x="{x}" y="{y}" fill="{fill}" font-family="{font}" font-size="{size}px" font-style="italic">{t}</text>"##,
2863 x = x,
2864 y = text_y,
2865 fill = theme.description_text_color,
2866 font = theme.font_family,
2867 size = state.config.font_size - 1.0,
2868 t = escape_xml(line)
2869 )
2870 .unwrap();
2871 }
2872
2873 state.current_y += lines.len() as f64 * line_height + 10.0;
2874}
2875
2876fn render_block(
2877 svg: &mut String,
2878 state: &mut RenderState,
2879 kind: &BlockKind,
2880 _label: &str,
2881 items: &[Item],
2882 else_items: Option<&[Item]>,
2883 depth: usize,
2884) {
2885 if block_is_parallel(kind) {
2886 state.push_parallel();
2887 let start_y = state.current_y;
2888 let mut max_end_y = start_y;
2889 for item in items {
2890 state.current_y = start_y;
2891 render_items(svg, state, std::slice::from_ref(item), depth);
2892 if state.current_y > max_end_y {
2893 max_end_y = state.current_y;
2894 }
2895 }
2896 let gap = if parallel_needs_gap(items) {
2897 PARALLEL_BLOCK_GAP
2898 } else {
2899 0.0
2900 };
2901 state.current_y = max_end_y + gap;
2902 state.pop_parallel();
2903 return;
2904 }
2905
2906 if matches!(kind, BlockKind::Serial) {
2907 state.push_serial_first_row_pending();
2908 render_items(svg, state, items, depth);
2909 if let Some(else_items) = else_items {
2910 render_items(svg, state, else_items, depth);
2911 }
2912 state.pop_serial_first_row_pending();
2913 return;
2914 }
2915
2916 if !block_has_frame(kind) {
2917 render_items(svg, state, items, depth);
2918 if let Some(else_items) = else_items {
2919 render_items(svg, state, else_items, depth);
2920 }
2921 return;
2922 }
2923
2924 state.current_y += block_header_space(&state.config, depth);
2929
2930 render_items(svg, state, items, depth + 1);
2932
2933 if let Some(else_items) = else_items {
2935 state.push_else_return_pending();
2936 state.current_y += block_else_spacing(&state.config, depth);
2937 render_items(svg, state, else_items, depth + 1);
2938 state.pop_else_return_pending();
2939 }
2940
2941 let end_y =
2942 state.current_y - state.config.row_height + block_footer_padding(&state.config, depth);
2943
2944 state.current_y = end_y + state.config.row_height * 1.0;
2946
2947 }
2950
2951fn render_activations(svg: &mut String, state: &mut RenderState, footer_y: f64) {
2952 for (participant, activations) in &state.activations {
2953 let x = state.get_x(participant);
2954 let box_x = x - state.config.activation_width / 2.0;
2955
2956 for (start_y, end_y) in activations {
2957 let end = end_y.unwrap_or(footer_y);
2959 let height = end - start_y;
2960
2961 if height > 0.0 {
2962 writeln!(
2963 svg,
2964 r#"<rect x="{x}" y="{y}" width="{w}" height="{h}" class="activation"/>"#,
2965 x = box_x,
2966 y = start_y,
2967 w = state.config.activation_width,
2968 h = height
2969 )
2970 .unwrap();
2971 }
2972 }
2973 }
2974}
2975
2976fn escape_xml(s: &str) -> String {
2977 s.replace('&', "&")
2978 .replace('<', "<")
2979 .replace('>', ">")
2980 .replace('"', """)
2981 .replace('\'', "'")
2982}
2983
2984#[cfg(test)]
2985mod tests {
2986 use super::*;
2987 use crate::parser::parse;
2988
2989 #[test]
2990 fn test_render_simple() {
2991 let diagram = parse("Alice->Bob: Hello").unwrap();
2992 let svg = render(&diagram);
2993 assert!(svg.contains("<svg"));
2994 assert!(svg.contains("Alice"));
2995 assert!(svg.contains("Bob"));
2996 assert!(svg.contains("Hello"));
2997 }
2998
2999 #[test]
3000 fn test_render_with_note() {
3001 let diagram = parse("Alice->Bob: Hello\nnote over Alice: Thinking").unwrap();
3002 let svg = render(&diagram);
3003 assert!(svg.contains("Thinking"));
3004 }
3005}