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    let element = chartml.render_from_yaml_with_size(
115        yaml,
116        Some(width as f64),
117        Some(height as f64),
118    )?;
119
120    let svg_str = element_to_svg(&element, width as f64, height as f64);
121    let svg_str = strip_dashoffset_for_static(&svg_str);
122    svg_to_png(&svg_str, width, height, density, DEFAULT_PADDING, WHITE)
123}
124
125/// Render a ChartML YAML spec to PNG bytes (async).
126///
127/// Runs the full pipeline: parse YAML → transform (DataFusion) → render → SVG → PNG.
128/// Use this for specs that require async transforms (sql, aggregate, forecast).
129///
130/// # Arguments
131/// * `chartml` — configured ChartML instance with renderers and transform middleware registered
132/// * `yaml` — ChartML YAML specification string
133/// * `width` — chart width in CSS pixels
134/// * `height` — chart height in CSS pixels
135/// * `density` — DPI (72 = 1x, 144 = 2x for PDF)
136#[cfg(feature = "rasterize")]
137pub async fn render_to_png_async(
138    chartml: &ChartML,
139    yaml: &str,
140    width: u32,
141    height: u32,
142    density: u32,
143) -> Result<Vec<u8>, RenderError> {
144    let element = chartml.render_from_yaml_with_params_async(
145        yaml,
146        Some(width as f64),
147        Some(height as f64),
148        None,
149    ).await?;
150
151    let svg_str = element_to_svg(&element, width as f64, height as f64);
152    let svg_str = strip_dashoffset_for_static(&svg_str);
153    svg_to_png(&svg_str, width, height, density, DEFAULT_PADDING, WHITE)
154}
155
156/// Render a pre-built ChartElement tree to PNG bytes.
157///
158/// Use this when you already have a ChartElement (e.g. from a custom rendering pipeline).
159#[cfg(feature = "rasterize")]
160pub fn element_to_png(
161    element: &chartml_core::ChartElement,
162    width: u32,
163    height: u32,
164    density: u32,
165) -> Result<Vec<u8>, RenderError> {
166    let svg_str = element_to_svg(element, width as f64, height as f64);
167    let svg_str = strip_dashoffset_for_static(&svg_str);
168    svg_to_png(&svg_str, width, height, density, DEFAULT_PADDING, WHITE)
169}
170
171#[cfg(test)]
172mod tests {
173    use super::*;
174
175    #[test]
176    fn strip_dashoffset_removes_animation_attrs() {
177        let svg = r##"<path d="M0,0L100,50" stroke="#D97706" stroke-width="2" stroke-dasharray="112" stroke-dashoffset="112" class="series-line"/>"##;
178        let result = strip_dashoffset_for_static(svg);
179        assert!(!result.contains("stroke-dashoffset"));
180        assert!(result.contains(r#"stroke-dasharray="112""#));
181        assert!(result.contains(r##"stroke="#D97706""##));
182    }
183
184    #[test]
185    fn strip_dashoffset_preserves_dashed_lines() {
186        let svg = r#"<path d="M0,0L100,50" stroke-dasharray="8 4" class="dashed"/>"#;
187        let result = strip_dashoffset_for_static(svg);
188        assert_eq!(result, svg);
189    }
190
191    #[test]
192    fn strip_dashoffset_handles_multiple_paths() {
193        let svg = r#"<path stroke-dashoffset="200"/><path stroke-dashoffset="300"/>"#;
194        let result = strip_dashoffset_for_static(svg);
195        assert!(!result.contains("stroke-dashoffset"));
196        assert_eq!(result, r#"<path/><path/>"#);
197    }
198
199    #[test]
200    fn strip_dashoffset_no_op_without_attr() {
201        let svg = r#"<circle cx="50" cy="50" r="4" fill="red"/>"#;
202        let result = strip_dashoffset_for_static(svg);
203        assert_eq!(result, svg);
204    }
205}