collective

Browse / github.com/storytold/gridcraft / patch

Charts: read, write and draw error bars

Charts: read, write and draw error bars ## What this adds Chart error bars, the last piece of the chart-model work on `feat/chart-error-bars`: - **Model:** each series can hold error bars, at most one set per direction (X or Y). A set has a direction, plus, minus or both sides, an amount (fixed value, percentage, standard deviation multiple, standard error, or custom plus/minus ranges or arrays), an optional end cap, and line colour, width and dash. - **XLSX:** `c:errBars` is read and written in schema order, after the trendlines. Column and bar series keep one Y set, as Excel writes them; area, scatter and bubble series keep one

Status
open
Score
0.00
Adopters
0 other operators
Author
Harrison-Booth (imported from pull request #2623, open)
Base
11bb67386c9bb19b0717e21e98adc608162ae2e5
Size
+1555 −64 in 31 files
Review
pending
Shared
2026-10-28
Id
c9164da0e5a90710d1e9e191

How it went

No other operator has reported using it yet.

Diff

raw · ROADMAP.md crates/chart/src/errbars.rs crates/chart/src/errbars/tests.rs crates/chart/src/lib.rs crates/chart/src/render.rs crates/chart/src/render/cartesian.rs crates/chart/src/resolve.rs crates/chart/src/tests.rs crates/engine/src/cmd/extra.rs crates/engine/src/cmd/extra/chart.rs crates/engine/src/cmd/forecast.rs crates/engine/src/cmd/insert/chart.rs crates/engine/src/cmd/inspect.rs crates/engine/src/pivot.rs crates/engine/src/pivot/charts.rs crates/engine/src/sample.rs crates/engine/src/tests.rs crates/engine/tests/it/pivotchart_link.rs crates/model/src/features.rs crates/xlsx/src/chart.rs crates/xlsx/src/tests.rs crates/xlsx/src/tests/chart_book_names.rs crates/xlsx/src/tests/errbars.rs crates/xlsx/src/tests/literal_category_roundtrip.rs crates/xlsx/src/tests/multilevel_category_roundtrip.rs crates/xlsx/src/tests/roundtrip.rs crates/xlsx/src/tests/smooth_roundtrip.rs crates/xlsx/src/tests/trendline_roundtrip.rs docs/chart-parity.md docs/gaps.md docs/roadmap.md

diff --git a/ROADMAP.md b/ROADMAP.md--- a/ROADMAP.md+++ b/ROADMAP.md@@ -2,7 +2,7 @@  **Stage: alpha** · next: beta, ~25 points of "ready for real work" and ~160–240 h away -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-25 · **Change:** minor (AutoSum over a selected row puts its total to the right, and fills a selection's empty last row and column) · **Target:** Microsoft Excel (Microsoft 365)+> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-25 · **Change:** minor (chart error bars; accounting negatives such as `$(1,234)` read as numbers; INDEX with one index on a 2-D reference is #REF! as in Excel; AutoSum over a selected row puts its total to the right, and fills a selection's empty last row and column; earlier: Page Setup paper sizes: JIS B4/B5/B6, the Japanese postcards and envelopes, and A6; long flat operator chains up to Excel's formula limit) · **Target:** Microsoft Excel (Microsoft 365)  GridCraft aims at full Microsoft Excel parity, and at being better in places: faster, open (XLSX is the native format), fully scriptable by agents (MCP, control channel, CLI), and on the web.@@ -169,6 +169,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in | 2026-10-14 | XLSX: formulas opened with dynamic-array metadata (`cm`) keep it on save, with `xl/metadata.xml`. Before, a formula returning one value lost it (11 of 40 corpus files that have the part lost it entirely), and Excel recalculated some of them with implicit intersection (`SUMPRODUCT(IFERROR(...))` changed value) | | 2026-10-14 | Recalculation: a dynamic array's anchor given a new formula that reads its old spill area (`A1` was `=SEQUENCE(3)`, becomes `=SUM(A2:A3)+1` or `=SEQUENCE(1,A2+1)`) sees that area empty instead of the old values | | 2026-10-14 | Recalculation: when two arrays of one recalculation want the same empty cells, the formulas that read the one laid out second (at its anchor or over the cells it would have spilled into) read `#SPILL!` and empty cells instead of its values |+| 2026-10-14 | Chart error bars: fixed value, percentage, standard deviation, standard error and custom plus/minus ranges, in X and Y, plus/minus/both, cap or no cap, line colour, width and dash; read and written as `c:errBars` in schema order, drawn on 2-D column, bar, line, area (stacked too), scatter and bubble series with the value axis widened to hold them; `chart.addElement` `errorBars` adds, replaces and removes them | | 2026-10-14 | Border keytips: each Alt, H, B letter applies the preset Excel's Borders menu gives it (H,B,O bottom, H,B,B bottom double, H,B,N no border, H,B,A all borders and the rest); they all drew a bottom border before. H,B,M opens Format Cells on the Border tab | | 2026-10-13 | Formula-entry Command/Ctrl-click inserts another reference with the locale's argument separator; Shift-click extends from a stable, sheet-aware anchor. Pointer tests cover repeated clicks, reversed ranges, ordinary replacement, typed separators, Spanish syntax, sheet switches and keyboard continuation. Readiness estimates unchanged. | | 2026-10-13 | `formulas.evaluate` (`gridcraft-cli eval`, MCP `evaluate_formula`) refuses what entering the formula in a cell refuses, with the reason (`=SUM(1;2)`: "expected `,` or `)`"), instead of `#NAME?` for any formula that doesn't parse |@@ -263,6 +264,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in | 2026-10-14 | minor | XLSX dynamic-array `cm` metadata kept on save; progress log row | | 2026-10-14 | minor | Progress log: spill anchor reading its old spill area | | 2026-10-14 | minor | Progress log: formulas reading an array that loses a spill conflict |+| 2026-10-14 | minor | Progress log: chart error bars | | 2026-10-13 | trivial | Progress log: More Colors… and Recent Colors in the colour menus (#928) | | 2026-10-13 | minor | Multi-level category axes (read, drawn, written); progress log row | | 2026-10-13 | trivial | Progress log: shared pictures written once on XLSX export (#985) |diff --git a/crates/chart/src/errbars.rs b/crates/chart/src/errbars.rsnew file mode 100644--- /dev/null+++ b/crates/chart/src/errbars.rs@@ -0,0 +1,101 @@+//! Error bar lengths (Excel's Error Amount choices) worked out from a series' values.++use gridcraft_model::{ChartKind, ErrorAmount, ErrorBarSides};++use crate::ErrorBarData;++/// One point's error bar in data units along its direction.+#[derive(Clone, Copy, Debug, PartialEq)]+pub struct ErrorSpan {+    /// Where the bar is centred: `None` = at the plotted point, `Some(mean)` for standard+    /// deviation bars (Excel centres those on the series mean).+    pub center: Option<f64>,+    /// Length below the centre (0 when the minus side is off).+    pub minus: f64,+    /// Length above the centre (0 when the plus side is off).+    pub plus: f64,+}++/// Whether series drawn as `k` show error bars (Excel offers them on 2-D column, bar, line,+/// area, scatter and bubble charts, stacked or not).+pub fn supports_error_bars(k: ChartKind) -> bool {+    use ChartKind::*;+    matches!(+        k,+        ColumnClustered+            | ColumnStacked+            | BarClustered+            | BarStacked+            | Line+            | LineMarkers+            | LineStacked+            | Area+            | AreaStacked+            | Scatter+            | ScatterLines+            | Bubble+    )+}++/// A non-negative, finite length (hostile file values become 0).+fn len(v: f64) -> f64 {+    if v.is_finite() { v.abs().min(1e300) } else { 0.0 }+}++/// Mean and sample standard deviation of the finite values (`None` for fewer than one value;+/// the deviation is 0 for a single value).+fn stats(vals: &[f64]) -> Option<(f64, f64, usize)> {+    let n = vals.len();+    if n == 0 {+        return None;+    }+    let mean = vals.iter().sum::<f64>() / n as f64;+    let sd = if n > 1 { (vals.iter().map(|v| (v - mean).powi(2)).sum::<f64>() / (n - 1) as f64).sqrt() } else { 0.0 };+    Some((mean, sd, n))+}++/// The error bar of every point of `values` (the series' values along the bar direction: y+/// for Y bars, x for X bars). `None` where the point has no value.+pub fn error_spans(eb: &ErrorBarData, values: &[Option<f64>]) -> Vec<Option<ErrorSpan>> {+    let finite: Vec<f64> = values.iter().flatten().copied().filter(|v| v.is_finite()).collect();+    let st = stats(&finite);+    let (plus_on, minus_on) = match eb.spec.sides {+        ErrorBarSides::Both => (true, true),+        ErrorBarSides::Plus => (true, false),+        ErrorBarSides::Minus => (false, true),+    };+    // A custom list with one value applies to every point.+    let custom = |list: &[Option<f64>], j: usize| -> f64 {+        let v = if list.len() == 1 { list.first() } else { list.get(j) };+        len(v.copied().flatten().unwrap_or(0.0))+    };+    values+        .iter()+        .enumerate()+        .map(|(j, v)| {+            let v = (*v).filter(|v| v.is_finite())?;+            let (center, lo, hi) = match &eb.spec.amount {+                ErrorAmount::Fixed(a) => (None, len(*a), len(*a)),+                ErrorAmount::Percentage(p) => {+                    let a = len(v * len(*p) / 100.0);+                    (None, a, a)+                }+                ErrorAmount::StdDev(m) => {+                    let (mean, sd, _) = st?;+                    let a = len(sd * len(*m));+                    (Some(mean), a, a)+                }+                ErrorAmount::StdErr => {+                    let (_, sd, n) = st?;+                    let a = len(sd / (n as f64).sqrt());+                    (None, a, a)+                }+                ErrorAmount::Custom { .. } => (None, custom(&eb.minus, j), custom(&eb.plus, j)),+            };+            Some(ErrorSpan { center, minus: if minus_on { lo } else { 0.0 }, plus: if plus_on { hi } else { 0.0 } })+        })+        .collect()+}++#[cfg(test)]+mod tests;diff --git a/crates/chart/src/errbars/tests.rs b/crates/chart/src/errbars/tests.rsnew file mode 100644--- /dev/null+++ b/crates/chart/src/errbars/tests.rs@@ -0,0 +1,342 @@+//! Error bar amounts (as Excel measures them) and how they are drawn.++use gridcraft_model::{Anchor, Chart, ChartKind, ErrorAmount, ErrorBarDirection, ErrorBarSides, ErrorBars, LegendPos};++use super::{ErrorSpan, error_spans};+use crate::{ApproxMeasure, ChartData, ErrorBarData, Prim, Rgba, SeriesData, render};++const EB: Rgba = [1, 2, 3, 255];+const S1: [f64; 8] = [2.0, 4.0, 4.0, 4.0, 5.0, 5.0, 7.0, 9.0];++fn bars(amount: ErrorAmount) -> ErrorBarData {+    ErrorBarData { spec: ErrorBars { amount, ..ErrorBars::default() }, color: EB, ..ErrorBarData::default() }+}++fn vals(v: &[f64]) -> Vec<Option<f64>> {+    v.iter().map(|v| Some(*v)).collect()+}++fn close(a: f64, b: f64) -> bool {+    (a - b).abs() < 1e-9+}++#[test]+fn standard_deviation_bars_are_centred_on_the_series_mean() {+    // Excel draws the same bar at every point: mean 5 ± one sample standard deviation.+    let sp = error_spans(&bars(ErrorAmount::StdDev(1.0)), &vals(&S1));+    let sd = (32.0f64 / 7.0).sqrt();+    for s in sp.iter().flatten() {+        assert_eq!(s.center, Some(5.0));+        assert!(close(s.minus, sd) && close(s.plus, sd), "{s:?}");+    }+    let two = error_spans(&bars(ErrorAmount::StdDev(2.0)), &vals(&S1));+    assert!(close(two[0].unwrap().plus, 2.0 * sd));+}++#[test]+fn standard_error_is_the_sample_deviation_over_root_n_at_each_point() {+    let sp = error_spans(&bars(ErrorAmount::StdErr), &vals(&S1));+    let se = (32.0f64 / 7.0).sqrt() / 8f64.sqrt();+    for s in sp.iter().flatten() {+        assert_eq!(s.center, None);+        assert!(close(s.minus, se) && close(s.plus, se), "{s:?}");+    }+    // Blank points are skipped by the statistics and get no bar.+    let mut v = vals(&S1);+    v.push(None);+    let sp = error_spans(&bars(ErrorAmount::StdErr), &v);+    assert!(sp[8].is_none() && close(sp[0].unwrap().plus, se));+}++#[test]+fn fixed_percentage_and_sides() {+    let f = error_spans(&bars(ErrorAmount::Fixed(1.5)), &vals(&[3.0, -2.0]));+    assert_eq!(f[1], Some(ErrorSpan { center: None, minus: 1.5, plus: 1.5 }));+    // A percentage of the value's size, negative values too.+    let p = error_spans(&bars(ErrorAmount::Percentage(10.0)), &vals(&[50.0, -20.0]));+    assert!(close(p[0].unwrap().plus, 5.0) && close(p[1].unwrap().minus, 2.0));+    let mut plus = bars(ErrorAmount::Fixed(1.0));+    plus.spec.sides = ErrorBarSides::Plus;+    assert_eq!(error_spans(&plus, &vals(&[3.0]))[0], Some(ErrorSpan { center: None, minus: 0.0, plus: 1.0 }));+    plus.spec.sides = ErrorBarSides::Minus;+    assert_eq!(error_spans(&plus, &vals(&[3.0]))[0], Some(ErrorSpan { center: None, minus: 1.0, plus: 0.0 }));+}++#[test]+fn custom_amounts_come_per_point_and_a_single_value_applies_to_all() {+    let mut c = bars(ErrorAmount::Custom { plus: None, minus: None });+    c.plus = vals(&[0.5]);+    c.minus = vec![Some(1.0), None, Some(-2.0)];+    let sp = error_spans(&c, &vals(&[1.0, 2.0, 3.0, 4.0]));+    let got: Vec<(f64, f64)> = sp.iter().flatten().map(|s| (s.minus, s.plus)).collect();+    assert_eq!(got, vec![(1.0, 0.5), (0.0, 0.5), (2.0, 0.5), (0.0, 0.5)]);+}++#[test]+fn hostile_amounts_give_finite_lengths() {+    for a in [ErrorAmount::Fixed(f64::NAN), ErrorAmount::Fixed(f64::INFINITY), ErrorAmount::Percentage(-f64::INFINITY), ErrorAmount::StdDev(f64::NAN)]+    {+        for s in error_spans(&bars(a.clone()), &vals(&[1e308, -1e308, 0.0])).iter().flatten() {+            assert!(s.minus.is_finite() && s.plus.is_finite() && s.minus >= 0.0 && s.plus >= 0.0, "{a:?}: {s:?}");+        }+    }+    assert!(error_spans(&bars(ErrorAmount::StdErr), &[]).is_empty());+    assert!(error_spans(&bars(ErrorAmount::StdDev(1.0)), &[None, Some(f64::NAN)]).iter().all(Option::is_none));+}++// ---------------------------------------------------------------- drawing++fn chart(kind: ChartKind) -> Chart {+    Chart {+        id: 1,+        kind,+        anchor: Anchor::default(),+        title: None,+        series: vec![],+        legend: LegendPos::None,+        data_labels: false,+        gridlines: false,+        style: 0,+        x_title: None,+        y_title: None,+        source: None,+        by_rows: false,+        pivot: None,+    }+}++fn series(values: &[f64], color: Rgba, kind: ChartKind, eb: Vec<ErrorBarData>) -> SeriesData {+    SeriesData { values: vals(values), color, kind, error_bars: eb, ..SeriesData::default() }+}++fn data(series: Vec<SeriesData>) -> ChartData {+    let n = series.iter().map(|s| s.values.len()).max().unwrap_or(0);+    ChartData { palette: vec![], category_levels: vec![], categories: (1..=n).map(|i| format!("c{i}")).collect(), series }+}++fn lines(prims: &[Prim], color: Rgba) -> Vec<[[f32; 2]; 2]> {+    prims+        .iter()+        .filter_map(|p| match p {+            Prim::Line { pts, color: c, .. } if *c == color && pts.len() == 2 => Some([pts[0], pts[1]]),+            _ => None,+        })+        .collect()+}++fn vertical(l: &[[f32; 2]; 2]) -> bool {+    (l[0][0] - l[1][0]).abs() < 0.01 && (l[0][1] - l[1][1]).abs() > 0.01+}++fn horizontal(l: &[[f32; 2]; 2]) -> bool {+    (l[0][1] - l[1][1]).abs() < 0.01 && (l[0][0] - l[1][0]).abs() > 0.01+}++fn draw(kind: ChartKind, d: &ChartData) -> Vec<Prim> {+    let prims = render(&chart(kind), d, 600.0, 400.0, &ApproxMeasure);+    for p in &prims {+        if let Prim::Line { pts, width, .. } = p {+            assert!(width.is_finite() && pts.iter().flatten().all(|v| v.is_finite() && (-0.01..=600.01).contains(v)), "{p:?}");+        }+    }+    prims+}++/// Top and bottom pixel of each vertical stem, left to right.+fn stems(prims: &[Prim]) -> Vec<(f32, f32, f32)> {+    let mut s: Vec<(f32, f32, f32)> =+        lines(prims, EB).iter().filter(|l| vertical(l)).map(|l| (l[0][0], l[0][1].min(l[1][1]), l[0][1].max(l[1][1]))).collect();+    s.sort_by(|a, b| a.0.total_cmp(&b.0));+    s+}++#[test]+fn line_chart_bars_run_from_each_point_with_caps() {+    let line: Rgba = [9, 9, 9, 255];+    let d = data(vec![series(&S1, line, ChartKind::Line, vec![bars(ErrorAmount::Fixed(1.0))])]);+    let prims = draw(ChartKind::Line, &d);+    let st = stems(&prims);+    assert_eq!(st.len(), 8);+    // The data line's points: the stems are centred on them and 2 units long.+    let pts = prims+        .iter()+        .find_map(|p| match p {+            Prim::Line { pts, color, .. } if *color == line => Some(pts.clone()),+            _ => None,+        })+        .unwrap();+    let unit = (pts[0][1] - pts[1][1]) / 2.0; // values 2 → 4+    for ((x, top, bottom), p) in st.iter().zip(&pts) {+        assert!((x - p[0]).abs() < 0.01);+        assert!(((top + bottom) / 2.0 - p[1]).abs() < 0.01 && ((bottom - top) - 2.0 * unit).abs() < 0.01, "{top} {bottom} {p:?}");+    }+    // Two caps a point.+    assert_eq!(lines(&prims, EB).iter().filter(|l| horizontal(l)).count(), 16);+    let mut nocap = bars(ErrorAmount::Fixed(1.0));+    nocap.spec.no_end_cap = true;+    let prims = draw(ChartKind::Line, &data(vec![series(&S1, line, ChartKind::Line, vec![nocap])]));+    assert_eq!(lines(&prims, EB).iter().filter(|l| horizontal(l)).count(), 0);+    // One-sided bars cap only their far end.+    let mut plus = bars(ErrorAmount::Fixed(1.0));+    plus.spec.sides = ErrorBarSides::Plus;+    let prims = draw(ChartKind::Line, &data(vec![series(&S1, line, ChartKind::Line, vec![plus])]));+    assert_eq!(lines(&prims, EB).iter().filter(|l| horizontal(l)).count(), 8);+}++#[test]+fn standard_deviation_bars_line_up_across_points() {+    let d = data(vec![series(&S1, [9, 9, 9, 255], ChartKind::LineMarkers, vec![bars(ErrorAmount::StdDev(1.0))])]);+    let st = stems(&draw(ChartKind::LineMarkers, &d));+    assert_eq!(st.len(), 8);+    assert!(st.iter().all(|s| (s.1 - st[0].1).abs() < 0.01 && (s.2 - st[0].2).abs() < 0.01), "{st:?}");+    // Plus only: up from the mean, capped at the top end alone (as Excel draws it).+    let mut plus = bars(ErrorAmount::StdDev(1.0));+    plus.spec.sides = ErrorBarSides::Plus;+    let prims = draw(ChartKind::LineMarkers, &data(vec![series(&S1, [9, 9, 9, 255], ChartKind::LineMarkers, vec![plus])]));+    let half = stems(&prims);+    assert!(half.iter().all(|s| (s.1 - st[0].1).abs() < 0.01 && (s.2 - (st[0].1 + st[0].2) / 2.0).abs() < 0.01), "{half:?}");+    assert_eq!(lines(&prims, EB).iter().filter(|l| horizontal(l)).count(), 8);+}++#[test]+fn the_value_axis_grows_to_hold_the_bars() {+    let line: Rgba = [9, 9, 9, 255];+    let without = draw(ChartKind::Line, &data(vec![series(&S1, line, ChartKind::Line, vec![])]));+    let with = draw(ChartKind::Line, &data(vec![series(&S1, line, ChartKind::Line, vec![bars(ErrorAmount::Fixed(20.0))])]));+    let labels = |p: &[Prim]| -> Vec<f64> {+        p.iter()+            .filter_map(|p| match p {+                Prim::Text { text, .. } => text.replace(',', "").parse::<f64>().ok(),+                _ => None,+            })+            .collect()+    };+    let max = |v: Vec<f64>| v.into_iter().fold(f64::MIN, f64::max);+    let min = |v: Vec<f64>| v.into_iter().fold(f64::MAX, f64::min);+    assert!(max(labels(&without)) < 20.0 && max(labels(&with)) >= 29.0 && min(labels(&with)) <= -18.0);+    // Every bar fits inside the plot, uncut: all 16 caps are drawn.+    assert_eq!(lines(&with, EB).iter().filter(|l| horizontal(l)).count(), 16);+}++#[test]+fn column_bars_sit_on_their_own_column_and_bar_chart_bars_run_across() {+    let (a, b): (Rgba, Rgba) = ([10, 0, 0, 255], [20, 0, 0, 255]);+    let d = data(vec![+        series(&[3.0, 4.0], a, ChartKind::ColumnClustered, vec![]),+        series(&[5.0, 6.0], b, ChartKind::ColumnClustered, vec![bars(ErrorAmount::Fixed(1.0))]),+    ]);+    let prims = draw(ChartKind::ColumnClustered, &d);+    let cols: Vec<(f32, f32)> = prims+        .iter()+        .filter_map(|p| match p {+            Prim::Rect { x, y, w, fill: Some(f), .. } if *f == b => Some((x + w / 2.0, *y)),+            _ => None,+        })+        .collect();+    let st = stems(&prims);+    assert_eq!((cols.len(), st.len()), (2, 2));+    for ((cx, top), (x, t, bt)) in cols.iter().zip(&st) {+        assert!((cx - x).abs() < 0.01 && ((t + bt) / 2.0 - top).abs() < 0.01, "column {cx},{top} stem {x},{t},{bt}");+    }+    // Horizontal bars: the error bars run along the horizontal value axis.+    let prims = draw(ChartKind::BarClustered, &d);+    let eb = lines(&prims, EB);+    assert_eq!(eb.iter().filter(|l| horizontal(l)).count(), 2);+    assert_eq!(eb.iter().filter(|l| vertical(l)).count(), 4, "caps across");+}++#[test]+fn stacked_bars_start_at_the_stack_top() {+    let (a, b): (Rgba, Rgba) = ([10, 0, 0, 255], [20, 0, 0, 255]);+    let d = data(vec![+        series(&[3.0], a, ChartKind::ColumnStacked, vec![]),+        series(&[5.0], b, ChartKind::ColumnStacked, vec![bars(ErrorAmount::Fixed(1.0))]),+    ]);+    let prims = draw(ChartKind::ColumnStacked, &d);+    let top = prims+        .iter()+        .find_map(|p| match p {+            Prim::Rect { y, fill: Some(f), .. } if *f == b => Some(*y),+            _ => None,+        })+        .unwrap();+    let st = stems(&prims);+    assert_eq!(st.len(), 1);+    assert!(((st[0].1 + st[0].2) / 2.0 - top).abs() < 0.01);+    // 100% stacked charts draw none (their values are shares, not data units).+    assert!(lines(&draw(ChartKind::ColumnStacked100, &d), EB).is_empty());+    assert!(lines(&draw(ChartKind::AreaStacked100, &d), EB).is_empty());+}++#[test]+fn scatter_charts_draw_x_and_y_bars() {+    let mut s = series(&[1.0, 4.0, 9.0], [9, 9, 9, 255], ChartKind::Scatter, vec![]);+    s.x = Some(vals(&[1.0, 2.0, 3.0]));+    let mut x = bars(ErrorAmount::Percentage(10.0));+    x.spec.direction = ErrorBarDirection::X;+    s.error_bars = vec![x, bars(ErrorAmount::StdErr)];+    let prims = draw(ChartKind::Scatter, &data(vec![s.clone()]));+    let eb = lines(&prims, EB);+    // Per point: one horizontal X stem with two vertical caps, one vertical Y stem with two horizontal caps.+    assert_eq!(eb.iter().filter(|l| horizontal(l)).count(), 9);+    assert_eq!(eb.iter().filter(|l| vertical(l)).count(), 9);+    // Bubble charts too.+    s.kind = ChartKind::Bubble;+    assert_eq!(lines(&draw(ChartKind::Bubble, &data(vec![s])), EB).len(), 18);+}++#[test]+fn pies_and_hostile_bars_draw_nothing_odd() {+    let d = data(vec![series(&S1, [9, 9, 9, 255], ChartKind::Pie, vec![bars(ErrorAmount::Fixed(1.0))])]);+    assert!(lines(&draw(ChartKind::Pie, &d), EB).is_empty());+    for k in [+        ChartKind::ColumnClustered,+        ChartKind::BarStacked,+        ChartKind::Line,+        ChartKind::Area,+        ChartKind::AreaStacked,+        ChartKind::Scatter,+        ChartKind::Bubble,+    ] {+        for a in [ErrorAmount::Fixed(f64::NAN), ErrorAmount::Fixed(1e300), ErrorAmount::Percentage(f64::INFINITY), ErrorAmount::StdDev(1e200)] {+            let mut e = bars(a);+            e.spec.width = Some(f32::NAN);+            let mut s = series(&[1e300, -1e300, 0.0, f64::MAX], [9, 9, 9, 255], k, vec![e.clone()]);+            s.error_bars.push(ErrorBarData { spec: ErrorBars { direction: ErrorBarDirection::X, ..e.spec.clone() }, ..e });+            draw(k, &data(vec![s]));+        }+    }+}++#[test]+fn bubbles_that_are_not_drawn_have_no_error_bars() {+    let mut s = series(&[1.0, 2.0, 3.0, 4.0], [9, 9, 9, 255], ChartKind::Bubble, vec![bars(ErrorAmount::Fixed(0.5))]);+    s.x = Some(vals(&[1.0, 2.0, 3.0, 4.0]));+    // Sizes: drawn, missing, zero, drawn.+    s.sizes = Some(vec![Some(1.0), None, Some(0.0), Some(2.0)]);+    let eb = lines(&draw(ChartKind::Bubble, &data(vec![s.clone()])), EB);+    // One vertical stem per drawn bubble (the caps are horizontal).+    assert_eq!(eb.iter().filter(|l| vertical(l)).count(), 2);+    // Without sizes every bubble is drawn.+    s.sizes = None;+    assert_eq!(lines(&draw(ChartKind::Bubble, &data(vec![s])), EB).iter().filter(|l| vertical(l)).count(), 4);+}++#[test]+fn a_chart_draws_a_bounded_number_of_error_bars() {+    let vals: Vec<f64> = (0..10_000).map(|i| (i % 50) as f64).collect();+    let d = data((0..3).map(|_| series(&vals, [9, 9, 9, 255], ChartKind::Line, vec![bars(ErrorAmount::Fixed(1.0))])).collect());+    // Uncapped this is 30 000 bars of three lines; the budget is 20 000 bars for the whole chart.+    let n = lines(&draw(ChartKind::Line, &d), EB).len();+    assert!(n > 0 && n <= 60_000, "{n}");+}++#[test]+fn bubbles_past_the_marker_cap_have_no_error_bars() {+    let n = 6000;+    let mut s = series(&vec![1.0; n], [9, 9, 9, 255], ChartKind::Bubble, vec![bars(ErrorAmount::Fixed(0.5))]);+    s.x = Some(vals(&(0..n).map(|i| i as f64).collect::<Vec<_>>()));+    let eb = lines(&draw(ChartKind::Bubble, &data(vec![s])), EB);+    // Only the first 5000 bubbles are drawn, so only they carry a bar (one vertical stem each).+    assert_eq!(eb.iter().filter(|l| vertical(l)).count(), 5000);+}diff --git a/crates/chart/src/lib.rs b/crates/chart/src/lib.rs--- a/crates/chart/src/lib.rs+++ b/crates/chart/src/lib.rs@@ -11,6 +11,7 @@ #![forbid(unsafe_code)]  mod axis;+mod errbars; mod raster; mod render; mod resolve;@@ -20,10 +21,13 @@ mod trend; #[cfg(test)] mod tests; -pub use gridcraft_model::{Chart, ChartKind, LegendPos, SparklineKind, Trendline, TrendlineKind};+pub use gridcraft_model::{+    Chart, ChartKind, ErrorAmount, ErrorBarDirection, ErrorBarSides, ErrorBars, LegendPos, SparklineKind, Trendline, TrendlineKind,+}; use serde::{Deserialize, Serialize};  pub use axis::{Scale, format_number, nice_scale};+pub use errbars::{ErrorSpan, error_spans, supports_error_bars}; pub use raster::rasterize; pub use render::{Swatch, legend_entries, render, supports_trendline}; pub use resolve::{default_palette, resolve, series_color, series_plot_kind};@@ -151,6 +155,9 @@ pub struct SeriesData {     pub smooth: bool,     /// Trendlines to fit and draw over this series.     pub trendlines: Vec<TrendlineData>,+    /// Error bars to draw on this series.+    #[serde(default)]+    pub error_bars: Vec<ErrorBarData>,     /// Number format code of the source values ("General" by default).     pub number_format: String, }@@ -162,6 +169,16 @@ pub struct TrendlineData {     pub color: Rgba, } +/// Error bars to draw: the model settings, the line colour resolved against the theme, and the+/// evaluated plus/minus amounts of custom error bars (empty for the other amount types).+#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]+pub struct ErrorBarData {+    pub spec: ErrorBars,+    pub color: Rgba,+    pub plus: Vec<Option<f64>>,+    pub minus: Vec<Option<f64>>,+}+ impl Default for SeriesData {     fn default() -> Self {         SeriesData {@@ -174,6 +191,7 @@ impl Default for SeriesData {             secondary: false,             smooth: false,             trendlines: vec![],+            error_bars: Vec::new(),             number_format: "General".into(),         }     }diff --git a/crates/chart/src/render.rs b/crates/chart/src/render.rs--- a/crates/chart/src/render.rs+++ b/crates/chart/src/render.rs@@ -3,12 +3,13 @@ use std::collections::HashSet; use std::f32::consts::{PI, TAU}; -use gridcraft_model::{Chart, ChartKind, LegendPos, LineDash, QuartileMethod, Theme, TrendlineKind};+use gridcraft_model::{Chart, ChartKind, ErrorBarDirection, LegendPos, LineDash, QuartileMethod, Theme, TrendlineKind};  use crate::axis::{Fmt, Scale, clean, fixed_scale, nice_scale};+use crate::errbars::{error_spans, supports_error_bars}; use crate::resolve::{MAX_POINTS, MAX_SERIES, MAX_TRENDLINES, series_color}; use crate::trend::{auto_name, fit_trendline, moving_average};-use crate::{ChartData, HAlign, Measure, Prim, Rgba, SeriesData, VAlign};+use crate::{ChartData, ErrorBarData, HAlign, Measure, Prim, Rgba, SeriesData, VAlign};  mod cartesian; mod frame;@@ -42,6 +43,8 @@ const MAX_CATS: usize = 20_000; const MAX_LINE_PTS: usize = 2000; /// Most markers per series (after de-duplicating by pixel). const MAX_MARKERS: usize = 5000;+/// Error bars drawn per chart, over every series and direction (axis bounds still use them all).+const MAX_ERR_BARS: usize = 20_000; /// Data labels are skipped when a series has more points than this. const MAX_LABELS: usize = 200; @@ -116,6 +119,12 @@ fn sanitize(d: &ChartData) -> ChartData {                 secondary: s.secondary,                 smooth: s.smooth,                 trendlines: s.trendlines.iter().take(MAX_TRENDLINES).cloned().collect(),+                error_bars: s+                    .error_bars+                    .iter()+                    .take(2)+                    .map(|e| ErrorBarData { spec: e.spec.clone(), color: e.color, plus: sanitize_vals(&e.plus), minus: sanitize_vals(&e.minus) })+                    .collect(),                 number_format: s.number_format.clone(),             })             .collect(),@@ -253,3 +262,72 @@ pub(crate) fn finalize(prims: Vec<Prim>, w: f32, h: f32) -> Vec<Prim> {         })         .collect() }++// ---------------------------------------------------------------- error bars++/// Half the length of an error bar's end cap.+const CAP: f32 = 2.5;++/// One drawn error bar in data units along its axis: the point index, the low and high ends,+/// and whether each end gets a cap (the start of a one-sided bar has none).+#[derive(Clone, Copy, Debug)]+struct ErrSeg {+    j: usize,+    lo: f64,+    hi: f64,+    cap_lo: bool,+    cap_hi: bool,+}++/// Error bar segments of `eb` for the points `values` (their own values along the bar axis)+/// drawn at `anchors` (the plotted positions: the value, or the stack top).+fn error_segments(eb: &ErrorBarData, values: &[Option<f64>], anchors: &[Option<f64>]) -> Vec<ErrSeg> {+    error_spans(eb, values)+        .into_iter()+        .enumerate()+        .filter_map(|(j, sp)| {+            let sp = sp?;+            let at = anchors.get(j).copied().flatten()?;+            let v = values.get(j).copied().flatten()?;+            let c = sp.center.map(|m| m + (at - v)).unwrap_or(at);+            let (lo, hi) = (c - sp.minus, c + sp.plus);+            // Excel caps only the ends away from the start: a plus-only standard deviation bar+            // runs up from the mean with no cap at the mean.+            (lo.is_finite() && hi.is_finite() && (sp.minus > 0.0 || sp.plus > 0.0)).then_some(ErrSeg {+                j,+                lo,+                hi,+                cap_lo: sp.minus > 0.0,+                cap_hi: sp.plus > 0.0,+            })+        })+        .collect()+}++/// Draws one error bar from `a` to `b` (pixels), capped across its direction at the ends+/// asked for, clipped to the plot.+fn draw_error_bar(ctx: &mut Ctx, plot: Bx, a: [f32; 2], b: [f32; 2], caps: (bool, bool), eb: &ErrorBarData, cap_on: bool) {+    if !(fin(a[0]) && fin(a[1]) && fin(b[0]) && fin(b[1])) {+        return;+    }+    let clamp = |p: [f32; 2]| [p[0].clamp(plot.x, plot.right()), p[1].clamp(plot.y, plot.bottom())];+    // Excel's automatic error bar line is 0.75 pt.+    let width = eb.spec.width.filter(|w| w.is_finite()).unwrap_or(0.75).clamp(0.25, 12.0);+    let dash = eb.spec.dash.is_some_and(|d| d != LineDash::Solid);+    let (ca, cb) = (clamp(a), clamp(b));+    if ca != cb {+        ctx.out.push(Prim::Line { pts: vec![ca, cb], color: eb.color, width, dash });+    }+    if !cap_on {+        return;+    }+    let vertical = (a[0] - b[0]).abs() < (a[1] - b[1]).abs();+    for (p, on, cp) in [(a, caps.0, ca), (b, caps.1, cb)] {+        // A cap past the plot edge is clipped away with its end.+        if !on || p != cp {+            continue;+        }+        let (p0, p1) = if vertical { ([p[0] - CAP, p[1]], [p[0] + CAP, p[1]]) } else { ([p[0], p[1] - CAP], [p[0], p[1] + CAP]) };+        ctx.out.push(Prim::Line { pts: vec![p0, p1], color: eb.color, width, dash: false });+    }+}diff --git a/crates/chart/src/render/cartesian.rs b/crates/chart/src/render/cartesian.rs--- a/crates/chart/src/render/cartesian.rs+++ b/crates/chart/src/render/cartesian.rs@@ -126,6 +126,24 @@ pub(super) fn cartesian(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx         spans.push(row);     } +    // Error bars run along the value axis from the plotted value (the stack top when stacked).+    let err_segs: Vec<Option<(&ErrorBarData, Vec<ErrSeg>)>> = data+        .series+        .iter()+        .enumerate()+        .map(|(i, s)| {+            let fam = fams.get(i).copied()?;+            let k = kinds.get(i).copied()?;+            if is_100(fam) || !supports_error_bars(k) {+                return None;+            }+            let eb = s.error_bars.iter().find(|e| e.spec.direction == ErrorBarDirection::Y)?;+            let anchors: Vec<Option<f64>> = spans.get(i)?.iter().map(|sp| sp.map(|(_, t)| t)).collect();+            let vals: Vec<Option<f64>> = s.values.iter().take(ncat).copied().collect();+            Some((eb, error_segments(eb, &vals, &anchors)))+        })+        .collect();+     // Axis scales.     let mut axes: Vec<(Scale, Fmt)> = Vec::new();     for axis in [false, true] {@@ -135,10 +153,15 @@ pub(super) fn cartesian(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx         }         let force_zero = members.iter().any(|i| fams.get(*i).is_some_and(|f| !is_line(*f)));         let all100 = members.iter().all(|i| fams.get(*i).is_some_and(|f| is_100(*f)));-        let range = value_range(members.iter().flat_map(|i| {-            let line = fams.get(*i).is_some_and(|f| is_line(*f));-            spans.get(*i).into_iter().flatten().flatten().flat_map(move |(b, t)| if line { vec![*t] } else { vec![*b, *t] })-        }));+        let range = value_range(+            members+                .iter()+                .flat_map(|i| {+                    let line = fams.get(*i).is_some_and(|f| is_line(*f));+                    spans.get(*i).into_iter().flatten().flatten().flat_map(move |(b, t)| if line { vec![*t] } else { vec![*b, *t] })+                })+                .chain(members.iter().filter_map(|i| err_segs.get(*i)?.as_ref()).flat_map(|(_, segs)| segs.iter().flat_map(|e| [e.lo, e.hi]))),+        );         let (lo, hi) = range.unwrap_or((0.0, 0.0));         let (scale, fmt) = if all100 {             (fixed_scale(if lo < -1e-9 { -1.0 } else { 0.0 }, if hi > 1e-9 || lo >= -1e-9 { 1.0 } else { 0.0 }, 0.2), Fmt::new("0%"))@@ -334,10 +357,36 @@ pub(super) fn cartesian(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx         draw_trendlines(ctx, s, &pts, &frac, &px, &mut trend_labels, (0.5, ncat_slots as f64 + 0.5));     }     draw_trend_labels(ctx, plot, &trend_labels);+    // Error bars over everything, at the column's slot or the line/area point.+    let mut budget = MAX_ERR_BARS;+    for (i, segs) in err_segs.iter().enumerate() {+        let Some((eb, segs)) = segs else { continue };+        let fam = fams.get(i).copied().unwrap_or(Fam::Col);+        let sc = scale_of(i);+        let k = slot_of.get(i).copied().unwrap_or(0) as f32;+        let n = segs.len().min(budget);+        budget = budget.saturating_sub(n);+        for e in segs.iter().take(n) {+            let mut c = center(e.j);+            if is_col(fam) {+                c = if horizontal { c + nslots * bw / 2.0 - (k + 0.5) * bw } else { c - nslots * bw / 2.0 + (k + 0.5) * bw };+            }+            let (a, b) = (vpos(e.lo, &sc), vpos(e.hi, &sc));+            let (pa, pb) = if horizontal { ([a, c], [b, c]) } else { ([c, a], [c, b]) };+            draw_error_bar(ctx, plot, pa, pb, (e.cap_lo, e.cap_hi), eb, !eb.spec.no_end_cap);+        }+    } }  // ---------------------------------------------------------------- scatter & bubble +/// The size of bubble `j`, `None` when it is not drawn (a missing or non-positive size; no sizes at all means 1).+fn bubble_size(s: &SeriesData, j: usize) -> Option<f64> {+    let sizes = s.sizes.as_deref().unwrap_or(&[]);+    let sz = if sizes.is_empty() { Some(1.0) } else { sizes.get(j).copied().flatten() };+    sz.filter(|sz| *sz > 0.0)+}+ pub(super) fn scatter(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx) {     let pts_of = |s: &SeriesData| -> Vec<Option<(f64, f64)>> {         s.values@@ -364,7 +413,55 @@ pub(super) fn scatter(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx)             xhi = xhi.max(hi + max_forward(s));         }     }-    let Some((ylo, yhi)) = value_range(all.iter().flatten().flatten().map(|p| p.1)) else { return };+    let Some((mut ylo, mut yhi)) = value_range(all.iter().flatten().flatten().map(|p| p.1)) else { return };+    // Error bars, X and Y, widen the axes to their ends.+    let bubble_chart = chart.kind == ChartKind::Bubble;+    let err: Vec<Vec<(ErrorBarDirection, &ErrorBarData, Vec<ErrSeg>)>> = data+        .series+        .iter()+        .zip(&all)+        .map(|(s, pts)| {+            // A bubble that is not drawn (no or non-positive size, or past the marker cap) carries no error bar either.+            let mut room = MAX_MARKERS;+            let shown: Vec<bool> = pts+                .iter()+                .enumerate()+                .map(|(j, p)| {+                    let drawn = !bubble_chart || (p.is_some() && bubble_size(s, j).is_some() && room > 0);+                    if bubble_chart && drawn {+                        room -= 1;+                    }+                    drawn+                })+                .collect();+            let xs: Vec<Option<f64>> = pts.iter().zip(&shown).map(|(p, on)| p.filter(|_| *on).map(|p| p.0)).collect();+            let ys: Vec<Option<f64>> = pts.iter().zip(&shown).map(|(p, on)| p.filter(|_| *on).map(|p| p.1)).collect();+            let mut seen = Vec::new();+            s.error_bars+                .iter()+                .filter(|e| {+                    let new = !seen.contains(&e.spec.direction);+                    seen.push(e.spec.direction);+                    new+                })+                .map(|e| {+                    let v = if e.spec.direction == ErrorBarDirection::X { &xs } else { &ys };+                    (e.spec.direction, e, error_segments(e, v, v))+                })+                .collect()+        })+        .collect();+    for (dir, _, segs) in err.iter().flatten() {+        if let Some((lo, hi)) = value_range(segs.iter().flat_map(|e| [e.lo, e.hi])) {+            if *dir == ErrorBarDirection::X {+                xlo = xlo.min(lo);+                xhi = xhi.max(hi);+            } else {+                ylo = ylo.min(lo);+                yhi = yhi.max(hi);+            }+        }+    }     let xs = nice_scale(xlo, xhi, false);     let ys = nice_scale(ylo, yhi, false);     let fx = Fmt::new("General");@@ -381,18 +478,8 @@ pub(super) fn scatter(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx)         if bubble {             let sizes = s.sizes.as_deref().unwrap_or(&[]);             let smax = sizes.iter().flatten().fold(0.0f64, |a, b| a.max(*b));-            let mut bubbles: Vec<([f32; 2], f64)> = px-                .iter()-                .enumerate()-                .filter_map(|(j, p)| {-                    let sz = if sizes.is_empty() { Some(1.0) } else { sizes.get(j).copied().flatten() };-                    match (p, sz) {-                        (Some(p), Some(sz)) if sz > 0.0 => Some((*p, sz)),-                        _ => None,-                    }-                })-                .take(MAX_MARKERS)-                .collect();+            let mut bubbles: Vec<([f32; 2], f64)> =+                px.iter().enumerate().filter_map(|(j, p)| Some(((*p)?, bubble_size(s, j)?))).take(MAX_MARKERS).collect();             bubbles.sort_by(|a, b| b.1.total_cmp(&a.1));             let smax = if sizes.is_empty() { 1.0 } else { smax };             let fill = [s.color[0], s.color[1], s.color[2], 0xC0];@@ -428,4 +515,16 @@ pub(super) fn scatter(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx)         draw_trendlines(ctx, s, &pts, &frac, &px, &mut trend_labels, (xs.min, xs.max));     }     draw_trend_labels(ctx, plot, &trend_labels);+    let mut budget = MAX_ERR_BARS;+    for (pts, bars) in all.iter().zip(&err) {+        for (dir, eb, segs) in bars {+            let n = segs.len().min(budget);+            budget = budget.saturating_sub(n);+            for e in segs.iter().take(n) {+                let Some((x, y)) = pts.get(e.j).copied().flatten() else { continue };+                let (a, b) = if *dir == ErrorBarDirection::X { (map((e.lo, y)), map((e.hi, y))) } else { (map((x, e.lo)), map((x, e.hi))) };+                draw_error_bar(ctx, plot, a, b, (e.cap_lo, e.cap_hi), eb, !eb.spec.no_end_cap);+            }+        }+    } }diff --git a/crates/chart/src/resolve.rs b/crates/chart/src/resolve.rs--- a/crates/chart/src/resolve.rs+++ b/crates/chart/src/resolve.rs@@ -2,10 +2,10 @@  use gridcraft_calc::evaluate; use gridcraft_core::{CellRef, RangeRef, Value, category_levels, number_to_text};-use gridcraft_model::{Chart, ChartKind, Theme, Workbook, style::apply_tint};+use gridcraft_model::{Chart, ChartKind, ErrorAmount, Theme, Workbook, style::apply_tint}; use gridcraft_numfmt::{NumberFormat, format_value}; -use crate::{ChartData, Rgba, SeriesData, TrendlineData};+use crate::{ChartData, ErrorBarData, Rgba, SeriesData, TrendlineData};  /// Cap on points per series (keeps whole-column references from exploding). pub(crate) const MAX_POINTS: usize = 100_000;@@ -13,6 +13,8 @@ pub(crate) const MAX_POINTS: usize = 100_000; pub(crate) const MAX_SERIES: usize = 255; /// Cap on trendlines drawn per series. pub(crate) const MAX_TRENDLINES: usize = 16;+/// Automatic error bar colour (a dark grey, like the axis text).+pub(crate) const ERROR_BAR_GREY: Rgba = [0x59, 0x59, 0x59, 0xFF];  /// Tints used for series beyond the six theme accents: lighter, darker, lighter still… const CYCLE_TINTS: [f64; 6] = [0.0, 0.4, -0.25, 0.6, -0.5, 0.2];@@ -258,6 +260,26 @@ pub fn resolve(wb: &Workbook, sheet: usize, chart: &Chart) -> ChartData {                 color: t.color.as_ref().and_then(|c| c.resolve(&wb.theme)).map(|[r, g, b]| [r, g, b, 0xFF]).unwrap_or(color),             })             .collect();+        let error_bars = s+            .error_bars+            .iter()+            .take(2)+            .map(|e| {+                let (plus, minus) = match &e.amount {+                    ErrorAmount::Custom { plus, minus } => {+                        let eval = |f: &Option<String>| f.as_deref().map(|f| nums(wb, sheet, f)).unwrap_or_default();+                        (eval(plus), eval(minus))+                    }+                    _ => (Vec::new(), Vec::new()),+                };+                ErrorBarData {+                    spec: e.clone(),+                    color: e.color.as_ref().and_then(|c| c.resolve(&wb.theme)).map(|[r, g, b]| [r, g, b, 0xFF]).unwrap_or(ERROR_BAR_GREY),+                    plus,+                    minus,+                }+            })+            .collect();         series.push(SeriesData {             name,             values,@@ -268,6 +290,7 @@ pub fn resolve(wb: &Workbook, sheet: usize, chart: &Chart) -> ChartData {             secondary: s.secondary,             smooth: s.smooth,             trendlines,+            error_bars,             number_format: ref_format(wb, sheet, &s.values),         });     }diff --git a/crates/chart/src/tests.rs b/crates/chart/src/tests.rs--- a/crates/chart/src/tests.rs+++ b/crates/chart/src/tests.rs@@ -64,6 +64,7 @@ fn series(name: &str, values: &[f64], color: Rgba, kind: ChartKind) -> SeriesDat         secondary: false,         smooth: false,         trendlines: Vec::new(),+        error_bars: Vec::new(),         number_format: "$#,##0".into(),     } }@@ -232,6 +233,7 @@ fn resolve_from_workbook() {             quartiles: Default::default(),             smooth: false,             trendlines: Vec::new(),+            error_bars: Vec::new(),         },         Series {             name: None,@@ -244,6 +246,7 @@ fn resolve_from_workbook() {             quartiles: Default::default(),             smooth: false,             trendlines: Vec::new(),+            error_bars: Vec::new(),         },         Series {             name: Some("Literal".into()),@@ -256,6 +259,7 @@ fn resolve_from_workbook() {             quartiles: Default::default(),             smooth: false,             trendlines: Vec::new(),+            error_bars: Vec::new(),         },     ];     let d = resolve(&wb, 0, &c);@@ -295,6 +299,7 @@ fn resolve_from_workbook() {             quartiles: Default::default(),             smooth: false,             trendlines: Vec::new(),+            error_bars: Vec::new(),         },         Series {             name: None,@@ -307,6 +312,7 @@ fn resolve_from_workbook() {             quartiles: Default::default(),             smooth: false,             trendlines: Vec::new(),+            error_bars: Vec::new(),         },     ];     let d = resolve(&wb, 0, &c);@@ -324,6 +330,7 @@ fn resolve_from_workbook() {         quartiles: Default::default(),         smooth: false,         trendlines: Vec::new(),+        error_bars: Vec::new(),     }];     let d = resolve(&wb, 0, &c);     assert_eq!(d.series[0].x.as_ref().map(|x| x.len()), Some(3));@@ -342,6 +349,7 @@ fn resolve_from_workbook() {         quartiles: Default::default(),         smooth: false,         trendlines: Vec::new(),+        error_bars: Vec::new(),     }];     let d = resolve(&wb, 7, &c);     assert_eq!(d.series.len(), 1);@@ -651,6 +659,26 @@ fn fuzz_random_data_and_sizes() {                         color: [0, 0, 0, 255],                     })                     .collect(),+                error_bars: (0..r.next() % 3)+                    .map(|_| {+                        let v = [0.0, 1.5, -2.0, f64::NAN, 1e300][(r.next() % 5) as usize];+                        ErrorBarData {+                            spec: ErrorBars {+                                direction: if r.next().is_multiple_of(2) { ErrorBarDirection::X } else { ErrorBarDirection::Y },+                                sides: [ErrorBarSides::Both, ErrorBarSides::Plus, ErrorBarSides::Minus][(r.next() % 3) as usize],+                                amount: [ErrorAmount::Fixed(v), ErrorAmount::Percentage(v), ErrorAmount::StdDev(v), ErrorAmount::StdErr]+                                    [(r.next() % 4) as usize]+                                    .clone(),+                                no_end_cap: r.next().is_multiple_of(2),+                                width: [None, Some(0.0), Some(f32::NAN), Some(3.0)][(r.next() % 4) as usize],+                                ..ErrorBars::default()+                            },+                            color: [0, 0, 0, 255],+                            plus: (0..r.next() % 4).map(|_| val(&mut r)).collect(),+                            minus: (0..r.next() % 4).map(|_| val(&mut r)).collect(),+                        }+                    })+                    .collect(),                 number_format: ["General", "$#,##0", "0.00%", "yyyy-mm-dd", "#,##0.0;[Red]-#,##0.0", "@"][(r.next() % 6) as usize].into(),             })             .collect();@@ -1043,6 +1071,7 @@ fn pie_points_follow_workbook_theme() {         quartiles: Default::default(),         smooth: false,         trendlines: Vec::new(),+        error_bars: Vec::new(),     }];     let d = resolve(&wb, 0, &c);     let expected = vec![series_color(&wb.theme, 0), series_color(&wb.theme, 1)];diff --git a/crates/engine/src/cmd/extra.rs b/crates/engine/src/cmd/extra.rs--- a/crates/engine/src/cmd/extra.rs+++ b/crates/engine/src/cmd/extra.rs@@ -32,7 +32,7 @@ pub fn specs() -> Vec<CommandSpec> {             "Add Chart Element",             ["Chart Design", "Chart Layouts"],             None,-            "{chart?, element: title|legend|dataLabels|gridlines|axisTitles|smooth|trendline, on?: bool, position?: bottom|top|left|right; trendline: series?: index (default all), type?: linear|exponential|linearForecast|movingAverage|logarithmic|power|polynomial, order?: 2..6, period?: 2..255, forward?, backward?, displayEquation?, displayRSquared?}",+            "{chart?, element: title|legend|dataLabels|gridlines|axisTitles|smooth|trendline, on?: bool, position?: bottom|top|left|right; trendline: series?: index (default all), type?: linear|exponential|linearForecast|movingAverage|logarithmic|power|polynomial, order?: 2..6, period?: 2..255, forward?, backward?, displayEquation?, displayRSquared?; errorBars: series?: index (default all), type?: standardError|percentage|standardDeviation|fixed|custom, value?: number, plus?/minus?: reference or {array} (custom), direction?: y|x (x on scatter/bubble), display?: both|plus|minus, endStyle?: cap|noCap, color?, width?: points; on=false removes them}",             has_doc,             add_element         ),diff --git a/crates/engine/src/cmd/extra/chart.rs b/crates/engine/src/cmd/extra/chart.rs--- a/crates/engine/src/cmd/extra/chart.rs+++ b/crates/engine/src/cmd/extra/chart.rs@@ -50,6 +50,7 @@ pub(super) fn add_element(s: &mut Session, p: &Json) -> Result<Json> {                     s.smooth = on;                 }             }+            "errorBars" => add_error_bars(c, p, on)?,             "trendline" => {                 let only = u32_param(p, "series").map(|i| i as usize);                 if only.is_some_and(|i| i >= c.series.len()) {@@ -82,6 +83,140 @@ pub(super) fn add_element(s: &mut Session, p: &Json) -> Result<Json> {     }) } +/// `chart.addElement` `errorBars`: adds (or replaces, per direction) or removes error bars on+/// one series or all that can show them (Excel's Add Chart Element ▸ Error Bars and the Format+/// Error Bars options).+fn add_error_bars(c: &mut Chart, p: &Json, on: bool) -> Result<()> {+    let only = match p.get("series").filter(|v| !v.is_null()) {+        None => None,+        Some(v) => {+            Some(v.as_u64().and_then(|i| usize::try_from(i).ok()).ok_or_else(|| bad("chart.addElement", "`series` must be a non-negative integer"))?)+        }+    };+    if only.is_some_and(|i| i >= c.series.len()) {+        return Err(bad("chart.addElement", "no such series"));+    }+    let targets: Vec<usize> = (0..c.series.len()).filter(|i| only.is_none_or(|o| o == *i)).collect();+    if !on {+        for i in targets {+            if let Some(se) = c.series.get_mut(i) {+                se.error_bars.clear();+            }+        }+        return Ok(());+    }+    let e = error_bars_param(p)?;+    let eligible: Vec<usize> = targets+        .into_iter()+        .filter(|i| {+            let k = gridcraft_chart::series_plot_kind(c, *i);+            gridcraft_chart::supports_error_bars(k)+                && (e.direction == ErrorBarDirection::Y || matches!(k, ChartKind::Scatter | ChartKind::ScatterLines | ChartKind::Bubble))+        })+        .collect();+    if eligible.is_empty() {+        return Err(bad(+            "chart.addElement",+            if e.direction == ErrorBarDirection::X {+                "X error bars need a scatter or bubble series"+            } else {+                "error bars need a 2-D column, bar, line, area, scatter or bubble series"+            },+        ));+    }+    for i in eligible {+        if let Some(se) = c.series.get_mut(i) {+            se.error_bars.retain(|o| o.direction != e.direction);+            se.error_bars.push(e.clone());+        }+    }+    Ok(())+}++/// Longest reference or array text accepted for custom error amounts.+const MAX_ERROR_REF: usize = 8192;++/// A text option of `chart.addElement` errorBars: `default` when absent or null, an error when present and not text.+fn word<'a>(p: &'a Json, k: &str, default: &'static str) -> Result<&'a str> {+    match p.get(k).filter(|v| !v.is_null()) {+        None => Ok(default),+        Some(v) => v.as_str().ok_or_else(|| bad("chart.addElement", format!("`{k}` must be text"))),+    }+}++/// The error bars described by `chart.addElement`'s errorBars parameters. Defaults follow+/// Excel's menu: Standard Error; Percentage is 5%, Standard Deviation 1, Fixed Value 1.+fn error_bars_param(p: &Json) -> Result<ErrorBars> {+    // A key that is absent or null takes its default; one that is present but unusable is an error.+    let given = |k: &str| p.get(k).filter(|v| !v.is_null());+    let value = |default: f64| -> Result<f64> {+        match given("value") {+            None => Ok(default),+            Some(v) => v+                .as_f64()+                .filter(|v| v.is_finite() && *v >= 0.0)+                .ok_or_else(|| bad("chart.addElement", "`value` must be a finite number that is not negative")),+        }+    };+    let amount = match word(p, "type", "standardError")? {+        "standardError" => ErrorAmount::StdErr,+        "percentage" => ErrorAmount::Percentage(value(5.0)?),+        "standardDeviation" => ErrorAmount::StdDev(value(1.0)?),+        "fixed" => ErrorAmount::Fixed(value(1.0)?),+        "custom" => {+            let r = |k: &str| -> Result<Option<String>> {+                match word(p, k, "")?.trim() {+                    "" => Ok(None),+                    t if t.len() > MAX_ERROR_REF => Err(bad("chart.addElement", format!("`{k}` is too long"))),+                    t => {+                        let t = t.strip_prefix('=').unwrap_or(t).trim();+                        match gridcraft_formula::parse(t) {+                            Ok(gridcraft_formula::Expr::Ref(_) | gridcraft_formula::Expr::Name(_)) => Ok(Some(t.to_string())),+                            // One row: the file keeps a flat list.+                            Ok(gridcraft_formula::Expr::Array(rows)) if rows.len() == 1 => Ok(Some(t.to_string())),+                            _ => Err(bad("chart.addElement", format!("`{k}` must be a reference, a name or a one-row array constant"))),+                        }+                    }+                }+            };+            let (plus, minus) = (r("plus")?, r("minus")?);+            if plus.is_none() && minus.is_none() {+                return Err(bad("chart.addElement", "custom error bars need `plus` or `minus`"));+            }+            ErrorAmount::Custom { plus, minus }+        }+        other => return Err(bad("chart.addElement", format!("unknown error bar type `{other}`"))),+    };+    let direction = match word(p, "direction", "y")? {+        "y" => ErrorBarDirection::Y,+        "x" => ErrorBarDirection::X,+        other => return Err(bad("chart.addElement", format!("unknown error bar direction `{other}`"))),+    };+    let sides = match word(p, "display", "both")? {+        "both" => ErrorBarSides::Both,+        "plus" => ErrorBarSides::Plus,+        "minus" => ErrorBarSides::Minus,+        other => return Err(bad("chart.addElement", format!("unknown error bar display `{other}`"))),+    };+    let no_end_cap = match word(p, "endStyle", "cap")? {+        "cap" => false,+        "noCap" => true,+        other => return Err(bad("chart.addElement", format!("unknown error bar end style `{other}`"))),+    };+    let color = match given("color") {+        None => None,+        Some(v) => Some(super::format::color_param(Some(v)).ok_or_else(|| bad("chart.addElement", "`color` is not a colour"))?),+    };+    let width = match given("width") {+        None => None,+        Some(v) => Some(+            v.as_f64().filter(|w| w.is_finite() && *w > 0.0).ok_or_else(|| bad("chart.addElement", "`width` must be a positive number"))?.min(100.0)+                as f32,+        ),+    };+    Ok(ErrorBars { direction, sides, amount, no_end_cap, color: color.filter(|c| *c != Color::Auto), width, dash: None })+}+ /// The trendline described by `chart.addElement`'s trendline parameters (Excel's Add Chart /// Element ▸ Trendline choices, plus the Format Trendline options). fn trendline_param(p: &Json) -> Result<Trendline> {diff --git a/crates/engine/src/cmd/forecast.rs b/crates/engine/src/cmd/forecast.rs--- a/crates/engine/src/cmd/forecast.rs+++ b/crates/engine/src/cmd/forecast.rs@@ -414,6 +414,7 @@ fn forecast_sheet(s: &mut Session, p: &Json) -> Result<Json> {                 quartiles: Default::default(),                 smooth: false,                 trendlines: Vec::new(),+                error_bars: Vec::new(),             })             .collect();         sheet.charts.push(Chart {diff --git a/crates/engine/src/cmd/insert/chart.rs b/crates/engine/src/cmd/insert/chart.rs--- a/crates/engine/src/cmd/insert/chart.rs+++ b/crates/engine/src/cmd/insert/chart.rs@@ -82,6 +82,7 @@ pub fn series_from_range(sh: &Sheet, r: RangeRef, by_rows: bool) -> Vec<Series>                 quartiles: Default::default(),                 smooth: false,                 trendlines: Vec::new(),+                error_bars: Vec::new(),             });         }     } else {@@ -98,6 +99,7 @@ pub fn series_from_range(sh: &Sheet, r: RangeRef, by_rows: bool) -> Vec<Series>                 quartiles: Default::default(),                 smooth: false,                 trendlines: Vec::new(),+                error_bars: Vec::new(),             });         }     }@@ -288,6 +290,18 @@ fn find_chart(s: &Session, p: &Json) -> Result<(usize, usize)> {     idx.map(|i| (sheet, i)).ok_or_else(|| bad("chart", "no such chart")) } +/// After a chart type change, drops error bars the new kind can't show or save: all of them on+/// pie, radar and the other kinds without error bars, X bars on column, bar, line and area.+fn keep_supported_error_bars(c: &mut Chart) {+    let kinds: Vec<ChartKind> = (0..c.series.len()).map(|i| gridcraft_chart::series_plot_kind(c, i)).collect();+    for (se, k) in c.series.iter_mut().zip(kinds) {+        let xy = matches!(k, ChartKind::Scatter | ChartKind::ScatterLines | ChartKind::Bubble);+        // 100% stacked and stock series keep theirs in the file, though they aren't drawn.+        let saved = gridcraft_chart::supports_error_bars(k) || matches!(k, ChartKind::ColumnStacked100 | ChartKind::BarStacked100 | ChartKind::Stock);+        se.error_bars.retain(|e| saved && (xy || e.direction == ErrorBarDirection::Y));+    }+}+ pub(super) fn chart_set(s: &mut Session, p: &Json) -> Result<Json> {     let (si, ci) = find_chart(s, p)?;     let at = cell_param_on(s, p, "at", si)?;@@ -304,6 +318,7 @@ pub(super) fn chart_set(s: &mut Session, p: &Json) -> Result<Json> {         let Some(c) = sh.charts.get_mut(ci) else { return Ok(Json::Null) };         if let Some(t) = str_param(p, "type") {             c.kind = chart_kind(t, str_param(p, "subtype").unwrap_or(""));+            keep_supported_error_bars(c);         }         if let Some(method) = quartiles {             for series in &mut c.series {diff --git a/crates/engine/src/cmd/inspect.rs b/crates/engine/src/cmd/inspect.rs--- a/crates/engine/src/cmd/inspect.rs+++ b/crates/engine/src/cmd/inspect.rs@@ -46,7 +46,7 @@ fn inspect(s: &mut Session, _: &Json) -> Result<Json> {                 "freeze": sh.freeze,                 "merges": sh.merges.iter().map(|m| m.a1()).collect::<Vec<_>>(),                 "tables": sh.tables.iter().map(|t| json!({"name": t.name, "range": t.range.a1(), "style": t.style, "totals": t.totals_row, "columns": t.columns.iter().map(|c| c.name.clone()).collect::<Vec<_>>()})).collect::<Vec<_>>(),-                "charts": sh.charts.iter().map(|c| json!({"id": c.id, "kind": format!("{:?}", c.kind), "title": c.title, "at": c.anchor.cell.a1(), "series": c.series.len(), "trendlines": c.series.iter().flat_map(|s| s.trendlines.iter().map(|t| format!("{:?}", t.kind))).collect::<Vec<_>>()})).collect::<Vec<_>>(),+                "charts": sh.charts.iter().map(|c| json!({"id": c.id, "kind": format!("{:?}", c.kind), "title": c.title, "at": c.anchor.cell.a1(), "series": c.series.len(), "trendlines": c.series.iter().flat_map(|s| s.trendlines.iter().map(|t| format!("{:?}", t.kind))).collect::<Vec<_>>(), "errorBars": c.series.iter().flat_map(|s| s.error_bars.iter().map(|e| format!("{:?} {:?}", e.direction, e.amount))).collect::<Vec<_>>()})).collect::<Vec<_>>(),                 "images": sh.images.len(),                 "shapes": sh.shapes.len(),                 "conditionalFormats": sh.cond_formats.len(),diff --git a/crates/engine/src/pivot.rs b/crates/engine/src/pivot.rs--- a/crates/engine/src/pivot.rs+++ b/crates/engine/src/pivot.rs@@ -327,3 +327,34 @@ mod fit_tests {         assert!(data_cols_exceed_sheet(16_385, false, 0));     } }++#[cfg(test)]+mod carry_error_bars_tests {+    use super::charts::carry_error_bars;+    use super::*;++    fn series(name: &str, bars: usize) -> Series {+        Series {+            name: Some(name.into()),+            categories: None,+            values: String::new(),+            bubble_sizes: None,+            color: None,+            secondary: false,+            kind: None,+            quartiles: Default::default(),+            smooth: false,+            trendlines: Vec::new(),+            error_bars: vec![Default::default(); bars],+        }+    }++    #[test]+    fn duplicate_names_take_distinct_old_series() {+        let old = [series("a", 1), series("a", 0)];+        let mut new = [series("a", 0), series("a", 0), series("a", 0)];+        carry_error_bars(&old, &mut new);+        let counts: Vec<usize> = new.iter().map(|s| s.error_bars.len()).collect();+        assert_eq!(counts, [1, 0, 0]);+    }+}diff --git a/crates/engine/src/pivot/charts.rs b/crates/engine/src/pivot/charts.rs--- a/crates/engine/src/pivot/charts.rs+++ b/crates/engine/src/pivot/charts.rs@@ -76,11 +76,24 @@ pub fn chart_series(sheet: &str, origin: CellRef, out: &Output) -> Vec<Series> {                 quartiles: Default::default(),                 smooth: false,                 trendlines: Vec::new(),+                error_bars: Vec::new(),             })         })         .collect() } +/// Error bars stay with the series of the same name; duplicate names pair up in order, each old series used once.+pub(super) fn carry_error_bars(old: &[Series], new: &mut [Series]) {+    let mut used = vec![false; old.len()];+    for s in new {+        let hit = old.iter().enumerate().find(|(i, o)| !used[*i] && o.name == s.name).map(|(i, _)| i);+        if let Some(i) = hit {+            used[i] = true;+            s.error_bars = old[i].error_bars.clone();+        }+    }+}+ /// Rebuilds the series of every PivotChart of a PivotTable after its report was written. pub(super) fn rebind_charts(wb: &mut Workbook, sheet: &str, pivot: &str, origin: CellRef, out: &Output) {     let linked = |ch: &Chart| ch.pivot.as_deref().is_some_and(|l| links_to(l, sheet, pivot));@@ -92,7 +105,9 @@ pub(super) fn rebind_charts(wb: &mut Workbook, sheet: &str, pivot: &str, origin:     for i in hits {         let Some(sh) = wb.sheet_mut(i) else { continue };         for ch in sh.charts.iter_mut().filter(|c| linked(c)) {+            let old = std::mem::take(&mut ch.series);             ch.series = series.clone();+            carry_error_bars(&old, &mut ch.series);         }     } }diff --git a/crates/engine/src/sample.rs b/crates/engine/src/sample.rs--- a/crates/engine/src/sample.rs+++ b/crates/engine/src/sample.rs@@ -111,6 +111,7 @@ fn sales(s: &mut Session) -> bool {                 quartiles: Default::default(),                 smooth: false,                 trendlines: Vec::new(),+                error_bars: Vec::new(),             }];         }     }diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs--- a/crates/engine/src/tests.rs+++ b/crates/engine/src/tests.rs@@ -3145,6 +3145,108 @@ fn trendlines_are_refused_on_charts_that_cannot_show_them() {     assert!(s.doc().unwrap().wb.active().unwrap().charts[0].series[0].trendlines.is_empty()); } +#[test]+fn chart_error_bars_are_added_replaced_saved_and_removed() {+    use gridcraft_model::{ErrorAmount, ErrorBarDirection, ErrorBarSides};+    let mut s = s();+    chart_data(&mut s);+    s.execute("insert.chart", json!({"type": "column", "range": "A1:E7"})).unwrap();+    let chart = |s: &Session| s.doc().unwrap().wb.active().unwrap().charts[0].clone();+    // Add Chart Element > Error Bars > Standard Error on every series.+    s.execute("chart.addElement", json!({"element": "errorBars"})).unwrap();+    let c = chart(&s);+    assert!(c.series.iter().all(|se| se.error_bars.len() == 1 && se.error_bars[0].amount == ErrorAmount::StdErr));+    // Format Error Bars on one series replaces its Y bars.+    s.execute(+        "chart.addElement",+        json!({"element": "errorBars", "series": 1, "type": "percentage", "value": 10, "display": "plus", "endStyle": "noCap", "color": "#C00000", "width": 2}),+    )+    .unwrap();+    s.execute("chart.addElement", json!({"element": "errorBars", "series": 2, "type": "custom", "plus": "=Sheet1!$C$2:$C$7", "minus": "{1}"}))+        .unwrap();+    let c = chart(&s);+    let e = &c.series[1].error_bars;+    assert_eq!(e.len(), 1);+    assert_eq!((e[0].amount.clone(), e[0].sides, e[0].no_end_cap, e[0].width), (ErrorAmount::Percentage(10.0), ErrorBarSides::Plus, true, Some(2.0)));+    assert_eq!(e[0].color, Some(gridcraft_model::Color::Rgb(0xC00000)));+    assert_eq!(c.series[2].error_bars[0].amount, ErrorAmount::Custom { plus: Some("Sheet1!$C$2:$C$7".into()), minus: Some("{1}".into()) });+    let info = s.execute("document.inspect", json!({})).unwrap();+    assert_eq!(info["sheets"][0]["charts"][0]["errorBars"][1], json!("Y Percentage(10.0)"));+    // Refusals: X bars on a column chart, custom without amounts, unknown names, no such series.+    assert!(s.execute("chart.addElement", json!({"element": "errorBars", "direction": "x"})).is_err());+    assert!(s.execute("chart.addElement", json!({"element": "errorBars", "type": "custom"})).is_err());+    assert!(s.execute("chart.addElement", json!({"element": "errorBars", "type": "custom", "plus": "SUM(A1:A2)"})).is_err());+    assert!(s.execute("chart.addElement", json!({"element": "errorBars", "type": "custom", "plus": "{1;2}"})).is_err());+    assert!(s.execute("chart.addElement", json!({"element": "errorBars", "type": "range"})).is_err());+    assert!(s.execute("chart.addElement", json!({"element": "errorBars", "display": "up"})).is_err());+    assert!(s.execute("chart.addElement", json!({"element": "errorBars", "series": 9})).is_err());+    // A present but unusable value, colour or width is refused, not swapped for a default; null means absent.+    for case in [+        json!({"type": "percentage", "value": -5}),+        json!({"type": "fixed", "value": "abc"}),+        json!({"color": "#GGG"}),+        json!({"color": 7}),+        json!({"width": -1}),+        json!({"width": "wide"}),+        json!({"type": 7}),+        json!({"direction": true}),+        json!({"display": 1}),+        json!({"endStyle": []}),+        json!({"series": "1", "on": false}),+        json!({"series": -1}),+        json!({"series": 1.5}),+        json!({"series": true}),+        json!({"type": "custom", "plus": 7, "minus": "B2:B7"}),+        json!({"type": "custom", "plus": "B2:B7", "minus": false}),+    ] {+        let mut p = json!({"element": "errorBars"});+        p.as_object_mut().unwrap().extend(case.as_object().unwrap().clone());+        assert!(s.execute("chart.addElement", p).is_err(), "{case}");+    }+    assert_eq!(chart(&s), c, "refused calls change nothing");+    s.execute("chart.addElement", json!({"element": "errorBars", "series": 3, "type": "fixed", "value": null, "color": null, "width": null}))+        .unwrap();+    assert_eq!(chart(&s).series[3].error_bars[0].amount, ErrorAmount::Fixed(1.0));+    let c = chart(&s);+    reopen(&mut s);+    let after = chart(&s);+    for i in 0..c.series.len() {+        assert_eq!(after.series[i].error_bars, c.series[i].error_bars, "series {i}");+    }+    s.execute("chart.addElement", json!({"element": "errorBars", "on": false})).unwrap();+    assert!(chart(&s).series.iter().all(|se| se.error_bars.is_empty()));+    s.execute("edit.undo", json!({})).unwrap();+    assert_eq!(chart(&s).series[1].error_bars.len(), 1);+    assert_eq!(chart(&s).series[1].error_bars[0].direction, ErrorBarDirection::Y);+}++#[test]+fn scatter_charts_take_x_and_y_error_bars_and_pies_take_none() {+    use gridcraft_model::{ErrorAmount, ErrorBarDirection};+    let mut s = s();+    chart_data(&mut s);+    s.execute("insert.chart", json!({"type": "scatter", "range": "B2:C7"})).unwrap();+    s.execute("chart.addElement", json!({"element": "errorBars", "type": "fixed", "value": 0.5, "direction": "x"})).unwrap();+    s.execute("chart.addElement", json!({"element": "errorBars", "type": "standardDeviation", "value": 2})).unwrap();+    let c = s.doc().unwrap().wb.active().unwrap().charts[0].clone();+    let e = &c.series[0].error_bars;+    assert_eq!(e.iter().map(|e| e.direction).collect::<Vec<_>>(), vec![ErrorBarDirection::X, ErrorBarDirection::Y]);+    assert_eq!(e[1].amount, ErrorAmount::StdDev(2.0));+    reopen(&mut s);+    assert_eq!(s.doc().unwrap().wb.active().unwrap().charts[0].series[0].error_bars, *e);+    // A column series has no X bars, a pie none at all: a type change drops what can't be kept.+    s.execute("chart.set", json!({"type": "column"})).unwrap();+    let kept = s.doc().unwrap().wb.active().unwrap().charts[0].series[0].error_bars.clone();+    assert_eq!(kept, vec![e[1].clone()]);+    s.execute("chart.set", json!({"type": "pie"})).unwrap();+    assert!(s.doc().unwrap().wb.active().unwrap().charts[0].series[0].error_bars.is_empty());++    let mut s = crate::tests::s();+    chart_data(&mut s);+    s.execute("insert.chart", json!({"type": "pie", "range": "A1:B7"})).unwrap();+    assert!(s.execute("chart.addElement", json!({"element": "errorBars"})).is_err());+}+ #[test] fn switch_row_column_with_whole_column_refs_is_bounded() {     let mut s = s();diff --git a/crates/engine/tests/it/pivotchart_link.rs b/crates/engine/tests/it/pivotchart_link.rs--- a/crates/engine/tests/it/pivotchart_link.rs+++ b/crates/engine/tests/it/pivotchart_link.rs@@ -39,3 +39,18 @@ fn pivot_chart_link_names_the_workbook_in_brackets() {     let link = reopened.doc().unwrap().wb.sheets.iter().flat_map(|sh| sh.charts.iter()).find_map(|c| c.pivot.clone());     assert_eq!(link.as_deref(), Some("Sheet2!PivotTable1")); }++#[test]+fn refreshing_a_pivot_chart_keeps_its_error_bars() {+    let mut s = source();+    run(&mut s, "selection.set", json!({"range": "A1"}));+    run(&mut s, "insert.pivotTable", json!({"rows": ["Region"], "values": ["Sales"]}));+    run(&mut s, "insert.pivotChart", json!({}));+    run(&mut s, "chart.addElement", json!({"element": "errorBars", "type": "fixed", "value": 2}));+    let bars = |s: &Session| -> Vec<usize> {+        s.doc().unwrap().wb.sheets.iter().flat_map(|sh| sh.charts.iter()).flat_map(|c| c.series.iter().map(|se| se.error_bars.len())).collect()+    };+    assert_eq!(bars(&s), vec![1]);+    run(&mut s, "pivot.refresh", json!({"pivot": "PivotTable1"}));+    assert_eq!(bars(&s), vec![1]);+}diff --git a/crates/model/src/features.rs b/crates/model/src/features.rs--- a/crates/model/src/features.rs+++ b/crates/model/src/features.rs@@ -470,6 +470,59 @@ pub struct Series {     /// Trendlines fitted to this series (Excel allows several per series).     #[serde(default)]     pub trendlines: Vec<Trendline>,+    /// Error bars: at most one set per direction (Y only on column, bar and line series).+    #[serde(default)]+    pub error_bars: Vec<ErrorBars>,+}++/// Which axis error bars run along (`c:errDir`). On a bar chart, `Y` bars run along the+/// horizontal value axis.+#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]+pub enum ErrorBarDirection {+    X,+    #[default]+    Y,+}++/// Which sides of the point error bars extend to (`c:errBarType`).+#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]+pub enum ErrorBarSides {+    #[default]+    Both,+    Plus,+    Minus,+}++/// How long error bars are (`c:errValType` with `c:val`, `c:plus`, `c:minus`).+#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]+pub enum ErrorAmount {+    /// The same amount for every point.+    Fixed(f64),+    /// This percentage of each point's value.+    Percentage(f64),+    /// This many standard deviations of the series, centred on the series mean.+    StdDev(f64),+    /// The standard error of the series.+    #[default]+    StdErr,+    /// Per-point amounts from references (`Sheet1!$C$2:$C$9`) or array constants (`{1,2}`).+    Custom { plus: Option<String>, minus: Option<String> },+}++/// A series' error bars (Add Chart Element ▸ Error Bars, Format Error Bars pane).+#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]+pub struct ErrorBars {+    pub direction: ErrorBarDirection,+    pub sides: ErrorBarSides,+    pub amount: ErrorAmount,+    /// End Style: No Cap.+    pub no_end_cap: bool,+    /// Line colour; `None` = automatic (dark grey).+    pub color: Option<Color>,+    /// Line width in points; `None` = default.+    pub width: Option<f32>,+    /// Line dash; `None` = solid.+    pub dash: Option<LineDash>, }  /// Regression a trendline fits (`c:trendlineType`).diff --git a/crates/xlsx/src/chart.rs b/crates/xlsx/src/chart.rs--- a/crates/xlsx/src/chart.rs+++ b/crates/xlsx/src/chart.rs@@ -5,7 +5,10 @@ use std::fmt::Write as _;  use gridcraft_core::{RangeRef, Value, category_levels}; use gridcraft_formula::{Expr, SheetSel};-use gridcraft_model::{Chart, ChartKind, Color, DefinedName, LegendPos, LineDash, Series, Trendline, TrendlineKind, Workbook};+use gridcraft_model::{+    Chart, ChartKind, Color, DefinedName, ErrorAmount, ErrorBarDirection, ErrorBarSides, ErrorBars, LegendPos, LineDash, Series, Trendline,+    TrendlineKind, Workbook,+};  use crate::drawing::{dml_color, srgb}; use crate::xml::{El, esc, num};@@ -101,6 +104,9 @@ pub(crate) fn chart_ref_from_file(text: &str) -> String {     out } +/// Most points kept of a literal list in a file (hostile `ptCount` and `idx` are cut here).+const MAX_LITERAL: usize = 65_536;+ fn data_ref(e: Option<&El>) -> Option<String> {     let e = e?;     for k in ["numRef", "strRef", "multiLvlStrRef"] {@@ -108,14 +114,29 @@ fn data_ref(e: Option<&El>) -> Option<String> {             return Some(chart_ref_from_file(&f.text));         }     }-    // Literal values become an array constant.+    // Literal values become an array constant. Points keep their `idx` and the list its `ptCount`: a gap (a point the file leaves+    // out) is written `""`, which reads back as no value and is skipped again on save, so later points stay on their own index.     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();+            let mut pts: Vec<Option<String>> = Vec::new();+            for (pos, p) in l.kids("pt").enumerate() {+                let Some(v) = p.child("v") else { continue };+                let idx = p.attr("idx").and_then(|i| i.trim().parse::<usize>().ok()).unwrap_or(pos);+                if idx >= MAX_LITERAL {+                    continue;+                }+                if pts.len() <= idx {+                    pts.resize(idx + 1, None);+                }+                if let Some(slot) = pts.get_mut(idx) {+                    *slot = Some(if k == "numLit" { v.text.trim().to_string() } else { format!("\"{}\"", v.text.replace('"', "\"\"")) });+                }+            }+            let count = l.child("ptCount").and_then(|c| c.attr("val")).and_then(|c| c.trim().parse::<usize>().ok()).unwrap_or(0);+            if pts.len() < count.min(MAX_LITERAL) {+                pts.resize(count.min(MAX_LITERAL), None);+            }+            let pts: Vec<String> = pts.into_iter().map(|v| v.unwrap_or_else(|| "\"\"".into())).collect();             return Some(format!("{{{}}}", pts.join(",")));         }     }@@ -143,6 +164,7 @@ fn read_series(s: &El) -> Series {         quartiles: Default::default(),         smooth: bool_val(s, "smooth"),         trendlines: s.kids("trendline").filter_map(read_trendline).collect(),+        error_bars: read_series_error_bars(s),     } } @@ -182,7 +204,7 @@ fn read_trendline(t: &El) -> Option<Trendline> {         "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"]);+    let (color, width, dash) = read_ln(t);     Some(Trendline {         kind,         name: t.child("name").map(|n| n.text.clone()),@@ -191,9 +213,63 @@ fn read_trendline(t: &El) -> Option<Trendline> {         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)),+        color,+        width,+        dash,+    })+}++/// Colour, width (points) and preset dash of an element's `c:spPr/a:ln`.+fn read_ln(e: &El) -> (Option<Color>, Option<f32>, Option<LineDash>) {+    let ln = e.path(&["spPr", "ln"]);+    (+        ln.and_then(|l| l.child("solidFill")).and_then(dml_color),+        ln.and_then(|l| l.attr_f64("w")).map(|w| (w / EMU_PER_PT) as f32).filter(|w| w.is_finite() && *w >= 0.0),+        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)),+    )+}++/// A series' `<c:errBars>`: at most one set per direction (the first wins); unknown value+/// types are dropped.+fn read_series_error_bars(s: &El) -> Vec<ErrorBars> {+    let mut out: Vec<ErrorBars> = Vec::new();+    for e in s.kids("errBars").filter_map(read_error_bars) {+        if !out.iter().any(|o| o.direction == e.direction) {+            out.push(e);+        }+    }+    out+}++/// `c:val` when a file leaves it out.+const DEFAULT_FIXED: f64 = 1.0;+const DEFAULT_PERCENT: f64 = 5.0;+const DEFAULT_STD_DEV: f64 = 1.0;++/// `<c:errBars>` (ECMA-376 Part 1, 21.2.2.55).+fn read_error_bars(e: &El) -> Option<ErrorBars> {+    let val = f64_val(e, "val").filter(|v| v.is_finite());+    let amount = match e.child_val("errValType").unwrap_or("fixedVal") {+        "fixedVal" => ErrorAmount::Fixed(val.unwrap_or(DEFAULT_FIXED)),+        "percentage" => ErrorAmount::Percentage(val.unwrap_or(DEFAULT_PERCENT)),+        "stdDev" => ErrorAmount::StdDev(val.unwrap_or(DEFAULT_STD_DEV)),+        "stdErr" => ErrorAmount::StdErr,+        "cust" => ErrorAmount::Custom { plus: data_ref(e.child("plus")), minus: data_ref(e.child("minus")) },+        _ => return None,+    };+    let (color, width, dash) = read_ln(e);+    Some(ErrorBars {+        direction: if e.child_val("errDir") == Some("x") { ErrorBarDirection::X } else { ErrorBarDirection::Y },+        sides: match e.child_val("errBarType") {+            Some("plus") => ErrorBarSides::Plus,+            Some("minus") => ErrorBarSides::Minus,+            _ => ErrorBarSides::Both,+        },+        amount,+        no_end_cap: bool_val(e, "noEndCap"),+        color,+        width,+        dash,     }) } @@ -562,6 +638,7 @@ fn series_xml(s: &Series, idx: usize, g: G, wb: &Workbook, si: usize) -> String         for t in &s.trendlines {             x.push_str(&trendline_xml(t, wb));         }+        x.push_str(&series_error_bars_xml(s, g, wb, si));     }     match g {         G::Scatter { .. } | G::Bubble => {@@ -597,22 +674,7 @@ fn trendline_xml(t: &Trendline, wb: &Workbook) -> String {     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>");-    }+    x.push_str(&ln_xml(t.color.as_ref(), t.width, t.dash, wb));     let ty = match t.kind {         TrendlineKind::Linear => "linear",         TrendlineKind::Exponential => "exp",@@ -647,6 +709,96 @@ fn trendline_xml(t: &Trendline, wb: &Workbook) -> String {     x } +/// DrawingML scheme colour names by `Color::Theme` index.+const SCHEME_NAMES: [&str; 12] = ["bg1", "tx1", "bg2", "tx2", "accent1", "accent2", "accent3", "accent4", "accent5", "accent6", "hlink", "folHlink"];++/// `<c:spPr><a:ln>` with the given colour, width and dash; empty when all are automatic.+fn ln_xml(color: Option<&Color>, width: Option<f32>, dash: Option<LineDash>, wb: &Workbook) -> String {+    // A plain theme colour keeps its scheme name, so a later theme change still reaches the line.+    let color = color.and_then(|c| match c {+        Color::Theme(i, 0) => SCHEME_NAMES.get(usize::from(*i)).map(|n| format!("<a:schemeClr val=\"{n}\"/>")),+        _ => srgb(c, &wb.theme),+    });+    if color.is_none() && width.is_none() && dash.is_none() {+        return String::new();+    }+    let mut x = String::from("<c:spPr><a:ln");+    if let Some(w) = 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) = 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>");+    x+}++/// The series' error bars as its chart group allows them: CT_BarSer and CT_LineSer (also+/// stock) hold one set, in Y; CT_AreaSer, CT_ScatterSer and CT_BubbleSer one per direction.+fn series_error_bars_xml(s: &Series, g: G, wb: &Workbook, si: usize) -> String {+    let two = matches!(g, G::Area { .. } | G::Scatter { .. } | G::Bubble);+    let mut x = String::new();+    let mut seen: Vec<ErrorBarDirection> = Vec::new();+    for e in &s.error_bars {+        if seen.contains(&e.direction) || (!two && (e.direction == ErrorBarDirection::X || !seen.is_empty())) {+            continue;+        }+        if let ErrorAmount::Custom { plus, minus } = &e.amount+            && !plus.iter().chain(minus).any(|f| custom_amount_ok(f))+        {+            continue;+        }+        seen.push(e.direction);+        x.push_str(&error_bars_xml(e, matches!(g, G::Bar { .. }), wb, si));+    }+    x+}++/// Whether a custom error amount can be written: a reference, a defined name or an array+/// constant.+fn custom_amount_ok(f: &str) -> bool {+    let f = f.trim();+    (f.starts_with('{') && f.ends_with('}')) || matches!(gridcraft_formula::parse(f), Ok(Expr::Ref(_) | Expr::Name(_)))+}++/// `<c:errBars>` with children in schema order: errDir, errBarType, errValType, noEndCap,+/// plus, minus, val, spPr. Column and bar series leave errDir out, as Excel writes them.+fn error_bars_xml(e: &ErrorBars, bar: bool, wb: &Workbook, si: usize) -> String {+    let dir = if e.direction == ErrorBarDirection::X { "x" } else { "y" };+    let dir = if bar { String::new() } else { format!("<c:errDir val=\"{dir}\"/>") };+    let sides = match e.sides {+        ErrorBarSides::Both => "both",+        ErrorBarSides::Plus => "plus",+        ErrorBarSides::Minus => "minus",+    };+    let (ty, val) = match &e.amount {+        ErrorAmount::Fixed(v) => ("fixedVal", Some(*v)),+        ErrorAmount::Percentage(v) => ("percentage", Some(*v)),+        ErrorAmount::StdDev(v) => ("stdDev", Some(*v)),+        ErrorAmount::StdErr => ("stdErr", None),+        ErrorAmount::Custom { .. } => ("cust", None),+    };+    let mut x = format!("<c:errBars>{dir}<c:errBarType val=\"{sides}\"/><c:errValType val=\"{ty}\"/><c:noEndCap val=\"{}\"/>", e.no_end_cap as u8);+    if let ErrorAmount::Custom { plus, minus } = &e.amount {+        for (tag, f) in [("plus", plus), ("minus", minus)] {+            if let Some(f) = f.as_deref().filter(|f| custom_amount_ok(f)) {+                x.push_str(&num_data(tag, Some(f), wb, si));+            }+        }+    }+    if let Some(v) = val.filter(|v| v.is_finite()) {+        let _ = write!(x, "<c:val val=\"{}\"/>", num(v));+    }+    x.push_str(&ln_xml(e.color.as_ref(), e.width, e.dash, wb));+    x.push_str("</c:errBars>");+    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>",diff --git a/crates/xlsx/src/tests.rs b/crates/xlsx/src/tests.rs--- a/crates/xlsx/src/tests.rs+++ b/crates/xlsx/src/tests.rs@@ -8,6 +8,7 @@ mod chart_combo_kinds; mod chart_ref_offsets; mod csv_tests; mod encrypted_tests;+mod errbars; mod fixtures; mod ink_roundtrip; mod literal_category_roundtrip;diff --git a/crates/xlsx/src/tests/chart_book_names.rs b/crates/xlsx/src/tests/chart_book_names.rs--- a/crates/xlsx/src/tests/chart_book_names.rs+++ b/crates/xlsx/src/tests/chart_book_names.rs@@ -46,6 +46,7 @@ fn series(name: &str, values: &str) -> Series {         kind: None,         smooth: false,         trendlines: Vec::new(),+        error_bars: Vec::new(),         quartiles: Default::default(),     } }diff --git a/crates/xlsx/src/tests/errbars.rs b/crates/xlsx/src/tests/errbars.rsnew file mode 100644--- /dev/null+++ b/crates/xlsx/src/tests/errbars.rs@@ -0,0 +1,272 @@+//! Chart error bars (`<c:errBars>`) round-trip through XLSX in every chart group that has them.++use std::io::Read;+use std::sync::Arc;++use gridcraft_core::{CellRef, Value};+use gridcraft_model::{+    Anchor, Chart, ChartKind, Color, ErrorAmount, ErrorBarDirection, ErrorBarSides, ErrorBars, LegendPos, LineDash, Series, Sheet, Trendline,+    Workbook,+};++use crate::{read_xlsx, write_xlsx};++fn y_bars() -> ErrorBars {+    ErrorBars {+        direction: ErrorBarDirection::Y,+        sides: ErrorBarSides::Plus,+        amount: ErrorAmount::Custom { plus: Some("Data!$C$2:$C$6".into()), minus: Some("{0.5,1,1.5,2,2.5}".into()) },+        no_end_cap: true,+        color: Some(Color::Rgb(0xC00000)),+        width: Some(1.5),+        dash: Some(LineDash::Dash),+    }+}++fn x_bars() -> ErrorBars {+    ErrorBars { direction: ErrorBarDirection::X, sides: ErrorBarSides::Minus, amount: ErrorAmount::StdDev(2.0), ..ErrorBars::default() }+}++/// Every amount type, each in Y.+fn amounts() -> Vec<ErrorAmount> {+    vec![+        ErrorAmount::Fixed(1.5),+        ErrorAmount::Percentage(10.0),+        ErrorAmount::StdDev(1.0),+        ErrorAmount::StdErr,+        ErrorAmount::Custom { plus: Some("Data!$C$2:$C$6".into()), minus: None },+    ]+}++fn chart(id: u32, kind: ChartKind, error_bars: Vec<ErrorBars>) -> Chart {+    Chart {+        id,+        kind,+        anchor: Anchor { cell: CellRef::new(id * 16, 3), width: 480.0, height: 300.0, ..Anchor::default() },+        title: None,+        series: vec![Series {+            name: Some("Data!$B$1".into()),+            categories: Some("Data!$A$2:$A$6".into()),+            values: "Data!$B$2:$B$6".into(),+            color: Some(Color::Rgb(0x4472C4)),+            trendlines: vec![Trendline::default()],+            error_bars,+            ..Series::default()+        }],+        legend: LegendPos::Bottom,+        data_labels: false,+        gridlines: true,+        style: 2,+        x_title: None,+        y_title: None,+        source: None,+        by_rows: false,+        pivot: None,+    }+}++/// Chart groups whose series hold one set of error bars (CT_BarSer, CT_LineSer) and those that+/// hold one per direction (CT_AreaSer, CT_ScatterSer, CT_BubbleSer).+const ONE: [ChartKind; 6] =+    [ChartKind::ColumnClustered, ChartKind::ColumnStacked, ChartKind::BarClustered, ChartKind::Line, ChartKind::LineMarkers, ChartKind::LineStacked];+const TWO: [ChartKind; 5] = [ChartKind::Area, ChartKind::AreaStacked, ChartKind::Scatter, ChartKind::ScatterLines, ChartKind::Bubble];++fn data_sheet() -> Sheet {+    let mut s = Sheet::new("Data");+    s.set_value(CellRef::new(0, 1), Value::Text("Sales".into()));+    for r in 1..=5u32 {+        s.set_value(CellRef::new(r, 0), Value::Number(f64::from(r)));+        s.set_value(CellRef::new(r, 1), Value::Number(f64::from(r * r)));+        s.set_value(CellRef::new(r, 2), Value::Number(f64::from(r) / 4.0));+    }+    s+}++fn workbook(charts: Vec<Chart>) -> Workbook {+    let mut s = data_sheet();+    s.charts = charts;+    Workbook { sheets: vec![Arc::new(s)], ..Workbook::default() }+}++fn chart_parts(bytes: &[u8]) -> Vec<String> {+    let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes.to_vec())).unwrap();+    let mut names: Vec<String> =+        (0..z.len()).map(|i| z.name_for_index(i).unwrap().to_string()).filter(|n| n.starts_with("xl/charts/chart")).collect();+    // chart2.xml before chart10.xml.+    names.sort_by_key(|n| n.trim_start_matches("xl/charts/chart").trim_end_matches(".xml").parse::<u32>().unwrap_or(0));+    names+        .iter()+        .map(|n| {+            let mut s = String::new();+            z.by_name(n).unwrap().read_to_string(&mut s).unwrap();+            s+        })+        .collect()+}++#[test]+fn every_amount_type_round_trips_in_every_chart_group() {+    let mut charts = Vec::new();+    let mut want = Vec::new();+    for k in ONE.iter().chain(TWO.iter()) {+        for a in amounts() {+            let e = ErrorBars { amount: a, ..ErrorBars::default() };+            charts.push(chart(charts.len() as u32 + 1, *k, vec![e.clone()]));+            want.push((*k, vec![e]));+        }+        // Every option at once.+        charts.push(chart(charts.len() as u32 + 1, *k, vec![y_bars()]));+        want.push((*k, vec![y_bars()]));+    }+    for k in TWO {+        // X and Y together.+        charts.push(chart(charts.len() as u32 + 1, k, vec![x_bars(), y_bars()]));+        want.push((k, vec![x_bars(), y_bars()]));+    }+    let bytes = write_xlsx(&workbook(charts)).unwrap();+    let (back, _rep) = read_xlsx(&bytes).unwrap();+    let got = &back.sheets[0].charts;+    assert_eq!(got.len(), want.len());+    for (c, (k, bars)) in got.iter().zip(&want) {+        assert_eq!(c.kind, *k);+        assert_eq!(&c.series[0].error_bars, bars, "{k:?}");+        assert_eq!(c.series[0].trendlines.len(), 1, "{k:?}: the trendline is kept beside the error bars");+    }+    // A second save writes the same parts (nothing drifts on the way round).+    assert_eq!(chart_parts(&write_xlsx(&back).unwrap()), chart_parts(&bytes));+}++#[test]+fn error_bars_follow_the_series_schema_sequence() {+    let mut charts: Vec<Chart> = ONE.iter().map(|k| chart(0, *k, vec![y_bars()])).collect();+    charts.extend(TWO.iter().map(|k| chart(0, *k, vec![x_bars(), y_bars()])));+    for (i, c) in charts.iter_mut().enumerate() {+        c.id = i as u32 + 1;+    }+    let kinds: Vec<ChartKind> = charts.iter().map(|c| c.kind).collect();+    let parts = chart_parts(&write_xlsx(&workbook(charts)).unwrap());+    assert_eq!(parts.len(), kinds.len());+    for (xml, k) in parts.iter().zip(&kinds) {+        let at = |tag: &str| xml.find(tag).unwrap_or_else(|| panic!("{k:?}: no {tag}"));+        let first = at("<c:errBars>");+        // After the trendlines, before the data references.+        let data = if matches!(k, ChartKind::Scatter | ChartKind::ScatterLines | ChartKind::Bubble) { "<c:xVal>" } else { "<c:cat>" };+        assert!(at("</c:trendline>") < first && first < at(data), "{k:?}");+        let n = if TWO.contains(k) { 2 } else { 1 };+        assert_eq!(xml.matches("<c:errBars>").count(), n, "{k:?}");+    }+    // Column and bar series leave errDir out, as Excel writes them; the others name it.+    assert!(!parts[0].contains("<c:errDir") && !parts[2].contains("<c:errDir"));+    assert_eq!(kinds[3], ChartKind::Line);+    // Children in CT_ErrBars order.+    let xml = &parts[3];+    let e = &xml[xml.find("<c:errBars>").unwrap()..];+    let e = &e[..e.find("</c:errBars>").unwrap()];+    let pos: Vec<usize> = [+        "<c:errDir val=\"y\"/>",+        "<c:errBarType val=\"plus\"/>",+        "<c:errValType val=\"cust\"/>",+        "<c:noEndCap val=\"1\"/>",+        "<c:plus><c:numRef><c:f>Data!$C$2:$C$6</c:f><c:numCache>",+        "<c:minus><c:numLit>",+        "<c:spPr><a:ln w=\"19050\"",+        "<a:prstDash val=\"dash\"/>",+    ]+    .iter()+    .map(|t| e.find(t).unwrap_or_else(|| panic!("missing {t} in {e}")))+    .collect();+    assert!(pos.windows(2).all(|w| w[0] < w[1]), "{e}");+    // A value amount writes c:val after the type.+    let parts = chart_parts(+        &write_xlsx(&workbook(vec![chart(1, ChartKind::Line, vec![ErrorBars { amount: ErrorAmount::Percentage(7.5), ..ErrorBars::default() }])]))+            .unwrap(),+    );+    assert!(parts[0].contains("<c:errValType val=\"percentage\"/><c:noEndCap val=\"0\"/><c:val val=\"7.5\"/></c:errBars>"), "{}", parts[0]);+}++#[test]+fn groups_without_error_bars_write_none_and_one_set_groups_keep_the_y_bars() {+    let charts = vec![+        chart(1, ChartKind::Pie, vec![y_bars()]),+        chart(2, ChartKind::Radar, vec![y_bars()]),+        // A column series holds only Y bars: X bars are left out, the Y bars kept.+        chart(3, ChartKind::ColumnClustered, vec![x_bars(), y_bars()]),+    ];+    let bytes = write_xlsx(&workbook(charts)).unwrap();+    let parts = chart_parts(&bytes);+    assert!(!parts[0].contains("errBars") && !parts[1].contains("errBars"));+    assert_eq!(parts[2].matches("<c:errBars>").count(), 1);+    let (back, _) = read_xlsx(&bytes).unwrap();+    assert_eq!(back.sheets[0].charts[2].series[0].error_bars, vec![y_bars()]);+}++#[test]+fn excel_style_error_bars_are_read() {+    // As Excel writes them: theme-coloured line, an empty noEndCap (true), a custom amount with a+    // literal minus, a value type left out (fixed) with no c:val (1), an unknown type that is+    // skipped and a second set in a direction already taken that is dropped.+    let xml = r#"<c:chartSpace xmlns:c="http://schemas.openxmlformats.org/drawingml/2006/chart" xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main"><c:chart><c:plotArea><c:scatterChart><c:scatterStyle val="lineMarker"/><c:ser><c:idx val="0"/><c:order val="0"/>+<c:errBars><c:errDir val="x"/><c:errBarType val="minus"/><c:errValType val="cust"/><c:noEndCap/><c:plus><c:numRef><c:f>=Data!$C$2:$C$6</c:f></c:numRef></c:plus><c:minus><c:numLit><c:formatCode>General</c:formatCode><c:ptCount val="2"/><c:pt idx="0"><c:v>0.5</c:v></c:pt><c:pt idx="1"><c:v>2</c:v></c:pt></c:numLit></c:minus><c:spPr><a:ln w="12700" cap="flat"><a:solidFill><a:schemeClr val="tx1"><a:lumMod val="65000"/><a:lumOff val="35000"/></a:schemeClr></a:solidFill><a:round/></a:ln></c:spPr></c:errBars>+<c:errBars><c:errDir val="y"/><c:errBarType val="both"/><c:errValType val="fancy"/></c:errBars>+<c:errBars><c:errBarType val="plus"/></c:errBars>+<c:errBars><c:errDir val="y"/><c:errValType val="stdErr"/></c:errBars>+<c:xVal><c:numRef><c:f>Data!$A$2:$A$6</c:f></c:numRef></c:xVal><c:yVal><c:numRef><c:f>Data!$B$2:$B$6</c:f></c:numRef></c:yVal></c:ser><c:axId val="1"/><c:axId val="2"/></c:scatterChart></c:plotArea></c:chart></c:chartSpace>"#;+    let root = crate::xml::parse(xml.as_bytes()).unwrap();+    let c = crate::chart::read_chart(&root).unwrap();+    let e = &c.series[0].error_bars;+    assert_eq!(e.len(), 2, "{e:?}");+    assert_eq!(e[0].direction, ErrorBarDirection::X);+    assert_eq!(e[0].sides, ErrorBarSides::Minus);+    assert!(e[0].no_end_cap, "an empty CT_Boolean means true");+    assert_eq!(e[0].amount, ErrorAmount::Custom { plus: Some("Data!$C$2:$C$6".into()), minus: Some("{0.5,2}".into()) });+    assert_eq!(e[0].width, Some(1.0));+    assert!(matches!(e[0].color, Some(Color::Theme(..))), "{:?}", e[0].color);+    // The direction defaults to Y and the type to a fixed value of 1; the later Y set is dropped.+    assert_eq!((e[1].direction, e[1].sides, e[1].amount.clone()), (ErrorBarDirection::Y, ErrorBarSides::Plus, ErrorAmount::Fixed(1.0)));+}++#[test]+fn hostile_error_bar_values_do_not_break_the_file() {+    let bars = vec![+        ErrorBars { amount: ErrorAmount::Fixed(f64::NAN), ..ErrorBars::default() },+        ErrorBars { direction: ErrorBarDirection::X, amount: ErrorAmount::Percentage(f64::INFINITY), width: Some(f32::NAN), ..ErrorBars::default() },+    ];+    let custom =+        vec![ErrorBars { amount: ErrorAmount::Custom { plus: Some("   ".into()), minus: Some("not a ref".into()) }, ..ErrorBars::default() }];+    let wb = workbook(vec![chart(1, ChartKind::Scatter, bars), chart(2, ChartKind::Line, custom)]);+    let bytes = write_xlsx(&wb).unwrap();+    let parts = chart_parts(&bytes);+    assert!(!parts[0].contains("NaN") && !parts[0].contains("inf"), "{}", parts[0]);+    assert!(!parts[1].contains("errBars"), "custom bars with nothing writable are left out: {}", parts[1]);+    let (back, _) = read_xlsx(&bytes).unwrap();+    assert_eq!(back.sheets[0].charts.len(), 2);+}++#[test]+fn sparse_literal_points_keep_their_index_through_read_and_save() {+    // Custom amounts for points 0 and 2 of 4 (1 and 3 left out), and categories with a hole at 1.+    let xml = r#"<c:chartSpace xmlns:c="http://schemas.openxmlformats.org/drawingml/2006/chart"><c:chart><c:plotArea><c:lineChart><c:ser><c:idx val="0"/><c:order val="0"/>+<c:errBars><c:errDir val="y"/><c:errBarType val="plus"/><c:errValType val="cust"/><c:plus><c:numLit><c:formatCode>General</c:formatCode><c:ptCount val="4"/><c:pt idx="0"><c:v>5</c:v></c:pt><c:pt idx="2"><c:v>7</c:v></c:pt></c:numLit></c:plus></c:errBars>+<c:cat><c:strLit><c:ptCount val="3"/><c:pt idx="0"><c:v>a</c:v></c:pt><c:pt idx="2"><c:v>c</c:v></c:pt></c:strLit></c:cat>+<c:val><c:numRef><c:f>Data!$B$2:$B$6</c:f></c:numRef></c:val></c:ser><c:axId val="1"/><c:axId val="2"/></c:lineChart></c:plotArea></c:chart></c:chartSpace>"#;+    let c = crate::chart::read_chart(&crate::xml::parse(xml.as_bytes()).unwrap()).unwrap();+    let s = &c.series[0];+    assert_eq!(s.error_bars[0].amount, ErrorAmount::Custom { plus: Some(r#"{5,"",7,""}"#.into()), minus: None });+    assert_eq!(s.categories.as_deref(), Some(r#"{"a","","c"}"#));+    // Saving writes the points back at their own indices under the same ptCount.+    let wb = workbook(vec![Chart { series: vec![s.clone()], ..chart(1, ChartKind::Line, vec![]) }]);+    let part = chart_parts(&write_xlsx(&wb).unwrap()).remove(0);+    assert!(part.contains(r#"<c:ptCount val="4"/><c:pt idx="0"><c:v>5</c:v></c:pt><c:pt idx="2"><c:v>7</c:v></c:pt></c:numLit>"#), "{part}");+    let (back, _) = read_xlsx(&write_xlsx(&wb).unwrap()).unwrap();+    assert_eq!(back.sheets[0].charts[0].series[0].error_bars, s.error_bars);+}++#[test]+fn a_theme_coloured_bar_line_keeps_its_scheme_name() {+    let e = ErrorBars { color: Some(Color::Theme(4, 0)), ..ErrorBars::default() };+    let wb = workbook(vec![chart(1, ChartKind::Line, vec![e.clone()])]);+    let bytes = write_xlsx(&wb).unwrap();+    assert!(chart_parts(&bytes)[0].contains(r#"<a:schemeClr val="accent1"/>"#));+    let (back, _) = read_xlsx(&bytes).unwrap();+    assert_eq!(back.sheets[0].charts[0].series[0].error_bars, vec![e]);+}diff --git a/crates/xlsx/src/tests/literal_category_roundtrip.rs b/crates/xlsx/src/tests/literal_category_roundtrip.rs--- a/crates/xlsx/src/tests/literal_category_roundtrip.rs+++ b/crates/xlsx/src/tests/literal_category_roundtrip.rs@@ -31,6 +31,7 @@ fn literal_category_with_separators_survives_save() {             quartiles: Default::default(),             smooth: false,             trendlines: Vec::new(),+            error_bars: Vec::new(),         })         .collect();     s.charts.push(Chart {diff --git a/crates/xlsx/src/tests/multilevel_category_roundtrip.rs b/crates/xlsx/src/tests/multilevel_category_roundtrip.rs--- a/crates/xlsx/src/tests/multilevel_category_roundtrip.rs+++ b/crates/xlsx/src/tests/multilevel_category_roundtrip.rs@@ -37,6 +37,7 @@ fn workbook(categories: &str, values: &str) -> Workbook {             kind: None,             smooth: false,             trendlines: Vec::new(),+            error_bars: Vec::new(),             quartiles: Default::default(),         }],         legend: LegendPos::Bottom,diff --git a/crates/xlsx/src/tests/roundtrip.rs b/crates/xlsx/src/tests/roundtrip.rs--- a/crates/xlsx/src/tests/roundtrip.rs+++ b/crates/xlsx/src/tests/roundtrip.rs@@ -404,6 +404,7 @@ fn sample() -> Workbook {             quartiles: Default::default(),             smooth: false,             trendlines: Vec::new(),+            error_bars: Vec::new(),         }],         legend: LegendPos::Bottom,         data_labels: true,diff --git a/crates/xlsx/src/tests/smooth_roundtrip.rs b/crates/xlsx/src/tests/smooth_roundtrip.rs--- a/crates/xlsx/src/tests/smooth_roundtrip.rs+++ b/crates/xlsx/src/tests/smooth_roundtrip.rs@@ -20,6 +20,7 @@ fn smooth_line_setting_round_trips() {         quartiles: Default::default(),         smooth: false,         trendlines: Vec::new(),+        error_bars: Vec::new(),     };     s.charts.push(Chart {         id: 1,diff --git a/crates/xlsx/src/tests/trendline_roundtrip.rs b/crates/xlsx/src/tests/trendline_roundtrip.rs--- a/crates/xlsx/src/tests/trendline_roundtrip.rs+++ b/crates/xlsx/src/tests/trendline_roundtrip.rs@@ -50,6 +50,7 @@ fn chart(id: u32, kind: ChartKind, trendlines: Vec<Trendline>) -> Chart {             quartiles: Default::default(),             smooth: false,             trendlines,+            error_bars: Vec::new(),         }],         legend: LegendPos::Bottom,         data_labels: false,diff --git a/docs/chart-parity.md b/docs/chart-parity.md--- a/docs/chart-parity.md+++ b/docs/chart-parity.md@@ -1,6 +1,6 @@ # Chart parity -> **Last reviewed:** 2026-10-18 · **Last updated:** 2026-10-24 · **Change:** minor (100% stacked area charts) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4+> **Last reviewed:** 2026-10-18 · **Last updated:** 2026-10-24 · **Change:** minor (error bars: model, XLSX round-trip, drawing, Add Chart Element; 100% stacked area charts) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4  Excel's chart types and chart elements against GridCraft. Part of [target-app-parity.md](target-app-parity.md). **Breadth ~50%, ready ~30%, 40–60 h** (estimated).@@ -46,7 +46,7 @@ Sources: `crates/model/src/features.rs` (`ChartKind`, `Chart`, `Series`), `crate | Multi-level category axis (a category range two or more columns wide beside the values: year beside month) | the outer levels grouped under the labels with separators | read (`multiLvlStrRef`), drawn (rows under the labels; columns beside them in bar charts; up to three outer levels), written back with Excel's level caches | | Secondary axis | any series | line series in combo charts | | Trendlines | linear, exponential, logarithmic, polynomial, power, moving average, equation and R² | all six types, several per series, name, forward/backward forecast, set intercept, equation and R² label, line colour/width/dash; read, written and drawn on 2-D column, bar, line, area, scatter and bubble series; `chart.addElement` `trendline` adds/removes them. No Format Trendline pane, label position/number format not kept |-| Error bars | fixed, %, standard deviation/error, custom | **missing** |+| Error bars | fixed, %, standard deviation/error, custom | fixed value, percentage, standard deviation (centred on the series mean, as Excel draws it), standard error (sample deviation / √n at each point) and custom plus/minus references or arrays; X and Y, plus/minus/both, cap or no cap, line colour/width/dash; read and written as `c:errBars`, drawn on 2-D column, bar, line, area (stacked too), scatter and bubble series, the value axis grows to hold them; `chart.addElement` `errorBars` adds, replaces and removes them. No Format Error Bars pane; 100% stacked series keep theirs in the file but don't draw them | | Up/down bars, high-low lines, drop lines, series lines | yes | **missing** | | Data table under the chart | yes | **missing** | | Per-series / per-point fill, line, marker, effects | Format pane for every element | series colour only; Format Chart pane covers chart-level options |@@ -64,7 +64,7 @@ Sources: `crates/model/src/features.rs` (`ChartKind`, `Chart`, `Series`), `crate ## XLSX round-trip  Charts are read from and written to DrawingML chart parts, but only the fields the model holds-survive: anything else (axis scaling, label options, error bars, per-point formats, chartex+survive: anything else (axis scaling, label options, per-point formats, chartex extensions) is lost when GridCraft saves a workbook it opened. That is the main reason charts are **~30% ready**: a user who opens an Excel workbook with charts and saves it gets simpler charts back.@@ -81,7 +81,7 @@ are (#1686). Such series get no `numCache`/`strCache` on save (#1690). 1. Chart resizing (#190): 1–2 h. 2. Preserve unmodelled chart XML on round-trip (keep the original part, patch only edited fields): 6–10 h. 3. Axis options, label options, gridlines per axis, legend formatting: 10–15 h.-4. Error bars; Format Trendline pane (trendlines landed): 4–6 h.+4. Format Trendline and Format Error Bars panes (trendlines and error bars landed): 3–4 h. 5. Per-element Format pane parity, smoothed lines, exploded pie, pie/bar of pie, 100% variants: 8–12 h. 6. 3-D column/bar/pie/area, surface and contour, Pareto: 8–12 h. 7. Chart sheets, templates and PivotChart field buttons: 5–8 h. Filled map: owner decision on boundary data.@@ -94,6 +94,7 @@ are (#1686). Such series get no `numCache`/`strCache` on save (#1690). | 2026-10-18 | trivial | Stale notes: stock/combo, treemap/sunburst, box and waterfall kinds survive XLSX round-trip (PRs #158, #161) | | 2026-10-16 | trivial | Series that refer to defined names (`[0]!Name`, `Sheet!Name`) resolve instead of showing `#NAME?`, and are saved qualified, as Excel needs (#1686) | | 2026-10-16 | minor | Box & whisker quartiles default to exclusive, honor each series on draw and XLSX round-trip; `chart.set {quartiles}` changes the method (#1453) |+| 2026-10-14 | minor | Error bars: all five amount types modelled, round-tripped through `c:errBars`, drawn on 2-D column, bar, line, area, scatter and bubble series; `chart.addElement` `errorBars` | | 2026-10-13 | minor | Multi-level category axes: read, drawn (outer levels under or beside the labels), written back with level caches | | 2026-10-12 | trivial | chart sheets are kept on save, still not editable (#727) | | 2026-10-11 | minor | Trendlines: all six types modelled, round-tripped through `c:trendline`, drawn with forecast and equation/R² labels; `chart.addElement` `trendline` |diff --git a/docs/gaps.md b/docs/gaps.md--- a/docs/gaps.md+++ b/docs/gaps.md@@ -1,6 +1,6 @@ # Gaps -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-24 · **Change:** minor (§8: PivotTable Enable show details; earlier: PivotTable Show Details) · **Target:** Microsoft Excel (Microsoft 365)+> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-24 · **Change:** minor (§8: PivotTable Enable show details; earlier: PivotTable Show Details; §4: chart error bars landed) · **Target:** Microsoft Excel (Microsoft 365)  Every known shortfall against Excel, one entry each, ranked by user impact (what blocks someone using GridCraft for real work first). This is the work list: pick from the top, check the open@@ -43,12 +43,9 @@ No alpha blockers: every core workflow passes the [alpha gate](roadmap.md#alpha- - **Estimate:** 6–10 h. [file-format-parity.md](file-format-parity.md)  ### 4. Charts lose options on round trip; shallow chart model (F, FF)-- **Missing:** axis scaling, label options, error bars, per-point formatting.+- **Missing:** axis scaling, label options, per-point formatting. - **Evidence:** `Chart` holds only title, legend position, two flags and axis titles-  (`crates/model/src/features.rs`); PRs #158, #161 open; charts can't be resized (#190). Smooth lines (#62), Switch Row/Column (#116) and trendlines (read, written, drawn) landed. Multi-level category axes (year beside month) are read, drawn and written back.-  (`crates/model/src/features.rs`); all 2-D kinds survive XLSX round trip (#158, #161); charts-  can't be resized (#190). Smooth lines (#62), Switch Row/Column (#116) and trendlines (read,-  written, drawn) landed.+  (`crates/model/src/features.rs`); PRs #158, #161 merged; charts can't be resized (#190). Smooth lines (#62), Switch Row/Column (#116), trendlines and error bars (read, written, drawn) landed. - **Impact:** charts are in most business workbooks. - **Estimate:** 25–35 h for beta (40–60 h full). [chart-parity.md](chart-parity.md) @@ -190,6 +187,7 @@ No alpha blockers: every core workflow passes the [alpha gate](roadmap.md#alpha- | 2026-10-17 | minor | §6: the five reports it tracked are closed and their PRs merged (#57, #133, #139, #140, #134; PRs #148, #155, #156, #157); the entry stays open for §15's oracle suite | | 2026-10-15 | minor | §6: Maximum Iterations and Maximum Change in File › Options › Formulas; dropped the stale "no Options dialog yet" | | 2026-10-15 | minor | §13: Solver prepares reports before committing the solution, model and report sheets together; report refusal preserves the workbook and Undo/Redo (#1050) |+| 2026-10-14 | minor | §4: chart error bars landed | | 2026-10-13 | trivial | §4: multi-level category axes landed | | 2026-10-12 | minor | §9: next/previous sheet commands retain the current visible sheet and selection when there is no visible destination | | 2026-10-12 | minor | rows auto-fit oversized fonts on open and whole-row/column formatting (#833) |diff --git a/docs/roadmap.md b/docs/roadmap.md--- a/docs/roadmap.md+++ b/docs/roadmap.md@@ -1,6 +1,6 @@ # Roadmap detail -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-23 · **Change:** minor (test command) · **Target:** Microsoft Excel (Microsoft 365)+> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-23 · **Change:** minor (test command; M7: chart error bars landed) · **Target:** Microsoft Excel (Microsoft 365)  Forward-looking plan. The one-page summary is [`ROADMAP.md`](../ROADMAP.md); the ranked work list is [`gaps.md`](gaps.md); numbers and method are in@@ -69,7 +69,7 @@ to check our files, and Windows machines with the reported GPU drivers. | M4 | Data (advanced filter, consolidate, what-if) | most; Solver, ToolPak and Power Query remain | | M5 | Formulas tab (trace arrows, watch window, evaluate, error checking) | done | | M6 | Conditional formatting manager parity | done |-| M7 | Charts parity | partial; Format pane in; axes, trendlines, error bars, round-trip remain |+| M7 | Charts parity | partial; Format pane, trendlines and error bars in; axes, round-trip remain | | M8 | Insert & Review | partial; ink, icons, comments pane in | | M9 | Page layout & print | most; Page Layout view remains | | M10 | PivotTables, Solver, Analysis ToolPak | PivotTables in; slicers, Solver, ToolPak remain |@@ -114,6 +114,7 @@ to check our files, and Windows machines with the reported GPU drivers. | Date | Change | Summary | |---|---|---| | 2026-10-23 | minor | Measuring: tests run through `cargo xtask test` |+| 2026-10-14 | minor | M7: trendlines and error bars landed | | 2026-10-11 | minor | PivotTable calculated fields landed | | 2026-10-10 | minor | Current focus and M15 updated for the 10-10 merges | | 2026-10-10 | minor | Added the alpha gate table (six core workflows; passes, stays alpha) |