Skip to main content

rust_office_charts/
chart.rs

1use roxmltree::Node;
2use serde::{Deserialize, Serialize};
3use std::collections::BTreeMap;
4
5#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
6pub struct ChartData {
7    pub title: Option<String>,
8    pub series: Vec<ChartSeries>,
9}
10
11#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
12#[serde(rename_all = "camelCase")]
13pub struct ChartSeries {
14    pub chart_type: String,
15    pub name: Option<String>,
16    pub points: Vec<ChartPoint>,
17}
18
19/// Source indices are zero-based. Values retain their original spelling and precision.
20#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
21#[serde(rename_all = "camelCase")]
22pub struct ChartPoint {
23    pub index: u32,
24    #[serde(default, skip_serializing_if = "Vec::is_empty")]
25    pub categories: Vec<ChartCategory>,
26    #[serde(skip_serializing_if = "Option::is_none")]
27    pub value: Option<String>,
28    #[serde(skip_serializing_if = "Option::is_none")]
29    pub x: Option<String>,
30    #[serde(skip_serializing_if = "Option::is_none")]
31    pub y: Option<String>,
32    #[serde(skip_serializing_if = "Option::is_none")]
33    pub bubble_size: Option<String>,
34}
35
36/// A label at its stored hierarchy level, starting at zero for the leaf level.
37#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
38pub struct ChartCategory {
39    pub level: usize,
40    pub label: String,
41}
42
43pub(crate) const CHARTEX_NS: &str = "http://schemas.microsoft.com/office/drawing/2014/chartex";
44
45pub(crate) fn cx_child<'a>(parent: Node<'a, 'a>, name: &str) -> Option<Node<'a, 'a>> {
46    parent
47        .children()
48        .find(|n| n.has_tag_name((CHARTEX_NS, name)))
49}
50
51pub(crate) fn chart_node(node: Node, name: &str) -> bool {
52    node.tag_name().name() == name
53        && matches!(
54            node.tag_name().namespace(),
55            Some(
56                "http://schemas.openxmlformats.org/drawingml/2006/chart"
57                    | "http://purl.oclc.org/ooxml/drawingml/chart"
58            )
59        )
60}
61
62pub(crate) fn child<'a>(node: Node<'a, 'a>, name: &str) -> Option<Node<'a, 'a>> {
63    node.children().find(|n| chart_node(*n, name))
64}
65
66fn series_data(series: Node, chart_type: &str) -> ChartSeries {
67    let name = child(series, "tx")
68        .and_then(|tx| tx.descendants().find(|n| chart_node(*n, "v")))
69        .and_then(|n| n.text())
70        .map(str::to_owned);
71    let mut points = BTreeMap::new();
72    for channel in series.children().filter(Node::is_element) {
73        let role = channel.tag_name().name();
74        if !chart_node(channel, role)
75            || !matches!(role, "cat" | "val" | "xVal" | "yVal" | "bubbleSize")
76        {
77            continue;
78        }
79        let Some(source) = channel.children().find(Node::is_element) else {
80            continue;
81        };
82        let cache = source
83            .children()
84            .find(|n| {
85                ["strCache", "numCache", "multiLvlStrCache"]
86                    .iter()
87                    .any(|name| chart_node(*n, name))
88            })
89            .unwrap_or(source);
90        let mut levels: Vec<_> = cache.children().filter(|n| chart_node(*n, "lvl")).collect();
91        if levels.is_empty() {
92            levels.push(cache);
93        }
94        for (level, source) in levels.into_iter().enumerate() {
95            for pt in source.children().filter(|n| chart_node(*n, "pt")) {
96                let Some(index) = pt.attribute("idx").and_then(|s| s.parse::<u32>().ok()) else {
97                    continue;
98                };
99                let value = child(pt, "v").map(|v| v.text().unwrap_or_default().to_owned());
100                let point = points.entry(index).or_insert_with(|| ChartPoint {
101                    index,
102                    ..Default::default()
103                });
104                match role {
105                    "cat" => {
106                        if let Some(label) = value {
107                            point.categories.push(ChartCategory { level, label });
108                        }
109                    }
110                    "val" => point.value = value,
111                    "xVal" => point.x = value,
112                    "yVal" => point.y = value,
113                    "bubbleSize" => point.bubble_size = value,
114                    _ => unreachable!(),
115                }
116            }
117        }
118    }
119    ChartSeries {
120        chart_type: chart_type.to_owned(),
121        name,
122        points: points.into_values().collect(),
123    }
124}
125
126/// Read chart series from literal and cached values without evaluating formulas.
127pub fn parse_chart_xml(content: &str) -> Option<ChartData> {
128    let xml = roxmltree::Document::parse(content).ok()?;
129    chart_data_from_root(xml.root_element())
130}
131
132pub(crate) fn chart_data_from_root(root: Node) -> Option<ChartData> {
133    if root.has_tag_name((CHARTEX_NS, "chartSpace")) {
134        return extended_data(root);
135    }
136    let chart = child(root, "chart")?;
137    let plot = child(chart, "plotArea")?;
138    let title = child(chart, "title")
139        .and_then(|n| child(n, "tx"))
140        .map(|tx| {
141            tx.descendants()
142                .filter(|n| {
143                    chart_node(*n, "v")
144                        || (n.tag_name().name() == "t"
145                            && matches!(
146                                n.tag_name().namespace(),
147                                Some(
148                                    "http://schemas.openxmlformats.org/drawingml/2006/main"
149                                        | "http://purl.oclc.org/ooxml/drawingml/main"
150                                )
151                            ))
152                })
153                .filter_map(|n| n.text())
154                .collect::<String>()
155        })
156        .filter(|s| !s.is_empty());
157    let mut series = Vec::new();
158    for group in plot.children().filter(Node::is_element) {
159        if let Some(kind) = group.tag_name().name().strip_suffix("Chart") {
160            series.extend(
161                group
162                    .children()
163                    .filter(|n| chart_node(*n, "ser"))
164                    .map(|n| series_data(n, kind)),
165            );
166        }
167    }
168    Some(ChartData { title, series })
169}
170
171fn extended_text(node: Node) -> Option<String> {
172    let value = cx_child(node, "tx")
173        .and_then(|tx| cx_child(tx, "txData"))
174        .and_then(|d| cx_child(d, "v"))
175        .and_then(|v| v.text());
176    if let Some(value) = value.filter(|v| !v.is_empty()) {
177        return Some(value.to_owned());
178    }
179    let rich = cx_child(node, "tx")
180        .and_then(|tx| cx_child(tx, "rich"))
181        .or_else(|| cx_child(node, "txPr"))?;
182    let text: String = rich
183        .descendants()
184        .filter(|n| {
185            n.tag_name().name() == "t"
186                && matches!(
187                    n.tag_name().namespace(),
188                    Some(
189                        "http://schemas.openxmlformats.org/drawingml/2006/main"
190                            | "http://purl.oclc.org/ooxml/drawingml/main"
191                    )
192                )
193        })
194        .filter_map(|n| n.text())
195        .collect();
196    (!text.is_empty()).then_some(text)
197}
198
199pub(crate) fn map_color_dimension<'a>(data: Node<'a, 'a>) -> Option<Node<'a, 'a>> {
200    data.children().find(|n| {
201        (n.has_tag_name((CHARTEX_NS, "numDim")) && n.attribute("type") == Some("colorVal"))
202            || (n.has_tag_name((CHARTEX_NS, "strDim")) && n.attribute("type") == Some("colorStr"))
203    })
204}
205
206fn extended_points(data: Node, layout: &str) -> Vec<ChartPoint> {
207    let mut points = BTreeMap::new();
208    let color_dimension = if layout == "regionMap" {
209        map_color_dimension(data)
210    } else {
211        None
212    };
213    for dimension in data.children().filter(|n| {
214        n.has_tag_name((CHARTEX_NS, "strDim")) || n.has_tag_name((CHARTEX_NS, "numDim"))
215    }) {
216        let role = dimension.attribute("type").unwrap_or_default();
217        let role = if Some(dimension) == color_dimension {
218            "val"
219        } else if color_dimension.is_some() && matches!(role, "val" | "size") {
220            continue;
221        } else {
222            role
223        };
224        if !matches!(role, "cat" | "val" | "size" | "x" | "y") {
225            continue;
226        }
227        for (level, cache) in dimension
228            .children()
229            .filter(|n| n.has_tag_name((CHARTEX_NS, "lvl")))
230            .enumerate()
231        {
232            for pt in cache
233                .children()
234                .filter(|n| n.has_tag_name((CHARTEX_NS, "pt")))
235            {
236                let Some(index) = pt.attribute("idx").and_then(|v| v.parse::<u32>().ok()) else {
237                    continue;
238                };
239                let point = points.entry(index).or_insert_with(|| ChartPoint {
240                    index,
241                    ..Default::default()
242                });
243                let value = pt.text().unwrap_or_default().to_owned();
244                match role {
245                    "cat" => point.categories.push(ChartCategory {
246                        level,
247                        label: value,
248                    }),
249                    "val" | "size" => point.value = Some(value),
250                    "x" => point.x = Some(value),
251                    "y" => point.y = Some(value),
252                    _ => unreachable!(),
253                }
254            }
255        }
256    }
257    points.into_values().collect()
258}
259
260fn extended_data(root: Node) -> Option<ChartData> {
261    let chart = cx_child(root, "chart")?;
262    let plot = cx_child(cx_child(chart, "plotArea")?, "plotAreaRegion")?;
263    let data = cx_child(root, "chartData");
264    let datasets: BTreeMap<_, _> = data
265        .into_iter()
266        .flat_map(|d| d.children())
267        .filter(|n| n.has_tag_name((CHARTEX_NS, "data")))
268        .filter_map(|n| Some((n.attribute("id")?.parse::<u32>().ok()?, n)))
269        .collect();
270    let series = plot
271        .children()
272        .filter(|n| n.has_tag_name((CHARTEX_NS, "series")))
273        .filter_map(|s| {
274            let layout = s.attribute("layoutId")?;
275            let id = cx_child(s, "dataId")
276                .and_then(|d| d.attribute("val"))
277                .and_then(|id| id.parse::<u32>().ok());
278            Some(ChartSeries {
279                chart_type: layout.to_owned(),
280                name: extended_text(s),
281                points: id
282                    .and_then(|id| datasets.get(&id))
283                    .map(|&d| extended_points(d, layout))
284                    .unwrap_or_default(),
285            })
286        })
287        .collect();
288    Some(ChartData {
289        title: cx_child(chart, "title").and_then(extended_text),
290        series,
291    })
292}
293
294#[cfg(test)]
295mod tests {
296    use super::*;
297
298    #[test]
299    fn extended_cache_uses_data_ids_and_preserves_sparse_hierarchy_levels() {
300        let xml = r#"<x:chartSpace xmlns:x="http://schemas.microsoft.com/office/drawing/2014/chartex" xmlns:t="http://purl.oclc.org/ooxml/drawingml/main">
301            <x:chartData><x:data id="9">
302                <x:strDim type="cat"><x:lvl ptCount="4"><x:pt idx="1">A</x:pt><x:pt idx="3">B</x:pt></x:lvl><x:lvl ptCount="4"><x:pt idx="1">Group</x:pt></x:lvl></x:strDim>
303                <x:numDim type="size"><x:lvl ptCount="4"><x:pt idx="3">12.00</x:pt></x:lvl></x:numDim>
304            </x:data></x:chartData>
305            <x:chart><x:title><x:tx><x:txData><x:v>Title</x:v></x:txData></x:tx><x:txPr><t:p><t:r><t:t>Title</t:t></t:r></t:p></x:txPr></x:title>
306            <x:plotArea><x:plotAreaRegion><x:series layoutId="treemap"><x:tx><x:txData><x:v>Series</x:v></x:txData></x:tx><x:dataId val="9"/></x:series><x:series layoutId="treemap"><x:dataId val="33"/></x:series></x:plotAreaRegion></x:plotArea>
307            </x:chart></x:chartSpace>"#;
308        let chart = parse_chart_xml(xml).unwrap();
309        assert_eq!(chart.title.as_deref(), Some("Title"));
310        let series = &chart.series[0];
311        assert_eq!(series.name.as_deref(), Some("Series"));
312        assert_eq!(series.chart_type, "treemap");
313        assert_eq!(
314            series.points.iter().map(|p| p.index).collect::<Vec<_>>(),
315            [1, 3]
316        );
317        assert_eq!(
318            series.points[0].categories[1],
319            ChartCategory {
320                level: 1,
321                label: "Group".into()
322            }
323        );
324        assert_eq!(series.points[0].value, None);
325        assert_eq!(series.points[1].value.as_deref(), Some("12.00"));
326        assert!(chart.series[1].points.is_empty());
327    }
328}