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