gridcraft/crates/xlsx/src/chart.rs

754 lines
31 KiB
Rust

//! DrawingML charts (`xl/charts/chartN.xml`): reading the common 2-D/3-D chart groups into the
//! model, and writing model charts (with cached series values) from scratch.
use std::fmt::Write as _;
use gridcraft_core::Value;
use gridcraft_formula::{Expr, SheetSel};
use gridcraft_model::{Chart, ChartKind, Color, LegendPos, LineDash, Series, Trendline, TrendlineKind, Workbook};
use crate::drawing::{dml_color, srgb};
use crate::xml::{El, esc, num};
// ---------------------------------------------------------------- reading
fn group_kind(g: &El) -> Option<ChartKind> {
let grouping = g.child_val("grouping").unwrap_or("standard");
let stacked = grouping == "stacked";
let pct = grouping == "percentStacked";
Some(match g.name.as_str() {
"barChart" | "bar3DChart" => {
let bar = g.child_val("barDir") == Some("bar");
match (bar, stacked, pct) {
(false, true, _) => ChartKind::ColumnStacked,
(false, _, true) => ChartKind::ColumnStacked100,
(false, _, _) => ChartKind::ColumnClustered,
(true, true, _) => ChartKind::BarStacked,
(true, _, true) => ChartKind::BarStacked100,
(true, _, _) => ChartKind::BarClustered,
}
}
"lineChart" | "line3DChart" => {
if stacked || pct {
ChartKind::LineStacked
} else {
let group_markers = g.child_val("marker").is_none_or(|v| v == "1" || v == "true");
let all_none = g.kids("ser").all(|s| s.path(&["marker", "symbol"]).and_then(|m| m.attr("val")) == Some("none"));
if !group_markers || (all_none && g.kids("ser").next().is_some()) { ChartKind::Line } else { ChartKind::LineMarkers }
}
}
"pieChart" | "pie3DChart" | "ofPieChart" => ChartKind::Pie,
"doughnutChart" => ChartKind::Doughnut,
"areaChart" | "area3DChart" => {
if stacked || pct {
ChartKind::AreaStacked
} else {
ChartKind::Area
}
}
"scatterChart" => {
let no_lines = g.kids("ser").all(|s| s.path(&["spPr", "ln"]).is_some_and(|l| l.child("noFill").is_some()));
if g.child_val("scatterStyle") == Some("marker") || (no_lines && g.kids("ser").next().is_some()) {
ChartKind::Scatter
} else {
ChartKind::ScatterLines
}
}
"bubbleChart" => ChartKind::Bubble,
"radarChart" => ChartKind::Radar,
"stockChart" => ChartKind::Stock,
_ => return None,
})
}
fn data_ref(e: Option<&El>) -> Option<String> {
let e = e?;
for k in ["numRef", "strRef", "multiLvlStrRef"] {
if let Some(f) = e.child(k).and_then(|r| r.child("f")) {
let t = f.text.trim();
return Some(t.strip_prefix('=').unwrap_or(t).to_string());
}
}
// Literal values become an array constant.
for k in ["numLit", "strLit"] {
if let Some(l) = e.child(k) {
let pts: Vec<String> = l
.kids("pt")
.filter_map(|p| p.child("v"))
.map(|v| if k == "numLit" { v.text.trim().to_string() } else { format!("\"{}\"", v.text.replace('"', "\"\"")) })
.collect();
return Some(format!("{{{}}}", pts.join(",")));
}
}
None
}
fn read_series(s: &El) -> Series {
let name = s.child("tx").and_then(|tx| {
if let Some(f) = tx.path(&["strRef", "f"]) {
let t = f.text.trim();
Some(t.strip_prefix('=').unwrap_or(t).to_string())
} else {
tx.child("v").map(|v| v.text.clone())
}
});
let sp = s.child("spPr");
let color = sp
.and_then(|p| p.child("solidFill"))
.and_then(dml_color)
.or_else(|| sp.and_then(|p| p.path(&["ln", "solidFill"])).and_then(dml_color))
.or_else(|| s.path(&["marker", "spPr", "solidFill"]).and_then(dml_color));
Series {
name,
categories: data_ref(s.child("cat")).or_else(|| data_ref(s.child("xVal"))),
values: data_ref(s.child("val")).or_else(|| data_ref(s.child("yVal"))).unwrap_or_default(),
bubble_sizes: data_ref(s.child("bubbleSize")),
color,
secondary: false,
kind: None,
smooth: s.child("smooth").and_then(|e| e.attr("val")).and_then(|v| v.parse::<u8>().ok()).is_some_and(|v| v != 0),
trendlines: s.kids("trendline").filter_map(read_trendline).collect(),
}
}
const DASHES: [(LineDash, &str); 11] = [
(LineDash::Solid, "solid"),
(LineDash::Dot, "dot"),
(LineDash::Dash, "dash"),
(LineDash::LgDash, "lgDash"),
(LineDash::DashDot, "dashDot"),
(LineDash::LgDashDot, "lgDashDot"),
(LineDash::LgDashDotDot, "lgDashDotDot"),
(LineDash::SysDash, "sysDash"),
(LineDash::SysDot, "sysDot"),
(LineDash::SysDashDot, "sysDashDot"),
(LineDash::SysDashDotDot, "sysDashDotDot"),
];
const EMU_PER_PT: f64 = 12_700.0;
/// A `CT_Boolean` child (`val` defaults to true).
fn bool_val(e: &El, tag: &str) -> bool {
e.child(tag).is_some_and(|c| c.flag("val", true))
}
fn f64_val(e: &El, tag: &str) -> Option<f64> {
e.child(tag).and_then(|c| c.attr_f64("val"))
}
/// `<c:trendline>` (ECMA-376 Part 1, 21.2.2.211). Unknown types are dropped.
fn read_trendline(t: &El) -> Option<Trendline> {
let kind = match t.child_val("trendlineType").unwrap_or("linear") {
"linear" => TrendlineKind::Linear,
"exp" => TrendlineKind::Exponential,
"log" => TrendlineKind::Logarithmic,
"power" => TrendlineKind::Power,
"poly" => TrendlineKind::Polynomial(t.child_val("order").and_then(|v| v.trim().parse::<u8>().ok()).unwrap_or(2).clamp(2, 6)),
"movingAvg" => TrendlineKind::MovingAverage(t.child_val("period").and_then(|v| v.trim().parse::<u32>().ok()).unwrap_or(2).clamp(2, 255)),
_ => return None,
};
let ln = t.path(&["spPr", "ln"]);
Some(Trendline {
kind,
name: t.child("name").map(|n| n.text.clone()),
forward: f64_val(t, "forward").unwrap_or(0.0).max(0.0),
backward: f64_val(t, "backward").unwrap_or(0.0).max(0.0),
intercept: f64_val(t, "intercept"),
display_equation: bool_val(t, "dispEq"),
display_r_squared: bool_val(t, "dispRSqr"),
color: ln.and_then(|l| l.child("solidFill")).and_then(dml_color),
width: ln.and_then(|l| l.attr_f64("w")).map(|w| (w / EMU_PER_PT) as f32).filter(|w| w.is_finite() && *w >= 0.0),
dash: ln.and_then(|l| l.child("prstDash")).and_then(|d| d.attr("val")).and_then(|v| DASHES.iter().find(|(_, n)| *n == v).map(|(d, _)| *d)),
})
}
fn rich_title(t: &El) -> Option<String> {
let tx = t.child("tx")?;
if let Some(rich) = tx.child("rich") {
let paras: Vec<String> =
rich.kids("p").map(|p| p.kids("r").filter_map(|r| r.child("t")).map(|t| t.text.as_str()).collect::<String>()).collect();
let s = paras.join("\n");
return if s.is_empty() { None } else { Some(s) };
}
if let Some(sr) = tx.child("strRef") {
if let Some(v) = sr.path(&["strCache", "pt", "v"]) {
return Some(v.text.clone());
}
return sr.child("f").map(|f| f.text.trim().to_string());
}
None
}
fn axis_ids(g: &El) -> Vec<String> {
g.kids("axId").filter_map(|a| a.attr("val")).map(str::to_string).collect()
}
pub fn read_chart(root: &El) -> Option<Chart> {
let chart = root.child("chart")?;
let plot = chart.child("plotArea")?;
let groups: Vec<(&El, ChartKind)> = plot.children.iter().filter_map(|g| group_kind(g).map(|k| (g, k))).collect();
let (first, first_kind) = *groups.first()?;
let first_axes = axis_ids(first);
let mut series = vec![];
for (gi, (g, k)) in groups.iter().enumerate() {
for s in g.kids("ser") {
let mut ser = read_series(s);
if gi > 0 {
ser.kind = Some(*k);
ser.secondary = axis_ids(g) != first_axes;
}
series.push(ser);
}
}
let kind = if groups.len() > 1 { ChartKind::Combo } else { first_kind };
let show_val = |e: &El| e.child("dLbls").and_then(|d| d.child_val("showVal")).is_some_and(|v| v == "1" || v == "true");
let data_labels = groups.iter().any(|(g, _)| show_val(g) || g.kids("ser").any(show_val));
let val_axes: Vec<&El> = plot.kids("valAx").collect();
let cat_ax = plot.child("catAx").or_else(|| plot.child("dateAx"));
let scatter = matches!(first_kind, ChartKind::Scatter | ChartKind::ScatterLines | ChartKind::Bubble);
let (x_ax, y_ax) = if scatter {
let x = val_axes.iter().find(|a| matches!(a.child_val("axPos"), Some("b") | Some("t"))).copied();
let y = val_axes.iter().find(|a| matches!(a.child_val("axPos"), Some("l") | Some("r"))).copied();
(x, y)
} else {
(cat_ax, val_axes.first().copied())
};
let legend = match chart.child("legend") {
None => LegendPos::None,
Some(l) => match l.child_val("legendPos").unwrap_or("r") {
"b" => LegendPos::Bottom,
"t" => LegendPos::Top,
"l" => LegendPos::Left,
_ => LegendPos::Right,
},
};
Some(Chart {
id: 0,
kind,
anchor: Default::default(),
title: chart.child("title").and_then(rich_title),
series,
legend,
data_labels,
gridlines: val_axes.iter().any(|a| a.child("majorGridlines").is_some()),
style: root.child_val("style").and_then(|v| v.parse().ok()).unwrap_or(2),
x_title: x_ax.and_then(|a| a.child("title")).and_then(rich_title),
y_title: y_ax.and_then(|a| a.child("title")).and_then(rich_title),
source: None,
by_rows: false,
pivot: root.path(&["pivotSource", "name"]).map(|n| n.text.trim().to_string()).filter(|n| !n.is_empty()),
})
}
// ---------------------------------------------------------------- writing
#[derive(Clone, Copy, PartialEq)]
enum G {
Bar { bar: bool, grouping: &'static str },
Line { stacked: bool, markers: bool },
Pie,
Doughnut,
Area { stacked: bool },
Scatter { lines: bool },
Bubble,
Radar,
Stock,
}
fn group_for(k: ChartKind) -> G {
match k {
ChartKind::ColumnStacked => G::Bar { bar: false, grouping: "stacked" },
ChartKind::ColumnStacked100 => G::Bar { bar: false, grouping: "percentStacked" },
ChartKind::BarClustered => G::Bar { bar: true, grouping: "clustered" },
ChartKind::BarStacked => G::Bar { bar: true, grouping: "stacked" },
ChartKind::BarStacked100 => G::Bar { bar: true, grouping: "percentStacked" },
ChartKind::Line => G::Line { stacked: false, markers: false },
ChartKind::LineMarkers => G::Line { stacked: false, markers: true },
ChartKind::LineStacked => G::Line { stacked: true, markers: false },
ChartKind::Pie => G::Pie,
ChartKind::Doughnut => G::Doughnut,
ChartKind::Area => G::Area { stacked: false },
ChartKind::AreaStacked => G::Area { stacked: true },
ChartKind::Scatter => G::Scatter { lines: false },
ChartKind::ScatterLines => G::Scatter { lines: true },
ChartKind::Bubble => G::Bubble,
ChartKind::Radar => G::Radar,
ChartKind::Stock => G::Stock,
// Combo charts start from clustered columns; chartex kinds have their own part.
_ => G::Bar { bar: false, grouping: "clustered" },
}
}
const MAX_CACHE: u64 = 100_000;
/// Values of a same-workbook reference (`Sheet1!$B$2:$B$9`), row-major.
pub(crate) fn ref_values(wb: &Workbook, sheet_idx: usize, f: &str) -> Option<Vec<Value>> {
let e = gridcraft_formula::parse(f).ok()?;
let Expr::Ref(r) = e else { return None };
let si = match &r.sheet {
SheetSel::Current => sheet_idx,
SheetSel::Named(n) => wb.sheet_index(n)?,
SheetSel::Span(..) => return None,
};
let sheet = wb.sheet(si)?;
let mut range = r.range();
// Whole columns/rows: limit to the used range.
if let Some(u) = sheet.used_range() {
range = range.intersection(&u)?;
}
if range.count() > MAX_CACHE {
return None;
}
Some(range.iter().map(|c| sheet.value(c)).collect())
}
fn is_ref(f: &str) -> bool {
matches!(gridcraft_formula::parse(f), Ok(Expr::Ref(_)))
}
fn num_data(tag: &str, f: Option<&str>, wb: &Workbook, si: usize) -> String {
let f = f.unwrap_or("").trim();
if f.is_empty() {
return format!("<c:{tag}><c:numLit><c:ptCount val=\"0\"/></c:numLit></c:{tag}>");
}
if f.starts_with('{') {
let vals: Vec<&str> = f.trim_matches(|c| c == '{' || c == '}').split([',', ';']).collect();
let mut s = format!("<c:{tag}><c:numLit><c:formatCode>General</c:formatCode><c:ptCount val=\"{}\"/>", vals.len());
for (i, v) in vals.iter().enumerate() {
if let Ok(n) = v.trim().parse::<f64>() {
let _ = write!(s, "<c:pt idx=\"{i}\"><c:v>{}</c:v></c:pt>", num(n));
}
}
let _ = write!(s, "</c:numLit></c:{tag}>");
return s;
}
let mut s = format!("<c:{tag}><c:numRef><c:f>{}</c:f>", esc(&crate::fmla::text_to_file(f)));
if let Some(vals) = ref_values(wb, si, f) {
let _ = write!(s, "<c:numCache><c:formatCode>General</c:formatCode><c:ptCount val=\"{}\"/>", vals.len());
for (i, v) in vals.iter().enumerate() {
if let Value::Number(n) = v {
let _ = write!(s, "<c:pt idx=\"{i}\"><c:v>{}</c:v></c:pt>", num(*n));
}
}
s.push_str("</c:numCache>");
}
let _ = write!(s, "</c:numRef></c:{tag}>");
s
}
/// Splits the inside of an array constant on `,` and `;` outside double quotes; inside quotes `""` is a literal quote.
/// Unquoted items are trimmed; quoted ones keep the text between the quotes and drop the spaces around them.
fn split_array_items(inner: &str) -> Vec<String> {
let mut items = Vec::new();
let mut cur = String::new();
let mut quoted = false;
let mut in_quote = false;
let mut chars = inner.chars().peekable();
let finish = |cur: &mut String, quoted: &mut bool| {
let text = std::mem::take(cur);
let item = if *quoted { text } else { text.trim().to_string() };
*quoted = false;
item
};
while let Some(c) = chars.next() {
match c {
'"' if in_quote && chars.peek() == Some(&'"') => {
cur.push('"');
chars.next();
}
'"' => {
if !quoted && cur.trim().is_empty() {
cur.clear();
}
in_quote = !in_quote;
quoted = true;
}
',' | ';' if !in_quote => items.push(finish(&mut cur, &mut quoted)),
c if quoted && !in_quote && c.is_whitespace() => {}
_ => cur.push(c),
}
}
items.push(finish(&mut cur, &mut quoted));
items
}
fn str_data(tag: &str, f: &str, wb: &Workbook, si: usize) -> String {
let f = f.trim();
if f.starts_with('{') {
let vals = split_array_items(f.trim_matches(|c| c == '{' || c == '}'));
let mut s = format!("<c:{tag}><c:strLit><c:ptCount val=\"{}\"/>", vals.len());
for (i, v) in vals.iter().enumerate() {
let _ = write!(s, "<c:pt idx=\"{i}\"><c:v>{}</c:v></c:pt>", esc(v));
}
let _ = write!(s, "</c:strLit></c:{tag}>");
return s;
}
let mut s = format!("<c:{tag}><c:strRef><c:f>{}</c:f>", esc(&crate::fmla::text_to_file(f)));
if let Some(vals) = ref_values(wb, si, f) {
let _ = write!(s, "<c:strCache><c:ptCount val=\"{}\"/>", vals.len());
for (i, v) in vals.iter().enumerate() {
if !v.is_empty() {
let _ = write!(s, "<c:pt idx=\"{i}\"><c:v>{}</c:v></c:pt>", esc(&v.display()));
}
}
s.push_str("</c:strCache>");
}
let _ = write!(s, "</c:strRef></c:{tag}>");
s
}
fn series_xml(s: &Series, idx: usize, g: G, wb: &Workbook, si: usize) -> String {
let mut x = format!("<c:ser><c:idx val=\"{idx}\"/><c:order val=\"{idx}\"/>");
if let Some(n) = &s.name {
if is_ref(n) {
x.push_str(&str_data("tx", n, wb, si));
} else {
let lit = n.trim().strip_prefix('"').and_then(|t| t.strip_suffix('"')).unwrap_or(n);
let _ = write!(x, "<c:tx><c:v>{}</c:v></c:tx>", esc(lit));
}
}
let color = s.color.as_ref().and_then(|c: &Color| srgb(c, &wb.theme));
let line_like = matches!(g, G::Line { .. } | G::Radar | G::Scatter { lines: true });
match (&color, line_like) {
(Some(c), true) => {
let _ = write!(x, "<c:spPr><a:ln w=\"28575\" cap=\"rnd\"><a:solidFill>{c}</a:solidFill><a:round/></a:ln></c:spPr>");
}
(Some(c), false) if g == (G::Scatter { lines: false }) => {
let _ = write!(
x,
"<c:spPr><a:ln w=\"19050\"><a:noFill/></a:ln></c:spPr><c:marker><c:symbol val=\"circle\"/><c:size val=\"5\"/><c:spPr><a:solidFill>{c}</a:solidFill></c:spPr></c:marker>"
);
}
(Some(c), false) => {
let _ = write!(x, "<c:spPr><a:solidFill>{c}</a:solidFill></c:spPr>");
}
(None, _) if g == (G::Scatter { lines: false }) || g == G::Stock => x.push_str("<c:spPr><a:ln w=\"19050\"><a:noFill/></a:ln></c:spPr>"),
_ => {}
}
match g {
G::Bar { .. } => x.push_str("<c:invertIfNegative val=\"0\"/>"),
G::Line { markers: false, .. } | G::Scatter { lines: true } | G::Stock => x.push_str("<c:marker><c:symbol val=\"none\"/></c:marker>"),
G::Bubble => x.push_str("<c:invertIfNegative val=\"0\"/>"),
_ => {}
}
// Trendlines sit between dLbls and the data in CT_LineSer, CT_BarSer, CT_AreaSer,
// CT_ScatterSer and CT_BubbleSer; pie and radar series have none.
if !matches!(g, G::Pie | G::Doughnut | G::Radar) {
for t in &s.trendlines {
x.push_str(&trendline_xml(t, wb));
}
}
match g {
G::Scatter { .. } | G::Bubble => {
if let Some(c) = &s.categories {
x.push_str(&num_data("xVal", Some(c), wb, si));
}
x.push_str(&num_data("yVal", Some(&s.values), wb, si));
if g == G::Bubble {
x.push_str(&num_data("bubbleSize", s.bubble_sizes.as_deref().or(Some(&s.values)), wb, si));
x.push_str("<c:bubble3D val=\"0\"/>");
} else {
let _ = write!(x, "<c:smooth val=\"{}\"/>", s.smooth as u8);
}
}
_ => {
if let Some(c) = &s.categories {
x.push_str(&str_data("cat", c, wb, si));
}
x.push_str(&num_data("val", Some(&s.values), wb, si));
if matches!(g, G::Line { .. }) {
let _ = write!(x, "<c:smooth val=\"{}\"/>", s.smooth as u8);
}
}
}
x.push_str("</c:ser>");
x
}
/// `<c:trendline>` with children in schema order: name, spPr, trendlineType, order, period,
/// forward, backward, intercept, dispRSqr, dispEq, trendlineLbl.
fn trendline_xml(t: &Trendline, wb: &Workbook) -> String {
let mut x = String::from("<c:trendline>");
if let Some(n) = &t.name {
let _ = write!(x, "<c:name>{}</c:name>", esc(n));
}
let color = t.color.as_ref().and_then(|c| srgb(c, &wb.theme));
if color.is_some() || t.width.is_some() || t.dash.is_some() {
x.push_str("<c:spPr><a:ln");
if let Some(w) = t.width.filter(|w| w.is_finite()) {
// ST_LineWidth: 0..=20116800 EMU.
let _ = write!(x, " w=\"{}\"", (f64::from(w) * EMU_PER_PT).round().clamp(0.0, 20_116_800.0) as i64);
}
x.push_str(" cap=\"rnd\">");
if let Some(c) = &color {
let _ = write!(x, "<a:solidFill>{c}</a:solidFill>");
}
if let Some(d) = t.dash.and_then(|d| DASHES.iter().find(|(k, _)| *k == d)) {
let _ = write!(x, "<a:prstDash val=\"{}\"/>", d.1);
}
x.push_str("</a:ln></c:spPr>");
}
let ty = match t.kind {
TrendlineKind::Linear => "linear",
TrendlineKind::Exponential => "exp",
TrendlineKind::Logarithmic => "log",
TrendlineKind::Polynomial(_) => "poly",
TrendlineKind::Power => "power",
TrendlineKind::MovingAverage(_) => "movingAvg",
};
let _ = write!(x, "<c:trendlineType val=\"{ty}\"/>");
match t.kind {
TrendlineKind::Polynomial(o) => {
let _ = write!(x, "<c:order val=\"{}\"/>", o.clamp(2, 6));
}
TrendlineKind::MovingAverage(p) => {
let _ = write!(x, "<c:period val=\"{}\"/>", p.clamp(2, 255));
}
_ => {}
}
for (tag, v) in [("forward", t.forward), ("backward", t.backward)] {
if v.is_finite() && v > 0.0 {
let _ = write!(x, "<c:{tag} val=\"{}\"/>", num(v));
}
}
if let Some(i) = t.intercept.filter(|i| i.is_finite()) {
let _ = write!(x, "<c:intercept val=\"{}\"/>", num(i));
}
let _ = write!(x, "<c:dispRSqr val=\"{}\"/><c:dispEq val=\"{}\"/>", t.display_r_squared as u8, t.display_equation as u8);
if t.display_r_squared || t.display_equation {
x.push_str("<c:trendlineLbl><c:numFmt formatCode=\"General\" sourceLinked=\"0\"/></c:trendlineLbl>");
}
x.push_str("</c:trendline>");
x
}
fn dlbls(show: bool) -> String {
format!(
"<c:dLbls><c:showLegendKey val=\"0\"/><c:showVal val=\"{}\"/><c:showCatName val=\"0\"/><c:showSerName val=\"0\"/><c:showPercent val=\"0\"/><c:showBubbleSize val=\"0\"/></c:dLbls>",
show as u8
)
}
fn group_xml(g: G, series: &[(usize, &Series)], ch: &Chart, axes: (u32, u32), wb: &Workbook, si: usize) -> String {
let sers: String = series.iter().map(|(i, s)| series_xml(s, *i, g, wb, si)).collect();
let dl = dlbls(ch.data_labels);
let ax = format!("<c:axId val=\"{}\"/><c:axId val=\"{}\"/>", axes.0, axes.1);
match g {
G::Bar { bar, grouping } => {
let overlap = if grouping == "clustered" { String::new() } else { "<c:overlap val=\"100\"/>".into() };
format!(
"<c:barChart><c:barDir val=\"{}\"/><c:grouping val=\"{grouping}\"/><c:varyColors val=\"0\"/>{sers}{dl}<c:gapWidth val=\"150\"/>{overlap}{ax}</c:barChart>",
if bar { "bar" } else { "col" }
)
}
G::Line { stacked, .. } => format!(
"<c:lineChart><c:grouping val=\"{}\"/><c:varyColors val=\"0\"/>{sers}{dl}<c:marker val=\"1\"/>{ax}</c:lineChart>",
if stacked { "stacked" } else { "standard" }
),
G::Pie => format!("<c:pieChart><c:varyColors val=\"1\"/>{sers}{dl}<c:firstSliceAng val=\"0\"/></c:pieChart>"),
G::Doughnut => {
format!("<c:doughnutChart><c:varyColors val=\"1\"/>{sers}{dl}<c:firstSliceAng val=\"0\"/><c:holeSize val=\"50\"/></c:doughnutChart>")
}
G::Area { stacked } => format!(
"<c:areaChart><c:grouping val=\"{}\"/><c:varyColors val=\"0\"/>{sers}{dl}{ax}</c:areaChart>",
if stacked { "stacked" } else { "standard" }
),
G::Scatter { .. } => format!("<c:scatterChart><c:scatterStyle val=\"lineMarker\"/><c:varyColors val=\"0\"/>{sers}{dl}{ax}</c:scatterChart>"),
G::Bubble => {
format!("<c:bubbleChart><c:varyColors val=\"0\"/>{sers}{dl}<c:bubbleScale val=\"100\"/><c:showNegBubbles val=\"0\"/>{ax}</c:bubbleChart>")
}
G::Radar => format!("<c:radarChart><c:radarStyle val=\"marker\"/><c:varyColors val=\"0\"/>{sers}{dl}{ax}</c:radarChart>"),
G::Stock => format!("<c:stockChart>{sers}{dl}<c:hiLowLines/>{ax}</c:stockChart>"),
}
}
fn title_xml(t: &str) -> String {
let mut s = String::from("<c:title><c:tx><c:rich><a:bodyPr/><a:lstStyle/>");
for line in t.split('\n') {
let _ = write!(s, "<a:p><a:pPr><a:defRPr/></a:pPr><a:r><a:t>{}</a:t></a:r></a:p>", esc(line));
}
s.push_str("</c:rich></c:tx><c:overlay val=\"0\"/></c:title>");
s
}
struct Ax<'a> {
cat: bool,
id: u32,
cross: u32,
pos: &'a str,
delete: bool,
grid: bool,
title: Option<&'a str>,
crosses_max: bool,
}
fn axis_xml(a: &Ax<'_>) -> String {
let mut s = format!(
"<c:{0}><c:axId val=\"{1}\"/><c:scaling><c:orientation val=\"minMax\"/></c:scaling><c:delete val=\"{2}\"/><c:axPos val=\"{3}\"/>",
if a.cat { "catAx" } else { "valAx" },
a.id,
a.delete as u8,
a.pos
);
if a.grid {
s.push_str("<c:majorGridlines/>");
}
if let Some(t) = a.title {
s.push_str(&title_xml(t));
}
let _ = write!(
s,
"<c:numFmt formatCode=\"General\" sourceLinked=\"1\"/><c:majorTickMark val=\"out\"/><c:minorTickMark val=\"none\"/><c:tickLblPos val=\"nextTo\"/><c:crossAx val=\"{}\"/><c:crosses val=\"{}\"/>",
a.cross,
if a.crosses_max { "max" } else { "autoZero" }
);
if a.cat {
s.push_str("<c:auto val=\"1\"/><c:lblAlgn val=\"ctr\"/><c:lblOffset val=\"100\"/><c:noMultiLvlLbl val=\"0\"/></c:catAx>");
} else {
s.push_str("<c:crossBetween val=\"between\"/></c:valAx>");
}
s
}
/// Chart part XML for a model chart on sheet `si`.
pub fn write_chart(wb: &Workbook, si: usize, ch: &Chart) -> String {
let mut s = String::from(
"<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>\n<c:chartSpace xmlns:c=\"http://schemas.openxmlformats.org/drawingml/2006/chart\" xmlns:a=\"http://schemas.openxmlformats.org/drawingml/2006/main\" xmlns:r=\"http://schemas.openxmlformats.org/officeDocument/2006/relationships\"><c:roundedCorners val=\"0\"/>",
);
if (1..=48).contains(&ch.style) {
let _ = write!(s, "<c:style val=\"{}\"/>", ch.style);
}
// A PivotChart names its PivotTable (`Sheet2!PivotTable1`).
if let Some(link) = ch.pivot.as_deref().map(str::trim).filter(|l| !l.is_empty()) {
let _ = write!(s, "<c:pivotSource><c:name>{}</c:name><c:fmtId val=\"0\"/></c:pivotSource>", esc(link));
}
s.push_str("<c:chart>");
match &ch.title {
Some(t) if !t.is_empty() => {
s.push_str(&title_xml(t));
s.push_str("<c:autoTitleDeleted val=\"0\"/>");
}
_ => s.push_str("<c:autoTitleDeleted val=\"1\"/>"),
}
s.push_str("<c:plotArea><c:layout/>");
let all: Vec<(usize, &Series)> = ch.series.iter().enumerate().collect();
let primary_g;
let mut secondary: Option<G> = None;
if ch.kind == ChartKind::Combo {
// Series without a kind of their own: the first is a column, the others lines (as drawn).
let kind = |i: usize, x: &Series| x.kind.filter(|k| *k != ChartKind::Combo).or((i > 0).then_some(ChartKind::Line));
let is_line = |k: Option<ChartKind>| matches!(k.map(group_for), Some(G::Line { .. } | G::Area { .. } | G::Radar | G::Scatter { .. }));
let bars: Vec<(usize, &Series)> = all.iter().filter(|(i, x)| !x.secondary && !is_line(kind(*i, x))).copied().collect();
let lines: Vec<(usize, &Series)> = all.iter().filter(|(i, x)| !x.secondary && is_line(kind(*i, x))).copied().collect();
let sec: Vec<(usize, &Series)> = all.iter().filter(|(_, x)| x.secondary).copied().collect();
primary_g = G::Bar { bar: false, grouping: "clustered" };
if !bars.is_empty() || (lines.is_empty() && sec.is_empty()) {
s.push_str(&group_xml(primary_g, &bars, ch, (1, 2), wb, si));
}
if !lines.is_empty() {
s.push_str(&group_xml(G::Line { stacked: false, markers: false }, &lines, ch, (1, 2), wb, si));
}
if !sec.is_empty() {
let g = match sec.first().and_then(|(_, x)| x.kind).map(group_for) {
Some(b @ G::Bar { .. }) => b,
_ => G::Line { stacked: false, markers: false },
};
s.push_str(&group_xml(g, &sec, ch, (3, 4), wb, si));
secondary = Some(g);
}
} else {
primary_g = match group_for(ch.kind) {
// A stock chart has 3 or 4 series (high-low-close, open-high-low-close).
G::Stock if !(3..=4).contains(&ch.series.len()) => G::Line { stacked: false, markers: false },
g => g,
};
s.push_str(&group_xml(primary_g, &all, ch, (1, 2), wb, si));
}
match primary_g {
G::Pie | G::Doughnut => {}
G::Scatter { .. } | G::Bubble => {
s.push_str(&axis_xml(&Ax {
cat: false,
id: 1,
cross: 2,
pos: "b",
delete: false,
grid: false,
title: ch.x_title.as_deref(),
crosses_max: false,
}));
s.push_str(&axis_xml(&Ax {
cat: false,
id: 2,
cross: 1,
pos: "l",
delete: false,
grid: ch.gridlines,
title: ch.y_title.as_deref(),
crosses_max: false,
}));
}
G::Bar { bar: true, .. } => {
s.push_str(&axis_xml(&Ax {
cat: true,
id: 1,
cross: 2,
pos: "l",
delete: false,
grid: false,
title: ch.x_title.as_deref(),
crosses_max: false,
}));
s.push_str(&axis_xml(&Ax {
cat: false,
id: 2,
cross: 1,
pos: "b",
delete: false,
grid: ch.gridlines,
title: ch.y_title.as_deref(),
crosses_max: false,
}));
}
_ => {
s.push_str(&axis_xml(&Ax {
cat: true,
id: 1,
cross: 2,
pos: "b",
delete: false,
grid: false,
title: ch.x_title.as_deref(),
crosses_max: false,
}));
s.push_str(&axis_xml(&Ax {
cat: false,
id: 2,
cross: 1,
pos: "l",
delete: false,
grid: ch.gridlines,
title: ch.y_title.as_deref(),
crosses_max: false,
}));
}
}
if secondary.is_some() {
s.push_str(&axis_xml(&Ax { cat: true, id: 3, cross: 4, pos: "b", delete: true, grid: false, title: None, crosses_max: false }));
s.push_str(&axis_xml(&Ax { cat: false, id: 4, cross: 3, pos: "r", delete: false, grid: false, title: None, crosses_max: true }));
}
s.push_str("</c:plotArea>");
let pos = match ch.legend {
LegendPos::None => None,
LegendPos::Bottom => Some("b"),
LegendPos::Top => Some("t"),
LegendPos::Left => Some("l"),
LegendPos::Right => Some("r"),
};
if let Some(p) = pos {
let _ = write!(s, "<c:legend><c:legendPos val=\"{p}\"/><c:overlay val=\"0\"/></c:legend>");
}
s.push_str("<c:plotVisOnly val=\"1\"/><c:dispBlanksAs val=\"gap\"/></c:chart></c:chartSpace>");
s
}