Skip to repository content192 lines · 8.0 KB · rust
tenant.openagents/omega
No repository description is available.
OpenAgents Git authority 2026-07-28T02:34:28.436Z Public web read
NIP-34 coordinate
30617:7649603503856e5148d571eac2766b288a8ff1e9e35d380337a1d2b0015b4f92:omegaMaintainersHidden in public view
References2 branches · 1 tag
Read-only clone
git clone https://openagents.com/git/tenant.openagents/omega.gitBrowse files
render.rs
1use std::sync::atomic::{AtomicU64, Ordering};
2
3use anyhow::{Context as _, Result, anyhow};
4
5use crate::{MermaidTheme, css_color};
6
7pub(super) fn render_mermaid(source: &str, theme: &MermaidTheme) -> Result<String> {
8 static COUNTER: AtomicU64 = AtomicU64::new(0);
9 let id = COUNTER.fetch_add(1, Ordering::Relaxed);
10 let diagram_id = format!("merman-{id}");
11
12 let config = to_merman_config(theme);
13 let renderer = merman::render::HeadlessRenderer::new()
14 .with_site_config(config)
15 .with_vendored_text_measurer()
16 .with_diagram_id(&diagram_id);
17 // Apply merman's raster-safe pipeline before Zed-specific styling. The
18 // pipeline handles generic rasterizer compatibility cleanup: foreignObject
19 // fallback text, unsupported CSS removal, and invalid SVG attribute cleanup.
20 // Zed also strips merman's existing `!important` declarations before
21 // injecting its own theme CSS so host styling wins consistently in usvg/resvg.
22 let pipeline = merman::render::SvgPipeline::resvg_safe()
23 .with_postprocessor(merman::render::CssOverridePostprocessor::strip_existing_important());
24
25 let svg = renderer
26 .render_svg_with_pipeline_sync(source, &pipeline)
27 .context("merman render failed")?
28 .ok_or_else(|| anyhow!("merman returned no SVG for the given input"))?;
29
30 Ok(svg)
31}
32
33fn to_merman_config(theme: &MermaidTheme) -> merman::MermaidConfig {
34 let primary = css_color(theme.primary_color);
35 let primary_text = css_color(theme.primary_text_color);
36 let primary_border = css_color(theme.primary_border_color);
37 let line = css_color(theme.line_color);
38 let secondary = css_color(theme.secondary_color);
39 let tertiary = css_color(theme.tertiary_color);
40 let background = css_color(theme.background);
41 let cluster_bg = css_color(theme.cluster_background);
42 let cluster_border = css_color(theme.cluster_border);
43 let edge_label_bg = css_color(theme.edge_label_background);
44 let text = css_color(theme.text_color);
45 let note_bg = css_color(theme.note_background);
46 let note_border = css_color(theme.note_border);
47 let actor_bg = css_color(theme.actor_background);
48 let actor_border = css_color(theme.actor_border);
49 let activation_bg = css_color(theme.activation_background);
50 let activation_border = css_color(theme.activation_border);
51 let er_odd = css_color(theme.er_attr_bg_odd);
52 let er_even = css_color(theme.er_attr_bg_even);
53 let git: [String; 8] = theme.git_branch_colors.map(css_color);
54 let git_lbl: [String; 8] = theme.git_branch_label_colors.map(css_color);
55
56 let mut theme_vars = serde_json::json!({
57 "primaryColor": primary,
58 "primaryTextColor": primary_text,
59 "primaryBorderColor": primary_border,
60 "lineColor": line,
61 "secondaryColor": secondary,
62 "secondaryTextColor": text,
63 "tertiaryColor": tertiary,
64 "tertiaryTextColor": text,
65 "background": background,
66 "mainBkg": primary,
67 "nodeBorder": primary_border,
68 "nodeTextColor": primary_text,
69 "clusterBkg": cluster_bg,
70 "clusterBorder": cluster_border,
71 "titleColor": text,
72 "edgeLabelBackground": edge_label_bg,
73 "textColor": text,
74 "fontFamily": theme.font_family,
75 "noteBkgColor": note_bg,
76 "noteBorderColor": note_border,
77 "noteTextColor": text,
78 "actorBkg": actor_bg,
79 "actorBorder": actor_border,
80 "actorTextColor": primary_text,
81 "labelTextColor": text,
82 "loopTextColor": text,
83 "signalColor": text,
84 "signalTextColor": text,
85 "activationBkgColor": activation_bg,
86 "activationBorderColor": activation_border,
87 "classText": text,
88 "labelColor": primary_text,
89 "attributeBackgroundColorOdd": er_odd,
90 "attributeBackgroundColorEven": er_even,
91 "pieTitleTextColor": text,
92 "pieSectionTextColor": text,
93 "pieLegendTextColor": text,
94 "pieStrokeColor": primary_border,
95 "pieOuterStrokeColor": primary_border,
96 "quadrant1Fill": primary,
97 "quadrant2Fill": primary,
98 "quadrant3Fill": primary,
99 "quadrant4Fill": primary,
100 "quadrant1TextFill": text,
101 "quadrant2TextFill": text,
102 "quadrant3TextFill": text,
103 "quadrant4TextFill": text,
104 "quadrantPointFill": line,
105 "quadrantPointTextFill": text,
106 "quadrantTitleFill": text,
107 "quadrantXAxisTextFill": text,
108 "quadrantYAxisTextFill": text,
109 "quadrantExternalBorderStrokeFill": primary_border,
110 "quadrantInternalBorderStrokeFill": primary_border,
111 });
112
113 if let Some(map) = theme_vars.as_object_mut() {
114 for (((i, color), label), pie_number) in git.iter().enumerate().zip(&git_lbl).zip(1..) {
115 map.insert(format!("cScale{i}"), color.clone().into());
116 map.insert(format!("cScaleLabel{i}"), label.clone().into());
117 map.insert(format!("pie{pie_number}"), color.clone().into());
118 }
119 }
120
121 merman::MermaidConfig::from_value(serde_json::json!({
122 "theme": "base",
123 "darkMode": theme.dark_mode,
124 "fontFamily": theme.font_family,
125 // resvg can't rasterize HTML `<foreignObject>` labels, and the
126 // fallback that replaces them loses soft wrapping, so emit native SVG
127 // text labels. Nodes read the top-level key; edges read `flowchart`.
128 "htmlLabels": false,
129 "flowchart": {
130 "htmlLabels": false,
131 "padding": 16,
132 },
133 "themeVariables": theme_vars,
134 }))
135}
136
137#[cfg(test)]
138mod tests {
139 use super::*;
140
141 #[test]
142 fn render_stage_applies_resvg_safe_pipeline() {
143 let html_label_source =
144 "classDiagram\n class Shelter {\n -List~Animal~ animals\n }";
145 let html_label_svg =
146 render_mermaid(html_label_source, &MermaidTheme::default()).expect("render failed");
147
148 assert!(
149 !html_label_svg.contains("<foreignObject"),
150 "got: {html_label_svg}"
151 );
152 assert!(
153 !html_label_svg.contains("&lt;"),
154 "got: {html_label_svg}"
155 );
156
157 let css_source = "sequenceDiagram\n Alice->>Bob: Hello\n Bob-->>Alice: Hi";
158 let css_svg = render_mermaid(css_source, &MermaidTheme::default()).expect("render failed");
159
160 assert!(!css_svg.contains("@keyframes"), "got: {css_svg}");
161 assert!(!css_svg.contains("@-webkit-keyframes"), "got: {css_svg}");
162 assert!(!css_svg.contains(":root"), "got: {css_svg}");
163 assert!(!css_svg.contains("animation:"), "got: {css_svg}");
164 assert!(!css_svg.contains("animation-name:"), "got: {css_svg}");
165 assert!(!css_svg.contains("!important"), "got: {css_svg}");
166 }
167
168 /// Soft-wrapped labels must render as native SVG `<tspan>` lines rather
169 /// than the resvg-safe pipeline's single-line `<foreignObject>` fallback,
170 /// which overflows the node box (see the `htmlLabels` comment in
171 /// [`to_merman_config`]). If the fallback marker reappears after a merman
172 /// upgrade, the config has stopped disabling HTML labels.
173 #[test]
174 fn long_labels_render_as_wrapped_svg_text() {
175 let source = "flowchart TD\n \
176 A[\"Pass 2: search transcript with annotation blocks excised, \
177 map offsets back to buffer space\"] --> \
178 |ambiguous or zero| B[\"Error describing where matches were found\"]";
179 let svg = render_mermaid(source, &MermaidTheme::default()).expect("render failed");
180
181 assert!(
182 !svg.contains(r#"data-merman-foreignobject="fallback""#),
183 "labels went through the foreignObject fallback, which loses soft wrapping: {svg}"
184 );
185 let wrapped_line_count = svg.matches("text-outer-tspan").count();
186 assert!(
187 wrapped_line_count > 3,
188 "expected long labels to wrap onto multiple tspan lines, got {wrapped_line_count}: {svg}"
189 );
190 }
191}
192