Skip to main content

merman_render/svg/
parity.rs

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
349/// Converts arbitrary host input into the conservative SVG/CSS identifier grammar used by every
350/// family renderer.
351///
352/// The result is safe to interpolate directly after `#` in generated stylesheets without CSS
353/// escaping changing its selector meaning.
354pub 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    /// Adds extra space around the computed viewBox.
414    pub viewbox_padding: f64,
415    /// Optional diagram id used for Mermaid-like marker ids.
416    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/// A point captured while diagnosing one flowchart edge route.
633#[derive(Debug, Clone, PartialEq, serde::Serialize)]
634pub struct FlowchartEdgeTracePoint {
635    pub x: f64,
636    pub y: f64,
637}
638
639/// An in-memory diagnostic record for one flowchart edge route.
640///
641/// The renderer never serializes this value or writes it to a host filesystem. Callers that need
642/// a file own that I/O boundary and can serialize a drained record themselves.
643#[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/// Explicit, caller-owned storage for flowchart route diagnostics.
664///
665/// Clones share one collection so a caller can retain a handle while a render owns a clone. A
666/// poisoned lock is recovered because trace collection is diagnostic-only and must not turn a
667/// completed render into an ambient host failure.
668#[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    /// Returns a snapshot without consuming the collected records.
680    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    /// Drains all records collected so far.
688    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/// An opaque flowchart trace request created by [`SvgDebugOptions::with_flowchart_edge_trace`].
699#[derive(Debug, Clone)]
700pub struct FlowchartEdgeTraceRequest {
701    edge_id: String,
702    collector: FlowchartEdgeTraceCollector,
703}
704
705/// Diagnostic visibility controls kept separate from production render requests.
706#[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    /// Optional caller-owned trace collection request.
715    ///
716    /// Use [`SvgDebugOptions::with_flowchart_edge_trace`] to construct a non-empty request. The
717    /// field remains publicly updateable so existing `SvgDebugOptions { ..Default::default() }`
718    /// callers keep their normal Rust struct-update ergonomics.
719    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    /// Captures diagnostics for one edge in caller-owned memory.
738    ///
739    /// This replaces the former implicit current-working-directory trace file. Hosts that want a
740    /// file must drain `collector` after rendering and perform their own checked I/O.
741    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/// A normalized diagram identifier whose output occurrences are admitted before they are written.
823///
824/// The raw string is available only for semantic inputs such as deterministic seeds and ownership
825/// checks. SVG, CSS, URL, ARIA, and marker output must format this value so the operation observes
826/// the cumulative identifier contribution before caller-controlled fanout can grow the document.
827#[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    /// Replays a terminal observed while an SVG ID was being projected.
1012    ///
1013    /// Family emitters call this at bounded loop boundaries so an early SVG-byte rejection or
1014    /// cancellation stops the tail of a high-fanout render instead of waiting for finalization.
1015    pub(crate) fn checkpoint_emit(&self) -> Result<()> {
1016        self.work_meter()
1017            .checkpoint(OperationPhase::Emit)
1018            .map_err(Into::into)
1019    }
1020
1021    /// Counts a retained SVG component through its production writer, admits the exact byte count,
1022    /// and only then allocates and materializes it.
1023    ///
1024    /// The counting pass owns no output buffer, so authored/config-sized content is not cloned just
1025    /// to establish the bound. The final whole-document check remains the authoritative
1026    /// `MaxSvgBytes` admission; this earlier absolute preflight prevents one amplified component
1027    /// from allocating beyond the same ceiling and is not accumulated a second time.
1028    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    edge_paint_geometry: Option<&mut Vec<crate::model::EdgePaintGeometry>>,
1120) -> Result<String> {
1121    let execution = SvgExecution::new(options, debug, session)?;
1122    let rooted_svg =
1123        render_builtin_family_artifact_raw(family, metadata, &execution, edge_paint_geometry);
1124    execution.finish_diagram_id_projection()?;
1125    let rooted_svg = rooted_svg?;
1126    let svg = rooted_svg.into_string_for(family.kind())?;
1127    apply_theme_css(svg, metadata.effective_config.as_value(), session)
1128}
1129
1130#[cfg(all(feature = "layout-cytoscape", feature = "diagram-architecture"))]
1131#[inline(never)]
1132pub(crate) fn render_architecture_family_artifact(
1133    pair: &crate::family::FamilyPair<
1134        merman_core::diagrams::architecture::ArchitectureDiagramRenderModel,
1135        ArchitectureDiagramLayout,
1136    >,
1137    effective_config: &merman_core::MermaidConfig,
1138    session: &RenderSession,
1139    options: &SvgRenderOptions,
1140    debug: &SvgDebugOptions,
1141) -> Result<String> {
1142    // Keep the deep-group Architecture path out of the heterogeneous dispatcher so it fits in
1143    // the renderer's supported low-stack worker budget.
1144    let execution = SvgExecution::new(options, debug, session)?;
1145    let rooted_svg = architecture::render_architecture_diagram_svg_typed_with_config(
1146        pair.layout(),
1147        pair.semantic(),
1148        effective_config,
1149        &execution,
1150    );
1151    execution.finish_diagram_id_projection()?;
1152    let rooted_svg = rooted_svg?;
1153    let svg = rooted_svg.into_string_for(crate::family::RenderFamilyKind::Architecture)?;
1154    apply_theme_css(svg, effective_config.as_value(), session)
1155}
1156
1157#[allow(
1158    unused_variables,
1159    reason = "Dispatch inputs depend on the selected diagram families."
1160)]
1161fn render_builtin_family_artifact_raw(
1162    family: &crate::family::BuiltinFamilyArtifact,
1163    metadata: &merman_core::ParseMetadata,
1164    options: &SvgExecution<'_>,
1165    edge_paint_geometry: Option<&mut Vec<crate::model::EdgePaintGeometry>>,
1166) -> Result<root_svg::RootedSvg> {
1167    use crate::family::BuiltinFamilyArtifact;
1168
1169    let measurer = options.text_measurer();
1170    let effective_config = &metadata.effective_config;
1171    let effective_config_value = effective_config.as_value();
1172    let title = metadata.title.as_deref();
1173
1174    match family {
1175        BuiltinFamilyArtifact::Error(pair) => error::render_error_diagram_svg_model(
1176            pair.layout(),
1177            pair.semantic(),
1178            effective_config_value,
1179            options,
1180        ),
1181        #[cfg(all(feature = "layout-cytoscape", feature = "diagram-architecture"))]
1182        #[cfg(feature = "diagram-architecture")]
1183        BuiltinFamilyArtifact::Architecture(pair) => {
1184            architecture::render_architecture_diagram_svg_typed_with_config(
1185                pair.layout(),
1186                pair.semantic(),
1187                effective_config,
1188                options,
1189            )
1190        }
1191        #[cfg(any(feature = "diagram-flowchart", feature = "diagram-swimlane"))]
1192        BuiltinFamilyArtifact::Flowchart(artifact) => flowchart::render_flowchart_svg_artifact(
1193            artifact,
1194            metadata,
1195            options,
1196            edge_paint_geometry,
1197        ),
1198        #[cfg(feature = "diagram-agentflow")]
1199        BuiltinFamilyArtifact::Agentflow { flow, .. } => {
1200            flowchart::render_flowchart_svg_artifact(flow, metadata, options, edge_paint_geometry)
1201        }
1202        #[cfg(any(feature = "diagram-flowchart", feature = "diagram-swimlane"))]
1203        BuiltinFamilyArtifact::Swimlane(artifact) => flowchart::render_swimlane_svg_artifact(
1204            artifact,
1205            metadata,
1206            options,
1207            edge_paint_geometry,
1208        ),
1209        #[cfg(feature = "diagram-cynefin")]
1210        BuiltinFamilyArtifact::Cynefin(pair) => cynefin::render_cynefin_diagram_svg_model(
1211            pair.layout(),
1212            pair.semantic(),
1213            effective_config_value,
1214            title,
1215            options,
1216        ),
1217        #[cfg(feature = "diagram-wardley")]
1218        BuiltinFamilyArtifact::Wardley(pair) => wardley::render_wardley_diagram_svg_model(
1219            pair.layout(),
1220            pair.semantic(),
1221            effective_config_value,
1222            title,
1223            options,
1224        ),
1225        #[cfg(feature = "diagram-railroad")]
1226        BuiltinFamilyArtifact::Railroad(pair) => railroad::render_railroad_diagram_svg_model(
1227            pair.layout(),
1228            pair.semantic(),
1229            effective_config_value,
1230            measurer,
1231            options,
1232        ),
1233        #[cfg(feature = "diagram-mindmap")]
1234        BuiltinFamilyArtifact::Mindmap(pair) => {
1235            mindmap::render_mindmap_diagram_svg_model_with_config(
1236                pair.layout(),
1237                pair.semantic(),
1238                effective_config,
1239                options,
1240            )
1241        }
1242        #[cfg(feature = "diagram-state")]
1243        BuiltinFamilyArtifact::State(pair) => state::render_state_diagram_svg_model(
1244            pair.layout(),
1245            pair.semantic(),
1246            effective_config_value,
1247            title,
1248            measurer,
1249            options,
1250        ),
1251        #[cfg(feature = "diagram-class")]
1252        BuiltinFamilyArtifact::Class(pair) => class::render_class_diagram_svg_model_with_config(
1253            pair.layout(),
1254            pair.semantic(),
1255            effective_config,
1256            title,
1257            measurer,
1258            options,
1259        ),
1260        #[cfg(feature = "diagram-sequence")]
1261        BuiltinFamilyArtifact::Sequence(pair) => {
1262            sequence::render_sequence_diagram_svg_model_with_config(
1263                pair.layout(),
1264                pair.semantic(),
1265                effective_config,
1266                title,
1267                measurer,
1268                options,
1269            )
1270        }
1271        #[cfg(feature = "diagram-zenuml")]
1272        BuiltinFamilyArtifact::Zenuml(pair) => zenuml::render_zenuml_diagram_svg_model(
1273            pair.layout(),
1274            pair.semantic(),
1275            effective_config_value,
1276            title,
1277            options,
1278        ),
1279        #[cfg(feature = "diagram-kanban")]
1280        BuiltinFamilyArtifact::Kanban(pair) => {
1281            kanban::render_kanban_diagram_svg(pair.layout(), effective_config, options)
1282        }
1283        #[cfg(feature = "diagram-gantt")]
1284        BuiltinFamilyArtifact::Gantt(pair) => gantt::render_gantt_diagram_svg_model(
1285            pair.layout(),
1286            pair.semantic(),
1287            effective_config_value,
1288            options,
1289        ),
1290        #[cfg(feature = "diagram-pie")]
1291        BuiltinFamilyArtifact::Pie(pair) => pie::render_pie_diagram_svg_model(
1292            pair.layout(),
1293            pair.semantic(),
1294            effective_config_value,
1295            options,
1296        ),
1297        #[cfg(feature = "diagram-packet")]
1298        BuiltinFamilyArtifact::Packet(pair) => packet::render_packet_diagram_svg_model(
1299            pair.layout(),
1300            pair.semantic(),
1301            effective_config_value,
1302            title,
1303            options,
1304        ),
1305        #[cfg(feature = "diagram-timeline")]
1306        BuiltinFamilyArtifact::Timeline(pair) => timeline::render_timeline_diagram_svg_model(
1307            pair.layout(),
1308            pair.semantic(),
1309            effective_config_value,
1310            title,
1311            measurer,
1312            options,
1313        ),
1314        #[cfg(feature = "diagram-journey")]
1315        BuiltinFamilyArtifact::Journey(pair) => journey::render_journey_diagram_svg_model(
1316            pair.layout(),
1317            pair.semantic(),
1318            effective_config_value,
1319            title,
1320            measurer,
1321            options,
1322        ),
1323        #[cfg(feature = "diagram-requirement")]
1324        BuiltinFamilyArtifact::Requirement(pair) => {
1325            requirement::render_requirement_diagram_svg_model(
1326                pair.layout(),
1327                pair.semantic(),
1328                effective_config,
1329                title,
1330                measurer,
1331                options,
1332            )
1333        }
1334        #[cfg(feature = "diagram-usecase")]
1335        BuiltinFamilyArtifact::Usecase(pair) => usecase::render_usecase_diagram_svg_model(
1336            pair.layout(),
1337            pair.semantic(),
1338            effective_config,
1339            title,
1340            measurer,
1341            options,
1342        ),
1343        #[cfg(feature = "diagram-sankey")]
1344        BuiltinFamilyArtifact::Sankey(pair) => {
1345            sankey::render_sankey_diagram_svg(pair.layout(), effective_config_value, options)
1346        }
1347        #[cfg(feature = "diagram-radar")]
1348        BuiltinFamilyArtifact::Radar(pair) => radar::render_radar_diagram_svg_model(
1349            pair.layout(),
1350            pair.semantic(),
1351            effective_config_value,
1352            title,
1353            options,
1354        ),
1355        #[cfg(feature = "diagram-info")]
1356        BuiltinFamilyArtifact::Info(pair) => {
1357            info::render_info_diagram_svg(pair.layout(), effective_config_value, options)
1358        }
1359        #[cfg(feature = "diagram-treemap")]
1360        BuiltinFamilyArtifact::Treemap(pair) => {
1361            treemap::render_treemap_diagram_svg(pair.layout(), effective_config_value, options)
1362        }
1363        #[cfg(feature = "diagram-venn")]
1364        BuiltinFamilyArtifact::Venn(pair) => venn::render_venn_diagram_svg_model(
1365            pair.layout(),
1366            pair.semantic(),
1367            effective_config_value,
1368            title,
1369            options,
1370        ),
1371        #[cfg(feature = "diagram-block")]
1372        BuiltinFamilyArtifact::Block(pair) => block::render_block_diagram_svg_model(
1373            pair.layout(),
1374            pair.semantic(),
1375            effective_config_value,
1376            options,
1377        ),
1378        #[cfg(feature = "diagram-er")]
1379        BuiltinFamilyArtifact::Er(pair) => er::render_er_diagram_svg_model(
1380            pair.layout(),
1381            pair.semantic(),
1382            effective_config_value,
1383            title,
1384            measurer,
1385            options,
1386        ),
1387        #[cfg(feature = "diagram-quadrant-chart")]
1388        BuiltinFamilyArtifact::QuadrantChart(pair) => {
1389            quadrantchart::render_quadrantchart_diagram_svg(
1390                pair.layout(),
1391                pair.semantic(),
1392                effective_config_value,
1393                options,
1394            )
1395        }
1396        #[cfg(feature = "diagram-xychart")]
1397        BuiltinFamilyArtifact::XyChart(pair) => xychart::render_xychart_diagram_svg(
1398            pair.layout(),
1399            pair.semantic(),
1400            effective_config_value,
1401            options,
1402        ),
1403        #[cfg(feature = "diagram-git-graph")]
1404        BuiltinFamilyArtifact::GitGraph(pair) => gitgraph::render_gitgraph_diagram_svg_model(
1405            pair.layout(),
1406            pair.semantic(),
1407            effective_config_value,
1408            title,
1409            measurer,
1410            options,
1411        ),
1412        #[cfg(feature = "diagram-tree-view")]
1413        BuiltinFamilyArtifact::TreeView(pair) => tree_view::render_tree_view_diagram_svg_model(
1414            pair.layout(),
1415            pair.semantic(),
1416            effective_config,
1417            options,
1418        ),
1419        #[cfg(feature = "diagram-ishikawa")]
1420        BuiltinFamilyArtifact::Ishikawa(pair) => {
1421            ishikawa::render_ishikawa_diagram_svg(pair.layout(), effective_config_value, options)
1422        }
1423        #[cfg(feature = "diagram-event-modeling")]
1424        BuiltinFamilyArtifact::EventModeling(pair) => {
1425            eventmodeling::render_eventmodeling_diagram_svg(
1426                pair.layout(),
1427                pair.semantic(),
1428                effective_config_value,
1429                options,
1430            )
1431        }
1432        #[cfg(feature = "diagram-c4")]
1433        BuiltinFamilyArtifact::C4(pair) => c4::render_c4_diagram_svg_typed(
1434            pair.layout(),
1435            pair.semantic(),
1436            effective_config_value,
1437            title,
1438            measurer,
1439            options,
1440        ),
1441    }
1442}
1443
1444fn apply_theme_css(
1445    svg: String,
1446    effective_config: &serde_json::Value,
1447    session: &RenderSession,
1448) -> Result<String> {
1449    const UNBALANCED_CSS_ERROR: &str = "{ /* ERROR: Unbalanced CSS */ }";
1450
1451    let Some(theme_css) = effective_config
1452        .get("themeCSS")
1453        .and_then(serde_json::Value::as_str)
1454        .map(str::trim)
1455        .filter(|css| !css.is_empty() && *css != UNBALANCED_CSS_ERROR)
1456    else {
1457        return Ok(svg);
1458    };
1459
1460    let metadata = SvgPostprocessMetadata::from_svg_with_execution(
1461        &svg,
1462        SvgPostprocessExecution::new(session),
1463    )?;
1464    let pipeline = SvgPipeline::parity()
1465        .with_postprocessor(ScopedCssPostprocessor::new(theme_css).with_existing_style_merge());
1466    pipeline.process_to_string_with_metadata(&svg, &metadata, session)
1467}
1468
1469#[cfg_attr(
1470    not(feature = "all-diagrams"),
1471    allow(
1472        dead_code,
1473        reason = "Shared SVG execution support is used by the selected family emitters."
1474    )
1475)]
1476fn curve_basis_path_d(points: &[crate::model::LayoutPoint]) -> String {
1477    curve::curve_basis_path_d(points)
1478}
1479
1480#[cfg_attr(
1481    not(feature = "all-diagrams"),
1482    allow(
1483        dead_code,
1484        reason = "Shared SVG execution support is used by the selected family emitters."
1485    )
1486)]
1487fn compute_layout_bounds(
1488    clusters: &[LayoutCluster],
1489    nodes: &[LayoutNode],
1490    edges: &[crate::model::LayoutEdge],
1491) -> Option<Bounds> {
1492    layout_debug::compute_layout_bounds(clusters, nodes, edges)
1493}
1494
1495#[cfg(test)]
1496mod operation_time_tests {
1497    use super::*;
1498
1499    #[test]
1500    fn svg_execution_uses_the_operation_session_time() {
1501        let session = crate::environment::RenderEnvironment::deterministic()
1502            .with_runtime_policy(
1503                merman_core::runtime::RuntimePolicy::deterministic().with_fixed_unix_millis(1_000),
1504            )
1505            .begin_session()
1506            .expect("begin render session");
1507        let request = SvgRenderOptions::default();
1508        let debug = SvgDebugOptions::default();
1509        let execution = SvgExecution::new(&request, &debug, &session).expect("SVG execution");
1510
1511        assert_eq!(execution.unix_ms(), session.unix_millis());
1512    }
1513
1514    #[test]
1515    fn svg_execution_preserves_truthy_javascript_number_seeds() {
1516        let session = crate::environment::RenderEnvironment::deterministic()
1517            .begin_session()
1518            .expect("begin render session");
1519        let request = SvgRenderOptions::default();
1520        let debug = SvgDebugOptions::default();
1521        let execution = SvgExecution::new(&request, &debug, &session).expect("SVG execution");
1522
1523        for seed in [
1524            serde_json::json!(-1),
1525            serde_json::json!(1.75),
1526            serde_json::json!(4_294_967_297_u64),
1527        ] {
1528            let seed = seed.as_f64().expect("numeric seed");
1529            assert_eq!(
1530                execution
1531                    .rough_randomness(seed, "render.test.roughjs")
1532                    .seed()
1533                    .number(),
1534                seed
1535            );
1536        }
1537    }
1538
1539    #[test]
1540    fn svg_execution_resolves_falsy_seed_and_shared_math_random_stream() {
1541        let session = crate::environment::RenderEnvironment::deterministic()
1542            .begin_session()
1543            .expect("begin render session");
1544        let request = SvgRenderOptions::default();
1545        let debug = SvgDebugOptions::default();
1546        let execution = SvgExecution::new(&request, &debug, &session).expect("SVG execution");
1547
1548        for seed in [0.0, -0.0] {
1549            assert_eq!(
1550                execution
1551                    .rough_randomness(seed, "render.test.roughjs")
1552                    .seed()
1553                    .number(),
1554                execution.seed() as f64
1555            );
1556        }
1557
1558        let randomness = execution.rough_randomness(0.0, "render.test.roughjs");
1559        assert_eq!(
1560            randomness.math_random().initial_seed(),
1561            session
1562                .operation_context()
1563                .derive_u64("render.test.roughjs", 0)
1564        );
1565        assert_ne!(
1566            randomness.math_random().initial_seed(),
1567            execution
1568                .rough_randomness(0.0, "render.other.roughjs")
1569                .math_random()
1570                .initial_seed()
1571        );
1572    }
1573}
1574
1575#[cfg(test)]
1576mod diagram_id_projection_tests {
1577    use super::*;
1578    use crate::resources::{RenderResourcePolicy, ResourceLimitId};
1579
1580    fn bounded_execution(maximum: usize) -> (RenderSession, SvgDebugOptions) {
1581        let policy = RenderResourcePolicy::unbounded_for_trusted_input()
1582            .with_limit(ResourceLimitId::MaxSvgBytes, maximum)
1583            .expect("valid SVG byte ceiling");
1584        let session = crate::environment::RenderEnvironment::deterministic()
1585            .with_resource_policy(policy)
1586            .begin_session()
1587            .expect("begin render session");
1588        let debug = SvgDebugOptions::default();
1589        (session, debug)
1590    }
1591
1592    #[test]
1593    fn controlled_diagram_id_normalization_matches_the_public_sanitizer() {
1594        let session = crate::environment::RenderEnvironment::deterministic()
1595            .begin_session()
1596            .expect("begin render session");
1597        for raw in [
1598            "",
1599            "   ",
1600            "diagram",
1601            "1diagram",
1602            "--",
1603            "a.b:c",
1604            "éclair",
1605            "  a---b  ",
1606            "m",
1607            "\u{2003}a:b\u{2003}",
1608        ] {
1609            let request = SvgRenderOptions {
1610                diagram_id: Some(raw.to_string()),
1611                ..SvgRenderOptions::default()
1612            };
1613            let normalized = normalize_svg_render_options(&request, &session)
1614                .expect("controlled normalization succeeds");
1615            assert_eq!(
1616                normalized.diagram_id.as_deref(),
1617                Some(sanitize_svg_id(raw).as_str()),
1618                "raw diagram id {raw:?}"
1619            );
1620        }
1621    }
1622
1623    #[test]
1624    fn controlled_diagram_id_scan_observes_preexisting_cancellation() {
1625        let control = merman_core::OperationControl::new();
1626        control.cancel();
1627        let session = crate::environment::RenderEnvironment::deterministic()
1628            .begin_session_with_control(control)
1629            .expect("begin render session");
1630        let request = SvgRenderOptions {
1631            diagram_id: Some("a".repeat(SVG_DIAGRAM_ID_SCAN_CHECKPOINT_BYTES * 2)),
1632            ..SvgRenderOptions::default()
1633        };
1634
1635        let error = normalize_svg_render_options(&request, &session)
1636            .expect_err("cancelled normalization must stop before allocation");
1637        let Error::Cancelled(cancelled) = error else {
1638            panic!("expected structured cancellation");
1639        };
1640        assert_eq!(cancelled.phase, OperationPhase::Emit);
1641    }
1642
1643    #[test]
1644    fn diagram_id_occurrences_enforce_exact_n_and_n_minus_one() {
1645        let request = SvgRenderOptions {
1646            diagram_id: Some("abc".to_string()),
1647            ..SvgRenderOptions::default()
1648        };
1649
1650        let (session, debug) = bounded_execution(6);
1651        let execution = SvgExecution::new(&request, &debug, &session).expect("SVG execution");
1652        let diagram_id = execution.diagram_id_or("fallback");
1653        assert_eq!(format!("{diagram_id}{diagram_id}"), "abcabc");
1654        execution
1655            .finish_diagram_id_projection()
1656            .expect("exact diagram-id contribution is admitted");
1657
1658        let (session, debug) = bounded_execution(5);
1659        let execution = SvgExecution::new(&request, &debug, &session).expect("SVG execution");
1660        let diagram_id = execution.diagram_id_or("fallback");
1661        assert_eq!(format!("{diagram_id}"), "abc");
1662        assert_eq!(format!("{diagram_id}"), "");
1663        let error = execution
1664            .finish_diagram_id_projection()
1665            .expect_err("N-1 must reject the second occurrence");
1666        let Error::ResourceLimitExceeded(details) = error else {
1667            panic!("expected SVG byte resource rejection");
1668        };
1669        assert_eq!(details.limit, "max_svg_bytes");
1670        assert_eq!(details.actual, 6);
1671        assert_eq!(details.max, 5);
1672        assert_eq!(
1673            details.phase,
1674            crate::resources::ResourceLimitPhase::SvgOutput
1675        );
1676    }
1677
1678    #[test]
1679    fn diagram_id_projection_terminal_is_replayed_at_emit_boundaries() {
1680        let request = SvgRenderOptions {
1681            diagram_id: Some("abc".to_string()),
1682            ..SvgRenderOptions::default()
1683        };
1684        let (session, debug) = bounded_execution(2);
1685        let execution = SvgExecution::new(&request, &debug, &session).expect("SVG execution");
1686        let diagram_id = execution.diagram_id_or("fallback");
1687        assert_eq!(format!("{diagram_id}"), "");
1688
1689        let error = execution
1690            .checkpoint_emit()
1691            .expect_err("a failed ID projection must stop the next emit boundary");
1692        let Error::ResourceLimitExceeded(details) = error else {
1693            panic!("expected SVG byte resource rejection");
1694        };
1695        assert_eq!(details.limit, "max_svg_bytes");
1696        assert_eq!(details.actual, 3);
1697        assert_eq!(details.max, 2);
1698        assert_eq!(
1699            details.phase,
1700            crate::resources::ResourceLimitPhase::SvgOutput
1701        );
1702    }
1703
1704    #[test]
1705    fn reusable_scoped_id_adapter_projects_each_output_occurrence() {
1706        let request = SvgRenderOptions {
1707            diagram_id: Some("abc".to_string()),
1708            ..SvgRenderOptions::default()
1709        };
1710        let (session, debug) = bounded_execution(5);
1711        let execution = SvgExecution::new(&request, &debug, &session).expect("SVG execution");
1712        let marker_url = scoped_svg_url(execution.diagram_id_or("fallback"), "arrowhead");
1713
1714        assert_eq!(format!("{marker_url}"), "url(#abc-arrowhead)");
1715        assert_eq!(format!("{marker_url}"), "url(#-arrowhead)");
1716        let error = execution
1717            .finish_diagram_id_projection()
1718            .expect_err("reusing a derived ID must charge the diagram ID again");
1719        let Error::ResourceLimitExceeded(details) = error else {
1720            panic!("expected SVG byte resource rejection");
1721        };
1722        assert_eq!(details.limit, "max_svg_bytes");
1723        assert_eq!(details.actual, 6);
1724        assert_eq!(details.max, 5);
1725    }
1726
1727    #[test]
1728    fn repeated_drop_shadow_references_project_each_diagram_id_occurrence() {
1729        let request = SvgRenderOptions {
1730            diagram_id: Some("abc".to_string()),
1731            ..SvgRenderOptions::default()
1732        };
1733        let (session, debug) = bounded_execution(5);
1734        let execution = SvgExecution::new(&request, &debug, &session).expect("SVG execution");
1735        let drop_shadow = scoped_drop_shadow(
1736            execution.diagram_id_or("fallback"),
1737            "url(#drop-shadow) url(#drop-shadow)",
1738        );
1739
1740        assert_eq!(
1741            format!("{drop_shadow}"),
1742            "url(#abc-drop-shadow) url(#-drop-shadow)"
1743        );
1744        let error = execution
1745            .finish_diagram_id_projection()
1746            .expect_err("N-1 must reject the repeated themed URL reference");
1747        let Error::ResourceLimitExceeded(details) = error else {
1748            panic!("expected SVG byte resource rejection");
1749        };
1750        assert_eq!(details.limit, "max_svg_bytes");
1751        assert_eq!(details.actual, 6);
1752        assert_eq!(details.max, 5);
1753    }
1754}