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) -> 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    // Keep the deep-group Architecture path out of the heterogeneous dispatcher so it fits in
1141    // the renderer's supported low-stack worker budget.
1142    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}