392 lines
20 KiB
Rust
392 lines
20 KiB
Rust
//! Chart parts (`c:chartSpace`) → [`Chart`]. The original XML is kept in `Chart.raw`.
|
|
|
|
use deckcraft_model::chart::{Chart, ChartType, Series};
|
|
use deckcraft_model::style::Fill;
|
|
|
|
use crate::xml::El;
|
|
|
|
/// Fills of chart parts never reference images we keep; read colour fills only.
|
|
fn simple_fill(sp: Option<&El>) -> Option<Fill> {
|
|
let sp = sp?;
|
|
for e in sp.elements() {
|
|
match e.local() {
|
|
"noFill" => return Some(Fill::None),
|
|
"solidFill" => return super::dml::color(e).map(|color| Fill::Solid { color }),
|
|
"gradFill" => return Some(Fill::Gradient(super::dml::gradient(e))),
|
|
_ => {}
|
|
}
|
|
}
|
|
None
|
|
}
|
|
|
|
fn simple_line(sp: Option<&El>) -> Option<deckcraft_model::Line> {
|
|
let ln = sp?.child("ln")?;
|
|
let mut l = deckcraft_model::Line { width: super::dml::pt(ln, "w"), ..Default::default() };
|
|
l.fill = simple_fill(Some(ln));
|
|
Some(l)
|
|
}
|
|
|
|
fn rich_text(t: &El) -> Option<String> {
|
|
let tx = t.child("tx")?;
|
|
if let Some(r) = tx.child("rich") {
|
|
let s = r.children_named("p").map(|p| p.text()).collect::<Vec<_>>().join("\n");
|
|
return Some(s);
|
|
}
|
|
tx.find("v").map(|v| v.text())
|
|
}
|
|
|
|
/// String values from `c:cat`/`c:tx` (str or num refs, caches or literals).
|
|
fn strings(e: &El) -> Vec<String> {
|
|
let cache = e.find("strCache").or_else(|| e.find("numCache")).or_else(|| e.find("strLit")).or_else(|| e.find("numLit"));
|
|
if let Some(c) = cache {
|
|
let n = c.child("ptCount").and_then(|p| p.u32("val")).unwrap_or(0).min(100_000) as usize;
|
|
let mut out = vec![String::new(); n];
|
|
for pt in c.children_named("pt").take(100_000) {
|
|
let i = pt.u32("idx").unwrap_or(0) as usize;
|
|
let v = pt.child("v").map(|v| v.text()).unwrap_or_default();
|
|
if i >= out.len() {
|
|
if i >= 100_000 {
|
|
continue;
|
|
}
|
|
out.resize(i + 1, String::new());
|
|
}
|
|
if let Some(s) = out.get_mut(i) {
|
|
*s = v;
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
if let Some(ml) = e.find("multiLvlStrCache") {
|
|
// Use the innermost level.
|
|
if let Some(l) = ml.child("lvl") {
|
|
return l.children_named("pt").map(|p| p.child("v").map(|v| v.text()).unwrap_or_default()).collect();
|
|
}
|
|
}
|
|
if let Some(v) = e.child("v") {
|
|
return vec![v.text()];
|
|
}
|
|
vec![]
|
|
}
|
|
|
|
fn numbers(e: &El) -> Vec<Option<f64>> {
|
|
strings(e).into_iter().map(|s| s.trim().parse::<f64>().ok().filter(|v| v.is_finite())).collect()
|
|
}
|
|
|
|
/// Parse a chart part. Returns `None` for something that isn't a chart we can read.
|
|
pub fn read_chart_xml(bytes: &[u8]) -> Option<Chart> {
|
|
let doc = crate::xml::parse(bytes).ok()?;
|
|
let mut root = doc.root;
|
|
super::resolve_mc(&mut root, 0);
|
|
if !root.is("chartSpace") {
|
|
return None;
|
|
}
|
|
let mut c = chart_from(&root)?;
|
|
c.raw = Some(String::from_utf8_lossy(bytes).into_owned());
|
|
Some(c)
|
|
}
|
|
|
|
pub(crate) fn chart_from(root: &El) -> Option<Chart> {
|
|
let ch = root.child("chart")?;
|
|
let pa = ch.child("plotArea")?;
|
|
let mut c = Chart { categories: vec![], series: vec![], title: None, legend: None, gridlines: false, ..Default::default() };
|
|
c.style = root.child_val("style").and_then(|v| v.parse().ok()).unwrap_or(2);
|
|
if let Some(t) = ch.child("title") {
|
|
c.title = Some(rich_text(t).unwrap_or_else(|| "Chart Title".into()));
|
|
}
|
|
if ch.child("autoTitleDeleted").and_then(|a| a.bool("val")) == Some(true) {
|
|
c.title = None;
|
|
}
|
|
c.legend = ch.child("legend").map(|l| l.child_val("legendPos").unwrap_or("r").to_string());
|
|
c.legend_overlay = ch.child("legend").and_then(|l| l.child("overlay")).and_then(|o| o.bool("val")).unwrap_or(false);
|
|
c.chart_fill = simple_fill(root.child("spPr"));
|
|
c.plot_fill = simple_fill(pa.child("spPr"));
|
|
|
|
let mut kinds: Vec<ChartType> = vec![];
|
|
// The value axis the first plot group uses is the primary one; a group on another value axis
|
|
// (a combo chart's line on the right) is on the secondary axis.
|
|
let val_ids: Vec<(&str, bool)> =
|
|
pa.children_named("valAx").filter_map(|a| Some((a.child_val("axId")?, matches!(a.child_val("axPos"), Some("l" | "r"))))).collect();
|
|
// A group's value axis: its vertical one when it has two (scatter), else its one.
|
|
let group_val_axis = |g: &El| {
|
|
let ids: Vec<(&str, bool)> =
|
|
g.children_named("axId").filter_map(|a| a.attr("val")).filter_map(|id| val_ids.iter().find(|(v, _)| *v == id).copied()).collect();
|
|
ids.iter().find(|(_, vertical)| *vertical).or(ids.first()).map(|(id, _)| id.to_string())
|
|
};
|
|
// The primary value axis is the one on the left (bottom for bar charts); the secondary one
|
|
// sits on the right or top, or crosses the category axis at its maximum.
|
|
let secondary_placed = |a: &El| a.child_val("crosses") == Some("max") || matches!(a.child_val("axPos"), Some("r" | "t"));
|
|
let used: Vec<String> = pa.elements().filter(|g| g.local().ends_with("Chart")).filter_map(group_val_axis).collect();
|
|
let primary_val: Option<String> = used
|
|
.iter()
|
|
.find(|id| pa.children_named("valAx").find(|a| a.child_val("axId") == Some(id.as_str())).is_some_and(|a| !secondary_placed(a)))
|
|
.or(used.first())
|
|
.cloned();
|
|
for g in pa.elements() {
|
|
let name = g.local();
|
|
if !name.ends_with("Chart") {
|
|
continue;
|
|
}
|
|
let grouping = g.child_val("grouping").unwrap_or("clustered");
|
|
let kind = match name {
|
|
"barChart" | "bar3DChart" => {
|
|
let bar = g.child_val("barDir") == Some("bar");
|
|
match (bar, grouping) {
|
|
(false, "stacked") => ChartType::StackedColumn,
|
|
(false, "percentStacked") => ChartType::PercentColumn,
|
|
(false, _) => ChartType::Column,
|
|
(true, "stacked") => ChartType::StackedBar,
|
|
(true, "percentStacked") => ChartType::PercentBar,
|
|
(true, _) => ChartType::Bar,
|
|
}
|
|
}
|
|
"lineChart" | "line3DChart" => {
|
|
let markers = g.child_val("marker").is_some_and(|v| v == "1" || v == "true")
|
|
&& g.children_named("ser").all(|s| s.path(&["marker", "symbol"]).and_then(|m| m.attr("val")) != Some("none"));
|
|
match grouping {
|
|
"stacked" | "percentStacked" => ChartType::StackedLine,
|
|
_ if markers => ChartType::LineMarkers,
|
|
_ => ChartType::Line,
|
|
}
|
|
}
|
|
"areaChart" | "area3DChart" => {
|
|
if matches!(grouping, "stacked" | "percentStacked") {
|
|
ChartType::StackedArea
|
|
} else {
|
|
ChartType::Area
|
|
}
|
|
}
|
|
"pieChart" | "pie3DChart" | "ofPieChart" => ChartType::Pie,
|
|
"doughnutChart" => ChartType::Doughnut,
|
|
"scatterChart" => ChartType::Scatter,
|
|
"bubbleChart" => ChartType::Bubble,
|
|
"radarChart" => {
|
|
if g.child_val("radarStyle") == Some("filled") {
|
|
ChartType::FilledRadar
|
|
} else {
|
|
ChartType::Radar
|
|
}
|
|
}
|
|
"stockChart" => ChartType::Stock,
|
|
"surfaceChart" | "surface3DChart" => ChartType::Surface,
|
|
_ => continue,
|
|
};
|
|
if let Some(v) = g.child_val("varyColors") {
|
|
c.vary_colors = v == "1" || v == "true";
|
|
}
|
|
if let Some(v) = g.child_val("gapWidth").and_then(|v| v.parse::<f64>().ok()) {
|
|
c.gap_width = (v / 100.0).clamp(0.0, 5.0);
|
|
}
|
|
if let Some(v) = g.child_val("overlap").and_then(|v| v.parse::<f64>().ok()) {
|
|
c.overlap = (v / 100.0).clamp(-1.0, 1.0);
|
|
}
|
|
if let Some(v) = g.child_val("holeSize").and_then(|v| v.parse::<f64>().ok()) {
|
|
c.hole_size = (v / 100.0).clamp(0.0, 0.9);
|
|
}
|
|
if g.path(&["dLbls", "showVal"]).and_then(|v| v.bool("val")) == Some(true) {
|
|
c.data_labels = true;
|
|
}
|
|
kinds.push(kind);
|
|
let val_axis = group_val_axis(g);
|
|
let secondary = val_axis.is_some() && val_axis != primary_val;
|
|
for s in g.children_named("ser").take(1000) {
|
|
let mut ser = Series {
|
|
name: s.child("tx").map(strings).and_then(|v| v.into_iter().next()).unwrap_or_default(),
|
|
fill: simple_fill(s.child("spPr")),
|
|
line: simple_line(s.child("spPr")),
|
|
smooth: s.child("smooth").and_then(|v| v.bool("val")).unwrap_or(false),
|
|
marker: s.path(&["marker", "symbol"]).and_then(|m| m.attr("val")).filter(|m| *m != "none").map(String::from),
|
|
kind: Some(kind),
|
|
secondary_axis: secondary,
|
|
..Default::default()
|
|
};
|
|
if let Some(d) = s.child("dLbls").and_then(|d| d.child("showVal")).and_then(|v| v.bool("val")) {
|
|
ser.data_labels = Some(d);
|
|
}
|
|
// Data labels' number format: the series' labels', the group's, else the source data's.
|
|
let label_fmt = |d: Option<&El>| {
|
|
d.and_then(|d| d.child("numFmt"))
|
|
.filter(|n| n.attr("sourceLinked") != Some("1"))
|
|
.and_then(|n| n.attr("formatCode"))
|
|
.map(str::to_string)
|
|
};
|
|
let source =
|
|
s.child("val").or_else(|| s.child("yVal")).and_then(|v| v.find("numCache")).and_then(|c| c.child("formatCode")).map(|f| f.text());
|
|
ser.number_format = label_fmt(s.child("dLbls")).or_else(|| label_fmt(g.child("dLbls"))).or(source).filter(|f| !f.is_empty());
|
|
for dp in s.children_named("dPt") {
|
|
if let (Some(i), Some(f)) = (dp.child("idx").and_then(|i| i.u32("val")), simple_fill(dp.child("spPr"))) {
|
|
ser.point_fills.push((i as usize, f));
|
|
}
|
|
}
|
|
if let Some(cat) = s.child("cat").or_else(|| s.child("xVal")) {
|
|
let cats = strings(cat);
|
|
if matches!(kind, ChartType::Scatter | ChartType::Bubble) {
|
|
ser.x_values = cats.iter().map(|v| v.trim().parse::<f64>().ok()).collect();
|
|
}
|
|
if c.categories.is_empty() {
|
|
c.categories = cats;
|
|
}
|
|
}
|
|
if let Some(v) = s.child("val").or_else(|| s.child("yVal")) {
|
|
ser.values = numbers(v);
|
|
}
|
|
if let Some(b) = s.child("bubbleSize") {
|
|
ser.sizes = numbers(b);
|
|
}
|
|
c.series.push(ser);
|
|
}
|
|
}
|
|
let first = *kinds.first()?;
|
|
c.kind = if kinds.iter().any(|k| *k != first) { ChartType::Combo } else { first };
|
|
if c.kind != ChartType::Combo {
|
|
for s in &mut c.series {
|
|
s.kind = None;
|
|
s.secondary_axis = false;
|
|
}
|
|
}
|
|
// Axes.
|
|
c.lang = root.child_val("lang").map(str::to_string);
|
|
let vertical = |a: &&El| matches!(a.child_val("axPos"), Some("l" | "r"));
|
|
let by_id = |id: &Option<String>| pa.children_named("valAx").find(|a| id.is_some() && a.child_val("axId") == id.as_deref());
|
|
let val_ax = by_id(&primary_val).or_else(|| pa.children_named("valAx").find(vertical)).or_else(|| pa.children_named("valAx").last());
|
|
let num = |a: &El, k: &str| a.child_val(k).and_then(|v| v.parse::<f64>().ok()).filter(|v| v.is_finite());
|
|
let format_code = |a: &El| a.child("numFmt").and_then(|n| n.attr("formatCode")).map(str::to_string);
|
|
let deleted = |a: &El| a.child("delete").and_then(|d| d.bool("val")) == Some(true);
|
|
if c.kind == ChartType::Combo && c.series.iter().any(|s| s.secondary_axis) {
|
|
let primary_id = val_ax.and_then(|a| a.child_val("axId"));
|
|
// The other vertical value axis is the secondary one.
|
|
if let Some(a) = pa.children_named("valAx").find(|a| a.child_val("axId") != primary_id && vertical(a)) {
|
|
let sc = a.child("scaling");
|
|
c.y2_min = sc.and_then(|s| num(s, "min"));
|
|
c.y2_max = sc.and_then(|s| num(s, "max"));
|
|
c.y2_major = num(a, "majorUnit");
|
|
c.y2_format = format_code(a);
|
|
c.y2_hidden = deleted(a);
|
|
}
|
|
}
|
|
let cat_ax = pa.child("catAx").or_else(|| pa.child("dateAx")).or_else(|| pa.children_named("valAx").find(|a| !vertical(a)));
|
|
if let Some(v) = val_ax {
|
|
c.gridlines = v.child("majorGridlines").is_some();
|
|
let sc = v.child("scaling");
|
|
c.y_min = sc.and_then(|s| s.child_val("min")).and_then(|v| v.parse().ok()).filter(|v: &f64| v.is_finite());
|
|
c.y_max = sc.and_then(|s| s.child_val("max")).and_then(|v| v.parse().ok()).filter(|v: &f64| v.is_finite());
|
|
c.axis_titles.1 = v.child("title").map(|t| rich_text(t).unwrap_or_else(|| "Axis Title".into()));
|
|
c.y_major = num(v, "majorUnit");
|
|
c.y_format = format_code(v);
|
|
c.y_hidden = deleted(v);
|
|
}
|
|
if let Some(a) = cat_ax {
|
|
c.axis_titles.0 = a.child("title").map(|t| rich_text(t).unwrap_or_else(|| "Axis Title".into()));
|
|
}
|
|
// Text sizes as the file sets them (PowerPoint draws chart text at these sizes, not scaled to
|
|
// the chart): the axes' and legend's, else the whole chart's; the title's own.
|
|
let chart_size = text_size(root.child("txPr"));
|
|
c.text_size = [val_ax, cat_ax, ch.child("legend")].into_iter().flatten().find_map(|e| text_size(e.child("txPr"))).or(chart_size);
|
|
c.title_size = ch.child("title").and_then(|t| text_size(t.child("tx").and_then(|x| x.child("rich"))).or_else(|| text_size(t.child("txPr"))));
|
|
Some(c)
|
|
}
|
|
|
|
/// The size in points of the first `a:defRPr` or `a:rPr` with one under `e` (`sz` in hundredths).
|
|
fn text_size(e: Option<&El>) -> Option<f64> {
|
|
fn rec(e: &El, depth: usize) -> Option<f64> {
|
|
if depth > 8 {
|
|
return None;
|
|
}
|
|
for c in e.elements() {
|
|
if (c.is("defRPr") || c.is("rPr"))
|
|
&& let Some(v) = c.attr("sz").and_then(|v| v.parse::<f64>().ok()).filter(|v| v.is_finite() && *v > 0.0)
|
|
{
|
|
return Some((v / 100.0).clamp(1.0, 400.0));
|
|
}
|
|
if let Some(v) = rec(c, depth + 1) {
|
|
return Some(v);
|
|
}
|
|
}
|
|
None
|
|
}
|
|
rec(e?, 0)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn legend_position_and_overlay_are_read() {
|
|
let legend = r#"<c:legend><c:legendPos val="r"/><c:overlay val="1"/></c:legend>"#;
|
|
let xml = format!(
|
|
r#"<c:chartSpace xmlns:c="http://schemas.openxmlformats.org/drawingml/2006/chart"><c:chart><c:plotArea><c:barChart><c:barDir val="col"/></c:barChart></c:plotArea>{legend}</c:chart></c:chartSpace>"#
|
|
);
|
|
let c = read_chart_xml(xml.as_bytes()).unwrap();
|
|
assert_eq!(c.legend.as_deref(), Some("r"));
|
|
assert!(c.legend_overlay);
|
|
let xml = br#"<c:chartSpace xmlns:c="http://schemas.openxmlformats.org/drawingml/2006/chart"><c:chart><c:plotArea><c:barChart><c:barDir val="col"/></c:barChart></c:plotArea><c:legend/></c:chart></c:chartSpace>"#;
|
|
let c = read_chart_xml(xml).unwrap();
|
|
assert_eq!(c.legend.as_deref(), Some("r"));
|
|
assert!(!c.legend_overlay);
|
|
}
|
|
|
|
#[test]
|
|
fn combo_chart_with_a_secondary_axis() {
|
|
// Columns on axes 1/2, a line on axes 3/4 whose value axis is on the right.
|
|
let xml = br##"<c:chartSpace xmlns:c="http://schemas.openxmlformats.org/drawingml/2006/chart" xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main"><c:lang val="ru-RU"/><c:chart><c:plotArea>
|
|
<c:barChart><c:barDir val="col"/><c:grouping val="clustered"/><c:ser><c:idx val="0"/><c:tx><c:v>Spend</c:v></c:tx><c:cat><c:strLit><c:ptCount val="2"/><c:pt idx="0"><c:v>W1</c:v></c:pt><c:pt idx="1"><c:v>W2</c:v></c:pt></c:strLit></c:cat><c:val><c:numLit><c:ptCount val="2"/><c:pt idx="0"><c:v>200000</c:v></c:pt><c:pt idx="1"><c:v>310000</c:v></c:pt></c:numLit></c:val></c:ser><c:axId val="1"/><c:axId val="2"/></c:barChart>
|
|
<c:lineChart><c:grouping val="standard"/><c:ser><c:idx val="1"/><c:tx><c:v>Clicks</c:v></c:tx><c:spPr><a:ln w="28575"><a:solidFill><a:srgbClr val="00B050"/></a:solidFill></a:ln></c:spPr><c:val><c:numLit><c:ptCount val="2"/><c:pt idx="0"><c:v>6000</c:v></c:pt><c:pt idx="1"><c:v>7400</c:v></c:pt></c:numLit></c:val></c:ser><c:marker val="1"/><c:axId val="3"/><c:axId val="4"/></c:lineChart>
|
|
<c:catAx><c:axId val="1"/><c:axPos val="b"/><c:crossAx val="2"/></c:catAx><c:valAx><c:axId val="2"/><c:axPos val="l"/><c:numFmt formatCode="#,##0" sourceLinked="1"/><c:crossAx val="1"/><c:majorUnit val="100000"/></c:valAx>
|
|
<c:valAx><c:axId val="4"/><c:scaling><c:max val="8000"/></c:scaling><c:axPos val="r"/><c:numFmt formatCode="0.0" sourceLinked="0"/><c:crossAx val="3"/></c:valAx><c:catAx><c:axId val="3"/><c:delete val="1"/><c:axPos val="b"/><c:crossAx val="4"/></c:catAx>
|
|
</c:plotArea></c:chart></c:chartSpace>"##;
|
|
let c = read_chart_xml(xml).unwrap();
|
|
assert_eq!(c.kind, ChartType::Combo);
|
|
assert_eq!(c.series.len(), 2);
|
|
assert!(!c.series[0].secondary_axis);
|
|
assert!(c.series[1].secondary_axis);
|
|
assert_eq!(c.series[1].kind, Some(ChartType::LineMarkers));
|
|
assert!(c.series[1].line.as_ref().is_some_and(|l| l.width.is_some_and(|w| (w - 2.25).abs() < 0.01)));
|
|
// Each value axis' number format, bounds and unit; the chart's language.
|
|
assert_eq!(c.y_format.as_deref(), Some("#,##0"));
|
|
assert_eq!((c.y_major, c.y2_max, c.y2_format.as_deref()), (Some(100000.0), Some(8000.0), Some("0.0")));
|
|
assert_eq!(c.lang.as_deref(), Some("ru-RU"));
|
|
// Group order doesn't decide: columns on the right-hand axis are the secondary series even
|
|
// when their group comes first, and the line on the left is primary.
|
|
let swapped = String::from_utf8_lossy(xml)
|
|
.replace(
|
|
"<c:axPos val=\"l\"/><c:numFmt formatCode=\"#,##0\"",
|
|
"<c:axPos val=\"r\"/><c:crosses val=\"max\"/><c:numFmt formatCode=\"#,##0\"",
|
|
)
|
|
.replace("<c:axPos val=\"r\"/><c:numFmt formatCode=\"0.0\"", "<c:axPos val=\"l\"/><c:numFmt formatCode=\"0.0\"");
|
|
let c2 = read_chart_xml(swapped.as_bytes()).unwrap();
|
|
assert!(c2.series[0].secondary_axis && !c2.series[1].secondary_axis);
|
|
assert_eq!((c2.y_format.as_deref(), c2.y2_format.as_deref()), (Some("0.0"), Some("#,##0")));
|
|
// A chart of one type on one axis has no secondary series.
|
|
let single = String::from_utf8_lossy(xml)
|
|
.replace("<c:axId val=\"3\"/><c:axId val=\"4\"/></c:lineChart>", "<c:axId val=\"1\"/><c:axId val=\"2\"/></c:lineChart>");
|
|
let c = read_chart_xml(single.as_bytes()).unwrap();
|
|
assert!(c.series.iter().all(|s| !s.secondary_axis));
|
|
}
|
|
|
|
#[test]
|
|
fn text_sizes_are_read_from_the_file() {
|
|
// PowerPoint's new charts set the axes' and legend's text to 11.97 pt and the title's to
|
|
// 18.62 pt; a chart that only sets its overall text size uses it everywhere.
|
|
let ns =
|
|
r#"xmlns:c="http://schemas.openxmlformats.org/drawingml/2006/chart" xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main""#;
|
|
let tx = |sz: u32| format!(r#"<c:txPr><a:bodyPr/><a:p><a:pPr><a:defRPr sz="{sz}"/></a:pPr></a:p></c:txPr>"#);
|
|
let xml = format!(
|
|
r#"<c:chartSpace {ns}><c:chart><c:title><c:tx><c:rich><a:bodyPr/><a:p><a:pPr><a:defRPr sz="1862"/></a:pPr><a:r><a:t>T</a:t></a:r></a:p></c:rich></c:tx></c:title><c:plotArea><c:barChart><c:barDir val="col"/><c:axId val="1"/><c:axId val="2"/></c:barChart><c:catAx><c:axId val="1"/>{}</c:catAx><c:valAx><c:axId val="2"/>{}</c:valAx></c:plotArea></c:chart></c:chartSpace>"#,
|
|
tx(1197),
|
|
tx(1197)
|
|
);
|
|
let c = read_chart_xml(xml.as_bytes()).unwrap();
|
|
assert_eq!((c.text_size, c.title_size), (Some(11.97), Some(18.62)));
|
|
let xml = format!(
|
|
r#"<c:chartSpace {ns}><c:chart><c:plotArea><c:barChart><c:barDir val="col"/></c:barChart></c:plotArea></c:chart>{}</c:chartSpace>"#,
|
|
tx(1400)
|
|
);
|
|
let c = read_chart_xml(xml.as_bytes()).unwrap();
|
|
assert_eq!((c.text_size, c.title_size), (Some(14.0), None));
|
|
let xml = format!(
|
|
r#"<c:chartSpace {ns}><c:chart><c:plotArea><c:barChart><c:barDir val="col"/></c:barChart></c:plotArea></c:chart></c:chartSpace>"#
|
|
);
|
|
assert_eq!(read_chart_xml(xml.as_bytes()).unwrap().text_size, None, "no sizes: the renderer picks one");
|
|
}
|
|
}
|