use super::model::{
ChartAxes, ChartAxis, ChartDataLabels, ChartFill, ChartLegend, ChartLine, ChartMarker,
ChartPlotGroup, ChartPoint, ChartPointLabel, ChartSeries, ChartSpace, ChartTextProperties,
};
pub const DEFAULT_SERIES_COLORS: [&str; 8] = [
"#4472C4", "#ED7D31", "#A5A5A5", "#FFC000", "#5B9BD5", "#70AD47", "#264478", "#9E480E",
];
const MAX_DEEP_DEPTH: usize = 64;
const MAX_POINTS: usize = 100_000;
const MAX_PLOT_GROUPS: usize = 64;
const MAX_AXES: usize = 128;
const MAX_CHART_SERIES: usize = 1_024;
const MAX_CHART_POINTS: usize = 200_000;
const MAX_AXIS_IDS: usize = 16;
const MAX_POINT_LABELS: usize = 4_096;
const MAX_TEXT_SPACING_HUNDREDTHS: f64 = 400_000.0;
struct Budget {
series: usize,
points: usize,
}
impl Budget {
fn new() -> Self {
Self {
series: MAX_CHART_SERIES,
points: MAX_CHART_POINTS,
}
}
fn series_cap(&self) -> usize {
self.series
}
fn spend_series(&mut self, used: usize) {
self.series = self.series.saturating_sub(used);
}
fn point_cap(&self, requested: usize) -> usize {
requested.min(self.points)
}
fn spend_points(&mut self, used: usize) {
self.points = self.points.saturating_sub(used);
}
}
pub trait ChartXml: Sized {
fn local_name(&self) -> &str;
fn attribute(&self, prefix: Option<&str>, name: &str) -> Option<&str>;
fn child_elements(&self) -> impl Iterator<Item = &Self>;
fn descendant_text(&self) -> String;
fn solid_fill_hex(&self) -> Option<String>;
}
pub fn parse_chart_space<E: ChartXml>(chart_space: &E) -> Option<ChartSpace> {
let plot_area = first_deep(chart_space, "plotArea", 0)?;
let chart_elements = plot_area
.child_elements()
.filter(|child| plot_type_for(*child).is_some())
.take(MAX_PLOT_GROUPS)
.collect::<Vec<_>>();
if chart_elements.is_empty() {
return None;
}
let budget = &mut Budget::new();
let plot_groups = chart_elements
.into_iter()
.map(|chart| parse_plot_group(chart, budget))
.collect::<Vec<_>>();
let first_type = plot_groups[0].chart_type.as_deref();
let chart_type = match first_type {
Some("bar" | "column" | "line" | "pie" | "doughnut") => first_type.unwrap().to_owned(),
Some("ofPie") => "pie".to_owned(),
_ => "line".to_owned(),
};
let series = plot_groups
.iter()
.flat_map(|group| group.series.iter())
.map(|series| ChartSeries {
name: series.name.clone(),
categories: series.categories.clone(),
values: series.values.clone(),
color: series.color.clone(),
index: None,
order: None,
category_formula: None,
value_formula: None,
axis_ids: None,
points: None,
grouping: None,
marker: None,
smooth: None,
x_values: None,
bubble_sizes: None,
data_labels: None,
line: None,
})
.collect::<Vec<_>>();
let axis_list = plot_area
.child_elements()
.filter(|child| matches!(child.local_name(), "catAx" | "dateAx" | "valAx" | "serAx"))
.take(MAX_AXES)
.map(parse_axis)
.collect::<Vec<_>>();
let axes = parse_axes(plot_area, series.first());
let title = first_deep(chart_space, "title", 0);
Some(ChartSpace {
chart_type,
title: text_from_rich_text(title),
legend: parse_legend(chart_space),
series,
axes,
plot_groups,
axis_list: (!axis_list.is_empty()).then_some(axis_list),
text: parse_text_properties(child(chart_space, "txPr")),
title_text: title.and_then(parse_title_text),
fill: parse_fill(child(chart_space, "spPr")),
})
}
fn child<'a, E: ChartXml>(parent: &'a E, local: &str) -> Option<&'a E> {
parent
.child_elements()
.find(|node| node.local_name() == local)
}
fn children<'a, E: ChartXml>(parent: &'a E, local: &'a str) -> impl Iterator<Item = &'a E> {
parent
.child_elements()
.filter(move |node| node.local_name() == local)
}
fn first_deep<'a, E: ChartXml>(root: &'a E, local: &str, depth: usize) -> Option<&'a E> {
if depth > MAX_DEEP_DEPTH {
return None;
}
if root.local_name() == local {
return Some(root);
}
root.child_elements()
.find_map(|node| first_deep(node, local, depth + 1))
}
fn all_deep<'a, E: ChartXml>(root: &'a E, local: &str, depth: usize, output: &mut Vec<&'a E>) {
if depth > MAX_DEEP_DEPTH || output.len() >= MAX_POINTS {
return;
}
if root.local_name() == local {
output.push(root);
}
for node in root.child_elements() {
all_deep(node, local, depth + 1, output);
if output.len() >= MAX_POINTS {
break;
}
}
}
fn val_attr<E: ChartXml>(element: Option<&E>) -> Option<&str> {
let element = element?;
element
.attribute(None, "val")
.or_else(|| element.attribute(Some("c"), "val"))
}
fn text_from_rich_text<E: ChartXml>(parent: Option<&E>) -> Option<String> {
let parent = parent?;
if let Some(rich) = first_deep(parent, "rich", 0) {
let mut elements = Vec::new();
all_deep(rich, "t", 0, &mut elements);
let text = elements
.into_iter()
.map(E::descendant_text)
.collect::<String>();
let trimmed = text.trim();
if !trimmed.is_empty() {
return Some(trimmed.to_owned());
}
}
let text = first_deep(parent, "v", 0)
.map(E::descendant_text)
.unwrap_or_default();
nonempty_trimmed(&text)
}
fn parse_number(raw: Option<&str>) -> Option<f64> {
let value = raw?.trim();
if value.is_empty() {
return None;
}
let parsed = if let Some(hex) = value
.strip_prefix("0x")
.or_else(|| value.strip_prefix("0X"))
{
u64::from_str_radix(hex, 16).ok().map(|value| value as f64)
} else if let Some(binary) = value
.strip_prefix("0b")
.or_else(|| value.strip_prefix("0B"))
{
u64::from_str_radix(binary, 2)
.ok()
.map(|value| value as f64)
} else if let Some(octal) = value
.strip_prefix("0o")
.or_else(|| value.strip_prefix("0O"))
{
u64::from_str_radix(octal, 8).ok().map(|value| value as f64)
} else {
value.parse::<f64>().ok()
}?;
parsed.is_finite().then_some(parsed)
}
fn parse_index(raw: Option<&str>) -> Option<f64> {
let value = raw?.trim();
if value.is_empty() || !value.bytes().all(|byte| byte.is_ascii_digit()) {
return None;
}
value.parse::<u32>().ok().map(f64::from)
}
fn take_points<'a, E: ChartXml + 'a, T>(
elements: impl Iterator<Item = &'a E>,
budget: &mut Budget,
mut parse: impl FnMut(&'a E) -> Option<T>,
) -> Vec<T> {
let mut examined = 0;
let values = elements
.take(budget.point_cap(MAX_POINTS))
.filter_map(|element| {
examined += 1;
parse(element)
})
.collect::<Vec<_>>();
budget.spend_points(examined);
values
}
fn parse_string_cache<E: ChartXml>(parent: Option<&E>, budget: &mut Budget) -> Vec<String> {
let Some(parent) = parent else {
return Vec::new();
};
let Some(cache) = first_deep(parent, "strCache", 0)
.or_else(|| first_deep(parent, "multiLvlStrCache", 0))
.or_else(|| first_deep(parent, "numCache", 0))
.or_else(|| first_deep(parent, "strLit", 0))
.or_else(|| first_deep(parent, "numLit", 0))
else {
return Vec::new();
};
take_points(children(cache, "pt"), budget, |point| {
Some(
child(point, "v")
.map(E::descendant_text)
.unwrap_or_default()
.trim()
.to_owned(),
)
})
}
fn parse_num_cache<E: ChartXml>(parent: Option<&E>, budget: &mut Budget) -> Vec<f64> {
let Some(parent) = parent else {
return Vec::new();
};
let Some(cache) = first_deep(parent, "numCache", 0).or_else(|| first_deep(parent, "numLit", 0))
else {
return Vec::new();
};
take_points(children(cache, "pt"), budget, |point| {
let text = child(point, "v")?.descendant_text();
parse_number(Some(text.trim()))
})
}
fn parse_num_cache_with_strings<E: ChartXml>(
parent: Option<&E>,
budget: &mut Budget,
) -> (Vec<String>, Vec<f64>) {
let Some(parent) = parent else {
return (Vec::new(), Vec::new());
};
let Some(cache) = first_deep(parent, "numCache", 0).or_else(|| first_deep(parent, "numLit", 0))
else {
return (Vec::new(), Vec::new());
};
let entries = take_points(children(cache, "pt"), budget, |point| {
let text = child(point, "v")
.map(E::descendant_text)
.unwrap_or_default()
.trim()
.to_owned();
let number = parse_number(Some(&text));
Some((text, number))
});
let mut strings = Vec::with_capacity(entries.len());
let mut numbers = Vec::with_capacity(entries.len());
for (string, number) in entries {
strings.push(string);
numbers.extend(number);
}
(strings, numbers)
}
fn parse_series_name<E: ChartXml>(series: &E) -> Option<String> {
text_from_rich_text(child(series, "tx"))
}
fn parse_series_color<E: ChartXml>(series: &E, index: usize) -> String {
let parsed = child(series, "spPr")
.and_then(|properties| first_deep(properties, "solidFill", 0))
.and_then(E::solid_fill_hex);
parsed.unwrap_or_else(|| DEFAULT_SERIES_COLORS[index % DEFAULT_SERIES_COLORS.len()].to_owned())
}
fn parse_series<E: ChartXml>(
chart: &E,
grouping: Option<&str>,
axis_ids: &[String],
budget: &mut Budget,
) -> Vec<ChartSeries> {
let cap = budget.series_cap();
let series = children(chart, "ser")
.enumerate()
.take(cap)
.map(|(index, series)| {
let x_value = child(series, "xVal");
let category = child(series, "cat");
let value = child(series, "val").or_else(|| child(series, "yVal"));
let marker = child(series, "marker");
let marker_symbol =
val_attr(marker.and_then(|value| child(value, "symbol"))).map(str::to_owned);
let marker_size = parse_number(val_attr(marker.and_then(|value| child(value, "size"))));
let marker_color = marker
.and_then(|marker| child(marker, "spPr"))
.and_then(|properties| first_deep(properties, "solidFill", 0))
.and_then(E::solid_fill_hex);
let points = take_points(children(series, "dPt"), budget, |point| {
let point_index = match child(point, "idx") {
Some(idx) => Some(parse_index(val_attr(Some(idx)))?),
None => None,
};
Some(ChartPoint {
index: point_index,
explosion: parse_number(val_attr(child(point, "explosion"))),
color: parse_series_color(point, index),
})
});
let uses_x_as_category = category.is_none() && x_value.is_some();
let (categories, mut x_values) = if uses_x_as_category {
parse_num_cache_with_strings(x_value, budget)
} else {
(parse_string_cache(category, budget), Vec::new())
};
let values = parse_num_cache(value, budget);
if !uses_x_as_category {
x_values = parse_num_cache(x_value, budget);
}
ChartSeries {
name: parse_series_name(series),
categories,
values,
color: parse_series_color(series, index),
index: parse_index(val_attr(child(series, "idx"))),
order: parse_index(val_attr(child(series, "order"))),
category_formula: child_formula(category.or(x_value)),
value_formula: child_formula(value),
axis_ids: (!axis_ids.is_empty()).then(|| axis_ids.to_vec()),
points: (!points.is_empty()).then_some(points),
grouping: grouping.map(str::to_owned),
marker: (marker_symbol.is_some()
|| marker_size.is_some()
|| marker_color.is_some())
.then_some(ChartMarker {
symbol: marker_symbol,
size: marker_size,
color: marker_color,
}),
smooth: (val_attr(child(series, "smooth")) == Some("1")).then_some(true),
x_values: (!x_values.is_empty()).then_some(x_values),
bubble_sizes: child(series, "bubbleSize")
.map(|element| parse_num_cache(Some(element), budget))
.filter(|values| !values.is_empty()),
data_labels: parse_data_labels(child(series, "dLbls"), budget),
line: parse_line(child(series, "spPr")),
}
})
.collect::<Vec<_>>();
budget.spend_series(series.len());
series
}
fn flag<E: ChartXml>(parent: &E, local: &str) -> Option<bool> {
match val_attr(child(parent, local))? {
"1" | "true" => Some(true),
"0" | "false" => Some(false),
_ => None,
}
}
fn parse_data_labels<E: ChartXml>(
labels: Option<&E>,
budget: &mut Budget,
) -> Option<ChartDataLabels> {
let labels = labels?;
let points = take_points(children(labels, "dLbl"), budget, |point| {
let index = match child(point, "idx") {
Some(index) => Some(parse_index(val_attr(Some(index)))?),
None => None,
};
Some(ChartPointLabel {
index,
text: text_from_rich_text(child(point, "tx")),
labels: label_switches(point),
})
});
let mut parsed = label_switches(labels);
parsed.points = (!points.is_empty()).then(|| {
let mut points = points;
points.truncate(MAX_POINT_LABELS);
points
});
Some(parsed)
}
fn label_switches<E: ChartXml>(labels: &E) -> ChartDataLabels {
ChartDataLabels {
delete: flag(labels, "delete"),
show_value: flag(labels, "showVal"),
show_category_name: flag(labels, "showCatName"),
show_series_name: flag(labels, "showSerName"),
show_percent: flag(labels, "showPercent"),
show_legend_key: flag(labels, "showLegendKey"),
show_bubble_size: flag(labels, "showBubbleSize"),
separator: child(labels, "separator")
.map(E::descendant_text)
.and_then(|value| (!value.is_empty()).then_some(value)),
position: val_attr(child(labels, "dLblPos")).map(str::to_owned),
number_format: child(labels, "numFmt")
.and_then(|value| value.attribute(None, "formatCode"))
.map(str::to_owned),
text: parse_text_properties(child(labels, "txPr")),
points: None,
}
}
fn parse_title_text<E: ChartXml>(title: &E) -> Option<ChartTextProperties> {
let base = parse_text_properties(child(title, "txPr"))
.or_else(|| parse_text_properties(first_deep(title, "rich", 0)));
let run = first_deep(title, "rich", 0)
.and_then(|rich| first_deep(rich, "rPr", 0))
.map(run_properties)
.filter(|parsed| !parsed.is_empty());
match (run, base) {
(Some(run), Some(base)) => Some(run.over(&base)),
(run, base) => run.or(base),
}
}
fn parse_text_properties<E: ChartXml>(container: Option<&E>) -> Option<ChartTextProperties> {
let container = container?;
let run = first_deep(container, "defRPr", 0).or_else(|| first_deep(container, "rPr", 0))?;
let parsed = run_properties(run);
(!parsed.is_empty()).then_some(parsed)
}
fn run_properties<E: ChartXml>(run: &E) -> ChartTextProperties {
ChartTextProperties {
font: first_deep(run, "latin", 0)
.and_then(|latin| latin.attribute(None, "typeface"))
.and_then(nonempty_trimmed),
size_pt: parse_number(run.attribute(None, "sz"))
.filter(|size| *size > 0.0)
.map(|size| size / 100.0),
bold: match run.attribute(None, "b") {
Some("1" | "true") => Some(true),
Some("0" | "false") => Some(false),
_ => None,
},
italic: match run.attribute(None, "i") {
Some("1" | "true") => Some(true),
Some("0" | "false") => Some(false),
_ => None,
},
color: first_deep(run, "solidFill", 0).and_then(E::solid_fill_hex),
spacing_pt: parse_number(run.attribute(None, "spc"))
.filter(|spacing| spacing.abs() <= MAX_TEXT_SPACING_HUNDREDTHS)
.map(|spacing| spacing / 100.0),
}
}
fn child_formula<E: ChartXml>(parent: Option<&E>) -> Option<String> {
let text = first_deep(parent?, "f", 0)?.descendant_text();
nonempty_trimmed(&text)
}
fn parse_legend<E: ChartXml>(chart_space: &E) -> Option<ChartLegend> {
let legend = first_deep(chart_space, "legend", 0)?;
let position = match val_attr(child(legend, "legendPos")) {
Some("l") => Some("left"),
Some("r") => Some("right"),
Some("t") => Some("top"),
Some("b") => Some("bottom"),
_ => None,
};
Some(ChartLegend {
position: position.map(str::to_owned),
visible: true,
text: parse_text_properties(child(legend, "txPr")),
})
}
fn parse_axis<E: ChartXml>(axis: &E) -> ChartAxis {
let scaling = child(axis, "scaling");
let crosses = val_attr(child(axis, "crosses"));
ChartAxis {
id: val_attr(child(axis, "axId")).map(str::to_owned),
title: text_from_rich_text(child(axis, "title")),
min: parse_number(val_attr(scaling.and_then(|value| child(value, "min")))),
max: parse_number(val_attr(scaling.and_then(|value| child(value, "max")))),
labels: None,
axis_type: match axis.local_name() {
"catAx" => "category",
"dateAx" => "date",
"serAx" => "series",
_ => "value",
}
.to_owned(),
position: match val_attr(child(axis, "axPos")) {
Some("l") => Some("left"),
Some("r") => Some("right"),
Some("t") => Some("top"),
Some("b") => Some("bottom"),
_ => None,
}
.map(str::to_owned),
cross_axis_id: val_attr(child(axis, "crossAx")).map(str::to_owned),
crosses: crosses
.filter(|value| matches!(*value, "min" | "max" | "autoZero"))
.map(str::to_owned),
crosses_at: parse_number(val_attr(child(axis, "crossesAt"))),
major_unit: parse_number(val_attr(child(axis, "majorUnit"))),
minor_unit: parse_number(val_attr(child(axis, "minorUnit"))),
logarithmic_base: parse_number(val_attr(scaling.and_then(|value| child(value, "logBase")))),
reversed: val_attr(scaling.and_then(|value| child(value, "orientation"))) == Some("maxMin"),
number_format: child(axis, "numFmt")
.and_then(|value| value.attribute(None, "formatCode"))
.map(str::to_owned),
major_tick_mark: val_attr(child(axis, "majorTickMark")).map(str::to_owned),
minor_tick_mark: val_attr(child(axis, "minorTickMark")).map(str::to_owned),
tick_label_position: val_attr(child(axis, "tickLblPos")).map(str::to_owned),
hidden: val_attr(child(axis, "delete")) == Some("1"),
major_gridlines: child(axis, "majorGridlines").is_some(),
minor_gridlines: child(axis, "minorGridlines").is_some(),
text: parse_text_properties(child(axis, "txPr")),
line: parse_line(child(axis, "spPr")),
}
}
fn parse_fill<E: ChartXml>(properties: Option<&E>) -> Option<ChartFill> {
let properties = properties?;
if child(properties, "noFill").is_some() {
return Some(ChartFill::None);
}
if let Some(solid) = child(properties, "solidFill") {
return solid
.solid_fill_hex()
.map(|color| ChartFill::Solid { color });
}
let pattern = child(properties, "pattFill")?;
Some(ChartFill::Pattern {
foreground: child(pattern, "fgClr").and_then(E::solid_fill_hex),
background: child(pattern, "bgClr").and_then(E::solid_fill_hex),
})
}
fn parse_line<E: ChartXml>(properties: Option<&E>) -> Option<ChartLine> {
let line = child(properties?, "ln")?;
Some(ChartLine {
none: child(line, "noFill").is_some(),
color: child(line, "solidFill").and_then(E::solid_fill_hex),
width_emu: parse_number(line.attribute(None, "w")),
})
}
fn parse_axes<E: ChartXml>(plot_area: &E, first_series: Option<&ChartSeries>) -> Option<ChartAxes> {
let category = child(plot_area, "catAx")
.or_else(|| child(plot_area, "dateAx"))
.map(parse_axis);
let value = child(plot_area, "valAx").map(parse_axis);
let mut category = category;
if let (Some(axis), Some(series)) = (&mut category, first_series)
&& !series.categories.is_empty()
{
axis.labels = Some(series.categories.clone());
}
(category.is_some() || value.is_some()).then_some(ChartAxes { category, value })
}
fn plot_type_for<E: ChartXml>(chart: &E) -> Option<String> {
let local = chart.local_name().replace("3DChart", "Chart");
let value = match local.as_str() {
"barChart" => {
if val_attr(child(chart, "barDir")) == Some("bar") {
"bar"
} else {
"column"
}
}
"lineChart" => "line",
"pieChart" => "pie",
"doughnutChart" => "doughnut",
"areaChart" => "area",
"scatterChart" => "scatter",
"radarChart" => "radar",
"stockChart" => "stock",
"bubbleChart" => "bubble",
"ofPieChart" => "ofPie",
"surfaceChart" => "surface",
_ => return None,
};
Some(value.to_owned())
}
fn parse_grouping<E: ChartXml>(chart: &E) -> Option<String> {
match val_attr(child(chart, "grouping")) {
Some(value @ ("stacked" | "percentStacked" | "clustered" | "standard")) => {
Some(value.to_owned())
}
_ => None,
}
}
fn parse_plot_group<E: ChartXml>(chart: &E, budget: &mut Budget) -> ChartPlotGroup {
let grouping = parse_grouping(chart);
let axis_ids = children(chart, "axId")
.filter_map(|axis| {
val_attr(Some(axis))
.filter(|value| !value.is_empty())
.map(str::to_owned)
})
.take(MAX_AXIS_IDS)
.collect::<Vec<_>>();
ChartPlotGroup {
chart_type: plot_type_for(chart),
grouping: grouping.clone(),
overlap: parse_number(val_attr(child(chart, "overlap"))),
gap_width: parse_number(val_attr(child(chart, "gapWidth"))),
series: parse_series(chart, grouping.as_deref(), &axis_ids, budget),
axis_ids,
vary_colors: val_attr(child(chart, "varyColors")) == Some("1"),
first_slice_angle: parse_number(val_attr(child(chart, "firstSliceAng"))),
hole_size: parse_number(val_attr(child(chart, "holeSize"))),
show_data_labels: shows_data_labels(chart),
scatter_style: val_attr(child(chart, "scatterStyle"))
.filter(|style| {
matches!(
*style,
"none" | "line" | "lineMarker" | "marker" | "smooth" | "smoothMarker"
)
})
.map(str::to_owned),
radar_style: val_attr(child(chart, "radarStyle"))
.filter(|style| matches!(*style, "standard" | "marker" | "filled"))
.map(str::to_owned),
bubble_scale: parse_number(val_attr(child(chart, "bubbleScale"))),
size_represents: val_attr(child(chart, "sizeRepresents"))
.filter(|value| matches!(*value, "area" | "w"))
.map(str::to_owned),
wireframe: flag(chart, "wireframe"),
hi_low_lines: child(chart, "hiLowLines").is_some(),
up_down_bars: child(chart, "upDownBars").is_some(),
marker: flag(chart, "marker"),
data_labels: parse_data_labels(child(chart, "dLbls"), budget),
}
}
fn shows_data_labels<E: ChartXml>(chart: &E) -> bool {
data_labels_visible(child(chart, "dLbls"))
|| children(chart, "ser")
.take(MAX_CHART_SERIES)
.any(|series| data_labels_visible(child(series, "dLbls")))
}
fn data_labels_visible<E: ChartXml>(labels: Option<&E>) -> bool {
labels.is_some_and(|labels| val_attr(child(labels, "delete")) != Some("1"))
}
fn nonempty_trimmed(value: &str) -> Option<String> {
let value = value.trim();
(!value.is_empty()).then(|| value.to_owned())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::chart::{PlotChart, PlotOp, PlotRect, plot_chart};
#[derive(Clone)]
struct Node {
name: String,
attrs: Vec<(String, String)>,
text: String,
children: Vec<Node>,
}
impl Node {
fn el(name: &str, children: Vec<Node>) -> Self {
Self {
name: name.to_owned(),
attrs: Vec::new(),
text: String::new(),
children,
}
}
fn val(name: &str, value: &str) -> Self {
Self::el(name, Vec::new()).attr("val", value)
}
fn text(name: &str, value: &str) -> Self {
Self {
name: name.to_owned(),
attrs: Vec::new(),
text: value.to_owned(),
children: Vec::new(),
}
}
fn attr(mut self, key: &str, value: &str) -> Self {
self.attrs.push((key.to_owned(), value.to_owned()));
self
}
}
impl ChartXml for Node {
fn local_name(&self) -> &str {
self.name
.split_once(':')
.map_or(self.name.as_str(), |(_, local)| local)
}
fn attribute(&self, prefix: Option<&str>, name: &str) -> Option<&str> {
prefix
.and_then(|prefix| {
let qualified = format!("{prefix}:{name}");
self.attrs.iter().find(|(key, _)| *key == qualified)
})
.or_else(|| self.attrs.iter().find(|(key, _)| key.as_str() == name))
.map(|(_, value)| value.as_str())
}
fn child_elements(&self) -> impl Iterator<Item = &Self> {
self.children.iter()
}
fn descendant_text(&self) -> String {
let mut text = self.text.clone();
for child in &self.children {
text.push_str(&child.descendant_text());
}
text
}
fn solid_fill_hex(&self) -> Option<String> {
self.children
.first()
.and_then(|child| child.attribute(None, "val"))
.map(|rgb| format!("#{rgb}"))
}
}
fn fixture(ns: &str) -> Node {
let el = |name: &str, children: Vec<Node>| Node::el(&format!("{ns}{name}"), children);
let val = |name: &str, value: &str| Node::val(&format!("{ns}{name}"), value);
let text = |name: &str, value: &str| Node::text(&format!("{ns}{name}"), value);
el(
"chartSpace",
vec![el(
"chart",
vec![
el("title", vec![el("rich", vec![text("t", " Sales ")])]),
el("legend", vec![val("legendPos", "l")]),
el(
"plotArea",
vec![
el(
"barChart",
vec![
val("barDir", "bar"),
val("grouping", "stacked"),
el(
"ser",
vec![
el(
"tx",
vec![el(
"v",
vec![text("x", "Nor"), text("x", "th")],
)],
),
el(
"spPr",
vec![el(
"solidFill",
vec![val("srgbClr", "FF0000")],
)],
),
el(
"cat",
vec![el(
"strCache",
vec![el("pt", vec![text("v", "Q1")])],
)],
),
el(
"val",
vec![el(
"numCache",
vec![el("pt", vec![text("v", "7")])],
)],
),
],
),
val("axId", "1"),
],
),
el("catAx", vec![val("axId", "1"), val("axPos", "b")]),
el(
"valAx",
vec![
val("axId", "2"),
el("scaling", vec![val("min", "0"), val("max", "9")]),
],
),
],
),
],
)],
)
}
#[test]
fn parses_series_axes_and_legend_from_a_generic_element_tree() {
let parsed = parse_chart_space(&fixture("")).expect("chart space parses");
assert_eq!(parsed.chart_type, "bar");
assert_eq!(parsed.title.as_deref(), Some("Sales"));
assert_eq!(parsed.legend.unwrap().position.as_deref(), Some("left"));
assert_eq!(parsed.series[0].name.as_deref(), Some("North"));
assert_eq!(parsed.series[0].categories, ["Q1"]);
assert_eq!(parsed.series[0].values, [7.0]);
assert_eq!(parsed.series[0].color, "#FF0000");
assert_eq!(parsed.plot_groups[0].grouping.as_deref(), Some("stacked"));
assert_eq!(parsed.axis_list.as_ref().unwrap().len(), 2);
let axes = parsed.axes.unwrap();
assert_eq!(axes.category.unwrap().labels.unwrap(), ["Q1"]);
assert_eq!(axes.value.unwrap().max, Some(9.0));
}
#[test]
fn a_namespace_prefixed_document_parses_the_same_as_a_bare_one() {
let bare = parse_chart_space(&fixture("")).expect("bare parses");
let prefixed = parse_chart_space(&fixture("c:")).expect("prefixed parses");
assert_eq!(bare, prefixed);
assert_eq!(prefixed.series[0].name.as_deref(), Some("North"));
}
#[test]
fn a_qualified_val_attribute_resolves_through_the_prefix_fallback() {
let space = Node::el(
"c:chartSpace",
vec![Node::el(
"c:chart",
vec![
Node::el(
"c:legend",
vec![Node::el("c:legendPos", Vec::new()).attr("c:val", "b")],
),
Node::el("c:plotArea", vec![Node::el("c:pieChart", Vec::new())]),
],
)],
);
let parsed = parse_chart_space(&space).expect("chart space parses");
assert_eq!(parsed.legend.unwrap().position.as_deref(), Some("bottom"));
}
#[test]
fn chart_wide_budgets_cap_series_and_axis_ids() {
let mut bar = vec![Node::val("c:barDir", "col")];
bar.extend((0..2_000).map(|_| {
Node::el(
"c:ser",
vec![Node::el("c:tx", vec![Node::text("c:v", "S")])],
)
}));
bar.extend((0..64).map(|_| Node::val("c:axId", "1")));
let space = Node::el(
"c:chartSpace",
vec![Node::el(
"c:chart",
vec![Node::el("c:plotArea", vec![Node::el("c:barChart", bar)])],
)],
);
let parsed = parse_chart_space(&space).expect("chart space parses");
assert_eq!(parsed.plot_groups[0].series.len(), MAX_CHART_SERIES);
assert_eq!(parsed.plot_groups[0].axis_ids.len(), MAX_AXIS_IDS);
assert!(
parsed.plot_groups[0].series.iter().all(|series| series
.axis_ids
.as_ref()
.unwrap()
.len()
== MAX_AXIS_IDS)
);
}
#[test]
fn out_of_schema_indexes_are_not_parsed_as_indexes() {
assert_eq!(parse_index(Some("2")), Some(2.0));
assert_eq!(parse_index(Some("0")), Some(0.0));
assert_eq!(parse_index(Some("007")), Some(7.0));
assert_eq!(parse_index(Some("4294967295")), Some(4_294_967_295.0));
assert_eq!(parse_index(Some("4294967296")), None);
assert_eq!(parse_index(Some("-1")), None);
assert_eq!(parse_index(Some("+1")), None);
assert_eq!(parse_index(Some("1.5")), None);
assert_eq!(parse_index(Some("2.0")), None);
assert_eq!(parse_index(Some("1e3")), None);
assert_eq!(parse_index(Some("1e30")), None);
assert_eq!(parse_index(Some("0x2")), None);
assert_eq!(parse_index(Some("nonsense")), None);
}
fn red_point(index: Option<&str>) -> Node {
let mut children = index
.map(|index| vec![Node::val("c:idx", index)])
.unwrap_or_default();
children.push(Node::el(
"c:spPr",
vec![Node::el(
"c:solidFill",
vec![Node::val("a:srgbClr", "FF0000")],
)],
));
Node::el("c:dPt", children)
}
fn pie_with(points: Vec<Node>) -> Node {
let mut series = vec![Node::el(
"c:val",
vec![Node::el(
"c:numCache",
vec![
Node::el("c:pt", vec![Node::text("c:v", "3")]),
Node::el("c:pt", vec![Node::text("c:v", "1")]),
],
)],
)];
series.extend(points);
Node::el(
"c:chartSpace",
vec![Node::el(
"c:chart",
vec![Node::el(
"c:plotArea",
vec![Node::el("c:pieChart", vec![Node::el("c:ser", series)])],
)],
)],
)
}
fn red_wedges(space: &ChartSpace) -> usize {
let rect = PlotRect {
x: 0.0,
y: 0.0,
w: 300.0,
h: 200.0,
};
plot_chart(&PlotChart::from(space), rect)
.iter()
.filter(|op| matches!(op, PlotOp::Path { fill, .. } if fill == "#FF0000"))
.count()
}
#[test]
fn an_invalid_point_index_is_dropped_instead_of_matching_every_slice() {
for index in ["-1", "1.5", "1e3", "0x2", "", "nonsense"] {
let space = parse_chart_space(&pie_with(vec![red_point(Some(index))]))
.expect("chart space parses");
assert!(space.plot_groups[0].series[0].points.is_none(), "{index}");
assert_eq!(red_wedges(&space), 0, "{index}");
}
let space = parse_chart_space(&pie_with(vec![red_point(Some("-1")), red_point(Some("1"))]))
.expect("chart space parses");
let points = space.plot_groups[0].series[0].points.as_ref().unwrap();
assert_eq!(points.len(), 1);
assert_eq!(points[0].index, Some(1.0));
assert_eq!(red_wedges(&space), 1);
}
#[test]
fn an_absent_point_index_stays_the_intentional_wildcard() {
let space =
parse_chart_space(&pie_with(vec![red_point(None)])).expect("chart space parses");
let points = space.plot_groups[0].series[0].points.as_ref().unwrap();
assert_eq!(points[0].index, None);
assert_eq!(red_wedges(&space), 2);
}
#[test]
fn every_examined_point_costs_budget_even_when_it_yields_nothing() {
let cache = Node::el(
"c:numCache",
vec![
Node::el("c:pt", vec![Node::text("c:v", "nonsense")]),
Node::el("c:pt", vec![Node::text("c:v", "1")]),
Node::el("c:pt", Vec::new()),
],
);
let mut budget = Budget::new();
assert_eq!(parse_num_cache(Some(&cache), &mut budget), [1.0]);
assert_eq!(budget.point_cap(MAX_CHART_POINTS), MAX_CHART_POINTS - 3);
let chart = Node::el(
"c:pieChart",
vec![Node::el(
"c:ser",
vec![
Node::el("c:dPt", vec![Node::val("c:idx", "-1")]),
Node::el("c:dPt", vec![Node::val("c:idx", "1")]),
],
)],
);
let mut budget = Budget::new();
let series = parse_series(&chart, None, &[], &mut budget);
assert_eq!(series[0].points.as_ref().unwrap().len(), 1);
assert_eq!(budget.point_cap(MAX_CHART_POINTS), MAX_CHART_POINTS - 2);
}
#[test]
fn the_point_budget_is_shared_across_every_cache_in_one_chart() {
let mut budget = Budget::new();
assert_eq!(budget.point_cap(MAX_POINTS), MAX_POINTS);
budget.spend_points(MAX_CHART_POINTS - 5);
assert_eq!(budget.point_cap(MAX_POINTS), 5);
budget.spend_points(9);
assert_eq!(budget.point_cap(MAX_POINTS), 0);
}
#[test]
fn data_labels_count_from_either_placement_and_deletion_switches_them_off() {
fn shows(group_labels: Option<Node>, series_labels: Option<Node>) -> bool {
let mut series = Vec::new();
series.extend(series_labels);
let mut bar = vec![Node::val("c:barDir", "col"), Node::el("c:ser", series)];
bar.extend(group_labels);
let space = Node::el(
"c:chartSpace",
vec![Node::el(
"c:chart",
vec![Node::el("c:plotArea", vec![Node::el("c:barChart", bar)])],
)],
);
parse_chart_space(&space)
.expect("chart space parses")
.plot_groups[0]
.show_data_labels
}
let shown = || Node::el("c:dLbls", vec![Node::val("c:showVal", "1")]);
let deleted = || Node::el("c:dLbls", vec![Node::val("c:delete", "1")]);
assert!(shows(Some(shown()), None));
assert!(shows(None, Some(shown())));
assert!(shows(Some(deleted()), Some(shown())));
assert!(!shows(None, None));
assert!(!shows(Some(deleted()), None));
assert!(!shows(None, Some(deleted())));
}
#[test]
fn data_label_cascade_stays_scoped_to_group_series_and_point() {
let labels = Node::el(
"c:dLbls",
vec![
Node::val("c:showVal", "1"),
Node::el(
"c:dLbl",
vec![Node::val("c:idx", "1"), Node::val("c:delete", "1")],
),
],
);
let deleted = Node::el("c:dLbls", vec![Node::val("c:delete", "1")]);
let space = Node::el(
"c:chartSpace",
vec![Node::el(
"c:chart",
vec![Node::el(
"c:plotArea",
vec![Node::el(
"c:barChart",
vec![
Node::val("c:barDir", "col"),
Node::el("c:ser", vec![labels]),
Node::el("c:ser", Vec::new()),
Node::el("c:ser", vec![deleted]),
],
)],
)],
)],
);
let group = &parse_chart_space(&space)
.expect("chart space parses")
.plot_groups[0];
assert!(group.data_labels.is_none());
let first = group.series[0].data_labels.as_ref().unwrap();
assert_eq!(first.show_value, Some(true));
let points = first.points.as_ref().unwrap();
assert_eq!(points[0].index, Some(1.0));
assert_eq!(points[0].labels.delete, Some(true));
assert!(group.series[1].data_labels.is_none());
assert_eq!(
group.series[2].data_labels.as_ref().unwrap().delete,
Some(true)
);
}
#[test]
fn group_data_labels_remain_defaults_for_an_unset_series() {
let space = Node::el(
"c:chartSpace",
vec![Node::el(
"c:chart",
vec![Node::el(
"c:plotArea",
vec![Node::el(
"c:barChart",
vec![
Node::val("c:barDir", "col"),
Node::el("c:ser", Vec::new()),
Node::el(
"c:dLbls",
vec![Node::val("c:showVal", "1"), Node::val("c:showCatName", "0")],
),
],
)],
)],
)],
);
let group = &parse_chart_space(&space)
.expect("chart space parses")
.plot_groups[0];
let labels = group.data_labels.as_ref().unwrap();
assert_eq!(labels.show_value, Some(true));
assert_eq!(labels.show_category_name, Some(false));
assert!(group.series[0].data_labels.is_none());
}
fn num_cache(wrapper: &str, values: &[f64]) -> Node {
Node::el(
wrapper,
vec![Node::el(
"c:numCache",
values
.iter()
.map(|value| Node::el("c:pt", vec![Node::text("c:v", &value.to_string())]))
.collect(),
)],
)
}
fn plot_area(group: Node) -> Node {
Node::el(
"c:chartSpace",
vec![Node::el(
"c:chart",
vec![Node::el("c:plotArea", vec![group])],
)],
)
}
#[test]
fn a_scatter_series_reads_its_x_and_y_values() {
let space = parse_chart_space(&plot_area(Node::el(
"c:scatterChart",
vec![
Node::val("c:scatterStyle", "smoothMarker"),
Node::el(
"c:ser",
vec![
num_cache("c:xVal", &[1.0, 4.0]),
num_cache("c:yVal", &[10.0, 20.0]),
],
),
],
)))
.expect("chart space parses");
let group = &space.plot_groups[0];
assert_eq!(group.chart_type.as_deref(), Some("scatter"));
assert_eq!(group.scatter_style.as_deref(), Some("smoothMarker"));
assert_eq!(group.series[0].values, [10.0, 20.0]);
assert_eq!(
group.series[0].x_values.as_deref(),
Some([1.0, 4.0].as_ref())
);
}
#[test]
fn a_scatter_x_cache_is_charged_once_when_it_also_supplies_categories() {
let chart = Node::el(
"c:scatterChart",
vec![Node::el(
"c:ser",
vec![
num_cache("c:xVal", &[1.0, 4.0]),
num_cache("c:yVal", &[10.0, 20.0]),
],
)],
);
let mut budget = Budget::new();
budget.spend_points(MAX_CHART_POINTS - 4);
let series = parse_series(&chart, None, &[], &mut budget);
assert_eq!(series[0].categories, ["1", "4"]);
assert_eq!(series[0].values, [10.0, 20.0]);
assert_eq!(series[0].x_values.as_deref(), Some([1.0, 4.0].as_ref()));
assert_eq!(budget.point_cap(MAX_CHART_POINTS), 0);
}
#[test]
fn a_bubble_series_reads_its_sizes_and_the_group_scale() {
let space = parse_chart_space(&plot_area(Node::el(
"c:bubbleChart",
vec![
Node::val("c:bubbleScale", "150"),
Node::val("c:sizeRepresents", "w"),
Node::el(
"c:ser",
vec![
num_cache("c:xVal", &[1.0, 2.0]),
num_cache("c:yVal", &[3.0, 4.0]),
num_cache("c:bubbleSize", &[5.0, 9.0]),
],
),
],
)))
.expect("chart space parses");
let group = &space.plot_groups[0];
assert_eq!(group.bubble_scale, Some(150.0));
assert_eq!(group.size_represents.as_deref(), Some("w"));
assert_eq!(
group.series[0].bubble_sizes.as_deref(),
Some([5.0, 9.0].as_ref())
);
}
#[test]
fn family_switches_parse_off_their_own_plot_group() {
let radar = parse_chart_space(&plot_area(Node::el(
"c:radarChart",
vec![Node::val("c:radarStyle", "filled")],
)))
.expect("parses");
assert_eq!(radar.plot_groups[0].radar_style.as_deref(), Some("filled"));
let surface = parse_chart_space(&plot_area(Node::el(
"c:surface3DChart",
vec![Node::val("c:wireframe", "1")],
)))
.expect("parses");
assert_eq!(
surface.plot_groups[0].chart_type.as_deref(),
Some("surface")
);
assert_eq!(surface.plot_groups[0].wireframe, Some(true));
let stock = parse_chart_space(&plot_area(Node::el(
"c:stockChart",
vec![
Node::el("c:hiLowLines", Vec::new()),
Node::el("c:upDownBars", Vec::new()),
],
)))
.expect("parses");
assert!(stock.plot_groups[0].hi_low_lines);
assert!(stock.plot_groups[0].up_down_bars);
}
#[test]
fn data_label_switches_and_per_point_overrides_parse() {
let labels = Node::el(
"c:dLbls",
vec![
Node::el(
"c:dLbl",
vec![
Node::val("c:idx", "1"),
Node::val("c:showVal", "0"),
Node::val("c:showCatName", "1"),
],
),
Node::el("c:numFmt", Vec::new()).attr("formatCode", "0.0%"),
Node::val("c:dLblPos", "outEnd"),
Node::text("c:separator", "; "),
Node::val("c:showVal", "1"),
Node::val("c:showPercent", "1"),
Node::val("c:showLegendKey", "0"),
],
);
let space = parse_chart_space(&plot_area(Node::el(
"c:pieChart",
vec![Node::el("c:ser", vec![labels])],
)))
.expect("chart space parses");
let parsed = space.plot_groups[0].series[0]
.data_labels
.as_ref()
.expect("labels parse");
assert_eq!(parsed.show_value, Some(true));
assert_eq!(parsed.show_percent, Some(true));
assert_eq!(parsed.show_legend_key, Some(false));
assert_eq!(parsed.show_series_name, None);
assert_eq!(parsed.number_format.as_deref(), Some("0.0%"));
assert_eq!(parsed.position.as_deref(), Some("outEnd"));
assert_eq!(parsed.separator.as_deref(), Some("; "));
let points = parsed.points.as_ref().expect("point overrides parse");
assert_eq!(points[0].index, Some(1.0));
assert_eq!(points[0].labels.show_value, Some(false));
assert_eq!(points[0].labels.show_category_name, Some(true));
assert!(points[0].labels.points.is_none());
}
#[test]
fn text_properties_parse_at_chart_axis_and_legend_scope() {
let text_properties = |size: &str| {
Node::el(
"c:txPr",
vec![Node::el(
"a:p",
vec![Node::el(
"a:pPr",
vec![
Node::el(
"a:defRPr",
vec![
Node::el("a:solidFill", vec![Node::val("a:srgbClr", "FF0000")]),
Node::el("a:latin", Vec::new()).attr("typeface", "Georgia"),
],
)
.attr("sz", size)
.attr("b", "1")
.attr("i", "1"),
],
)],
)],
)
};
let space = Node::el(
"c:chartSpace",
vec![
text_properties("1400"),
Node::el(
"c:chart",
vec![
Node::el("c:legend", vec![text_properties("900")]),
Node::el(
"c:plotArea",
vec![
Node::el("c:barChart", vec![Node::val("c:barDir", "col")]),
Node::el(
"c:valAx",
vec![
Node::val("c:axId", "1"),
Node::el("c:majorGridlines", Vec::new()),
text_properties("800"),
],
),
],
),
],
),
],
);
let parsed = parse_chart_space(&space).expect("chart space parses");
let chart_text = parsed.text.expect("chart text parses");
assert_eq!(chart_text.size_pt, Some(14.0));
assert_eq!(chart_text.font.as_deref(), Some("Georgia"));
assert_eq!(chart_text.bold, Some(true));
assert_eq!(chart_text.italic, Some(true));
assert_eq!(chart_text.color.as_deref(), Some("#FF0000"));
assert_eq!(parsed.legend.unwrap().text.unwrap().size_pt, Some(9.0));
let axis = &parsed.axis_list.unwrap()[0];
assert!(axis.major_gridlines);
assert!(!axis.minor_gridlines);
assert_eq!(axis.text.as_ref().unwrap().size_pt, Some(8.0));
}
#[test]
fn a_title_run_overrides_the_paragraph_default_it_sits_under() {
let title = Node::el(
"c:title",
vec![
Node::el(
"c:tx",
vec![Node::el(
"c:rich",
vec![Node::el(
"a:p",
vec![Node::el(
"a:r",
vec![
Node::el("a:rPr", Vec::new()).attr("spc", "600"),
Node::text("a:t", "Revenue"),
],
)],
)],
)],
),
Node::el(
"c:txPr",
vec![Node::el(
"a:p",
vec![Node::el(
"a:pPr",
vec![
Node::el(
"a:defRPr",
vec![
Node::el("a:latin", Vec::new()).attr("typeface", "+mj-lt"),
],
)
.attr("spc", "300")
.attr("i", "1"),
],
)],
)],
),
],
);
let space = Node::el(
"c:chartSpace",
vec![Node::el(
"c:chart",
vec![
title,
Node::el(
"c:plotArea",
vec![Node::el("c:barChart", vec![Node::val("c:barDir", "col")])],
),
],
)],
);
let parsed = parse_chart_space(&space).expect("chart space parses");
assert_eq!(parsed.title.as_deref(), Some("Revenue"));
let text = parsed.title_text.expect("title text parses");
assert_eq!(text.spacing_pt, Some(6.0));
assert_eq!(text.italic, Some(true));
assert_eq!(text.font.as_deref(), Some("+mj-lt"));
}
#[test]
fn character_spacing_parses_in_points_and_declines_an_absurd_one() {
let space = |spacing: &str| {
Node::el(
"c:chartSpace",
vec![
Node::el(
"c:txPr",
vec![Node::el(
"a:p",
vec![Node::el(
"a:pPr",
vec![Node::el("a:defRPr", Vec::new()).attr("spc", spacing)],
)],
)],
),
Node::el(
"c:chart",
vec![Node::el(
"c:plotArea",
vec![Node::el("c:barChart", vec![Node::val("c:barDir", "col")])],
)],
),
],
)
};
let spacing = |raw: &str| {
parse_chart_space(&space(raw))
.expect("chart space parses")
.text
.and_then(|text| text.spacing_pt)
};
assert_eq!(spacing("300"), Some(3.0));
assert_eq!(spacing("-150"), Some(-1.5));
assert_eq!(spacing("400001"), None);
assert_eq!(spacing(""), None);
}
#[test]
fn returns_none_without_a_recognized_plot() {
assert!(
parse_chart_space(&Node::el(
"c:chartSpace",
vec![Node::el("c:chart", Vec::new())]
))
.is_none()
);
assert!(
parse_chart_space(&Node::el(
"c:chartSpace",
vec![Node::el(
"c:chart",
vec![Node::el("c:plotArea", Vec::new())]
)]
))
.is_none()
);
}
}