Skip to main content

rusty_mermaid_svg/
lib.rs

1//! SVG rendering backend for rusty-mermaid.
2//!
3//! Converts a [`Scene`] into an SVG string by
4//! walking each primitive and emitting the corresponding SVG element. Marker
5//! definitions are generated per-color so arrow heads match their edge stroke.
6//!
7//! Implements the [`Renderer`] trait from core
8//! (`Output = String`).
9//!
10//! # Key types
11//!
12//! * [`SvgRenderer`] -- the rendering backend.
13//! * [`SvgConfig`] -- padding, default stroke color/width.
14//!   Use [`SvgConfig::from_theme`] to derive settings from a
15//!   [`Theme`].
16//!
17//! [`SvgRenderer::render_themed`] adds a background `<rect>` when the theme
18//! background is not white (e.g. dark themes).
19//!
20//! # Examples
21//!
22//! ```
23//! use rusty_mermaid_core::{Renderer, Scene};
24//! use rusty_mermaid_svg::SvgRenderer;
25//!
26//! let scene = Scene::new(200.0, 100.0);
27//! let svg: String = SvgRenderer::new().render(&scene);
28//! assert!(svg.contains("<svg"));
29//! ```
30
31pub 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/// SVG-specific rendering configuration.
44#[derive(Debug, Clone)]
45pub struct SvgConfig {
46    /// Padding around the diagram (pixels on each side).
47    pub padding: f64,
48    /// Default stroke color for paths/arcs that don't specify one.
49    pub default_stroke: Color,
50    /// Default stroke width for paths that don't specify one.
51    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
70/// SVG rendering backend. Converts a Scene to an SVG string.
71pub 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    /// Render a scene with theme-derived config and optional background rect.
93    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        // Emit per-color marker defs
101        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        // Background rect (for dark theme or non-white backgrounds)
109        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        // Emit per-color marker defs
156        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        // Wrap everything in a group with padding offset
164        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        // Width should be 100 + 40 = 140, height 50 + 40 = 90
247        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}