From 823c56582d00be91e31b4f7f3680496cf89e84fb Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sat, 17 Oct 2026 20:03:36 -0400 Subject: [PATCH 1/6] Release 0.8.0 Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 32 ++++++++++++++++---------------- Cargo.toml | 2 +- 2 files changed, 17 insertions(+), 17 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 6d618a02a..17ebf848c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1496,7 +1496,7 @@ dependencies = [ [[package]] name = "gridcraft" -version = "0.7.0" +version = "0.8.0" dependencies = [ "arboard", "craft-print", @@ -1518,7 +1518,7 @@ dependencies = [ [[package]] name = "gridcraft-calc" -version = "0.7.0" +version = "0.8.0" dependencies = [ "gridcraft-core", "gridcraft-formula", @@ -1530,7 +1530,7 @@ dependencies = [ [[package]] name = "gridcraft-chart" -version = "0.7.0" +version = "0.8.0" dependencies = [ "gridcraft-calc", "gridcraft-core", @@ -1541,7 +1541,7 @@ dependencies = [ [[package]] name = "gridcraft-cli" -version = "0.7.0" +version = "0.8.0" dependencies = [ "gridcraft-engine", "gridcraft-mcp", @@ -1553,7 +1553,7 @@ dependencies = [ [[package]] name = "gridcraft-core" -version = "0.7.0" +version = "0.8.0" dependencies = [ "js-sys", "serde", @@ -1563,7 +1563,7 @@ dependencies = [ [[package]] name = "gridcraft-engine" -version = "0.7.0" +version = "0.8.0" dependencies = [ "gridcraft-calc", "gridcraft-chart", @@ -1584,7 +1584,7 @@ dependencies = [ [[package]] name = "gridcraft-formula" -version = "0.7.0" +version = "0.8.0" dependencies = [ "gridcraft-core", "serde", @@ -1593,7 +1593,7 @@ dependencies = [ [[package]] name = "gridcraft-functions" -version = "0.7.0" +version = "0.8.0" dependencies = [ "gridcraft-core", "regex", @@ -1601,7 +1601,7 @@ dependencies = [ [[package]] name = "gridcraft-mcp" -version = "0.7.0" +version = "0.8.0" dependencies = [ "gridcraft-engine", "log", @@ -1610,7 +1610,7 @@ dependencies = [ [[package]] name = "gridcraft-model" -version = "0.7.0" +version = "0.8.0" dependencies = [ "gridcraft-core", "gridcraft-formula", @@ -1622,18 +1622,18 @@ dependencies = [ [[package]] name = "gridcraft-numfmt" -version = "0.7.0" +version = "0.8.0" dependencies = [ "gridcraft-core", ] [[package]] name = "gridcraft-pdf" -version = "0.7.0" +version = "0.8.0" [[package]] name = "gridcraft-ui-egui" -version = "0.7.0" +version = "0.8.0" dependencies = [ "craft-ui", "egui", @@ -1655,7 +1655,7 @@ dependencies = [ [[package]] name = "gridcraft-web" -version = "0.7.0" +version = "0.8.0" dependencies = [ "eframe", "egui", @@ -1672,7 +1672,7 @@ dependencies = [ [[package]] name = "gridcraft-xlsx" -version = "0.7.0" +version = "0.8.0" dependencies = [ "aes", "gridcraft-core", @@ -4526,7 +4526,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7" [[package]] name = "xtask" -version = "0.7.0" +version = "0.8.0" dependencies = [ "gridcraft-engine", "serde_json", diff --git a/Cargo.toml b/Cargo.toml index 98dd572e0..a967b999f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"] default-members = ["crates/*", "apps/gridcraft", "apps/gridcraft-cli", "xtask"] [workspace.package] -version = "0.7.0" +version = "0.8.0" edition = "2024" license = "MIT OR Apache-2.0" rust-version = "1.90" From 217a8907ffe9552c88329cdb693553048c003774 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Sun, 18 Oct 2026 02:34:28 +0200 Subject: [PATCH 2/6] Reject unknown value-field func in insert.pivotTable (#1757) Found while testing crates/engine/src/cmd/pivot.rs:678 (func_param at :248) Co-authored-by: Claude Haiku 5.5 --- crates/engine/src/cmd/pivot.rs | 8 +++++- crates/engine/tests/pivot_unknown_func.rs | 33 +++++++++++++++++++++++ 2 files changed, 40 insertions(+), 1 deletion(-) create mode 100644 crates/engine/tests/pivot_unknown_func.rs diff --git a/crates/engine/src/cmd/pivot.rs b/crates/engine/src/cmd/pivot.rs index 8a441fa1b..06050f7e8 100644 --- a/crates/engine/src/cmd/pivot.rs +++ b/crates/engine/src/cmd/pivot.rs @@ -675,7 +675,13 @@ fn insert_pivot(s: &mut Session, p: &Json) -> Result { Json::String(n) => add_to_area("insert.pivotTable", pt, &src, n, area, None, None, None)?, Json::Object(_) => { let n = str_param(f, "field").ok_or_else(|| bad("insert.pivotTable", "a field object needs `field`"))?; - add_to_area("insert.pivotTable", pt, &src, n, area, func_param(str_param(f, "func")), str_param(f, "name"), None)?; + let func = match str_param(f, "func") { + Some(fname) => { + Some(func_param(Some(fname)).ok_or_else(|| bad("insert.pivotTable", format!("unknown function `{fname}`")))?) + } + None => None, + }; + add_to_area("insert.pivotTable", pt, &src, n, area, func, str_param(f, "name"), None)?; let by = group_param(str_param(f, "group")); if let Some(by) = by.filter(|b| *b != PivotDateGroup::None) && matches!(area, "rows" | "columns") diff --git a/crates/engine/tests/pivot_unknown_func.rs b/crates/engine/tests/pivot_unknown_func.rs new file mode 100644 index 000000000..f4d2427f0 --- /dev/null +++ b/crates/engine/tests/pivot_unknown_func.rs @@ -0,0 +1,33 @@ +use gridcraft_engine::Session; +use serde_json::json; + +fn session() -> Session { + let mut s = Session::new(); + s.new_workbook(); + s.execute("range.setValues", json!({"range":"A1", "values":[["Region", "Sales"], ["East", "10"], ["East", "2"], ["West", "4"]]})).unwrap(); + s +} + +#[test] +fn insert_pivot_table_rejects_unknown_func() { + for func in ["stdev", "sumx"] { + let mut s = session(); + let r = s.execute( + "insert.pivotTable", + json!({"source":"Sheet1!A1:B4", "destination":"Sheet1!F1", "rows":["Region"], "values":[{"field":"Sales", "func":func}]}), + ); + assert!(r.is_err(), "unknown func `{func}` accepted silently"); + assert!(s.doc().unwrap().wb.active().unwrap().pivots.is_empty()); + } +} + +#[test] +fn insert_pivot_table_accepts_known_func() { + let mut s = session(); + s.execute( + "insert.pivotTable", + json!({"source":"Sheet1!A1:B4", "destination":"Sheet1!F1", "rows":["Region"], "values":[{"field":"Sales", "func":"stddev"}]}), + ) + .unwrap(); + assert_eq!(s.doc().unwrap().wb.active().unwrap().pivots.len(), 1); +} From 83189c0ccd9138539f576fbaf39d38712e05f5ad Mon Sep 17 00:00:00 2001 From: Dante Bertuzzi <67659136+dantebertuzzi@users.noreply.github.com> Date: Sat, 17 Oct 2026 21:35:20 -0300 Subject: [PATCH 3/6] Fonts: probe ChromeOS's /usr/share/fonts/chromeos for script fallbacks (fixes #1762) (#1768) Crostini's cros-host-fonts mounts the host's fonts under /usr/share/fonts/chromeos (noto/, notocjk/, crosextra/), which none of the fixed Linux path lists reached: Thai, Hebrew, CJK and Symbols 2 fell back to egui's Latin-only faces and drew tofu with the fonts installed, and the UI and cell roles missed Noto Sans and Carlito. Add those folders to each list, with a structural test that every list probes them. Co-authored-by: Claude Opus 5.5 --- crates/ui-egui/src/theme.rs | 43 +++++++++++++++++++++++++++++++------ 1 file changed, 37 insertions(+), 6 deletions(-) diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index 485b9a2f3..08097ba27 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -252,7 +252,8 @@ fn candidates(role: &str) -> Vec<(String, u32)> { let mac_sys = "/System/Library/Fonts"; let mac_sup = "/System/Library/Fonts/Supplemental"; let win = std::env::var("WINDIR").map(|w| format!("{w}\\Fonts")).unwrap_or_else(|_| "C:\\Windows\\Fonts".into()); - let lin = ["/usr/share/fonts/truetype", "/usr/share/fonts", "/usr/local/share/fonts", "/usr/share/fonts/TTF"]; + // ChromeOS's Linux container (Crostini) mounts the host's fonts under `chromeos/` (#1762). + let lin = ["/usr/share/fonts/truetype", "/usr/share/fonts", "/usr/local/share/fonts", "/usr/share/fonts/TTF", "/usr/share/fonts/chromeos"]; let mut v: Vec<(String, u32)> = Vec::new(); let mut add = |s: String, i: u32| v.push((s, i)); match role { @@ -498,10 +499,10 @@ fn fallback_faces(han: HanOrder) -> Vec<(&'static str, Vec<(String, u32)>)> { } /// Linux directories that hold CJK fonts across distributions: Debian/Ubuntu (`opentype/noto`, -/// `truetype/wqy`), Fedora (`google-noto-cjk`), Arch (`noto-cjk`, `wenquanyi`), plus local and -/// flat layouts. +/// `truetype/wqy`), Fedora (`google-noto-cjk`), Arch (`noto-cjk`, `wenquanyi`), ChromeOS's Linux +/// container (`chromeos/notocjk`, #1762), plus local and flat layouts. #[cfg(not(target_arch = "wasm32"))] -const LINUX_CJK_DIRS: [&str; 10] = [ +const LINUX_CJK_DIRS: [&str; 11] = [ "/usr/share/fonts/opentype/noto", "/usr/share/fonts/google-noto-cjk", "/usr/share/fonts/noto-cjk", @@ -512,6 +513,7 @@ const LINUX_CJK_DIRS: [&str; 10] = [ "/usr/share/fonts", "/usr/local/share/fonts", "/usr/local/share/fonts/noto-cjk", + "/usr/share/fonts/chromeos/notocjk", ]; /// Japanese Han + Kana faces (Japanese kanji glyph shapes). @@ -581,7 +583,13 @@ fn thai() -> Vec<(String, u32)> { let mac_sys = "/System/Library/Fonts"; let mac_sup = "/System/Library/Fonts/Supplemental"; let win = std::env::var("WINDIR").map(|w| format!("{w}\\Fonts")).unwrap_or_else(|_| "C:\\Windows\\Fonts".into()); - let lin = ["/usr/share/fonts/truetype/noto", "/usr/share/fonts/opentype/noto", "/usr/share/fonts/truetype", "/usr/share/fonts"]; + let lin = [ + "/usr/share/fonts/truetype/noto", + "/usr/share/fonts/opentype/noto", + "/usr/share/fonts/truetype", + "/usr/share/fonts", + "/usr/share/fonts/chromeos/noto", // ChromeOS's Linux container (#1762) + ]; let mut v: Vec<(String, u32)> = Vec::new(); v.push((format!("{win}\\LeelawUI.ttf"), 0)); // Leelawadee UI v.push((format!("{win}\\tahoma.ttf"), 0)); // Tahoma @@ -603,7 +611,13 @@ fn hebrew() -> Vec<(String, u32)> { let mac_sys = "/System/Library/Fonts"; let mac_sup = "/System/Library/Fonts/Supplemental"; let win = std::env::var("WINDIR").map(|w| format!("{w}\\Fonts")).unwrap_or_else(|_| "C:\\Windows\\Fonts".into()); - let lin = ["/usr/share/fonts/truetype/noto", "/usr/share/fonts/opentype/noto", "/usr/share/fonts/truetype", "/usr/share/fonts"]; + let lin = [ + "/usr/share/fonts/truetype/noto", + "/usr/share/fonts/opentype/noto", + "/usr/share/fonts/truetype", + "/usr/share/fonts", + "/usr/share/fonts/chromeos/noto", // ChromeOS's Linux container (#1762) + ]; let mut v: Vec<(String, u32)> = Vec::new(); v.push((format!("{win}\\david.ttf"), 0)); // David v.push((format!("{win}\\frank.ttf"), 0)); // FrankRuehl @@ -657,6 +671,7 @@ fn symbols_more() -> Vec<(String, u32)> { "/usr/share/fonts/google-noto-sans-symbols-2-fonts", "/usr/share/fonts/google-noto", "/usr/local/share/fonts/noto", + "/usr/share/fonts/chromeos/noto", ] { v.push((format!("{d}/NotoSansSymbols2-Regular.ttf"), 0)); } @@ -888,6 +903,22 @@ mod tests { } } + #[test] + fn chromeos_font_layout_is_probed() { + // #1762: Crostini mounts the host's fonts under /usr/share/fonts/chromeos, one folder per + // family set; a script whose list misses it renders as tofu with the font installed. + let probes = |cands: Vec<(String, u32)>, path: &str| cands.iter().any(|(p, _)| p == path); + let cros = "/usr/share/fonts/chromeos"; + assert!(probes(thai(), &format!("{cros}/noto/NotoSansThai-Regular.ttf"))); + assert!(probes(hebrew(), &format!("{cros}/noto/NotoSansHebrew-Regular.ttf"))); + assert!(probes(cjk_han_japanese(), &format!("{cros}/notocjk/NotoSansCJK-Regular.ttc"))); + assert!(probes(cjk_han_simplified(), &format!("{cros}/notocjk/NotoSansCJK-Regular.ttc"))); + assert!(probes(cjk_hangul(), &format!("{cros}/notocjk/NotoSansCJK-Regular.ttc"))); + assert!(probes(symbols_more(), &format!("{cros}/noto/NotoSansSymbols2-Regular.ttf"))); + assert!(probes(candidates(UI), &format!("{cros}/noto/NotoSans-Regular.ttf"))); + assert!(probes(candidates(CELL), &format!("{cros}/crosextra/Carlito-Regular.ttf"))); + } + #[test] fn unparseable_font_files_are_rejected_not_fatal() { // #226: epaint panics in `set_fonts` on a face it can't parse ("An offset was out of From 19f4387cca2ff373da2cfcb851b5cb9cead9e14b Mon Sep 17 00:00:00 2001 From: Dante Bertuzzi <67659136+dantebertuzzi@users.noreply.github.com> Date: Sat, 17 Oct 2026 21:37:09 -0300 Subject: [PATCH 4/6] Conditional formatting: show a rule's number format (fixes #1163) (#1769) The grid's look() and print's cell_look() layered a rule's fill, font and borders onto the cell style but not its num_fmt, so a cell under a rule with a number format kept its own. Take the rule's code when it isn't General (in a differential style General means "not set", as layer_under treats it). Print also re-formatted every non-General cell from its own style through display::cell_text; format with the look's code instead, through a new display::value_text_as that value_text now calls (CSV/Text output unchanged). Show Formulas keeps cell_text. Co-authored-by: Claude Opus 5.5 --- crates/engine/src/cmd/print.rs | 22 +++++++++++++++++++++- crates/engine/src/display.rs | 8 +++++++- crates/ui-egui/src/grid.rs | 26 ++++++++++++++++++++++++++ 3 files changed, 54 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/cmd/print.rs b/crates/engine/src/cmd/print.rs index a10f19d46..8350e56d7 100644 --- a/crates/engine/src/cmd/print.rs +++ b/crates/engine/src/cmd/print.rs @@ -650,6 +650,10 @@ fn cell_look(wb: &Workbook, si: usize, sh: &Sheet, c: CellRef, cf: &mut Option String { /// and no General narrowing (Save As CSV/Text writes this). Formulas give their result. pub fn value_text(wb: &Workbook, sheet: &Sheet, c: CellRef) -> String { let style = wb.styles.get(sheet.style_id(c)); - let f = format(&sheet.value(c), style.num_fmt.as_str(), wb); + value_text_as(wb, &sheet.value(c), style.num_fmt.as_str()) +} + +/// [`value_text`] for a value and a format code given apart from a cell (print takes the code +/// from the cell's effective look, which a conditional format can override). +pub fn value_text_as(wb: &Workbook, v: &Value, code: &str) -> String { + let f = format(v, code, wb); match f.fill { Some((_, pos)) => { let mut t = f.text; diff --git a/crates/ui-egui/src/grid.rs b/crates/ui-egui/src/grid.rs index 00e0c18be..90a39cb06 100644 --- a/crates/ui-egui/src/grid.rs +++ b/crates/ui-egui/src/grid.rs @@ -482,6 +482,10 @@ fn look(wb: &Workbook, si: usize, sh: &Sheet, c: CellRef, cf: &mut CfCache) -> ( if st.font.strike { style.font.strike = true; } + // In a differential style General means "not set" (#1163). + if st.num_fmt.as_str() != "General" { + style.num_fmt = st.num_fmt.clone(); + } for (dst, src) in [ (&mut style.borders.left, st.borders.left), (&mut style.borders.right, st.borders.right), @@ -3406,6 +3410,28 @@ mod tests { assert_eq!(center_across_end(0, 1, |_| true), 1); } + #[test] + fn conditional_number_format_reaches_the_cell_text() { + // #1163: a rule's number format replaces the cell's own; unmatched cells keep theirs. + let mut session = gridcraft_engine::Session::new(); + session.new_workbook(); + session.execute("range.setValues", json!({"range": "A1", "values": [[1], [3], [4]]})).unwrap(); + session.execute("home.numberFormat", json!({"range": "A3", "code": "0.0"})).unwrap(); + let rule = json!({"type": "cellIs", "operator": "greater", "value": "2", "style": {"num_fmt": "0.00"}}); + session.execute("home.conditionalFormat", json!({"range": "A1:A3", "rule": rule})).unwrap(); + let wb = session.active().unwrap().wb.clone(); + let sh = wb.sheet(0).unwrap(); + let mut cf = CfCache::new(); + let text = |row: u32, cf: &mut CfCache| { + let c = CellRef::new(row, 0); + let (lk, _) = look(&wb, 0, sh, c, cf); + display_text(&wb, sh, c, &sh.value(c), &lk.style, 200.0, 7.0).0 + }; + assert_eq!(text(0, &mut cf), "1"); + assert_eq!(text(1, &mut cf), "3.00"); + assert_eq!(text(2, &mut cf), "4.00"); + } + #[test] fn filter_menu_uses_the_table_whose_header_was_clicked() { use gridcraft_engine::model::Table; From c30fa3f1cf324603f3bfc0171689b1d4083b40e5 Mon Sep 17 00:00:00 2001 From: Mex <44814932+MMexicann@users.noreply.github.com> Date: Sun, 18 Oct 2026 05:13:03 +0200 Subject: [PATCH 5/6] fix(functions): avoid overflow in median midpoints (#1579) (#1585) --- crates/calc/src/tests.rs | 28 ++++++++++++++++++++++++ crates/functions/src/stat.rs | 42 +++++++++++++++++++++++++++++++++++- docs/function-parity.md | 8 ++++++- 3 files changed, 76 insertions(+), 2 deletions(-) diff --git a/crates/calc/src/tests.rs b/crates/calc/src/tests.rs index 196c57de7..97f7a8350 100644 --- a/crates/calc/src/tests.rs +++ b/crates/calc/src/tests.rs @@ -1266,3 +1266,31 @@ fn offset_extreme_arguments_return_ref() { assert_eq!(crate::evaluate(&wb, 0, c("D1"), formula), Value::Number(expected), "{formula}"); } } + +#[test] +fn median_large_values_recalculate_dependents() { + let mut t = T::new(); + t.set("A1", "1e308"); + t.set("A2", "1.6e308"); + t.set("B1", "=MEDIAN(A1:A2)"); + t.set("B2", "=AGGREGATE(12,0,A1:A2)"); + t.set("C1", "=B1/1e308"); + t.set("C2", "=B2/1e308"); + for cell in ["B1", "B2"] { + assert!((t.num(cell) / 1e308 - 1.3).abs() < 1e-14); + } + for cell in ["C1", "C2"] { + assert!((t.num(cell) - 1.3).abs() < 1e-14); + } + t.set("A2", "-1e308"); + for cell in ["B1", "B2", "C1", "C2"] { + assert_eq!(t.num(cell), 0.0); + } + t.set("A1", "-1.6e308"); + for cell in ["B1", "B2"] { + assert!((t.num(cell) / 1e308 + 1.3).abs() < 1e-14); + } + for cell in ["C1", "C2"] { + assert!((t.num(cell) + 1.3).abs() < 1e-14); + } +} diff --git a/crates/functions/src/stat.rs b/crates/functions/src/stat.rs index 7c50abeaa..9d8642e24 100644 --- a/crates/functions/src/stat.rs +++ b/crates/functions/src/stat.rs @@ -54,7 +54,7 @@ fn median(v: Vec) -> R { let s = sorted(v); let n = s.len(); let hi = s.get(n / 2).copied().unwrap_or(0.0); - if n % 2 == 1 { Ok(hi) } else { Ok((s.get(n / 2 - 1).copied().unwrap_or(0.0) + hi) / 2.0) } + if n % 2 == 1 { Ok(hi) } else { Ok(s.get(n / 2 - 1).copied().unwrap_or(0.0).midpoint(hi)) } } /// Values occurring at least twice, ordered by first occurrence, with their (equal, maximal) count. @@ -2255,6 +2255,46 @@ mod tests { av(col(xs)) } + // The two middle values are finite and their midpoint is representable even when + // adding them first would overflow. Exercise both callers of the shared helper. + #[test] + fn median_finite_midpoint_does_not_overflow() { + for (values, expected) in [ + (vec![1e308, 1e308], 1e308), + (vec![-1e308, -1e308], -1e308), + (vec![1.6e308, 1e308], 1.3e308), + (vec![-1e308, -1.6e308], -1.3e308), + (vec![f64::MAX, f64::MAX], f64::MAX), + (vec![-f64::MAX, -f64::MAX], -f64::MAX), + (vec![-f64::MAX, f64::MAX], 0.0), + (vec![1.7e308, -1.7e308, 1e308, 1.6e308], 1.3e308), + ] { + close(ev("MEDIAN", values.iter().copied().map(n).collect()), expected); + close(ev("MEDIAN", vec![rf(col(&values))]), expected); + close(aggregate_values(12, &values.iter().copied().map(nv).collect::>()), expected); + } + } + + #[test] + fn median_midpoint_keeps_small_values_and_argument_semantics() { + // Halving each operand unconditionally can lose the smallest numbers. Use exact + // equality here: the usual aggregate-test tolerance would accept zero. + for value in [f64::MIN_POSITIVE, -f64::MIN_POSITIVE, f64::from_bits(1), -f64::from_bits(1)] { + assert_eq!(ev("MEDIAN", vec![n(value), n(value)]), nv(value)); + assert_eq!(aggregate_values(12, &[nv(value), nv(value)]), nv(value)); + } + close(ev("MEDIAN", vec![c(&[1.0, 2.0, 3.0, 4.0, 5.0, 6.0])]), 3.5); + close(ev("MEDIAN", vec![c(&[1e308, -1e308, 3.0])]), 3.0); + close(ev("MEDIAN", vec![n(1e308)]), 1e308); + close(ev("MEDIAN", vec![t("4"), b(true)]), 2.5); + close(ev("MEDIAN", vec![rf(arr(vec![vec![nv(1.0), tv("ignored"), Value::Empty, Value::Bool(true), nv(6.0)]]))]), 3.5); + is_err(ev("MEDIAN", vec![t("invalid")]), CellError::Value); + is_err(ev("MEDIAN", vec![e(CellError::NA), n(1e308)]), CellError::NA); + is_err(ev("MEDIAN", vec![rf(arr(vec![vec![tv("ignored"), Value::Empty]]))]), CellError::Num); + is_err(aggregate_values(12, &[]), CellError::Num); + is_err(aggregate_values(12, &[nv(1e308), Value::Error(CellError::Div0)]), CellError::Div0); + } + #[test] fn averages_and_counts() { close(ev("AVERAGE", vec![n(1.0), n(2.0), n(6.0)]), 3.0); diff --git a/docs/function-parity.md b/docs/function-parity.md index 274800699..2343e0de9 100644 --- a/docs/function-parity.md +++ b/docs/function-parity.md @@ -1,6 +1,6 @@ # Function parity: worksheet functions and formulas -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-13 · **Change:** minor (clearing large sparse ranges recalculates every changed cell's dependents, #1032) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (MEDIAN and AGGREGATE median avoid intermediate overflow) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 Excel's worksheet functions, category by category, against GridCraft's function library. Part of [target-app-parity.md](target-app-parity.md); gaps feed [gaps.md](gaps.md). @@ -101,6 +101,11 @@ fraction sections with literal text (`#\:#=/=#`, `#_#/#`), zero fractions, zero- denominators, `?` placeholders padding the thousands separator, and booleans through the text section (`TEXT(TRUE,";;;\@@\@")` = `@TRUE@`). +`MEDIAN` and `AGGREGATE` function number 12 use an overflow-safe midpoint for even-sized +inputs, so large finite middle values do not become `#NUM!` merely because their sum overflows (#1579). +Sorting, argument coercion and error propagation are unchanged; this is a numerical correction +under the documented median rule, not an extreme-value comparison against desktop Excel. + Work to close (16–26 h in total): the in-scope missing functions (10–16 h with the table above), an oracle corpus of Excel-computed results per function (owner runs Excel on generated workbooks; results checked in as data, not Excel files) and fixing what it finds (6–10 h). @@ -109,6 +114,7 @@ workbooks; results checked in as data, not Excel files) and fixing what it finds | Date | Change | Summary | |---|---|---| +| 2026-10-15 | minor | MEDIAN and AGGREGATE median use an overflow-safe midpoint (#1579); regressions cover finite extremes, tiny values and dependent recalculation | | 2026-10-13 | minor | Clear reports changed stored cells sparsely, including those beyond the first million coordinates; dependent formulas and removed spills update, while clearing empty merges still unblocks spills (#1032) | | 2026-10-12 | minor | USDOLLAR added as a Compatibility function (not on Microsoft's list, so the counts don't change); MIRR with no positive flow is -1, COUNTIF's bare `"<="` / `">="` skip empty strings, INDIRECT with `a1` = FALSE gives `#REF!` for A1-style text (#738) | | 2026-10-11 | minor | TEXT / number formats fixed against Excel-cached results: fraction literals and zero fractions, `0` denominators zero padded, `?` pads the thousands separator, booleans use the text section (226 mismatches → 0 in 352 TEXT cells) | From 6e984bb84dfea70ce5c4def29a9fdeb14ffb122c Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Sun, 18 Oct 2026 14:31:36 -0400 Subject: [PATCH 6/6] ci: configure Dependabot for Cargo and GitHub Actions Co-authored-by: Dylan M. Taylor --- .github/dependabot.yml | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 .github/dependabot.yml diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 000000000..2c4b8cb1a --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,30 @@ +version: 2 +updates: + # Maintain Rust / Cargo dependencies + - package-ecosystem: "cargo" + directory: "/" + schedule: + interval: "daily" + time: "06:00" + timezone: "America/New_York" + # Consolidate updates into a single PR rather than one PR per crate + groups: + cargo-dependencies: + patterns: + - "*" + commit-message: + prefix: "chore(deps)" + + # Maintain GitHub Actions workflows + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "daily" + time: "06:00" + timezone: "America/New_York" + groups: + actions: + patterns: + - "*" + commit-message: + prefix: "ci(deps)"