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` / `shape` / `zstack` / gradient
4//! backgrounds that Adaptive Cards cannot express natively.
5
6use crate::models::{
7    CanvasDrawCommand, ChartDataPoint, ChartType, ColorValue, ElementStyle, GaugeStyle,
8    GradientConfig, GradientDirection, GradientType, ProgressElement, ProgressStyle, ShapeType,
9    WidgetElement, GaugeElement, ChartElement, ShapeElement, CanvasElement, TextElement,
10    ImageElement, ZStackElement, BackgroundValue,
11};
12use std::f64::consts::PI;
13
14const DEFAULT_TINT: &str = "#4CAF50";
15const PIE_COLORS: &[&str] = &[
16    "#3b82f6", "#22c55e", "#f97316", "#ef4444", "#a855f7", "#eab308", "#ec4899", "#14b8a6",
17];
18
19/// Build an SVG document for chart/canvas/gauge/shape; `None` for other element types.
20pub fn element_to_svg(el: &WidgetElement) -> Option<String> {
21    match el {
22        WidgetElement::Chart(ChartElement {
23            chart_type,
24            chart_data,
25            tint,
26            ..
27        }) => Some(chart_svg(chart_type, chart_data, tint.as_ref())),
28        WidgetElement::Canvas(CanvasElement {
29            width,
30            height,
31            elements,
32            ..
33        }) => Some(canvas_svg(*width, *height, elements)),
34        WidgetElement::Gauge(GaugeElement {
35            value,
36            min,
37            max,
38            tint,
39            gauge_style,
40            current_value_label,
41            label,
42            ..
43        }) => Some(gauge_svg(
44            *value,
45            min.unwrap_or(0.0),
46            max.unwrap_or(1.0),
47            tint.as_ref(),
48            gauge_style.as_ref(),
49            current_value_label.as_deref(),
50            label.as_deref(),
51        )),
52        WidgetElement::Progress(ProgressElement {
53            value,
54            total,
55            tint,
56            label,
57            bar_style,
58            ..
59        }) if matches!(bar_style, Some(ProgressStyle::Circular)) => Some(gauge_svg(
60            *value,
61            0.0,
62            if *total <= 0.0 { 1.0 } else { *total },
63            tint.as_ref(),
64            Some(&GaugeStyle::Circular),
65            None,
66            label.as_deref(),
67        )),
68        WidgetElement::Shape(ShapeElement {
69            shape_type,
70            fill,
71            stroke,
72            stroke_width,
73            size,
74            ..
75        }) => Some(shape_svg(
76            shape_type,
77            fill.as_ref(),
78            stroke.as_ref(),
79            stroke_width.unwrap_or(1.0),
80            size.unwrap_or(24.0),
81        )),
82        WidgetElement::ZStack(ZStackElement {
83            children, style, ..
84        }) => zstack_svg(children, style),
85        _ => None,
86    }
87}
88
89/// Build an SVG gradient fill for Adaptive Cards `backgroundImage` baking.
90pub fn gradient_to_svg(g: &GradientConfig, width: f64, height: f64) -> String {
91    use std::sync::atomic::{AtomicU64, Ordering};
92    static GRAD_ID: AtomicU64 = AtomicU64::new(1);
93    let id = format!("g{}", GRAD_ID.fetch_add(1, Ordering::Relaxed));
94    let w = width.max(8.0);
95    let h = height.max(8.0);
96    let colors = if g.colors.is_empty() {
97        vec!["#6366f1".into(), "#a855f7".into()]
98    } else {
99        g.colors.clone()
100    };
101    let stops: String = colors
102        .iter()
103        .enumerate()
104        .map(|(i, c)| {
105            let off = if colors.len() == 1 {
106                0.0
107            } else {
108                i as f64 / (colors.len() - 1) as f64
109            };
110            format!(
111                r#"<stop offset="{:.0}%" stop-color="{}"/>"#,
112                off * 100.0,
113                esc(c)
114            )
115        })
116        .collect();
117    match g.gradient_type {
118        GradientType::Radial => format!(
119            r#"<svg xmlns="http://www.w3.org/2000/svg" width="{w}" height="{h}" viewBox="0 0 {w} {h}"><defs><radialGradient id="{id}" cx="50%" cy="50%" r="70%">{stops}</radialGradient></defs><rect width="100%" height="100%" fill="url(#{id})"/></svg>"#
120        ),
121        GradientType::Angular => {
122            // Approximate angular as a linear sweep — good enough for AC bake.
123            format!(
124                r#"<svg xmlns="http://www.w3.org/2000/svg" width="{w}" height="{h}" viewBox="0 0 {w} {h}"><defs><linearGradient id="{id}" x1="0%" y1="0%" x2="100%" y2="100%">{stops}</linearGradient></defs><rect width="100%" height="100%" fill="url(#{id})"/></svg>"#
125            )
126        }
127        GradientType::Linear => {
128            let (x1, y1, x2, y2) = match g.direction.as_ref() {
129                Some(GradientDirection::LeadingToTrailing) => ("0%", "0%", "100%", "0%"),
130                Some(GradientDirection::TrailingToLeading) => ("100%", "0%", "0%", "0%"),
131                Some(GradientDirection::BottomToTop) => ("0%", "100%", "0%", "0%"),
132                Some(GradientDirection::TopLeadingToBottomTrailing) => ("0%", "0%", "100%", "100%"),
133                Some(GradientDirection::TopTrailingToBottomLeading) => ("100%", "0%", "0%", "100%"),
134                _ => ("0%", "0%", "0%", "100%"), // topToBottom default
135            };
136            format!(
137                r#"<svg xmlns="http://www.w3.org/2000/svg" width="{w}" height="{h}" viewBox="0 0 {w} {h}"><defs><linearGradient id="{id}" x1="{x1}" y1="{y1}" x2="{x2}" y2="{y2}">{stops}</linearGradient></defs><rect width="100%" height="100%" fill="url(#{id})"/></svg>"#
138            )
139        }
140    }
141}
142
143/// Rasterize a gradient background to a PNG data URI when `rasterize` is enabled.
144pub fn gradient_to_png_data_uri(g: &GradientConfig) -> Result<String, String> {
145    svg_to_data_uri(&gradient_to_svg(g, 320.0, 200.0))
146}
147
148fn zstack_svg(children: &[WidgetElement], style: &ElementStyle) -> Option<String> {
149    const W: f64 = 160.0;
150    const H: f64 = 160.0;
151    let mut layers = String::new();
152    // Bake background so AC Image isn't transparent when style.background is set.
153    if let Some(bg) = &style.background {
154        match bg {
155            BackgroundValue::Solid(s) => {
156                let fill = normalize_color(s);
157                layers.push_str(&format!(
158                    r#"<rect width="100%" height="100%" fill="{fill}"/>"#
159                ));
160            }
161            BackgroundValue::Adaptive { light, .. } => {
162                let fill = normalize_color(light);
163                layers.push_str(&format!(
164                    r#"<rect width="100%" height="100%" fill="{fill}"/>"#
165                ));
166            }
167            BackgroundValue::Gradient(g) => {
168                let grad_svg = gradient_to_svg(g, W, H);
169                let inner = strip_outer_svg(&grad_svg);
170                layers.push_str(&format!(
171                    r#"<svg x="0" y="0" width="{W}" height="{H}" viewBox="0 0 {W} {H}" preserveAspectRatio="none">{inner}</svg>"#
172                ));
173            }
174        }
175    }
176    let mut drew = 0usize;
177    for child in children {
178        match child {
179            WidgetElement::Text(TextElement {
180                content,
181                font_size,
182                color,
183                ..
184            }) => {
185                let size = font_size.unwrap_or(16.0);
186                let fill = color_str(color.as_ref(), "#ffffff");
187                layers.push_str(&format!(
188                    r#"<text x="{:.1}" y="{:.1}" text-anchor="middle" dominant-baseline="middle" font-size="{size}" fill="{fill}" font-family="system-ui,sans-serif">{}</text>"#,
189                    W / 2.0,
190                    H / 2.0,
191                    esc(content)
192                ));
193                drew += 1;
194            }
195            WidgetElement::Image(ImageElement {
196                system_name,
197                size,
198                color,
199                ..
200            }) => {
201                let glyph = system_name
202                    .as_deref()
203                    .map(|n| crate::adaptive_card::sf_symbol_glyph_public(n))
204                    .unwrap_or("•");
205                let sz = size.unwrap_or(28.0);
206                let fill = color_str(color.as_ref(), "#ffffff");
207                layers.push_str(&format!(
208                    r#"<text x="{:.1}" y="{:.1}" text-anchor="middle" dominant-baseline="middle" font-size="{sz}" fill="{fill}">{}</text>"#,
209                    W / 2.0,
210                    H / 2.0,
211                    esc(glyph)
212                ));
213                drew += 1;
214            }
215            other => {
216                let Some(inner) = element_to_svg(other) else {
217                    // Any unrenderable child → fall back so AC can render the full tree.
218                    return None;
219                };
220                let (vbx, vby, vbw, vbh) = extract_viewbox(&inner).unwrap_or((0.0, 0.0, W, H));
221                layers.push_str(&format!(
222                    r#"<svg x="0" y="0" width="{W}" height="{H}" viewBox="{vbx} {vby} {vbw} {vbh}" preserveAspectRatio="xMidYMid meet">{inner_body}</svg>"#,
223                    inner_body = strip_outer_svg(&inner)
224                ));
225                drew += 1;
226            }
227        }
228    }
229    if drew == 0 {
230        return None;
231    }
232    Some(format!(
233        r#"<svg xmlns="http://www.w3.org/2000/svg" width="{W}" height="{H}" viewBox="0 0 {W} {H}">{layers}</svg>"#
234    ))
235}
236
237fn extract_viewbox(svg: &str) -> Option<(f64, f64, f64, f64)> {
238    let re = regex_lite_viewbox(svg)?;
239    Some(re)
240}
241
242/// Tiny viewBox extractor — avoids a regex crate dependency.
243fn regex_lite_viewbox(svg: &str) -> Option<(f64, f64, f64, f64)> {
244    let key = "viewBox=\"";
245    let start = svg.find(key)? + key.len();
246    let end = svg[start..].find('"')? + start;
247    let parts: Vec<f64> = svg[start..end]
248        .split_whitespace()
249        .filter_map(|p| p.parse().ok())
250        .collect();
251    if parts.len() == 4 {
252        Some((parts[0], parts[1], parts[2], parts[3]))
253    } else {
254        None
255    }
256}
257
258fn strip_outer_svg(svg: &str) -> String {
259    let s = svg.trim();
260    if let Some(start) = s.find('>') {
261        let inner = &s[start + 1..];
262        if let Some(end) = inner.rfind("</svg>") {
263            return inner[..end].to_string();
264        }
265    }
266    s.to_string()
267}
268
269/// Rasterize SVG to `data:image/png;base64,…` when the `rasterize` feature is on.
270pub fn svg_to_data_uri(svg: &str) -> Result<String, String> {
271    #[cfg(feature = "rasterize")]
272    {
273        svg_to_data_uri_impl(svg)
274    }
275    #[cfg(not(feature = "rasterize"))]
276    {
277        let _ = svg;
278        Err("rasterize feature disabled".into())
279    }
280}
281
282/// Convenience: element → PNG data URI.
283pub fn element_to_png_data_uri(el: &WidgetElement) -> Result<String, String> {
284    let svg =
285        element_to_svg(el).ok_or_else(|| "element is not chart/canvas/gauge/shape".to_string())?;
286    svg_to_data_uri(&svg)
287}
288
289#[cfg(feature = "rasterize")]
290fn svg_to_data_uri_impl(svg: &str) -> Result<String, String> {
291    use base64::Engine;
292    let mut opts = resvg::usvg::Options::default();
293    opts.fontdb_mut().load_system_fonts();
294    let tree = resvg::usvg::Tree::from_str(svg, &opts).map_err(|e| format!("usvg: {e}"))?;
295    let size = tree.size();
296    let w = size.width().ceil().max(1.0) as u32;
297    let h = size.height().ceil().max(1.0) as u32;
298    let mut pixmap =
299        resvg::tiny_skia::Pixmap::new(w, h).ok_or_else(|| "pixmap alloc failed".to_string())?;
300    resvg::render(
301        &tree,
302        resvg::tiny_skia::Transform::default(),
303        &mut pixmap.as_mut(),
304    );
305    let png = pixmap
306        .encode_png()
307        .map_err(|e| format!("png encode: {e}"))?;
308    let b64 = base64::engine::general_purpose::STANDARD.encode(png);
309    Ok(format!("data:image/png;base64,{b64}"))
310}
311
312fn color_str(c: Option<&ColorValue>, fallback: &str) -> String {
313    match c {
314        Some(ColorValue::Solid(s)) => normalize_color(s),
315        Some(ColorValue::Adaptive { light, .. }) => normalize_color(light),
316        None => fallback.to_string(),
317    }
318}
319
320fn normalize_color(s: &str) -> String {
321    let t = s.trim();
322    if t.starts_with('#') || t.starts_with("rgb") {
323        return t.to_string();
324    }
325    // Named / semantic tokens → fallback hex.
326    match t.to_ascii_lowercase().as_str() {
327        "accent" | "blue" => "#2196F3".into(),
328        "good" | "success" | "green" => "#4CAF50".into(),
329        "warning" | "orange" => "#FF9800".into(),
330        "attention" | "error" | "danger" | "red" => "#F44336".into(),
331        "label" | "dark" | "black" => "#212121".into(),
332        "secondarylabel" | "light" | "white" => "#FAFAFA".into(),
333        _ => {
334            if t.is_empty() {
335                DEFAULT_TINT.into()
336            } else {
337                t.to_string()
338            }
339        }
340    }
341}
342
343fn esc(s: &str) -> String {
344    s.replace('&', "&amp;")
345        .replace('<', "&lt;")
346        .replace('>', "&gt;")
347        .replace('"', "&quot;")
348}
349
350fn chart_svg(chart_type: &ChartType, pts: &[ChartDataPoint], tint: Option<&ColorValue>) -> String {
351    let tint = color_str(tint, DEFAULT_TINT);
352    let max_v = pts
353        .iter()
354        .map(|p| p.value)
355        .fold(1.0_f64, f64::max)
356        .max(1e-6);
357
358    match chart_type {
359        ChartType::Line | ChartType::Area => {
360            let w = 200.0_f64;
361            let h = 60.0_f64;
362            let n = pts.len().max(1);
363            let mut path = String::new();
364            for (i, p) in pts.iter().enumerate() {
365                let x = (i as f64 / (n - 1).max(1) as f64) * w;
366                let y = h - (p.value / max_v) * h;
367                if i == 0 {
368                    path.push_str(&format!("M{x:.2},{y:.2}"));
369                } else {
370                    path.push_str(&format!(" L{x:.2},{y:.2}"));
371                }
372            }
373            let mut body = String::new();
374            if matches!(chart_type, ChartType::Area) {
375                let mut area = format!("M0,{h:.2}");
376                for (i, p) in pts.iter().enumerate() {
377                    let x = (i as f64 / (n - 1).max(1) as f64) * w;
378                    let y = h - (p.value / max_v) * h;
379                    area.push_str(&format!(" L{x:.2},{y:.2}"));
380                }
381                area.push_str(&format!(" L{w:.2},{h:.2} Z"));
382                body.push_str(&format!(
383                    r#"<path d="{area}" fill="{tint}" opacity="0.3"/>"#
384                ));
385            }
386            body.push_str(&format!(
387                r#"<path d="{path}" fill="none" stroke="{tint}" stroke-width="2"/>"#
388            ));
389            format!(
390                r#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 {w} {h}" width="{w}" height="{h}">{body}</svg>"#
391            )
392        }
393        ChartType::Pie => {
394            let total: f64 = pts.iter().map(|p| p.value).sum::<f64>().max(1e-6);
395            let r = 40.0;
396            let cx = 50.0;
397            let cy = 50.0;
398            let mut ca = -90.0_f64;
399            let mut body = String::new();
400            for (i, p) in pts.iter().enumerate() {
401                let angle = (p.value / total) * 360.0;
402                let sr = ca * PI / 180.0;
403                let er = (ca + angle) * PI / 180.0;
404                let x1 = cx + r * sr.cos();
405                let y1 = cy + r * sr.sin();
406                let x2 = cx + r * er.cos();
407                let y2 = cy + r * er.sin();
408                let lf = if angle > 180.0 { 1 } else { 0 };
409                let fill = color_str(p.color.as_ref(), PIE_COLORS[i % PIE_COLORS.len()]);
410                body.push_str(&format!(
411                    r#"<path d="M{cx},{cy} L{x1:.2},{y1:.2} A{r},{r} 0 {lf},1 {x2:.2},{y2:.2} Z" fill="{fill}"/>"#
412                ));
413                ca += angle;
414            }
415            format!(
416                r#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" width="80" height="80">{body}</svg>"#
417            )
418        }
419        ChartType::Bar => {
420            let n = pts.len().max(1) as f64;
421            let gap = 4.0;
422            let w = 200.0;
423            let h = 70.0;
424            let bar_w = ((w - gap * (n + 1.0)) / n).max(2.0);
425            let mut body = String::new();
426            for (i, p) in pts.iter().enumerate() {
427                let bh = ((p.value / max_v) * 60.0).max(2.0);
428                let x = gap + i as f64 * (bar_w + gap);
429                let y = h - 10.0 - bh;
430                let fill = color_str(p.color.as_ref(), &tint);
431                body.push_str(&format!(
432                    r#"<rect x="{x:.2}" y="{y:.2}" width="{bar_w:.2}" height="{bh:.2}" fill="{fill}" rx="2"/>"#
433                ));
434                body.push_str(&format!(
435                    r#"<text x="{:.2}" y="{:.2}" font-size="8" fill="{}" text-anchor="middle">{}</text>"#,
436                    x + bar_w / 2.0,
437                    h - 1.0,
438                    "#999",
439                    esc(&p.label)
440                ));
441            }
442            format!(
443                r#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 {w} {h}" width="{w}" height="{h}">{body}</svg>"#
444            )
445        }
446    }
447}
448
449fn canvas_svg(width: f64, height: f64, elements: &[CanvasDrawCommand]) -> String {
450    let mut body = String::new();
451    for cmd in elements {
452        match cmd {
453            CanvasDrawCommand::Circle {
454                cx,
455                cy,
456                r,
457                fill,
458                stroke,
459                stroke_width,
460            } => {
461                body.push_str(&format!(
462                    r#"<circle cx="{cx}" cy="{cy}" r="{r}" fill="{}" stroke="{}" stroke-width="{}"/>"#,
463                    color_str(fill.as_ref(), "none"),
464                    color_str(stroke.as_ref(), "none"),
465                    stroke_width.unwrap_or(1.0)
466                ));
467            }
468            CanvasDrawCommand::Line {
469                x1,
470                y1,
471                x2,
472                y2,
473                stroke,
474                stroke_width,
475                line_cap,
476            } => {
477                body.push_str(&format!(
478                    r#"<line x1="{x1}" y1="{y1}" x2="{x2}" y2="{y2}" stroke="{}" stroke-width="{}" stroke-linecap="{}"/>"#,
479                    color_str(stroke.as_ref(), "#ffffff"),
480                    stroke_width.unwrap_or(1.0),
481                    line_cap.as_deref().unwrap_or("butt")
482                ));
483            }
484            CanvasDrawCommand::Rect {
485                x,
486                y,
487                width: rw,
488                height: rh,
489                fill,
490                stroke,
491                stroke_width,
492                corner_radius,
493            } => {
494                let rx = corner_radius.unwrap_or(0.0);
495                body.push_str(&format!(
496                    r#"<rect x="{x}" y="{y}" width="{rw}" height="{rh}" rx="{rx}" ry="{rx}" fill="{}" stroke="{}" stroke-width="{}"/>"#,
497                    color_str(fill.as_ref(), "none"),
498                    color_str(stroke.as_ref(), "none"),
499                    stroke_width.unwrap_or(1.0)
500                ));
501            }
502            CanvasDrawCommand::Arc {
503                cx,
504                cy,
505                r,
506                start_angle,
507                end_angle,
508                fill,
509                stroke,
510                stroke_width,
511            } => {
512                let sa = start_angle * PI / 180.0;
513                let ea = end_angle * PI / 180.0;
514                let sx = cx + r * sa.cos();
515                let sy = cy + r * sa.sin();
516                let ex = cx + r * ea.cos();
517                let ey = cy + r * ea.sin();
518                let lf = if (ea - sa).abs() > PI { 1 } else { 0 };
519                let fill_s = color_str(fill.as_ref(), "none");
520                let d = if fill_s != "none" {
521                    format!("M{cx},{cy} L{sx:.2},{sy:.2} A{r},{r} 0 {lf} 1 {ex:.2},{ey:.2} Z")
522                } else {
523                    format!("M{sx:.2},{sy:.2} A{r},{r} 0 {lf} 1 {ex:.2},{ey:.2}")
524                };
525                body.push_str(&format!(
526                    r#"<path d="{d}" fill="{fill_s}" stroke="{}" stroke-width="{}"/>"#,
527                    color_str(stroke.as_ref(), "none"),
528                    stroke_width.unwrap_or(1.0)
529                ));
530            }
531            CanvasDrawCommand::Text {
532                x,
533                y,
534                content,
535                font_size,
536                color,
537                anchor,
538            } => {
539                let anchor = match anchor.as_deref() {
540                    Some("middle") => "middle",
541                    Some("end") => "end",
542                    _ => "start",
543                };
544                body.push_str(&format!(
545                    r#"<text x="{x}" y="{y}" font-size="{}" fill="{}" text-anchor="{anchor}">{}</text>"#,
546                    font_size.unwrap_or(12.0),
547                    color_str(color.as_ref(), "#ffffff"),
548                    esc(content)
549                ));
550            }
551            CanvasDrawCommand::Path {
552                d,
553                fill,
554                stroke,
555                stroke_width,
556            } => {
557                body.push_str(&format!(
558                    r#"<path d="{}" fill="{}" stroke="{}" stroke-width="{}"/>"#,
559                    esc(d),
560                    color_str(fill.as_ref(), "none"),
561                    color_str(stroke.as_ref(), "none"),
562                    stroke_width.unwrap_or(1.0)
563                ));
564            }
565        }
566    }
567    format!(
568        r#"<svg xmlns="http://www.w3.org/2000/svg" width="{width}" height="{height}" viewBox="0 0 {width} {height}">{body}</svg>"#
569    )
570}
571
572fn gauge_svg(
573    value: f64,
574    min: f64,
575    max: f64,
576    tint: Option<&ColorValue>,
577    style: Option<&GaugeStyle>,
578    current: Option<&str>,
579    label: Option<&str>,
580) -> String {
581    let tint = color_str(tint, DEFAULT_TINT);
582    let track = "#e0e0e0";
583    let pct = (((value - min) / (max - min).max(1e-6)) * 100.0).clamp(0.0, 100.0);
584
585    if matches!(style, Some(GaugeStyle::Linear)) {
586        let mut body = String::new();
587        if let Some(l) = label {
588            body.push_str(&format!(
589                r#"<text x="0" y="10" font-size="10" fill="{tint}" opacity="0.7">{}</text>"#,
590                esc(l)
591            ));
592        }
593        if let Some(c) = current {
594            body.push_str(&format!(
595                r#"<text x="120" y="10" font-size="11" font-weight="600" fill="{tint}" text-anchor="end">{}</text>"#,
596                esc(c)
597            ));
598        }
599        body.push_str(&format!(
600            r#"<rect x="0" y="16" width="120" height="6" rx="3" fill="{track}"/>"#
601        ));
602        let fw = (120.0 * pct / 100.0).max(0.0);
603        body.push_str(&format!(
604            r#"<rect x="0" y="16" width="{fw:.2}" height="6" rx="3" fill="{tint}"/>"#
605        ));
606        format!(
607            r#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 28" width="120" height="28">{body}</svg>"#
608        )
609    } else {
610        let mut body = String::new();
611        body.push_str(&format!(
612            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"/>"#
613        ));
614        body.push_str(&format!(
615            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"/>"#
616        ));
617        if let Some(c) = current {
618            let white = "#ffffff";
619            body.push_str(&format!(
620                r#"<text x="18" y="20" font-size="8" font-weight="600" fill="{white}" text-anchor="middle">{}</text>"#,
621                esc(c)
622            ));
623        }
624        let label_h = if label.is_some() { 14.0 } else { 0.0 };
625        if let Some(l) = label {
626            // Lighten label vs ring tint so it stays readable on dark widget goldens.
627            let label_fill = "#ffffff";
628            body.push_str(&format!(
629                r#"<text x="18" y="48" font-size="8" fill="{label_fill}" text-anchor="middle">{}</text>"#,
630                esc(l)
631            ));
632        }
633        format!(
634            r#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 36 {:.0}" width="56" height="{:.0}">{body}</svg>"#,
635            36.0 + label_h,
636            56.0 + label_h
637        )
638    }
639}
640
641fn shape_svg(
642    shape_type: &ShapeType,
643    fill: Option<&ColorValue>,
644    stroke: Option<&ColorValue>,
645    stroke_width: f64,
646    size: f64,
647) -> String {
648    let fill_s = color_str(fill, DEFAULT_TINT);
649    let stroke_s = color_str(stroke, "none");
650    let sw = stroke_width;
651    match shape_type {
652        ShapeType::Circle => {
653            let r = size / 2.0;
654            format!(
655                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>"#
656            )
657        }
658        ShapeType::Capsule => {
659            let w = size * 2.0;
660            let h = size;
661            let rx = size / 2.0;
662            format!(
663                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>"#
664            )
665        }
666        ShapeType::Rectangle => format!(
667            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>"#
668        ),
669    }
670}
671
672#[cfg(test)]
673mod tests {
674    use super::*;
675    use crate::models::{ChartDataPoint, ChartType, ShapeType, WidgetElement};
676
677    #[test]
678    fn chart_svg_non_empty() {
679        let el = WidgetElement::Chart(ChartElement {
680            chart_type: ChartType::Bar,
681            chart_data: vec![
682                ChartDataPoint {
683                    label: "a".into(),
684                    value: 3.0,
685                    color: None,
686                },
687                ChartDataPoint {
688                    label: "b".into(),
689                    value: 5.0,
690                    color: None,
691                },
692            ],
693            tint: None,
694            style: Default::default(),
695        });
696        let svg = element_to_svg(&el).unwrap();
697        assert!(svg.contains("<svg"));
698        assert!(svg.contains("<rect"));
699    }
700
701    #[test]
702    fn shape_svg_circle() {
703        let el = WidgetElement::Shape(ShapeElement {
704            shape_type: ShapeType::Circle,
705            fill: None,
706            stroke: None,
707            stroke_width: None,
708            size: Some(32.0),
709            style: Default::default(),
710        });
711        let svg = element_to_svg(&el).unwrap();
712        assert!(svg.contains("<circle"));
713    }
714
715    #[cfg(feature = "rasterize")]
716    #[test]
717    fn chart_png_data_uri() {
718        let el = WidgetElement::Chart(ChartElement {
719            chart_type: ChartType::Line,
720            chart_data: vec![
721                ChartDataPoint {
722                    label: "a".into(),
723                    value: 1.0,
724                    color: None,
725                },
726                ChartDataPoint {
727                    label: "b".into(),
728                    value: 2.0,
729                    color: None,
730                },
731            ],
732            tint: None,
733            style: Default::default(),
734        });
735        let uri = element_to_png_data_uri(&el).unwrap();
736        assert!(uri.starts_with("data:image/png;base64,"));
737        assert!(uri.len() > 64);
738    }
739}