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, class, data } => {
82 let origin_x = x + width / 2.0;
84 let origin_y = y + height;
85 write!(
86 buf,
87 r#"<rect x="{}" y="{}" width="{}" height="{}" fill="{}" style="transform-origin: {}px {}px;""#,
88 x, y, width, height, xml_escape(fill), origin_x, origin_y,
89 ).unwrap();
90 if let Some(s) = stroke {
91 write!(buf, r#" stroke="{}""#, xml_escape(s)).unwrap();
92 }
93 if !class.is_empty() {
94 write!(buf, r#" class="{}""#, xml_escape(class)).unwrap();
95 }
96 write_data_attrs(buf, data);
97 buf.push_str("/>");
98 }
99
100 ChartElement::Path { d, fill, stroke, stroke_width, stroke_dasharray, opacity, class, data } => {
101 write!(buf, r#"<path d="{}""#, xml_escape(d)).unwrap();
102 match fill.as_deref() {
103 Some(f) => write!(buf, r#" fill="{}""#, xml_escape(f)).unwrap(),
104 None => buf.push_str(r#" fill="none""#),
105 }
106 match stroke.as_deref() {
107 Some(s) => write!(buf, r#" stroke="{}""#, xml_escape(s)).unwrap(),
108 None => buf.push_str(r#" stroke="none""#),
109 }
110 if let Some(sw) = stroke_width {
111 write!(buf, r#" stroke-width="{}""#, sw).unwrap();
112 }
113 if let Some(sda) = stroke_dasharray {
114 write!(buf, r#" stroke-dasharray="{}""#, xml_escape(sda)).unwrap();
115 }
116 if let Some(op) = opacity {
117 write!(buf, r#" opacity="{}""#, op).unwrap();
118 }
119 if !class.is_empty() {
120 write!(buf, r#" class="{}""#, xml_escape(class)).unwrap();
121 }
122 write_data_attrs(buf, data);
123 buf.push_str("/>");
124 }
125
126 ChartElement::Circle { cx, cy, r, fill, stroke, class, data } => {
127 write!(
128 buf,
129 r#"<circle cx="{}" cy="{}" r="{}" fill="{}""#,
130 cx, cy, r, xml_escape(fill),
131 ).unwrap();
132 if let Some(s) = stroke {
133 write!(buf, r#" stroke="{}""#, xml_escape(s)).unwrap();
134 }
135 if !class.is_empty() {
136 write!(buf, r#" class="{}""#, xml_escape(class)).unwrap();
137 }
138 write_data_attrs(buf, data);
139 buf.push_str("/>");
140 }
141
142 ChartElement::Line { x1, y1, x2, y2, stroke, stroke_width, stroke_dasharray, class } => {
143 write!(
144 buf,
145 r#"<line x1="{}" y1="{}" x2="{}" y2="{}" stroke="{}""#,
146 x1, y1, x2, y2, xml_escape(stroke),
147 ).unwrap();
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 !class.is_empty() {
155 write!(buf, r#" class="{}""#, xml_escape(class)).unwrap();
156 }
157 buf.push_str("/>");
158 }
159
160 ChartElement::Text { x, y, content, anchor, dominant_baseline, transform, font_size, font_weight, fill, class, .. } => {
161 write!(
162 buf,
163 r#"<text x="{}" y="{}" text-anchor="{}" font-family="{}""#,
164 x, y, anchor, DEFAULT_FONT_FAMILY,
165 ).unwrap();
166 if let Some(db) = dominant_baseline {
167 write!(buf, r#" dominant-baseline="{}""#, xml_escape(db)).unwrap();
168 }
169 if let Some(t) = transform {
170 write!(buf, r#" transform="{}""#, t.to_svg_string()).unwrap();
171 }
172 if let Some(fs) = font_size {
173 write!(buf, r#" font-size="{}""#, xml_escape(fs)).unwrap();
174 }
175 if let Some(fw) = font_weight {
176 write!(buf, r#" font-weight="{}""#, xml_escape(fw)).unwrap();
177 }
178 if let Some(f) = fill {
179 write!(buf, r#" fill="{}""#, xml_escape(f)).unwrap();
180 }
181 if !class.is_empty() {
182 write!(buf, r#" class="{}""#, xml_escape(class)).unwrap();
183 }
184 buf.push('>');
185 buf.push_str(&xml_escape(content));
186 buf.push_str("</text>");
187 }
188
189 ChartElement::Div { class, style, children } => {
190 buf.push_str(r#"<div xmlns="http://www.w3.org/1999/xhtml""#);
191 if !class.is_empty() {
192 write!(buf, r#" class="{}""#, xml_escape(class)).unwrap();
193 }
194 let style_str = style_map_to_string(style);
195 if !style_str.is_empty() {
196 write!(buf, r#" style="{}""#, xml_escape(&style_str)).unwrap();
197 }
198 buf.push('>');
199 for child in children {
200 write_element(buf, child);
201 }
202 buf.push_str("</div>");
203 }
204
205 ChartElement::Span { class, style, content } => {
206 buf.push_str("<span");
207 if !class.is_empty() {
208 write!(buf, r#" class="{}""#, xml_escape(class)).unwrap();
209 }
210 let style_str = style_map_to_string(style);
211 if !style_str.is_empty() {
212 write!(buf, r#" style="{}""#, xml_escape(&style_str)).unwrap();
213 }
214 buf.push('>');
215 buf.push_str(&xml_escape(content));
216 buf.push_str("</span>");
217 }
218 }
219}
220
221fn style_map_to_string(style: &std::collections::HashMap<String, String>) -> String {
223 let mut pairs: Vec<_> = style.iter().collect();
224 pairs.sort_by_key(|(k, _)| (*k).clone());
225 pairs.iter()
226 .map(|(k, v)| format!("{}: {}", k, v))
227 .collect::<Vec<_>>()
228 .join("; ")
229}
230
231fn write_data_attrs(buf: &mut String, data: &Option<ElementData>) {
233 if let Some(d) = data {
234 if !d.label.is_empty() {
235 write!(buf, r#" data-label="{}""#, xml_escape(&d.label)).unwrap();
236 }
237 if !d.value.is_empty() {
238 write!(buf, r#" data-value="{}""#, xml_escape(&d.value)).unwrap();
239 }
240 if let Some(ref s) = d.series {
241 write!(buf, r#" data-series="{}""#, xml_escape(s)).unwrap();
242 }
243 }
244}
245
246fn xml_escape(s: &str) -> String {
248 let mut result = String::with_capacity(s.len());
249 for c in s.chars() {
250 match c {
251 '&' => result.push_str("&"),
252 '<' => result.push_str("<"),
253 '>' => result.push_str(">"),
254 '"' => result.push_str("""),
255 '\'' => result.push_str("'"),
256 _ => result.push(c),
257 }
258 }
259 result
260}
261
262#[cfg(test)]
263mod tests {
264 use super::*;
265 use chartml_core::element::{ViewBox, Transform, TextAnchor, ElementData};
266 use std::collections::HashMap;
267
268 #[test]
269 fn simple_svg_with_rect() {
270 let element = ChartElement::Svg {
271 viewbox: ViewBox::new(0.0, 0.0, 800.0, 400.0),
272 width: Some(800.0),
273 height: Some(400.0),
274 class: "chart".to_string(),
275 children: vec![
276 ChartElement::Rect {
277 x: 10.0, y: 20.0, width: 50.0, height: 100.0,
278 fill: "#ff0000".to_string(), stroke: None,
279 class: "bar".to_string(), data: None,
280 },
281 ],
282 };
283
284 let svg = element_to_svg(&element, 800.0, 400.0);
285 assert!(svg.contains(r#"<svg xmlns="http://www.w3.org/2000/svg""#));
286 assert!(svg.contains(r#"viewBox="0 0 800 400""#));
287 assert!(svg.contains(r##"fill="#ff0000""##));
288 assert!(svg.contains("</svg>"));
289 }
290
291 #[test]
292 fn group_with_transform() {
293 let element = ChartElement::Group {
294 class: "bars".to_string(),
295 transform: Some(Transform::Translate(10.0, 20.0)),
296 children: vec![
297 ChartElement::Rect {
298 x: 0.0, y: 0.0, width: 50.0, height: 100.0,
299 fill: "blue".to_string(), stroke: Some("black".to_string()),
300 class: "".to_string(), data: None,
301 },
302 ],
303 };
304
305 let svg = element_to_svg(&element, 800.0, 400.0);
306 assert!(svg.contains(r#"transform="translate(10,20)""#));
309 }
310
311 #[test]
312 fn text_element_with_font() {
313 let element = ChartElement::Svg {
314 viewbox: ViewBox::new(0.0, 0.0, 800.0, 400.0),
315 width: Some(800.0),
316 height: Some(400.0),
317 class: "".to_string(),
318 children: vec![
319 ChartElement::Text {
320 x: 400.0, y: 20.0,
321 content: "Revenue & Costs".to_string(),
322 anchor: TextAnchor::Middle,
323 dominant_baseline: None,
324 transform: None,
325 font_size: Some("16".to_string()),
326 font_weight: Some("bold".to_string()),
327 fill: Some("#333".to_string()),
328 class: "title".to_string(),
329 data: None,
330 },
331 ],
332 };
333
334 let svg = element_to_svg(&element, 800.0, 400.0);
335 assert!(svg.contains(r#"font-family="Inter, Liberation Sans, Arial, sans-serif""#));
336 assert!(svg.contains("Revenue & Costs"));
337 assert!(svg.contains(r#"text-anchor="middle""#));
338 }
339
340 #[test]
341 fn path_element() {
342 let element = ChartElement::Svg {
343 viewbox: ViewBox::new(0.0, 0.0, 100.0, 100.0),
344 width: Some(100.0),
345 height: Some(100.0),
346 class: "".to_string(),
347 children: vec![
348 ChartElement::Path {
349 d: "M0,0 L100,100".to_string(),
350 fill: None,
351 stroke: Some("#000".to_string()),
352 stroke_width: Some(2.0),
353 stroke_dasharray: Some("5,3".to_string()),
354 opacity: Some(0.5),
355 class: "line".to_string(),
356 data: None,
357 },
358 ],
359 };
360
361 let svg = element_to_svg(&element, 100.0, 100.0);
362 assert!(svg.contains(r#"d="M0,0 L100,100""#));
363 assert!(svg.contains(r#"fill="none""#));
364 assert!(svg.contains(r##"stroke="#000""##));
365 assert!(svg.contains(r#"stroke-width="2""#));
366 assert!(svg.contains(r#"stroke-dasharray="5,3""#));
367 assert!(svg.contains(r#"opacity="0.5""#));
368 }
369
370 #[test]
371 fn circle_element() {
372 let element = ChartElement::Svg {
373 viewbox: ViewBox::new(0.0, 0.0, 100.0, 100.0),
374 width: Some(100.0),
375 height: Some(100.0),
376 class: "".to_string(),
377 children: vec![
378 ChartElement::Circle {
379 cx: 50.0, cy: 50.0, r: 5.0,
380 fill: "red".to_string(), stroke: None,
381 class: "dot".to_string(), data: None,
382 },
383 ],
384 };
385
386 let svg = element_to_svg(&element, 100.0, 100.0);
387 assert!(svg.contains(r#"<circle cx="50" cy="50" r="5" fill="red""#));
388 }
389
390 #[test]
391 fn line_element() {
392 let element = ChartElement::Svg {
393 viewbox: ViewBox::new(0.0, 0.0, 100.0, 100.0),
394 width: Some(100.0),
395 height: Some(100.0),
396 class: "".to_string(),
397 children: vec![
398 ChartElement::Line {
399 x1: 0.0, y1: 0.0, x2: 100.0, y2: 100.0,
400 stroke: "#ccc".to_string(),
401 stroke_width: Some(1.0),
402 stroke_dasharray: None,
403 class: "grid".to_string(),
404 },
405 ],
406 };
407
408 let svg = element_to_svg(&element, 100.0, 100.0);
409 assert!(svg.contains(r##"stroke="#ccc""##));
410 }
411
412 #[test]
413 fn div_span_metric_card() {
414 let element = ChartElement::Div {
415 class: "metric".to_string(),
416 style: HashMap::from([
417 ("font-size".to_string(), "36px".to_string()),
418 ("color".to_string(), "#333".to_string()),
419 ]),
420 children: vec![
421 ChartElement::Span {
422 class: "value".to_string(),
423 style: HashMap::new(),
424 content: "$1,234".to_string(),
425 },
426 ],
427 };
428
429 let svg = element_to_svg(&element, 200.0, 100.0);
430 assert!(svg.contains(r#"<svg xmlns="http://www.w3.org/2000/svg""#));
432 assert!(svg.contains("<foreignObject"));
433 assert!(svg.contains(r#"<div xmlns="http://www.w3.org/1999/xhtml""#));
434 assert!(svg.contains("$1,234"));
435 }
436
437 #[test]
438 fn xml_escape_special_chars() {
439 assert_eq!(xml_escape("a & b"), "a & b");
440 assert_eq!(xml_escape("<script>"), "<script>");
441 assert_eq!(xml_escape(r#"say "hi""#), "say "hi"");
442 }
443
444 #[test]
445 fn interactive_data_ignored_for_svg() {
446 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::Rect {
454 x: 0.0, y: 0.0, width: 50.0, height: 50.0,
455 fill: "blue".to_string(), stroke: None,
456 class: "".to_string(),
457 data: Some(ElementData::new("Jan", "1234")),
458 },
459 ],
460 };
461
462 let svg = element_to_svg(&element, 100.0, 100.0);
463 assert!(svg.contains(r#"<rect x="0" y="0""#));
465 assert!(!svg.contains("Jan"));
467 assert!(!svg.contains("1234"));
468 }
469}