1use std::path::Path;
4
5use quick_xml::Reader;
6use quick_xml::events::Event;
7use serde::{Deserialize, Serialize};
8
9use crate::convert::{ConvertOptions, PageConsumer, read_limited_file};
10use crate::error::{Error, Result};
11use crate::ir::{
12 IDENTITY, LineCap, LineJoin, Node, Page, Paint, SourceMeta, Stroke, TextAnchor, TextRun,
13};
14
15#[derive(Clone, Debug, Serialize, Deserialize)]
16#[serde(rename_all = "snake_case")]
17pub enum ChartType {
18 Bar,
19 Line,
20 Pie,
21 #[serde(alias = "donut")]
22 Doughnut,
23 Area,
24 Scatter,
25}
26
27#[derive(Clone, Debug, Serialize, Deserialize)]
28pub struct ChartSeries {
29 pub name: String,
30 pub data: Vec<f64>,
31 pub color: Option<String>,
32}
33
34#[derive(Clone, Debug, Serialize, Deserialize)]
35pub struct ChartSpec {
36 pub r#type: ChartType,
37 pub title: Option<String>,
38 pub labels: Vec<String>,
39 pub series: Vec<ChartSeries>,
40}
41
42pub(crate) fn convert(
43 path: &Path,
44 options: &ConvertOptions,
45 sink: &mut dyn PageConsumer,
46) -> Result<Vec<String>> {
47 let bytes = read_limited_file(path, options.max_input_bytes, "chart input")?;
48 let spec: ChartSpec = serde_json::from_slice(&bytes)
49 .map_err(|e| Error::InvalidInput(format!("chart JSON is invalid: {e}")))?;
50
51 let page = layout_and_render_chart(&spec, options)?;
52 sink.consume(page)?;
53 Ok(Vec::new())
54}
55
56pub fn layout_and_render_chart(spec: &ChartSpec, _options: &ConvertOptions) -> Result<Page> {
57 let page_width = 640.0;
58 let page_height = 400.0;
59 let mut page = Page::new(1, page_width, page_height, "chart");
60 if let Ok(json_str) = serde_json::to_string(spec) {
61 page.embedded_source = Some(json_str);
62 }
63
64 let default_palette = [
65 "#3b82f6", "#10b981", "#f59e0b", "#ef4444", "#8b5cf6", "#06b6d4", ];
72
73 let plot_left = 60.0;
74 let plot_right = page_width - 40.0;
75 let plot_top = if spec.title.is_some() { 60.0 } else { 40.0 };
76 let plot_bottom = page_height - 60.0;
77 let plot_width = plot_right - plot_left;
78 let plot_height = plot_bottom - plot_top;
79
80 if let Some(ref title) = spec.title {
82 page.nodes.push(Node::Text {
83 id: String::new(),
84 x: page_width / 2.0,
85 y: 28.0,
86 runs: vec![TextRun {
87 text: title.clone(),
88 font_size: 16.0,
89 font_family: "Helvetica, Arial, sans-serif".to_string(),
90 bold: true,
91 fill: Paint::solid("#0f172a"),
92 ..Default::default()
93 }],
94 anchor: TextAnchor::Middle,
95 transform: IDENTITY,
96 opacity: 1.0,
97 stroke: Stroke::default(),
98 clip_id: None,
99 meta: SourceMeta::default(),
100 });
101 }
102
103 if matches!(
105 spec.r#type,
106 ChartType::Bar | ChartType::Line | ChartType::Area | ChartType::Scatter
107 ) && spec.series.len() > 1
108 {
109 let mut legend_x = plot_left;
110 let legend_y = if spec.title.is_some() { 48.0 } else { 24.0 };
111
112 for (s_idx, s) in spec.series.iter().enumerate() {
113 let color = s
114 .color
115 .as_deref()
116 .unwrap_or(default_palette[s_idx % default_palette.len()]);
117 let marker_d = format!("M {:.1},{:.1} h 10 v 10 h -10 Z", legend_x, legend_y - 8.0);
119 page.nodes.push(Node::Path {
120 id: String::new(),
121 d: marker_d,
122 fill_rule: String::new(),
123 fill: Paint::solid(color),
124 stroke: Stroke::default(),
125 transform: IDENTITY,
126 clip_id: None,
127 meta: SourceMeta::default(),
128 });
129
130 page.nodes.push(Node::Text {
132 id: String::new(),
133 x: legend_x + 14.0,
134 y: legend_y,
135 runs: vec![TextRun {
136 text: s.name.clone(),
137 font_size: 11.0,
138 font_family: "Helvetica, Arial, sans-serif".to_string(),
139 fill: Paint::solid("#475569"),
140 ..Default::default()
141 }],
142 anchor: TextAnchor::Start,
143 transform: IDENTITY,
144 opacity: 1.0,
145 stroke: Stroke::default(),
146 clip_id: None,
147 meta: SourceMeta::default(),
148 });
149
150 legend_x += (s.name.len() as f64 * 7.0) + 32.0;
151 }
152 }
153
154 match spec.r#type {
155 ChartType::Bar | ChartType::Line | ChartType::Area | ChartType::Scatter => {
156 let mut min_val = 0.0f64;
157 let mut max_val = 0.0f64;
158 let mut has_data = false;
159 for s in &spec.series {
160 for &v in &s.data {
161 if v.is_finite() {
162 if !has_data {
163 min_val = v;
164 max_val = v;
165 has_data = true;
166 } else {
167 if v < min_val {
168 min_val = v;
169 }
170 if v > max_val {
171 max_val = v;
172 }
173 }
174 }
175 }
176 }
177
178 let (min_y, max_y) = if !has_data {
179 (0.0, 10.0)
180 } else if min_val >= 0.0 {
181 (0.0, (max_val * 1.15).ceil().max(5.0))
182 } else if max_val <= 0.0 {
183 ((min_val * 1.15).floor().min(-5.0), 0.0)
184 } else {
185 let pad = ((max_val - min_val) * 0.1).max(1.0);
186 ((min_val - pad).floor(), (max_val + pad).ceil())
187 };
188
189 let range_y = (max_y - min_y).max(1e-6);
190 let map_y = |val: f64| -> f64 {
191 let frac = (val - min_y) / range_y;
192 plot_bottom - frac * plot_height
193 };
194 let zero_y = map_y(0.0).clamp(plot_top, plot_bottom);
195
196 let steps = 4;
197 for i in 0..=steps {
198 let val = min_y + (i as f64 / steps as f64) * (max_y - min_y);
199 let y = map_y(val);
200
201 let d = format!("M {:.2},{:.2} L {:.2},{:.2}", plot_left, y, plot_right, y);
203 let is_zero_line = val.abs() < 1e-6 || (i == 0 && min_y == 0.0);
204 page.nodes.push(Node::Path {
205 id: String::new(),
206 d,
207 fill_rule: String::new(),
208 fill: Paint::None,
209 stroke: Stroke {
210 paint: Paint::solid(if is_zero_line { "#94a3b8" } else { "#f1f5f9" }),
211 width: if is_zero_line { 1.2 } else { 1.0 },
212 line_cap: LineCap::Butt,
213 line_join: LineJoin::Miter,
214 ..Default::default()
215 },
216 transform: IDENTITY,
217 clip_id: None,
218 meta: SourceMeta::default(),
219 });
220
221 let text = format!("{val:.0}");
223 page.nodes.push(Node::Text {
224 id: String::new(),
225 x: plot_left - 8.0,
226 y: y + 4.0,
227 runs: vec![TextRun {
228 text,
229 font_size: 11.0,
230 font_family: "Helvetica, Arial, sans-serif".to_string(),
231 fill: Paint::solid("#64748b"),
232 ..Default::default()
233 }],
234 anchor: TextAnchor::End,
235 transform: IDENTITY,
236 opacity: 1.0,
237 stroke: Stroke::default(),
238 clip_id: None,
239 meta: SourceMeta::default(),
240 });
241 }
242
243 let max_data_len = spec.series.iter().map(|s| s.data.len()).max().unwrap_or(0);
244 let cat_count = spec.labels.len().max(max_data_len).max(1);
245 let cat_width = plot_width / cat_count as f64;
246
247 for (idx, label) in spec.labels.iter().enumerate() {
249 let x = plot_left + (idx as f64 + 0.5) * cat_width;
250 page.nodes.push(Node::Text {
251 id: String::new(),
252 x,
253 y: plot_bottom + 20.0,
254 runs: vec![TextRun {
255 text: label.clone(),
256 font_size: 11.0,
257 font_family: "Helvetica, Arial, sans-serif".to_string(),
258 fill: Paint::solid("#475569"),
259 ..Default::default()
260 }],
261 anchor: TextAnchor::Middle,
262 transform: IDENTITY,
263 opacity: 1.0,
264 stroke: Stroke::default(),
265 clip_id: None,
266 meta: SourceMeta::default(),
267 });
268 }
269
270 if matches!(spec.r#type, ChartType::Bar) {
271 let series_count = spec.series.len().max(1);
272 let bar_group_width = cat_width * 0.7;
273 let single_bar_width = (bar_group_width / series_count as f64).min(40.0);
274
275 for (s_idx, s) in spec.series.iter().enumerate() {
276 let color = s
277 .color
278 .as_deref()
279 .unwrap_or(default_palette[s_idx % default_palette.len()]);
280 for (c_idx, &val) in s.data.iter().enumerate() {
281 if c_idx < cat_count {
282 let val = if val.is_finite() { val } else { 0.0 };
283 let val_y = map_y(val);
284 let (top_y, bottom_y) = if val >= 0.0 {
285 (val_y, zero_y)
286 } else {
287 (zero_y, val_y)
288 };
289 let group_start_x = plot_left
290 + (c_idx as f64 * cat_width)
291 + (cat_width - bar_group_width) / 2.0;
292 let bar_x = group_start_x + s_idx as f64 * single_bar_width;
293
294 let d = format!(
295 "M {:.2},{:.2} L {:.2},{:.2} L {:.2},{:.2} L {:.2},{:.2} Z",
296 bar_x,
297 bottom_y,
298 bar_x,
299 top_y,
300 bar_x + single_bar_width * 0.9,
301 top_y,
302 bar_x + single_bar_width * 0.9,
303 bottom_y
304 );
305
306 page.nodes.push(Node::Path {
307 id: String::new(),
308 d,
309 fill_rule: String::new(),
310 fill: Paint::solid(color),
311 stroke: Stroke::default(),
312 transform: IDENTITY,
313 clip_id: None,
314 meta: SourceMeta::default(),
315 });
316 }
317 }
318 }
319 } else {
320 for (s_idx, s) in spec.series.iter().enumerate() {
321 let color = s
322 .color
323 .as_deref()
324 .unwrap_or(default_palette[s_idx % default_palette.len()]);
325 let mut d = String::new();
326 let mut first_x = None;
327 let mut last_x = None;
328
329 for (c_idx, &val) in s.data.iter().enumerate() {
330 if c_idx < cat_count {
331 let val = if val.is_finite() { val } else { 0.0 };
332 let pt_x = plot_left + (c_idx as f64 + 0.5) * cat_width;
333 let pt_y = map_y(val);
334 if c_idx == 0 {
335 d.push_str(&format!("M {:.2},{:.2} ", pt_x, pt_y));
336 first_x = Some(pt_x);
337 } else {
338 d.push_str(&format!("L {:.2},{:.2} ", pt_x, pt_y));
339 }
340 last_x = Some(pt_x);
341 }
342 }
343
344 if matches!(spec.r#type, ChartType::Area)
345 && let (Some(fx), Some(lx)) = (first_x, last_x)
346 {
347 let mut area_d = d.clone();
348 area_d.push_str(&format!(
349 "L {:.2},{:.2} L {:.2},{:.2} Z",
350 lx, zero_y, fx, zero_y
351 ));
352 page.nodes.push(Node::Path {
353 id: String::new(),
354 d: area_d,
355 fill_rule: String::new(),
356 fill: Paint::Solid {
357 color: color.to_string(),
358 opacity: 0.25,
359 },
360 stroke: Stroke::default(),
361 transform: IDENTITY,
362 clip_id: None,
363 meta: SourceMeta::default(),
364 });
365 }
366
367 if !matches!(spec.r#type, ChartType::Scatter) {
368 page.nodes.push(Node::Path {
369 id: String::new(),
370 d,
371 fill_rule: String::new(),
372 fill: Paint::None,
373 stroke: Stroke {
374 paint: Paint::solid(color),
375 width: 2.5,
376 line_cap: LineCap::Round,
377 line_join: LineJoin::Round,
378 ..Default::default()
379 },
380 transform: IDENTITY,
381 clip_id: None,
382 meta: SourceMeta::default(),
383 });
384 }
385
386 for (c_idx, &val) in s.data.iter().enumerate() {
388 if c_idx < cat_count {
389 let val = if val.is_finite() { val } else { 0.0 };
390 let pt_x = plot_left + (c_idx as f64 + 0.5) * cat_width;
391 let pt_y = map_y(val);
392 let r = 4.0;
393 let c = 0.5522847498 * r;
394 let circle_d = format!(
395 "M {:.2},{:.2} C {:.2},{:.2} {:.2},{:.2} {:.2},{:.2} C {:.2},{:.2} {:.2},{:.2} {:.2},{:.2} C {:.2},{:.2} {:.2},{:.2} {:.2},{:.2} C {:.2},{:.2} {:.2},{:.2} {:.2},{:.2} Z",
396 pt_x,
397 pt_y - r,
398 pt_x + c,
399 pt_y - r,
400 pt_x + r,
401 pt_y - c,
402 pt_x + r,
403 pt_y,
404 pt_x + r,
405 pt_y + c,
406 pt_x + c,
407 pt_y + r,
408 pt_x,
409 pt_y + r,
410 pt_x - c,
411 pt_y + r,
412 pt_x - r,
413 pt_y + c,
414 pt_x - r,
415 pt_y,
416 pt_x - r,
417 pt_y - c,
418 pt_x - c,
419 pt_y - r,
420 pt_x,
421 pt_y - r
422 );
423 page.nodes.push(Node::Path {
424 id: String::new(),
425 d: circle_d,
426 fill_rule: String::new(),
427 fill: Paint::solid("#ffffff"),
428 stroke: Stroke {
429 paint: Paint::solid(color),
430 width: 2.0,
431 ..Default::default()
432 },
433 transform: IDENTITY,
434 clip_id: None,
435 meta: SourceMeta::default(),
436 });
437 }
438 }
439 }
440 }
441 }
442 ChartType::Pie | ChartType::Doughnut => {
443 let cx = page_width * 0.36;
444 let cy = plot_top + plot_height / 2.0;
445 let radius = (plot_height / 2.0).min(plot_width * 0.28) - 10.0;
446
447 let mut total_val = 0.0;
448 if let Some(first_series) = spec.series.first() {
449 for &v in &first_series.data {
450 if v.is_finite() && v > 0.0 {
451 total_val += v;
452 }
453 }
454
455 let mut current_angle = -std::f64::consts::FRAC_PI_2;
456 let legend_start_x = page_width * 0.62;
457 let mut legend_y = cy - (first_series.data.len() as f64 * 14.0);
458
459 for (idx, &v) in first_series.data.iter().enumerate() {
460 let v_clean = if v.is_finite() { v.max(0.0) } else { 0.0 };
461 let slice_angle = if total_val > 0.0 {
462 (v_clean / total_val) * std::f64::consts::TAU
463 } else {
464 0.0
465 };
466 let percent = if total_val > 0.0 {
467 (v_clean / total_val) * 100.0
468 } else {
469 0.0
470 };
471 let end_angle = current_angle + slice_angle;
472 let color = default_palette[idx % default_palette.len()];
473
474 let is_donut = matches!(spec.r#type, ChartType::Doughnut);
475 let inner_radius = if is_donut { radius * 0.55 } else { 0.0 };
476
477 let mut d = String::new();
478 let steps = 16;
479 for step in 0..=steps {
480 let a = current_angle + (step as f64 / steps as f64) * slice_angle;
481 let px = cx + radius * a.cos();
482 let py = cy + radius * a.sin();
483 if step == 0 {
484 d.push_str(&format!("M {:.2},{:.2} ", px, py));
485 } else {
486 d.push_str(&format!("L {:.2},{:.2} ", px, py));
487 }
488 }
489 if is_donut {
490 for step in (0..=steps).rev() {
491 let a = current_angle + (step as f64 / steps as f64) * slice_angle;
492 let px = cx + inner_radius * a.cos();
493 let py = cy + inner_radius * a.sin();
494 d.push_str(&format!("L {:.2},{:.2} ", px, py));
495 }
496 } else {
497 d.push_str(&format!("L {:.2},{:.2} ", cx, cy));
498 }
499 d.push('Z');
500
501 page.nodes.push(Node::Path {
502 id: String::new(),
503 d,
504 fill_rule: String::new(),
505 fill: Paint::solid(color),
506 stroke: Stroke {
507 paint: Paint::solid("#ffffff"),
508 width: 2.0,
509 ..Default::default()
510 },
511 transform: IDENTITY,
512 clip_id: None,
513 meta: SourceMeta::default(),
514 });
515
516 let label = spec.labels.get(idx).map(|s| s.as_str()).unwrap_or("Item");
518 let legend_text = format!("{label}: {v:.0} ({percent:.1}%)");
519
520 let marker_d = format!(
521 "M {:.1},{:.1} h 10 v 10 h -10 Z",
522 legend_start_x,
523 legend_y - 8.0
524 );
525 page.nodes.push(Node::Path {
526 id: String::new(),
527 d: marker_d,
528 fill_rule: String::new(),
529 fill: Paint::solid(color),
530 stroke: Stroke::default(),
531 transform: IDENTITY,
532 clip_id: None,
533 meta: SourceMeta::default(),
534 });
535
536 page.nodes.push(Node::Text {
537 id: String::new(),
538 x: legend_start_x + 16.0,
539 y: legend_y,
540 runs: vec![TextRun {
541 text: legend_text,
542 font_size: 12.0,
543 font_family: "Helvetica, Arial, sans-serif".to_string(),
544 fill: Paint::solid("#334155"),
545 ..Default::default()
546 }],
547 anchor: TextAnchor::Start,
548 transform: IDENTITY,
549 opacity: 1.0,
550 stroke: Stroke::default(),
551 clip_id: None,
552 meta: SourceMeta::default(),
553 });
554
555 legend_y += 24.0;
556 current_angle = end_angle;
557 }
558 }
559 }
560 }
561
562 Ok(page)
563}
564
565pub fn extract_csv_from_chart_svg(svg_bytes: &[u8]) -> Result<String> {
567 let svg_text = std::str::from_utf8(svg_bytes)
568 .map_err(|e| Error::InvalidInput(format!("SVG is not valid UTF-8: {e}")))?;
569
570 if let Some(decoded) = crate::cad::svg_reader::extract_embedded_source(svg_bytes)
572 && let Ok(spec) = serde_json::from_str::<ChartSpec>(&decoded)
573 {
574 let mut csv = String::new();
575 csv.push_str("Label");
577 for s in &spec.series {
578 csv.push(',');
579 let series_name = if s.name.is_empty() { "Value" } else { &s.name };
580 csv.push_str(&format!("\"{}\"", series_name.replace('"', "\"\"")));
581 }
582 csv.push('\n');
583
584 let max_data_len = spec.series.iter().map(|s| s.data.len()).max().unwrap_or(0);
585 let row_count = spec.labels.len().max(max_data_len);
586
587 for idx in 0..row_count {
589 let default_label = format!("Item {}", idx + 1);
590 let label = spec
591 .labels
592 .get(idx)
593 .map(|s| s.as_str())
594 .unwrap_or(&default_label);
595 csv.push_str(&format!("\"{}\"", label.replace('"', "\"\"")));
596 for s in &spec.series {
597 csv.push(',');
598 if let Some(&val) = s.data.get(idx) {
599 csv.push_str(&format!("{val}"));
600 }
601 }
602 csv.push('\n');
603 }
604 return Ok(csv);
605 }
606
607 let mut reader = Reader::from_str(svg_text);
609 reader.config_mut().trim_text(true);
610
611 let mut texts = Vec::new();
612 let mut in_text = false;
613 let mut current_text = String::new();
614
615 while let Ok(event) = reader.read_event() {
616 match event {
617 Event::Start(e) if e.name().as_ref() == b"text" => {
618 in_text = true;
619 current_text.clear();
620 }
621 Event::Text(e) if in_text => {
622 let bytes = e.as_ref();
623 if let Ok(s) = std::str::from_utf8(bytes) {
624 current_text.push_str(s);
625 }
626 }
627 Event::End(e) if e.name().as_ref() == b"text" => {
628 in_text = false;
629 let trimmed = current_text.trim();
630 if !trimmed.is_empty() {
631 texts.push(trimmed.to_string());
632 }
633 }
634 Event::Eof => break,
635 _ => {}
636 }
637 }
638
639 let mut csv = String::from("Label,Value\n");
640 for t in texts {
641 if let Some((label_part, rest)) = t.split_once(':') {
643 let label = label_part.trim().replace('"', "\"\"");
644 let val = rest
645 .split('(')
646 .next()
647 .unwrap_or(rest)
648 .trim()
649 .replace('"', "\"\"");
650 csv.push_str(&format!("\"{label}\",\"{val}\"\n"));
651 } else {
652 csv.push_str(&format!("\"{}\",\n", t.replace('"', "\"\"")));
653 }
654 }
655 Ok(csv)
656}
657
658#[cfg(test)]
659mod tests {
660 use super::*;
661
662 #[test]
663 fn renders_bar_chart_with_positive_and_negative_values() {
664 let spec = ChartSpec {
665 r#type: ChartType::Bar,
666 title: Some("Revenue Growth".to_string()),
667 labels: vec!["Q1".into(), "Q2".into(), "Q3".into(), "Q4".into()],
668 series: vec![ChartSeries {
669 name: "Net Profit".into(),
670 data: vec![100.0, -50.0, 150.0, -20.0],
671 color: None,
672 }],
673 };
674
675 let page =
676 layout_and_render_chart(&spec, &ConvertOptions::default()).expect("render chart");
677 assert_eq!(page.source_format, "chart");
678 assert!(page.nodes.len() >= 4); }
680
681 #[test]
682 fn renders_area_and_scatter_with_multi_series_legend() {
683 let spec_area = ChartSpec {
684 r#type: ChartType::Area,
685 title: Some("Multi Area".to_string()),
686 labels: vec!["Jan".into(), "Feb".into(), "Mar".into()],
687 series: vec![
688 ChartSeries {
689 name: "Series A".into(),
690 data: vec![10.0, 20.0, 30.0],
691 color: Some("#ff0000".into()),
692 },
693 ChartSeries {
694 name: "Series B".into(),
695 data: vec![5.0, 15.0, 25.0],
696 color: Some("#00ff00".into()),
697 },
698 ],
699 };
700
701 let page_area =
702 layout_and_render_chart(&spec_area, &ConvertOptions::default()).expect("render area");
703 let has_legend_a = page_area.nodes.iter().any(|n| match n {
705 Node::Text { runs, .. } => runs.iter().any(|r| r.text == "Series A"),
706 _ => false,
707 });
708 assert!(
709 has_legend_a,
710 "Area chart should render series legend for multiple series"
711 );
712
713 let spec_scatter = ChartSpec {
714 r#type: ChartType::Scatter,
715 title: Some("Scatter Plot".to_string()),
716 labels: vec!["P1".into(), "P2".into()],
717 series: vec![
718 ChartSeries {
719 name: "Points A".into(),
720 data: vec![1.0, 2.0],
721 color: None,
722 },
723 ChartSeries {
724 name: "Points B".into(),
725 data: vec![3.0, 4.0],
726 color: None,
727 },
728 ],
729 };
730
731 let page_scatter = layout_and_render_chart(&spec_scatter, &ConvertOptions::default())
732 .expect("render scatter");
733 let has_legend_scatter = page_scatter.nodes.iter().any(|n| match n {
734 Node::Text { runs, .. } => runs.iter().any(|r| r.text == "Points B"),
735 _ => false,
736 });
737 assert!(
738 has_legend_scatter,
739 "Scatter chart should render series legend for multiple series"
740 );
741 }
742
743 #[test]
744 fn renders_doughnut_and_extracts_csv() {
745 let spec = ChartSpec {
746 r#type: ChartType::Doughnut,
747 title: Some("Market Share".to_string()),
748 labels: vec!["Product A".into(), "Product B".into()],
749 series: vec![ChartSeries {
750 name: "Share".into(),
751 data: vec![60.0, 40.0],
752 color: None,
753 }],
754 };
755
756 let page =
757 layout_and_render_chart(&spec, &ConvertOptions::default()).expect("render doughnut");
758 let embedded = page.embedded_source.as_ref().expect("embedded source");
759 let svg = format!(
760 r#"<svg xmlns="http://www.w3.org/2000/svg" content="{}"><text>Product A</text></svg>"#,
761 embedded.replace('"', """)
762 );
763 let csv = extract_csv_from_chart_svg(svg.as_bytes()).expect("extract csv");
764 assert!(csv.contains("Product A"));
765 assert!(csv.contains("Product B"));
766 assert!(csv.contains("60"));
767 assert!(csv.contains("40"));
768 }
769}