1pub mod document;
32pub mod markers;
33pub mod path;
34pub mod primitive;
35pub mod style;
36
37use rusty_mermaid_core::{Color, Renderer, Scene, Theme};
38
39use document::SvgDocument;
40use markers::marker_defs;
41use primitive::{collect_marker_colors, render_primitive};
42
43#[derive(Debug, Clone)]
45pub struct SvgConfig {
46 pub padding: f64,
48 pub default_stroke: Color,
50 pub default_stroke_width: f64,
52}
53
54impl Default for SvgConfig {
55 fn default() -> Self {
56 Self::from_theme(&Theme::default())
57 }
58}
59
60impl SvgConfig {
61 pub fn from_theme(theme: &Theme) -> Self {
62 Self {
63 padding: theme.padding,
64 default_stroke: theme.edge_stroke,
65 default_stroke_width: theme.default_stroke_width,
66 }
67 }
68}
69
70pub struct SvgRenderer {
72 pub config: SvgConfig,
73}
74
75impl SvgRenderer {
76 pub fn new() -> Self {
77 Self {
78 config: SvgConfig::default(),
79 }
80 }
81
82 pub fn with_config(config: SvgConfig) -> Self {
83 Self { config }
84 }
85
86 pub fn with_theme(theme: &Theme) -> Self {
87 Self {
88 config: SvgConfig::from_theme(theme),
89 }
90 }
91
92 pub fn render_themed(&self, scene: &Scene, theme: &Theme) -> String {
94 let padding = self.config.padding;
95 let w = scene.width + padding * 2.0;
96 let h = scene.height + padding * 2.0;
97
98 let mut doc = SvgDocument::new(w, h);
99
100 let marker_colors = collect_marker_colors(scene.elements(), &self.config);
102 if !marker_colors.is_empty() {
103 doc.open_tag("defs", &[]);
104 doc.raw(&marker_defs(&marker_colors));
105 doc.close_tag("defs");
106 }
107
108 if theme.background != Color::WHITE {
110 let bg_hex = format!(
111 "#{:02x}{:02x}{:02x}",
112 theme.background.r, theme.background.g, theme.background.b
113 );
114 doc.open_tag(
115 "rect",
116 &[
117 ("width", "100%"),
118 ("height", "100%"),
119 ("fill", &bg_hex),
120 ],
121 );
122 doc.close_tag("rect");
123 }
124
125 let tx = document::fmt_f64(padding);
126 let ty = document::fmt_f64(padding);
127 let transform = format!("translate({tx}, {ty})");
128 doc.open_tag("g", &[("transform", &transform)]);
129
130 for elem in scene.elements() {
131 render_primitive(&mut doc, &elem.primitive, &self.config);
132 }
133
134 doc.close_tag("g");
135 doc.finish()
136 }
137}
138
139impl Default for SvgRenderer {
140 fn default() -> Self {
141 Self::new()
142 }
143}
144
145impl Renderer for SvgRenderer {
146 type Output = String;
147
148 fn render(&self, scene: &Scene) -> String {
149 let padding = self.config.padding;
150 let w = scene.width + padding * 2.0;
151 let h = scene.height + padding * 2.0;
152
153 let mut doc = SvgDocument::new(w, h);
154
155 let marker_colors = collect_marker_colors(scene.elements(), &self.config);
157 if !marker_colors.is_empty() {
158 doc.open_tag("defs", &[]);
159 doc.raw(&marker_defs(&marker_colors));
160 doc.close_tag("defs");
161 }
162
163 let tx = document::fmt_f64(padding);
165 let ty = document::fmt_f64(padding);
166 let transform = format!("translate({tx}, {ty})");
167 doc.open_tag("g", &[("transform", &transform)]);
168
169 for elem in scene.elements() {
170 render_primitive(&mut doc, &elem.primitive, &self.config);
171 }
172
173 doc.close_tag("g");
174 doc.finish()
175 }
176}
177
178#[cfg(test)]
179mod tests {
180 use rusty_mermaid_core::*;
181
182 use super::*;
183
184 #[test]
185 fn render_empty_scene() {
186 let scene = Scene::new(100.0, 100.0);
187 let svg = SvgRenderer::new().render(&scene);
188 assert!(svg.contains("<svg"));
189 assert!(svg.contains("</svg>"));
190 assert!(svg.contains("viewBox"));
191 }
192
193 #[test]
194 fn render_scene_with_rect_and_text() {
195 let mut scene = Scene::new(200.0, 100.0);
196 scene.push(Primitive::Rect {
197 bbox: BBox::new(100.0, 50.0, 80.0, 40.0),
198 rx: 0.0,
199 ry: 0.0,
200 style: Style {
201 fill: Some(Color::WHITE),
202 stroke: Some(Color::BLACK),
203 stroke_width: Some(1.0),
204 ..Default::default()
205 },
206 });
207 scene.push(Primitive::Text {
208 position: Point::new(100.0, 50.0),
209 content: "Hello".into(),
210 anchor: TextAnchor::Middle,
211 style: TextStyle::default(),
212 });
213
214 let svg = SvgRenderer::new().render(&scene);
215 assert!(svg.contains("<rect"));
216 assert!(svg.contains("<text"));
217 assert!(svg.contains("Hello"));
218 }
219
220 #[test]
221 fn render_scene_with_path_marker() {
222 let mut scene = Scene::new(200.0, 100.0);
223 scene.push(Primitive::Path {
224 segments: vec![
225 PathSegment::MoveTo(Point::new(10.0, 50.0)),
226 PathSegment::LineTo(Point::new(190.0, 50.0)),
227 ],
228 style: Style {
229 stroke: Some(Color::rgb(51, 51, 51)),
230 ..Default::default()
231 },
232 marker_start: None,
233 marker_end: Some(MarkerType::ArrowPoint),
234 });
235
236 let svg = SvgRenderer::new().render(&scene);
237 assert!(svg.contains("<defs>"));
238 assert!(svg.contains("arrow-point-333333"));
239 assert!(svg.contains("marker-end"));
240 }
241
242 #[test]
243 fn render_includes_padding() {
244 let scene = Scene::new(100.0, 50.0);
245 let svg = SvgRenderer::new().render(&scene);
246 assert!(svg.contains(r#"width="140""#));
248 assert!(svg.contains(r#"height="90""#));
249 }
250
251 #[test]
252 fn custom_padding() {
253 let scene = Scene::new(100.0, 50.0);
254 let renderer = SvgRenderer::with_config(SvgConfig {
255 padding: 10.0,
256 ..Default::default()
257 });
258 let svg = renderer.render(&scene);
259 assert!(svg.contains(r#"width="120""#));
260 assert!(svg.contains(r#"height="70""#));
261 }
262
263 #[test]
264 fn per_color_marker_defs() {
265 let mut scene = Scene::new(200.0, 200.0);
266 scene.push(Primitive::Path {
267 segments: vec![
268 PathSegment::MoveTo(Point::new(0.0, 0.0)),
269 PathSegment::LineTo(Point::new(100.0, 0.0)),
270 ],
271 style: Style {
272 stroke: Some(Color::rgb(255, 0, 0)),
273 ..Default::default()
274 },
275 marker_start: None,
276 marker_end: Some(MarkerType::ArrowPoint),
277 });
278 scene.push(Primitive::Path {
279 segments: vec![
280 PathSegment::MoveTo(Point::new(0.0, 50.0)),
281 PathSegment::LineTo(Point::new(100.0, 50.0)),
282 ],
283 style: Style {
284 stroke: Some(Color::rgb(0, 128, 0)),
285 ..Default::default()
286 },
287 marker_start: None,
288 marker_end: Some(MarkerType::ArrowPoint),
289 });
290 let svg = SvgRenderer::new().render(&scene);
291 assert!(svg.contains("arrow-point-ff0000"), "red marker def");
292 assert!(svg.contains("arrow-point-008000"), "green marker def");
293 }
294
295 #[test]
296 fn marker_color_matches_edge_stroke() {
297 let mut scene = Scene::new(200.0, 100.0);
298 scene.push(Primitive::Path {
299 segments: vec![
300 PathSegment::MoveTo(Point::new(0.0, 0.0)),
301 PathSegment::LineTo(Point::new(100.0, 0.0)),
302 ],
303 style: Style {
304 stroke: Some(Color::rgb(147, 112, 219)),
305 ..Default::default()
306 },
307 marker_start: None,
308 marker_end: Some(MarkerType::ArrowPoint),
309 });
310 let svg = SvgRenderer::new().render(&scene);
311 assert!(
312 svg.contains("arrow-point-9370db"),
313 "marker ID should include edge color"
314 );
315 assert!(svg.contains(r#"marker-end="url(#arrow-point-9370db)""#));
316 }
317}