Skip to main content

chartml_render/
rasterize.rs

1//! SVG string → PNG bytes rasterization using resvg.
2//!
3//! Uses resvg (pure Rust) for SVG parsing and rasterization.
4//! Supports density/DPI scaling, white background, and configurable padding.
5
6use crate::error::RenderError;
7use std::sync::{Arc, LazyLock};
8
9/// Shared font database — loaded once, reused across all renders.
10static FONT_DB: LazyLock<Arc<fontdb::Database>> = LazyLock::new(|| {
11    let mut db = fontdb::Database::new();
12    // Load system fonts (Liberation Sans, DejaVu, etc.)
13    db.load_system_fonts();
14    Arc::new(db)
15});
16
17/// Rasterize an SVG string to PNG bytes.
18///
19/// # Arguments
20/// * `svg` — SVG XML string
21/// * `width` — target width in CSS pixels
22/// * `height` — target height in CSS pixels
23/// * `density` — DPI scale factor (72 = 1x, 144 = 2x for crisp PDF output)
24/// * `padding` — padding in CSS pixels around the chart
25/// * `background` — RGB background color (e.g. `[255, 255, 255]` for white)
26pub fn svg_to_png(
27    svg: &str,
28    width: u32,
29    height: u32,
30    density: u32,
31    padding: u32,
32    background: [u8; 3],
33) -> Result<Vec<u8>, RenderError> {
34    let scale = density as f32 / 72.0;
35
36    // Parse SVG with font database
37    let options = usvg::Options {
38        font_family: "Inter, Liberation Sans, Arial, sans-serif".to_string(),
39        font_size: 12.0,
40        dpi: density as f32,
41        fontdb: FONT_DB.clone(),
42        ..Default::default()
43    };
44    let tree = usvg::Tree::from_str(svg, &options)
45        .map_err(|e| RenderError::SvgParse(e.to_string()))?;
46
47    // Calculate output dimensions with padding
48    let total_width = ((width + 2 * padding) as f32 * scale).ceil() as u32;
49    let total_height = ((height + 2 * padding) as f32 * scale).ceil() as u32;
50
51    // Create pixel buffer with background
52    let mut pixmap = tiny_skia::Pixmap::new(total_width, total_height)
53        .ok_or_else(|| RenderError::Rasterize("Failed to create pixmap".into()))?;
54
55    // Fill with background color
56    let bg = tiny_skia::Color::from_rgba8(background[0], background[1], background[2], 255);
57    pixmap.fill(bg);
58
59    // Render SVG into the pixmap with padding offset and scale
60    let padding_px = padding as f32 * scale;
61    let chart_width = width as f32 * scale;
62    let chart_height = height as f32 * scale;
63
64    // Scale the SVG to fit within the chart area (excluding padding)
65    let svg_size = tree.size();
66    let scale_x = chart_width / svg_size.width();
67    let scale_y = chart_height / svg_size.height();
68
69    let transform = tiny_skia::Transform::from_translate(padding_px, padding_px)
70        .post_scale(scale_x, scale_y);
71
72    resvg::render(&tree, transform, &mut pixmap.as_mut());
73
74    // Encode to PNG
75    let png_data = pixmap.encode_png()
76        .map_err(|e| RenderError::PngEncode(e.to_string()))?;
77
78    Ok(png_data)
79}
80
81#[cfg(test)]
82mod tests {
83    use super::*;
84
85    fn simple_svg() -> String {
86        concat!(
87            r#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 100" width="200" height="100">"#,
88            r#"<rect x="10" y="10" width="180" height="80" fill="steelblue"/>"#,
89            r#"</svg>"#,
90        ).to_string()
91    }
92
93    fn circle_svg() -> String {
94        concat!(
95            r#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" width="100" height="100">"#,
96            r#"<circle cx="50" cy="50" r="40" fill="red"/>"#,
97            r#"</svg>"#,
98        ).to_string()
99    }
100
101    #[test]
102    fn renders_simple_svg_to_png() {
103        let svg = simple_svg();
104        let result = svg_to_png(&svg, 200, 100, 72, 16, [255, 255, 255]);
105        assert!(result.is_ok(), "render failed: {:?}", result.err());
106        let png = result.unwrap();
107        // PNG magic bytes
108        assert_eq!(&png[0..4], &[0x89, 0x50, 0x4E, 0x47]);
109        assert!(png.len() > 100);
110    }
111
112    #[test]
113    fn density_2x_produces_larger_output() {
114        let svg = circle_svg();
115        let png_1x = svg_to_png(&svg, 100, 100, 72, 0, [255, 255, 255]).unwrap();
116        let png_2x = svg_to_png(&svg, 100, 100, 144, 0, [255, 255, 255]).unwrap();
117        assert!(png_2x.len() > png_1x.len());
118    }
119
120    #[test]
121    fn padding_increases_dimensions() {
122        let svg = circle_svg();
123        let no_pad = svg_to_png(&svg, 100, 100, 72, 0, [255, 255, 255]).unwrap();
124        let with_pad = svg_to_png(&svg, 100, 100, 72, 16, [255, 255, 255]).unwrap();
125        assert!(with_pad.len() > no_pad.len());
126    }
127
128    #[test]
129    fn invalid_svg_returns_error() {
130        let result = svg_to_png("not valid svg", 100, 100, 72, 0, [255, 255, 255]);
131        assert!(result.is_err());
132    }
133
134    #[test]
135    fn svg_with_text_renders() {
136        let svg = concat!(
137            r#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 100" width="200" height="100">"#,
138            r#"<text x="100" y="50" text-anchor="middle" font-family="Liberation Sans, Arial, sans-serif" font-size="16">Hello World</text>"#,
139            r#"</svg>"#,
140        );
141        let result = svg_to_png(svg, 200, 100, 72, 0, [255, 255, 255]);
142        assert!(result.is_ok(), "text render failed: {:?}", result.err());
143    }
144}