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