Skip to main content

document_svg/chart/
mod.rs

1//! Chart and data visualization (Bar, Line, Pie), Page IR rendering, and CSV/data reverse extraction.
2
3use 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", // Blue
66        "#10b981", // Emerald
67        "#f59e0b", // Amber
68        "#ef4444", // Red
69        "#8b5cf6", // Purple
70        "#06b6d4", // Cyan
71    ];
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    // 1. Title
81    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    // 2. Legend (for multiple series or named series)
104    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            // Color marker box
118            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            // Series name
131            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                // Grid line
202                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                // Y-axis label
222                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            // X-axis category labels
248            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                    // Point markers
387                    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                    // Legend item for slice
517                    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
565/// Reverse extraction: parses an SVG chart and recovers tabular data as CSV.
566pub 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    // 1. Check embedded source (content attribute)
571    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        // Headers: Label, Series1, Series2, ...
576        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        // Rows for each category label / data index
588        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    // 2. Geometric fallback: extract text nodes
608    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 text is in the format "Label: Value (Percent%)" (e.g. from pie chart legend), split into Label and Value
642        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); // title + axes + bars
679    }
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        // Verify legend text is rendered
704        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('"', "&quot;")
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}