//! 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 { 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 { 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 = 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::().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 { e.child(tag).and_then(|c| c.attr_f64("val")) } /// `` (ECMA-376 Part 1, 21.2.2.211). Unknown types are dropped. fn read_trendline(t: &El) -> Option { 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::().ok()).unwrap_or(2).clamp(2, 6)), "movingAvg" => TrendlineKind::MovingAverage(t.child_val("period").and_then(|v| v.trim().parse::().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 { let tx = t.child("tx")?; if let Some(rich) = tx.child("rich") { let paras: Vec = rich.kids("p").map(|p| p.kids("r").filter_map(|r| r.child("t")).map(|t| t.text.as_str()).collect::()).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 { g.kids("axId").filter_map(|a| a.attr("val")).map(str::to_string).collect() } pub fn read_chart(root: &El) -> Option { 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> { 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!(""); } if f.starts_with('{') { let vals: Vec<&str> = f.trim_matches(|c| c == '{' || c == '}').split([',', ';']).collect(); let mut s = format!("General", vals.len()); for (i, v) in vals.iter().enumerate() { if let Ok(n) = v.trim().parse::() { let _ = write!(s, "{}", num(n)); } } let _ = write!(s, ""); return s; } let mut s = format!("{}", esc(&crate::fmla::text_to_file(f))); if let Some(vals) = ref_values(wb, si, f) { let _ = write!(s, "General", vals.len()); for (i, v) in vals.iter().enumerate() { if let Value::Number(n) = v { let _ = write!(s, "{}", num(*n)); } } s.push_str(""); } let _ = write!(s, ""); s } fn str_data(tag: &str, f: &str, wb: &Workbook, si: usize) -> String { let f = f.trim(); if f.starts_with('{') { let vals: Vec = f.trim_matches(|c| c == '{' || c == '}').split([',', ';']).map(|v| v.trim().trim_matches('"').to_string()).collect(); let mut s = format!("", vals.len()); for (i, v) in vals.iter().enumerate() { let _ = write!(s, "{}", esc(v)); } let _ = write!(s, ""); return s; } let mut s = format!("{}", esc(&crate::fmla::text_to_file(f))); if let Some(vals) = ref_values(wb, si, f) { let _ = write!(s, "", vals.len()); for (i, v) in vals.iter().enumerate() { if !v.is_empty() { let _ = write!(s, "{}", esc(&v.display())); } } s.push_str(""); } let _ = write!(s, ""); s } fn series_xml(s: &Series, idx: usize, g: G, wb: &Workbook, si: usize) -> String { let mut x = format!(""); 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, "{}", 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}"); } (Some(c), false) if g == (G::Scatter { lines: false }) => { let _ = write!( x, "{c}" ); } (Some(c), false) => { let _ = write!(x, "{c}"); } (None, _) if g == (G::Scatter { lines: false }) || g == G::Stock => x.push_str(""), _ => {} } match g { G::Bar { .. } => x.push_str(""), G::Line { markers: false, .. } | G::Scatter { lines: true } | G::Stock => x.push_str(""), G::Bubble => x.push_str(""), _ => {} } // 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(""); } else { let _ = write!(x, "", 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, "", s.smooth as u8); } } } x.push_str(""); x } /// `` 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(""); if let Some(n) = &t.name { let _ = write!(x, "{}", 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(""); if let Some(c) = &color { let _ = write!(x, "{c}"); } if let Some(d) = t.dash.and_then(|d| DASHES.iter().find(|(k, _)| *k == d)) { let _ = write!(x, "", d.1); } x.push_str(""); } 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, ""); match t.kind { TrendlineKind::Polynomial(o) => { let _ = write!(x, "", o.clamp(2, 6)); } TrendlineKind::MovingAverage(p) => { let _ = write!(x, "", p.clamp(2, 255)); } _ => {} } for (tag, v) in [("forward", t.forward), ("backward", t.backward)] { if v.is_finite() && v > 0.0 { let _ = write!(x, "", num(v)); } } if let Some(i) = t.intercept.filter(|i| i.is_finite()) { let _ = write!(x, "", num(i)); } let _ = write!(x, "", t.display_r_squared as u8, t.display_equation as u8); if t.display_r_squared || t.display_equation { x.push_str(""); } x.push_str(""); x } fn dlbls(show: bool) -> String { format!( "", 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!("", axes.0, axes.1); match g { G::Bar { bar, grouping } => { let overlap = if grouping == "clustered" { String::new() } else { "".into() }; format!( "{sers}{dl}{overlap}{ax}", if bar { "bar" } else { "col" } ) } G::Line { stacked, .. } => format!( "{sers}{dl}{ax}", if stacked { "stacked" } else { "standard" } ), G::Pie => format!("{sers}{dl}"), G::Doughnut => { format!("{sers}{dl}") } G::Area { stacked } => format!( "{sers}{dl}{ax}", if stacked { "stacked" } else { "standard" } ), G::Scatter { .. } => format!("{sers}{dl}{ax}"), G::Bubble => { format!("{sers}{dl}{ax}") } G::Radar => format!("{sers}{dl}{ax}"), G::Stock => format!("{sers}{dl}{ax}"), } } fn title_xml(t: &str) -> String { let mut s = String::from(""); for line in t.split('\n') { let _ = write!(s, "{}", esc(line)); } s.push_str(""); 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!( "", if a.cat { "catAx" } else { "valAx" }, a.id, a.delete as u8, a.pos ); if a.grid { s.push_str(""); } if let Some(t) = a.title { s.push_str(&title_xml(t)); } let _ = write!( s, "", a.cross, if a.crosses_max { "max" } else { "autoZero" } ); if a.cat { s.push_str(""); } else { s.push_str(""); } 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( "\n", ); if (1..=48).contains(&ch.style) { let _ = write!(s, "", 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, "{}", esc(link)); } s.push_str(""); match &ch.title { Some(t) if !t.is_empty() => { s.push_str(&title_xml(t)); s.push_str(""); } _ => s.push_str(""), } s.push_str(""); let all: Vec<(usize, &Series)> = ch.series.iter().enumerate().collect(); let primary_g; let mut secondary: Option = 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| 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(""); 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, ""); } s.push_str(""); s }