From 3ea696dc1a27720df03a9c93b1249971dc47e50e Mon Sep 17 00:00:00 2001 From: Nikita Rakitin Date: Wed, 14 Oct 2026 04:48:33 +0300 Subject: [PATCH] Charts: read, write and draw error bars --- crates/model/src/chart.rs | 66 ++++++++++++++++++++++++++++++++++ crates/pptx/src/read/chart.rs | 25 ++++++++++++- crates/pptx/src/write/chart.rs | 36 +++++++++++++++++++ crates/render/src/chart.rs | 31 ++++++++++++++++ 4 files changed, 157 insertions(+), 1 deletion(-) diff --git a/crates/model/src/chart.rs b/crates/model/src/chart.rs index aebee497..ac6156b1 100644 --- a/crates/model/src/chart.rs +++ b/crates/model/src/chart.rs @@ -138,6 +138,55 @@ pub struct Series { /// source data's. #[serde(skip_serializing_if = "Option::is_none")] pub number_format: Option, + /// `c:errBars` on the values (y bars; a bar chart's run along its value axis). + #[serde(skip_serializing_if = "Option::is_none")] + pub error_bars: Option, +} + +/// Error bars (`c:errBars`): how far each point's bar reaches below and above it. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct ErrorBars { + /// `both`, `plus` or `minus`. + pub bar_type: String, + /// `fixedVal`, `percentage`, `stdDev`, `stdErr` or `cust`. + pub val_type: String, + pub value: f64, + /// `cust`: the per-point plus and minus amounts. + pub plus: Vec>, + pub minus: Vec>, + pub end_cap: bool, +} + +impl Default for ErrorBars { + fn default() -> Self { + ErrorBars { bar_type: "both".into(), val_type: "fixedVal".into(), value: 1.0, plus: vec![], minus: vec![], end_cap: true } + } +} + +impl ErrorBars { + /// Where point `i` (value `v`) of a series with `values` has its bar: (low, high) in data units. + /// Standard deviation bars stand around the series' mean, as Excel draws them; the others + /// around the point. `None` for an amount that isn't a finite number. + pub fn range(&self, values: &[Option], i: usize, v: f64) -> Option<(f64, f64)> { + let vals: Vec = values.iter().flatten().copied().filter(|v| v.is_finite()).collect(); + let n = vals.len() as f64; + let mean = vals.iter().sum::() / n.max(1.0); + let sd = if n > 1.0 { (vals.iter().map(|x| (x - mean).powi(2)).sum::() / (n - 1.0)).sqrt() } else { 0.0 }; + let (centre, minus, plus) = match self.val_type.as_str() { + "percentage" => (v, (v * self.value / 100.0).abs(), (v * self.value / 100.0).abs()), + "stdDev" => (mean, sd * self.value, sd * self.value), + "stdErr" => (v, sd / n.max(1.0).sqrt(), sd / n.max(1.0).sqrt()), + "cust" => (v, self.minus.get(i).copied().flatten().unwrap_or(0.0), self.plus.get(i).copied().flatten().unwrap_or(0.0)), + _ => (v, self.value, self.value), + }; + let (lo, hi) = match self.bar_type.as_str() { + "plus" => (centre, centre + plus), + "minus" => (centre - minus, centre), + _ => (centre - minus, centre + plus), + }; + (lo.is_finite() && hi.is_finite()).then_some((lo, hi)) + } } #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] @@ -431,6 +480,23 @@ pub fn nice_scale(lo: f64, hi: f64) -> (f64, f64, f64) { mod tests { use super::*; + #[test] + fn error_bar_ranges() { + let vals = [Some(3.0), Some(5.0), Some(2.0), Some(4.0)]; + let mut e = ErrorBars::default(); + assert_eq!(e.range(&vals, 0, 3.0), Some((2.0, 4.0))); + e.val_type = "percentage".into(); + e.value = 20.0; + assert_eq!(e.range(&vals, 1, 5.0), Some((4.0, 6.0))); + e.bar_type = "plus".into(); + assert_eq!(e.range(&vals, 1, 5.0), Some((5.0, 6.0))); + e = ErrorBars { val_type: "cust".into(), plus: vec![Some(0.5)], minus: vec![Some(1.5)], ..Default::default() }; + assert_eq!(e.range(&vals, 0, 3.0), Some((1.5, 3.5))); + e = ErrorBars { val_type: "stdDev".into(), ..Default::default() }; + let (lo, hi) = e.range(&vals, 0, 3.0).unwrap(); + assert!((lo + hi - 7.0).abs() < 1e-9 && (hi - lo - 2.0 * 1.2909944487358056).abs() < 1e-9); + } + #[test] fn nice_scale_covers() { let (a, b, s) = nice_scale(0.0, 5.1); diff --git a/crates/pptx/src/read/chart.rs b/crates/pptx/src/read/chart.rs index 6d7541ff..6234df23 100644 --- a/crates/pptx/src/read/chart.rs +++ b/crates/pptx/src/read/chart.rs @@ -1,6 +1,6 @@ //! Chart parts (`c:chartSpace`) → [`Chart`]. The original XML is kept in `Chart.raw`. -use deckcraft_model::chart::{Chart, ChartType, Series}; +use deckcraft_model::chart::{Chart, ChartType, ErrorBars, Series}; use deckcraft_model::style::Fill; use crate::xml::El; @@ -229,6 +229,21 @@ pub(crate) fn chart_from(root: &El) -> Option { if let Some(v) = s.child("val").or_else(|| s.child("yVal")) { ser.values = numbers(v); } + // Error bars on the values: a scatter chart's y bars, any other chart's only ones. + if let Some(e) = s + .children_named("errBars") + .find(|e| e.child_val("errDir").is_none_or(|d| d == "y" || !matches!(kind, ChartType::Scatter | ChartType::Bubble))) + { + let d = ErrorBars::default(); + ser.error_bars = Some(ErrorBars { + bar_type: e.child_val("errBarType").unwrap_or("both").to_string(), + val_type: e.child_val("errValType").unwrap_or("fixedVal").to_string(), + value: e.child_val("val").and_then(|v| v.parse().ok()).filter(|v: &f64| v.is_finite()).unwrap_or(d.value), + plus: e.child("plus").map(numbers).unwrap_or_default(), + minus: e.child("minus").map(numbers).unwrap_or_default(), + end_cap: e.child("noEndCap").and_then(|v| v.bool("val")) != Some(true), + }); + } if let Some(b) = s.child("bubbleSize") { ser.sizes = numbers(b); } @@ -310,6 +325,14 @@ fn text_size(e: Option<&El>) -> Option { mod tests { use super::*; + #[test] + fn error_bars_are_read() { + let xml = br#"5"#; + let c = read_chart_xml(xml).unwrap(); + let e = c.series[0].error_bars.clone().unwrap(); + assert_eq!((e.bar_type.as_str(), e.val_type.as_str(), e.value, e.end_cap), ("plus", "percentage", 20.0, false)); + } + #[test] fn legend_position_and_overlay_are_read() { let legend = r#""#; diff --git a/crates/pptx/src/write/chart.rs b/crates/pptx/src/write/chart.rs index 76567c9e..b9ab714b 100644 --- a/crates/pptx/src/write/chart.rs +++ b/crates/pptx/src/write/chart.rs @@ -108,6 +108,20 @@ fn num_cache(w: &mut W, f: &str, vals: &[Option]) { w.close("c:numRef"); } +/// Numbers written into the chart itself (`c:numLit`), with no worksheet range behind them. +fn num_lit(w: &mut W, vals: &[Option]) { + w.open0("c:numLit"); + w.val("c:ptCount", vals.len()); + for (i, v) in vals.iter().enumerate() { + if let Some(v) = v.filter(|v| v.is_finite()) { + w.open("c:pt", A::new().a("idx", i)); + w.elt("c:v", &fmt_num(v)); + w.close("c:pt"); + } + } + w.close("c:numLit"); +} + #[derive(Clone, Copy, PartialEq)] enum Group { Bar, @@ -176,6 +190,28 @@ fn series(w: &mut W, c: &Chart, s: &Series, i: usize, kind: ChartType, g: Group) } let n = c.categories.len().max(s.values.len()); let last = n + 1; + if let Some(e) = &s.error_bars { + let word = |v: &str, ok: &[&'static str], d: &'static str| ok.iter().copied().find(|o| *o == v).unwrap_or(d); + w.open0("c:errBars"); + if matches!(g, Group::Scatter | Group::Bubble) { + w.val("c:errDir", "y"); + } + w.val("c:errBarType", word(&e.bar_type, &["both", "plus", "minus"], "both")); + let vt = word(&e.val_type, &["fixedVal", "percentage", "stdDev", "stdErr", "cust"], "fixedVal"); + w.val("c:errValType", vt); + w.val("c:noEndCap", if e.end_cap { 0 } else { 1 }); + if vt == "cust" { + w.open0("c:plus"); + num_lit(w, &e.plus); + w.close("c:plus"); + w.open0("c:minus"); + num_lit(w, &e.minus); + w.close("c:minus"); + } else if e.value.is_finite() { + w.val("c:val", fmt_num(e.value)); + } + w.close("c:errBars"); + } if matches!(g, Group::Scatter | Group::Bubble) { let xs: Vec> = if s.x_values.is_empty() { (1..=s.values.len()).map(|v| Some(v as f64)).collect() } else { s.x_values.clone() }; w.open0("c:xVal"); diff --git a/crates/render/src/chart.rs b/crates/render/src/chart.rs index eb2b0a96..f6805c65 100644 --- a/crates/render/src/chart.rs +++ b/crates/render/src/chart.rs @@ -379,6 +379,8 @@ pub fn draw(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64, h } } let zero = to_v(0.0f64.clamp(min, max)); + // Error bars, drawn over every series: (position across the value axis, from, to, cap length, cap). + let mut err_marks: Vec<(f64, f64, f64, f64, bool)> = vec![]; if let Some(bk) = bar_kind { let bars: Vec<(usize, &deckcraft_model::chart::Series)> = c.series.iter().enumerate().filter(|(_, s)| c.series_kind(s).is_bar_like()).collect(); @@ -420,6 +422,13 @@ pub fn draw(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64, h ctx.set_transform(m); ctx.set_paint(color(series_fill(rctx, c, *si, Some(s)), 1.0)); ctx.fill_rect(&r.abs()); + if let Some(e) = &s.error_bars { + let (end, mid) = if horiz { (if v >= 0.0 { b } else { a }, r.center().y) } else { (if v >= 0.0 { b } else { a }, r.center().x) }; + if let Some((lo, hi)) = e.range(&s.values, i, end) { + let (p0, p1) = if horiz { (to_v(lo), to_v(hi)) } else { (to_axis(sec, lo), to_axis(sec, hi)) }; + err_marks.push((mid, p0, p1, bar.min(base * 0.8) * 0.5, e.end_cap)); + } + } if c.data_labels { let t = c.data_label(s, s.values.get(i).copied().flatten().unwrap_or(0.0)); let lr = if horiz { @@ -475,6 +484,9 @@ pub fn draw(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64, h } _ => plot.x0 + band * (i as f64 + 0.5), }; + if let Some((lo, hi)) = s.error_bars.as_ref().and_then(|e| e.range(&s.values, i, v)) { + err_marks.push((x, to_axis(sec, lo), to_axis(sec, hi), base * 0.4, s.error_bars.as_ref().is_some_and(|e| e.end_cap))); + } pts.push(Point::new(x, to_axis(sec, v))); } if pts.is_empty() { @@ -511,6 +523,25 @@ pub fn draw(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64, h } } } + if !err_marks.is_empty() { + let mut p = BezPath::new(); + for &(mid, a, b, cap, end_cap) in &err_marks { + // A horizontal bar chart's error bars run across, its value axis being horizontal. + let pt = |along: f64, across: f64| if horiz { Point::new(along, across) } else { Point::new(across, along) }; + p.move_to(pt(a, mid)); + p.line_to(pt(b, mid)); + if end_cap { + for e in [a, b] { + p.move_to(pt(e, mid - cap)); + p.line_to(pt(e, mid + cap)); + } + } + } + ctx.set_transform(m); + ctx.set_paint(color(text_col, 1.0)); + ctx.set_stroke(kurbo::Stroke::new(0.75)); + ctx.stroke_path(&p); + } // Axis line. let mut ax = BezPath::new(); if horiz {