Merge main into fix/lambda-recursion-stack

This commit is contained in:
dotCooCoo
2026-10-18 12:58:08 -07:00
12 changed files with 254 additions and 28 deletions
+30
View File
@@ -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)"
Generated
+16 -16
View File
@@ -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",
+1 -1
View File
@@ -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"
+28
View File
@@ -1298,3 +1298,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);
}
}
+7 -1
View File
@@ -675,7 +675,13 @@ fn insert_pivot(s: &mut Session, p: &Json) -> Result<Json> {
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")
+21 -1
View File
@@ -650,6 +650,10 @@ fn cell_look(wb: &Workbook, si: usize, sh: &Sheet, c: CellRef, cf: &mut Option<c
if st.font.underline != Underline::None {
style.font.underline = st.font.underline;
}
// 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),
@@ -1076,7 +1080,8 @@ fn draw_text(pg: &mut Page, b: &Block, c: CellRef, l: &Look, rect: (f32, f32, f3
let max_chars = (avail / digit).floor().max(1.0) as usize;
gridcraft_numfmt::format_general_fit(n, max_chars.min(11)).unwrap_or_else(|| "#".repeat(max_chars.min(255)))
} else {
crate::display::cell_text(wb, sh, c)
// The look's code, which a conditional format can override (#1163).
crate::display::value_text_as(wb, &v, code)
};
if !wrap {
text = text.replace(['\n', '\r'], " ");
@@ -1932,6 +1937,21 @@ mod tests {
assert_eq!(expand_header_footer("&", &cx)[1].text, "");
}
#[test]
fn conditional_number_format_prints() {
// #1163: a rule's number format replaces the cell's own, whether that is General or not;
// cells the rule doesn't match keep theirs.
let mut s = s();
s.execute("range.setValues", json!({"range": "A1", "values": [[1], [3], [4]]})).unwrap();
s.execute("home.numberFormat", json!({"range": "A3", "code": "0.0"})).unwrap();
let rule = json!({"type": "cellIs", "operator": "greater", "value": "2", "style": {"num_fmt": "0.00"}});
s.execute("home.conditionalFormat", json!({"range": "A1:A3", "rule": rule})).unwrap();
let (pdf, _) = pdf_of(&mut s, json!({}));
assert!(contains(&pdf, "(1) Tj"), "an unmatched cell keeps General");
assert!(contains(&pdf, "(3.00) Tj") && !contains(&pdf, "(3) Tj"), "General cell under the rule");
assert!(contains(&pdf, "(4.00) Tj") && !contains(&pdf, "(4.0) Tj"), "formatted cell under the rule");
}
#[test]
fn export_writes_file() {
let mut s = s();
+7 -1
View File
@@ -95,7 +95,13 @@ pub fn cell_text(wb: &Workbook, sheet: &Sheet, c: CellRef) -> 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;
+33
View File
@@ -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);
}
+41 -1
View File
@@ -54,7 +54,7 @@ fn median(v: Vec<f64>) -> R<f64> {
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::<Vec<_>>()), 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);
+26
View File
@@ -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;
+37 -6
View File
@@ -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
+7 -1
View File
@@ -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) |