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//!
6//! ## Font resolution
7//!
8//! The shared font database is lazily initialized with system fonts
9//! (Liberation Sans, DejaVu, etc. on Linux) on first use. Charts that
10//! emit SVG `<text>` elements with named `font-family` values beyond
11//! the system set (e.g. `'Instrument Serif'`, `'DM Sans'`) will silently
12//! fail to render text unless those fonts are registered first.
13//!
14//! To register additional fonts (e.g. compile-time embedded design-system
15//! fonts), call [`init_font_database`] before the first render:
16//!
17//! ```ignore
18//! let dm_sans = include_bytes!("fonts/DMSans-Regular.ttf").to_vec();
19//! chartml_render::init_font_database(vec![dm_sans]);
20//! ```
21//!
22//! If `init_font_database` is not called, the database initializes lazily
23//! with system fonts only — existing consumers see no behavior change.
24
25use crate::error::RenderError;
26use std::sync::{Arc, OnceLock};
27
28/// Shared font database — loaded once, reused across all renders.
29///
30/// First caller to any rasterize function (either `svg_to_png` directly or
31/// indirectly via `render_to_png`) triggers lazy initialization with system
32/// fonts only. To add extra fonts (e.g. design-system TTF bytes), call
33/// [`init_font_database`] BEFORE the first render.
34static FONT_DB: OnceLock<Arc<fontdb::Database>> = OnceLock::new();
35
36/// Initialize the font database with system fonts plus additional font data.
37///
38/// Call this once at startup, before any chart rendering, to register fonts
39/// that aren't installed system-wide (e.g. design-system fonts embedded via
40/// `include_bytes!`). If you call this AFTER the first render has already
41/// triggered lazy initialization, the call has no effect and returns `false`.
42///
43/// Returns `true` if the database was initialized by this call, `false` if
44/// it was already initialized (either by a previous `init_font_database`
45/// call or by lazy init on first render).
46///
47/// # Example
48/// ```ignore
49/// let dm_sans = include_bytes!("../../fonts/DMSans-Regular.ttf").to_vec();
50/// let geist_mono = include_bytes!("../../fonts/GeistMono-Regular.ttf").to_vec();
51/// chartml_render::init_font_database(vec![dm_sans, geist_mono]);
52/// ```
53pub fn init_font_database(extra_fonts: Vec<Vec<u8>>) -> bool {
54    let mut db = fontdb::Database::new();
55    db.load_system_fonts();
56    for font_bytes in extra_fonts {
57        db.load_font_data(font_bytes);
58    }
59    FONT_DB.set(Arc::new(db)).is_ok()
60}
61
62/// Get (or lazily initialize) the shared font database.
63fn get_font_db() -> Arc<fontdb::Database> {
64    FONT_DB
65        .get_or_init(|| {
66            let mut db = fontdb::Database::new();
67            db.load_system_fonts();
68            Arc::new(db)
69        })
70        .clone()
71}
72
73/// Rasterize an SVG string to PNG bytes.
74///
75/// # Arguments
76/// * `svg` — SVG XML string
77/// * `width` — target width in CSS pixels
78/// * `height` — target height in CSS pixels
79/// * `density` — DPI scale factor (72 = 1x, 144 = 2x for crisp PDF output)
80/// * `padding` — padding in CSS pixels around the chart
81/// * `background` — RGB background color (e.g. `[255, 255, 255]` for white)
82pub fn svg_to_png(
83    svg: &str,
84    width: u32,
85    height: u32,
86    density: u32,
87    padding: u32,
88    background: [u8; 3],
89) -> Result<Vec<u8>, RenderError> {
90    let scale = density as f32 / 72.0;
91
92    // Parse SVG with font database
93    let options = usvg::Options {
94        font_family: "Inter, Liberation Sans, Arial, sans-serif".to_string(),
95        font_size: 12.0,
96        dpi: density as f32,
97        fontdb: get_font_db(),
98        ..Default::default()
99    };
100    let tree = usvg::Tree::from_str(svg, &options)
101        .map_err(|e| RenderError::SvgParse(e.to_string()))?;
102
103    // Calculate output dimensions with padding
104    let total_width = ((width + 2 * padding) as f32 * scale).ceil() as u32;
105    let total_height = ((height + 2 * padding) as f32 * scale).ceil() as u32;
106
107    // Create pixel buffer with background
108    let mut pixmap = tiny_skia::Pixmap::new(total_width, total_height)
109        .ok_or_else(|| RenderError::Rasterize("Failed to create pixmap".into()))?;
110
111    // Fill with background color
112    let bg = tiny_skia::Color::from_rgba8(background[0], background[1], background[2], 255);
113    pixmap.fill(bg);
114
115    // Render SVG into the pixmap with padding offset and scale
116    let padding_px = padding as f32 * scale;
117    let chart_width = width as f32 * scale;
118    let chart_height = height as f32 * scale;
119
120    // Scale the SVG to fit within the chart area (excluding padding)
121    let svg_size = tree.size();
122    let scale_x = chart_width / svg_size.width();
123    let scale_y = chart_height / svg_size.height();
124
125    let transform = tiny_skia::Transform::from_translate(padding_px, padding_px)
126        .post_scale(scale_x, scale_y);
127
128    resvg::render(&tree, transform, &mut pixmap.as_mut());
129
130    // Encode to PNG
131    let png_data = pixmap.encode_png()
132        .map_err(|e| RenderError::PngEncode(e.to_string()))?;
133
134    Ok(png_data)
135}
136
137#[cfg(test)]
138mod tests {
139    use super::*;
140
141    fn simple_svg() -> String {
142        concat!(
143            r#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 100" width="200" height="100">"#,
144            r#"<rect x="10" y="10" width="180" height="80" fill="steelblue"/>"#,
145            r#"</svg>"#,
146        ).to_string()
147    }
148
149    fn circle_svg() -> String {
150        concat!(
151            r#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" width="100" height="100">"#,
152            r#"<circle cx="50" cy="50" r="40" fill="red"/>"#,
153            r#"</svg>"#,
154        ).to_string()
155    }
156
157    #[test]
158    fn renders_simple_svg_to_png() {
159        let svg = simple_svg();
160        let result = svg_to_png(&svg, 200, 100, 72, 16, [255, 255, 255]);
161        assert!(result.is_ok(), "render failed: {:?}", result.err());
162        let png = result.unwrap();
163        // PNG magic bytes
164        assert_eq!(&png[0..4], &[0x89, 0x50, 0x4E, 0x47]);
165        assert!(png.len() > 100);
166    }
167
168    #[test]
169    fn density_2x_produces_larger_output() {
170        let svg = circle_svg();
171        let png_1x = svg_to_png(&svg, 100, 100, 72, 0, [255, 255, 255]).unwrap();
172        let png_2x = svg_to_png(&svg, 100, 100, 144, 0, [255, 255, 255]).unwrap();
173        assert!(png_2x.len() > png_1x.len());
174    }
175
176    #[test]
177    fn padding_increases_dimensions() {
178        let svg = circle_svg();
179        let no_pad = svg_to_png(&svg, 100, 100, 72, 0, [255, 255, 255]).unwrap();
180        let with_pad = svg_to_png(&svg, 100, 100, 72, 16, [255, 255, 255]).unwrap();
181        assert!(with_pad.len() > no_pad.len());
182    }
183
184    #[test]
185    fn invalid_svg_returns_error() {
186        let result = svg_to_png("not valid svg", 100, 100, 72, 0, [255, 255, 255]);
187        assert!(result.is_err());
188    }
189
190    #[test]
191    fn svg_with_text_renders() {
192        let svg = concat!(
193            r#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 100" width="200" height="100">"#,
194            r#"<text x="100" y="50" text-anchor="middle" font-family="Liberation Sans, Arial, sans-serif" font-size="16">Hello World</text>"#,
195            r#"</svg>"#,
196        );
197        let result = svg_to_png(svg, 200, 100, 72, 0, [255, 255, 255]);
198        assert!(result.is_ok(), "text render failed: {:?}", result.err());
199    }
200}