1use 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
19pub 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
89pub 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 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%"), };
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
143pub 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 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 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
242fn 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
269pub 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
282pub 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 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('&', "&")
345 .replace('<', "<")
346 .replace('>', ">")
347 .replace('"', """)
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 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}