1use 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
15pub 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
66pub 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
79pub 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 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('&', "&")
142 .replace('<', "<")
143 .replace('>', ">")
144 .replace('"', """)
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 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}