Skip to main content

chartml_render/
lib.rs

1//! Server-side ChartML rendering: spec → ChartElement → SVG → PNG.
2//!
3//! This crate provides the final rendering step for chartml-rs,
4//! converting ChartElement trees into static PNG images without
5//! requiring a browser, DOM, or JavaScript runtime.
6//!
7//! # Features
8//!
9//! - **SVG serialization** (always available): converts `ChartElement` trees to SVG strings
10//! - **PNG rasterization** (requires `rasterize` feature, enabled by default): converts SVG to PNG
11//!
12//! # Usage
13//!
14//! ```rust,no_run
15//! use chartml_core::ChartML;
16//! use chartml_render::render_to_png;
17//!
18//! let chartml = ChartML::new();
19//! // ... register renderers ...
20//!
21//! let yaml = r#"
22//! type: chart
23//! version: 1
24//! data:
25//!   provider: inline
26//!   rows:
27//!     - { x: "A", y: 10 }
28//!     - { x: "B", y: 20 }
29//! visualize:
30//!   type: bar
31//!   columns: x
32//!   rows: y
33//! "#;
34//!
35//! let png_bytes = render_to_png(&chartml, yaml, 800, 400, 72).unwrap();
36//! ```
37
38pub mod error;
39#[cfg(feature = "rasterize")]
40pub mod rasterize;
41pub mod svg;
42
43pub use error::RenderError;
44#[cfg(feature = "rasterize")]
45pub use rasterize::{init_font_database, svg_to_png};
46pub use svg::element_to_svg;
47
48#[cfg(feature = "rasterize")]
49use chartml_core::ChartML;
50
51/// Default padding in CSS pixels around the chart.
52#[cfg(feature = "rasterize")]
53const DEFAULT_PADDING: u32 = 16;
54
55/// Strip `stroke-dashoffset` attributes from an SVG string before static
56/// rasterization.
57///
58/// Line charts set both `stroke-dasharray` and `stroke-dashoffset` to the
59/// path length for the CSS draw animation — a browser animates the offset
60/// to 0, progressively revealing the stroke. In a static rasterizer (resvg)
61/// the animation never runs, leaving every line fully offset and invisible;
62/// only the circle dot markers remain.
63///
64/// Removing `stroke-dashoffset` leaves `stroke-dasharray` set to a single
65/// number ≥ the path length, which SVG renders as one unbroken dash — a
66/// fully visible solid line. Legitimately dashed lines (`"8 4"` etc.) never
67/// set `stroke-dashoffset` in chartml, so they are unaffected.
68#[cfg(feature = "rasterize")]
69fn strip_dashoffset_for_static(svg: &str) -> String {
70    const ATTR: &str = " stroke-dashoffset=\"";
71    if !svg.contains(ATTR) {
72        return svg.to_owned();
73    }
74    let mut out = String::with_capacity(svg.len());
75    let mut rest = svg;
76    while let Some(pos) = rest.find(ATTR) {
77        out.push_str(&rest[..pos]);
78        let after = &rest[pos + ATTR.len()..];
79        match after.find('"') {
80            Some(end) => rest = &after[end + 1..],
81            None => {
82                rest = after;
83                break;
84            }
85        }
86    }
87    out.push_str(rest);
88    out
89}
90
91/// White background color.
92#[cfg(feature = "rasterize")]
93const WHITE: [u8; 3] = [255, 255, 255];
94
95/// Render a ChartML YAML spec to PNG bytes (synchronous).
96///
97/// Runs the full pipeline: parse YAML → render ChartElement → SVG → PNG.
98/// Use this for specs with inline data and no async transforms (sql/forecast).
99///
100/// # Arguments
101/// * `chartml` — configured ChartML instance with renderers registered
102/// * `yaml` — ChartML YAML specification string
103/// * `width` — chart width in CSS pixels
104/// * `height` — chart height in CSS pixels
105/// * `density` — DPI (72 = 1x, 144 = 2x for PDF)
106#[cfg(feature = "rasterize")]
107pub fn render_to_png(
108    chartml: &ChartML,
109    yaml: &str,
110    width: u32,
111    height: u32,
112    density: u32,
113) -> Result<Vec<u8>, RenderError> {
114    render_to_png_with_background(chartml, yaml, width, height, density, WHITE)
115}
116
117/// Render a ChartML YAML spec to PNG bytes (synchronous) with an explicit
118/// canvas background color.
119///
120/// Identical to [`render_to_png`] except the pixmap is filled with
121/// `background` instead of white. Use this when the PNG will be placed on
122/// a non-white surface (e.g. a dark-mode email card) — the theme set on
123/// `chartml` should use matching colors or chart text will be illegible.
124///
125/// # Arguments
126/// * `chartml` — configured ChartML instance with renderers registered
127/// * `yaml` — ChartML YAML specification string
128/// * `width` — chart width in CSS pixels
129/// * `height` — chart height in CSS pixels
130/// * `density` — DPI (72 = 1x, 144 = 2x for PDF)
131/// * `background` — canvas fill color as `[r, g, b]`
132#[cfg(feature = "rasterize")]
133pub fn render_to_png_with_background(
134    chartml: &ChartML,
135    yaml: &str,
136    width: u32,
137    height: u32,
138    density: u32,
139    background: [u8; 3],
140) -> Result<Vec<u8>, RenderError> {
141    let element = chartml.render_from_yaml_with_size(
142        yaml,
143        Some(width as f64),
144        Some(height as f64),
145    )?;
146
147    let svg_str = element_to_svg(&element, width as f64, height as f64);
148    let svg_str = strip_dashoffset_for_static(&svg_str);
149    svg_to_png(&svg_str, width, height, density, DEFAULT_PADDING, background)
150}
151
152/// Render a ChartML YAML spec to PNG bytes (async).
153///
154/// Runs the full pipeline: parse YAML → transform (DataFusion) → render → SVG → PNG.
155/// Use this for specs that require async transforms (sql, aggregate, forecast).
156///
157/// # Arguments
158/// * `chartml` — configured ChartML instance with renderers and transform middleware registered
159/// * `yaml` — ChartML YAML specification string
160/// * `width` — chart width in CSS pixels
161/// * `height` — chart height in CSS pixels
162/// * `density` — DPI (72 = 1x, 144 = 2x for PDF)
163#[cfg(feature = "rasterize")]
164pub async fn render_to_png_async(
165    chartml: &ChartML,
166    yaml: &str,
167    width: u32,
168    height: u32,
169    density: u32,
170) -> Result<Vec<u8>, RenderError> {
171    render_to_png_with_background_async(chartml, yaml, width, height, density, WHITE).await
172}
173
174/// Render a ChartML YAML spec to PNG bytes (async) with an explicit canvas
175/// background color.
176///
177/// Identical to [`render_to_png_async`] except the pixmap is filled with
178/// `background` instead of white. See [`render_to_png_with_background`].
179///
180/// # Arguments
181/// * `chartml` — configured ChartML instance with renderers and transform middleware registered
182/// * `yaml` — ChartML YAML specification string
183/// * `width` — chart width in CSS pixels
184/// * `height` — chart height in CSS pixels
185/// * `density` — DPI (72 = 1x, 144 = 2x for PDF)
186/// * `background` — canvas fill color as `[r, g, b]`
187#[cfg(feature = "rasterize")]
188pub async fn render_to_png_with_background_async(
189    chartml: &ChartML,
190    yaml: &str,
191    width: u32,
192    height: u32,
193    density: u32,
194    background: [u8; 3],
195) -> Result<Vec<u8>, RenderError> {
196    let element = chartml.render_from_yaml_with_params_async(
197        yaml,
198        Some(width as f64),
199        Some(height as f64),
200        None,
201    ).await?;
202
203    let svg_str = element_to_svg(&element, width as f64, height as f64);
204    let svg_str = strip_dashoffset_for_static(&svg_str);
205    svg_to_png(&svg_str, width, height, density, DEFAULT_PADDING, background)
206}
207
208/// Render a pre-built ChartElement tree to PNG bytes.
209///
210/// Use this when you already have a ChartElement (e.g. from a custom rendering pipeline).
211#[cfg(feature = "rasterize")]
212pub fn element_to_png(
213    element: &chartml_core::ChartElement,
214    width: u32,
215    height: u32,
216    density: u32,
217) -> Result<Vec<u8>, RenderError> {
218    element_to_png_with_background(element, width, height, density, WHITE)
219}
220
221/// Render a pre-built ChartElement tree to PNG bytes with an explicit
222/// canvas background color. See [`render_to_png_with_background`].
223#[cfg(feature = "rasterize")]
224pub fn element_to_png_with_background(
225    element: &chartml_core::ChartElement,
226    width: u32,
227    height: u32,
228    density: u32,
229    background: [u8; 3],
230) -> Result<Vec<u8>, RenderError> {
231    let svg_str = element_to_svg(element, width as f64, height as f64);
232    let svg_str = strip_dashoffset_for_static(&svg_str);
233    svg_to_png(&svg_str, width, height, density, DEFAULT_PADDING, background)
234}
235
236#[cfg(test)]
237mod tests {
238    use super::*;
239
240    #[test]
241    fn strip_dashoffset_removes_animation_attrs() {
242        let svg = r##"<path d="M0,0L100,50" stroke="#D97706" stroke-width="2" stroke-dasharray="112" stroke-dashoffset="112" class="series-line"/>"##;
243        let result = strip_dashoffset_for_static(svg);
244        assert!(!result.contains("stroke-dashoffset"));
245        assert!(result.contains(r#"stroke-dasharray="112""#));
246        assert!(result.contains(r##"stroke="#D97706""##));
247    }
248
249    #[test]
250    fn strip_dashoffset_preserves_dashed_lines() {
251        let svg = r#"<path d="M0,0L100,50" stroke-dasharray="8 4" class="dashed"/>"#;
252        let result = strip_dashoffset_for_static(svg);
253        assert_eq!(result, svg);
254    }
255
256    #[test]
257    fn strip_dashoffset_handles_multiple_paths() {
258        let svg = r#"<path stroke-dashoffset="200"/><path stroke-dashoffset="300"/>"#;
259        let result = strip_dashoffset_for_static(svg);
260        assert!(!result.contains("stroke-dashoffset"));
261        assert_eq!(result, r#"<path/><path/>"#);
262    }
263
264    #[test]
265    fn strip_dashoffset_no_op_without_attr() {
266        let svg = r#"<circle cx="50" cy="50" r="4" fill="red"/>"#;
267        let result = strip_dashoffset_for_static(svg);
268        assert_eq!(result, svg);
269    }
270
271    #[cfg(feature = "rasterize")]
272    #[test]
273    fn element_to_png_with_background_fills_canvas() {
274        let element = chartml_core::ChartElement::Svg {
275            viewbox: chartml_core::element::ViewBox {
276                x: 0.0,
277                y: 0.0,
278                width: 100.0,
279                height: 50.0,
280            },
281            width: Some(100.0),
282            height: Some(50.0),
283            class: String::new(),
284            children: vec![],
285        };
286
287        let bg = [36u8, 32, 30]; // #24201E — a dark surface
288        let png_bytes =
289            element_to_png_with_background(&element, 100, 50, 72, bg).expect("render succeeds");
290
291        let decoder = png::Decoder::new(&png_bytes[..]);
292        let mut reader = decoder.read_info().expect("valid PNG");
293        let mut buf = vec![0u8; reader.output_buffer_size()];
294        let info = reader.next_frame(&mut buf).expect("decodable frame");
295
296        // Canvas = chart size + DEFAULT_PADDING on each side at 1x density.
297        assert_eq!(info.width, 100 + 2 * DEFAULT_PADDING);
298        assert_eq!(info.height, 50 + 2 * DEFAULT_PADDING);
299
300        // The empty chart draws nothing, so every pixel is the background.
301        // Check the first and last pixels (RGBA output).
302        assert_eq!(&buf[0..3], &bg);
303        let last = buf.len() - 4;
304        assert_eq!(&buf[last..last + 3], &bg);
305    }
306
307    #[cfg(feature = "rasterize")]
308    #[test]
309    fn element_to_png_defaults_to_white_background() {
310        let element = chartml_core::ChartElement::Svg {
311            viewbox: chartml_core::element::ViewBox {
312                x: 0.0,
313                y: 0.0,
314                width: 100.0,
315                height: 50.0,
316            },
317            width: Some(100.0),
318            height: Some(50.0),
319            class: String::new(),
320            children: vec![],
321        };
322
323        let png_bytes = element_to_png(&element, 100, 50, 72).expect("render succeeds");
324
325        let decoder = png::Decoder::new(&png_bytes[..]);
326        let mut reader = decoder.read_info().expect("valid PNG");
327        let mut buf = vec![0u8; reader.output_buffer_size()];
328        reader.next_frame(&mut buf).expect("decodable frame");
329
330        assert_eq!(&buf[0..3], &WHITE);
331    }
332}