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#[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#[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
126pub 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}