Browse / github.com/storytold/gridcraft / patch
Charts: series through defined names get Excel's cache and number formats (#1690, #1689)
XLSX: chart series that refer to defined names are saved without `numCache`/`strCache` `xlsx/src/chart.rs::ref_values` writes cached points only for a plain reference (`Expr::Ref`). A series whose `<c:f>` is a defined name (`[0]!Dates`) is saved with no cache. Excel recalculates on open, but readers that only use the cache (viewers, previewers, some converters) show an empty series. - Repro: a bar chart with values `Dates` (a workbook name); save; the `<c:numRef>` has a `<c:f>` but no `<c:numCache>`. - Fix idea: evaluate the name to its reference (or values) when writing the cache. `xlsx` is L2 alongside `calc`, so check the layering rules before pulling in evaluation. Found w
- Status
- open
- Score
- 0.00
- Adopters
- 0 other operators
- Author
- Harrison-Booth (imported from pull request #2701, open)
- Base
11bb67386c9bb19b0717e21e98adc608162ae2e5- Size
- +282 −21 in 8 files
- Review
- pending
- Shared
- 2026-10-28
- Id
f9e1c25a758888f45e90c394
How it went
No other operator has reported using it yet.
Diff
raw · crates/calc/src/lib.rs crates/calc/src/recalc.rs crates/chart/src/resolve.rs crates/chart/src/tests.rs crates/xlsx/src/chart.rs crates/xlsx/src/tests.rs crates/xlsx/src/tests/chart_name_caches.rs docs/chart-parity.md
@@ -8,7 +8,7 @@ pub mod recalc; mod trimrange; pub use eval::{Area, SPECIAL_FUNCTIONS, is_known_function};-pub use recalc::{Calc, Key, evaluate, now_serial};+pub use recalc::{Calc, Key, evaluate, evaluate_area, now_serial}; #[cfg(test)] mod tests;@@ -17,7 +17,7 @@ use gridcraft_core::{Array, CellError, CellRef, RangeRef, Value}; use gridcraft_formula::Expr; use gridcraft_model::{CalcMode, Workbook}; -use crate::eval::{Area, Evaluator, Host, precedents, stack_position};+use crate::eval::{Area, Ev, Evaluator, Host, precedents, stack_position}; mod graph; mod pass_host;@@ -513,6 +513,22 @@ pub fn evaluate_expr(wb: &Workbook, sheet: usize, at: CellRef, expr: &Expr) -> V ev.value(expr) } +/// The sheet and range a formula evaluates to when it is a single reference: `Sheet1!A1:B2`, a+/// defined name for one, an `OFFSET` or `INDEX` result. Chart series use it to find the cells+/// behind a name (for number formats); `None` for values and for unions of areas.+pub fn evaluate_area(wb: &Workbook, sheet: usize, formula: &str) -> Option<Area> {+ let body = formula.trim().strip_prefix('=').unwrap_or(formula.trim());+ let expr = gridcraft_formula::parse(body).ok()?;+ let exprs = HashMap::default();+ let mut rng = 0x1234_5678_9ABC_DEF0u64;+ let mut host = PassHost::new(wb, &exprs, &mut rng, now_serial());+ let mut ev = Evaluator::new(&mut host, sheet, CellRef::default());+ match ev.eval(&expr) {+ Ev::R(areas) if areas.len() == 1 => areas.first().copied(),+ _ => None,+ }+}+ #[allow(clippy::disallowed_methods)] // the clock is read only off wasm fn prof_now() -> f64 { #[cfg(not(target_arch = "wasm32"))]@@ -50,6 +50,12 @@ fn parse_ref(wb: &Workbook, sheet: usize, formula: &str) -> Option<(usize, Range Some((sh, r)) } +/// The cells a series formula stands for: a reference, or a defined name (static, or `OFFSET`+/// and the like) that evaluates to one. Number formats come from these cells.+fn target(wb: &Workbook, sheet: usize, formula: &str) -> Option<(usize, RangeRef)> {+ parse_ref(wb, sheet, formula).or_else(|| gridcraft_calc::evaluate_area(wb, sheet, formula).map(|a| (a.sheet, a.range)))+}+ /// The parts of a union reference `(Sheet1!$A$1:$A$3,Sheet1!$A$5)`, or `None`. fn union_parts(f: &str) -> Option<Vec<&str>> { let inner = f.strip_prefix('(')?.strip_suffix(')')?;@@ -115,9 +121,9 @@ fn nums(wb: &Workbook, sheet: usize, formula: &str) -> Vec<Option<f64>> { eval_list(wb, sheet, formula).iter().map(to_num).collect() } -/// Number format code of the first cell of a reference ("General" when not a reference).+/// Number format code of the first cell of a reference or a name for one ("General" otherwise). fn ref_format(wb: &Workbook, sheet: usize, formula: &str) -> String {- parse_ref(wb, sheet, formula)+ target(wb, sheet, formula) .and_then(|(sh, r)| wb.sheet(sh).map(|s| wb.styles.get(s.style_id(r.start)).num_fmt.0.to_string())) .unwrap_or_else(|| "General".into()) }@@ -139,7 +145,7 @@ fn value_text(v: &Value) -> String { /// has one entry per category, non-empty where a group starts, as the cells are. fn category_table(wb: &Workbook, sheet: usize, formula: &str, values: &str) -> (Vec<String>, Vec<Vec<String>>) { let vals = eval_list(wb, sheet, formula);- let cells: Option<(usize, RangeRef)> = parse_ref(wb, sheet, formula);+ let cells: Option<(usize, RangeRef)> = target(wb, sheet, formula); let src = cells.and_then(|(sh, r)| wb.sheet(sh).map(|s| (s, r))); let texts: Vec<String> = vals .iter()@@ -159,7 +165,7 @@ fn category_table(wb: &Workbook, sheet: usize, formula: &str, values: &str) -> ( }) .collect(); let Some((_, r)) = src else { return (texts, vec![]) };- let value_range = parse_ref(wb, sheet, values).map(|(_, r)| r);+ let value_range = target(wb, sheet, values).map(|(_, r)| r); let Some(levels) = category_levels(&r, value_range.as_ref()) else { return (texts, vec![]) }; let (w, h) = (r.width().max(1) as usize, r.height().max(1) as usize); if texts.len() != w.saturating_mul(h) {@@ -1255,3 +1255,55 @@ fn box_whisker_draws_each_series_quartile_method() { // The boxes span 3.5 and 2.5 data units on the same value axis. assert!((heights[0] / heights[1] - 1.4).abs() < 0.001); }++/// A series through a defined name takes its number format (and its date categories) from the+/// cells the name stands for, as the same series over the plain reference does.+#[test]+fn names_resolve_formats_like_references() {+ let mut wb = Workbook::new();+ let date = wb.styles.intern(Style { num_fmt: gridcraft_model::NumFmt(Arc::from("m/d/yyyy")), ..Style::default() });+ let money = wb.styles.intern(Style { num_fmt: gridcraft_model::NumFmt(Arc::from("0.00")), ..Style::default() });+ {+ let s = wb.sheet_mut(0).unwrap();+ for (i, (d, v)) in [(45296.0, 10.0), (45327.0, 20.0), (45356.0, 30.0)].iter().enumerate() {+ let r = i as u32 + 1;+ s.set_value(CellRef::new(r, 0), Value::Number(*d));+ s.set_style(CellRef::new(r, 0), date);+ s.set_value(CellRef::new(r, 1), Value::Number(*v));+ s.set_style(CellRef::new(r, 1), money);+ }+ }+ let name =+ |n: &str, f: &str| gridcraft_model::DefinedName { name: n.into(), scope: None, formula: f.into(), comment: String::new(), hidden: false };+ wb.names = vec![+ name("Cats", "Sheet1!$A$2:$A$4"),+ name("Vals", "Sheet1!$B$2:$B$4"),+ name("DynC", "OFFSET(Sheet1!$A$2,0,0,3,1)"),+ name("DynV", "OFFSET(Sheet1!$B$2,0,0,COUNT(Sheet1!$B:$B),1)"),+ name("Dates", "Sheet1!$A$2:$A$4"),+ ];+ let resolved = |cats: &str, vals: &str| {+ let mut c = chart(ChartKind::ColumnClustered);+ c.series = vec![Series {+ name: None,+ categories: Some(cats.into()),+ values: vals.into(),+ bubble_sizes: None,+ color: None,+ secondary: false,+ kind: None,+ quartiles: Default::default(),+ smooth: false,+ trendlines: Vec::new(),+ }];+ let d = resolve(&wb, 0, &c);+ (d.categories, d.series[0].number_format.clone())+ };+ let direct = resolved("Sheet1!$A$2:$A$4", "Sheet1!$B$2:$B$4");+ assert_eq!(direct, (vec!["1/5/2024".to_string(), "2/5/2024".to_string(), "3/5/2024".to_string()], "0.00".to_string()));+ assert_eq!(resolved("Cats", "Vals"), direct);+ // Dynamic names: an OFFSET with a calculated height is followed through the calculation.+ assert_eq!(resolved("DynC", "DynV"), direct);+ // The axis of a series whose values are date cells reads the date format through a name.+ assert_eq!(resolved("Cats", "Dates").1, "m/d/yyyy");+}@@ -4,7 +4,7 @@ use std::fmt::Write as _; use gridcraft_core::{RangeRef, Value, category_levels};-use gridcraft_formula::{Expr, SheetSel};+use gridcraft_formula::{Expr, SheetSel, UnOp}; use gridcraft_model::{Chart, ChartKind, Color, DefinedName, LegendPos, LineDash, Series, Trendline, TrendlineKind, Workbook}; use crate::drawing::{dml_color, srgb};@@ -320,16 +320,65 @@ fn group_for(k: ChartKind) -> G { const MAX_CACHE: u64 = 100_000; -/// The sheet and range a same-workbook reference (`Sheet1!$B$2:$B$9`) names.+/// How many names may refer to names (or OFFSETs nest) before a series stops resolving.+const MAX_NAME_DEPTH: u32 = 8;++/// The sheet and range a series formula names: a same-workbook reference (`Sheet1!$B$2:$B$9`), a+/// defined name that stands for one (names may refer to names), or an `OFFSET` of one with+/// literal arguments. A name that has to be calculated (`OFFSET(…,COUNTA(…))`) has no target+/// here: this crate doesn't evaluate formulas. fn ref_target(wb: &Workbook, sheet_idx: usize, f: &str) -> Option<(usize, RangeRef)> {- let e = gridcraft_formula::parse(f).ok()?;- let Expr::Ref(r) = e else { return None };- let si = match &r.sheet {- SheetSel::Current => sheet_idx,- SheetSel::Named(n) => wb.sheet_index(n)?,- SheetSel::Span(..) => return None,- };- Some((si, r.range()))+ let e = gridcraft_formula::parse(f.trim()).ok()?;+ expr_target(wb, sheet_idx, &e, 0)+}++fn expr_target(wb: &Workbook, sheet_idx: usize, e: &Expr, depth: u32) -> Option<(usize, RangeRef)> {+ if depth > MAX_NAME_DEPTH {+ return None;+ }+ match e {+ Expr::Paren(inner) => expr_target(wb, sheet_idx, inner, depth + 1),+ Expr::Ref(r) => {+ let si = match &r.sheet {+ SheetSel::Current => sheet_idx,+ SheetSel::Named(n) => wb.sheet_index(n)?,+ SheetSel::Span(..) => return None,+ };+ Some((si, r.range()))+ }+ Expr::Name(n) => {+ let (_, _, def) = chart_name(wb, sheet_idx, n)?;+ let def = def?;+ let body = gridcraft_formula::parse_name(&def.formula).ok()?;+ expr_target(wb, def.scope.unwrap_or(sheet_idx), &body, depth + 1)+ }+ Expr::Call(func, args) if func == "OFFSET" => {+ let (si, r) = expr_target(wb, sheet_idx, args.first()?, depth + 1)?;+ let int = |i: usize| match args.get(i) {+ None | Some(Expr::Missing) => Some(None),+ Some(Expr::Number(n)) if n.fract() == 0.0 && n.abs() < 2e6 => Some(Some(*n as i64)),+ Some(Expr::Unary(UnOp::Neg, n)) => match **n {+ Expr::Number(n) if n.fract() == 0.0 && n < 2e6 => Some(Some(-(n as i64))),+ _ => None,+ },+ _ => None,+ };+ let (dr, dc) = (int(1)?.unwrap_or(0), int(2)?.unwrap_or(0));+ let (h, w) = (int(3)?.unwrap_or(i64::from(r.height())), int(4)?.unwrap_or(i64::from(r.width())));+ let start = r.start.offset(dr, dc)?;+ let end = start.offset(h.checked_sub(1).filter(|v| *v >= 0)?, w.checked_sub(1).filter(|v| *v >= 0)?)?;+ Some((si, RangeRef::new(start, end)))+ }+ _ => None,+ }+}++/// The number format code of the first cell a series formula refers to, as Excel writes it in+/// `<c:formatCode>` (`General` when the formula isn't a reference or a name for one).+fn source_format(wb: &Workbook, sheet_idx: usize, f: &str) -> String {+ ref_target(wb, sheet_idx, f)+ .and_then(|(si, r)| Some(wb.styles.get(wb.sheet(si)?.style_id(r.start)).num_fmt.0.to_string()))+ .unwrap_or_else(|| "General".into()) } /// Values of a same-workbook reference (`Sheet1!$B$2:$B$9`), row-major.@@ -408,7 +457,7 @@ fn num_data(tag: &str, f: Option<&str>, wb: &Workbook, si: usize) -> String { } let mut s = format!("<c:{tag}><c:numRef><c:f>{}</c:f>", esc(&chart_ref_to_file(wb, si, f))); if let Some(vals) = ref_values(wb, si, f) {- let _ = write!(s, "<c:numCache><c:formatCode>General</c:formatCode><c:ptCount val=\"{}\"/>", vals.len());+ let _ = write!(s, "<c:numCache><c:formatCode>{}</c:formatCode><c:ptCount val=\"{}\"/>", esc(&source_format(wb, si, f)), vals.len()); for (i, v) in vals.iter().enumerate() { if let Value::Number(n) = v { let _ = write!(s, "<c:pt idx=\"{i}\"><c:v>{}</c:v></c:pt>", num(*n));@@ -486,7 +535,16 @@ fn str_data(tag: &str, f: &str, wb: &Workbook, si: usize) -> String { /// year beside month). Its cache lists the innermost level first, then each outer level with /// only the cells that start a group, as Excel writes it. fn cat_data(f: &str, values: &str, wb: &Workbook, si: usize) -> String {- multi_level_cat(f.trim(), values, wb, si).unwrap_or_else(|| str_data("cat", f, wb, si))+ multi_level_cat(f.trim(), values, wb, si).or_else(|| numeric_cat(f, wb, si)).unwrap_or_else(|| str_data("cat", f, wb, si))+}++/// Categories that are all numbers (dates, years) are a number reference with a number cache, as+/// Excel writes them; the cache's format code makes a viewer show dates rather than serials.+fn numeric_cat(f: &str, wb: &Workbook, si: usize) -> Option<String> {+ let vals = ref_values(wb, si, f)?;+ let mut filled = vals.iter().filter(|v| !v.is_empty()).peekable();+ filled.peek()?;+ filled.all(|v| matches!(v, Value::Number(_))).then(|| num_data("cat", Some(f), wb, si)) } fn multi_level_cat(f: &str, values: &str, wb: &Workbook, si: usize) -> Option<String> {@@ -508,7 +566,7 @@ fn multi_level_cat(f: &str, values: &str, wb: &Workbook, si: usize) -> Option<St } else { (w, (0..=levels.outer as usize).filter_map(|k| (h - 1).checked_sub(k)).map(|r| (0..w).map(|c| text(r, c)).collect()).collect()) };- let mut s = format!("<c:cat><c:multiLvlStrRef><c:f>{}</c:f><c:multiLvlStrCache><c:ptCount val=\"{n}\"/>", esc(&crate::fmla::text_to_file(f)));+ let mut s = format!("<c:cat><c:multiLvlStrRef><c:f>{}</c:f><c:multiLvlStrCache><c:ptCount val=\"{n}\"/>", esc(&chart_ref_to_file(wb, si, f))); for lvl in &lvls { s.push_str("<c:lvl>"); for (i, v) in lvl.iter().enumerate() {@@ -5,6 +5,7 @@ mod cellmetadata; mod chart_book_names; mod chart_bool_default; mod chart_combo_kinds;+mod chart_name_caches; mod chart_ref_offsets; mod csv_tests; mod encrypted_tests;new file mode 100644@@ -0,0 +1,124 @@+//! A series that goes through a defined name is saved with the cache Excel writes for it: the+//! values of the cells the name stands for and their number format (observed in Excel for+//! Windows, Microsoft 365: charts over `[0]!Name` carry the same `numCache`/`strCache` as charts+//! over the plain reference, dynamic `OFFSET` names included).++use std::io::Read;+use std::sync::Arc;++use gridcraft_core::{CellRef, Value};+use gridcraft_model::{Anchor, Chart, ChartKind, DefinedName, LegendPos, NumFmt, Series, Sheet, Style, Workbook};++use crate::write_xlsx;++fn name(n: &str, formula: &str) -> DefinedName {+ DefinedName { name: n.into(), scope: None, formula: formula.into(), comment: String::new(), hidden: false }+}++fn chart_xml(cats: &str, vals: &str, names: Vec<DefinedName>) -> String {+ let mut wb = Workbook::default();+ let date = wb.styles.intern(Style { num_fmt: NumFmt(Arc::from("m/d/yyyy")), ..Style::default() });+ let money = wb.styles.intern(Style { num_fmt: NumFmt(Arc::from("0.00")), ..Style::default() });+ let mut s = Sheet::new("Sheet1");+ for (i, (d, v)) in [(45296.0, 10.0), (45327.0, 20.0), (45356.0, 30.0)].iter().enumerate() {+ let r = i as u32 + 1;+ s.set_value(CellRef::new(r, 0), Value::Number(*d));+ s.set_style(CellRef::new(r, 0), date);+ s.set_value(CellRef::new(r, 1), Value::Number(*v));+ s.set_style(CellRef::new(r, 1), money);+ s.set_value(CellRef::new(r + 5, 0), Value::from(["a", "b", "c"][i]));+ }+ s.charts.push(Chart {+ id: 1,+ kind: ChartKind::ColumnClustered,+ anchor: Anchor { cell: CellRef::new(0, 4), width: 480.0, height: 300.0, ..Anchor::default() },+ title: None,+ series: vec![Series {+ name: None,+ categories: Some(cats.into()),+ values: vals.into(),+ bubble_sizes: None,+ color: None,+ secondary: false,+ kind: None,+ smooth: false,+ trendlines: Vec::new(),+ quartiles: Default::default(),+ }],+ legend: LegendPos::Bottom,+ data_labels: false,+ gridlines: true,+ style: 2,+ x_title: None,+ y_title: None,+ source: None,+ by_rows: false,+ pivot: None,+ });+ wb.sheets = vec![Arc::new(s)];+ wb.names = names;+ let bytes = write_xlsx(&wb).unwrap();+ let mut xml = String::new();+ zip::ZipArchive::new(std::io::Cursor::new(&bytes)).unwrap().by_name("xl/charts/chart1.xml").unwrap().read_to_string(&mut xml).unwrap();+ xml+}++fn part<'a>(xml: &'a str, tag: &str) -> &'a str {+ let open = format!("<c:{tag}>");+ let start = xml.find(&open).unwrap() + open.len();+ let end = xml[start..].find(&format!("</c:{tag}>")).unwrap() + start;+ &xml[start..end]+}++const DATE_CACHE: &str = "<c:numCache><c:formatCode>m/d/yyyy</c:formatCode><c:ptCount val=\"3\"/><c:pt idx=\"0\"><c:v>45296</c:v></c:pt><c:pt idx=\"1\"><c:v>45327</c:v></c:pt><c:pt idx=\"2\"><c:v>45356</c:v></c:pt></c:numCache>";+const VAL_CACHE: &str = "<c:numCache><c:formatCode>0.00</c:formatCode><c:ptCount val=\"3\"/><c:pt idx=\"0\"><c:v>10</c:v></c:pt><c:pt idx=\"1\"><c:v>20</c:v></c:pt><c:pt idx=\"2\"><c:v>30</c:v></c:pt></c:numCache>";++#[test]+fn plain_references_carry_the_source_format() {+ let xml = chart_xml("Sheet1!$A$2:$A$4", "Sheet1!$B$2:$B$4", vec![]);+ assert_eq!(part(&xml, "cat"), format!("<c:numRef><c:f>Sheet1!$A$2:$A$4</c:f>{DATE_CACHE}</c:numRef>"));+ assert_eq!(part(&xml, "val"), format!("<c:numRef><c:f>Sheet1!$B$2:$B$4</c:f>{VAL_CACHE}</c:numRef>"));+}++#[test]+fn static_names_are_cached_like_the_plain_reference() {+ let names = vec![name("Cats", "Sheet1!$A$2:$A$4"), name("Vals", "Sheet1!$B$2:$B$4")];+ let xml = chart_xml("Cats", "Vals", names);+ assert_eq!(part(&xml, "cat"), format!("<c:numRef><c:f>[0]!Cats</c:f>{DATE_CACHE}</c:numRef>"));+ assert_eq!(part(&xml, "val"), format!("<c:numRef><c:f>[0]!Vals</c:f>{VAL_CACHE}</c:numRef>"));+}++#[test]+fn names_of_names_and_offsets_are_cached() {+ let names = vec![+ name("Base", "Sheet1!$A$2:$A$4"),+ name("Cats", "Base"),+ name("DynV", "OFFSET(Sheet1!$B$2,0,0,3,1)"),+ name("Shifted", "OFFSET(Cats,0,1)"),+ ];+ let xml = chart_xml("Cats", "DynV", names.clone());+ assert_eq!(part(&xml, "cat"), format!("<c:numRef><c:f>[0]!Cats</c:f>{DATE_CACHE}</c:numRef>"));+ assert_eq!(part(&xml, "val"), format!("<c:numRef><c:f>[0]!DynV</c:f>{VAL_CACHE}</c:numRef>"));+ // OFFSET of a name: the cells one column over.+ assert_eq!(part(&chart_xml("Cats", "Shifted", names), "val"), format!("<c:numRef><c:f>[0]!Shifted</c:f>{VAL_CACHE}</c:numRef>"));+}++#[test]+fn text_categories_through_a_name_get_a_string_cache() {+ let xml = chart_xml("Txt", "Sheet1!$B$2:$B$4", vec![name("Txt", "Sheet1!$A$7:$A$9")]);+ assert_eq!(+ part(&xml, "cat"),+ "<c:strRef><c:f>[0]!Txt</c:f><c:strCache><c:ptCount val=\"3\"/><c:pt idx=\"0\"><c:v>a</c:v></c:pt><c:pt idx=\"1\"><c:v>b</c:v></c:pt><c:pt idx=\"2\"><c:v>c</c:v></c:pt></c:strCache></c:strRef>"+ );+}++#[test]+fn names_that_cannot_be_resolved_stay_without_a_cache() {+ // Name chains that loop, calculated sizes and undefined names: no cache, and no hang.+ let names = vec![name("A", "B"), name("B", "A"), name("Calc", "OFFSET(Sheet1!$B$2,0,0,COUNT(Sheet1!$B:$B),1)")];+ for n in ["A", "Calc", "Missing"] {+ let xml = chart_xml(n, n, names.clone());+ assert!(!xml.contains("<c:numCache>") && !xml.contains("<c:strCache>"), "{n}: {xml}");+ assert!(xml.contains(&format!("<c:f>[0]!{n}</c:f>")), "{n}: {xml}");+ }+}@@ -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-26 · **Change:** minor (chart caches and number formats through defined names) · **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).@@ -74,7 +74,10 @@ Excel writes a workbook-scoped name as `[0]!Name` in `<c:f>`. GridCraft reads it name and writes it back with `[0]!`; a sheet-scoped name is written `Sheet!Name`. Excel needs the qualifier: with a bare name in `<c:f>` it draws the whole chart blank, without a repair prompt (observed in Excel for Windows, Microsoft 365). External books (`[1]Sheet1!…`) are kept as they-are (#1686). Such series get no `numCache`/`strCache` on save (#1690).+are (#1686). Series through a name are saved with the same `numCache`/`strCache` as the plain+reference, `formatCode` from the first cell (#1690), and the chart reads number formats and date+categories through the name (#1689). The file writer can't calculate, so a name whose size comes+from a calculation (`OFFSET(…,COUNTA(…))`) is saved without a cache; Excel recalculates it on open. ## Work (40–60 h) @@ -90,6 +93,7 @@ are (#1686). Such series get no `numCache`/`strCache` on save (#1690). | Date | Change | Summary | |---|---|---|+| 2026-10-26 | minor | Series through defined names (static and `OFFSET` with literal arguments) are saved with `numCache`/`strCache` and the first cell's `formatCode`; the chart reads number formats and date categories through names; all-number categories are written as `numRef` (#1689, #1690) | | 2026-10-24 | minor | 100% Stacked Area: inserted from the ribbon and All Charts, drawn as shares of each category's total, read and written as `percentStacked` | | 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) |