1use super::pipeline::{
2 ScopedCssPostprocessor, SvgPipeline, SvgPostprocessExecution, SvgPostprocessMetadata,
3};
4use crate::environment::{RenderSession, RoutedTextMeasurer, TextMeasurementPhase};
5#[cfg(all(feature = "layout-cytoscape", feature = "diagram-architecture"))]
6use crate::model::ArchitectureDiagramLayout;
7use crate::model::*;
8#[cfg_attr(
9 not(feature = "all-diagrams"),
10 allow(
11 unused_imports,
12 reason = "Common SVG imports are consumed by the selected family emitters."
13 )
14)]
15use crate::text::{TextMeasurer, TextStyle, WrapMode};
16use crate::{Error, Result};
17#[cfg_attr(
18 not(feature = "all-diagrams"),
19 allow(
20 unused_imports,
21 reason = "Common SVG imports are consumed by the selected family emitters."
22 )
23)]
24use base64::Engine as _;
25#[cfg_attr(
26 not(feature = "all-diagrams"),
27 allow(
28 unused_imports,
29 reason = "Common SVG imports are consumed by the selected family emitters."
30 )
31)]
32use indexmap::IndexMap;
33use merman_core::OperationPhase;
34use std::fmt::Write as _;
35
36pub(crate) const C4_PERSON_IMG: &str = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAADAAAAAwCAIAAADYYG7QAAACD0lEQVR4Xu2YoU4EMRCGT+4j8Ai8AhaH4QHgAUjQuFMECUgMIUgwJAgMhgQsAYUiJCiQIBBY+EITsjfTdme6V24v4c8vyGbb+ZjOtN0bNcvjQXmkH83WvYBWto6PLm6v7p7uH1/w2fXD+PBycX1Pv2l3IdDm/vn7x+dXQiAubRzoURa7gRZWd0iGRIiJbOnhnfYBQZNJjNbuyY2eJG8fkDE3bbG4ep6MHUAsgYxmE3nVs6VsBWJSGccsOlFPmLIViMzLOB7pCVO2AtHJMohH7Fh6zqitQK7m0rJvAVYgGcEpe//PLdDz65sM4pF9N7ICcXDKIB5Nv6j7tD0NoSdM2QrU9Gg0ewE1LqBhHR3BBdvj2vapnidjHxD/q6vd7Pvhr31AwcY8eXMTXAKECZZJFXuEq27aLgQK5uLMohCenGGuGewOxSjBvYBqeG6B+Nqiblggdjnc+ZXDy+FNFpFzw76O3UBAROuXh6FoiAcf5g9eTvUgzy0nWg6I8cXHRUpg5bOVBCo+KDpFajOf23GgPme7RSQ+lacIENUgJ6gg1k6HjgOlqnLqip4tEuhv0hNEMXUD0clyXE3p6pZA0S2nnvTlXwLJEZWlb7cTQH1+USgTN4VhAenm/wea1OCAOmqo6fE1WCb9WSKBah+rbUWPWAmE2Rvk0ApiB45eOyNAzU8xcTvj8KvkKEoOaIYeHNA3ZuygAvFMUO0AAAAASUVORK5CYII=";
37pub(crate) const C4_EXTERNAL_PERSON_IMG: &str = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAADAAAAAwCAIAAADYYG7QAAAB6ElEQVR4Xu2YLY+EMBCG9+dWr0aj0Wg0Go1Go0+j8Xdv2uTCvv1gpt0ebHKPuhDaeW4605Z9mJvx4AdXUyTUdd08z+u6flmWZRnHsWkafk9DptAwDPu+f0eAYtu2PEaGWuj5fCIZrBAC2eLBAnRCsEkkxmeaJp7iDJ2QMDdHsLg8SxKFEJaAo8lAXnmuOFIhTMpxxKATebo4UiFknuNo4OniSIXQyRxEA3YsnjGCVEjVXD7yLUAqxBGUyPv/Y4W2beMgGuS7kVQIBycH0fD+oi5pezQETxdHKmQKGk1eQEYldK+jw5GxPfZ9z7Mk0Qnhf1W1m3w//EUn5BDmSZsbR44QQLBEqrBHqOrmSKaQAxdnLArCrxZcM7A7ZKs4ioRq8LFC+NpC3WCBJsvpVw5edm9iEXFuyNfxXAgSwfrFQ1c0iNda8AdejvUgnktOtJQQxmcfFzGglc5WVCj7oDgFqU18boeFSs52CUh8LE8BIVQDT1ABrB0HtgSEYlX5doJnCwv9TXocKCaKbnwhdDKPq4lf3SwU3HLq4V/+WYhHVMa/3b4IlfyikAduCkcBc7mQ3/z/Qq/cTuikhkzB12Ae/mcJC9U+Vo8Ej1gWAtgbeGgFsAMHr50BIWOLCbezvhpBFUdY6EJuJ/QDW0XoMX60zZ0AAAAASUVORK5CYII=";
38
39#[cfg(all(feature = "layout-cytoscape", feature = "diagram-architecture"))]
40mod architecture;
41#[cfg(feature = "diagram-block")]
42mod block;
43#[cfg(feature = "diagram-c4")]
44mod c4;
45#[cfg(feature = "diagram-class")]
46mod class;
47#[cfg_attr(
48 not(feature = "all-diagrams"),
49 allow(
50 dead_code,
51 unused_imports,
52 reason = "Shared rendering utilities have different callers in each diagram selection."
53 )
54)]
55mod css;
56#[cfg_attr(
57 not(feature = "all-diagrams"),
58 allow(
59 dead_code,
60 unused_imports,
61 reason = "Shared rendering utilities have different callers in each diagram selection."
62 )
63)]
64mod curve;
65#[cfg(feature = "diagram-cynefin")]
66mod cynefin;
67#[cfg_attr(
68 not(feature = "all-diagrams"),
69 allow(
70 dead_code,
71 unused_imports,
72 reason = "Shared rendering utilities have different callers in each diagram selection."
73 )
74)]
75mod edge_label_geometry;
76#[cfg_attr(
77 not(feature = "all-diagrams"),
78 allow(
79 dead_code,
80 unused_imports,
81 reason = "Shared rendering utilities have different callers in each diagram selection."
82 )
83)]
84mod edge_path;
85#[cfg_attr(
86 not(feature = "all-diagrams"),
87 allow(
88 dead_code,
89 unused_imports,
90 reason = "Shared rendering utilities have different callers in each diagram selection."
91 )
92)]
93mod emitted_bounds;
94#[cfg(feature = "diagram-er")]
95mod er;
96mod error;
97#[cfg(feature = "diagram-event-modeling")]
98mod eventmodeling;
99#[cfg(any(
100 feature = "diagram-flowchart",
101 feature = "diagram-swimlane",
102 feature = "diagram-agentflow"
103))]
104mod flowchart;
105#[cfg(feature = "diagram-gantt")]
106mod gantt;
107#[cfg(feature = "diagram-git-graph")]
108mod gitgraph;
109#[cfg(feature = "diagram-info")]
110mod info;
111#[cfg(feature = "diagram-ishikawa")]
112mod ishikawa;
113#[cfg(feature = "diagram-journey")]
114mod journey;
115#[cfg(feature = "diagram-kanban")]
116mod kanban;
117#[cfg_attr(
118 not(feature = "all-diagrams"),
119 allow(
120 dead_code,
121 unused_imports,
122 reason = "Shared rendering utilities have different callers in each diagram selection."
123 )
124)]
125mod label;
126#[cfg_attr(
127 not(feature = "all-diagrams"),
128 allow(
129 dead_code,
130 unused_imports,
131 reason = "Shared rendering utilities have different callers in each diagram selection."
132 )
133)]
134mod layout_debug;
135#[cfg_attr(
136 not(feature = "all-diagrams"),
137 allow(
138 dead_code,
139 unused_imports,
140 reason = "Shared rendering utilities have different callers in each diagram selection."
141 )
142)]
143mod line_hops;
144#[cfg_attr(
145 not(feature = "all-diagrams"),
146 allow(
147 dead_code,
148 unused_imports,
149 reason = "Shared rendering utilities have different callers in each diagram selection."
150 )
151)]
152mod look_defs;
153#[cfg_attr(
154 not(feature = "all-diagrams"),
155 allow(
156 dead_code,
157 unused_imports,
158 reason = "Shared rendering utilities have different callers in each diagram selection."
159 )
160)]
161mod markers;
162#[cfg(feature = "diagram-mindmap")]
163mod mindmap;
164#[cfg(feature = "diagram-packet")]
165mod packet;
166#[cfg_attr(
167 not(feature = "all-diagrams"),
168 allow(
169 dead_code,
170 unused_imports,
171 reason = "Shared rendering utilities have different callers in each diagram selection."
172 )
173)]
174mod path_bounds;
175#[cfg(feature = "diagram-pie")]
176mod pie;
177#[cfg(feature = "diagram-quadrant-chart")]
178mod quadrantchart;
179#[cfg(feature = "diagram-radar")]
180mod radar;
181#[cfg(feature = "diagram-railroad")]
182mod railroad;
183#[cfg(feature = "diagram-requirement")]
184mod requirement;
185#[cfg_attr(
186 not(feature = "all-diagrams"),
187 allow(
188 dead_code,
189 unused_imports,
190 reason = "Shared rendering utilities have different callers in each diagram selection."
191 )
192)]
193mod root_svg;
194#[cfg_attr(
195 not(feature = "all-diagrams"),
196 allow(
197 dead_code,
198 unused_imports,
199 reason = "Shared rendering utilities have different callers in each diagram selection."
200 )
201)]
202mod roughjs_common;
203#[cfg(feature = "diagram-sankey")]
204mod sankey;
205#[cfg(feature = "diagram-sequence")]
206mod sequence;
207#[cfg(feature = "diagram-state")]
208mod state;
209#[cfg_attr(
210 not(feature = "all-diagrams"),
211 allow(
212 dead_code,
213 unused_imports,
214 reason = "Shared rendering utilities have different callers in each diagram selection."
215 )
216)]
217mod style;
218#[cfg_attr(
219 not(feature = "all-diagrams"),
220 allow(
221 dead_code,
222 unused_imports,
223 reason = "Shared rendering utilities have different callers in each diagram selection."
224 )
225)]
226pub(crate) mod theme;
227#[cfg(feature = "diagram-timeline")]
228mod timeline;
229#[cfg_attr(
230 not(feature = "all-diagrams"),
231 allow(
232 dead_code,
233 unused_imports,
234 reason = "Shared rendering utilities have different callers in each diagram selection."
235 )
236)]
237mod timing;
238#[cfg(feature = "diagram-tree-view")]
239mod tree_view;
240#[cfg(feature = "diagram-treemap")]
241mod treemap;
242#[cfg(feature = "diagram-usecase")]
243mod usecase;
244#[cfg_attr(
245 not(feature = "all-diagrams"),
246 allow(
247 dead_code,
248 unused_imports,
249 reason = "Shared rendering utilities have different callers in each diagram selection."
250 )
251)]
252mod util;
253#[cfg(feature = "diagram-venn")]
254mod venn;
255#[cfg(feature = "diagram-wardley")]
256mod wardley;
257#[cfg(feature = "diagram-xychart")]
258mod xychart;
259#[cfg(feature = "diagram-zenuml")]
260mod zenuml;
261#[cfg(feature = "diagram-pie")]
262use css::PieCss;
263#[cfg(feature = "diagram-er")]
264use css::er_css;
265#[cfg(feature = "diagram-gantt")]
266use css::gantt_css;
267#[cfg(feature = "diagram-xychart")]
268use css::push_xychart_css;
269#[cfg(feature = "diagram-requirement")]
270use css::requirement_css;
271#[cfg(feature = "diagram-sankey")]
272use css::sankey_css;
273#[cfg(feature = "diagram-treemap")]
274use css::treemap_css;
275#[cfg_attr(
276 not(feature = "all-diagrams"),
277 allow(
278 unused_imports,
279 reason = "Common SVG imports are consumed by the selected family emitters."
280 )
281)]
282use css::{
283 info_css_parts_with_config, info_css_parts_with_theme_font_size_only, info_css_with_config,
284};
285#[cfg_attr(
286 not(feature = "all-diagrams"),
287 allow(
288 unused_imports,
289 reason = "Common SVG imports are consumed by the selected family emitters."
290 )
291)]
292use path_bounds::{svg_path_bounds_from_d, svg_path_length_from_d};
293#[cfg(feature = "diagram-mindmap")]
294pub(crate) fn mindmap_cloud_rendered_bbox_size_px(w: f64, h: f64) -> Option<(f64, f64)> {
295 mindmap::mindmap_cloud_rendered_bbox_size_px(w, h)
296}
297
298pub use emitted_bounds::{
299 SvgEmittedBoundsContributor, SvgEmittedBoundsDebug, debug_svg_emitted_bounds,
300};
301#[cfg_attr(
302 not(feature = "all-diagrams"),
303 allow(
304 unused_imports,
305 reason = "Common SVG imports are consumed by the selected family emitters."
306 )
307)]
308use emitted_bounds::{svg_emitted_bounds_from_svg, svg_emitted_bounds_from_svg_inner};
309#[cfg_attr(
310 not(feature = "all-diagrams"),
311 allow(
312 unused_imports,
313 reason = "Common SVG imports are consumed by the selected family emitters."
314 )
315)]
316use roughjs_common::{ops_to_svg_path_d as roughjs_ops_to_svg_path_d, roughjs_paths_for_rect};
317#[cfg_attr(
318 not(feature = "all-diagrams"),
319 allow(
320 unused_imports,
321 reason = "Common SVG imports are consumed by the selected family emitters."
322 )
323)]
324use style::{is_rect_style_key, is_text_style_key, parse_style_decl};
325#[cfg_attr(
326 not(feature = "all-diagrams"),
327 allow(
328 unused_imports,
329 reason = "Common SVG imports are consumed by the selected family emitters."
330 )
331)]
332use theme::PresentationTheme;
333#[cfg_attr(
334 not(feature = "all-diagrams"),
335 allow(
336 unused_imports,
337 reason = "Common SVG imports are consumed by the selected family emitters."
338 )
339)]
340use util::{
341 SvgTheme, config_bool, config_diagram_look, config_f64, config_f64_css_px, config_string,
342 css_rgba_fade, decode_mermaid_entities_for_render_text, escape_attr, escape_attr_display,
343 escape_attr_into, escape_xml, escape_xml_display, escape_xml_into, fmt, fmt_display, fmt_into,
344 fmt_path, fmt_path_into, fmt_points, fmt_string, json_stringify_points,
345 json_stringify_points_into, normalize_css_font_family, scoped_drop_shadow, scoped_svg_id,
346 scoped_svg_url, theme_token,
347};
348
349pub fn sanitize_svg_id(raw: &str) -> String {
355 let raw = raw.trim();
356 if raw.is_empty() {
357 return "m-untitled".to_string();
358 }
359
360 let mut iter = raw.chars();
361 let Some(first_raw) = iter.next() else {
362 return "m-untitled".to_string();
363 };
364
365 let sanitize_char = |ch: char| {
366 if ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_') {
367 ch
368 } else {
369 '-'
370 }
371 };
372 let first = sanitize_char(first_raw);
373 let mut out = String::with_capacity(raw.len() + 2);
374 let mut previous_was_dash = false;
375
376 if !first.is_ascii_alphabetic() {
377 out.push('m');
378 if first != '-' {
379 out.push('-');
380 previous_was_dash = true;
381 }
382 }
383
384 let push = |ch: char, out: &mut String, previous_was_dash: &mut bool| {
385 if ch == '-' {
386 if *previous_was_dash {
387 return;
388 }
389 *previous_was_dash = true;
390 } else {
391 *previous_was_dash = false;
392 }
393 out.push(ch);
394 };
395
396 push(first, &mut out, &mut previous_was_dash);
397 for ch in iter {
398 push(sanitize_char(ch), &mut out, &mut previous_was_dash);
399 }
400 while out.ends_with('-') {
401 out.pop();
402 }
403
404 if out.is_empty() || out == "m" {
405 "m-untitled".to_string()
406 } else {
407 out
408 }
409}
410
411#[derive(Debug, Clone)]
412pub struct SvgRenderOptions {
413 pub viewbox_padding: f64,
415 pub diagram_id: Option<String>,
417}
418
419impl Default for SvgRenderOptions {
420 fn default() -> Self {
421 Self {
422 viewbox_padding: 8.0,
423 diagram_id: None,
424 }
425 }
426}
427
428const SVG_DIAGRAM_ID_SCAN_CHECKPOINT_BYTES: usize = 4 * 1024;
429
430#[derive(Clone, Copy)]
431struct SvgDiagramIdSanitizationPlan<'a> {
432 trimmed: &'a str,
433 output_bytes: usize,
434 uses_fallback: bool,
435}
436
437fn checkpoint_svg_diagram_id_scan(
438 session: &RenderSession,
439 next_checkpoint: &mut usize,
440 scanned_bytes: usize,
441) -> Result<()> {
442 if scanned_bytes < *next_checkpoint {
443 return Ok(());
444 }
445 session.checkpoint(OperationPhase::Emit)?;
446 *next_checkpoint = scanned_bytes
447 .checked_add(SVG_DIAGRAM_ID_SCAN_CHECKPOINT_BYTES)
448 .ok_or_else(|| {
449 Error::from(session.work_meter().terminate_svg_byte_count_overflow(
450 crate::resources::ResourceLimitPhase::SvgOutput,
451 OperationPhase::Emit,
452 ))
453 })?;
454 Ok(())
455}
456
457fn controlled_trim_svg_diagram_id<'a>(raw: &'a str, session: &RenderSession) -> Result<&'a str> {
458 let mut first = None;
459 let mut end = 0usize;
460 let mut next_checkpoint = 0usize;
461 for (offset, ch) in raw.char_indices() {
462 checkpoint_svg_diagram_id_scan(session, &mut next_checkpoint, offset)?;
463 if !ch.is_whitespace() {
464 first.get_or_insert(offset);
465 end = offset + ch.len_utf8();
466 }
467 }
468 session.checkpoint(OperationPhase::Emit)?;
469 Ok(first.map_or("", |first| &raw[first..end]))
470}
471
472fn plan_svg_diagram_id_sanitization<'a>(
473 raw: &'a str,
474 session: &RenderSession,
475) -> Result<SvgDiagramIdSanitizationPlan<'a>> {
476 let trimmed = controlled_trim_svg_diagram_id(raw, session)?;
477 if trimmed.is_empty() {
478 return Ok(SvgDiagramIdSanitizationPlan {
479 trimmed,
480 output_bytes: "m-untitled".len(),
481 uses_fallback: true,
482 });
483 }
484
485 let sanitize_char = |ch: char| {
486 if ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_') {
487 ch
488 } else {
489 '-'
490 }
491 };
492 let mut chars = trimmed.char_indices();
493 let (_, first_raw) = chars.next().expect("non-empty trimmed diagram id");
494 let first = sanitize_char(first_raw);
495 let mut output_bytes = 0usize;
496 let mut first_output = None;
497 let mut previous_was_dash = false;
498 let mut append = |ch: char| -> Result<()> {
499 if ch == '-' && previous_was_dash {
500 return Ok(());
501 }
502 previous_was_dash = ch == '-';
503 first_output.get_or_insert(ch);
504 output_bytes = output_bytes.checked_add(1).ok_or_else(|| {
505 Error::from(session.work_meter().terminate_svg_byte_count_overflow(
506 crate::resources::ResourceLimitPhase::SvgOutput,
507 OperationPhase::Emit,
508 ))
509 })?;
510 Ok(())
511 };
512
513 if !first.is_ascii_alphabetic() {
514 append('m')?;
515 if first != '-' {
516 append('-')?;
517 }
518 }
519 append(first)?;
520
521 let mut next_checkpoint = SVG_DIAGRAM_ID_SCAN_CHECKPOINT_BYTES;
522 for (offset, ch) in chars {
523 checkpoint_svg_diagram_id_scan(session, &mut next_checkpoint, offset)?;
524 append(sanitize_char(ch))?;
525 }
526 if previous_was_dash {
527 output_bytes -= 1;
528 }
529 let uses_fallback = output_bytes == 0 || (output_bytes == 1 && first_output == Some('m'));
530 if uses_fallback {
531 output_bytes = "m-untitled".len();
532 }
533 session.checkpoint(OperationPhase::Emit)?;
534 Ok(SvgDiagramIdSanitizationPlan {
535 trimmed,
536 output_bytes,
537 uses_fallback,
538 })
539}
540
541fn materialize_svg_diagram_id(
542 plan: SvgDiagramIdSanitizationPlan<'_>,
543 session: &RenderSession,
544) -> Result<String> {
545 if session
546 .resource_policy()
547 .value(crate::resources::ResourceLimitId::MaxSvgBytes)
548 .is_some()
549 {
550 session.work_meter().preflight_svg_byte_count(
551 plan.output_bytes,
552 crate::resources::ResourceLimitPhase::SvgOutput,
553 OperationPhase::Emit,
554 )?;
555 }
556 let mut output = String::new();
557 output
558 .try_reserve_exact(plan.output_bytes)
559 .map_err(|error| Error::InvalidModel {
560 message: format!("failed to allocate normalized SVG diagram id: {error}"),
561 })?;
562 if plan.uses_fallback {
563 output.push_str("m-untitled");
564 return Ok(output);
565 }
566
567 let sanitize_char = |ch: char| {
568 if ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_') {
569 ch
570 } else {
571 '-'
572 }
573 };
574 let mut chars = plan.trimmed.char_indices();
575 let (_, first_raw) = chars.next().expect("non-fallback diagram id is non-empty");
576 let first = sanitize_char(first_raw);
577 let mut previous_was_dash = false;
578 let push = |ch: char, output: &mut String, previous_was_dash: &mut bool| {
579 if ch == '-' && *previous_was_dash {
580 return;
581 }
582 *previous_was_dash = ch == '-';
583 output.push(ch);
584 };
585
586 if !first.is_ascii_alphabetic() {
587 output.push('m');
588 if first != '-' {
589 output.push('-');
590 previous_was_dash = true;
591 }
592 }
593 push(first, &mut output, &mut previous_was_dash);
594 let mut next_checkpoint = SVG_DIAGRAM_ID_SCAN_CHECKPOINT_BYTES;
595 for (offset, ch) in chars {
596 checkpoint_svg_diagram_id_scan(session, &mut next_checkpoint, offset)?;
597 push(sanitize_char(ch), &mut output, &mut previous_was_dash);
598 }
599 while output.ends_with('-') {
600 output.pop();
601 }
602 session.checkpoint(OperationPhase::Emit)?;
603 if output.len() != plan.output_bytes {
604 return Err(Error::InvalidModel {
605 message: format!(
606 "SVG diagram id byte projection drifted: projected {} bytes but materialized {}",
607 plan.output_bytes,
608 output.len()
609 ),
610 });
611 }
612 Ok(output)
613}
614
615pub(crate) fn normalize_svg_render_options(
616 request: &SvgRenderOptions,
617 session: &RenderSession,
618) -> Result<SvgRenderOptions> {
619 let diagram_id = request
620 .diagram_id
621 .as_deref()
622 .map(|raw| plan_svg_diagram_id_sanitization(raw, session))
623 .transpose()?
624 .map(|plan| materialize_svg_diagram_id(plan, session))
625 .transpose()?;
626 Ok(SvgRenderOptions {
627 viewbox_padding: request.viewbox_padding,
628 diagram_id,
629 })
630}
631
632#[derive(Debug, Clone, PartialEq, serde::Serialize)]
634pub struct FlowchartEdgeTracePoint {
635 pub x: f64,
636 pub y: f64,
637}
638
639#[derive(Debug, Clone, PartialEq, serde::Serialize)]
644pub struct FlowchartEdgeTrace {
645 pub fixture_diagram_id: String,
646 pub edge_id: String,
647 pub from: String,
648 pub to: String,
649 pub layout_from: String,
650 pub layout_to: String,
651 pub from_cluster: Option<String>,
652 pub to_cluster: Option<String>,
653 pub origin_x: f64,
654 pub origin_y: f64,
655 pub tx: f64,
656 pub ty: f64,
657 pub base_points: Vec<FlowchartEdgeTracePoint>,
658 pub points_after_intersect: Vec<FlowchartEdgeTracePoint>,
659 pub points_for_render: Vec<FlowchartEdgeTracePoint>,
660 pub points_for_data_points: Vec<FlowchartEdgeTracePoint>,
661}
662
663#[derive(Debug, Clone, Default)]
669pub struct FlowchartEdgeTraceCollector(std::sync::Arc<std::sync::Mutex<Vec<FlowchartEdgeTrace>>>);
670
671impl FlowchartEdgeTraceCollector {
672 pub(crate) fn record(&self, trace: FlowchartEdgeTrace) {
673 self.0
674 .lock()
675 .unwrap_or_else(std::sync::PoisonError::into_inner)
676 .push(trace);
677 }
678
679 pub fn snapshot(&self) -> Vec<FlowchartEdgeTrace> {
681 self.0
682 .lock()
683 .unwrap_or_else(std::sync::PoisonError::into_inner)
684 .clone()
685 }
686
687 pub fn drain(&self) -> Vec<FlowchartEdgeTrace> {
689 std::mem::take(
690 &mut *self
691 .0
692 .lock()
693 .unwrap_or_else(std::sync::PoisonError::into_inner),
694 )
695 }
696}
697
698#[derive(Debug, Clone)]
700pub struct FlowchartEdgeTraceRequest {
701 edge_id: String,
702 collector: FlowchartEdgeTraceCollector,
703}
704
705#[derive(Debug, Clone)]
707pub struct SvgDebugOptions {
708 pub include_edges: bool,
709 pub include_nodes: bool,
710 pub include_clusters: bool,
711 pub include_cluster_debug_markers: bool,
712 pub include_edge_id_labels: bool,
713 pub include_timing_diagnostics: bool,
714 pub flowchart_edge_trace: Option<FlowchartEdgeTraceRequest>,
720}
721
722impl Default for SvgDebugOptions {
723 fn default() -> Self {
724 Self {
725 include_edges: true,
726 include_nodes: true,
727 include_clusters: true,
728 include_cluster_debug_markers: false,
729 include_edge_id_labels: false,
730 include_timing_diagnostics: false,
731 flowchart_edge_trace: None,
732 }
733 }
734}
735
736impl SvgDebugOptions {
737 pub fn with_flowchart_edge_trace(
742 mut self,
743 edge_id: impl Into<String>,
744 collector: FlowchartEdgeTraceCollector,
745 ) -> Self {
746 self.flowchart_edge_trace = Some(FlowchartEdgeTraceRequest {
747 edge_id: edge_id.into(),
748 collector,
749 });
750 self
751 }
752
753 pub(crate) fn flowchart_edge_trace(&self) -> Option<(&str, &FlowchartEdgeTraceCollector)> {
754 self.flowchart_edge_trace
755 .as_ref()
756 .map(|trace| (trace.edge_id.as_str(), &trace.collector))
757 }
758}
759
760#[cfg_attr(
761 not(feature = "all-diagrams"),
762 allow(
763 dead_code,
764 reason = "Shared SVG execution support is used by the selected family emitters."
765 )
766)]
767pub(crate) struct SvgExecution<'a> {
768 request: &'a SvgRenderOptions,
769 session: &'a RenderSession,
770 text_measurer: RoutedTextMeasurer<'a>,
771 diagram_id_projection: SvgDiagramIdProjection<'a>,
772 timing: timing::RenderTiming,
773 pub(crate) debug: &'a SvgDebugOptions,
774}
775
776struct SvgDiagramIdProjection<'a> {
777 work_meter: &'a crate::resources::OperationWorkMeter,
778 projected_bytes: std::cell::Cell<usize>,
779 error: std::cell::RefCell<Option<Error>>,
780}
781
782impl SvgDiagramIdProjection<'_> {
783 fn write(&self, value: &str, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
784 if self.error.borrow().is_some() {
785 return Ok(());
786 }
787 let Some(projected_bytes) = self.projected_bytes.get().checked_add(value.len()) else {
788 self.error.replace(Some(
789 self.work_meter
790 .terminate_svg_byte_count_overflow(
791 crate::resources::ResourceLimitPhase::SvgOutput,
792 OperationPhase::Emit,
793 )
794 .into(),
795 ));
796 return Ok(());
797 };
798 match self.work_meter.preflight_svg_byte_count(
799 projected_bytes,
800 crate::resources::ResourceLimitPhase::SvgOutput,
801 OperationPhase::Emit,
802 ) {
803 Ok(()) => {
804 self.projected_bytes.set(projected_bytes);
805 formatter.write_str(value)
806 }
807 Err(error) => {
808 self.error.replace(Some(error.into()));
809 Ok(())
810 }
811 }
812 }
813
814 fn finish(&self) -> Result<()> {
815 match self.error.borrow_mut().take() {
816 Some(error) => Err(error),
817 None => Ok(()),
818 }
819 }
820}
821
822#[derive(Clone, Copy)]
828pub(super) struct SvgDiagramId<'a> {
829 value: &'a str,
830 projection: &'a SvgDiagramIdProjection<'a>,
831}
832
833impl<'a> SvgDiagramId<'a> {
834 pub(super) fn semantic_str(self) -> &'a str {
835 self.value
836 }
837}
838
839impl std::fmt::Debug for SvgDiagramId<'_> {
840 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
841 formatter
842 .debug_tuple("SvgDiagramId")
843 .field(&self.value)
844 .finish()
845 }
846}
847
848impl std::fmt::Display for SvgDiagramId<'_> {
849 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
850 self.projection.write(self.value, formatter)
851 }
852}
853
854#[cfg_attr(
855 not(feature = "all-diagrams"),
856 allow(
857 dead_code,
858 reason = "Shared SVG execution support is used by the selected family emitters."
859 )
860)]
861pub(super) trait SvgDiagramIdValue: Copy + std::fmt::Display {
862 fn semantic_value(&self) -> &str;
863}
864
865impl SvgDiagramIdValue for SvgDiagramId<'_> {
866 fn semantic_value(&self) -> &str {
867 self.value
868 }
869}
870
871impl SvgDiagramIdValue for &str {
872 fn semantic_value(&self) -> &str {
873 self
874 }
875}
876
877#[derive(Default)]
878#[cfg_attr(
879 not(feature = "all-diagrams"),
880 allow(
881 dead_code,
882 reason = "Shared SVG execution support is used by the selected family emitters."
883 )
884)]
885struct SvgComponentByteCounter {
886 bytes: usize,
887 overflowed: bool,
888}
889
890impl std::fmt::Write for SvgComponentByteCounter {
891 fn write_str(&mut self, value: &str) -> std::fmt::Result {
892 let Some(bytes) = self.bytes.checked_add(value.len()) else {
893 self.overflowed = true;
894 return Err(std::fmt::Error);
895 };
896 self.bytes = bytes;
897 Ok(())
898 }
899}
900
901#[cfg_attr(
902 not(feature = "all-diagrams"),
903 allow(
904 dead_code,
905 reason = "Shared SVG execution support is used by the selected family emitters."
906 )
907)]
908impl<'a> SvgExecution<'a> {
909 fn new(
910 request: &'a SvgRenderOptions,
911 debug: &'a SvgDebugOptions,
912 session: &'a RenderSession,
913 ) -> Result<Self> {
914 let timing = if debug.include_timing_diagnostics {
915 timing::RenderTiming::enabled(
916 session
917 .operation_context()
918 .require_timing()
919 .map_err(Error::from)?,
920 )
921 } else {
922 timing::RenderTiming::disabled()
923 };
924 Ok(Self {
925 request,
926 session,
927 text_measurer: session
928 .controlled_text_measurer(TextMeasurementPhase::SvgBBox, OperationPhase::Emit),
929 diagram_id_projection: SvgDiagramIdProjection {
930 work_meter: session.work_meter().as_ref(),
931 projected_bytes: std::cell::Cell::new(0),
932 error: std::cell::RefCell::new(None),
933 },
934 timing,
935 debug,
936 })
937 }
938
939 pub(super) fn diagram_id_or<'execution>(
940 &'execution self,
941 fallback: &'static str,
942 ) -> SvgDiagramId<'execution> {
943 SvgDiagramId {
944 value: self.request.diagram_id.as_deref().unwrap_or(fallback),
945 projection: &self.diagram_id_projection,
946 }
947 }
948
949 pub(super) fn has_explicit_diagram_id(&self) -> bool {
950 self.request.diagram_id.is_some()
951 }
952
953 fn finish_diagram_id_projection(&self) -> Result<()> {
954 self.diagram_id_projection.finish()
955 }
956
957 pub(crate) fn text_measurer(&self) -> &dyn TextMeasurer {
958 &self.text_measurer
959 }
960
961 pub(crate) fn text_measurer_for(&self, phase: TextMeasurementPhase) -> RoutedTextMeasurer<'_> {
962 self.session
963 .controlled_text_measurer(phase, OperationPhase::Emit)
964 }
965
966 pub(crate) fn math_renderer(&self) -> Option<&(dyn crate::math::MathRenderer + Send + Sync)> {
967 self.session.math_renderer()
968 }
969
970 pub(crate) fn icon_registry(&self) -> Option<&super::icon_registry::IconRegistry> {
971 self.session.icon_registry()
972 }
973
974 pub(crate) fn unix_ms(&self) -> i64 {
975 self.session.unix_millis()
976 }
977
978 pub(crate) fn local_time_zone(&self) -> &merman_core::time::LocalTimeZone {
979 self.session.local_time_zone()
980 }
981
982 pub(crate) fn seed(&self) -> u64 {
983 self.session.render_seed().get()
984 }
985
986 pub(crate) fn rough_randomness(
987 &self,
988 configured_seed: f64,
989 owner_domain: &str,
990 ) -> roughr::core::RoughRandomness {
991 let resolved_seed = if configured_seed == 0.0 {
992 self.seed() as f64
993 } else {
994 configured_seed
995 };
996 let operation = self.session.operation_context();
997 roughr::core::RoughRandomness::new(
998 roughr::core::RoughJsSeed::new(resolved_seed),
999 roughr::core::RoughMathRandom::new(operation.derive_u64(owner_domain, 0)),
1000 )
1001 }
1002
1003 pub(crate) fn timing(&self) -> timing::RenderTiming {
1004 self.timing
1005 }
1006
1007 pub(crate) fn work_meter(&self) -> &crate::resources::OperationWorkMeter {
1008 self.session.work_meter().as_ref()
1009 }
1010
1011 pub(crate) fn checkpoint_emit(&self) -> Result<()> {
1016 self.work_meter()
1017 .checkpoint(OperationPhase::Emit)
1018 .map_err(Into::into)
1019 }
1020
1021 fn materialize_counted_svg_component(
1029 &self,
1030 component_name: &'static str,
1031 count_component: impl Fn(&mut dyn std::fmt::Write) -> std::fmt::Result,
1032 write_component: impl Fn(&mut dyn std::fmt::Write) -> std::fmt::Result,
1033 ) -> Result<String> {
1034 if self
1035 .work_meter()
1036 .policy()
1037 .value(crate::resources::ResourceLimitId::MaxSvgBytes)
1038 .is_none()
1039 {
1040 let mut output = String::new();
1041 write_component(&mut output).map_err(|_| Error::InvalidModel {
1042 message: format!("failed to materialize {component_name}"),
1043 })?;
1044 return Ok(output);
1045 }
1046
1047 let mut counter = SvgComponentByteCounter::default();
1048 let projection = count_component(&mut counter);
1049 if counter.overflowed {
1050 return Err(self
1051 .work_meter()
1052 .terminate_svg_byte_count_overflow(
1053 crate::resources::ResourceLimitPhase::SvgOutput,
1054 OperationPhase::Emit,
1055 )
1056 .into());
1057 }
1058 projection.map_err(|_| Error::InvalidModel {
1059 message: format!("failed to count {component_name}"),
1060 })?;
1061 let projected_bytes = counter.bytes;
1062 self.work_meter()
1063 .preflight_svg_byte_count(
1064 projected_bytes,
1065 crate::resources::ResourceLimitPhase::SvgOutput,
1066 OperationPhase::Emit,
1067 )
1068 .map_err(Error::from)?;
1069
1070 let mut output = String::new();
1071 output
1072 .try_reserve_exact(projected_bytes)
1073 .map_err(|error| Error::InvalidModel {
1074 message: format!("failed to allocate {component_name}: {error}"),
1075 })?;
1076 write_component(&mut output).map_err(|_| Error::InvalidModel {
1077 message: format!("failed to materialize {component_name}"),
1078 })?;
1079 if output.len() != projected_bytes {
1080 return Err(Error::InvalidModel {
1081 message: format!(
1082 "{component_name} byte projection drifted: projected {projected_bytes} bytes but materialized {}",
1083 output.len()
1084 ),
1085 });
1086 }
1087 Ok(output)
1088 }
1089}
1090
1091impl std::ops::Deref for SvgExecution<'_> {
1092 type Target = SvgRenderOptions;
1093
1094 fn deref(&self) -> &Self::Target {
1095 self.request
1096 }
1097}
1098
1099#[cfg(test)]
1100pub(crate) fn with_test_svg_execution<T>(
1101 request: &SvgRenderOptions,
1102 run: impl FnOnce(&SvgExecution<'_>) -> T,
1103) -> T {
1104 let session = crate::environment::RenderEnvironment::deterministic()
1105 .begin_session()
1106 .expect("create test render session");
1107 let debug = SvgDebugOptions::default();
1108 let execution = SvgExecution::new(request, &debug, &session)
1109 .expect("default test SVG execution does not request timing");
1110 run(&execution)
1111}
1112
1113pub(crate) fn render_builtin_family_artifact(
1114 family: &crate::family::BuiltinFamilyArtifact,
1115 metadata: &merman_core::ParseMetadata,
1116 session: &RenderSession,
1117 options: &SvgRenderOptions,
1118 debug: &SvgDebugOptions,
1119) -> Result<String> {
1120 let execution = SvgExecution::new(options, debug, session)?;
1121 let rooted_svg = render_builtin_family_artifact_raw(family, metadata, &execution);
1122 execution.finish_diagram_id_projection()?;
1123 let rooted_svg = rooted_svg?;
1124 let svg = rooted_svg.into_string_for(family.kind())?;
1125 apply_theme_css(svg, metadata.effective_config.as_value(), session)
1126}
1127
1128#[cfg(all(feature = "layout-cytoscape", feature = "diagram-architecture"))]
1129#[inline(never)]
1130pub(crate) fn render_architecture_family_artifact(
1131 pair: &crate::family::FamilyPair<
1132 merman_core::diagrams::architecture::ArchitectureDiagramRenderModel,
1133 ArchitectureDiagramLayout,
1134 >,
1135 effective_config: &merman_core::MermaidConfig,
1136 session: &RenderSession,
1137 options: &SvgRenderOptions,
1138 debug: &SvgDebugOptions,
1139) -> Result<String> {
1140 let execution = SvgExecution::new(options, debug, session)?;
1143 let rooted_svg = architecture::render_architecture_diagram_svg_typed_with_config(
1144 pair.layout(),
1145 pair.semantic(),
1146 effective_config,
1147 &execution,
1148 );
1149 execution.finish_diagram_id_projection()?;
1150 let rooted_svg = rooted_svg?;
1151 let svg = rooted_svg.into_string_for(crate::family::RenderFamilyKind::Architecture)?;
1152 apply_theme_css(svg, effective_config.as_value(), session)
1153}
1154
1155#[allow(
1156 unused_variables,
1157 reason = "Dispatch inputs depend on the selected diagram families."
1158)]
1159fn render_builtin_family_artifact_raw(
1160 family: &crate::family::BuiltinFamilyArtifact,
1161 metadata: &merman_core::ParseMetadata,
1162 options: &SvgExecution<'_>,
1163) -> Result<root_svg::RootedSvg> {
1164 use crate::family::BuiltinFamilyArtifact;
1165
1166 let measurer = options.text_measurer();
1167 let effective_config = &metadata.effective_config;
1168 let effective_config_value = effective_config.as_value();
1169 let title = metadata.title.as_deref();
1170
1171 match family {
1172 BuiltinFamilyArtifact::Error(pair) => error::render_error_diagram_svg_model(
1173 pair.layout(),
1174 pair.semantic(),
1175 effective_config_value,
1176 options,
1177 ),
1178 #[cfg(all(feature = "layout-cytoscape", feature = "diagram-architecture"))]
1179 #[cfg(feature = "diagram-architecture")]
1180 BuiltinFamilyArtifact::Architecture(pair) => {
1181 architecture::render_architecture_diagram_svg_typed_with_config(
1182 pair.layout(),
1183 pair.semantic(),
1184 effective_config,
1185 options,
1186 )
1187 }
1188 #[cfg(any(feature = "diagram-flowchart", feature = "diagram-swimlane"))]
1189 BuiltinFamilyArtifact::Flowchart(artifact) => {
1190 flowchart::render_flowchart_svg_artifact(artifact, metadata, options)
1191 }
1192 #[cfg(feature = "diagram-agentflow")]
1193 BuiltinFamilyArtifact::Agentflow { flow, .. } => {
1194 flowchart::render_flowchart_svg_artifact(flow, metadata, options)
1195 }
1196 #[cfg(any(feature = "diagram-flowchart", feature = "diagram-swimlane"))]
1197 BuiltinFamilyArtifact::Swimlane(artifact) => {
1198 flowchart::render_swimlane_svg_artifact(artifact, metadata, options)
1199 }
1200 #[cfg(feature = "diagram-cynefin")]
1201 BuiltinFamilyArtifact::Cynefin(pair) => cynefin::render_cynefin_diagram_svg_model(
1202 pair.layout(),
1203 pair.semantic(),
1204 effective_config_value,
1205 title,
1206 options,
1207 ),
1208 #[cfg(feature = "diagram-wardley")]
1209 BuiltinFamilyArtifact::Wardley(pair) => wardley::render_wardley_diagram_svg_model(
1210 pair.layout(),
1211 pair.semantic(),
1212 effective_config_value,
1213 title,
1214 options,
1215 ),
1216 #[cfg(feature = "diagram-railroad")]
1217 BuiltinFamilyArtifact::Railroad(pair) => railroad::render_railroad_diagram_svg_model(
1218 pair.layout(),
1219 pair.semantic(),
1220 effective_config_value,
1221 measurer,
1222 options,
1223 ),
1224 #[cfg(feature = "diagram-mindmap")]
1225 BuiltinFamilyArtifact::Mindmap(pair) => {
1226 mindmap::render_mindmap_diagram_svg_model_with_config(
1227 pair.layout(),
1228 pair.semantic(),
1229 effective_config,
1230 options,
1231 )
1232 }
1233 #[cfg(feature = "diagram-state")]
1234 BuiltinFamilyArtifact::State(pair) => state::render_state_diagram_svg_model(
1235 pair.layout(),
1236 pair.semantic(),
1237 effective_config_value,
1238 title,
1239 measurer,
1240 options,
1241 ),
1242 #[cfg(feature = "diagram-class")]
1243 BuiltinFamilyArtifact::Class(pair) => class::render_class_diagram_svg_model_with_config(
1244 pair.layout(),
1245 pair.semantic(),
1246 effective_config,
1247 title,
1248 measurer,
1249 options,
1250 ),
1251 #[cfg(feature = "diagram-sequence")]
1252 BuiltinFamilyArtifact::Sequence(pair) => {
1253 sequence::render_sequence_diagram_svg_model_with_config(
1254 pair.layout(),
1255 pair.semantic(),
1256 effective_config,
1257 title,
1258 measurer,
1259 options,
1260 )
1261 }
1262 #[cfg(feature = "diagram-zenuml")]
1263 BuiltinFamilyArtifact::Zenuml(pair) => zenuml::render_zenuml_diagram_svg_model(
1264 pair.layout(),
1265 pair.semantic(),
1266 effective_config_value,
1267 title,
1268 options,
1269 ),
1270 #[cfg(feature = "diagram-kanban")]
1271 BuiltinFamilyArtifact::Kanban(pair) => {
1272 kanban::render_kanban_diagram_svg(pair.layout(), effective_config, options)
1273 }
1274 #[cfg(feature = "diagram-gantt")]
1275 BuiltinFamilyArtifact::Gantt(pair) => gantt::render_gantt_diagram_svg_model(
1276 pair.layout(),
1277 pair.semantic(),
1278 effective_config_value,
1279 options,
1280 ),
1281 #[cfg(feature = "diagram-pie")]
1282 BuiltinFamilyArtifact::Pie(pair) => pie::render_pie_diagram_svg_model(
1283 pair.layout(),
1284 pair.semantic(),
1285 effective_config_value,
1286 options,
1287 ),
1288 #[cfg(feature = "diagram-packet")]
1289 BuiltinFamilyArtifact::Packet(pair) => packet::render_packet_diagram_svg_model(
1290 pair.layout(),
1291 pair.semantic(),
1292 effective_config_value,
1293 title,
1294 options,
1295 ),
1296 #[cfg(feature = "diagram-timeline")]
1297 BuiltinFamilyArtifact::Timeline(pair) => timeline::render_timeline_diagram_svg_model(
1298 pair.layout(),
1299 pair.semantic(),
1300 effective_config_value,
1301 title,
1302 measurer,
1303 options,
1304 ),
1305 #[cfg(feature = "diagram-journey")]
1306 BuiltinFamilyArtifact::Journey(pair) => journey::render_journey_diagram_svg_model(
1307 pair.layout(),
1308 pair.semantic(),
1309 effective_config_value,
1310 title,
1311 measurer,
1312 options,
1313 ),
1314 #[cfg(feature = "diagram-requirement")]
1315 BuiltinFamilyArtifact::Requirement(pair) => {
1316 requirement::render_requirement_diagram_svg_model(
1317 pair.layout(),
1318 pair.semantic(),
1319 effective_config,
1320 title,
1321 measurer,
1322 options,
1323 )
1324 }
1325 #[cfg(feature = "diagram-usecase")]
1326 BuiltinFamilyArtifact::Usecase(pair) => usecase::render_usecase_diagram_svg_model(
1327 pair.layout(),
1328 pair.semantic(),
1329 effective_config,
1330 title,
1331 measurer,
1332 options,
1333 ),
1334 #[cfg(feature = "diagram-sankey")]
1335 BuiltinFamilyArtifact::Sankey(pair) => {
1336 sankey::render_sankey_diagram_svg(pair.layout(), effective_config_value, options)
1337 }
1338 #[cfg(feature = "diagram-radar")]
1339 BuiltinFamilyArtifact::Radar(pair) => radar::render_radar_diagram_svg_model(
1340 pair.layout(),
1341 pair.semantic(),
1342 effective_config_value,
1343 title,
1344 options,
1345 ),
1346 #[cfg(feature = "diagram-info")]
1347 BuiltinFamilyArtifact::Info(pair) => {
1348 info::render_info_diagram_svg(pair.layout(), effective_config_value, options)
1349 }
1350 #[cfg(feature = "diagram-treemap")]
1351 BuiltinFamilyArtifact::Treemap(pair) => {
1352 treemap::render_treemap_diagram_svg(pair.layout(), effective_config_value, options)
1353 }
1354 #[cfg(feature = "diagram-venn")]
1355 BuiltinFamilyArtifact::Venn(pair) => venn::render_venn_diagram_svg_model(
1356 pair.layout(),
1357 pair.semantic(),
1358 effective_config_value,
1359 title,
1360 options,
1361 ),
1362 #[cfg(feature = "diagram-block")]
1363 BuiltinFamilyArtifact::Block(pair) => block::render_block_diagram_svg_model(
1364 pair.layout(),
1365 pair.semantic(),
1366 effective_config_value,
1367 options,
1368 ),
1369 #[cfg(feature = "diagram-er")]
1370 BuiltinFamilyArtifact::Er(pair) => er::render_er_diagram_svg_model(
1371 pair.layout(),
1372 pair.semantic(),
1373 effective_config_value,
1374 title,
1375 measurer,
1376 options,
1377 ),
1378 #[cfg(feature = "diagram-quadrant-chart")]
1379 BuiltinFamilyArtifact::QuadrantChart(pair) => {
1380 quadrantchart::render_quadrantchart_diagram_svg(
1381 pair.layout(),
1382 pair.semantic(),
1383 effective_config_value,
1384 options,
1385 )
1386 }
1387 #[cfg(feature = "diagram-xychart")]
1388 BuiltinFamilyArtifact::XyChart(pair) => xychart::render_xychart_diagram_svg(
1389 pair.layout(),
1390 pair.semantic(),
1391 effective_config_value,
1392 options,
1393 ),
1394 #[cfg(feature = "diagram-git-graph")]
1395 BuiltinFamilyArtifact::GitGraph(pair) => gitgraph::render_gitgraph_diagram_svg_model(
1396 pair.layout(),
1397 pair.semantic(),
1398 effective_config_value,
1399 title,
1400 measurer,
1401 options,
1402 ),
1403 #[cfg(feature = "diagram-tree-view")]
1404 BuiltinFamilyArtifact::TreeView(pair) => tree_view::render_tree_view_diagram_svg_model(
1405 pair.layout(),
1406 pair.semantic(),
1407 effective_config,
1408 options,
1409 ),
1410 #[cfg(feature = "diagram-ishikawa")]
1411 BuiltinFamilyArtifact::Ishikawa(pair) => {
1412 ishikawa::render_ishikawa_diagram_svg(pair.layout(), effective_config_value, options)
1413 }
1414 #[cfg(feature = "diagram-event-modeling")]
1415 BuiltinFamilyArtifact::EventModeling(pair) => {
1416 eventmodeling::render_eventmodeling_diagram_svg(
1417 pair.layout(),
1418 pair.semantic(),
1419 effective_config_value,
1420 options,
1421 )
1422 }
1423 #[cfg(feature = "diagram-c4")]
1424 BuiltinFamilyArtifact::C4(pair) => c4::render_c4_diagram_svg_typed(
1425 pair.layout(),
1426 pair.semantic(),
1427 effective_config_value,
1428 title,
1429 measurer,
1430 options,
1431 ),
1432 }
1433}
1434
1435fn apply_theme_css(
1436 svg: String,
1437 effective_config: &serde_json::Value,
1438 session: &RenderSession,
1439) -> Result<String> {
1440 const UNBALANCED_CSS_ERROR: &str = "{ /* ERROR: Unbalanced CSS */ }";
1441
1442 let Some(theme_css) = effective_config
1443 .get("themeCSS")
1444 .and_then(serde_json::Value::as_str)
1445 .map(str::trim)
1446 .filter(|css| !css.is_empty() && *css != UNBALANCED_CSS_ERROR)
1447 else {
1448 return Ok(svg);
1449 };
1450
1451 let metadata = SvgPostprocessMetadata::from_svg_with_execution(
1452 &svg,
1453 SvgPostprocessExecution::new(session),
1454 )?;
1455 let pipeline = SvgPipeline::parity()
1456 .with_postprocessor(ScopedCssPostprocessor::new(theme_css).with_existing_style_merge());
1457 pipeline.process_to_string_with_metadata(&svg, &metadata, session)
1458}
1459
1460#[cfg_attr(
1461 not(feature = "all-diagrams"),
1462 allow(
1463 dead_code,
1464 reason = "Shared SVG execution support is used by the selected family emitters."
1465 )
1466)]
1467fn curve_basis_path_d(points: &[crate::model::LayoutPoint]) -> String {
1468 curve::curve_basis_path_d(points)
1469}
1470
1471#[cfg_attr(
1472 not(feature = "all-diagrams"),
1473 allow(
1474 dead_code,
1475 reason = "Shared SVG execution support is used by the selected family emitters."
1476 )
1477)]
1478fn compute_layout_bounds(
1479 clusters: &[LayoutCluster],
1480 nodes: &[LayoutNode],
1481 edges: &[crate::model::LayoutEdge],
1482) -> Option<Bounds> {
1483 layout_debug::compute_layout_bounds(clusters, nodes, edges)
1484}
1485
1486#[cfg(test)]
1487mod operation_time_tests {
1488 use super::*;
1489
1490 #[test]
1491 fn svg_execution_uses_the_operation_session_time() {
1492 let session = crate::environment::RenderEnvironment::deterministic()
1493 .with_runtime_policy(
1494 merman_core::runtime::RuntimePolicy::deterministic().with_fixed_unix_millis(1_000),
1495 )
1496 .begin_session()
1497 .expect("begin render session");
1498 let request = SvgRenderOptions::default();
1499 let debug = SvgDebugOptions::default();
1500 let execution = SvgExecution::new(&request, &debug, &session).expect("SVG execution");
1501
1502 assert_eq!(execution.unix_ms(), session.unix_millis());
1503 }
1504
1505 #[test]
1506 fn svg_execution_preserves_truthy_javascript_number_seeds() {
1507 let session = crate::environment::RenderEnvironment::deterministic()
1508 .begin_session()
1509 .expect("begin render session");
1510 let request = SvgRenderOptions::default();
1511 let debug = SvgDebugOptions::default();
1512 let execution = SvgExecution::new(&request, &debug, &session).expect("SVG execution");
1513
1514 for seed in [
1515 serde_json::json!(-1),
1516 serde_json::json!(1.75),
1517 serde_json::json!(4_294_967_297_u64),
1518 ] {
1519 let seed = seed.as_f64().expect("numeric seed");
1520 assert_eq!(
1521 execution
1522 .rough_randomness(seed, "render.test.roughjs")
1523 .seed()
1524 .number(),
1525 seed
1526 );
1527 }
1528 }
1529
1530 #[test]
1531 fn svg_execution_resolves_falsy_seed_and_shared_math_random_stream() {
1532 let session = crate::environment::RenderEnvironment::deterministic()
1533 .begin_session()
1534 .expect("begin render session");
1535 let request = SvgRenderOptions::default();
1536 let debug = SvgDebugOptions::default();
1537 let execution = SvgExecution::new(&request, &debug, &session).expect("SVG execution");
1538
1539 for seed in [0.0, -0.0] {
1540 assert_eq!(
1541 execution
1542 .rough_randomness(seed, "render.test.roughjs")
1543 .seed()
1544 .number(),
1545 execution.seed() as f64
1546 );
1547 }
1548
1549 let randomness = execution.rough_randomness(0.0, "render.test.roughjs");
1550 assert_eq!(
1551 randomness.math_random().initial_seed(),
1552 session
1553 .operation_context()
1554 .derive_u64("render.test.roughjs", 0)
1555 );
1556 assert_ne!(
1557 randomness.math_random().initial_seed(),
1558 execution
1559 .rough_randomness(0.0, "render.other.roughjs")
1560 .math_random()
1561 .initial_seed()
1562 );
1563 }
1564}
1565
1566#[cfg(test)]
1567mod diagram_id_projection_tests {
1568 use super::*;
1569 use crate::resources::{RenderResourcePolicy, ResourceLimitId};
1570
1571 fn bounded_execution(maximum: usize) -> (RenderSession, SvgDebugOptions) {
1572 let policy = RenderResourcePolicy::unbounded_for_trusted_input()
1573 .with_limit(ResourceLimitId::MaxSvgBytes, maximum)
1574 .expect("valid SVG byte ceiling");
1575 let session = crate::environment::RenderEnvironment::deterministic()
1576 .with_resource_policy(policy)
1577 .begin_session()
1578 .expect("begin render session");
1579 let debug = SvgDebugOptions::default();
1580 (session, debug)
1581 }
1582
1583 #[test]
1584 fn controlled_diagram_id_normalization_matches_the_public_sanitizer() {
1585 let session = crate::environment::RenderEnvironment::deterministic()
1586 .begin_session()
1587 .expect("begin render session");
1588 for raw in [
1589 "",
1590 " ",
1591 "diagram",
1592 "1diagram",
1593 "--",
1594 "a.b:c",
1595 "éclair",
1596 " a---b ",
1597 "m",
1598 "\u{2003}a:b\u{2003}",
1599 ] {
1600 let request = SvgRenderOptions {
1601 diagram_id: Some(raw.to_string()),
1602 ..SvgRenderOptions::default()
1603 };
1604 let normalized = normalize_svg_render_options(&request, &session)
1605 .expect("controlled normalization succeeds");
1606 assert_eq!(
1607 normalized.diagram_id.as_deref(),
1608 Some(sanitize_svg_id(raw).as_str()),
1609 "raw diagram id {raw:?}"
1610 );
1611 }
1612 }
1613
1614 #[test]
1615 fn controlled_diagram_id_scan_observes_preexisting_cancellation() {
1616 let control = merman_core::OperationControl::new();
1617 control.cancel();
1618 let session = crate::environment::RenderEnvironment::deterministic()
1619 .begin_session_with_control(control)
1620 .expect("begin render session");
1621 let request = SvgRenderOptions {
1622 diagram_id: Some("a".repeat(SVG_DIAGRAM_ID_SCAN_CHECKPOINT_BYTES * 2)),
1623 ..SvgRenderOptions::default()
1624 };
1625
1626 let error = normalize_svg_render_options(&request, &session)
1627 .expect_err("cancelled normalization must stop before allocation");
1628 let Error::Cancelled(cancelled) = error else {
1629 panic!("expected structured cancellation");
1630 };
1631 assert_eq!(cancelled.phase, OperationPhase::Emit);
1632 }
1633
1634 #[test]
1635 fn diagram_id_occurrences_enforce_exact_n_and_n_minus_one() {
1636 let request = SvgRenderOptions {
1637 diagram_id: Some("abc".to_string()),
1638 ..SvgRenderOptions::default()
1639 };
1640
1641 let (session, debug) = bounded_execution(6);
1642 let execution = SvgExecution::new(&request, &debug, &session).expect("SVG execution");
1643 let diagram_id = execution.diagram_id_or("fallback");
1644 assert_eq!(format!("{diagram_id}{diagram_id}"), "abcabc");
1645 execution
1646 .finish_diagram_id_projection()
1647 .expect("exact diagram-id contribution is admitted");
1648
1649 let (session, debug) = bounded_execution(5);
1650 let execution = SvgExecution::new(&request, &debug, &session).expect("SVG execution");
1651 let diagram_id = execution.diagram_id_or("fallback");
1652 assert_eq!(format!("{diagram_id}"), "abc");
1653 assert_eq!(format!("{diagram_id}"), "");
1654 let error = execution
1655 .finish_diagram_id_projection()
1656 .expect_err("N-1 must reject the second occurrence");
1657 let Error::ResourceLimitExceeded(details) = error else {
1658 panic!("expected SVG byte resource rejection");
1659 };
1660 assert_eq!(details.limit, "max_svg_bytes");
1661 assert_eq!(details.actual, 6);
1662 assert_eq!(details.max, 5);
1663 assert_eq!(
1664 details.phase,
1665 crate::resources::ResourceLimitPhase::SvgOutput
1666 );
1667 }
1668
1669 #[test]
1670 fn diagram_id_projection_terminal_is_replayed_at_emit_boundaries() {
1671 let request = SvgRenderOptions {
1672 diagram_id: Some("abc".to_string()),
1673 ..SvgRenderOptions::default()
1674 };
1675 let (session, debug) = bounded_execution(2);
1676 let execution = SvgExecution::new(&request, &debug, &session).expect("SVG execution");
1677 let diagram_id = execution.diagram_id_or("fallback");
1678 assert_eq!(format!("{diagram_id}"), "");
1679
1680 let error = execution
1681 .checkpoint_emit()
1682 .expect_err("a failed ID projection must stop the next emit boundary");
1683 let Error::ResourceLimitExceeded(details) = error else {
1684 panic!("expected SVG byte resource rejection");
1685 };
1686 assert_eq!(details.limit, "max_svg_bytes");
1687 assert_eq!(details.actual, 3);
1688 assert_eq!(details.max, 2);
1689 assert_eq!(
1690 details.phase,
1691 crate::resources::ResourceLimitPhase::SvgOutput
1692 );
1693 }
1694
1695 #[test]
1696 fn reusable_scoped_id_adapter_projects_each_output_occurrence() {
1697 let request = SvgRenderOptions {
1698 diagram_id: Some("abc".to_string()),
1699 ..SvgRenderOptions::default()
1700 };
1701 let (session, debug) = bounded_execution(5);
1702 let execution = SvgExecution::new(&request, &debug, &session).expect("SVG execution");
1703 let marker_url = scoped_svg_url(execution.diagram_id_or("fallback"), "arrowhead");
1704
1705 assert_eq!(format!("{marker_url}"), "url(#abc-arrowhead)");
1706 assert_eq!(format!("{marker_url}"), "url(#-arrowhead)");
1707 let error = execution
1708 .finish_diagram_id_projection()
1709 .expect_err("reusing a derived ID must charge the diagram ID again");
1710 let Error::ResourceLimitExceeded(details) = error else {
1711 panic!("expected SVG byte resource rejection");
1712 };
1713 assert_eq!(details.limit, "max_svg_bytes");
1714 assert_eq!(details.actual, 6);
1715 assert_eq!(details.max, 5);
1716 }
1717
1718 #[test]
1719 fn repeated_drop_shadow_references_project_each_diagram_id_occurrence() {
1720 let request = SvgRenderOptions {
1721 diagram_id: Some("abc".to_string()),
1722 ..SvgRenderOptions::default()
1723 };
1724 let (session, debug) = bounded_execution(5);
1725 let execution = SvgExecution::new(&request, &debug, &session).expect("SVG execution");
1726 let drop_shadow = scoped_drop_shadow(
1727 execution.diagram_id_or("fallback"),
1728 "url(#drop-shadow) url(#drop-shadow)",
1729 );
1730
1731 assert_eq!(
1732 format!("{drop_shadow}"),
1733 "url(#abc-drop-shadow) url(#-drop-shadow)"
1734 );
1735 let error = execution
1736 .finish_diagram_id_projection()
1737 .expect_err("N-1 must reject the repeated themed URL reference");
1738 let Error::ResourceLimitExceeded(details) = error else {
1739 panic!("expected SVG byte resource rejection");
1740 };
1741 assert_eq!(details.limit, "max_svg_bytes");
1742 assert_eq!(details.actual, 6);
1743 assert_eq!(details.max, 5);
1744 }
1745}