Skip to main content

pptxboss_core/
chart.rs

1//! Chart text: the title, axis titles, series names, categories and
2//! values cached in a DrawingML chart part (ECMA-376 Part 1, clause 21.2)
3//! or in the 2014 extended chart part (`cx:chartSpace`). Values come back
4//! as written; nothing is computed or formatted.
5
6use crate::hash::FastMap;
7use crate::mce::children;
8use crate::slide::parse_text_body;
9use crate::xml::{unescape_attr, Event, Ns, Reader, Start, XmlError};
10
11/// One series of a chart.
12#[derive(Clone, Debug, Default, PartialEq, Eq)]
13pub struct Series {
14    pub name: Option<String>,
15    /// Category labels (`c:cat`, or `c:xVal` for scatter and bubble charts), in point order.
16    pub categories: Vec<String>,
17    /// Values (`c:val`, or `c:yVal`) as written, in point order.
18    pub values: Vec<String>,
19}
20
21/// What a chart part says in words and numbers.
22#[derive(Clone, Debug, Default, PartialEq, Eq)]
23pub struct ChartData {
24    pub title: Option<String>,
25    /// The chart type elements found, e.g. `barChart`, `lineChart`; extended charts give their layout ids.
26    pub kinds: Vec<String>,
27    pub category_axis_title: Option<String>,
28    pub value_axis_title: Option<String>,
29    pub series: Vec<Series>,
30}
31
32impl ChartData {
33    /// True when the chart carries no words or numbers at all.
34    pub fn is_empty(&self) -> bool {
35        self.title.is_none()
36            && self.series.iter().all(|series| {
37                series.name.is_none() && series.categories.is_empty() && series.values.is_empty()
38            })
39    }
40
41    /// True when every series shares the same category labels.
42    pub fn shares_categories(&self) -> bool {
43        let Some(first) = self.series.first() else {
44            return false;
45        };
46        !first.categories.is_empty()
47            && self
48                .series
49                .iter()
50                .all(|series| series.categories == first.categories)
51    }
52
53    /// Row-major cells of the chart as a table: a header of series names
54    /// (first cell empty) when any series is named, then one row per
55    /// category with each series' value. Series with differing categories
56    /// fall back to one row per series: name, then its values.
57    pub fn rows(&self) -> Vec<Vec<String>> {
58        let mut rows = Vec::new();
59        if self.shares_categories() {
60            if self.series.iter().any(|series| series.name.is_some()) {
61                let mut header = vec![String::new()];
62                header.extend(
63                    self.series
64                        .iter()
65                        .map(|series| series.name.clone().unwrap_or_default()),
66                );
67                rows.push(header);
68            }
69            for (index, category) in self.series[0].categories.iter().enumerate() {
70                let mut row = vec![category.clone()];
71                row.extend(
72                    self.series
73                        .iter()
74                        .map(|series| series.values.get(index).cloned().unwrap_or_default()),
75                );
76                rows.push(row);
77            }
78            return rows;
79        }
80        for series in &self.series {
81            let mut row = vec![series.name.clone().unwrap_or_default()];
82            match series.categories.is_empty() {
83                true => row.extend(series.values.iter().cloned()),
84                false => row.extend(
85                    series
86                        .categories
87                        .iter()
88                        .zip(
89                            series
90                                .values
91                                .iter()
92                                .chain(std::iter::repeat(&String::new())),
93                        )
94                        .map(|(category, value)| format!("{category}: {value}")),
95                ),
96            }
97            rows.push(row);
98        }
99        rows
100    }
101
102    /// The chart as text: the title, then the table rows with cells separated by `separator`.
103    pub fn write_text(&self, separator: &str, out: &mut String) {
104        let start = out.len();
105        if let Some(title) = &self.title {
106            out.push_str(title.trim());
107        }
108        for row in self.rows() {
109            if out.len() > start {
110                out.push('\n');
111            }
112            out.push_str(&row.join(separator));
113        }
114    }
115}
116
117/// Parses a chart part of either flavour.
118pub fn parse_chart(xml: &[u8]) -> Result<ChartData, XmlError> {
119    let mut reader = Reader::new(xml);
120    let root = loop {
121        match reader.next()? {
122            Event::Start(start) => break start,
123            Event::Eof => return Ok(ChartData::default()),
124            _ => {}
125        }
126    };
127    let mut chart = ChartData::default();
128    match root.name.ns {
129        Ns::ChartEx => parse_extended(&mut reader, &mut chart)?,
130        _ => parse_chart_space(&mut reader, &root, &mut chart)?,
131    }
132    Ok(chart)
133}
134
135/// `c:chartSpace` (or a bare `c:chart`): title, plot area, axes.
136fn parse_chart_space<'a>(
137    reader: &mut Reader<'a>,
138    root: &Start<'a>,
139    chart: &mut ChartData,
140) -> Result<(), XmlError> {
141    if root.name.is(Ns::Chart, b"chart") {
142        return parse_c_chart(reader, chart);
143    }
144    children(
145        reader,
146        &mut |reader, child| match child.name.is(Ns::Chart, b"chart") {
147            true => parse_c_chart(reader, chart),
148            false => reader.skip_element(),
149        },
150    )
151}
152
153fn parse_c_chart<'a>(reader: &mut Reader<'a>, chart: &mut ChartData) -> Result<(), XmlError> {
154    children(reader, &mut |reader, child| {
155        if child.name.ns != Ns::Chart {
156            return reader.skip_element();
157        }
158        match child.name.local {
159            b"title" => {
160                chart.title = title_text(reader)?;
161                Ok(())
162            }
163            b"plotArea" => parse_plot_area(reader, chart),
164            _ => reader.skip_element(),
165        }
166    })
167}
168
169fn parse_plot_area<'a>(reader: &mut Reader<'a>, chart: &mut ChartData) -> Result<(), XmlError> {
170    children(reader, &mut |reader, child| {
171        if child.name.ns != Ns::Chart {
172            return reader.skip_element();
173        }
174        let local = String::from_utf8_lossy(child.name.local).into_owned();
175        if local.ends_with("Chart") {
176            chart.kinds.push(local);
177            return children(
178                reader,
179                &mut |reader, item| match item.name.is(Ns::Chart, b"ser") {
180                    true => {
181                        let series = parse_series(reader)?;
182                        chart.series.push(series);
183                        Ok(())
184                    }
185                    false => reader.skip_element(),
186                },
187            );
188        }
189        match child.name.local {
190            b"catAx" | b"dateAx" | b"serAx" => {
191                let title = axis_title(reader)?;
192                if chart.category_axis_title.is_none() {
193                    chart.category_axis_title = title;
194                }
195                Ok(())
196            }
197            b"valAx" => {
198                let title = axis_title(reader)?;
199                if chart.value_axis_title.is_none() {
200                    chart.value_axis_title = title;
201                }
202                Ok(())
203            }
204            _ => reader.skip_element(),
205        }
206    })
207}
208
209fn axis_title<'a>(reader: &mut Reader<'a>) -> Result<Option<String>, XmlError> {
210    let mut title = None;
211    children(
212        reader,
213        &mut |reader, child| match child.name.is(Ns::Chart, b"title") {
214            true => {
215                title = title_text(reader)?;
216                Ok(())
217            }
218            false => reader.skip_element(),
219        },
220    )?;
221    Ok(title)
222}
223
224/// The text of a `c:title`: rich text under `c:tx/c:rich`, or a cached string reference.
225fn title_text<'a>(reader: &mut Reader<'a>) -> Result<Option<String>, XmlError> {
226    let mut text = None;
227    children(reader, &mut |reader, child| {
228        if !child.name.is(Ns::Chart, b"tx") {
229            return reader.skip_element();
230        }
231        children(reader, &mut |reader, inner| {
232            if inner.name.is(Ns::Chart, b"rich") {
233                let body = parse_text_body(reader)?.text();
234                if !body.trim().is_empty() {
235                    text = Some(body);
236                }
237                return Ok(());
238            }
239            if inner.name.is(Ns::Chart, b"strRef") {
240                let points = string_points(reader)?;
241                let joined = points.join(" ");
242                if !joined.trim().is_empty() {
243                    text = Some(joined);
244                }
245                return Ok(());
246            }
247            reader.skip_element()
248        })
249    })?;
250    Ok(text)
251}
252
253fn parse_series<'a>(reader: &mut Reader<'a>) -> Result<Series, XmlError> {
254    let mut series = Series::default();
255    children(reader, &mut |reader, child| {
256        if child.name.ns != Ns::Chart {
257            return reader.skip_element();
258        }
259        match child.name.local {
260            b"tx" => {
261                let points = data_points(reader)?;
262                series.name = Some(points.join(" ")).filter(|name| !name.trim().is_empty());
263                Ok(())
264            }
265            b"cat" | b"xVal" => {
266                series.categories = data_points(reader)?;
267                Ok(())
268            }
269            b"val" | b"yVal" => {
270                series.values = data_points(reader)?;
271                Ok(())
272            }
273            _ => reader.skip_element(),
274        }
275    })?;
276    Ok(series)
277}
278
279/// The cached points of `c:tx`, `c:cat`, `c:val` and friends: a literal
280/// `c:v`, or the `c:pt/c:v` children of a string or number reference or
281/// literal, in `idx` order.
282fn data_points<'a>(reader: &mut Reader<'a>) -> Result<Vec<String>, XmlError> {
283    let mut points = Vec::new();
284    children(reader, &mut |reader, child| {
285        if child.name.ns != Ns::Chart {
286            return reader.skip_element();
287        }
288        match child.name.local {
289            b"v" => {
290                let mut text = String::new();
291                reader.text_content(&mut text)?;
292                points.push(text);
293                Ok(())
294            }
295            b"strRef" | b"numRef" | b"strLit" | b"numLit" | b"multiLvlStrRef" => {
296                points.extend(string_points(reader)?);
297                Ok(())
298            }
299            _ => reader.skip_element(),
300        }
301    })?;
302    Ok(points)
303}
304
305/// The `c:pt/c:v` values under a reference or cache, in `idx` order; for
306/// multi-level string caches the innermost level is taken.
307fn string_points<'a>(reader: &mut Reader<'a>) -> Result<Vec<String>, XmlError> {
308    let mut indexed: Vec<(u32, String)> = Vec::new();
309    let mut level = 0usize;
310    let mut kept_level = 0usize;
311    collect_points(reader, &mut indexed, &mut level, &mut kept_level)?;
312    indexed.sort_by_key(|(index, _)| *index);
313    Ok(indexed.into_iter().map(|(_, value)| value).collect())
314}
315
316fn collect_points<'a>(
317    reader: &mut Reader<'a>,
318    indexed: &mut Vec<(u32, String)>,
319    level: &mut usize,
320    kept_level: &mut usize,
321) -> Result<(), XmlError> {
322    children(reader, &mut |reader, child| {
323        if child.name.ns != Ns::Chart {
324            return reader.skip_element();
325        }
326        match child.name.local {
327            b"pt" => {
328                let index = reader
329                    .attr(&child, Ns::None, b"idx")
330                    .and_then(|raw| std::str::from_utf8(raw).ok()?.trim().parse().ok())
331                    .unwrap_or(indexed.len() as u32);
332                let mut value = String::new();
333                children(
334                    reader,
335                    &mut |reader, inner| match inner.name.is(Ns::Chart, b"v") {
336                        true => reader.text_content(&mut value),
337                        false => reader.skip_element(),
338                    },
339                )?;
340                if *level == *kept_level {
341                    indexed.push((index, value));
342                }
343                Ok(())
344            }
345            b"lvl" => {
346                *level += 1;
347                if *level > *kept_level {
348                    *kept_level = *level;
349                    indexed.clear();
350                }
351                collect_points(reader, indexed, level, kept_level)?;
352                *level -= 1;
353                Ok(())
354            }
355            b"f" | b"ptCount" | b"formatCode" | b"extLst" => reader.skip_element(),
356            _ => collect_points(reader, indexed, level, kept_level),
357        }
358    })
359}
360
361/// `cx:chartSpace`: the 2014 extended charts (treemap, sunburst, waterfall,
362/// histogram, funnel, box and whisker, region maps).
363fn parse_extended<'a>(reader: &mut Reader<'a>, chart: &mut ChartData) -> Result<(), XmlError> {
364    let mut data: FastMap<String, (Vec<String>, Vec<String>)> = FastMap::default();
365    let mut order: Vec<String> = Vec::new();
366    children(reader, &mut |reader, child| {
367        if child.name.ns != Ns::ChartEx {
368            return reader.skip_element();
369        }
370        match child.name.local {
371            b"chartData" => children(reader, &mut |reader, item| {
372                if !item.name.is(Ns::ChartEx, b"data") {
373                    return reader.skip_element();
374                }
375                let id = reader
376                    .attr(&item, Ns::None, b"id")
377                    .map(unescape_attr)
378                    .unwrap_or_default();
379                let mut categories = Vec::new();
380                let mut values = Vec::new();
381                children(reader, &mut |reader, dim| {
382                    if dim.name.is(Ns::ChartEx, b"strDim") {
383                        categories = extended_points(reader)?;
384                        return Ok(());
385                    }
386                    if dim.name.is(Ns::ChartEx, b"numDim") && values.is_empty() {
387                        values = extended_points(reader)?;
388                        return Ok(());
389                    }
390                    reader.skip_element()
391                })?;
392                order.push(id.clone());
393                data.insert(id, (categories, values));
394                Ok(())
395            }),
396            b"chart" => children(reader, &mut |reader, item| {
397                if item.name.is(Ns::ChartEx, b"title") {
398                    chart.title = extended_text(reader)?;
399                    return Ok(());
400                }
401                if !item.name.is(Ns::ChartEx, b"plotArea") {
402                    return reader.skip_element();
403                }
404                children(reader, &mut |reader, region| {
405                    if !region.name.is(Ns::ChartEx, b"plotAreaRegion") {
406                        return reader.skip_element();
407                    }
408                    children(reader, &mut |reader, series| {
409                        if !series.name.is(Ns::ChartEx, b"series") {
410                            return reader.skip_element();
411                        }
412                        if let Some(layout) = reader.attr(&series, Ns::None, b"layoutId") {
413                            chart.kinds.push(unescape_attr(layout));
414                        }
415                        let mut item = Series::default();
416                        let mut data_id = None;
417                        children(reader, &mut |reader, part| {
418                            if part.name.is(Ns::ChartEx, b"tx") {
419                                item.name = extended_text(reader)?;
420                                return Ok(());
421                            }
422                            if part.name.is(Ns::ChartEx, b"dataId") {
423                                data_id = reader.attr(&part, Ns::None, b"val").map(unescape_attr);
424                            }
425                            reader.skip_element()
426                        })?;
427                        if let Some((categories, values)) =
428                            data_id.as_deref().and_then(|id| data.get(id))
429                        {
430                            item.categories = categories.clone();
431                            item.values = values.clone();
432                        }
433                        chart.series.push(item);
434                        Ok(())
435                    })
436                })
437            }),
438            _ => reader.skip_element(),
439        }
440    })?;
441    if chart.series.is_empty() {
442        for id in order {
443            if let Some((categories, values)) = data.remove(&id) {
444                chart.series.push(Series {
445                    name: None,
446                    categories,
447                    values,
448                });
449            }
450        }
451    }
452    Ok(())
453}
454
455/// `cx:tx`: `cx:txData/cx:v` or rich text.
456fn extended_text<'a>(reader: &mut Reader<'a>) -> Result<Option<String>, XmlError> {
457    let mut text = None;
458    children(reader, &mut |reader, child| {
459        if child.name.is(Ns::ChartEx, b"tx") {
460            return children(reader, &mut |reader, inner| {
461                if inner.name.is(Ns::ChartEx, b"txData") {
462                    return children(reader, &mut |reader, leaf| match leaf
463                        .name
464                        .is(Ns::ChartEx, b"v")
465                    {
466                        true => {
467                            let mut value = String::new();
468                            reader.text_content(&mut value)?;
469                            text = Some(value).filter(|value| !value.trim().is_empty());
470                            Ok(())
471                        }
472                        false => reader.skip_element(),
473                    });
474                }
475                if inner.name.is(Ns::ChartEx, b"rich") {
476                    let body = parse_text_body(reader)?.text();
477                    text = Some(body).filter(|body| !body.trim().is_empty());
478                    return Ok(());
479                }
480                reader.skip_element()
481            });
482        }
483        if child.name.is(Ns::ChartEx, b"txData") {
484            return children(
485                reader,
486                &mut |reader, leaf| match leaf.name.is(Ns::ChartEx, b"v") {
487                    true => {
488                        let mut value = String::new();
489                        reader.text_content(&mut value)?;
490                        text = Some(value).filter(|value| !value.trim().is_empty());
491                        Ok(())
492                    }
493                    false => reader.skip_element(),
494                },
495            );
496        }
497        if child.name.is(Ns::ChartEx, b"rich") {
498            let body = parse_text_body(reader)?.text();
499            text = Some(body).filter(|body| !body.trim().is_empty());
500            return Ok(());
501        }
502        reader.skip_element()
503    })?;
504    Ok(text)
505}
506
507/// The `cx:pt` values of the innermost `cx:lvl` of a dimension, in `idx` order.
508fn extended_points<'a>(reader: &mut Reader<'a>) -> Result<Vec<String>, XmlError> {
509    let mut levels: Vec<Vec<(u32, String)>> = Vec::new();
510    children(reader, &mut |reader, child| {
511        if !child.name.is(Ns::ChartEx, b"lvl") {
512            return reader.skip_element();
513        }
514        let mut points = Vec::new();
515        children(reader, &mut |reader, pt| {
516            if !pt.name.is(Ns::ChartEx, b"pt") {
517                return reader.skip_element();
518            }
519            let index = reader
520                .attr(&pt, Ns::None, b"idx")
521                .and_then(|raw| std::str::from_utf8(raw).ok()?.trim().parse().ok())
522                .unwrap_or(points.len() as u32);
523            let mut value = String::new();
524            reader.text_content(&mut value)?;
525            points.push((index, value));
526            Ok(())
527        })?;
528        points.sort_by_key(|(index, _)| *index);
529        levels.push(points);
530        Ok(())
531    })?;
532    Ok(levels
533        .into_iter()
534        .last()
535        .unwrap_or_default()
536        .into_iter()
537        .map(|(_, value)| value)
538        .collect())
539}
540
541#[cfg(test)]
542mod tests {
543    use super::*;
544
545    const C: &str = "http://schemas.openxmlformats.org/drawingml/2006/chart";
546    const A: &str = "http://schemas.openxmlformats.org/drawingml/2006/main";
547
548    #[test]
549    fn bar_chart_title_series_categories_and_values() {
550        let xml = format!(
551            r#"<c:chartSpace xmlns:c="{C}" xmlns:a="{A}"><c:chart><c:title><c:tx><c:rich><a:bodyPr/><a:p><a:r><a:t>Sales by </a:t></a:r><a:r><a:t>region</a:t></a:r></a:p></c:rich></c:tx></c:title><c:plotArea><c:barChart><c:ser><c:idx val="0"/><c:tx><c:strRef><c:f>Sheet1!$B$1</c:f><c:strCache><c:ptCount val="1"/><c:pt idx="0"><c:v>2023</c:v></c:pt></c:strCache></c:strRef></c:tx><c:cat><c:strRef><c:strCache><c:pt idx="1"><c:v>South</c:v></c:pt><c:pt idx="0"><c:v>North</c:v></c:pt></c:strCache></c:strRef></c:cat><c:val><c:numRef><c:numCache><c:formatCode>General</c:formatCode><c:pt idx="0"><c:v>4.3</c:v></c:pt><c:pt idx="1"><c:v>2.5</c:v></c:pt></c:numCache></c:numRef></c:val></c:ser><c:ser><c:idx val="1"/><c:tx><c:v>2024</c:v></c:tx><c:cat><c:strLit><c:pt idx="0"><c:v>North</c:v></c:pt><c:pt idx="1"><c:v>South</c:v></c:pt></c:strLit></c:cat><c:val><c:numLit><c:pt idx="0"><c:v>5</c:v></c:pt><c:pt idx="1"><c:v>3</c:v></c:pt></c:numLit></c:val></c:ser></c:barChart><c:catAx><c:title><c:tx><c:rich><a:bodyPr/><a:p><a:r><a:t>Region</a:t></a:r></a:p></c:rich></c:tx></c:title></c:catAx><c:valAx><c:title><c:tx><c:rich><a:bodyPr/><a:p><a:r><a:t>Millions</a:t></a:r></a:p></c:rich></c:tx></c:title></c:valAx></c:plotArea></c:chart></c:chartSpace>"#
552        );
553        let chart = parse_chart(xml.as_bytes()).unwrap();
554        assert_eq!(chart.title.as_deref(), Some("Sales by region"));
555        assert_eq!(chart.kinds, ["barChart"]);
556        assert_eq!(chart.category_axis_title.as_deref(), Some("Region"));
557        assert_eq!(chart.value_axis_title.as_deref(), Some("Millions"));
558        assert_eq!(chart.series.len(), 2);
559        assert_eq!(chart.series[0].name.as_deref(), Some("2023"));
560        assert_eq!(chart.series[0].categories, ["North", "South"]);
561        assert_eq!(chart.series[0].values, ["4.3", "2.5"]);
562        assert_eq!(chart.series[1].name.as_deref(), Some("2024"));
563        assert!(chart.shares_categories());
564        let mut text = String::new();
565        chart.write_text("\t", &mut text);
566        assert_eq!(
567            text,
568            "Sales by region\n\t2023\t2024\nNorth\t4.3\t5\nSouth\t2.5\t3"
569        );
570    }
571
572    #[test]
573    fn scatter_series_with_different_points_list_per_series() {
574        let xml = format!(
575            r#"<c:chartSpace xmlns:c="{C}"><c:chart><c:plotArea><c:scatterChart><c:ser><c:tx><c:v>Run A</c:v></c:tx><c:xVal><c:numLit><c:pt idx="0"><c:v>1</c:v></c:pt><c:pt idx="1"><c:v>2</c:v></c:pt></c:numLit></c:xVal><c:yVal><c:numLit><c:pt idx="0"><c:v>10</c:v></c:pt><c:pt idx="1"><c:v>20</c:v></c:pt></c:numLit></c:yVal></c:ser><c:ser><c:tx><c:v>Run B</c:v></c:tx><c:xVal><c:numLit><c:pt idx="0"><c:v>3</c:v></c:pt></c:numLit></c:xVal><c:yVal><c:numLit><c:pt idx="0"><c:v>30</c:v></c:pt></c:numLit></c:yVal></c:ser></c:scatterChart></c:plotArea></c:chart></c:chartSpace>"#
576        );
577        let chart = parse_chart(xml.as_bytes()).unwrap();
578        assert!(chart.title.is_none());
579        assert!(!chart.shares_categories());
580        let mut text = String::new();
581        chart.write_text("\t", &mut text);
582        assert_eq!(text, "Run A\t1: 10\t2: 20\nRun B\t3: 30");
583    }
584
585    #[test]
586    fn extended_charts_read_titles_series_and_dimensions() {
587        let xml = r#"<cx:chartSpace xmlns:cx="http://schemas.microsoft.com/office/drawing/2014/chartex" xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main"><cx:chartData><cx:data id="0"><cx:strDim type="cat"><cx:f>Sheet1!$A$2:$A$4</cx:f><cx:lvl ptCount="3"><cx:pt idx="0">Leaf</cx:pt><cx:pt idx="1">Stem</cx:pt><cx:pt idx="2">Root</cx:pt></cx:lvl></cx:strDim><cx:numDim type="size"><cx:f>Sheet1!$B$2:$B$4</cx:f><cx:lvl ptCount="3" formatCode="General"><cx:pt idx="0">5</cx:pt><cx:pt idx="1">3</cx:pt><cx:pt idx="2">2</cx:pt></cx:lvl></cx:numDim></cx:data></cx:chartData><cx:chart><cx:title pos="t" align="ctr" overlay="0"><cx:tx><cx:txData><cx:v>Plant parts</cx:v></cx:txData></cx:tx></cx:title><cx:plotArea><cx:plotAreaRegion><cx:series layoutId="treemap" uniqueId="{1}"><cx:tx><cx:txData><cx:v>Mass</cx:v></cx:txData></cx:tx><cx:dataId val="0"/></cx:series></cx:plotAreaRegion></cx:plotArea></cx:chart></cx:chartSpace>"#;
588        let chart = parse_chart(xml.as_bytes()).unwrap();
589        assert_eq!(chart.title.as_deref(), Some("Plant parts"));
590        assert_eq!(chart.kinds, ["treemap"]);
591        assert_eq!(chart.series.len(), 1);
592        assert_eq!(chart.series[0].name.as_deref(), Some("Mass"));
593        assert_eq!(chart.series[0].categories, ["Leaf", "Stem", "Root"]);
594        assert_eq!(chart.series[0].values, ["5", "3", "2"]);
595    }
596
597    #[test]
598    fn an_empty_chart_part_is_empty_not_an_error() {
599        let chart = parse_chart(format!(r#"<c:chartSpace xmlns:c="{C}"/>"#).as_bytes()).unwrap();
600        assert!(chart.is_empty());
601        assert!(chart.rows().is_empty());
602    }
603}