1use crate::element::{ChartElement, ElementData};
13use std::fmt::Write;
14
15const DEFAULT_FONT_FAMILY: &str = "Inter, Liberation Sans, Arial, sans-serif";
17
18pub fn element_to_svg(element: &ChartElement, width: f64, height: f64) -> String {
23 let mut buf = String::with_capacity(4096);
24
25 match element {
26 ChartElement::Svg { .. } => {
27 write_element(&mut buf, element);
28 }
29 _ => {
31 write!(
32 &mut buf,
33 r#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 {} {}" width="{}" height="{}">"#,
34 width, height, width, height,
35 ).unwrap();
36 buf.push_str(r#"<foreignObject x="0" y="0" width="100%" height="100%">"#);
37 write_element(&mut buf, element);
38 buf.push_str("</foreignObject>");
39 buf.push_str("</svg>");
40 }
41 }
42
43 buf
44}
45
46fn write_element(buf: &mut String, element: &ChartElement) {
48 match element {
49 ChartElement::Svg { viewbox, width, height, class, children } => {
50 write!(
51 buf,
52 r##"<svg xmlns="http://www.w3.org/2000/svg" viewBox="{}""##,
53 viewbox.to_svg_string(),
54 ).unwrap();
55 if let Some(w) = width {
56 write!(buf, r#" width="{}""#, w).unwrap();
57 }
58 if let Some(h) = height {
59 write!(buf, r#" height="{}""#, h).unwrap();
60 }
61 if !class.is_empty() {
62 write!(buf, r#" class="{}""#, xml_escape(class)).unwrap();
63 }
64 buf.push('>');
65 for child in children {
66 write_element(buf, child);
67 }
68 buf.push_str("</svg>");
69 }
70
71 ChartElement::Group { class, transform, children } => {
72 buf.push_str("<g");
73 if !class.is_empty() {
74 write!(buf, r#" class="{}""#, xml_escape(class)).unwrap();
75 }
76 if let Some(t) = transform {
77 write!(buf, r#" transform="{}""#, t.to_svg_string()).unwrap();
78 }
79 buf.push('>');
80 for child in children {
81 write_element(buf, child);
82 }
83 buf.push_str("</g>");
84 }
85
86 ChartElement::Rect { x, y, width, height, fill, stroke, rx, ry, class, data, animation_origin: _ } => {
87 let (origin_x, origin_y) = if width > height {
104 (*x, y + height / 2.0)
105 } else {
106 (x + width / 2.0, y + height)
107 };
108 write!(
109 buf,
110 r#"<rect x="{}" y="{}" width="{}" height="{}" fill="{}" style="transform-origin: {}px {}px;""#,
111 x, y, width, height, xml_escape(fill), origin_x, origin_y,
112 ).unwrap();
113 if let Some(s) = stroke {
114 write!(buf, r#" stroke="{}""#, xml_escape(s)).unwrap();
115 }
116 if let Some(r) = rx {
117 write!(buf, r#" rx="{}""#, r).unwrap();
118 }
119 if let Some(r) = ry {
120 write!(buf, r#" ry="{}""#, r).unwrap();
121 }
122 if !class.is_empty() {
123 write!(buf, r#" class="{}""#, xml_escape(class)).unwrap();
124 }
125 write_data_attrs(buf, data);
126 buf.push_str("/>");
127 }
128
129 ChartElement::Path { d, fill, stroke, stroke_width, stroke_dasharray, opacity, class, data, animation_origin } => {
130 write!(buf, r#"<path d="{}""#, xml_escape(d)).unwrap();
131 if let Some((ox, oy)) = animation_origin {
138 write!(buf, r#" style="transform-origin: {}px {}px;""#, ox, oy).unwrap();
139 }
140 match fill.as_deref() {
141 Some(f) => write!(buf, r#" fill="{}""#, xml_escape(f)).unwrap(),
142 None => buf.push_str(r#" fill="none""#),
143 }
144 match stroke.as_deref() {
145 Some(s) => write!(buf, r#" stroke="{}""#, xml_escape(s)).unwrap(),
146 None => buf.push_str(r#" stroke="none""#),
147 }
148 if let Some(sw) = stroke_width {
149 write!(buf, r#" stroke-width="{}""#, sw).unwrap();
150 }
151 if let Some(sda) = stroke_dasharray {
152 write!(buf, r#" stroke-dasharray="{}""#, xml_escape(sda)).unwrap();
153 }
154 if let Some(op) = opacity {
155 write!(buf, r#" opacity="{}""#, op).unwrap();
156 }
157 if !class.is_empty() {
158 write!(buf, r#" class="{}""#, xml_escape(class)).unwrap();
159 }
160 write_data_attrs(buf, data);
161 buf.push_str("/>");
162 }
163
164 ChartElement::Circle { cx, cy, r, fill, stroke, class, data } => {
165 write!(
166 buf,
167 r#"<circle cx="{}" cy="{}" r="{}" fill="{}""#,
168 cx, cy, r, xml_escape(fill),
169 ).unwrap();
170 if let Some(s) = stroke {
171 write!(buf, r#" stroke="{}""#, xml_escape(s)).unwrap();
172 }
173 if !class.is_empty() {
174 write!(buf, r#" class="{}""#, xml_escape(class)).unwrap();
175 }
176 write_data_attrs(buf, data);
177 buf.push_str("/>");
178 }
179
180 ChartElement::Line { x1, y1, x2, y2, stroke, stroke_width, stroke_dasharray, class } => {
181 write!(
182 buf,
183 r#"<line x1="{}" y1="{}" x2="{}" y2="{}" stroke="{}""#,
184 x1, y1, x2, y2, xml_escape(stroke),
185 ).unwrap();
186 if let Some(sw) = stroke_width {
187 write!(buf, r#" stroke-width="{}""#, sw).unwrap();
188 }
189 if let Some(sda) = stroke_dasharray {
190 write!(buf, r#" stroke-dasharray="{}""#, xml_escape(sda)).unwrap();
191 }
192 if !class.is_empty() {
193 write!(buf, r#" class="{}""#, xml_escape(class)).unwrap();
194 }
195 buf.push_str("/>");
196 }
197
198 ChartElement::Text {
199 x, y, content, anchor, dominant_baseline, transform,
200 font_family, font_size, font_weight, letter_spacing, text_transform,
201 fill, class, ..
202 } => {
203 let family = font_family.as_deref().unwrap_or(DEFAULT_FONT_FAMILY);
207 write!(
208 buf,
209 r#"<text x="{}" y="{}" text-anchor="{}" font-family="{}""#,
210 x, y, anchor, xml_escape(family),
211 ).unwrap();
212 if let Some(db) = dominant_baseline {
213 write!(buf, r#" dominant-baseline="{}""#, xml_escape(db)).unwrap();
214 }
215 if let Some(t) = transform {
216 write!(buf, r#" transform="{}""#, t.to_svg_string()).unwrap();
217 }
218 if let Some(fs) = font_size {
219 write!(buf, r#" font-size="{}""#, xml_escape(fs)).unwrap();
220 }
221 if let Some(fw) = font_weight {
222 write!(buf, r#" font-weight="{}""#, xml_escape(fw)).unwrap();
223 }
224 if let Some(ls) = letter_spacing {
225 write!(buf, r#" letter-spacing="{}""#, xml_escape(ls)).unwrap();
226 }
227 if let Some(tt) = text_transform {
228 write!(buf, r#" text-transform="{}""#, xml_escape(tt)).unwrap();
229 }
230 if let Some(f) = fill {
231 write!(buf, r#" fill="{}""#, xml_escape(f)).unwrap();
232 }
233 if !class.is_empty() {
234 write!(buf, r#" class="{}""#, xml_escape(class)).unwrap();
235 }
236 buf.push('>');
237 buf.push_str(&xml_escape(content));
238 buf.push_str("</text>");
239 }
240
241 ChartElement::Div { class, style, children } => {
242 buf.push_str(r#"<div xmlns="http://www.w3.org/1999/xhtml""#);
243 if !class.is_empty() {
244 write!(buf, r#" class="{}""#, xml_escape(class)).unwrap();
245 }
246 let style_str = style_map_to_string(style);
247 if !style_str.is_empty() {
248 write!(buf, r#" style="{}""#, xml_escape(&style_str)).unwrap();
249 }
250 buf.push('>');
251 for child in children {
252 write_element(buf, child);
253 }
254 buf.push_str("</div>");
255 }
256
257 ChartElement::Span { class, style, content } => {
258 buf.push_str("<span");
259 if !class.is_empty() {
260 write!(buf, r#" class="{}""#, xml_escape(class)).unwrap();
261 }
262 let style_str = style_map_to_string(style);
263 if !style_str.is_empty() {
264 write!(buf, r#" style="{}""#, xml_escape(&style_str)).unwrap();
265 }
266 buf.push('>');
267 buf.push_str(&xml_escape(content));
268 buf.push_str("</span>");
269 }
270 }
271}
272
273fn style_map_to_string(style: &std::collections::HashMap<String, String>) -> String {
275 let mut pairs: Vec<_> = style.iter().collect();
276 pairs.sort_by_key(|(k, _)| (*k).clone());
277 pairs.iter()
278 .map(|(k, v)| format!("{}: {}", k, v))
279 .collect::<Vec<_>>()
280 .join("; ")
281}
282
283fn write_data_attrs(buf: &mut String, data: &Option<ElementData>) {
285 if let Some(d) = data {
286 if !d.label.is_empty() {
287 write!(buf, r#" data-label="{}""#, xml_escape(&d.label)).unwrap();
288 }
289 if !d.value.is_empty() {
290 write!(buf, r#" data-value="{}""#, xml_escape(&d.value)).unwrap();
291 }
292 if let Some(ref s) = d.series {
293 write!(buf, r#" data-series="{}""#, xml_escape(s)).unwrap();
294 }
295 }
296}
297
298fn xml_escape(s: &str) -> String {
300 let mut result = String::with_capacity(s.len());
301 for c in s.chars() {
302 match c {
303 '&' => result.push_str("&"),
304 '<' => result.push_str("<"),
305 '>' => result.push_str(">"),
306 '"' => result.push_str("""),
307 '\'' => result.push_str("'"),
308 _ => result.push(c),
309 }
310 }
311 result
312}
313
314#[cfg(test)]
315mod tests {
316 use super::*;
317 use crate::element::{ViewBox, Transform, TextAnchor, ElementData};
318 use std::collections::HashMap;
319
320 #[test]
321 fn simple_svg_with_rect() {
322 let element = ChartElement::Svg {
323 viewbox: ViewBox::new(0.0, 0.0, 800.0, 400.0),
324 width: Some(800.0),
325 height: Some(400.0),
326 class: "chart".to_string(),
327 children: vec![
328 ChartElement::Rect {
329 x: 10.0, y: 20.0, width: 50.0, height: 100.0,
330 fill: "#ff0000".to_string(), stroke: None,
331 rx: None, ry: None,
332 class: "bar".to_string(), data: None,
333 animation_origin: None,
334 },
335 ],
336 };
337
338 let svg = element_to_svg(&element, 800.0, 400.0);
339 assert!(svg.contains(r#"<svg xmlns="http://www.w3.org/2000/svg""#));
340 assert!(svg.contains(r#"viewBox="0 0 800 400""#));
341 assert!(svg.contains(r##"fill="#ff0000""##));
342 assert!(svg.contains("</svg>"));
343 }
344
345 #[test]
346 fn group_with_transform() {
347 let element = ChartElement::Group {
348 class: "bars".to_string(),
349 transform: Some(Transform::Translate(10.0, 20.0)),
350 children: vec![
351 ChartElement::Rect {
352 x: 0.0, y: 0.0, width: 50.0, height: 100.0,
353 fill: "blue".to_string(), stroke: Some("black".to_string()),
354 rx: None, ry: None,
355 class: "".to_string(), data: None,
356 animation_origin: None,
357 },
358 ],
359 };
360
361 let svg = element_to_svg(&element, 800.0, 400.0);
362 assert!(svg.contains(r#"transform="translate(10,20)""#));
366 }
367
368 #[test]
369 fn text_element_with_font() {
370 let element = ChartElement::Svg {
371 viewbox: ViewBox::new(0.0, 0.0, 800.0, 400.0),
372 width: Some(800.0),
373 height: Some(400.0),
374 class: "".to_string(),
375 children: vec![
376 ChartElement::Text {
377 x: 400.0, y: 20.0,
378 content: "Revenue & Costs".to_string(),
379 anchor: TextAnchor::Middle,
380 dominant_baseline: None,
381 transform: None,
382 font_family: None,
383 font_size: Some("16".to_string()),
384 font_weight: Some("bold".to_string()),
385 letter_spacing: None,
386 text_transform: None,
387 fill: Some("#333".to_string()),
388 class: "title".to_string(),
389 data: None,
390 },
391 ],
392 };
393
394 let svg = element_to_svg(&element, 800.0, 400.0);
395 assert!(svg.contains(r#"font-family="Inter, Liberation Sans, Arial, sans-serif""#));
396 assert!(svg.contains("Revenue & Costs"));
397 assert!(svg.contains(r#"text-anchor="middle""#));
398 }
399
400 #[test]
401 fn path_element() {
402 let element = ChartElement::Svg {
403 viewbox: ViewBox::new(0.0, 0.0, 100.0, 100.0),
404 width: Some(100.0),
405 height: Some(100.0),
406 class: "".to_string(),
407 children: vec![
408 ChartElement::Path {
409 d: "M0,0 L100,100".to_string(),
410 fill: None,
411 stroke: Some("#000".to_string()),
412 stroke_width: Some(2.0),
413 stroke_dasharray: Some("5,3".to_string()),
414 opacity: Some(0.5),
415 class: "line".to_string(),
416 data: None,
417 animation_origin: None,
418 },
419 ],
420 };
421
422 let svg = element_to_svg(&element, 100.0, 100.0);
423 assert!(svg.contains(r#"d="M0,0 L100,100""#));
424 assert!(svg.contains(r#"fill="none""#));
425 assert!(svg.contains(r##"stroke="#000""##));
426 assert!(svg.contains(r#"stroke-width="2""#));
427 assert!(svg.contains(r#"stroke-dasharray="5,3""#));
428 assert!(svg.contains(r#"opacity="0.5""#));
429 }
430
431 #[test]
432 fn circle_element() {
433 let element = ChartElement::Svg {
434 viewbox: ViewBox::new(0.0, 0.0, 100.0, 100.0),
435 width: Some(100.0),
436 height: Some(100.0),
437 class: "".to_string(),
438 children: vec![
439 ChartElement::Circle {
440 cx: 50.0, cy: 50.0, r: 5.0,
441 fill: "red".to_string(), stroke: None,
442 class: "dot".to_string(), data: None,
443 },
444 ],
445 };
446
447 let svg = element_to_svg(&element, 100.0, 100.0);
448 assert!(svg.contains(r#"<circle cx="50" cy="50" r="5" fill="red""#));
449 }
450
451 #[test]
452 fn line_element() {
453 let element = ChartElement::Svg {
454 viewbox: ViewBox::new(0.0, 0.0, 100.0, 100.0),
455 width: Some(100.0),
456 height: Some(100.0),
457 class: "".to_string(),
458 children: vec![
459 ChartElement::Line {
460 x1: 0.0, y1: 0.0, x2: 100.0, y2: 100.0,
461 stroke: "red".to_string(),
462 stroke_width: Some(1.0),
463 stroke_dasharray: None,
464 class: "grid".to_string(),
465 },
466 ],
467 };
468
469 let svg = element_to_svg(&element, 100.0, 100.0);
470 assert!(svg.contains(r##"stroke="red""##));
471 }
472
473 #[test]
474 fn div_span_metric_card() {
475 let element = ChartElement::Div {
476 class: "metric".to_string(),
477 style: HashMap::from([
478 ("font-size".to_string(), "36px".to_string()),
479 ("color".to_string(), "#333".to_string()),
480 ]),
481 children: vec![
482 ChartElement::Span {
483 class: "value".to_string(),
484 style: HashMap::new(),
485 content: "$1,234".to_string(),
486 },
487 ],
488 };
489
490 let svg = element_to_svg(&element, 200.0, 100.0);
491 assert!(svg.contains(r#"<svg xmlns="http://www.w3.org/2000/svg""#));
493 assert!(svg.contains("<foreignObject"));
494 assert!(svg.contains(r#"<div xmlns="http://www.w3.org/1999/xhtml""#));
495 assert!(svg.contains("$1,234"));
496 }
497
498 #[test]
499 fn xml_escape_special_chars() {
500 assert_eq!(xml_escape("a & b"), "a & b");
501 assert_eq!(xml_escape("<script>"), "<script>");
502 assert_eq!(xml_escape(r#"say "hi""#), "say "hi"");
503 }
504
505 #[test]
506 fn interactive_data_ignored_for_svg() {
507 let element = ChartElement::Svg {
509 viewbox: ViewBox::new(0.0, 0.0, 100.0, 100.0),
510 width: Some(100.0),
511 height: Some(100.0),
512 class: "".to_string(),
513 children: vec![
514 ChartElement::Rect {
515 x: 0.0, y: 0.0, width: 50.0, height: 50.0,
516 fill: "blue".to_string(), stroke: None,
517 rx: None, ry: None,
518 class: "".to_string(),
519 data: Some(ElementData::new("Jan", "1234")),
520 animation_origin: None,
521 },
522 ],
523 };
524
525 let svg = element_to_svg(&element, 100.0, 100.0);
526 assert!(svg.contains(r#"<rect x="0" y="0""#));
528 assert!(svg.contains(r#"data-label="Jan""#));
529 assert!(svg.contains(r#"data-value="1234""#));
530 }
531}