Skip to main content

tauri_plugin_widgets/
rasterize.rs

1//! SVG builders + optional PNG rasterize for Adaptive Cards Image data URIs.
2//!
3//! Used for `chart` / `canvas` / `gauge` nodes that Adaptive Cards cannot express natively.
4
5use crate::models::{
6    CanvasDrawCommand, ChartDataPoint, ChartType, ColorValue, GaugeStyle, ShapeType, WidgetElement, GaugeElement, ChartElement, ShapeElement, CanvasElement,
7};
8use std::f64::consts::PI;
9
10const DEFAULT_TINT: &str = "#4CAF50";
11const PIE_COLORS: &[&str] = &[
12    "#3b82f6", "#22c55e", "#f97316", "#ef4444", "#a855f7", "#eab308", "#ec4899", "#14b8a6",
13];
14
15/// Build an SVG document for chart/canvas/gauge/shape; `None` for other element types.
16pub fn element_to_svg(el: &WidgetElement) -> Option<String> {
17    match el {
18        WidgetElement::Chart(ChartElement {
19            chart_type,
20            chart_data,
21            tint,
22            ..
23        }) => Some(chart_svg(chart_type, chart_data, tint.as_ref())),
24        WidgetElement::Canvas(CanvasElement {
25            width,
26            height,
27            elements,
28            ..
29        }) => Some(canvas_svg(*width, *height, elements)),
30        WidgetElement::Gauge(GaugeElement {
31            value,
32            min,
33            max,
34            tint,
35            gauge_style,
36            current_value_label,
37            label,
38            ..
39        }) => Some(gauge_svg(
40            *value,
41            min.unwrap_or(0.0),
42            max.unwrap_or(1.0),
43            tint.as_ref(),
44            gauge_style.as_ref(),
45            current_value_label.as_deref(),
46            label.as_deref(),
47        )),
48        WidgetElement::Shape(ShapeElement {
49            shape_type,
50            fill,
51            stroke,
52            stroke_width,
53            size,
54            ..
55        }) => Some(shape_svg(
56            shape_type,
57            fill.as_ref(),
58            stroke.as_ref(),
59            stroke_width.unwrap_or(1.0),
60            size.unwrap_or(24.0),
61        )),
62        _ => None,
63    }
64}
65
66/// Rasterize SVG to `data:image/png;base64,…` when the `rasterize` feature is on.
67pub fn svg_to_data_uri(svg: &str) -> Result<String, String> {
68    #[cfg(feature = "rasterize")]
69    {
70        svg_to_data_uri_impl(svg)
71    }
72    #[cfg(not(feature = "rasterize"))]
73    {
74        let _ = svg;
75        Err("rasterize feature disabled".into())
76    }
77}
78
79/// Convenience: element → PNG data URI.
80pub fn element_to_png_data_uri(el: &WidgetElement) -> Result<String, String> {
81    let svg =
82        element_to_svg(el).ok_or_else(|| "element is not chart/canvas/gauge/shape".to_string())?;
83    svg_to_data_uri(&svg)
84}
85
86#[cfg(feature = "rasterize")]
87fn svg_to_data_uri_impl(svg: &str) -> Result<String, String> {
88    use base64::Engine;
89    let mut opts = resvg::usvg::Options::default();
90    opts.fontdb_mut().load_system_fonts();
91    let tree = resvg::usvg::Tree::from_str(svg, &opts).map_err(|e| format!("usvg: {e}"))?;
92    let size = tree.size();
93    let w = size.width().ceil().max(1.0) as u32;
94    let h = size.height().ceil().max(1.0) as u32;
95    let mut pixmap =
96        resvg::tiny_skia::Pixmap::new(w, h).ok_or_else(|| "pixmap alloc failed".to_string())?;
97    resvg::render(
98        &tree,
99        resvg::tiny_skia::Transform::default(),
100        &mut pixmap.as_mut(),
101    );
102    let png = pixmap
103        .encode_png()
104        .map_err(|e| format!("png encode: {e}"))?;
105    let b64 = base64::engine::general_purpose::STANDARD.encode(png);
106    Ok(format!("data:image/png;base64,{b64}"))
107}
108
109fn color_str(c: Option<&ColorValue>, fallback: &str) -> String {
110    match c {
111        Some(ColorValue::Solid(s)) => normalize_color(s),
112        Some(ColorValue::Adaptive { light, .. }) => normalize_color(light),
113        None => fallback.to_string(),
114    }
115}
116
117fn normalize_color(s: &str) -> String {
118    let t = s.trim();
119    if t.starts_with('#') || t.starts_with("rgb") {
120        return t.to_string();
121    }
122    // Named / semantic tokens → fallback hex.
123    match t.to_ascii_lowercase().as_str() {
124        "accent" | "blue" => "#2196F3".into(),
125        "good" | "success" | "green" => "#4CAF50".into(),
126        "warning" | "orange" => "#FF9800".into(),
127        "attention" | "error" | "danger" | "red" => "#F44336".into(),
128        "label" | "dark" | "black" => "#212121".into(),
129        "secondarylabel" | "light" | "white" => "#FAFAFA".into(),
130        _ => {
131            if t.is_empty() {
132                DEFAULT_TINT.into()
133            } else {
134                t.to_string()
135            }
136        }
137    }
138}
139
140fn esc(s: &str) -> String {
141    s.replace('&', "&amp;")
142        .replace('<', "&lt;")
143        .replace('>', "&gt;")
144        .replace('"', "&quot;")
145}
146
147fn chart_svg(chart_type: &ChartType, pts: &[ChartDataPoint], tint: Option<&ColorValue>) -> String {
148    let tint = color_str(tint, DEFAULT_TINT);
149    let max_v = pts
150        .iter()
151        .map(|p| p.value)
152        .fold(1.0_f64, f64::max)
153        .max(1e-6);
154
155    match chart_type {
156        ChartType::Line | ChartType::Area => {
157            let w = 200.0_f64;
158            let h = 60.0_f64;
159            let n = pts.len().max(1);
160            let mut path = String::new();
161            for (i, p) in pts.iter().enumerate() {
162                let x = (i as f64 / (n - 1).max(1) as f64) * w;
163                let y = h - (p.value / max_v) * h;
164                if i == 0 {
165                    path.push_str(&format!("M{x:.2},{y:.2}"));
166                } else {
167                    path.push_str(&format!(" L{x:.2},{y:.2}"));
168                }
169            }
170            let mut body = String::new();
171            if matches!(chart_type, ChartType::Area) {
172                let mut area = format!("M0,{h:.2}");
173                for (i, p) in pts.iter().enumerate() {
174                    let x = (i as f64 / (n - 1).max(1) as f64) * w;
175                    let y = h - (p.value / max_v) * h;
176                    area.push_str(&format!(" L{x:.2},{y:.2}"));
177                }
178                area.push_str(&format!(" L{w:.2},{h:.2} Z"));
179                body.push_str(&format!(
180                    r#"<path d="{area}" fill="{tint}" opacity="0.3"/>"#
181                ));
182            }
183            body.push_str(&format!(
184                r#"<path d="{path}" fill="none" stroke="{tint}" stroke-width="2"/>"#
185            ));
186            format!(
187                r#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 {w} {h}" width="{w}" height="{h}">{body}</svg>"#
188            )
189        }
190        ChartType::Pie => {
191            let total: f64 = pts.iter().map(|p| p.value).sum::<f64>().max(1e-6);
192            let r = 40.0;
193            let cx = 50.0;
194            let cy = 50.0;
195            let mut ca = -90.0_f64;
196            let mut body = String::new();
197            for (i, p) in pts.iter().enumerate() {
198                let angle = (p.value / total) * 360.0;
199                let sr = ca * PI / 180.0;
200                let er = (ca + angle) * PI / 180.0;
201                let x1 = cx + r * sr.cos();
202                let y1 = cy + r * sr.sin();
203                let x2 = cx + r * er.cos();
204                let y2 = cy + r * er.sin();
205                let lf = if angle > 180.0 { 1 } else { 0 };
206                let fill = color_str(p.color.as_ref(), PIE_COLORS[i % PIE_COLORS.len()]);
207                body.push_str(&format!(
208                    r#"<path d="M{cx},{cy} L{x1:.2},{y1:.2} A{r},{r} 0 {lf},1 {x2:.2},{y2:.2} Z" fill="{fill}"/>"#
209                ));
210                ca += angle;
211            }
212            format!(
213                r#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" width="80" height="80">{body}</svg>"#
214            )
215        }
216        ChartType::Bar => {
217            let n = pts.len().max(1) as f64;
218            let gap = 4.0;
219            let w = 200.0;
220            let h = 70.0;
221            let bar_w = ((w - gap * (n + 1.0)) / n).max(2.0);
222            let mut body = String::new();
223            for (i, p) in pts.iter().enumerate() {
224                let bh = ((p.value / max_v) * 60.0).max(2.0);
225                let x = gap + i as f64 * (bar_w + gap);
226                let y = h - 10.0 - bh;
227                let fill = color_str(p.color.as_ref(), &tint);
228                body.push_str(&format!(
229                    r#"<rect x="{x:.2}" y="{y:.2}" width="{bar_w:.2}" height="{bh:.2}" fill="{fill}" rx="2"/>"#
230                ));
231                body.push_str(&format!(
232                    r#"<text x="{:.2}" y="{:.2}" font-size="8" fill="{}" text-anchor="middle">{}</text>"#,
233                    x + bar_w / 2.0,
234                    h - 1.0,
235                    "#999",
236                    esc(&p.label)
237                ));
238            }
239            format!(
240                r#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 {w} {h}" width="{w}" height="{h}">{body}</svg>"#
241            )
242        }
243    }
244}
245
246fn canvas_svg(width: f64, height: f64, elements: &[CanvasDrawCommand]) -> String {
247    let mut body = String::new();
248    for cmd in elements {
249        match cmd {
250            CanvasDrawCommand::Circle {
251                cx,
252                cy,
253                r,
254                fill,
255                stroke,
256                stroke_width,
257            } => {
258                body.push_str(&format!(
259                    r#"<circle cx="{cx}" cy="{cy}" r="{r}" fill="{}" stroke="{}" stroke-width="{}"/>"#,
260                    color_str(fill.as_ref(), "none"),
261                    color_str(stroke.as_ref(), "none"),
262                    stroke_width.unwrap_or(1.0)
263                ));
264            }
265            CanvasDrawCommand::Line {
266                x1,
267                y1,
268                x2,
269                y2,
270                stroke,
271                stroke_width,
272                line_cap,
273            } => {
274                body.push_str(&format!(
275                    r#"<line x1="{x1}" y1="{y1}" x2="{x2}" y2="{y2}" stroke="{}" stroke-width="{}" stroke-linecap="{}"/>"#,
276                    color_str(stroke.as_ref(), "#ffffff"),
277                    stroke_width.unwrap_or(1.0),
278                    line_cap.as_deref().unwrap_or("butt")
279                ));
280            }
281            CanvasDrawCommand::Rect {
282                x,
283                y,
284                width: rw,
285                height: rh,
286                fill,
287                stroke,
288                stroke_width,
289                corner_radius,
290            } => {
291                let rx = corner_radius.unwrap_or(0.0);
292                body.push_str(&format!(
293                    r#"<rect x="{x}" y="{y}" width="{rw}" height="{rh}" rx="{rx}" ry="{rx}" fill="{}" stroke="{}" stroke-width="{}"/>"#,
294                    color_str(fill.as_ref(), "none"),
295                    color_str(stroke.as_ref(), "none"),
296                    stroke_width.unwrap_or(1.0)
297                ));
298            }
299            CanvasDrawCommand::Arc {
300                cx,
301                cy,
302                r,
303                start_angle,
304                end_angle,
305                fill,
306                stroke,
307                stroke_width,
308            } => {
309                let sa = start_angle * PI / 180.0;
310                let ea = end_angle * PI / 180.0;
311                let sx = cx + r * sa.cos();
312                let sy = cy + r * sa.sin();
313                let ex = cx + r * ea.cos();
314                let ey = cy + r * ea.sin();
315                let lf = if (ea - sa).abs() > PI { 1 } else { 0 };
316                let fill_s = color_str(fill.as_ref(), "none");
317                let d = if fill_s != "none" {
318                    format!("M{cx},{cy} L{sx:.2},{sy:.2} A{r},{r} 0 {lf} 1 {ex:.2},{ey:.2} Z")
319                } else {
320                    format!("M{sx:.2},{sy:.2} A{r},{r} 0 {lf} 1 {ex:.2},{ey:.2}")
321                };
322                body.push_str(&format!(
323                    r#"<path d="{d}" fill="{fill_s}" stroke="{}" stroke-width="{}"/>"#,
324                    color_str(stroke.as_ref(), "none"),
325                    stroke_width.unwrap_or(1.0)
326                ));
327            }
328            CanvasDrawCommand::Text {
329                x,
330                y,
331                content,
332                font_size,
333                color,
334                anchor,
335            } => {
336                let anchor = match anchor.as_deref() {
337                    Some("middle") => "middle",
338                    Some("end") => "end",
339                    _ => "start",
340                };
341                body.push_str(&format!(
342                    r#"<text x="{x}" y="{y}" font-size="{}" fill="{}" text-anchor="{anchor}">{}</text>"#,
343                    font_size.unwrap_or(12.0),
344                    color_str(color.as_ref(), "#ffffff"),
345                    esc(content)
346                ));
347            }
348            CanvasDrawCommand::Path {
349                d,
350                fill,
351                stroke,
352                stroke_width,
353            } => {
354                body.push_str(&format!(
355                    r#"<path d="{}" fill="{}" stroke="{}" stroke-width="{}"/>"#,
356                    esc(d),
357                    color_str(fill.as_ref(), "none"),
358                    color_str(stroke.as_ref(), "none"),
359                    stroke_width.unwrap_or(1.0)
360                ));
361            }
362        }
363    }
364    format!(
365        r#"<svg xmlns="http://www.w3.org/2000/svg" width="{width}" height="{height}" viewBox="0 0 {width} {height}">{body}</svg>"#
366    )
367}
368
369fn gauge_svg(
370    value: f64,
371    min: f64,
372    max: f64,
373    tint: Option<&ColorValue>,
374    style: Option<&GaugeStyle>,
375    current: Option<&str>,
376    label: Option<&str>,
377) -> String {
378    let tint = color_str(tint, DEFAULT_TINT);
379    let track = "#e0e0e0";
380    let pct = (((value - min) / (max - min).max(1e-6)) * 100.0).clamp(0.0, 100.0);
381
382    if matches!(style, Some(GaugeStyle::Linear)) {
383        let mut body = String::new();
384        if let Some(l) = label {
385            body.push_str(&format!(
386                r#"<text x="0" y="10" font-size="10" fill="{tint}" opacity="0.7">{}</text>"#,
387                esc(l)
388            ));
389        }
390        if let Some(c) = current {
391            body.push_str(&format!(
392                r#"<text x="120" y="10" font-size="11" font-weight="600" fill="{tint}" text-anchor="end">{}</text>"#,
393                esc(c)
394            ));
395        }
396        body.push_str(&format!(
397            r#"<rect x="0" y="16" width="120" height="6" rx="3" fill="{track}"/>"#
398        ));
399        let fw = (120.0 * pct / 100.0).max(0.0);
400        body.push_str(&format!(
401            r#"<rect x="0" y="16" width="{fw:.2}" height="6" rx="3" fill="{tint}"/>"#
402        ));
403        format!(
404            r#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 28" width="120" height="28">{body}</svg>"#
405        )
406    } else {
407        let mut body = String::new();
408        body.push_str(&format!(
409            r#"<path d="M18 2.0845 a 15.9155 15.9155 0 0 1 0 31.831 a 15.9155 15.9155 0 0 1 0 -31.831" fill="none" stroke="{track}" stroke-width="4"/>"#
410        ));
411        body.push_str(&format!(
412            r#"<path d="M18 2.0845 a 15.9155 15.9155 0 0 1 0 31.831 a 15.9155 15.9155 0 0 1 0 -31.831" fill="none" stroke="{tint}" stroke-width="4" stroke-dasharray="{pct:.1}, 100" stroke-linecap="round"/>"#
413        ));
414        if let Some(c) = current {
415            let white = "#ffffff";
416            body.push_str(&format!(
417                r#"<text x="18" y="20" font-size="8" font-weight="600" fill="{white}" text-anchor="middle">{}</text>"#,
418                esc(c)
419            ));
420        }
421        let label_h = if label.is_some() { 14.0 } else { 0.0 };
422        if let Some(l) = label {
423            // Lighten label vs ring tint so it stays readable on dark widget goldens.
424            let label_fill = "#ffffff";
425            body.push_str(&format!(
426                r#"<text x="18" y="48" font-size="8" fill="{label_fill}" text-anchor="middle">{}</text>"#,
427                esc(l)
428            ));
429        }
430        format!(
431            r#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 36 {:.0}" width="56" height="{:.0}">{body}</svg>"#,
432            36.0 + label_h,
433            56.0 + label_h
434        )
435    }
436}
437
438fn shape_svg(
439    shape_type: &ShapeType,
440    fill: Option<&ColorValue>,
441    stroke: Option<&ColorValue>,
442    stroke_width: f64,
443    size: f64,
444) -> String {
445    let fill_s = color_str(fill, DEFAULT_TINT);
446    let stroke_s = color_str(stroke, "none");
447    let sw = stroke_width;
448    match shape_type {
449        ShapeType::Circle => {
450            let r = size / 2.0;
451            format!(
452                r#"<svg xmlns="http://www.w3.org/2000/svg" width="{size}" height="{size}" viewBox="0 0 {size} {size}"><circle cx="{r}" cy="{r}" r="{r}" fill="{fill_s}" stroke="{stroke_s}" stroke-width="{sw}"/></svg>"#
453            )
454        }
455        ShapeType::Capsule => {
456            let w = size * 2.0;
457            let h = size;
458            let rx = size / 2.0;
459            format!(
460                r#"<svg xmlns="http://www.w3.org/2000/svg" width="{w}" height="{h}" viewBox="0 0 {w} {h}"><rect x="0" y="0" width="{w}" height="{h}" rx="{rx}" ry="{rx}" fill="{fill_s}" stroke="{stroke_s}" stroke-width="{sw}"/></svg>"#
461            )
462        }
463        ShapeType::Rectangle => format!(
464            r#"<svg xmlns="http://www.w3.org/2000/svg" width="{size}" height="{size}" viewBox="0 0 {size} {size}"><rect x="0" y="0" width="{size}" height="{size}" fill="{fill_s}" stroke="{stroke_s}" stroke-width="{sw}"/></svg>"#
465        ),
466    }
467}
468
469#[cfg(test)]
470mod tests {
471    use super::*;
472    use crate::models::{ChartDataPoint, ChartType, ShapeType, WidgetElement};
473
474    #[test]
475    fn chart_svg_non_empty() {
476        let el = WidgetElement::Chart(ChartElement {
477            chart_type: ChartType::Bar,
478            chart_data: vec![
479                ChartDataPoint {
480                    label: "a".into(),
481                    value: 3.0,
482                    color: None,
483                },
484                ChartDataPoint {
485                    label: "b".into(),
486                    value: 5.0,
487                    color: None,
488                },
489            ],
490            tint: None,
491            style: Default::default(),
492        });
493        let svg = element_to_svg(&el).unwrap();
494        assert!(svg.contains("<svg"));
495        assert!(svg.contains("<rect"));
496    }
497
498    #[test]
499    fn shape_svg_circle() {
500        let el = WidgetElement::Shape(ShapeElement {
501            shape_type: ShapeType::Circle,
502            fill: None,
503            stroke: None,
504            stroke_width: None,
505            size: Some(32.0),
506            style: Default::default(),
507        });
508        let svg = element_to_svg(&el).unwrap();
509        assert!(svg.contains("<circle"));
510    }
511
512    #[cfg(feature = "rasterize")]
513    #[test]
514    fn chart_png_data_uri() {
515        let el = WidgetElement::Chart(ChartElement {
516            chart_type: ChartType::Line,
517            chart_data: vec![
518                ChartDataPoint {
519                    label: "a".into(),
520                    value: 1.0,
521                    color: None,
522                },
523                ChartDataPoint {
524                    label: "b".into(),
525                    value: 2.0,
526                    color: None,
527                },
528            ],
529            tint: None,
530            style: Default::default(),
531        });
532        let uri = element_to_png_data_uri(&el).unwrap();
533        assert!(uri.starts_with("data:image/png;base64,"));
534        assert!(uri.len() > 64);
535    }
536}