From 669f9cc53d6467e67b63a781e0c587413c1358ee Mon Sep 17 00:00:00 2001 From: Sebastian Whincop Date: Sun, 11 Oct 2026 16:25:10 +1300 Subject: [PATCH 001/259] Accuracy: Excel's snap to zero at a formula's root and in SUM Excel returns exactly 0 when an add or subtract of opposite-sign operands cancels to less than 2^(e-49), e the binary exponent of the LEFT operand. It does this only at the bare root of a formula (=A-B, the end of =0+A-B) and in the last addition of every SUM, never inside parentheses, unary signs, IF branches or inner steps. GridCraft returned the IEEE residue (=1-1.0000000000000002 gave -2.2e-16, =0.1+0.2-0.3 gave 5.5e-17). - core: arith::snap_add and arith::sum_snapped - calc: Evaluator::value_root applies the snap at the formula root - functions: SUM uses sum_snapped - tests: unit tests for the threshold and operand edge cases, evaluator tests for snapped and not-snapped shapes (including =SUM(A-B) and =A-B-0, which Excel does not snap), error and text operands, and a binade-crossing case Evidence: the Spreadsheet Accuracy Suite's high-confidence formula rows go from 488 failures to 47 with no regressions, and 297 probe rows recalculated by Windows Excel 16.0.20430 (11 magnitudes, 6-10 ulp thresholds, negative left operands, SUM shapes) match bit for bit; the unfixed code matched 174. modified: crates/calc/src/eval.rs modified: crates/calc/src/recalc.rs modified: crates/calc/src/tests.rs new file: crates/core/src/arith.rs modified: crates/core/src/lib.rs modified: crates/functions/src/math.rs --- crates/calc/src/eval.rs | 25 +++++++ crates/calc/src/recalc.rs | 4 +- crates/calc/src/tests.rs | 136 +++++++++++++++++++++++++++++++++++ crates/core/src/arith.rs | 71 ++++++++++++++++++ crates/core/src/lib.rs | 1 + crates/functions/src/math.rs | 3 +- 6 files changed, 237 insertions(+), 3 deletions(-) create mode 100644 crates/core/src/arith.rs diff --git a/crates/calc/src/eval.rs b/crates/calc/src/eval.rs index eff659d989..294575b85f 100644 --- a/crates/calc/src/eval.rs +++ b/crates/calc/src/eval.rs @@ -108,6 +108,20 @@ impl<'h> Evaluator<'h> { self.deref(ev) } + /// Evaluates the root of a formula. Excel snaps a near-cancelling add or subtract to exactly 0 + /// only here (and in the last addition of a `SUM`), never inside parentheses, unary signs, + /// `IF` branches or an inner step: `=A1-B1` is 0 over two numbers one ulp apart, `=(A1-B1)` is not. + pub fn value_root(&mut self, e: &Expr) -> Value { + match e { + Expr::Binary(op @ (BinOp::Add | BinOp::Sub), a, b) => { + let x = self.value(a); + let y = self.value(b); + zip2(&x, &y, |l, r| binary_scalar_snapped(*op, l, r)) + } + _ => self.value(e), + } + } + fn resolve_sheet(&self, s: &SheetSel) -> Result, CellError> { let wb = self.host.workbook(); match s { @@ -1303,6 +1317,17 @@ pub fn binary_values(op: BinOp, x: &Value, y: &Value) -> Value { zip2(x, y, |a, b| binary_scalar(op, a, b)) } +/// `a + b` or `a - b` with Excel's snap to zero (see [`Evaluator::value_root`]). +fn binary_scalar_snapped(op: BinOp, a: &Value, b: &Value) -> Value { + match (a, b) { + (Value::Error(_), _) | (_, Value::Error(_)) => binary_scalar(op, a, b), + _ => match (a.to_number(), b.to_number()) { + (Ok(x), Ok(y)) => Value::number(gridcraft_core::arith::snap_add(x, if op == BinOp::Sub { -y } else { y })), + (Err(e), _) | (_, Err(e)) => Value::Error(e), + }, + } +} + fn binary_scalar(op: BinOp, a: &Value, b: &Value) -> Value { if let Value::Error(e) = a { return Value::Error(*e); diff --git a/crates/calc/src/recalc.rs b/crates/calc/src/recalc.rs index 4cf0c6a21c..29f680622d 100644 --- a/crates/calc/src/recalc.rs +++ b/crates/calc/src/recalc.rs @@ -430,7 +430,7 @@ impl PassHost<'_> { let expr = std::sync::Arc::clone(expr); let v = { let mut ev = Evaluator::new(self, k.0, k.1); - ev.value(&expr) + ev.value_root(&expr) }; // A legacy array formula fills the range it was entered in. let legacy = self.wb.sheet(k.0).and_then(|s| s.cell(k.1)).and_then(|c| c.formula.as_ref()).and_then(|f| f.array); @@ -958,7 +958,7 @@ pub fn evaluate_expr(wb: &Workbook, sheet: usize, at: CellRef, expr: &Expr) -> V depth: 0, }; let mut ev = Evaluator::new(&mut host, sheet, at); - ev.value(expr) + ev.value_root(expr) } #[allow(clippy::disallowed_methods)] // the clock is read only off wasm diff --git a/crates/calc/src/tests.rs b/crates/calc/src/tests.rs index d1e8576dc5..5714718ff9 100644 --- a/crates/calc/src/tests.rs +++ b/crates/calc/src/tests.rs @@ -467,3 +467,139 @@ fn sumif_resized_sum_range_on_another_sheet_and_through_a_name() { assert_eq!(t.num("C1"), 10.0); assert_eq!(t.num("C2"), 5.0); } + +// ---- Excel's snap to zero (suite: cancel-ulps, cancel-band, decimal-sum, excel-probe) ---- + +impl T { + /// Stores an exact double, bypassing input parsing. + fn put(&mut self, at: &str, x: f64) { + self.wb.sheet_mut(0).unwrap().cells.set(c(at), Cell::value(Value::Number(x))); + self.calc.cells_changed(&mut self.wb, &[(0, c(at))]); + } +} + +const ULP1: f64 = f64::EPSILON; // the spacing of doubles just above 1.0 + +#[test] +fn a_bare_root_add_or_sub_snaps_a_near_cancellation_to_zero() { + let mut t = T::new(); + t.put("A1", 1.0); + t.put("B1", 1.0 + ULP1); // one ulp apart: Excel shows 0, not -2.22e-16 + t.set("C1", "=A1-B1"); + t.set("C2", "=B1-A1"); + t.set("C3", "=A1+-B1"); + t.set("C4", "=0+A1-B1"); // the end of =0+A-B snaps too + t.set("C5", "=SUM(A1,-B1)*1"); // the last addition of a SUM snaps even when SUM isn't the root + t.put("D1", -(1.0 + ULP1)); + t.set("C6", "=SUM(A1,D1)"); + t.set("C7", "=A1+D1"); + for at in ["C1", "C2", "C3", "C4", "C5", "C6", "C7"] { + assert_eq!(t.get(at), Value::Number(0.0), "{at}"); + } + // The classic: 0.1+0.2-0.3 is 0 in Excel (5.55e-17 in IEEE). + t.put("E1", 0.1); + t.put("E2", 0.2); + t.put("E3", 0.3); + t.set("E4", "=E1+E2-E3"); + assert_eq!(t.get("E4"), Value::Number(0.0)); +} + +#[test] +fn the_snap_threshold_is_two_to_the_e_minus_49_of_the_left_operand() { + let mut t = T::new(); + t.put("A1", 1.0); + // 7 ulps below the threshold of 8 ulps (2^-49): snaps. Exactly 8: does not. + t.put("B1", 1.0 + 7.0 * ULP1); + t.put("B2", 1.0 + 8.0 * ULP1); + t.set("C1", "=A1-B1"); + t.set("C2", "=A1-B2"); + assert_eq!(t.get("C1"), Value::Number(0.0)); + assert_eq!(t.get("C2"), Value::Number(-8.0 * ULP1)); + // The binade is the LEFT operand's (Excel observed, request 4, excel-probe-4.00044 to 00046). + t.put("A3", 0.4999999999999998); + t.put("B3", 0.5000000000000002); + t.set("C3", "=A3-B3"); + t.set("C4", "=B3-A3"); + assert_eq!(t.get("C3"), Value::Number(-4.440892098500626e-16)); + assert_eq!(t.get("C4"), Value::Number(0.0)); +} + +#[test] +fn the_snap_does_not_apply_inside_parentheses_unary_if_or_inner_steps() { + let mut t = T::new(); + t.put("A1", 1.0); + t.put("B1", 1.0 + ULP1); + let residue = 1.0 - (1.0 + ULP1); + for (at, f) in [ + ("C1", "=(A1-B1)"), + ("C2", "=+(A1-B1)"), + ("C3", "=-(B1-A1)"), + ("C4", "=IF(TRUE,A1-B1)"), + ("C5", "=A1-B1+0"), // the root is the +0; the inner subtraction is not a root + ("C6", "=SUM(A1,-B1,0)"), // the last addition is residue+0 + ] { + t.set(at, f); + assert_eq!(t.get(at), Value::Number(residue), "{f}"); + } + // Excel observed (request 3): =SUM(A-B) does not snap (the subtraction is an inner step, the SUM's + // last addition is residue + 0), and neither does a root that adds a zero. + t.set("C7", "=SUM(A1-B1)"); + t.set("C8", "=A1-B1-0"); + assert_eq!(t.get("C7"), Value::Number(residue)); + assert_eq!(t.get("C8"), Value::Number(residue)); + // Only the LAST addition of a SUM snaps: 3+1e16-1e16 is 4, not 0. + t.put("D1", 3.0); + t.put("D2", 1e16); + t.put("D3", -1e16); + t.put("D4", 0.0); + t.set("E1", "=SUM(D1:D4)"); + assert_eq!(t.get("E1"), Value::Number(4.0)); +} + +#[test] +fn same_sign_and_exact_arithmetic_never_snap() { + let mut t = T::new(); + t.put("A1", 1e-20); + t.put("B1", 2e-20); + t.set("C1", "=A1+B1"); // a tiny sum of small numbers is not a cancellation + t.set("C2", "=A1-B1"); + t.put("A2", 3.0); + t.put("B2", 2.0); + t.set("C3", "=A2-B2"); + assert_eq!(t.num("C1"), 1e-20 + 2e-20); + assert_eq!(t.num("C2"), 1e-20 - 2e-20); + assert_eq!(t.num("C3"), 1.0); +} + +#[test] +fn the_snap_threshold_follows_the_left_operand_across_a_binade_boundary() { + // Windows Excel 16.0.20430, snap probe rows 226, 231 and 236 (=A-B, A just below 1.0): the + // threshold is 2^-50 (A's binade), not 2^-49 (B's), so 7.8e-16 snaps and 9.99e-16 does not. + let mut t = T::new(); + t.put("A1", 0.9999999999999999); + t.put("B1", 1.0000000000000004); + t.put("B2", 1.0000000000000007); + t.put("B3", 1.0000000000000009); + t.set("D1", "=A1-B1"); + t.set("D2", "=A1-B2"); + t.set("D3", "=A1-B3"); + assert_eq!(t.get("D1"), Value::Number(0.0)); + assert_eq!(t.get("D2"), Value::Number(0.0)); + assert_eq!(t.get("D3"), Value::Number(-9.992007221626409e-16)); +} + +#[test] +fn the_snap_leaves_error_and_text_operands_alone() { + let mut t = T::new(); + t.put("B1", 1.0 + ULP1); + t.set("A1", "=1/0"); + t.set("C1", "=A1-B1"); + assert_eq!(t.get("C1"), Value::Error(CellError::Div0)); + t.set("A2", "abc"); + t.set("C2", "=A2-B1"); + assert_eq!(t.get("C2"), Value::Error(CellError::Value)); + t.set("A3", "1"); // numeric text still converts, then the subtraction snaps like any other + t.put("B3", 1.0); + t.set("C3", "=A3-B3"); + assert_eq!(t.get("C3"), Value::Number(0.0)); +} diff --git a/crates/core/src/arith.rs b/crates/core/src/arith.rs new file mode 100644 index 0000000000..f300a660d7 --- /dev/null +++ b/crates/core/src/arith.rs @@ -0,0 +1,71 @@ +//! Arithmetic that follows Excel's bits rather than IEEE's: the snap to zero. + +/// 2^-49: the snap threshold is this fraction of the left operand's binade. +const SNAP: f64 = 1.0 / 562_949_953_421_312.0; + +/// The power of two at the bottom of `x`'s binade (`x` with its mantissa cleared); 0 for zero, +/// subnormal and non-finite values. +fn binade(x: f64) -> f64 { + if x.is_finite() { f64::from_bits(x.to_bits() & 0x7FF0_0000_0000_0000) } else { 0.0 } +} + +/// `x + y` as Excel computes it at the root of a formula or as the last addition of a `SUM`: +/// when the operands have opposite signs and `|x + y| < 2^(e-49)`, `e` the binary exponent of +/// the LEFT operand `x`, the result is exactly 0. A subtraction passes `-y`. +/// +/// Excel does this only at those places, not at every add or subtract (see +/// `EXCEL-BEHAVIOUR.md` of the Spreadsheet Accuracy Suite, "The snap to zero"). +pub fn snap_add(x: f64, y: f64) -> f64 { + let s = x + y; + let opposite = (x > 0.0 && y < 0.0) || (x < 0.0 && y > 0.0); + if opposite && s.abs() < binade(x) * SNAP { 0.0 } else { s } +} + +/// The sum of `values` left to right, each addition rounded, with the LAST addition snapped. +/// This is `SUM`'s arithmetic: `SUM(a, -b)` is 0 for a near-cancellation, but `SUM(a, -b, 0)` keeps +/// the residue because its last addition is residue + 0. +pub fn sum_snapped(values: &[f64]) -> f64 { + let Some((last, head)) = values.split_last() else { return 0.0 }; + snap_add(head.iter().fold(0.0, |acc, v| acc + v), *last) +} + +#[cfg(test)] +mod tests { + use super::*; + + const ULP1: f64 = f64::EPSILON; + + #[test] + fn snaps_below_two_to_the_e_minus_49_of_the_left_operand() { + assert_eq!(snap_add(1.0, -(1.0 + ULP1)), 0.0); + assert_eq!(snap_add(1.0, -(1.0 + 7.0 * ULP1)), 0.0); + // Exactly 2^-49 (8 ulps of 1.0) does not snap. + assert_eq!(snap_add(1.0, -(1.0 + 8.0 * ULP1)), -8.0 * ULP1); + // The left operand's binade decides (Excel observed, request 4, excel-probe-4.00044 to 00046): + // 0.4999999999999998 - 0.5000000000000002 keeps -4.44e-16 (threshold 2^-51 is not beaten), + // the other way round it is 0 (threshold 2^-50). + assert_eq!(snap_add(0.4999999999999998, -0.5000000000000002), -4.440892098500626e-16); + assert_eq!(snap_add(0.5000000000000002, -0.4999999999999998), 0.0); + } + + #[test] + fn never_snaps_same_sign_zero_or_hostile_operands() { + assert_eq!(snap_add(1e-20, 2e-20), 1e-20 + 2e-20); + assert_eq!(snap_add(3.0, 0.0), 3.0); + assert_eq!(snap_add(0.0, -1e-300), -1e-300); + // A subnormal left operand has no binade to measure against: it never snaps. + assert_eq!(snap_add(5e-324, -1e-323), -5e-324); + assert!(snap_add(f64::INFINITY, -f64::INFINITY).is_nan()); + assert_eq!(snap_add(-1.0, 1.0 + ULP1), 0.0); + } + + #[test] + fn sum_snaps_only_its_last_addition() { + assert_eq!(sum_snapped(&[]), 0.0); + assert_eq!(sum_snapped(&[1.0, -(1.0 + ULP1)]), 0.0); + assert_eq!(sum_snapped(&[1.0, -(1.0 + ULP1), 0.0]), 1.0 - (1.0 + ULP1)); + assert_eq!(sum_snapped(&[3.0, 1e16, -1e16, 0.0]), 4.0); + // Left to right, each addition rounded: ten 0.1s do not make 1. + assert_eq!(sum_snapped(&[0.1; 10]), 0.999_999_999_999_999_9); + } +} diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs index d5adbc07ed..e9debad5be 100644 --- a/crates/core/src/lib.rs +++ b/crates/core/src/lib.rs @@ -4,6 +4,7 @@ #![forbid(unsafe_code)] pub mod addr; +pub mod arith; pub mod date; pub mod parse; pub mod value; diff --git a/crates/functions/src/math.rs b/crates/functions/src/math.rs index 4d9eb45967..db033699e5 100644 --- a/crates/functions/src/math.rs +++ b/crates/functions/src/math.rs @@ -83,7 +83,8 @@ fn round_dir(x: f64, digits: i32, up: bool) -> f64 { // Sums and products fn sum(a: &[Arg], _c: &mut dyn Ctx) -> R { - num_val(numbers(a)?.iter().sum()) + // Left to right, each addition rounded; Excel snaps only the last one to zero. + num_val(gridcraft_core::arith::sum_snapped(&numbers(a)?)) } fn sumsq(a: &[Arg], _c: &mut dyn Ctx) -> R { From c0f448050435098a536a9717848c603e4632431a Mon Sep 17 00:00:00 2001 From: Sebastian Whincop Date: Sun, 11 Oct 2026 16:54:50 +1300 Subject: [PATCH 002/259] Accuracy: snap a defined name's body, and only the root in Evaluate Formula Excel treats a defined name's body as a formula of its own, so the snap to zero (an add or subtract of opposite-sign operands that cancels to less than 2^(e-49) of the left operand becomes exactly 0) applies to the body by the same root rule as a cell, wherever the name is used. A name probe on Windows Excel 16.0.20430 (8 name bodies in 12 positions: =N, =N*1, =N+0, =0+N, =(N), =-N, =+N, =SUM(N), =IF(TRUE,N), =ABS(N), =1*N, =N-0) shows the result depends only on the body's shape: a body that is a bare subtraction, a SUM's last addition, or a name for one snaps everywhere; a parenthesised, unary, IF, inner-step or +0 body keeps its residue everywhere. Sheet-scoped names and array-valued names behave the same way in Excel. The 8 bodies use a gap of one ulp; the threshold for name bodies rests on request 3's lookup-shapes.xlsx (Sheet1!H15:H26: =Gap, =Gap*1, =Gap+0 are 0 for gaps of 1, 4 and 7 ulps, and the 8-ulp gap keeps 1.78e-15). This is the case the suite leaves undecided:name-snap. - calc: Evaluator::name evaluates a body whose root is an add or subtract with value_root. Any other body, a range included, is evaluated as before, so range-valued names still refer to their range. A name that points at another name inherits the snap through the existing recursion. - calc: value_root charges its own level of the depth budget. It bypasses Evaluator::eval, so without this a chain of names cost half the depth and recursive names nested through cells could overflow the stack. - calc: evaluate_inner_expr evaluates a sub-expression without the root snap. - engine: Evaluate Formula uses the root snap for the root step only. An inner step of =(A1-B1) used to show 0 while the cell showed the residue. - tests: 8 bodies x 12 positions with signs, Excel's own Gap shapes, range-, array- and sheet-scoped names, self-referential and long name chains, a guard that range-valued names still refer to their range, and the Evaluate Formula steps. Evidence: the 96-row name probe matched Excel on 72 rows before and matches on all 96 now; the Spreadsheet Accuracy Suite's formula rows show no new failures and the 297-row snap probe is unchanged. modified: crates/calc/src/eval.rs modified: crates/calc/src/recalc.rs modified: crates/calc/src/tests.rs modified: crates/engine/src/cmd/formulas.rs modified: crates/engine/src/tests.rs --- crates/calc/src/eval.rs | 16 +++- crates/calc/src/recalc.rs | 12 ++- crates/calc/src/tests.rs | 153 ++++++++++++++++++++++++++++++ crates/engine/src/cmd/formulas.rs | 7 +- crates/engine/src/tests.rs | 25 +++++ 5 files changed, 210 insertions(+), 3 deletions(-) diff --git a/crates/calc/src/eval.rs b/crates/calc/src/eval.rs index 294575b85f..575db755cc 100644 --- a/crates/calc/src/eval.rs +++ b/crates/calc/src/eval.rs @@ -114,8 +114,16 @@ impl<'h> Evaluator<'h> { pub fn value_root(&mut self, e: &Expr) -> Value { match e { Expr::Binary(op @ (BinOp::Add | BinOp::Sub), a, b) => { + // The root skips `eval`, so it must pay its own depth: a name that refers to itself + // (directly or through cells) is bounded only by this counter. + self.depth += 1; + if self.depth > MAX_DEPTH { + self.depth -= 1; + return Value::Error(CellError::Calc); + } let x = self.value(a); let y = self.value(b); + self.depth -= 1; zip2(&x, &y, |l, r| binary_scalar_snapped(*op, l, r)) } _ => self.value(e), @@ -261,7 +269,13 @@ impl<'h> Evaluator<'h> { let saved = self.sheet; // Unqualified references in a name refer to the scope sheet. self.sheet = scope; - let r = self.eval(&expr); + // A name's body is a formula of its own, so Excel snaps it by the root rule wherever + // the name is used (name probe, Windows Excel 16.0.20430). Only a value-producing + // add or subtract needs it; any other body, a range included, stays as it is. + let r = match &expr { + Expr::Binary(BinOp::Add | BinOp::Sub, ..) => Ev::V(self.value_root(&expr)), + _ => self.eval(&expr), + }; self.sheet = saved; r } diff --git a/crates/calc/src/recalc.rs b/crates/calc/src/recalc.rs index 29f680622d..5bd1858487 100644 --- a/crates/calc/src/recalc.rs +++ b/crates/calc/src/recalc.rs @@ -941,6 +941,16 @@ pub fn evaluate(wb: &Workbook, sheet: usize, at: CellRef, formula: &str) -> Valu } pub fn evaluate_expr(wb: &Workbook, sheet: usize, at: CellRef, expr: &Expr) -> Value { + evaluate_expr_at(wb, sheet, at, expr, true) +} + +/// Like [`evaluate_expr`] for a sub-expression of a larger formula (Evaluate Formula's steps): Excel's +/// snap to zero applies at a formula's root only, so an inner `A1-B1` keeps its IEEE residue. +pub fn evaluate_inner_expr(wb: &Workbook, sheet: usize, at: CellRef, expr: &Expr) -> Value { + evaluate_expr_at(wb, sheet, at, expr, false) +} + +fn evaluate_expr_at(wb: &Workbook, sheet: usize, at: CellRef, expr: &Expr, root: bool) -> Value { let exprs = HashMap::default(); let mut rng = 0x1234_5678_9ABC_DEF0u64; let mut host = PassHost { @@ -958,7 +968,7 @@ pub fn evaluate_expr(wb: &Workbook, sheet: usize, at: CellRef, expr: &Expr) -> V depth: 0, }; let mut ev = Evaluator::new(&mut host, sheet, at); - ev.value_root(expr) + if root { ev.value_root(expr) } else { ev.value(expr) } } #[allow(clippy::disallowed_methods)] // the clock is read only off wasm diff --git a/crates/calc/src/tests.rs b/crates/calc/src/tests.rs index 5714718ff9..0dc464ca5f 100644 --- a/crates/calc/src/tests.rs +++ b/crates/calc/src/tests.rs @@ -603,3 +603,156 @@ fn the_snap_leaves_error_and_text_operands_alone() { t.set("C3", "=A3-B3"); assert_eq!(t.get("C3"), Value::Number(0.0)); } + +// ---- the snap in a defined name's body (Windows Excel 16.0.20430, name probe: 8 bodies x 12 positions) ---- + +impl T { + fn name(&mut self, name: &str, body: &str) { + self.wb.names.push(gridcraft_model::DefinedName { + name: name.into(), + scope: None, + formula: body.into(), + comment: String::new(), + hidden: false, + }); + } +} + +#[test] +fn a_defined_name_is_a_formula_of_its_own_so_its_body_snaps_wherever_it_is_used() { + let mut t = T::new(); + t.put("A1", 1.0); + t.put("B1", 1.0 + ULP1); + // (body, does Excel snap it?) Only the shape of the BODY matters, not where the name is used. + let bodies = [ + ("N_sub", "Sheet1!$A$1-Sheet1!$B$1", true), + ("N_par", "(Sheet1!$A$1-Sheet1!$B$1)", false), + ("N_plus0", "Sheet1!$A$1-Sheet1!$B$1+0", false), + ("N_sum", "SUM(Sheet1!$A$1,-Sheet1!$B$1)", true), + ("N_neg", "-(Sheet1!$B$1-Sheet1!$A$1)", false), + ("N_ref", "N_sub", true), // a name that is a name inherits it + ("N_if", "IF(TRUE,Sheet1!$A$1-Sheet1!$B$1)", false), + ("N_sumsub", "SUM(Sheet1!$A$1-Sheet1!$B$1)", false), + ]; + for (name, body, _) in bodies { + t.name(name, body); + } + // All 12 positions of the Excel probe. + let uses = ["{n}", "{n}*1", "{n}+0", "0+{n}", "({n})", "-{n}", "+{n}", "SUM({n})", "IF(TRUE,{n})", "ABS({n})", "1*{n}", "{n}-0"]; + let mut row = 2; + for (name, body, snaps) in bodies { + for u in uses { + let at = format!("D{row}"); + t.set(&at, &format!("={}", u.replace("{n}", name))); + // Every body is -1 ulp (A1-B1) or exactly 0; a minus sign or ABS flips the residue. + let flips = u.starts_with('-') || u.starts_with("ABS"); + let want = if snaps { + 0.0 + } else if flips { + ULP1 + } else { + -ULP1 + }; + assert_eq!(t.get(&at), Value::Number(want), "{u} over {name} = {body}"); + row += 1; + } + } +} + +#[test] +fn excels_own_gap_shapes_from_lookup_shapes_xlsx() { + // excel-evidence/request-3/files/lookup-shapes.xlsx, Sheet1!H15:H26: the gaps of 1, 4 and 7 ulps + // snap to 0 as =Gap, =Gap*1 and =Gap+0; the gap of 8 ulps keeps its residue. + let mut t = T::new(); + for (cell, v) in [("A1", 1.0000000000000002), ("A2", 1.0000000000000009), ("A3", 1.0000000000000016), ("A4", 1.0000000000000018)] { + t.put(cell, v); + } + for (cell, v) in [("B1", 1.0), ("B2", 1.0), ("B3", 1.0), ("B4", 1.0)] { + t.put(cell, v); + } + for (name, row) in [("Gap_1", 1), ("Gap_4", 2), ("Gap_7", 3), ("Gap_8", 4)] { + t.name(name, &format!("Sheet1!$A${row}-Sheet1!$B${row}")); + } + let mut r = 10; + for (name, residue) in [("Gap_1", 0.0), ("Gap_4", 0.0), ("Gap_7", 0.0), ("Gap_8", 1.7763568394002505e-15)] { + for f in ["{n}", "{n}*1", "{n}+0"] { + let at = format!("F{r}"); + t.set(&at, &format!("={}", f.replace("{n}", name))); + assert_eq!(t.get(&at), Value::Number(residue), "{f} over {name}"); + r += 1; + } + } +} + +#[test] +fn a_name_that_refers_to_a_range_still_refers_to_it() { + // Only a reference answers these: ISREF, ROW and OFFSET over a name. Wrapping every name + // body in value_root would turn the range into an array and fail all three. + let mut t = T::new(); + t.put("A1", 1.0); + t.put("A2", 2.0); + t.put("B1", 3.0); + t.put("B2", 4.0); + t.name("Block", "Sheet1!$A$1:$B$2"); + t.set("D1", "=ISREF(Block)"); + t.set("D2", "=MIN(ROW(Block))"); + t.set("D3", "=SUM(OFFSET(Block,0,1))"); + assert_eq!(t.get("D1"), Value::Bool(true)); + assert_eq!(t.num("D2"), 1.0); + assert_eq!(t.num("D3"), 7.0); +} + +#[test] +fn a_name_whose_body_is_an_array_difference_snaps_element_wise() { + // Windows Excel (review probe): INDEX(Arr,1)*1 and INDEX(Arr,2)*1 are 0 for Arr = A1:A2-B1:B2. + let mut t = T::new(); + for (a, b) in [("A1", "B1"), ("A2", "B2")] { + t.put(a, 1.0); + t.put(b, 1.0 + ULP1); + } + t.name("Arr", "Sheet1!$A$1:$A$2-Sheet1!$B$1:$B$2"); + t.set("D1", "=INDEX(Arr,1)*1"); + t.set("D2", "=INDEX(Arr,2)*1"); + t.set("D3", "=SUM(Arr)"); + assert_eq!((t.get("D1"), t.get("D2")), (Value::Number(0.0), Value::Number(0.0))); + assert_eq!(t.get("D3"), Value::Number(0.0)); +} + +#[test] +fn a_sheet_scoped_name_snaps_on_its_own_sheet() { + // Windows Excel (review probe): a sheet-scoped Loc = S2!$A$1-S2!$B$1 gives 0 as =S2!Loc*1. + let mut t = T::new(); + t.wb.sheets.push(std::sync::Arc::new(gridcraft_model::Sheet::new("S2"))); + t.wb.sheet_mut(1).unwrap().cells.set(c("A1"), Cell::value(Value::Number(1.0))); + t.wb.sheet_mut(1).unwrap().cells.set(c("B1"), Cell::value(Value::Number(1.0 + ULP1))); + t.wb.names.push(gridcraft_model::DefinedName { + name: "Loc".into(), + scope: Some(1), + formula: "S2!$A$1-S2!$B$1".into(), + comment: String::new(), + hidden: false, + }); + t.set("D1", "=S2!Loc*1"); + t.set("D2", "=S2!Loc"); + assert_eq!((t.get("D1"), t.get("D2")), (Value::Number(0.0), Value::Number(0.0))); +} + +#[test] +fn recursive_names_stay_bounded_and_cost_the_same_depth_as_before_the_snap() { + let mut t = T::new(); + // A name that refers to itself ends in #CALC!, not a stack overflow. + t.name("Rec", "Rec-1"); + t.set("A1", "=Rec"); + assert_eq!(t.get("A1"), Value::Error(CellError::Calc)); + // A long chain of names, each one `-0` over the last, runs out of depth at the same place as it did + // before names were snapped (value_root must charge its own level, or each link would be cheaper + // and a chain through cells could overflow the stack). + t.name("Nm_0", "1"); + for i in 1..=250 { + t.name(&format!("Nm_{i}"), &format!("Nm_{}-0", i - 1)); + } + t.set("A2", "=Nm_250"); + assert_eq!(t.get("A2"), Value::Error(CellError::Calc)); + t.set("A3", "=Nm_20"); + assert_eq!(t.get("A3"), Value::Number(1.0)); +} diff --git a/crates/engine/src/cmd/formulas.rs b/crates/engine/src/cmd/formulas.rs index f3576e4f53..7fa3a7acbc 100644 --- a/crates/engine/src/cmd/formulas.rs +++ b/crates/engine/src/cmd/formulas.rs @@ -444,7 +444,12 @@ fn evaluate_formula(s: &mut Session, p: &Json) -> Result { } }); for e in nodes.into_iter().rev().take(200) { - let v = gridcraft_calc::recalc::evaluate_expr(&d.wb, sheet, at, &e); + // The snap to zero belongs to a formula's root: the root step is the result, an inner one is not. + let v = if e == expr { + gridcraft_calc::recalc::evaluate_expr(&d.wb, sheet, at, &e) + } else { + gridcraft_calc::recalc::evaluate_inner_expr(&d.wb, sheet, at, &e) + }; steps.push(json!({"expression": gridcraft_formula::print(&e), "value": crate::cmd::inspect::value_json(&v)})); } let result = gridcraft_calc::recalc::evaluate_expr(&d.wb, sheet, at, &expr); diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 6bd03d55ea..58432cabb7 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -1633,3 +1633,28 @@ fn notes_comments_and_links_survive_a_json_save() { assert_eq!(after.hyperlinks, before.hyperlinks); assert_eq!(after.cells, before.cells); } + +#[test] +fn evaluate_formula_steps_snap_only_the_root() { + // Excel snaps a near-cancelling subtraction only at the root of a formula, so an inner step shows + // the IEEE residue while the final result of =(A1-B1) is the same residue, and =A1-B1 shows 0 for both. + let mut s = s(); + s.execute("range.setValues", json!({"range": "A1", "values": [[1.0, 1.0 + f64::EPSILON]]})).unwrap(); + let steps = |s: &mut Session, f: &str| s.execute("formulas.evaluateFormula", json!({"formula": f})).unwrap(); + let find = |r: &serde_json::Value, expr: &str| { + r["steps"].as_array().unwrap().iter().find(|x| x["expression"] == expr).map(|x| x["value"].as_f64().unwrap()) + }; + + let r = steps(&mut s, "=(A1-B1)"); + assert_eq!(find(&r, "A1-B1"), Some(-f64::EPSILON)); + assert_eq!(r["result"].as_f64(), Some(-f64::EPSILON)); + + let r = steps(&mut s, "=A1-B1"); + assert_eq!(find(&r, "A1-B1"), Some(0.0), "the root step is the result"); + assert_eq!(r["result"].as_f64(), Some(0.0)); + + // An inner step of a larger formula does not snap, even when it is itself a subtraction. + let r = steps(&mut s, "=A1-B1+0"); + assert_eq!(find(&r, "A1-B1"), Some(-f64::EPSILON)); + assert_eq!(r["result"].as_f64(), Some(-f64::EPSILON)); +} From 27fc0e828f43dbfd7dc97130c0b356c07f25cb8d Mon Sep 17 00:00:00 2001 From: Sebastian Whincop Date: Sun, 11 Oct 2026 17:19:52 +1300 Subject: [PATCH 003/259] Accuracy: snap a LAMBDA body by its own root Excel treats a LAMBDA body as a formula of its own, so the snap to zero (an add or subtract of opposite-sign operands that cancels to less than 2^(e-49) of the left operand becomes exactly 0) applies to a body whose root is an add or subtract, by the same root rule as a cell and as a defined name's body. A LAMBDA probe on Windows Excel 16.0.20430 (126 formulas) shows it in every context probed: a named lambda in 9 positions (=L(x,y), *1, +0, 0+, (..), -.., SUM, IF, ABS), inline lambdas, LET-bound lambdas, and MAP, REDUCE, SCAN, MAKEARRAY, BYROW and BYCOL. A bare x-y body (or a lambda that calls one) snaps; a parenthesised, unary, IF, inner-step, +0 or LET body keeps its residue. A review probe (34 more cells) showed the same 7-ulp/8-ulp threshold inside a body, element-wise snapping of array arguments, and that curried and nested lambdas, 0+x-y and a LET closure snap by the root of the inner body, while a body that returns a reference still returns one. - calc: call_lambda_values, the one place a LAMBDA body is evaluated for every call route, evaluates a body whose root is an add or subtract with value_root (which charges its own depth). Any other body, including one with a LET or IF at its root, is evaluated as before. - tests: 10 body shapes x 9 positions with signs, inline and LET-bound lambdas, one snapping and one non-snapping body under each higher-order function, array arguments, the threshold inside a body, curried, nested and closed-over bodies, a guard that reference-returning bodies still return a reference, and a self-recursive lambda ending in an error. Evidence: the 126-formula probe matched Excel on 92 rows before and matches on all 126 now; the Spreadsheet Accuracy Suite's formula rows show no new failures, and the 297-row snap probe and the 96-row name probe are unchanged. Conditional-formatting and data-validation formulas and Mac Excel were not probed. modified: crates/calc/src/eval.rs modified: crates/calc/src/tests.rs --- crates/calc/src/eval.rs | 8 +- crates/calc/src/tests.rs | 166 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 173 insertions(+), 1 deletion(-) diff --git a/crates/calc/src/eval.rs b/crates/calc/src/eval.rs index 575db755cc..fd872100c1 100644 --- a/crates/calc/src/eval.rs +++ b/crates/calc/src/eval.rs @@ -808,7 +808,13 @@ impl<'h> Evaluator<'h> { fn call_lambda_values(&mut self, l: &Lambda, bound: Vec<(String, Ev)>) -> Ev { let saved = std::mem::replace(&mut self.env, l.env.clone()); self.env.extend(bound); - let r = self.eval(&l.body); + // A LAMBDA body is a formula of its own, so Excel snaps it by the root rule in every context + // (named, inline, LET-bound, MAP, REDUCE, SCAN, MAKEARRAY, BYROW, BYCOL; LAMBDA probe on Windows + // Excel 16.0.20430). A LET, IF, parenthesis or unary sign at the root of the body does not. + let r = match &l.body { + Expr::Binary(BinOp::Add | BinOp::Sub, ..) => Ev::V(self.value_root(&l.body)), + _ => self.eval(&l.body), + }; self.env = saved; r } diff --git a/crates/calc/src/tests.rs b/crates/calc/src/tests.rs index 0dc464ca5f..ed11c0f852 100644 --- a/crates/calc/src/tests.rs +++ b/crates/calc/src/tests.rs @@ -756,3 +756,169 @@ fn recursive_names_stay_bounded_and_cost_the_same_depth_as_before_the_snap() { t.set("A3", "=Nm_20"); assert_eq!(t.get("A3"), Value::Number(1.0)); } + +// ---- the snap in a LAMBDA body (Windows Excel 16.0.20430, LAMBDA probe: 126 formulas) ---- +// A LAMBDA body is a formula of its own, like a defined name's: Excel snaps it by the root rule in every +// context (named, inline, LET-bound, and under MAP, REDUCE, SCAN, MAKEARRAY, BYROW and BYCOL). + +fn lambda_t() -> T { + let mut t = T::new(); + t.put("A1", 1.0); + t.put("B1", 1.0 + ULP1); + t.put("A2", 1.0 + ULP1); + t.put("B2", 1.0 + ULP1); + t +} + +#[test] +fn a_lambda_body_snaps_by_its_own_root_wherever_the_lambda_is_called() { + let mut t = lambda_t(); + // (name, body, does Excel snap it?) + let lambdas = [ + ("L_sub", "LAMBDA(x,y,x-y)", true), + ("L_par", "LAMBDA(x,y,(x-y))", false), + ("L_plus0", "LAMBDA(x,y,x-y+0)", false), + ("L_sum", "LAMBDA(x,y,SUM(x,-y))", true), + ("L_neg", "LAMBDA(x,y,-(y-x))", false), + ("L_if", "LAMBDA(x,y,IF(TRUE,x-y))", false), + ("L_sumsub", "LAMBDA(x,y,SUM(x-y))", false), + ("L_let", "LAMBDA(x,y,LET(d,x-y,d))", false), // a LET inside the body does not snap + ("L_letroot", "LAMBDA(x,y,LET(a,x,a-y))", false), + ("L_ref", "LAMBDA(x,y,L_sub(x,y))", true), // a lambda that calls one inherits it + ]; + for (name, body, _) in lambdas { + t.name(name, body); + } + let uses = ["{c}", "{c}*1", "{c}+0", "0+{c}", "({c})", "-{c}", "SUM({c})", "IF(TRUE,{c})", "ABS({c})"]; + let mut row = 5; + for (name, body, snaps) in lambdas { + for u in uses { + let at = format!("D{row}"); + t.set(&at, &format!("={}", u.replace("{c}", &format!("{name}(Sheet1!$A$1,Sheet1!$B$1)")))); + let flips = u.starts_with('-') || u.starts_with("ABS"); + let want = if snaps { + 0.0 + } else if flips { + ULP1 + } else { + -ULP1 + }; + assert_eq!(t.get(&at), Value::Number(want), "{u} over {name} = {body}"); + row += 1; + } + } +} + +#[test] +fn inline_and_let_bound_lambdas_snap_the_same_way() { + let mut t = lambda_t(); + for (i, (f, snaps)) in [ + ("LAMBDA(x,y,x-y)(Sheet1!$A$1,Sheet1!$B$1)", true), + ("LAMBDA(x,y,x-y)(Sheet1!$A$1,Sheet1!$B$1)*1", true), + ("LAMBDA(x,y,(x-y))(Sheet1!$A$1,Sheet1!$B$1)", false), + ("LAMBDA(x,y,x-y+0)(Sheet1!$A$1,Sheet1!$B$1)", false), + ("LAMBDA(x,y,SUM(x,-y))(Sheet1!$A$1,Sheet1!$B$1)", true), + ("LET(f,LAMBDA(x,y,x-y),f(Sheet1!$A$1,Sheet1!$B$1))", true), + ("LET(f,LAMBDA(x,y,x-y),f(Sheet1!$A$1,Sheet1!$B$1))*1", true), + ("LET(f,LAMBDA(x,y,(x-y)),f(Sheet1!$A$1,Sheet1!$B$1))", false), + ] + .into_iter() + .enumerate() + { + let at = format!("D{}", i + 5); + t.set(&at, &format!("={f}")); + assert_eq!(t.get(&at), Value::Number(if snaps { 0.0 } else { -ULP1 }), "{f}"); + } +} + +#[test] +fn higher_order_functions_snap_their_lambda_body() { + let mut t = lambda_t(); + // The cell is INDEX(,1,1), bare and times 1. A is (1, 1+ulp), B is (1+ulp, 1+ulp). + let cases = [ + ("MAP(Sheet1!$A$1:$A$2,Sheet1!$B$1:$B$2,LAMBDA(x,y,{b}))", "x-y"), + ("REDUCE(Sheet1!$A$1,Sheet1!$B$1,LAMBDA(x,y,{b}))", "x-y"), + ("SCAN(Sheet1!$A$1,Sheet1!$B$1:$B$2,LAMBDA(x,y,{b}))", "x-y"), + ("MAKEARRAY(1,1,LAMBDA(x,y,{b}))", "Sheet1!$A$1-Sheet1!$B$1"), + ("BYROW(Sheet1!$A$1:$B$2,LAMBDA(r,{b}))", "INDEX(r,1,1)-INDEX(r,1,2)"), + ("BYCOL(Sheet1!$A$1:$B$2,LAMBDA(r,{b}))", "INDEX(r,1,1)-INDEX(r,2,1)"), + ]; + let mut row = 5; + for (f, bare) in cases { + for (b, snaps) in [(bare.to_string(), true), (format!("({bare})"), false)] { + for u in ["INDEX({c},1,1)", "INDEX({c},1,1)*1"] { + let at = format!("D{row}"); + let formula = u.replace("{c}", &f.replace("{b}", &b)); + t.set(&at, &format!("={formula}")); + assert_eq!(t.get(&at), Value::Number(if snaps { 0.0 } else { -ULP1 }), "{formula}"); + row += 1; + } + } + } +} + +#[test] +fn a_lambda_body_that_returns_a_reference_still_returns_one() { + // Windows Excel (review probe): a body that returns a reference is still a reference, so only an + // add or subtract root takes the snapping path. ISREF and OFFSET need the reference; wrapping every + // body in value_root would turn it into a value. + let mut t = lambda_t(); + t.set("D5", "=ISREF(LAMBDA(x,x)(Sheet1!$A$1))"); + t.set("D6", "=ISREF(LAMBDA(x,IF(TRUE,x))(Sheet1!$A$1))"); + t.set("D7", "=ROW(LAMBDA(x,OFFSET(x,1,0))(Sheet1!$A$1))"); + assert_eq!(t.get("D5"), Value::Bool(true)); + assert_eq!(t.get("D6"), Value::Bool(true)); + assert_eq!(t.num("D7"), 2.0); +} + +#[test] +fn nested_curried_and_closed_over_lambda_bodies_snap_by_the_root_of_the_inner_body() { + // Windows Excel (review probe): each of these is exactly 0. + let mut t = lambda_t(); + for (i, f) in [ + "LAMBDA(x,LAMBDA(y,x-y))(Sheet1!$A$1)(Sheet1!$B$1)", // curried + "LAMBDA(x,y,LAMBDA(p,q,p-q)(x,y))(Sheet1!$A$1,Sheet1!$B$1)", // a body that calls an inner lambda + "LAMBDA(x,y,0+x-y)(Sheet1!$A$1,Sheet1!$B$1)", // the end of 0+x-y snaps like =0+A-B + "LET(a,Sheet1!$A$1,LAMBDA(y,a-y)(Sheet1!$B$1))", // a closure over a LET variable + "LAMBDA(x,y,x-y)(Sheet1!$A$1+0,Sheet1!$B$1+0)", + ] + .into_iter() + .enumerate() + { + let at = format!("D{}", i + 5); + t.set(&at, &format!("={f}")); + assert_eq!(t.get(&at), Value::Number(0.0), "{f}"); + } +} + +#[test] +fn a_lambda_body_uses_the_same_threshold_and_snaps_array_arguments_element_wise() { + // Windows Excel (review probe): 7 ulps snap, 8 ulps keep the residue, inside a body as in a cell. + let mut t = lambda_t(); + t.put("G1", 1.0); + t.put("G2", 1.0 + 7.0 * ULP1); + t.put("G3", 1.0 + 8.0 * ULP1); + t.set("D5", "=LAMBDA(x,y,x-y)(Sheet1!$G$1,Sheet1!$G$2)"); + t.set("D6", "=LAMBDA(x,y,x-y)(Sheet1!$G$1,Sheet1!$G$3)"); + assert_eq!(t.get("D5"), Value::Number(0.0)); + assert_eq!(t.get("D6"), Value::Number(-8.0 * ULP1)); + // Array arguments: each element snaps by its own left operand (1 and 3 are different binades). + t.put("E1", 1.0); + t.put("E2", 3.0); + t.put("F1", 1.0 + ULP1); + t.put("F2", 3.0 + 2.0 * ULP1); + t.set("D8", "=INDEX(LAMBDA(x,y,x-y)(Sheet1!$E$1:$E$2,Sheet1!$F$1:$F$2),1,1)"); + t.set("D9", "=INDEX(LAMBDA(x,y,x-y)(Sheet1!$E$1:$E$2,Sheet1!$F$1:$F$2),2,1)"); + assert_eq!(t.get("D8"), Value::Number(0.0)); + assert_eq!(t.get("D9"), Value::Number(0.0)); +} + +#[test] +fn a_lambda_that_calls_itself_ends_in_an_error_not_a_crash() { + // Which error depends on where the depth runs out first (#CALC! or #NAME?); what matters is that + // it ends. A recursion through cells is a separate, existing limit. + let mut t = lambda_t(); + t.name("Loop", "LAMBDA(x,Loop(x)-1)"); + t.set("D5", "=Loop(1)"); + assert!(matches!(t.get("D5"), Value::Error(_)), "{:?}", t.get("D5")); +} From fcb42c3d8e42d93f1a4557abb359eed355de1450 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9C=D0=B0=D0=B3=D0=B4=D0=B0=20=D0=9C=2E=D0=93=2E?= Date: Mon, 12 Oct 2026 13:50:03 +0300 Subject: [PATCH 004/259] i18n(ru): translate 547 more labels, dialog strings and tooltips The Russian catalog had 318 entries; every label, dialog string and tooltip that another catalog already translates (including the open Czech, Polish, Turkish and Spanish-dialog PRs) now has a Russian entry too: 865 in all. Terminology follows the names Excel uses in its Russian interface; the Paste Special access letters stay Latin, as in the Spanish catalog. --- crates/ui-egui/src/i18n/ru.rs | 567 ++++++++++++++++++++++++++++++++++ 1 file changed, 567 insertions(+) diff --git a/crates/ui-egui/src/i18n/ru.rs b/crates/ui-egui/src/i18n/ru.rs index c8f0445d8c..850ad4af64 100644 --- a/crates/ui-egui/src/i18n/ru.rs +++ b/crates/ui-egui/src/i18n/ru.rs @@ -393,6 +393,573 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Workbook calculation" => "Вычисления в книге", "Enable iterative calculation" => "Включить итеративные вычисления", "Calculation settings are saved with the workbook." => "Параметры вычислений сохраняются вместе с книгой.", + " (password)" => " (пароль)", + " (password, unlocked)" => " (пароль, разблокировано)", + "$ English (United States)" => "$ английский (США)", + "'{file}' is protected." => "Файл «{file}» защищён.", + "(No suggestions)" => "(Нет вариантов)", + "(unnamed)" => "(без имени)", + "+ Add Level" => "+ Добавить уровень", + "100% Stacked Bar" => "Нормированная линейчатая", + "100% Stacked Column" => "Нормированная гистограмма", + "A clean-room, open-source, Rust-native spreadsheet. Part of ArtCraft." => { + "Электронные таблицы с открытым исходным кодом, написанные с нуля на Rust. Часть ArtCraft." + } + "A to Z" => "От А до Я", + "About" => "О программе", + "About GridCraft" => "О программе GridCraft", + "Accessibility" => "Специальные возможности", + "Action" => "Действие", + "Action Journal" => "Журнал действий", + "Add" => "Добавить", + "Add Chart Element" => "Добавить элемент диаграммы", + "Add subtotal to (columns, e.g. C,D):" => "Добавить итоги по (столбцы, например C,D):", + "Add to Dictionary" => "Добавить в словарь", + "Add-in and Automation" => "Надстройки и автоматизация", + "Agent Control" => "Управление агентами", + "Aggregate duplicates using" => "Объединять повторы с помощью", + "Align Left" => "Выровнять по левому краю", + "Align Right" => "Выровнять по правому краю", + "Alignment" => "Выравнивание", + "All" => "Все", + "All Charts" => "Все диаграммы", + "All done. We made {count} replacements." => "Готово. Выполнено замен: {count}.", + "All except borders" => "Без рамки", + "Allow" => "Разрешить", + "Allow all users of this sheet to:" => "Разрешить всем пользователям этого листа:", + "Alpha:" => "Альфа:", + "Analysis Tools" => "Инструменты анализа", + "Anova: Single Factor" => "Однофакторный дисперсионный анализ", + "Any value" => "Любое значение", + "Area" => "С областями", + "At each change in (column letter):" => "При каждом изменении в (буква столбца):", + "Automation in this workbook. GridCraft runs its own scripts; a file's VBA project is preserved but not executed." => { + "Автоматизация в этой книге. GridCraft выполняет собственные сценарии; проект VBA из файла сохраняется, но не выполняется." + } + "Average" => "Среднее", + "Background Color:" => "Цвет фона:", + "Before sheet (position, 0 = first):" => "Перед листом (позиция, 0 = первый):", + "Bin Range (blank for automatic bins):" => "Интервал карманов (пусто — автоматически):", + "Blanks" => "Пустые ячейки", + "Bold" => "Полужирный", + "Border" => "Граница", + "Bottom" => "Нижняя", + "Bottom Align" => "Выровнять по нижнему краю", + "Bottom margin (in):" => "Нижнее поле (дюймы):", + "By Changing Variable Cells:" => "Изменяя ячейки переменных:", + "By changing cell:" => "Изменяя значение ячейки:", + "Calculate Now (F9)" => "Пересчёт (F9)", + "Calculate Sheet (⇧F9)" => "Произвести вычисления на листе (⇧F9)", + "Calculation Options" => "Параметры вычислений", + "Cancel" => "Отмена", + "Category:" => "Категория:", + "Cell Color" => "Цвет ячейки", + "Cell Styles" => "Стили ячеек", + "Cell Values" => "Значения ячеек", + "Center Across Selection" => "По центру выделения", + "Change" => "Заменить", + "Change All" => "Заменить все", + "Change Chart Type" => "Изменить тип диаграммы", + "Chart" => "Диаграмма", + "Chart Title" => "Название диаграммы", + "Chart data range:" => "Диапазон данных для диаграммы:", + "Check Accessibility" => "Проверка читаемости", + "Choose a PNG or JPEG no larger than 16 MB." => "Выберите файл PNG или JPEG размером не более 16 МБ.", + "Clear Filter" => "Очистить фильтр", + "Clustered Bar" => "Линейчатая с группировкой", + "Clustered Column" => "Гистограмма с группировкой", + "Color:" => "Цвет:", + "Column Width" => "Ширина столбца", + "Column chart" => "Гистограмма", + "Column or Bar" => "Гистограмма или линейчатая", + "Column width (characters):" => "Ширина столбца (символы):", + "Column widths" => "Ширины столбцов", + "Columns" => "Столбцы", + "Columns (blank = all, e.g. A,C):" => "Столбцы (пусто = все, например A,C):", + "Combo" => "Комбинированная", + "Comma" => "Запятая", + "Comment:" => "Примечание:", + "Comments and notes (C)" => "Примечания и заметки (C)", + "Comments pane" => "Область примечаний", + "Compatibility" => "Совместимость", + "Conditional Formatting Rules Manager" => "Диспетчер правил условного форматирования", + "Conditional formats" => "Условные форматы", + "Confidence Level (%):" => "Уровень надёжности (%):", + "Confidence Level for Mean (%), blank for none:" => "Уровень надёжности для среднего (%), пусто — нет:", + "Confidence interval" => "Доверительный интервал", + "Confidence level (%):" => "Уровень надёжности (%):", + "Constant is Zero" => "Константа — ноль", + "Contributors" => "Участники", + "Convert Text to Columns" => "Распределение текста по столбцам", + "Convert the last ink stroke to a shape" => "Преобразовать последний рукописный штрих в фигуру", + "Convert to Range" => "Преобразовать в диапазон", + "Copy" => "Копировать", + "Correlation" => "Корреляция", + "Count" => "Количество", + "CountA" => "Количество значений", + "Covariance" => "Ковариация", + "Create Forecast Worksheet" => "Создание листа прогноза", + "Create Sparklines" => "Создание спарклайнов", + "Create a copy" => "Создать копию", + "Cube" => "Куб", + "Cumulative Percentage" => "Интегральный процент", + "Current region" => "Текущая область", + "Custom (%):" => "Другой (%):", + "Custom Sort" => "Настраиваемая сортировка", + "Cut" => "Вырезать", + "Damping factor:" => "Фактор затухания:", + "Data Range:" => "Диапазон данных:", + "Data validation" => "Проверка данных", + "Database" => "Работа с базой данных", + "Date & Time" => "Дата и время", + "Date unit" => "Единица даты", + "Day" => "День", + "Decimal" => "Действительное", + "Define Name…" => "Присвоить имя…", + "Delete Comment" => "Удалить примечание", + "Delete Sheet" => "Удалить лист", + "Delete chart" => "Удалить диаграмму", + "Delete rows" => "Удаление строк", + "Delete…" => "Удалить…", + "Description (optional):" => "Описание (необязательно):", + "Descriptive Statistics" => "Описательная статистика", + "Destination:" => "Поместить в:", + "Diagonal ↗" => "Диагональ ↗", + "Diagonal ↘" => "Диагональ ↘", + "Dismiss" => "Закрыть", + "Display:" => "Отображать:", + "Distributed" => "Распределённый", + "Divide" => "Разделить", + "Do you want to save the changes you made to {title}?" => "Сохранить изменения в «{title}»?", + "Don't Save" => "Не сохранять", + "Double" => "Двойное", + "Double Accounting" => "Двойное, по ячейке", + "Doughnut" => "Кольцевая", + "Down" => "Вниз", + "Edit Name" => "Изменение имени", + "Edit chart title" => "Изменить название диаграммы", + "Edit…" => "Изменить…", + "Engineering" => "Инженерные", + "Entire column" => "Столбец", + "Entire row" => "Строку", + "Erase ink strokes" => "Стереть рукописные штрихи", + "Error message:" => "Сообщение:", + "Error style" => "Вид", + "Errors" => "Ошибки", + "Every command run in this session (replayable)" => "Все команды, выполненные в этом сеансе (можно повторить)", + "Expand formula bar" => "Развернуть строку формул", + "Exponential Smoothing" => "Экспоненциальное сглаживание", + "F-Test Two-Sample for Variances" => "Двухвыборочный F-тест для дисперсии", + "Fill missing points using" => "Заполнить отсутствующие точки с помощью", + "Filter (⌘⇧F)" => "Фильтр (⌘⇧F)", + "Filter by Selected Cell's Value" => "Фильтр по значению выделенной ячейки", + "Find" => "Найти", + "Find & Select" => "Найти и выделить", + "Find All" => "Найти все", + "Find Next" => "Найти далее", + "Find and Replace" => "Найти и заменить", + "Find what:" => "Найти:", + "Fits inside the selected cell and moves with its content." => "Помещается в выделенную ячейку и перемещается вместе с её содержимым.", + "Flash Fill (⌘E)" => "Мгновенное заполнение (⌘E)", + "Font" => "Шрифт", + "Footer (in):" => "Нижний колонтитул (дюймы):", + "Footer:" => "Нижний колонтитул:", + "Forecast Sheet" => "Лист прогноза", + "Forecast end (date or number; blank: a third of the history):" => "Завершение прогноза (дата или число; пусто — треть истории):", + "Format Cells" => "Формат ячеек", + "Format Cells|Fill" => "Заливка", + "Format Chart pane" => "Область «Формат диаграммы»", + "Format as Table" => "Форматировать как таблицу", + "Format cells" => "Форматирование ячеек", + "Format cells that are (value):" => "Форматировать ячейки, которые (значение):", + "Format cells that contain a date occurring:" => "Форматировать ячейки, содержащие дату:", + "Format cells that contain the text:" => "Форматировать ячейки, содержащие текст:", + "Format columns" => "Форматирование столбцов", + "Format rows" => "Форматирование строк", + "Format values where this formula is true:" => "Форматировать значения, для которых следующая формула является истинной:", + "Formats (T)" => "Форматы (T)", + "Formula Builder" => "Построитель формул", + "Formula:" => "Формула:", + "Formulas (F)" => "Формулы (F)", + "Formulas and number formats" => "Формулы и форматы чисел", + "Funnel" => "Воронка", + "GRG Nonlinear" => "Поиск решения нелинейных задач методом ОПГ", + "GRG Nonlinear: a local optimum of a smooth model, from the current values." => { + "Поиск решения нелинейных задач методом ОПГ: локальный оптимум гладкой модели, начиная с текущих значений." + } + "Go To" => "Перейти", + "Go To Special" => "Выделить группу ячеек", + "Goal Seek" => "Подбор параметра", + "Goal Seeking found a solution: {value}" => "Подбор параметра нашёл решение: {value}", + "Gradebook" => "Журнал оценок", + "Green Fill with Dark Green Text" => "Зелёная заливка и тёмно-зелёный текст", + "GridCraft is fully drivable by agents. Every menu item, button and gesture is a command:" => { + "GridCraft полностью управляется агентами. Каждый пункт меню, кнопка и жест — это команда:" + } + "Grouped By:" => "Группирование:", + "Growth" => "Геометрическая", + "Header (&L left, &C center, &R right; &P page, &N pages, &D date, &A sheet):" => { + "Верхний колонтитул (&L слева, &C по центру, &R справа; &P страница, &N страниц, &D дата, &A лист):" + } + "Header (in):" => "Верхний колонтитул (дюймы):", + "Hidden" => "Скрыть формулы", + "Hidden: {sheets}" => "Скрытые: {sheets}", + "Hide" => "Скрыть", + "Hide Sheet" => "Скрыть лист", + "Hierarchy & more" => "Иерархия и другие", + "Highlighter" => "Маркер", + "Histogram" => "Гистограмма распределения", + "Horizontal (Category) Axis Labels:" => "Подписи горизонтальной оси (категории):", + "Horizontal axis title:" => "Название горизонтальной оси:", + "Household Budget" => "Семейный бюджет", + "How agents drive GridCraft (MCP / control channel)" => "Как агенты управляют GridCraft (MCP / канал управления)", + "Hypothesized Mean Difference:" => "Гипотетическая средняя разность:", + "Ignore Once" => "Пропустить", + "Ignore blank" => "Игнорировать пустые ячейки", + "Import a text or CSV file" => "Импорт текстового файла или CSV", + "In the last 7 days" => "За последние 7 дней", + "In-cell dropdown" => "Список допустимых значений", + "Include forecast statistics" => "Включить статистику прогноза", + "Indent" => "Отступ", + "Information" => "Сообщение", + "Input Range:" => "Входной интервал:", + "Input X Range:" => "Входной интервал X:", + "Input Y Range:" => "Входной интервал Y:", + "Input message:" => "Сообщение для ввода:", + "Insert Calculated Field" => "Вставка вычисляемого поля", + "Insert Chart" => "Вставка диаграммы", + "Insert Function" => "Вставка функции", + "Insert Function (⇧F3)" => "Вставить функцию (⇧F3)", + "Insert Hyperlink" => "Вставка гиперссылки", + "Insert Link (⌘K)" => "Вставить ссылку (⌘K)", + "Insert Sheet" => "Вставить лист", + "Insert Slicer" => "Вставка среза", + "Insert a picture" => "Вставить рисунок", + "Insert a slicer to filter a table" => "Вставить срез для фильтрации таблицы", + "Insert an icon" => "Вставить значок", + "Insert rows" => "Вставка строк", + "Insert…" => "Вставить…", + "Interpolation" => "Интерполяция", + "Interval:" => "Интервал:", + "Italic" => "Курсив", + "Join the ArtCraft Discord" => "Присоединиться к Discord ArtCraft", + "Justify" => "По ширине", + "Keep" => "Оставить", + "Keep Solver Solution" => "Сохранить найденное решение", + "Kth Largest (blank for none):" => "K-й наибольший (пусто — нет):", + "Kth Smallest (blank for none):" => "K-й наименьший (пусто — нет):", + "Labels in first row" => "Метки в первой строке", + "Last cell" => "Последнюю ячейку", + "Last month" => "В прошлом месяце", + "Last week" => "На прошлой неделе", + "Left margin (in):" => "Левое поле (дюймы):", + "Legend Entries (Series)" => "Элементы легенды (ряды)", + "Light Red Fill" => "Светло-красная заливка", + "Light Red Fill with Dark Red Text" => "Светло-красная заливка и тёмно-красный текст", + "Line" => "График", + "Line chart" => "График", + "Line or Area" => "График или диаграмма с областями", + "Line with Markers" => "График с маркерами", + "Linear" => "Арифметическая", + "Link to (web address, e-mail or Sheet!A1):" => "Связать с (веб-адрес, электронная почта или Лист!A1):", + "Link…" => "Ссылка…", + "List" => "Список", + "Location Range:" => "Диапазон расположения:", + "Locked" => "Защищаемая ячейка", + "Locking cells or hiding formulas has no effect until you protect the worksheet (Review › Protect Sheet)." => { + "Защита ячеек и скрытие формул действуют только после защиты листа (Рецензирование › Защитить лист)." + } + "Lookup & Reference" => "Ссылки и массивы", + "Macros" => "Макросы", + "Macros and scripts in this workbook (Alt+F8)" => "Макросы и сценарии этой книги (Alt+F8)", + "Magnification" => "Масштаб", + "Make Unconstrained Variables Non-Negative" => "Сделать переменные без ограничений неотрицательными", + "Markers" => "Маркеры", + "Match case" => "Учитывать регистр", + "Match entire cell contents" => "Ячейка целиком", + "Math & Trig" => "Математические", + "Max" => "Максимум", + "Maximum:" => "Максимум:", + "Median" => "Медиана", + "Middle Align" => "Выровнять по середине", + "Min" => "Минимум", + "Minimum / Source / Formula:" => "Минимум / источник / формула:", + "Models" => "Модели", + "Modify" => "Изменить", + "Month" => "Месяц", + "More commands" => "Другие команды", + "Move or Copy" => "Переместить или скопировать", + "Move or Copy Sheet…" => "Переместить или скопировать лист…", + "Moving Average" => "Скользящее среднее", + "Multiply" => "Умножить", + "My data has headers" => "Мои данные содержат заголовки", + "My list has headers" => "Мои данные содержат заголовки", + "Name" => "Имя", + "Name Manager" => "Диспетчер имён", + "Name:" => "Имя:", + "New Comment" => "Создать примечание", + "New Name" => "Создание имени", + "New Worksheet Ply:" => "Новый рабочий лист:", + "New sheet (⇧F11)" => "Новый лист (⇧F11)", + "New title (Modify):" => "Новое название (изменить):", + "New…" => "Создать…", + "Next month" => "В следующем месяце", + "Next sheet" => "Следующий лист", + "Next week" => "На следующей неделе", + "No names defined" => "Имена не определены", + "No picture selected" => "Рисунок не выбран", + "No ranges yet." => "Диапазонов пока нет.", + "None" => "Нет", + "Normal Probability" => "График нормального распределения", + "Not in Dictionary ({cell}):" => "Нет в словаре ({cell}):", + "Note:" => "Заметка:", + "Numbers" => "Числа", + "OK" => "ОК", + "Open…" => "Открыть…", + "Operation" => "Операция", + "Orientation (degrees)" => "Ориентация (градусы)", + "Other:" => "Другой:", + "Outline" => "Внешние", + "Output Range:" => "Выходной интервал:", + "Output options" => "Параметры вывода", + "Page Breaks" => "Разрывы страниц", + "Page Setup" => "Параметры страницы", + "Paper Size" => "Размер бумаги", + "Pareto (sorted histogram)" => "Парето (отсортированная гистограмма)", + "Password" => "Пароль", + "Password (optional):" => "Пароль (необязательно):", + "Password:" => "Пароль:", + "Paste (⌘V)" => "Вставить (⌘V)", + "Paste Special" => "Специальная вставка", + "Pick From Drop-down List…" => "Выбрать из раскрывающегося списка…", + "Pick From List" => "Выбрать из списка", + "Picture file path (PNG or JPEG):" => "Путь к файлу рисунка (PNG или JPEG):", + "Pie" => "Круговая", + "Pie or Doughnut" => "Круговая или кольцевая", + "Place Picture in Cell" => "Поместить рисунок в ячейку", + "Place Picture over Cells" => "Поместить рисунок поверх ячеек", + "Previous sheet" => "Предыдущий лист", + "Print Area" => "Область печати", + "Print Titles" => "Печатать заголовки", + "Product" => "Произведение", + "Protect Sheet" => "Защитить лист", + "Protect Structure and Windows" => "Защита структуры и окон", + "Protect Workbook structure" => "Защитить структуру книги", + "Protect workbook for: Structure (sheets can't be added, moved, renamed, hidden or deleted)." => { + "Защитить книгу: структуру (листы нельзя добавлять, перемещать, переименовывать, скрывать и удалять)." + } + "Protection" => "Защита", + "Quarterly Sales Dashboard" => "Сводка продаж по кварталам", + "Radar" => "Лепестковая", + "Range password:" => "Пароль диапазона:", + "Ranges that stay editable when the sheet is protected:" => "Диапазоны, которые можно изменять на защищённом листе:", + "Rank and Percentile" => "Ранг и персентиль", + "Recommended Charts" => "Рекомендуемые диаграммы", + "Red Border" => "Красная граница", + "Red Text" => "Красный текст", + "Redo (⌘Y)" => "Повторить (⌘Y)", + "Reference:" => "Ссылка:", + "Refers To" => "Диапазон", + "Refers to cells:" => "Ссылается на ячейки:", + "Refers to:" => "Диапазон:", + "Regression" => "Регрессия", + "Remove" => "Удалить", + "Remove Duplicates" => "Удалить дубликаты", + "Remove the password (Modify)" => "Снять пароль (изменить)", + "Rename Sheet" => "Переименовать лист", + "Replace" => "Заменить", + "Replace All" => "Заменить все", + "Replace with:" => "Заменить на:", + "Residuals" => "Остатки", + "Restore Original Values" => "Восстановить исходные значения", + "Return to the original worksheet to insert this picture, or cancel and choose another cell." => { + "Вернитесь на исходный лист, чтобы вставить рисунок, или отмените и выберите другую ячейку." + } + "Right margin (in):" => "Правое поле (дюймы):", + "Row Height" => "Высота строки", + "Row height (pixels):" => "Высота строки (пиксели):", + "Rows" => "Строки", + "Sample" => "Выборка", + "Save (⌘S)" => "Сохранить (⌘S)", + "Save Changes?" => "Сохранить изменения?", + "Save a Copy" => "Сохранить копию", + "Save a copy as .{format}" => "Сохранить копию как .{format}", + "Scatter" => "Точечная", + "Scatter or Bubble" => "Точечная или пузырьковая", + "Scatter with Lines" => "Точечная с прямыми отрезками", + "Scope" => "Область", + "Scope (Workbook or sheet name):" => "Область (книга или имя листа):", + "ScreenTip:" => "Подсказка:", + "Search Commands" => "Поиск команд", + "Search and run any command" => "Найти и выполнить любую команду", + "Search commands (⌘⇧Space)" => "Поиск команд (⌘⇧Пробел)", + "Search:" => "Поиск:", + "Seasonality (auto, 0 for none, or a season length):" => "Сезонность (авто, 0 — нет, или длина сезона):", + "Select" => "Выделить", + "Select Data" => "Выбрать данные", + "Select Data Source" => "Выбор источника данных", + "Select a Solving Method:" => "Выберите метод решения:", + "Select a chart first." => "Сначала выделите диаграмму.", + "Select and move ink and shapes" => "Выделять и перемещать рукописные штрихи и фигуры", + "Semicolon" => "Точка с запятой", + "Series" => "Ряд", + "Series in" => "Ряды в", + "Series{n}" => "Ряд{n}", + "Series…" => "Прогрессия…", + "Set Objective:" => "Оптимизировать целевую функцию:", + "Set cell:" => "Установить в ячейке:", + "Share: export or save a copy" => "Поделиться: экспорт или сохранение копии", + "Sheets: {sheets}" => "Листы: {sheets}", + "Shift cells down" => "Ячейки со сдвигом вниз", + "Shift cells left" => "Ячейки со сдвигом влево", + "Shift cells right" => "Ячейки со сдвигом вправо", + "Shift cells up" => "Ячейки со сдвигом вверх", + "Show Formulas (⌃`)" => "Показать формулы (⌃`)", + "Show password" => "Показать пароль", + "Shrink to fit" => "Автоподбор ширины", + "Simplex LP: for linear models; integer and binary constraints by branch and bound." => { + "Симплекс-метод: для линейных моделей; целочисленные и двоичные ограничения — методом ветвей и границ." + } + "Single" => "Одинарное", + "Single Accounting" => "Одинарное, по ячейке", + "Skip blanks" => "Пропускать пустые ячейки", + "Slicer" => "Срез", + "Solve" => "Найти решение", + "Solver Parameters" => "Параметры поиска решения", + "Solver Results" => "Результаты поиска решения", + "Sort & Filter" => "Сортировка и фильтр", + "Sort by" => "Сортировать по", + "Space" => "Пробел", + "Special…" => "Выделить…", + "Spelling (F7)" => "Орфография (F7)", + "Stacked Area" => "С областями с накоплением", + "Stacked Bar" => "Линейчатая с накоплением", + "Stacked Column" => "Гистограмма с накоплением", + "Stacked Line" => "График с накоплением", + "Standard Errors" => "Стандартные погрешности", + "Standard Width" => "Стандартная ширина", + "Standard column width (pixels):" => "Стандартная ширина столбца (пиксели):", + "Standardized Residuals" => "Стандартизированные остатки", + "Start a conversation:" => "Начать разговор:", + "Start a new workbook" => "Создать новую книгу", + "Statistic" => "Статистика", + "Statistical" => "Статистические", + "Step value:" => "Шаг:", + "Stop" => "Останов", + "Stop value:" => "Предельное значение:", + "Subject to the Constraints:" => "В соответствии с ограничениями:", + "Subscript" => "Подстрочный", + "Subtract" => "Вычесть", + "Suggestions:" => "Варианты:", + "Sum" => "Сумма", + "Summary statistics" => "Итоговая статистика", + "Superscript" => "Надстрочный", + "Switch Row/Column" => "Строка/столбец", + "Tab" => "Знак табуляции", + "Table (⌘T)" => "Таблица (⌘T)", + "Table Styles" => "Стили таблиц", + "Text Box" => "Надпись", + "Text length" => "Длина текста", + "Text to Columns" => "Текст по столбцам", + "The output range already holds data. Replace it with the report?" => "Выходной интервал уже содержит данные. Заменить их отчётом?", + "The spelling check is complete for the entire sheet." => "Проверка орфографии на всём листе завершена.", + "Then by" => "Затем по", + "There are no conditional formatting rules on this sheet." => "На этом листе нет правил условного форматирования.", + "This month" => "В текущем месяце", + "This week" => "На текущей неделе", + "This workbook" => "Эта книга", + "This workbook has no scripts or macros." => "В этой книге нет сценариев и макросов.", + "Timeline range (blank: first column of the selection):" => "Диапазон временной шкалы (пусто — первый столбец выделения):", + "Title:" => "Заголовок:", + "To value:" => "Значение:", + "To:" => "До:", + "Today" => "Сегодня", + "Tomorrow" => "Завтра", + "Top" => "Верхняя", + "Top Align" => "Выровнять по верхнему краю", + "Top margin (in):" => "Верхнее поле (дюймы):", + "Transpose (E)" => "Транспонировать (E)", + "Treat consecutive delimiters as one" => "Считать последовательные разделители одним", + "Type" => "Тип", + "Type (custom format code):" => "Тип (код пользовательского формата):", + "Type a command, e.g. freeze, chart, bold…" => "Введите команду, например закрепить, диаграмма, полужирный…", + "Undo (⌘Z)" => "Отменить (⌘Z)", + "Undo list" => "Список отмены", + "Unhide" => "Показать", + "Unhide Sheet…" => "Показать лист…", + "Unhide sheet:" => "Показать лист:", + "Unlock with password" => "Разблокировать паролем", + "Unprotect Sheet" => "Снять защиту листа", + "Unprotect Workbook" => "Снять защиту книги", + "Up" => "Вверх", + "Use AutoFilter" => "Использование автофильтра", + "Use function" => "Операция", + "Validation" => "Проверка данных", + "Value" => "Значение", + "Value Of:" => "Значения:", + "Values (V)" => "Значения (V)", + "Values and number formats" => "Значения и форматы чисел", + "Values range (blank: second column of the selection):" => "Диапазон значений (пусто — второй столбец выделения):", + "Variable 1 Range:" => "Интервал переменной 1:", + "Variable 2 Range:" => "Интервал переменной 2:", + "Version {version}" => "Версия {version}", + "Version, contributors and the AI models that helped" => "Версия, участники и модели ИИ, которые помогали", + "Vertical" => "По вертикали", + "Vertical axis title:" => "Название вертикальной оси:", + "Visible cells only" => "Только видимые ячейки", + "Warning" => "Предупреждение", + "Waterfall" => "Каскадная", + "Web" => "Интернет", + "Weekday" => "Рабочий день", + "What-If Analysis" => "Анализ «что если»", + "Whole number" => "Целое число", + "Within workbook" => "В книге", + "Workbook" => "Книга", + "Workbook Statistics" => "Статистика книги", + "Wrap text" => "Переносить текст", + "Year" => "Год", + "Yellow Fill with Dark Yellow Text" => "Жёлтая заливка и тёмно-жёлтый текст", + "Yesterday" => "Вчера", + "Z to A" => "От Я до А", + "Zeros" => "Нули", + "Zoom In" => "Увеличить", + "Zoom Out" => "Уменьшить", + "Zoom to 100%" => "Масштаб 100%", + "Zoom to Selection" => "Масштаб по выделенному", + "Zoom…" => "Масштаб…", + "and:" => "и:", + "applies to" => "применяется к", + "between" => "между", + "dashed" => "штриховая", + "dotted" => "точечная", + "double" => "двойная", + "e.g. lookup, average, date" => "например: поиск, среднее, дата", + "equal to" => "равно", + "greater than" => "больше", + "greater than or equal to" => "больше или равно", + "less than" => "меньше", + "less than or equal to" => "меньше или равно", + "medium" => "средняя", + "none" => "нет", + "not between" => "вне", + "not equal to" => "не равно", + "t-Test: Paired Two Sample for Means" => "Парный двухвыборочный t-тест для средних", + "t-Test: Two-Sample Assuming Equal Variances" => "Двухвыборочный t-тест с одинаковыми дисперсиями", + "t-Test: Two-Sample Assuming Unequal Variances" => "Двухвыборочный t-тест с различными дисперсиями", + "thick" => "толстая", + "thin" => "тонкая", + "this workbook" => "эта книга", + "with" => "с", + "{count} cell(s) found" => "Найдено ячеек: {count}", + "{count} engine commands are available. See docs/mcp.md and docs/control-protocol.md." => { + "Доступно команд движка: {count}. См. docs/mcp.md и docs/control-protocol.md." + } + "£ English (United Kingdom)" => "£ английский (Великобритания)", + "¥ Japanese" => "¥ японский", + "— 1 step" => "— 1 шаг", + "— VBA project (preserved, not run)" => "— проект VBA (сохраняется, не выполняется)", + "— {steps} steps" => "— шагов: {steps}", + "€ Euro" => "€ евро", _ => return None, }) } From a1d97d49b363d94dcbabb03c9470ea4afbb3f2f7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9C=D0=B0=D0=B3=D0=B4=D0=B0=20=D0=9C=2E=D0=93=2E?= Date: Mon, 12 Oct 2026 14:39:53 +0300 Subject: [PATCH 005/259] =?UTF-8?q?i18n(ru):=20name=20the=20Insert=20tab?= =?UTF-8?q?=20=C2=AB=D0=92=D1=81=D1=82=D0=B0=D0=B2=D0=BA=D0=B0=C2=BB,=20tr?= =?UTF-8?q?anslate=20AutoSave?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Russian names the Insert tab and the Insert command differently («Вставка» vs «Вставить»). Ribbon tabs now look up a `Tab|` key first and fall back to the plain label, so other languages are unchanged. Also translates the AutoSave toggle and adds the two French header/footer labels whose absence failed the catalog completeness test. --- crates/ui-egui/src/i18n.rs | 24 +++++++++++++++++++++--- crates/ui-egui/src/i18n/fr.rs | 2 ++ crates/ui-egui/src/i18n/ru.rs | 5 +++++ crates/ui-egui/src/ribbon.rs | 6 +++--- 4 files changed, 31 insertions(+), 6 deletions(-) diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 406cc3dd51..e68fcaab0f 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -112,7 +112,19 @@ impl Language { /// A label that needs two translations (Home's text "Orientation" vs Page Layout's print /// "Orientation") is keyed `context|label`: English shows only the part after the `|`. pub fn tr(self, text: &str) -> &str { - let translated = match self { + self.lookup(text).unwrap_or_else(|| text.rsplit_once('|').map_or(text, |(_, label)| label)) + } + + /// A ribbon tab's name. Where a language names the tab differently from a command with the + /// same English label (Russian: the Insert tab is «Вставка», the Insert button «Вставить»), its + /// catalog keys the tab `Tab|name`; every other language falls back to the plain label. + pub fn tr_tab(self, tab: &str) -> &str { + self.lookup(&format!("Tab|{tab}")).unwrap_or_else(|| self.tr(tab)) + } + + /// The catalog entry for `text`, if this language has one. + fn lookup(self, text: &str) -> Option<&'static str> { + match self { Self::En => None, Self::Es => es::translate(text), Self::Ja => JAPANESE.iter().find(|(english, _)| *english == text).map(|(_, translated)| *translated), @@ -123,8 +135,7 @@ impl Language { Self::De => de::translate(text), Self::Cs => cs::translate(text), Self::Fr => fr::translate(text), - }; - translated.unwrap_or_else(|| text.rsplit_once('|').map_or(text, |(_, label)| label)) + } } } @@ -592,6 +603,13 @@ mod tests { assert_eq!(Language::Zh.tr("Data"), "数据"); assert_eq!(Language::Ko.tr("Data"), "데이터"); assert_eq!(Language::Ru.tr("Data"), "Данные"); + // The Insert tab and the Insert command have different Russian names; elsewhere the tab + // name is the plain label. + assert_eq!(Language::Ru.tr_tab("Insert"), "Вставка"); + assert_eq!(Language::Ru.tr("Insert"), "Вставить"); + assert_eq!(Language::Ru.tr_tab("Data"), "Данные"); + assert_eq!(Language::Es.tr_tab("Insert"), Language::Es.tr("Insert")); + assert_eq!(Language::En.tr_tab("Insert"), "Insert"); assert_eq!(Language::PtBr.tr("Data"), "Dados"); assert_eq!(Language::Es.tr("Data"), "Datos"); assert_eq!(Language::Es.tr("Freeze Panes"), "Inmovilizar paneles"); diff --git a/crates/ui-egui/src/i18n/fr.rs b/crates/ui-egui/src/i18n/fr.rs index fdf1f891dd..8cd4e9b48d 100644 --- a/crates/ui-egui/src/i18n/fr.rs +++ b/crates/ui-egui/src/i18n/fr.rs @@ -381,6 +381,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Workbook calculation" => "Calcul du classeur", "Enable iterative calculation" => "Classeur Excel (*.xlsx)", "Calculation settings are saved with the workbook." => "Modifier le type de fichier", + "Different First Page" => "Première page différente", + "Different Odd & Even Pages" => "Pages paires et impaires différentes", _ => return None, }) } diff --git a/crates/ui-egui/src/i18n/ru.rs b/crates/ui-egui/src/i18n/ru.rs index 850ad4af64..7afd626123 100644 --- a/crates/ui-egui/src/i18n/ru.rs +++ b/crates/ui-egui/src/i18n/ru.rs @@ -960,6 +960,11 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "— VBA project (preserved, not run)" => "— проект VBA (сохраняется, не выполняется)", "— {steps} steps" => "— шагов: {steps}", "€ Euro" => "€ евро", + "Tab|Insert" => "Вставка", + "AutoSave" => "Автосохранение", + "AutoSave: save after every change (workbooks saved to a file)" => { + "Автосохранение: сохранять после каждого изменения (для книг, сохранённых в файл)" + } _ => return None, }) } diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index e8291accdc..5bff246baa 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -52,14 +52,14 @@ pub fn title_bar(app: &mut SheetApp, ui: &mut Ui) { ui.horizontal_centered(|ui| { ui.add_space(TITLE_LEFT_PAD); // AutoSave toggle (saves after each change when the workbook has a file). - ui.label(egui::RichText::new("AutoSave").font(theme::ui_font(12.5)).color(t.text_dim)); + ui.label(egui::RichText::new(app.ui.language.tr("AutoSave")).font(theme::ui_font(12.5)).color(t.text_dim)); let on = app.grid.autosave(); let (r, resp) = ui.allocate_exact_size(vec2(30.0, 16.0), Sense::click()); ui.painter().rect_filled(r, 8.0, if on { t.accent } else { Color32::TRANSPARENT }); ui.painter().rect_stroke(r, 8.0, Stroke::new(1.0, if on { t.accent } else { t.text_dim }), StrokeKind::Inside); let knob = if on { pos2(r.right() - 8.0, r.center().y) } else { pos2(r.left() + 8.0, r.center().y) }; ui.painter().circle_filled(knob, 5.0, if on { Color32::WHITE } else { t.text_dim }); - if resp.on_hover_text("AutoSave: save after every change (workbooks saved to a file)").clicked() { + if resp.on_hover_text(app.ui.language.tr("AutoSave: save after every change (workbooks saved to a file)")).clicked() { app.grid.toggle_autosave(); } ui.add_space(6.0); @@ -181,7 +181,7 @@ pub fn show(app: &mut SheetApp, ui: &mut Ui) { let active = app.ui.ribbon_tab == tab; let contextual = ctx_tabs.contains(&tab); let font = theme::ui_font(13.5); - let label = app.ui.language.tr(tab); + let label = app.ui.language.tr_tab(tab); let tw = ui.painter().layout_no_wrap(label.to_string(), font.clone(), t.text).size().x; let (r, resp) = ui.allocate_exact_size(vec2(tw + 22.0, 30.0), Sense::click()); if resp.hovered() && !active { From 7d13161b0317ba0cd59e3f0ce693a83fb45cf8a9 Mon Sep 17 00:00:00 2001 From: msaulc <103976208+msaulc@users.noreply.github.com> Date: Mon, 12 Oct 2026 10:59:34 -0600 Subject: [PATCH 006/259] Windows: dragging across monitors with different scales no longer grows the window and crashes On Windows 11, dragging the window across the boundary between monitors with different display scales (100 % and 250 % here) made it flip between the two scales and grow by their ratio, from 3600x2250 to 9048x5757. The surface then outgrew the 8192-pixel texture limit and wgpu's validation error closed the app (Surface::configure, "Requested was (9048, 5757)"). The cause is winit 0.30.13's WM_DPICHANGED handling during a live drag (rust-windowing/winit#4041, #4600): it replaces the rect Windows suggests with its own and nudges it back onto the monitor being left, which triggers another DPI change. winit master fixed it in 488c036a (#4341), but eframe 0.36 needs winit 0.30 and the fix isn't in a 0.30 release. Patch winit to the single commit of rust-windowing/winit#4694, the 0.30.x backport (+21/-34 lines in src/platform_impl/windows/event_loop.rs on top of v0.30.13), pinned by SHA as #389's review asked for instead of a vendored copy. Verified on Windows 11 24H2 with a scripted mouse drag that crashed every time before: the window now changes scale once and snaps to the target monitor. Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 12 ++++++++---- Cargo.toml | 10 ++++++++++ 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index ade3a5adba..b6eca0a66b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -898,6 +898,11 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" +[[package]] +name = "dpi" +version = "0.1.1" +source = "git+https://github.com/rust-windowing/winit?rev=39c4009c41e5977d779cf8a365792c8153ca77c5#39c4009c41e5977d779cf8a365792c8153ca77c5" + [[package]] name = "dpi" version = "0.1.2" @@ -2103,7 +2108,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9a7b000cca0783af26837fe2ebd9ddf1c42279463c085b97622ee1d2f444b89e" dependencies = [ "crossbeam-channel", - "dpi", + "dpi 0.1.2", "keyboard-types", "objc2 0.6.5", "objc2-app-kit 0.3.2", @@ -4368,8 +4373,7 @@ checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" [[package]] name = "winit" version = "0.30.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6755fa58a9f8350bd1e472d4c3fcc25f824ec358933bba33306d0b63df5978d" +source = "git+https://github.com/rust-windowing/winit?rev=39c4009c41e5977d779cf8a365792c8153ca77c5#39c4009c41e5977d779cf8a365792c8153ca77c5" dependencies = [ "ahash", "android-activity", @@ -4383,7 +4387,7 @@ dependencies = [ "core-foundation", "core-graphics", "cursor-icon", - "dpi", + "dpi 0.1.1", "js-sys", "libc", "memmap2", diff --git a/Cargo.toml b/Cargo.toml index 0bfee246d1..0f8c36a618 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -75,3 +75,13 @@ opt-level = 3 lto = "thin" codegen-units = 16 debug = 0 + +# Windows 11: winit 0.30.13 resizes a window wrongly while it is dragged across monitors with +# different display scales (rust-windowing/winit#4041, #4600). It flips between the two scales, +# grows by the scale ratio (3600×2250 → 9048×5757 between 250 % and 100 %) and outgrows the GPU +# surface, which closed GridCraft. Pinned to the one commit of rust-windowing/winit#4694, the +# 0.30.x backport of the fix winit master has had since 488c036a (#4341): during a live drag it +# applies the rect Windows suggests (+21/−34 lines in src/platform_impl/windows/event_loop.rs on +# top of v0.30.13). Remove once eframe depends on a winit release with the fix. +[patch.crates-io] +winit = { git = "https://github.com/rust-windowing/winit", rev = "39c4009c41e5977d779cf8a365792c8153ca77c5" } From 768a8a79f3dbeaa55b5b40ccc576aded09385bf7 Mon Sep 17 00:00:00 2001 From: msaulc <103976208+msaulc@users.noreply.github.com> Date: Mon, 12 Oct 2026 11:10:11 -0600 Subject: [PATCH 007/259] Windows and monitors wider than 8192 pixels keep their surface instead of crashing eframe's device descriptor asks for exactly 8192 pixels of texture side, whatever the GPU offers, and the window's surface is a texture. A window stretched across monitors that add up to more (1080 + 3440 + 3840 = 8360 pixels at 100 %) failed Surface::configure ("Requested was (8344, 1261), maximum extent for either dimension is 8192"), which wgpu treats as fatal, so GridCraft closed. The mixed-DPI drag fixed in the previous commit hit the same limit. Ask for the adapter's own largest texture side (16384 on DirectX 12 and Metal hardware), keeping eframe's other requirements, on every native platform and before the DirectX 12 probe that checks adapters against these limits. A smaller adapter is never asked for more than it has. Verified on Windows 11 24H2: the 8360-pixel window crashed before and stays up now. Co-Authored-By: Claude Opus 5.5 --- apps/gridcraft/src/gpu_limits.rs | 61 ++++++++++++++++++++++++++++++++ apps/gridcraft/src/main.rs | 7 +++- docs/hardware-parity.md | 5 +-- 3 files changed, 70 insertions(+), 3 deletions(-) create mode 100644 apps/gridcraft/src/gpu_limits.rs diff --git a/apps/gridcraft/src/gpu_limits.rs b/apps/gridcraft/src/gpu_limits.rs new file mode 100644 index 0000000000..6600e76eed --- /dev/null +++ b/apps/gridcraft/src/gpu_limits.rs @@ -0,0 +1,61 @@ +//! The device limits GridCraft asks the graphics adapter for, on every native platform. +//! +//! The window's surface is a texture, so its sides can't exceed the device's largest texture +//! side, and eframe asks for exactly 8192 whatever the GPU can do. A window across monitors that +//! add up to more (1080 + 3440 + 3840 = 8360 pixels side by side at 100 %), or one a mixed-DPI +//! drag scaled up (9048x5757 between 250 % and 100 % monitors), then failed `Surface::configure`, +//! which wgpu treats as fatal: the app closed. DirectX 12 and Metal hardware offer 16384. + +use eframe::{NativeOptions, egui_wgpu::WgpuSetup, wgpu::Limits}; +use std::sync::Arc; + +/// `required` with the largest texture side the adapter's `supported` limits offer. Taking the +/// adapter's own value also never asks a smaller (downlevel) adapter for more than it has. +pub fn surface_texture_limits(mut required: Limits, supported: &Limits) -> Limits { + required.max_texture_dimension_2d = supported.max_texture_dimension_2d; + required +} + +/// Makes the device eframe creates ask for the adapter's largest texture side +/// ([`surface_texture_limits`]), keeping eframe's other device requirements. +pub fn request_adapter_texture_size(options: &mut NativeOptions) { + if let WgpuSetup::CreateNew(create) = &mut options.wgpu_options.wgpu_setup { + let device_descriptor = Arc::clone(&create.device_descriptor); + create.device_descriptor = Arc::new(move |adapter| { + let mut descriptor = device_descriptor(adapter); + descriptor.required_limits = surface_texture_limits(descriptor.required_limits, &adapter.limits()); + descriptor + }); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn surface_texture_size_is_the_adapters_own() { + // Monitors adding up to 8360 pixels, or a mixed-DPI drag's 9048x5757, need more than + // eframe's 8192; a downlevel adapter must not be asked for more than it has. + for base in [Limits::default(), Limits::downlevel_webgl2_defaults()] { + let eframe_request = Limits { max_texture_dimension_2d: 8192, ..base.clone() }; + for side in [4096, 8192, 16384, 32768] { + let supported = Limits { max_texture_dimension_2d: side, ..base.clone() }; + let limits = surface_texture_limits(eframe_request.clone(), &supported); + assert_eq!(limits.max_texture_dimension_2d, side); + // Everything else eframe asks for is unchanged. + assert_eq!(Limits { max_texture_dimension_2d: 8192, ..limits.clone() }, eframe_request); + assert!(limits.check_limits(&supported)); + } + } + } + + #[test] + fn native_options_keep_creating_their_own_device() { + // The wrapper only replaces the device descriptor: eframe still creates the instance, + // adapter and device, and graphics.rs can still restrict the backends afterwards. + let mut options = NativeOptions::default(); + request_adapter_texture_size(&mut options); + assert!(matches!(options.wgpu_options.wgpu_setup, WgpuSetup::CreateNew(_))); + } +} diff --git a/apps/gridcraft/src/main.rs b/apps/gridcraft/src/main.rs index 8093205c4f..dbb29c2581 100644 --- a/apps/gridcraft/src/main.rs +++ b/apps/gridcraft/src/main.rs @@ -11,6 +11,7 @@ #[cfg(target_os = "macos")] mod apple_events; mod control_server; +mod gpu_limits; #[cfg(any(target_os = "windows", test))] mod graphics; mod logging; @@ -306,7 +307,11 @@ fn main() -> eframe::Result<()> { if let Some(i) = icon() { viewport = viewport.with_icon(i); } - let options = eframe::NativeOptions { viewport, ..Default::default() }; + let mut options = eframe::NativeOptions { viewport, ..Default::default() }; + // The adapter's largest texture side instead of eframe's 8192, so a window across several + // monitors keeps its surface. Before the DirectX 12 probe, which checks adapters against + // these limits. + gpu_limits::request_adapter_texture_size(&mut options); // Before eframe creates the wgpu instance: Windows starts on DirectX 12, or OpenGL when // DirectX 12 has no GPU driver, and never touches Vulkan unless asked (see graphics.rs). #[cfg(target_os = "windows")] diff --git a/docs/hardware-parity.md b/docs/hardware-parity.md index 3f9135c490..c6f932ab66 100644 --- a/docs/hardware-parity.md +++ b/docs/hardware-parity.md @@ -1,6 +1,6 @@ # Hardware parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (direct desktop printing through craft-print) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** trivial (Windows mixed-DPI drag and windows wider than 8192 px no longer crash) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 The hardware Excel uses, per platform, against GridCraft. Part of [target-app-parity.md](target-app-parity.md). **Hardware: ~40% ready, 25–40 h** (estimated). @@ -12,7 +12,7 @@ The hardware Excel uses, per platform, against GridCraft. Part of | Pen and touch (Draw tab) | Pen pressure and tilt, eraser end, touch mode, Ink to Shape, ink replay | Pen, highlighter, eraser, Ink to Shape with mouse/pen input; **no pressure or tilt**, no touch mode | Pressure-sensitive strokes where winit reports force; touch-friendly ribbon spacing: 4–6 h | | Trackpad and mouse | Smooth scrolling, pinch zoom, horizontal scroll, Magic Mouse | Smooth scrolling and zoom via egui | Verify pinch zoom on each platform: 1–2 h | | Multiple windows and monitors | One window per workbook, New Window for the same workbook, per-monitor DPI | One window; multiple workbooks switch inside it (#162; PR #171) | Native window per workbook, New Window, Arrange All across monitors: 6–8 h | -| High-DPI and display scaling | yes | yes (display scale matched to desktop density; View › Interface size 75–200 % on top, #230) | — | +| High-DPI and display scaling | yes | yes (display scale matched to desktop density; View › Interface size 75–200 % on top, #230; Windows 11 drags across monitors with different scales keep the window's size, via winit pinned to the rust-windowing/winit#4694 backport; surfaces up to the GPU's largest texture, 16384 px on DirectX 12 and Metal, not 8192) | — | | Accessibility hardware (screen readers, braille displays, switch control) | Narrator, JAWS, NVDA, VoiceOver | AccessKit enabled in eframe; grid cells and ribbon not yet exposed as an accessible tree | Counted under UI ([ui-parity.md](ui-parity.md)) | | Printers | System print with driver options | Desktop submits through craft-print with printer choice, copies, duplex and grayscale; web browser printing | Counted under print | @@ -21,6 +21,7 @@ The hardware Excel uses, per platform, against GridCraft. Part of | Date | Change | Summary | |---|---|---| | 2026-10-12 | minor | Desktop File > Print now discovers printers and submits PDF jobs through pinned craft-print with printer choice, copies, duplex and grayscale; browser printing and PDF export remain available. Remaining print parity includes page ranges and driver-specific tray/options UI; physical printer fidelity needs hardware validation. | +| 2026-10-12 | trivial | Windows: dragging across monitors with different display scales no longer grows the window and crashes (winit pinned to the rust-windowing/winit#4694 backport); windows wider or taller than 8192 px (several monitors) keep their surface | | 2026-10-11 | trivial | Windows: DirectX 12 shaders compiled with FXC, no DXC runtime needed (#339, #354) | | 2026-10-11 | trivial | Windows: OpenGL fallback when DirectX 12 has no GPU or fails to start; Vulkan never by default (#120) | | 2026-10-10 | major | Created: single-threaded calc, wgpu rendering, no pen pressure, single window | From 691883f99c65772ed3d25a6247f3e71b28c0f184 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Sun, 11 Oct 2026 20:56:16 +0200 Subject: [PATCH 008/259] Functions: VLOOKUP/HLOOKUP/LOOKUP/XLOOKUP keep an empty target blank (#374) A looked-up empty cell was turned into the number 0 inside the function, so ISBLANK saw FALSE and & / CONCATENATE inserted "0". The value now stays blank, like INDEX; a bare lookup still displays 0 as a formula's final result. Co-Authored-By: Claude Opus 5.5 --- crates/calc/src/tests.rs | 23 +++++++++++++++++++++++ crates/functions/src/lookup.rs | 22 ++++++++++------------ 2 files changed, 33 insertions(+), 12 deletions(-) diff --git a/crates/calc/src/tests.rs b/crates/calc/src/tests.rs index 5133409833..ac58b3d311 100644 --- a/crates/calc/src/tests.rs +++ b/crates/calc/src/tests.rs @@ -968,3 +968,26 @@ fn trim_references() { t.set("K20", "=SUM(Q1:.Q9)"); assert_eq!(t.get("K20"), Value::Error(CellError::Calc)); } + +#[test] +fn lookup_of_an_empty_cell_stays_blank() { + // #374: B1 (the value for key "k") is empty. + let mut t = T::new(); + t.set("A1", "k"); + t.set("A2", "j"); + t.set("B2", "v"); + for (at, f, want) in [ + ("D1", "=ISBLANK(VLOOKUP(\"k\",A1:B2,2,0))", Value::Bool(true)), + ("D2", "=\"x\"&VLOOKUP(\"k\",A1:B2,2,0)&\"y\"", Value::from("xy")), + ("D3", "=CONCATENATE(\"x\",VLOOKUP(\"k\",A1:B2,2,0),\"y\")", Value::from("xy")), + ("D4", "=ISBLANK(XLOOKUP(\"k\",A1:A2,B1:B2))", Value::Bool(true)), + ("D5", "=\"x\"&XLOOKUP(\"k\",A1:A2,B1:B2)&\"y\"", Value::from("xy")), + ("D6", "=ISBLANK(LOOKUP(\"k\",A1:A1,B1:B1))", Value::Bool(true)), + ("D7", "=ISBLANK(HLOOKUP(\"j\",A2:B3,2,0))", Value::Bool(true)), + // A bare lookup still shows 0, like a reference to an empty cell. + ("D8", "=VLOOKUP(\"k\",A1:B2,2,0)", Value::Number(0.0)), + ] { + t.set(at, f); + assert_eq!(t.get(at), want, "{f}"); + } +} diff --git a/crates/functions/src/lookup.rs b/crates/functions/src/lookup.rs index 56739c9333..a04ce99605 100644 --- a/crates/functions/src/lookup.rs +++ b/crates/functions/src/lookup.rs @@ -119,11 +119,6 @@ fn row(a: &Array, r: usize) -> Vec { (0..a.cols).map(|c| a.get(r, c).cloned().unwrap_or(Value::Empty)).collect() } -/// A looked-up cell value: blanks read as 0 like a reference to an empty cell. -fn cell_result(v: Value) -> Value { - if matches!(v, Value::Empty) { Value::Number(0.0) } else { v } -} - // --------------------------------------------------------------------------------------------- // VLOOKUP / HLOOKUP / LOOKUP / MATCH @@ -146,7 +141,9 @@ fn vh_lookup(args: &[Arg], vertical: bool) -> R { let pos = if approx { bsearch_last(&x, &keys, false) } else { find_exact(&x, &keys, true, false) }; let pos = pos.ok_or(CellError::NA)?; let v = if vertical { table.get(pos, idx) } else { table.get(idx, pos) }; - Ok(cell_result(v.cloned().unwrap_or(Value::Empty))) + // An empty target stays blank, as in Excel: ISBLANK sees it as blank and `&` / + // CONCATENATE treat it as ""; only a formula's final result displays 0 (#374). + Ok(v.cloned().unwrap_or(Value::Empty)) } fn vlookup(a: &[Arg], _c: &mut dyn Ctx) -> R { @@ -179,18 +176,18 @@ fn lookup(args: &[Arg], _c: &mut dyn Ctx) -> R { } else { None }; - return v.map(cell_result).ok_or(CellError::NA); + return v.ok_or(CellError::NA); } // Array form: search the first row of a wide array, else the first column; return the // matching entry from the last row / column. if la.cols > la.rows { let keys = row(&la, 0); let pos = bsearch_last(&x, &keys, false).ok_or(CellError::NA)?; - Ok(cell_result(la.get(la.rows - 1, pos).cloned().unwrap_or(Value::Empty))) + Ok(la.get(la.rows - 1, pos).cloned().unwrap_or(Value::Empty)) } else { let keys = column(&la, 0); let pos = bsearch_last(&x, &keys, false).ok_or(CellError::NA)?; - Ok(cell_result(la.get(pos, la.cols - 1).cloned().unwrap_or(Value::Empty))) + Ok(la.get(pos, la.cols - 1).cloned().unwrap_or(Value::Empty)) } } @@ -311,13 +308,13 @@ fn xlookup(args: &[Arg], _c: &mut dyn Ctx) -> R { if vertical { let r = row(&ra, p); if r.len() == 1 { - return Ok(cell_result(r.into_iter().next().unwrap_or(Value::Empty))); + return Ok(r.into_iter().next().unwrap_or(Value::Empty)); } array_val(1, r.len(), r) } else { let c = column(&ra, p); if c.len() == 1 { - return Ok(cell_result(c.into_iter().next().unwrap_or(Value::Empty))); + return Ok(c.into_iter().next().unwrap_or(Value::Empty)); } array_val(c.len(), 1, c) } @@ -581,7 +578,8 @@ mod tests { is_err(ev("VLOOKUP", vec![n(4.0), rf(table()), n(2.0), b(false)]), CellError::NA); is_err(ev("VLOOKUP", vec![n(4.0), rf(table()), n(2.0), empty()]), CellError::NA); close(ev("VLOOKUP", vec![n(3.0), rf(table()), n(3.0), b(false)]), 30.0); - close(ev("VLOOKUP", vec![n(5.0), rf(table()), n(3.0), b(false)]), 0.0); + // An empty target stays blank (the cell displays 0 only as a final result) (#374). + assert_eq!(ev("VLOOKUP", vec![n(5.0), rf(table()), n(3.0), b(false)]), Value::Empty); is_err(ev("VLOOKUP", vec![n(3.0), rf(table()), n(4.0)]), CellError::Ref); is_err(ev("VLOOKUP", vec![n(3.0), rf(table()), n(0.0)]), CellError::Value); close(ev("VLOOKUP", vec![t("b*"), rf(names()), n(2.0), b(false)]), 2.0); From d2b9affbc519c54d438e3b6b74f930a0c3de6c8f Mon Sep 17 00:00:00 2001 From: vangel76 Date: Sun, 11 Oct 2026 20:59:09 +0200 Subject: [PATCH 009/259] Functions: test that arithmetic reads a blank lookup result as 0 (#374) Co-Authored-By: Claude Opus 5.5 --- crates/calc/src/tests.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/crates/calc/src/tests.rs b/crates/calc/src/tests.rs index ac58b3d311..fe55dc3e1c 100644 --- a/crates/calc/src/tests.rs +++ b/crates/calc/src/tests.rs @@ -986,6 +986,8 @@ fn lookup_of_an_empty_cell_stays_blank() { ("D7", "=ISBLANK(HLOOKUP(\"j\",A2:B3,2,0))", Value::Bool(true)), // A bare lookup still shows 0, like a reference to an empty cell. ("D8", "=VLOOKUP(\"k\",A1:B2,2,0)", Value::Number(0.0)), + // Arithmetic still reads the blank as 0. + ("D9", "=VLOOKUP(\"k\",A1:B2,2,0)+1", Value::Number(1.0)), ] { t.set(at, f); assert_eq!(t.get(at), want, "{f}"); From 423810750d8d43f732163d57267905a0110afe83 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Mon, 12 Oct 2026 19:18:01 +0200 Subject: [PATCH 010/259] Functions: don't assert ISBLANK(VLOOKUP/HLOOKUP/LOOKUP), unverified against Excel (#374) Co-Authored-By: Claude Sonnet 5.5 --- crates/calc/src/tests.rs | 3 --- 1 file changed, 3 deletions(-) diff --git a/crates/calc/src/tests.rs b/crates/calc/src/tests.rs index fe55dc3e1c..cfce701d1d 100644 --- a/crates/calc/src/tests.rs +++ b/crates/calc/src/tests.rs @@ -977,13 +977,10 @@ fn lookup_of_an_empty_cell_stays_blank() { t.set("A2", "j"); t.set("B2", "v"); for (at, f, want) in [ - ("D1", "=ISBLANK(VLOOKUP(\"k\",A1:B2,2,0))", Value::Bool(true)), ("D2", "=\"x\"&VLOOKUP(\"k\",A1:B2,2,0)&\"y\"", Value::from("xy")), ("D3", "=CONCATENATE(\"x\",VLOOKUP(\"k\",A1:B2,2,0),\"y\")", Value::from("xy")), ("D4", "=ISBLANK(XLOOKUP(\"k\",A1:A2,B1:B2))", Value::Bool(true)), ("D5", "=\"x\"&XLOOKUP(\"k\",A1:A2,B1:B2)&\"y\"", Value::from("xy")), - ("D6", "=ISBLANK(LOOKUP(\"k\",A1:A1,B1:B1))", Value::Bool(true)), - ("D7", "=ISBLANK(HLOOKUP(\"j\",A2:B3,2,0))", Value::Bool(true)), // A bare lookup still shows 0, like a reference to an empty cell. ("D8", "=VLOOKUP(\"k\",A1:B2,2,0)", Value::Number(0.0)), // Arithmetic still reads the blank as 0. From ed2bfdf17e692a5c915e867eef194aa275ef30f0 Mon Sep 17 00:00:00 2001 From: neilcawse <8085959+neilcawse@users.noreply.github.com> Date: Mon, 12 Oct 2026 11:41:01 -0400 Subject: [PATCH 011/259] PivotTables: slicers from XLSX files are shown, filter their PivotTables and are saved A slicer can now target a PivotTable field as well as a table column (`SlicerSource::Pivot`). PivotTable slicers are read from the drawing frame (the a14 choice of the markup-compatibility block, not its fallback), the slicer part, the slicer cache (tabular items with their selected flags, sort order, crossFilter, showMissing) and the sheet/workbook x14 ext lists, so they no longer warn as a skipped embedded object. Real SmartArt and OLE objects still warn. The tiles are the pivot field's items; the selection is the PivotTables' item filter on the field (their hidden items), so the two always agree. Clicking a tile sets that filter on every connected PivotTable and refreshes them; a field outside Rows and Columns gets a hidden filter with no report filter cell, as in Excel. Saving writes the slicer cache, slicer part, an sle:slicer graphicFrame in an mc:AlternateContent block with a fallback shape, the workbook x14:slicerCaches and sheet x14:slicerList ext entries, content types and relationships. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/pivot.rs | 86 ++++- crates/engine/src/cmd/sheet.rs | 8 + crates/engine/src/cmd/slicer.rs | 217 ++++++++---- crates/engine/src/pivot.rs | 89 ++++- crates/engine/src/tests.rs | 4 +- crates/model/src/features.rs | 101 +++++- crates/ui-egui/src/chartview.rs | 29 +- crates/xlsx/src/drawing.rs | 10 + crates/xlsx/src/lib.rs | 1 + crates/xlsx/src/pivot.rs | 101 +++++- crates/xlsx/src/read.rs | 4 + crates/xlsx/src/sheet_read.rs | 5 +- crates/xlsx/src/sheet_write.rs | 18 +- crates/xlsx/src/slicer.rs | 481 +++++++++++++++++++++++++++ crates/xlsx/src/tests/pivot_tests.rs | 106 +++++- crates/xlsx/src/write.rs | 13 +- docs/file-format-parity.md | 5 +- docs/gaps.md | 11 +- docs/target-app-parity.md | 5 +- 19 files changed, 1169 insertions(+), 125 deletions(-) create mode 100644 crates/xlsx/src/slicer.rs diff --git a/crates/engine/src/cmd/pivot.rs b/crates/engine/src/cmd/pivot.rs index 8a441fa1b4..87757f5921 100644 --- a/crates/engine/src/cmd/pivot.rs +++ b/crates/engine/src/cmd/pivot.rs @@ -479,8 +479,8 @@ fn add_to_area( take_from_axes(pt, &field); let existing = pt.filters.iter().position(|f| f.source_col.eq_ignore_ascii_case(&field)); let entry = match existing { - Some(i) => pt.filters.remove(i), - None => PivotFilter { source_col: field, selected: None }, + Some(i) => PivotFilter { hidden: false, ..pt.filters.remove(i) }, + None => PivotFilter { source_col: field, selected: None, hidden: false }, }; let at = position.unwrap_or(pt.filters.len()).min(pt.filters.len()); pt.filters.insert(at, entry); @@ -517,7 +517,7 @@ fn remove_from_area(cmd: &str, pt: &mut PivotTable, field: &str, area: &str) -> let i = pt .filters .iter() - .position(|f| f.source_col.eq_ignore_ascii_case(field) && !in_axes(pt, &f.source_col)) + .position(|f| f.source_col.eq_ignore_ascii_case(field) && !f.hidden && !in_axes(pt, &f.source_col)) .ok_or_else(|| bad(cmd, format!("`{field}` isn't in the filters area")))?; Ok(pt.filters.remove(i).source_col) } @@ -747,7 +747,7 @@ fn pivot_json(wb: &Workbook, si: usize, pt: &PivotTable) -> Json { "rows": pt.rows.iter().map(|f| json!({"field": f.source_col, "sort": sort_name(f.sort), "group": group_name(f.date_group), "collapsed": f.collapsed_items})).collect::>(), "columns": pt.columns.iter().map(|f| json!({"field": f.source_col, "sort": sort_name(f.sort), "group": group_name(f.date_group), "collapsed": f.collapsed_items})).collect::>(), "values": pt.values.iter().map(|v| json!({"field": v.source_col, "func": func_name(v.func), "name": v.name, "showAs": show_as_name(v.show_as), "numberFormat": v.number_format})).collect::>(), - "filters": pt.filters.iter().map(|f| json!({"field": f.source_col, "selected": f.selected, "report": !in_axes(pt, &f.source_col)})).collect::>(), + "filters": pt.filters.iter().map(|f| json!({"field": f.source_col, "selected": f.selected, "report": !f.hidden && !in_axes(pt, &f.source_col)})).collect::>(), "layout": layout_name(pt.layout), "grandTotalsRows": pt.grand_totals_rows, "grandTotalsCols": pt.grand_totals_cols, @@ -782,7 +782,7 @@ fn fields(s: &mut Session, p: &Json) -> Result { if pt.values.iter().any(|f| f.source_col.eq_ignore_ascii_case(h)) { areas.push("values"); } - if pt.filters.iter().any(|f| f.source_col.eq_ignore_ascii_case(h)) && !in_axes(pt, h) { + if pt.filters.iter().any(|f| f.source_col.eq_ignore_ascii_case(h) && !f.hidden) && !in_axes(pt, h) { areas.push("filters"); } json!({ @@ -1111,7 +1111,7 @@ fn filter(s: &mut Session, p: &Json) -> Result { let i = pt.filters.iter().position(|f| f.source_col.eq_ignore_ascii_case(&field)); match (i, selected) { (Some(i), None) => { - if in_axes(pt, &field) { + if in_axes(pt, &field) || pt.filters.get(i).is_some_and(|f| f.hidden) { pt.filters.remove(i); } else if let Some(f) = pt.filters.get_mut(i) { f.selected = None; @@ -1122,7 +1122,7 @@ fn filter(s: &mut Session, p: &Json) -> Result { f.selected = Some(sel); } } - (None, Some(sel)) => pt.filters.push(PivotFilter { source_col: field.clone(), selected: Some(sel) }), + (None, Some(sel)) => pt.filters.push(PivotFilter { source_col: field.clone(), selected: Some(sel), hidden: false }), (None, None) => {} } Ok(json!({"field": field})) @@ -1355,7 +1355,7 @@ fn change_source(s: &mut Session, p: &Json) -> Result { fn delete(s: &mut Session, p: &Json) -> Result { let (si, pi) = find_pivot(s, p)?; edit(s, |cx| { - // Its PivotCharts become ordinary charts. + // Its PivotCharts become ordinary charts, and its slicers lose it. let link = cx.wb.sheet(si).and_then(|sh| sh.pivots.get(pi).map(|pt| (sh.name.clone(), pt.name.clone()))); if let Some((sheet, name)) = link { for i in 0..cx.wb.sheets.len() { @@ -1366,6 +1366,7 @@ fn delete(s: &mut Session, p: &Json) -> Result { } } } + super::slicer::drop_pivot(&mut cx.wb, &sheet, &name); } pv::clear_output(&mut cx.wb, si, pi); let sh = cx.sheet_mut(si)?; @@ -2308,4 +2309,73 @@ mod tests { // 64 Sub items under the collapsed "g" show as one column group: (1 + grand total) x 256 columns fit. s.execute("pivot.addField", json!({"field": "Sub", "area": "columns"})).unwrap(); } + + #[test] + fn pivot_slicer_selects_items_of_its_pivot_tables() { + use gridcraft_model::{Anchor, PivotSort, Slicer, SlicerCrossFilter, SlicerPivot, SlicerSource}; + let mut s = sales(); + s.execute("insert.pivotTable", json!({})).unwrap(); + s.execute("pivot.addField", json!({"field": "Region", "area": "rows"})).unwrap(); + s.execute("pivot.addField", json!({"field": "Sales", "area": "values"})).unwrap(); + let slicer = |id: u32, field: &str, pivots: Vec| Slicer { + id, + source: SlicerSource::Pivot { + pivots, + field: field.into(), + sort: PivotSort::Asc, + cross_filter: SlicerCrossFilter::default(), + show_missing: true, + }, + caption: field.into(), + anchor: Anchor { width: 150.0, height: 200.0, ..Anchor::default() }, + columns: 1, + name: String::new(), + style: String::new(), + row_height: None, + }; + { + let d = s.doc_mut().unwrap(); + let si = d.wb.active_sheet; + let sheet = d.wb.sheets[si].name.clone(); + let sh = std::sync::Arc::make_mut(&mut d.wb).sheet_mut(si).unwrap(); + let target = vec![SlicerPivot { sheet: sheet.clone(), name: "PivotTable1".into() }]; + sh.slicers.push(slicer(1, "Region", target.clone())); + sh.slicers.push(slicer(2, "Product", target)); + // A slicer whose PivotTable is gone shows no tiles and ignores clicks. + sh.slicers.push(slicer(3, "Region", vec![SlicerPivot { sheet, name: "Missing".into() }])); + } + let tiles = |s: &Session, id: u32| { + let d = s.doc().unwrap(); + let sh = d.wb.active().unwrap(); + let sl = sh.slicers.iter().find(|x| x.id == id).unwrap(); + crate::cmd::slicer::slicer_items(&d.wb, d.wb.active_sheet, sl).into_iter().map(|t| (t.label, t.selected)).collect::>() + }; + assert_eq!(tiles(&s, 1), vec![("East".into(), true), ("North".into(), true), ("West".into(), true)]); + assert!(tiles(&s, 3).is_empty()); + assert!(s.execute("slicer.toggle", json!({"slicer": 3, "value": "East"})).is_err()); + + // A field on Rows: clicking a tile selects that item of the field and refreshes the table. + s.execute("slicer.toggle", json!({"slicer": 1, "value": "East"})).unwrap(); + assert_eq!(tiles(&s, 1), vec![("East".into(), true), ("North".into(), false), ("West".into(), false)]); + let f = pt(&s).filters.into_iter().find(|f| f.source_col == "Region").unwrap(); + assert_eq!((f.selected, f.hidden), (Some(vec!["East".to_string()]), false)); + assert!(find_row(&s, "East").is_some()); + assert!(find_row(&s, "North").is_none() && find_row(&s, "West").is_none()); + s.execute("slicer.clear", json!({"slicer": 1})).unwrap(); + assert!(pt(&s).filters.is_empty()); + assert!(find_row(&s, "North").is_some()); + + // A field the table doesn't show: a hidden filter, with no report filter cell. + let anchor = pt(&s).anchor; + s.execute("slicer.toggle", json!({"slicer": 2, "value": "Apples"})).unwrap(); + let f = pt(&s).filters.into_iter().find(|f| f.source_col == "Product").unwrap(); + assert_eq!((f.selected, f.hidden), (Some(vec!["Apples".to_string()]), true)); + assert_eq!(pt(&s).anchor, anchor, "a hidden filter adds no filter rows"); + let total = find_row(&s, "Grand Total").map(|r| val(&s, &CellRef::new(r, anchor.col + 1).a1()).as_f64().unwrap()).unwrap(); + assert!(total > 0.0 && total < TOTAL, "{total}"); + s.execute("slicer.toggle", json!({"slicer": 2, "value": "Pears", "multi": true})).unwrap(); + assert_eq!(tiles(&s, 2), vec![("Apples".into(), true), ("Pears".into(), true), ("Plums".into(), false)]); + s.execute("slicer.selectAll", json!({"slicer": 2})).unwrap(); + assert!(pt(&s).filters.is_empty()); + } } diff --git a/crates/engine/src/cmd/sheet.rs b/crates/engine/src/cmd/sheet.rs index 7870dee4e9..6d2602e7cf 100644 --- a/crates/engine/src/cmd/sheet.rs +++ b/crates/engine/src/cmd/sheet.rs @@ -806,6 +806,14 @@ fn rename_sheet(s: &mut Session, p: &Json) -> Result { ch.pivot = Some(crate::pivot::chart_link(&name, &pivot)); } } + // So does a slicer. + for sl in sh.slicers.iter_mut() { + if let gridcraft_model::SlicerSource::Pivot { pivots, .. } = &mut sl.source { + for p in pivots.iter_mut().filter(|p| p.sheet.eq_ignore_ascii_case(&old)) { + p.sheet = name.clone(); + } + } + } } for n in cx.wb.names.iter_mut() { if let Ok(e) = gridcraft_formula::parse(&n.formula) { diff --git a/crates/engine/src/cmd/slicer.rs b/crates/engine/src/cmd/slicer.rs index 00342a5a9a..3e388b806f 100644 --- a/crates/engine/src/cmd/slicer.rs +++ b/crates/engine/src/cmd/slicer.rs @@ -1,12 +1,16 @@ -//! Slicers: on-sheet widgets that filter one column of a table by clicking value tiles. +//! Slicers: on-sheet widgets that filter a table column or a PivotTable field by clicking value +//! tiles. //! -//! A slicer is a thin front-end over the existing filter machinery: its selection is written as a -//! [`FilterCriterion::Values`] into the sheet's [`AutoFilter`] (over the table's range) and -//! [`super::data::apply_filter`] hides the rows. Creating a slicer (`insert.slicer` / -//! `table.insertSlicer`) selects everything, i.e. applies no filter. +//! A slicer is a thin front-end over the existing filter machinery. A table slicer's selection is +//! written as a [`FilterCriterion::Values`] into the sheet's [`AutoFilter`] (over the table's +//! range) and [`super::data::apply_filter`] hides the rows. A PivotTable slicer's selection is each +//! connected PivotTable's [`PivotFilter`] on the field, and the PivotTables are refreshed. Creating +//! a slicer (`insert.slicer` / `table.insertSlicer`) selects everything, i.e. applies no filter. use gridcraft_core::CellRef; use gridcraft_model::*; + +use crate::pivot::{self as pv, SlicerTile}; use serde_json::{Value as Json, json}; use super::*; @@ -60,28 +64,95 @@ pub fn column_values(wb: &Workbook, sheet: usize, table: &str, column: &str) -> out } -/// A slicer's tiles as `(value, selected)`, for the UI. `selected` is true for every tile when the -/// slicer has no filter (`selected == None`). -pub fn slicer_items(wb: &Workbook, sheet: usize, sl: &Slicer) -> Vec<(String, bool)> { - column_values(wb, sheet, &sl.table, &sl.column) - .into_iter() - .map(|v| { - let on = match &sl.selected { - None => true, - Some(set) => set.iter().any(|x| x.eq_ignore_ascii_case(&v)), - }; - (v, on) +/// A slicer's tiles, for the UI. Every tile is selected when the slicer has no filter. +pub fn slicer_items(wb: &Workbook, sheet: usize, sl: &Slicer) -> Vec { + match &sl.source { + SlicerSource::Table { table, column, selected } => column_values(wb, sheet, table, column) + .into_iter() + .map(|v| { + let on = match selected { + None => true, + Some(set) => set.iter().any(|x| x.eq_ignore_ascii_case(&v)), + }; + SlicerTile { key: v.clone(), label: v, selected: on, has_data: true } + }) + .collect(), + SlicerSource::Pivot { pivots, field, sort, cross_filter, .. } => { + let Some((si, pi)) = slicer_pivots(wb, pivots).into_iter().next() else { return vec![] }; + let Some(pt) = wb.sheet(si).and_then(|sh| sh.pivots.get(pi)) else { return vec![] }; + pv::slicer_tiles(wb, pt, si, field, *sort, *cross_filter) + } + } +} + +/// The connected PivotTables that exist, as (sheet, index). +pub fn slicer_pivots(wb: &Workbook, pivots: &[SlicerPivot]) -> Vec<(usize, usize)> { + pivots + .iter() + .filter_map(|p| { + let si = wb.sheet_index(&p.sheet)?; + let pi = wb.sheet(si)?.pivots.iter().position(|t| t.name.eq_ignore_ascii_case(&p.name))?; + Some((si, pi)) }) .collect() } +/// Sets `field`'s item selection (`None`: everything) on each PivotTable and refreshes them. A field +/// outside Rows and Columns gets a hidden filter (no report filter cell), as an Excel slicer does. +fn set_pivot_selection(wb: &mut Workbook, pivots: &[(usize, usize)], field: &str, selected: Option>) -> Result<()> { + for &(si, pi) in pivots { + let Some(pt) = wb.sheet_mut(si).and_then(|sh| sh.pivots.get_mut(pi)) else { continue }; + let on_axis = pt.rows.iter().chain(pt.columns.iter()).any(|f| f.source_col.eq_ignore_ascii_case(field)); + match (pt.filters.iter().position(|f| f.source_col.eq_ignore_ascii_case(field)), selected.clone()) { + (Some(i), None) => { + if on_axis || pt.filters.get(i).is_some_and(|f| f.hidden) { + pt.filters.remove(i); + } else if let Some(f) = pt.filters.get_mut(i) { + f.selected = None; + } + } + (Some(i), Some(sel)) => { + if let Some(f) = pt.filters.get_mut(i) { + f.selected = Some(sel); + } + } + (None, Some(sel)) => pt.filters.push(PivotFilter { source_col: field.to_string(), selected: Some(sel), hidden: !on_axis }), + (None, None) => {} + } + pv::refresh(wb, si, pi, false).map_err(EngineError::Other)?; + } + Ok(()) +} + +/// The selection after clicking `value` among `all`: a single click shows only that value (or, +/// on the sole selected value, clears the filter); `multi` toggles it. Everything selected is the +/// same as no filter. +fn next_selection(current: Option>, all: &[String], value: &str, multi: bool) -> Option> { + if multi { + let mut set: Vec = current.unwrap_or_else(|| all.to_vec()); + match set.iter().position(|x| x.eq_ignore_ascii_case(value)) { + Some(pos) => { + set.remove(pos); + } + None => set.push(value.to_string()), + } + if set.len() == all.len() && all.iter().all(|a| set.iter().any(|x| x.eq_ignore_ascii_case(a))) { None } else { Some(set) } + } else { + match ¤t { + Some(set) if set.len() == 1 && set.iter().any(|x| x.eq_ignore_ascii_case(value)) => None, + _ => Some(vec![value.to_string()]), + } + } +} + /// Writes the slicer's current selection into the sheet filter and re-applies it. fn reapply_slicer(wb: &mut Workbook, sheet: usize, id: u32) { let info = wb.sheet(sheet).and_then(|sh| { let sl = sh.slicers.iter().find(|s| s.id == id)?; - let t = sh.tables.iter().find(|t| t.name.eq_ignore_ascii_case(&sl.table))?; - let ci = t.column_index(&sl.column)?; - Some((t.range, t.range.start.col + ci as u32, sl.selected.clone())) + let SlicerSource::Table { table, column, selected } = &sl.source else { return None }; + let t = sh.tables.iter().find(|t| t.name.eq_ignore_ascii_case(table))?; + let ci = t.column_index(column)?; + Some((t.range, t.range.start.col + ci as u32, selected.clone())) }); let Some((table_range, abs_col, selected)) = info else { return }; { @@ -127,12 +198,13 @@ fn insert_slicer(s: &mut Session, p: &Json) -> Result { let id = d.wb.next_object_id(); let slicer = Slicer { id, - table, - column: column.clone(), + source: SlicerSource::Table { table, column: column.clone(), selected: None }, caption: column, anchor: Anchor { cell: at, dx: 4.0, dy: 4.0, width, height, mode: AnchorMode::MoveOnly }, - selected: None, columns, + name: String::new(), + style: String::new(), + row_height: None, }; edit(s, |cx| { cx.sheet_mut(sheet)?.slicers.push(slicer); @@ -156,37 +228,29 @@ fn slicer_toggle(s: &mut Session, p: &Json) -> Result { let value = str_param(p, "value").ok_or_else(|| bad("slicer.toggle", "missing `value`"))?.to_string(); let multi = bool_param(p, "multi").unwrap_or(false); let sheet = s.doc()?.wb.active_sheet; - let (table, column, current) = { - let d = s.doc()?; - let sl = d.wb.active().and_then(|sh| sh.slicers.get(idx)).ok_or_else(|| bad("slicer.toggle", "no slicer"))?; - (sl.table.clone(), sl.column.clone(), sl.selected.clone()) + let d = s.doc()?; + let sl = d.wb.active().and_then(|sh| sh.slicers.get(idx)).ok_or_else(|| bad("slicer.toggle", "no slicer"))?; + let tiles = slicer_items(&d.wb, sheet, sl); + let Some(tile) = tiles.iter().find(|t| t.label.eq_ignore_ascii_case(&value) || t.key.eq_ignore_ascii_case(&value)) else { + return Err(bad("slicer.toggle", "value is not one of the slicer's items")); }; - let all = column_values(&s.doc()?.wb, sheet, &table, &column); - if !all.iter().any(|v| v.eq_ignore_ascii_case(&value)) { - return Err(bad("slicer.toggle", "value is not in the column")); - } - let new_selected: Option> = if multi { - let mut set: Vec = current.unwrap_or_else(|| all.clone()); - match set.iter().position(|x| x.eq_ignore_ascii_case(&value)) { - Some(pos) => { - set.remove(pos); - } - None => set.push(value.clone()), - } - // Everything selected is the same as no filter. - if set.len() == all.len() && all.iter().all(|a| set.iter().any(|x| x.eq_ignore_ascii_case(a))) { None } else { Some(set) } - } else { - // Single click: show only this value; clicking the sole selected value clears the filter. - match ¤t { - Some(set) if set.len() == 1 && set.iter().any(|x| x.eq_ignore_ascii_case(&value)) => None, - _ => Some(vec![value.clone()]), - } + let all: Vec = tiles.iter().map(|t| t.key.clone()).collect(); + let current = (!tiles.iter().all(|t| t.selected)).then(|| tiles.iter().filter(|t| t.selected).map(|t| t.key.clone()).collect()); + let new_selected = next_selection(current, &all, &tile.key, multi); + let pivot = match &sl.source { + SlicerSource::Pivot { pivots, field, .. } => Some((slicer_pivots(&d.wb, pivots), field.clone())), + SlicerSource::Table { .. } => None, }; edit(s, |cx| { - if let Some(sl) = cx.sheet_mut(sheet)?.slicers.get_mut(idx) { - sl.selected = new_selected; + match &pivot { + Some((targets, field)) => set_pivot_selection(&mut cx.wb, targets, field, new_selected)?, + None => { + if let Some(SlicerSource::Table { selected, .. }) = cx.sheet_mut(sheet)?.slicers.get_mut(idx).map(|sl| &mut sl.source) { + *selected = new_selected; + } + reapply_slicer(&mut cx.wb, sheet, id); + } } - reapply_slicer(&mut cx.wb, sheet, id); cx.structural = true; Ok(Json::Null) }) @@ -195,36 +259,69 @@ fn slicer_toggle(s: &mut Session, p: &Json) -> Result { fn slicer_select_all(s: &mut Session, p: &Json) -> Result { let (idx, id) = find_slicer(s, p)?; let sheet = s.doc()?.wb.active_sheet; + let d = s.doc()?; + let pivot = d.wb.active().and_then(|sh| sh.slicers.get(idx)).and_then(|sl| match &sl.source { + SlicerSource::Pivot { pivots, field, .. } => Some((slicer_pivots(&d.wb, pivots), field.clone())), + SlicerSource::Table { .. } => None, + }); edit(s, |cx| { - if let Some(sl) = cx.sheet_mut(sheet)?.slicers.get_mut(idx) { - sl.selected = None; + match &pivot { + Some((targets, field)) => set_pivot_selection(&mut cx.wb, targets, field, None)?, + None => { + if let Some(SlicerSource::Table { selected, .. }) = cx.sheet_mut(sheet)?.slicers.get_mut(idx).map(|sl| &mut sl.source) { + *selected = None; + } + reapply_slicer(&mut cx.wb, sheet, id); + } } - reapply_slicer(&mut cx.wb, sheet, id); cx.structural = true; Ok(Json::Null) }) } +/// Deletes a slicer. A table slicer's filter is cleared; a PivotTable slicer leaves its +/// PivotTables filtered, as Excel does. fn slicer_delete(s: &mut Session, p: &Json) -> Result { let (idx, id) = find_slicer(s, p)?; let sheet = s.doc()?.wb.active_sheet; - // Absolute column of the slicer, so its filter criterion can be removed too. + // Absolute column of a table slicer, so its filter criterion can be removed too. let abs_col = s.doc().ok().and_then(|d| { let sh = d.wb.active()?; - let sl = sh.slicers.get(idx)?; - let t = sh.tables.iter().find(|t| t.name.eq_ignore_ascii_case(&sl.table))?; - let ci = t.column_index(&sl.column)?; + let SlicerSource::Table { table, column, .. } = &sh.slicers.get(idx)?.source else { return None }; + let t = sh.tables.iter().find(|t| t.name.eq_ignore_ascii_case(table))?; + let ci = t.column_index(column)?; Some(t.range.start.col + ci as u32) }); edit(s, |cx| { let sh = cx.sheet_mut(sheet)?; sh.slicers.retain(|x| x.id != id); - if let (Some(abs), Some(af)) = (abs_col, sh.autofilter.as_mut()) { - let off = abs.saturating_sub(af.range.start.col); - af.criteria.retain(|(o, _)| *o != off); + if let Some(abs) = abs_col { + if let Some(af) = sh.autofilter.as_mut() { + let off = abs.saturating_sub(af.range.start.col); + af.criteria.retain(|(o, _)| *o != off); + } + super::data::apply_filter(&mut cx.wb, sheet); } - super::data::apply_filter(&mut cx.wb, sheet); cx.structural = true; Ok(Json::Null) }) } + +/// Disconnects the PivotTable `name` on sheet `sheet` from its slicers; slicers left with no +/// PivotTable are deleted (used when the PivotTable is deleted). +pub(crate) fn drop_pivot(wb: &mut Workbook, sheet: &str, name: &str) { + let linked = |p: &SlicerPivot| p.sheet.eq_ignore_ascii_case(sheet) && p.name.eq_ignore_ascii_case(name); + let links = |sl: &Slicer| matches!(&sl.source, SlicerSource::Pivot { pivots, .. } if pivots.iter().any(linked)); + for i in 0..wb.sheets.len() { + if !wb.sheet(i).is_some_and(|sh| sh.slicers.iter().any(links)) { + continue; + } + let Some(sh) = wb.sheet_mut(i) else { continue }; + for sl in sh.slicers.iter_mut() { + if let SlicerSource::Pivot { pivots, .. } = &mut sl.source { + pivots.retain(|p| !linked(p)); + } + } + sh.slicers.retain(|sl| !matches!(&sl.source, SlicerSource::Pivot { pivots, .. } if pivots.is_empty())); + } +} diff --git a/crates/engine/src/pivot.rs b/crates/engine/src/pivot.rs index 6146bd4f91..f44044f2b8 100644 --- a/crates/engine/src/pivot.rs +++ b/crates/engine/src/pivot.rs @@ -294,6 +294,89 @@ pub fn distinct_labels(wb: &Workbook, src: &Source, col: usize, group: PivotDate items.into_iter().take(limit).map(|i| i.label).collect() } +/// Whether a filter's selected labels take in a source value: by its label, or (outside a date +/// grouping) a number by its plain number text, which is how files name number and date items. +fn selects(sel: &[String], v: &Value, label: &str, group: PivotDateGroup) -> bool { + if sel.iter().any(|s| s.eq_ignore_ascii_case(label)) { + return true; + } + match v.scalar() { + Value::Number(n) if group == PivotDateGroup::None => sel.contains(&gridcraft_core::number_to_text(n)), + _ => false, + } +} + +/// A slicer tile: one item of a PivotTable field. +#[derive(Clone, Debug, PartialEq)] +pub struct SlicerTile { + /// The item as shown (numbers and dates in the source format). + pub label: String, + /// The label a [`PivotFilter`] selects the item by: the plain number text for numbers (as + /// files store them), else the label. + pub key: String, + pub selected: bool, + /// Some source row with this item passes the PivotTable's other filters. + pub has_data: bool, +} + +/// The items of `field` in `pt`'s source as slicer tiles: selection from the PivotTable's filter +/// on the field, in `sort` order (`None`: source order), and with `cross` the items without data +/// last or marked as Excel does. +pub fn slicer_tiles(wb: &Workbook, pt: &PivotTable, sheet: usize, field: &str, sort: PivotSort, cross: SlicerCrossFilter) -> Vec { + let Ok(src) = read_source(wb, &pt.source, sheet) else { return vec![] }; + let Some(col) = src.col(field) else { return vec![] }; + let fmt_of = |c: usize| src.formats.get(c).map(String::as_str).unwrap_or("General"); + let group_of = |name: &str| { + pt.rows.iter().chain(pt.columns.iter()).find(|f| f.source_col.eq_ignore_ascii_case(name)).map(|f| f.date_group).unwrap_or_default() + }; + let own = pt.filters.iter().find(|f| f.source_col.eq_ignore_ascii_case(field)).and_then(|f| f.selected.as_deref()); + let others: Vec<(usize, &[String], PivotDateGroup)> = pt + .filters + .iter() + .filter(|f| !f.source_col.eq_ignore_ascii_case(field)) + .filter_map(|f| Some((src.col(&f.source_col)?, f.selected.as_deref()?, group_of(&f.source_col)))) + .collect(); + let mut fi = FieldItems::default(); + let mut data: Vec = Vec::new(); + for (i, r) in src.rows.iter().enumerate() { + let k = fi.intern(wb, r.get(col).unwrap_or(&Value::Empty), PivotDateGroup::None, fmt_of(col), i) as usize; + if data.len() <= k { + data.resize(k + 1, false); + } + if !data[k] + && others.iter().all(|(c, sel, g)| { + let v = r.get(*c).unwrap_or(&Value::Empty); + selects(sel, v, &item_label(wb, v, *g, fmt_of(*c)), *g) + }) + { + data[k] = true; + } + if fi.items.len() > 100_000 { + break; + } + } + let mut items: Vec<(Item, bool)> = fi.items.into_iter().zip(data).collect(); + match sort { + PivotSort::Asc => items.sort_by(|a, b| cmp_items(&a.0, &b.0)), + PivotSort::Desc => items.sort_by(|a, b| cmp_items(&b.0, &a.0)), + PivotSort::None => {} + } + if cross == SlicerCrossFilter::ShowItemsWithDataAtTop { + items.sort_by_key(|(_, d)| !*d); + } + items + .into_iter() + .map(|(it, has_data)| { + let key = match &it.value { + Value::Number(n) => gridcraft_core::number_to_text(*n), + _ => it.label.clone(), + }; + let selected = own.is_none_or(|sel| selects(sel, &it.value, &it.label, PivotDateGroup::None)); + SlicerTile { label: it.label, key, selected, has_data: has_data || cross == SlicerCrossFilter::None } + }) + .collect() +} + // ---------------------------------------------------------------- aggregation #[derive(Clone, Debug, Default)] @@ -864,7 +947,7 @@ pub fn compute(wb: &Workbook, pt: &PivotTable, pivot_sheet: usize) -> Result = filters.iter().filter(|f| f.3).collect(); @@ -893,8 +976,8 @@ pub fn compute(wb: &Workbook, pt: &PivotTable, pivot_sheet: usize) -> Result, } -/// A slicer: an on-sheet filter widget bound to one column of a table. Clicking its value tiles -/// sets that column's `FilterCriterion::Values` in the sheet's [`AutoFilter`] and re-applies it, -/// so a slicer is just a visual front-end over the existing filter machinery. +/// A slicer: an on-sheet filter widget whose value tiles drive an existing filter, so a slicer is +/// just a visual front-end over the filter machinery (see [`SlicerSource`]). #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct Slicer { pub id: u32, - /// Name of the table this slicer filters. - pub table: String, - /// Header name of the filtered column within the table. - pub column: String, - /// Caption shown in the slicer header (defaults to the column name). + /// What the slicer filters, and where its selection lives. + #[serde(flatten)] + pub source: SlicerSource, + /// Caption shown in the slicer header (defaults to the column or field name). pub caption: String, pub anchor: Anchor, - /// Selected display values. `None` means every value (no filter from this slicer); an empty - /// list means nothing is selected (all rows hidden), matching Excel's "clear all buttons". - pub selected: Option>, /// Number of button columns in the layout (Excel default 1). pub columns: u32, + /// The slicer's name (unique in a workbook; Excel's Slicer Settings "Name"). Empty: derived + /// from the caption when saved. + #[serde(default)] + pub name: String, + /// Slicer style name (`SlicerStyleLight1`…). Empty: the default style. + #[serde(default)] + pub style: String, + /// Button height in pixels; `None` uses the default. + #[serde(default)] + pub row_height: Option, +} + +impl Slicer { + /// The column or field the slicer filters. + pub fn field(&self) -> &str { + match &self.source { + SlicerSource::Table { column, .. } => column, + SlicerSource::Pivot { field, .. } => field, + } + } +} + +/// What a slicer filters. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(untagged)] +pub enum SlicerSource { + /// One column of a table: the selection is written as that column's + /// `FilterCriterion::Values` in the sheet's [`AutoFilter`] and re-applied. + Table { + /// Name of the table. + table: String, + /// Header name of the filtered column within the table. + column: String, + /// Selected display values. `None` means every value (no filter from this slicer); an + /// empty list means nothing is selected (all rows hidden), matching Excel's "clear all + /// buttons". + selected: Option>, + }, + /// One field of one or more PivotTables built from the same source. The selection is not + /// stored here: it is each PivotTable's [`PivotFilter`] on the field (its hidden items), so the + /// slicer and the PivotTables always agree. + Pivot { + /// The PivotTables it filters. + pivots: Vec, + /// Source field (column header) name. + field: String, + /// Tile order: ascending, descending, or data source order (`PivotSort::None`). + #[serde(default)] + sort: PivotSort, + #[serde(default)] + cross_filter: SlicerCrossFilter, + /// Whether items no longer in the source are shown (Excel's "Show items deleted from the + /// data source"; kept for the file). + #[serde(default = "default_true")] + show_missing: bool, + }, +} + +fn default_true() -> bool { + true +} + +/// A PivotTable a slicer filters: its sheet and name. +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct SlicerPivot { + pub sheet: String, + pub name: String, +} + +/// How a slicer shows items that have no data under its PivotTables' other filters (Excel's +/// "Visually indicate items with no data" options). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +pub enum SlicerCrossFilter { + /// Items with no data aren't marked. + None, + /// Items with data first (the default). + #[default] + ShowItemsWithDataAtTop, + /// Items with no data stay in their place. + ShowItemsWithNoData, } #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] @@ -972,6 +1047,10 @@ pub struct PivotFilter { pub source_col: String, /// Item labels shown; `None` = all. pub selected: Option>, + /// For a field outside Rows and Columns: it filters the PivotTable without being a report + /// filter (no filter cell above the table). Set by a slicer on a field the PivotTable doesn't + /// show, and read from files as hidden items of such a field. + pub hidden: bool, } /// A PivotTable: its definition plus where its output was last written. The output itself is diff --git a/crates/ui-egui/src/chartview.rs b/crates/ui-egui/src/chartview.rs index 180f15c21a..23faa7af50 100644 --- a/crates/ui-egui/src/chartview.rs +++ b/crates/ui-egui/src/chartview.rs @@ -399,11 +399,12 @@ pub struct SlicerLayout { pub tiles: Vec, } -pub fn slicer_layout(rect: Rect, z: f32, n: usize, columns: u32) -> SlicerLayout { +/// `row_height`: the slicer's button height in pixels (`None`: the default). +pub fn slicer_layout(rect: Rect, z: f32, n: usize, columns: u32, row_height: Option) -> SlicerLayout { let header_h = 26.0 * z; let pad = 6.0 * z; let gap = 3.0 * z; - let tile_h = 22.0 * z; + let tile_h = row_height.filter(|h| h.is_finite()).unwrap_or(22.0).clamp(8.0, 400.0) * z; let header = Rect::from_min_size(rect.min, vec2(rect.width(), header_h)); let cb = 16.0 * z; let clear = Rect::from_min_size(pos2(header.right() - pad - cb, header.top() + (header_h - cb) / 2.0), vec2(cb, cb)); @@ -431,7 +432,7 @@ fn paint_slicers(app: &SheetApp, p: &Painter, geo: &Geo, wb: &Workbook, si: usiz continue; } let items = gridcraft_engine::cmd::slicer::slicer_items(wb, si, sl); - let lay = slicer_layout(r, geo.z, items.len(), sl.columns); + let lay = slicer_layout(r, geo.z, items.len(), sl.columns, sl.row_height); let radius = 4.0 * geo.z; // Card + soft shadow. p.rect_filled(r.translate(vec2(0.0, 1.5)), radius, Color32::from_black_alpha(18)); @@ -440,22 +441,28 @@ fn paint_slicers(app: &SheetApp, p: &Painter, geo: &Geo, wb: &Workbook, si: usiz // Header. p.rect_filled(lay.header, radius, tk.accent_soft); p.rect_filled(Rect::from_min_max(pos2(lay.header.left(), lay.header.bottom() - radius), lay.header.max), 0.0, tk.accent_soft); - let cap = if sl.caption.is_empty() { sl.column.clone() } else { sl.caption.clone() }; + let cap = if sl.caption.is_empty() { sl.field().to_string() } else { sl.caption.clone() }; p.text(lay.header.left_center() + vec2(8.0 * geo.z, 0.0), Align2::LEFT_CENTER, cap, theme::ui_bold(12.5 * geo.z), tk.accent_dark); // Clear-filter button (tinted when a filter is active). - let filtered = sl.selected.is_some(); + let filtered = items.iter().any(|t| !t.selected); if let Some(icon) = crate::icons::from_name("filter") { crate::icons::paint(p, lay.clear, icon, if filtered { tk.accent } else { tk.text_dim }); } // Value tiles. - for ((val, on), tr) in items.iter().zip(&lay.tiles) { + for (tile, tr) in items.iter().zip(&lay.tiles) { if !r.contains(tr.left_top()) { continue; // overflowed the card; skip (no scroll yet) } - let (fill, fg) = if *on { (tk.accent, Color32::WHITE) } else { (tk.hover, tk.text_dim) }; + // Items with no data under the PivotTables' other filters are dimmed. + let (fill, fg) = match (tile.selected, tile.has_data) { + (true, true) => (tk.accent, Color32::WHITE), + (true, false) => (tk.accent_soft, tk.text_dim), + (false, true) => (tk.hover, tk.text_dim), + (false, false) => (tk.window, tk.text_dim), + }; p.rect_filled(*tr, 3.0 * geo.z, fill); let cp = p.with_clip_rect(tr.intersect(p.clip_rect())); - cp.text(tr.left_center() + vec2(6.0 * geo.z, 0.0), Align2::LEFT_CENTER, val, theme::ui_font(12.0 * geo.z), fg); + cp.text(tr.left_center() + vec2(6.0 * geo.z, 0.0), Align2::LEFT_CENTER, &tile.label, theme::ui_font(12.0 * geo.z), fg); } if app.selected_chart == Some(sl.id) { selection_frame(p, r); @@ -481,13 +488,13 @@ pub fn slicer_click(geo: &Geo, sh: &Sheet, wb: &Workbook, si: usize, p: Pos2) -> continue; } let items = gridcraft_engine::cmd::slicer::slicer_items(wb, si, sl); - let lay = slicer_layout(r, geo.z, items.len(), sl.columns); + let lay = slicer_layout(r, geo.z, items.len(), sl.columns, sl.row_height); if lay.clear.contains(p) { return Some((sl.id, SlicerAction::Clear)); } - for ((val, _on), tr) in items.iter().zip(&lay.tiles) { + for (tile, tr) in items.iter().zip(&lay.tiles) { if r.contains(tr.left_top()) && tr.contains(p) { - return Some((sl.id, SlicerAction::Toggle(val.clone()))); + return Some((sl.id, SlicerAction::Toggle(tile.key.clone()))); } } return None; // inside this (topmost) slicer, but on its header/background → select/drag diff --git a/crates/xlsx/src/drawing.rs b/crates/xlsx/src/drawing.rs index 6dbd17e7e2..d2e6b07a7c 100644 --- a/crates/xlsx/src/drawing.rs +++ b/crates/xlsx/src/drawing.rs @@ -175,6 +175,8 @@ pub fn read_drawing(cx: &mut Ctx<'_>, part: &str, sheet: &mut Sheet) -> Result<( None => cx.warn(format!("chart {target} has no supported plot and was skipped")), } } + } else if crate::slicer::read_frame(cx, &sheet.name, gd, anchor) { + // A slicer: joined with its slicer part and cache once the sheets are read. } else if uri.contains("chartex") || uri.contains("chartEx") { cx.warn("a histogram, waterfall, treemap… chart without its chart part was skipped"); } else { @@ -311,6 +313,8 @@ pub enum Obj<'a> { Image(&'a Image, String), Chart(&'a gridcraft_model::Chart, String), Shape(&'a Shape), + /// A PivotTable slicer and its (unique) name. + Slicer(&'a gridcraft_model::Slicer, String), } /// The drawing part for a sheet's objects (relationship ids already assigned). @@ -359,6 +363,12 @@ pub fn write_drawing(sheet: &Sheet, theme: &Theme, objs: &[Obj<'_>]) -> String { xfrm(&ch.anchor, "xdr:xfrm") ); } + Obj::Slicer(sl, name) => { + let (open, close) = anchor_open(sheet, &sl.anchor); + close_tag = close; + s.push_str(&open); + s.push_str(&crate::slicer::frame_xml(id, name, &xfrm(&sl.anchor, "xdr:xfrm"), &xfrm(&sl.anchor, "a:xfrm"))); + } Obj::Shape(sh) => { let (open, close) = anchor_open(sheet, &sh.anchor); close_tag = close; diff --git a/crates/xlsx/src/lib.rs b/crates/xlsx/src/lib.rs index 3300f97fe5..186ef11b26 100644 --- a/crates/xlsx/src/lib.rs +++ b/crates/xlsx/src/lib.rs @@ -28,6 +28,7 @@ mod read; mod richdata; mod sheet_read; mod sheet_write; +mod slicer; mod styles; mod tables; #[cfg(any(test, feature = "test-support"))] diff --git a/crates/xlsx/src/pivot.rs b/crates/xlsx/src/pivot.rs index 609ae530e1..f65994f1f1 100644 --- a/crates/xlsx/src/pivot.rs +++ b/crates/xlsx/src/pivot.rs @@ -40,6 +40,8 @@ const MAX_ITEMS: usize = 1_048_576; const MAX_FIELDS: usize = 16_384; /// The `x14:dataField` extension (Excel 2010 show-values-as additions such as rank). const EXT_X14_DATA_FIELD: &str = "{E15A36E0-9728-4e99-A89B-3F7291B0FE68}"; +/// Extension carrying a cache's id for slicer caches. +const EXT_X14_CACHE_ID: &str = "{725AE2AE-9491-48be-B2B4-4EB974FC3084}"; const NS_X14: &str = "http://schemas.microsoft.com/office/spreadsheetml/2009/9/main"; const MONTHS: [&str; 12] = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]; @@ -257,6 +259,18 @@ struct Cache { axis_map: Vec>, source_xml: String, records: Option<(u64, String)>, + /// Fields slicers filter (written with items even off the axes). + slicer_fields: Vec, + /// The cache's id for slicer caches (`x14:pivotCacheDefinition pivotCacheId`). + slicer_id: Option, +} + +/// What a slicer cache needs from a written PivotTable. +pub struct PivotSlicerInfo { + /// The cache's `pivotCacheId`. + pub cache_id: u32, + /// Slicer fields: name and the captions its shared item indices refer to. + pub fields: Vec<(String, Vec)>, } /// PivotTables being written; the tables themselves are finished after the styles part (their @@ -267,6 +281,8 @@ pub struct PivotWriter { caches: Vec<(u32, String)>, pending: Vec, tables: u32, + /// (sheet, pivot index) → slicer data of PivotTables that slicers filter. + slicer_info: HashMap<(usize, usize), PivotSlicerInfo>, } struct Pending { @@ -283,8 +299,14 @@ impl PivotWriter { pub fn add_sheet(&mut self, wb: &Workbook, si: usize, out: &mut Out, rels: &mut Rels) { let Some(sheet) = wb.sheet(si) else { return }; for (idx, pt) in sheet.pivots.iter().enumerate() { - let Some(cache) = build_cache(wb, pt, si) else { continue }; + let slicer_fields = out.slicers.fields_for(si, idx); + let Some(mut cache) = build_cache(wb, pt, si, &slicer_fields) else { continue }; let n = self.caches.len() as u32 + 1; + if !cache.slicer_fields.is_empty() { + cache.slicer_id = Some(n); + let fields = cache.slicer_fields.iter().filter_map(|&i| cache.fields.get(i)).map(|f| (f.name.clone(), f.item_labels())).collect(); + self.slicer_info.insert((si, idx), PivotSlicerInfo { cache_id: n, fields }); + } let cache_part = format!("xl/pivotCache/pivotCacheDefinition{n}.xml"); let rec_rid = if let Some((count, xml)) = &cache.records { let mut r = Rels::default(); @@ -308,6 +330,11 @@ impl PivotWriter { } } + /// Slicer data of the PivotTable `idx` on sheet `si`, when it was written for a slicer. + pub fn slicer_info(&self, si: usize, idx: usize) -> Option<&PivotSlicerInfo> { + self.slicer_info.get(&(si, idx)) + } + /// Adds the workbook relationships to the caches; returns the `` element (empty /// when there are none). pub fn workbook_xml(&self, wb_rels: &mut Rels) -> String { @@ -412,7 +439,16 @@ fn header_text(v: &Value) -> String { } } -fn build_cache(wb: &Workbook, pt: &PivotTable, pivot_sheet: usize) -> Option { +/// Whether a slicer on `field` of `pt` can be written: the PivotTable's cache can be built and its +/// source has the field. +pub(crate) fn slicer_field_ok(wb: &Workbook, pt: &PivotTable, pivot_sheet: usize, field: &str) -> bool { + let Some((si, r, _)) = resolve_source(wb, &pt.source, pivot_sheet, 0) else { return false }; + let Some(sh) = wb.sheet(si) else { return false }; + r.count() <= MAX_SOURCE_CELLS && (r.start.col..=r.end.col).any(|c| same(&header_text(&sh.value(CellRef::new(r.start.row, c)).scalar()), field)) +} + +/// `slicer_fields`: fields slicers filter, which need shared items. +fn build_cache(wb: &Workbook, pt: &PivotTable, pivot_sheet: usize, slicer_fields: &[String]) -> Option { let (si, r, source_xml) = resolve_source(wb, &pt.source, pivot_sheet, 0)?; if r.count() > MAX_SOURCE_CELLS { return None; @@ -567,6 +603,12 @@ fn build_cache(wb: &Workbook, pt: &PivotTable, pivot_sheet: usize) -> Option = slicer_fields.iter().filter_map(|n| find(&fields, n)).collect(); + for &i in &slicer_idx { + if let Some(f) = fields.get_mut(i) { + f.itemized = true; + } + } for f in fields.iter_mut() { if f.group.is_some() { f.itemized = false; @@ -630,7 +672,7 @@ fn build_cache(wb: &Workbook, pt: &PivotTable, pivot_sheet: usize) -> Option Vec { @@ -761,7 +803,14 @@ fn cache_xml(c: &Cache, records_rid: Option<&str>, sys: DateSystem) -> String { } s.push_str(""); } - s.push_str(""); + s.push_str(""); + if let Some(id) = c.slicer_id { + let _ = write!( + s, + "" + ); + } + s.push_str(""); s } @@ -770,7 +819,8 @@ fn table_xml(pt: &PivotTable, idx: usize, cache_id: u32, c: &Cache, fmt_id: &dyn let find_db = |name: &str| c.fields.iter().position(|f| f.col.is_some() && same(&f.name, name)); let row_idx: Vec = c.axis_map.iter().take(pt.rows.len()).filter_map(|x| *x).collect(); let col_idx: Vec = c.axis_map.iter().skip(pt.rows.len()).filter_map(|x| *x).collect(); - // Filters: on an axis field → hidden items there; otherwise a page field. + // Filters: on an axis field → hidden items there; a hidden filter (a slicer's) → hidden items + // of a field in no area; otherwise a page field. let mut hidden: HashMap = HashMap::new(); let mut pages: Vec<(usize, &PivotFilter)> = Vec::new(); for f in &pt.filters { @@ -781,6 +831,11 @@ fn table_xml(pt: &PivotTable, idx: usize, cache_id: u32, c: &Cache, fmt_id: &dyn hidden.insert(*i, f); } } + None if f.hidden => { + if let Some(i) = find_db(&f.source_col) { + hidden.insert(i, f); + } + } None => { if let Some(i) = find_db(&f.source_col) && !pages.iter().any(|(j, _)| *j == i) @@ -908,7 +963,7 @@ fn table_xml(pt: &PivotTable, idx: usize, cache_id: u32, c: &Cache, fmt_id: &dyn if !pt.subtotals_top { s.push_str(" subtotalTop=\"0\""); } - if axis.is_some() { + if axis.is_some() || (f.itemized && (hidden.contains_key(&i) || c.slicer_fields.contains(&i))) { let collapsed: &[String] = axis_field(i).map(|f| f.collapsed_items.as_slice()).unwrap_or(&[]); let count = labels.len() + usize::from(pt.subtotals); let _ = write!(s, ">"); @@ -1052,6 +1107,8 @@ impl FieldIn { struct CacheIn { source: String, fields: Vec, + /// `x14:pivotCacheDefinition pivotCacheId`: how slicer caches name the cache. + slicer_id: Option, } /// Workbook-level pivot cache lookup, filled from ``; caches are parsed once. @@ -1076,6 +1133,13 @@ impl CachesIn { } CachesIn { by_id, parsed: HashMap::new(), next_id: 1 } } + + /// Item captions of field `field` in the cache a slicer cache names by `pivotCacheId`, in + /// shared item order (what the slicer cache's `` indices refer to). + pub fn slicer_labels(&self, cache_id: u32, field: &str) -> Option<&[String]> { + let cache = self.parsed.values().flatten().find(|c| c.slicer_id == Some(cache_id))?; + Some(cache.fields.iter().find(|f| f.database && same(&f.name, field))?.labels()) + } } fn item_label_el(e: &El, sys: DateSystem) -> String { @@ -1151,7 +1215,9 @@ fn read_cache(cx: &mut Ctx<'_>, part: &str, sheet_name: &str) -> Option cx.warn("a PivotTable's cache has no fields; kept as plain cells"); return None; } - Some(CacheIn { source, fields }) + let slicer_id = + x.child("extLst").and_then(|l| l.kids("ext").find_map(|e| e.child("pivotCacheDefinition"))).and_then(|d| d.attr_u32("pivotCacheId")); + Some(CacheIn { source, fields, slicer_id }) } /// Reads the PivotTables related to a worksheet part. @@ -1300,7 +1366,7 @@ fn read_table(cx: &mut Ctx<'_>, x: &El, cache: &CacheIn, id: u32) -> Option = items.iter().filter(|it| !it.flag("h", false)).filter_map(|it| lab(it)).collect(); if !filters.iter().any(|ff| same(&ff.source_col, &name)) { - filters.push(PivotFilter { source_col: name.clone(), selected: Some(shown) }); + filters.push(PivotFilter { source_col: name.clone(), selected: Some(shown), hidden: false }); } } } @@ -1327,7 +1393,7 @@ fn read_table(cx: &mut Ctx<'_>, x: &El, cache: &CacheIn, id: u32) -> Option, x: &El, cache: &CacheIn, id: u32) -> Option