Skip to main content

rusty_mermaid_raster/
lib.rs

1//! Raster (PNG) rendering backend for rusty-mermaid.
2//!
3//! Converts a [`Scene`] into PNG bytes using
4//! [tiny-skia](https://crates.io/crates/tiny-skia) for CPU-based rasterisation.
5//!
6//! Implements the [`Renderer`] trait from core
7//! (`Output = Vec<u8>`).
8//!
9//! # Key types
10//!
11//! * [`RasterRenderer`] -- the rendering backend.
12//! * [`RasterConfig`] -- DPI scale factor and [`Theme`].
13//!   Scale defaults to `2.0` (Retina); set to `1.0` for 1x output.
14//!
15//! # Examples
16//!
17//! ```
18//! use rusty_mermaid_core::{Renderer, Scene};
19//! use rusty_mermaid_raster::RasterRenderer;
20//!
21//! let scene = Scene::new(200.0, 100.0);
22//! let png: Vec<u8> = RasterRenderer::new().render(&scene);
23//! assert_eq!(&png[..4], &[0x89, b'P', b'N', b'G']);
24//! ```
25
26mod primitive;
27
28use rusty_mermaid_core::{Color, Renderer, Scene, Theme};
29
30/// Raster-specific rendering configuration.
31#[derive(Debug, Clone)]
32pub struct RasterConfig {
33    /// DPI scale factor (1.0 = 1x, 2.0 = 2x / Retina).
34    pub scale: f64,
35    /// Theme providing colors, typography, padding, background, and optional custom font.
36    pub theme: Theme,
37}
38
39impl Default for RasterConfig {
40    fn default() -> Self {
41        Self {
42            scale: 2.0,
43            theme: Theme::default(),
44        }
45    }
46}
47
48/// Raster rendering backend. Converts a Scene to PNG bytes.
49pub struct RasterRenderer {
50    pub config: RasterConfig,
51}
52
53impl RasterRenderer {
54    pub fn new() -> Self {
55        Self {
56            config: RasterConfig::default(),
57        }
58    }
59
60    pub fn with_config(config: RasterConfig) -> Self {
61        Self { config }
62    }
63}
64
65impl Default for RasterRenderer {
66    fn default() -> Self {
67        Self::new()
68    }
69}
70
71impl Renderer for RasterRenderer {
72    type Output = Vec<u8>;
73
74    fn render(&self, scene: &Scene) -> Vec<u8> {
75        let theme = &self.config.theme;
76        let padding = theme.padding;
77        let scale = self.config.scale;
78        let w = ((scene.width + padding * 2.0) * scale).ceil() as u32;
79        let h = ((scene.height + padding * 2.0) * scale).ceil() as u32;
80
81        let mut pixmap = tiny_skia::Pixmap::new(w, h).expect("pixmap dimensions must be > 0");
82
83        // Fill background
84        let bg = to_skia_color(theme.background);
85        pixmap.fill(bg);
86
87        // Render all primitives with padding offset applied via transform
88        let offset = tiny_skia::Transform::from_scale(scale as f32, scale as f32)
89            .post_translate(padding as f32 * scale as f32, padding as f32 * scale as f32);
90
91        for elem in scene.elements() {
92            primitive::render_primitive(&mut pixmap, &elem.primitive, offset, theme);
93        }
94
95        encode_png(&pixmap)
96    }
97}
98
99fn to_skia_color(c: Color) -> tiny_skia::Color {
100    tiny_skia::Color::from_rgba8(c.r, c.g, c.b, c.a)
101}
102
103fn encode_png(pixmap: &tiny_skia::Pixmap) -> Vec<u8> {
104    let mut buf = Vec::new();
105    let mut encoder = png::Encoder::new(&mut buf, pixmap.width(), pixmap.height());
106    encoder.set_color(png::ColorType::Rgba);
107    encoder.set_depth(png::BitDepth::Eight);
108    let mut writer = encoder.write_header().expect("PNG header write failed");
109    writer
110        .write_image_data(pixmap.data())
111        .expect("PNG data write failed");
112    writer.finish().expect("PNG finish failed");
113    buf
114}
115
116#[cfg(test)]
117mod tests {
118    use super::*;
119    use rusty_mermaid_core::{BBox, Point, Primitive, Style};
120
121    #[test]
122    fn render_empty_scene() {
123        let renderer = RasterRenderer::new();
124        let scene = Scene::new(100.0, 50.0);
125        let png = renderer.render(&scene);
126        // Valid PNG starts with magic bytes
127        assert_eq!(&png[..4], &[0x89, b'P', b'N', b'G']);
128    }
129
130    #[test]
131    fn render_rect() {
132        let renderer = RasterRenderer::new();
133        let mut scene = Scene::new(200.0, 100.0);
134        scene.push(Primitive::Rect {
135            bbox: BBox::new(100.0, 50.0, 80.0, 40.0),
136            rx: 5.0,
137            ry: 5.0,
138            style: Style {
139                fill: Some(Color::rgb(236, 236, 255)),
140                stroke: Some(Color::rgb(147, 112, 219)),
141                stroke_width: Some(1.5),
142                ..Default::default()
143            },
144        });
145        let png = renderer.render(&scene);
146        assert_eq!(&png[..4], &[0x89, b'P', b'N', b'G']);
147        assert!(png.len() > 100); // non-trivial output
148    }
149
150    #[test]
151    fn render_circle() {
152        let renderer = RasterRenderer::new();
153        let mut scene = Scene::new(100.0, 100.0);
154        scene.push(Primitive::Circle {
155            center: Point::new(50.0, 50.0),
156            radius: 20.0,
157            style: Style {
158                fill: Some(Color::rgb(51, 51, 51)),
159                stroke: Some(Color::rgb(51, 51, 51)),
160                ..Default::default()
161            },
162        });
163        let png = renderer.render(&scene);
164        assert_eq!(&png[..4], &[0x89, b'P', b'N', b'G']);
165    }
166
167    #[test]
168    fn render_path() {
169        use rusty_mermaid_core::PathSegment;
170        let renderer = RasterRenderer::new();
171        let mut scene = Scene::new(200.0, 200.0);
172        scene.push(Primitive::Path {
173            segments: vec![
174                PathSegment::MoveTo(Point::new(10.0, 10.0)),
175                PathSegment::LineTo(Point::new(190.0, 190.0)),
176            ],
177            style: Style {
178                stroke: Some(Color::rgb(51, 51, 51)),
179                stroke_width: Some(1.5),
180                ..Default::default()
181            },
182            marker_start: None,
183            marker_end: None,
184        });
185        let png = renderer.render(&scene);
186        assert_eq!(&png[..4], &[0x89, b'P', b'N', b'G']);
187    }
188
189    #[test]
190    fn scale_affects_pixel_dimensions() {
191        let renderer_1x = RasterRenderer::with_config(RasterConfig {
192            scale: 1.0,
193            ..Default::default()
194        });
195        let renderer_2x = RasterRenderer::with_config(RasterConfig {
196            scale: 2.0,
197            ..Default::default()
198        });
199        let scene = Scene::new(100.0, 50.0);
200        let png_1x = renderer_1x.render(&scene);
201        let png_2x = renderer_2x.render(&scene);
202        // 2x should produce larger output
203        assert!(png_2x.len() > png_1x.len());
204    }
205
206    #[test]
207    fn render_polygon() {
208        let renderer = RasterRenderer::new();
209        let mut scene = Scene::new(100.0, 100.0);
210        scene.push(Primitive::Polygon {
211            points: vec![
212                Point::new(50.0, 10.0),
213                Point::new(90.0, 90.0),
214                Point::new(10.0, 90.0),
215            ],
216            style: Style {
217                fill: Some(Color::rgb(200, 200, 255)),
218                stroke: Some(Color::rgb(100, 100, 200)),
219                stroke_width: Some(2.0),
220                ..Default::default()
221            },
222        });
223        let png = renderer.render(&scene);
224        assert_eq!(&png[..4], &[0x89, b'P', b'N', b'G']);
225    }
226
227    #[test]
228    fn render_text() {
229        use rusty_mermaid_core::{TextAnchor, TextStyle};
230        let renderer = RasterRenderer::new();
231        let mut scene = Scene::new(200.0, 50.0);
232        scene.push(Primitive::Text {
233            position: Point::new(100.0, 25.0),
234            content: String::from("Hello"),
235            anchor: TextAnchor::Middle,
236            style: TextStyle {
237                font_size: 14.0,
238                fill: Some(Color::rgb(51, 51, 51)),
239                ..Default::default()
240            },
241        });
242        let png = renderer.render(&scene);
243        assert_eq!(&png[..4], &[0x89, b'P', b'N', b'G']);
244        // Text rendering produces more pixel data than empty scene
245        let empty_png = RasterRenderer::new().render(&Scene::new(200.0, 50.0));
246        assert_ne!(png, empty_png);
247    }
248
249    #[test]
250    fn render_text_multiline() {
251        use rusty_mermaid_core::{TextAnchor, TextStyle};
252        let renderer = RasterRenderer::new();
253        let mut scene = Scene::new(200.0, 100.0);
254        scene.push(Primitive::Text {
255            position: Point::new(100.0, 50.0),
256            content: String::from("Line 1\nLine 2"),
257            anchor: TextAnchor::Middle,
258            style: TextStyle::default(),
259        });
260        let png = renderer.render(&scene);
261        assert_eq!(&png[..4], &[0x89, b'P', b'N', b'G']);
262    }
263
264    #[test]
265    fn render_full_diagram_to_file() {
266        use rusty_mermaid_core::Renderer;
267        let scene = rusty_mermaid_diagrams::render_to_scene(
268            "stateDiagram-v2\n    [*] --> Active\n    Active --> Paused : pause\n    Paused --> Active : resume\n    Active --> [*] : done",
269            &rusty_mermaid_core::Theme::default(),
270        ).unwrap();
271        let renderer = RasterRenderer::new();
272        let png = renderer.render(&scene);
273        let path = std::env::temp_dir().join("rusty_mermaid_state.png");
274        std::fs::write(&path, &png).unwrap();
275        eprintln!("wrote PNG to {}", path.display());
276        assert!(png.len() > 1000);
277    }
278
279    #[test]
280    fn render_flowchart_to_file() {
281        use rusty_mermaid_core::Renderer;
282        let scene = rusty_mermaid_diagrams::render_to_scene(
283            "flowchart TD\n    A[Start] --> B{Decision}\n    B -->|Yes| C[OK]\n    B -->|No| D[Fail]",
284            &rusty_mermaid_core::Theme::default(),
285        ).unwrap();
286        let renderer = RasterRenderer::new();
287        let png = renderer.render(&scene);
288        let path = std::env::temp_dir().join("rusty_mermaid_flowchart.png");
289        std::fs::write(&path, &png).unwrap();
290        eprintln!("wrote PNG to {}", path.display());
291        assert!(png.len() > 1000);
292    }
293}