mirror of
https://github.com/storytold/gridcraft.git
synced 2026-11-07 02:51:33 +00:00
fix(calc): apply "Set precision as displayed" in calculation (#1366)
* ft-121.7.9: apply Set precision as displayed in calculation (#1343) With the option on, a formula result (and each element of a spill) is rounded to its cell's number format before it is stored and before other formulas read it. Numbers typed, pasted or reformatted while it is on are rounded at once, and turning it on through formulas.calculationOptions {precisionAsDisplayed} rounds every number constant, as Excel does. The rounding is NumberFormat::round_as_displayed in the numfmt crate, which shares the renderer's digit logic (fixed, scientific, fraction). General, date/time, text, digitless and conditional formats keep the full value, as observed in Excel through COM. Nothing changes when the flag is off. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * ft-121.7.9: round conditional formats by the sign's section under precision as displayed (#1343) Excel does round formats with a condition when "Set precision as displayed" is on; the PR kept them at full value. Observed through COM with synthetic values (and in gridcraft-corpus excel/precision-displayed/pad-on-conditional-sections.xlsx): conditions are ignored and the sign picks the section, the first for positives and the second for negatives. `[<1]0.00;0` shows 2+1/3 as 2 but stores 2.33, and stores -10/3 as -3. Two more observed details: with a condition and a single number section, negatives keep the full value (`[<1]0.00`), unless an `@` section follows (`[<1]0.00;@` rounds them by the first section); and a completely empty section passes to the next one (`0.0;;0.000` stores -2.333 as -2.333, `;0.0` stores 2.333 as 2.3), for formats with or without a condition. Display is unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * engine: recalculate spill anchors when a spill cell is reformatted under precision as displayed round_edited_as_displayed only looked at stored-cell changes, but a spill destination lives in Sheet::spill, so reformatting it (or its row or column) left the anchor out of the changed set and the element unrounded. Add each spill anchor whose range holds a changed cell or a changed row/column style to the changed set. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> * numfmt: drop a cast the fraction merge made redundant * precision as displayed: keep locked constants on protected sheets, round new sheets, fix manual-mode comment * precision as displayed: name the input sheet in the new-sheet histogram test * precision as displayed: use six values in the new-sheet histogram test --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
c402953828
commit
292b784f02
13 files changed
+770
-51
No files matched your search
@@ -177,6 +177,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in
|
||||
| 2026-10-15 | File › Options › Formulas: Maximum Iterations and Maximum Change fields for iterative calculation, disabled while it is off, with Excel's limits (1 to 32767, 0 or more) |
|
||||
| 2026-10-15 | Function help follows the interface language: argument hints, autocomplete, Insert Function (search and readable categories) and the Formulas-tab menus read per-language tables of descriptions and argument names; Spanish keeps its 10 functions in the new format |
|
||||
| 2026-10-15 | Row auto-fit counts a cell's line breaks only when the cell wraps, as Excel does: a file whose unwrapped cells hold multi-line text no longer opens with tall rows (typing a break still turns wrap on and grows the row) |
|
||||
| 2026-10-14 | Set precision as displayed is applied: with the option on, formula results, spills and numbers typed or reformatted are stored as their number format shows them and formulas calculate from those values; `formulas.calculationOptions` gains `precisionAsDisplayed` (turning it on rounds existing constants; locked constants on a protected sheet keep their value; numbers written to a new sheet are rounded too) (#1343) |
|
||||
| 2026-10-14 | Lookups (MATCH, XMATCH, VLOOKUP, HLOOKUP, XLOOKUP, LOOKUP) compare numbers exactly like Excel, so a computed key such as `=0.1+0.2` no longer finds 0.3; `=` and COUNTIF/SUMIF keep their 15-digit comparison |
|
||||
| 2026-10-14 | Functions: EOMONTH, EDATE, WORKDAY, NETWORKDAYS, YEARFRAC, WEEKNUM, the bond and coupon functions, DISC, BIN2DEC, IM* and the other Analysis ToolPak functions give `#VALUE!` for a reference to more than one cell in a value argument, as Excel 365 does, instead of spilling; array constants and computed arrays still lift |
|
||||
| 2026-10-14 | Protect Sheet on a protected sheet: Home › Format, its keytips (HOP, RPS) and the tab menu now read Unprotect Sheet… and unprotect (straight away without a password); a protected sheet that forbids selecting locked (or unlocked) cells is enforced for clicks, arrows and Go To, and the Protect Sheet dialog has the select options (#1346) |
|
||||
@@ -288,6 +289,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in
|
||||
| 2026-10-16 | minor | Progress log: floating pictures' textures bounded and kept per workbook |
|
||||
| 2026-10-16 | trivial | Progress log: web preferences kept between visits (#1562) |
|
||||
| 2026-10-16 | minor | Progress log: interface fonts held once instead of twice |
|
||||
| 2026-10-14 | minor | Progress log: Set precision as displayed applied in calculation (#1343) |
|
||||
| 2026-10-14 | minor | Progress log: `.xls` import reads cell formatting |
|
||||
| 2026-10-14 | minor | Progress log: lookups compare numbers exactly |
|
||||
| 2026-10-14 | minor | Progress log: Analysis ToolPak value arguments reject multi-cell references |
|
||||
|
||||
@@ -4,10 +4,12 @@
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
pub mod eval;
|
||||
mod precision;
|
||||
pub mod recalc;
|
||||
mod trimrange;
|
||||
|
||||
pub use eval::{Area, SPECIAL_FUNCTIONS, is_known_function};
|
||||
pub use precision::DisplayedPrecision;
|
||||
pub use recalc::{Calc, Key, evaluate, now_serial};
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
//! "Set precision as displayed" (File › Options › Advanced; `fullPrecision="0"` in `.xlsx`,
|
||||
//! CalcPrecision in `.xls`): with it on, a number is stored as its cell's number format shows it,
|
||||
//! and formulas read those rounded values. The rounding itself is the number format crate's
|
||||
//! ([`gridcraft_numfmt::NumberFormat::round_as_displayed`]); this module looks up each cell's
|
||||
//! format, parsing every style's code once.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use gridcraft_core::{CellRef, Value};
|
||||
use gridcraft_model::{StyleId, Workbook};
|
||||
use gridcraft_numfmt::NumberFormat;
|
||||
|
||||
/// Rounds values to their cell's displayed precision, remembering each style's parsed format.
|
||||
/// Styles don't change while it is used (one recalculation pass, one sweep of a workbook).
|
||||
#[derive(Default)]
|
||||
pub struct DisplayedPrecision {
|
||||
/// Parsed format per style; `None` for General, which keeps the full value.
|
||||
formats: HashMap<StyleId, Option<NumberFormat>>,
|
||||
}
|
||||
|
||||
impl DisplayedPrecision {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// `n` as the format of `c` on sheet `sheet` displays it (the cell's own style, else its
|
||||
/// row's, else its column's, as the grid shows it).
|
||||
pub fn round(&mut self, wb: &Workbook, sheet: usize, c: CellRef, n: f64) -> f64 {
|
||||
let Some(sh) = wb.sheet(sheet) else { return n };
|
||||
let id = sh.style_id(c);
|
||||
let fmt = self.formats.entry(id).or_insert_with(|| {
|
||||
let f = NumberFormat::parse_cached(wb.styles.get(id).num_fmt.as_str());
|
||||
(!f.is_general()).then_some(f)
|
||||
});
|
||||
match fmt {
|
||||
Some(f) => f.round_as_displayed(n),
|
||||
None => n,
|
||||
}
|
||||
}
|
||||
|
||||
/// Like [`DisplayedPrecision::round`] for a value: only numbers change.
|
||||
pub fn round_value(&mut self, wb: &Workbook, sheet: usize, c: CellRef, v: Value) -> Value {
|
||||
match v {
|
||||
Value::Number(n) => {
|
||||
let r = self.round(wb, sheet, c, n);
|
||||
// Bit-equal results keep the value as it was (no needless change for undo/diffs).
|
||||
if r.to_bits() == n.to_bits() { Value::Number(n) } else { Value::Number(r) }
|
||||
}
|
||||
v => v,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -64,6 +64,9 @@ pub(super) struct PassHost<'a> {
|
||||
pub(super) tainted: HashSet<Key>,
|
||||
/// Whether the formula being evaluated has read a previous value.
|
||||
taint: bool,
|
||||
/// "Set precision as displayed" is on: results are rounded to their cells' formats before
|
||||
/// they are stored or read by other formulas.
|
||||
precision: Option<crate::DisplayedPrecision>,
|
||||
/// `stack_position()` where the outermost formula being calculated started.
|
||||
stack_top: usize,
|
||||
/// Whether an evaluation ran past the stack budget since the innermost formula started.
|
||||
@@ -104,6 +107,7 @@ impl<'a> PassHost<'a> {
|
||||
members: HashSet::default(),
|
||||
tainted: HashSet::default(),
|
||||
taint: false,
|
||||
precision: wb.calc.precision_as_displayed.then(crate::DisplayedPrecision::new),
|
||||
stack_top: 0,
|
||||
out_of_stack: false,
|
||||
python: HashMap::default(),
|
||||
@@ -344,6 +348,23 @@ impl PassHost<'_> {
|
||||
let data = a.data.iter().map(|x| if matches!(x, Value::Empty) { Value::Number(0.0) } else { x.clone() }).collect();
|
||||
Array::new(a.rows, a.cols, data).map(std::sync::Arc::new).unwrap_or(a)
|
||||
};
|
||||
// Precision as displayed: each element takes the format of the cell it lands in.
|
||||
let a = match self.precision.as_mut() {
|
||||
Some(p) if a.data.iter().any(|x| matches!(x, Value::Number(_))) => {
|
||||
let cols = a.cols.max(1);
|
||||
let data = a
|
||||
.data
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, x)| {
|
||||
let at = CellRef::new(k.1.row.saturating_add((i / cols) as u32), k.1.col.saturating_add((i % cols) as u32));
|
||||
p.round_value(self.wb, k.0, at, x.clone())
|
||||
})
|
||||
.collect();
|
||||
Array::new(a.rows, a.cols, data).map(std::sync::Arc::new).unwrap_or(a)
|
||||
}
|
||||
_ => a,
|
||||
};
|
||||
// A dynamic-array formula anchors a spill range even while its result is one
|
||||
// value -- a FILTER matching one row, a SORT or UNIQUE over one item -- so `C1#`
|
||||
// is the 1 x 1 range and `ROWS(C1#)` is 1, as in Excel (#1491). A formula that
|
||||
@@ -352,6 +373,10 @@ impl PassHost<'_> {
|
||||
a.data.first().cloned().unwrap_or_default()
|
||||
}
|
||||
Value::Empty => Value::Number(0.0),
|
||||
v @ Value::Number(_) => match self.precision.as_mut() {
|
||||
Some(p) => p.round_value(self.wb, k.0, k.1, v),
|
||||
None => v,
|
||||
},
|
||||
v => v,
|
||||
};
|
||||
// An array that can't spill is #SPILL! for the formulas evaluated after it in this pass too.
|
||||
|
||||
@@ -1922,6 +1922,72 @@ fn offset_extreme_arguments_return_ref() {
|
||||
}
|
||||
}
|
||||
|
||||
/// "Set precision as displayed": results are rounded to their cell's format before they are
|
||||
/// stored and before other formulas read them. Values observed in Excel (COM,
|
||||
/// `Workbook.PrecisionAsDisplayed = True`) with the same synthetic formulas.
|
||||
#[test]
|
||||
fn precision_as_displayed_rounds_results_by_cell_format() {
|
||||
let fmt = |t: &mut T, at: &str, code: &str| {
|
||||
let id = t.wb.styles.intern(gridcraft_model::Style { num_fmt: gridcraft_model::NumFmt::new(code), ..Default::default() });
|
||||
t.wb.sheet_mut(0).unwrap().set_style(c(at), id);
|
||||
};
|
||||
let mut t = T::new();
|
||||
t.set("A1", "=-130+657.91");
|
||||
t.set("A2", "=A1*10");
|
||||
t.set("A3", "=A1/7");
|
||||
// Cross-cell: E2 rounds what it reads from E1; E1 itself (General) keeps full precision.
|
||||
t.set("E1", "=10/3");
|
||||
t.set("E2", "=E1");
|
||||
t.set("E3", "=E2*10");
|
||||
t.set("E4", "=E1*10");
|
||||
// A spill: each element takes the format of the cell it lands in.
|
||||
t.set("F1", "=SEQUENCE(3,1,0.123,0.111)");
|
||||
t.set("G4", "=SUM(F1:F3)");
|
||||
t.set("H1", "=TRUE");
|
||||
t.set("H2", "=\"1.5\"");
|
||||
// A conditional format rounds by the sign's section, not the one displayed: `[<1]0.00;0`
|
||||
// shows 2+1/3 as `2` but stores 2.33, and stores -10/3 as -3.
|
||||
t.set("J1", "=2+1/3");
|
||||
t.set("J2", "=-10/3");
|
||||
t.set("K1", "=J1*3");
|
||||
t.set("K2", "=J2*3");
|
||||
fmt(&mut t, "J1", "[<1]0.00;0");
|
||||
fmt(&mut t, "J2", "[<1]0.00;0");
|
||||
fmt(&mut t, "A1", "0");
|
||||
fmt(&mut t, "A3", "0.0000");
|
||||
fmt(&mut t, "E2", "0");
|
||||
fmt(&mut t, "F1", "0.0");
|
||||
fmt(&mut t, "F2", "0.0");
|
||||
fmt(&mut t, "H1", "0");
|
||||
fmt(&mut t, "H2", "0");
|
||||
// Off: formats don't change values.
|
||||
t.calc.recalc_all(&mut t.wb);
|
||||
assert_eq!(t.num("A1"), -130.0 + 657.91);
|
||||
assert_eq!(t.num("E2"), 10.0 / 3.0);
|
||||
t.wb.calc.precision_as_displayed = true;
|
||||
t.calc.recalc_all(&mut t.wb);
|
||||
assert_eq!((t.num("A1"), t.num("A2"), t.num("A3")), (528.0, 5280.0, 75.4286));
|
||||
assert_eq!((t.num("E1"), t.num("E2"), t.num("E3"), t.num("E4")), (10.0 / 3.0, 3.0, 30.0, 10.0 / 3.0 * 10.0));
|
||||
let sh = t.wb.sheet(0).unwrap();
|
||||
assert_eq!(
|
||||
(sh.value(c("F1")), sh.value(c("F2")), sh.value(c("F3"))),
|
||||
(Value::Number(0.1), Value::Number(0.2), Value::Number(0.123 + 0.111 * 2.0))
|
||||
);
|
||||
assert_eq!(t.num("G4"), 0.1 + 0.2 + (0.123 + 0.111 * 2.0));
|
||||
assert_eq!((t.num("J1"), t.num("J2"), t.num("K1"), t.num("K2")), (2.33, -3.0, 2.33 * 3.0, -9.0));
|
||||
// Booleans and text are left alone.
|
||||
assert_eq!((t.get("H1"), t.get("H2")), (Value::Bool(true), Value::from("1.5")));
|
||||
// An edit recalculates through the rounded values too.
|
||||
t.set("A1", "=-130+657.41");
|
||||
fmt(&mut t, "A1", "0");
|
||||
t.calc.cells_changed(&mut t.wb, &[(0, c("A1"))]);
|
||||
assert_eq!((t.num("A1"), t.num("A2")), (527.0, 5270.0));
|
||||
// Off again: a recalculation brings back full precision.
|
||||
t.wb.calc.precision_as_displayed = false;
|
||||
t.calc.recalc_all(&mut t.wb);
|
||||
assert_eq!(t.num("A2"), (-130.0 + 657.41) * 10.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn analysis_toolpak_value_params_reject_multi_cell_references() {
|
||||
// Excel 365 (observed): EOMONTH, WORKDAY, COUPDAYS, BIN2DEC... give #VALUE! for a reference to more
|
||||
|
||||
+115
-1
@@ -406,7 +406,7 @@ pub(crate) fn commit<R>(d: &mut DocState, f: impl FnOnce(&mut Ctx) -> Result<R>)
|
||||
edits: Vec::new(),
|
||||
};
|
||||
let r = f(&mut ctx)?;
|
||||
let Ctx { mut wb, changed, structural, mut fit_rows, protection_checked, edits, .. } = ctx;
|
||||
let Ctx { mut wb, mut changed, structural, mut fit_rows, protection_checked, edits, .. } = ctx;
|
||||
// Equations whose source changed (or that arrived without a layout, as from a paste) are
|
||||
// laid out here, so painting only ever reads the cache.
|
||||
wb.refresh_equations();
|
||||
@@ -416,6 +416,9 @@ pub(crate) fn commit<R>(d: &mut DocState, f: impl FnOnce(&mut Ctx) -> Result<R>)
|
||||
if !structural {
|
||||
check_arrays(&d.wb, &wb)?;
|
||||
}
|
||||
if wb.calc.precision_as_displayed {
|
||||
round_edited_as_displayed(&d.wb, &mut wb, &mut changed, structural);
|
||||
}
|
||||
if let Some(log) = d.touched.as_mut() {
|
||||
for (si, c) in changed.iter().take(TOUCH_LOG_LIMIT.saturating_sub(log.len())) {
|
||||
if let Some(sh) = wb.sheet(*si) {
|
||||
@@ -454,6 +457,117 @@ pub(crate) fn commit<R>(d: &mut DocState, f: impl FnOnce(&mut Ctx) -> Result<R>)
|
||||
Ok(r)
|
||||
}
|
||||
|
||||
/// "Set precision as displayed": a number typed, pasted or reformatted is stored as its format
|
||||
/// shows it (Excel rounds a constant when it is entered and when its cell's format changes,
|
||||
/// observed through COM). Only cells the edit touched are visited, so this costs nothing for
|
||||
/// the rest of the workbook; formula cells whose format changed are recalculated, and the
|
||||
/// recalculation rounds them. A structural edit recalculates everything anyway, so it only
|
||||
/// rounds its constants (a sheet the edit created, for one, holds written numbers).
|
||||
fn round_edited_as_displayed(old: &Workbook, wb: &mut Workbook, changed: &mut Vec<gridcraft_calc::Key>, structural: bool) {
|
||||
let mut prec = gridcraft_calc::DisplayedPrecision::new();
|
||||
for si in 0..wb.sheets.len() {
|
||||
let Some(after) = wb.sheets.get(si) else { continue };
|
||||
// Sheets are matched by name. A sheet the edit created has no earlier version, so all of
|
||||
// its cells are new.
|
||||
let before = old.sheet_index(&after.name).and_then(|i| old.sheets.get(i));
|
||||
if before.is_some_and(|b| std::sync::Arc::ptr_eq(b, after)) {
|
||||
continue;
|
||||
}
|
||||
let diffs: Vec<CellRef> = match before {
|
||||
Some(before) => before.cells.diff(&after.cells),
|
||||
None => after.cells.iter().map(|(c, _)| c).collect(),
|
||||
};
|
||||
let mut anchors = std::collections::HashSet::new();
|
||||
for &c in &diffs {
|
||||
if !structural && after.cell(c).is_some_and(|cell| cell.formula.is_some()) {
|
||||
changed.push((si, c));
|
||||
anchors.insert(c);
|
||||
}
|
||||
}
|
||||
let updates = displayed_updates(&mut prec, wb, si, diffs.iter().copied());
|
||||
// A spill destination is no stored cell: reformatting it (or its row or column) leaves
|
||||
// no change in the store, so its anchor is recalculated to round the new element.
|
||||
if let (Some(before), false) = (before, structural) {
|
||||
for (&anchor, range) in &after.spill_ranges {
|
||||
if !anchors.contains(&anchor)
|
||||
&& (diffs.iter().any(|d| range.contains(*d))
|
||||
|| line_styles_differ(&before.rows, &after.rows, range.start.row, range.end.row)
|
||||
|| line_styles_differ(&before.cols, &after.cols, range.start.col, range.end.col))
|
||||
{
|
||||
changed.push((si, anchor));
|
||||
}
|
||||
}
|
||||
}
|
||||
if updates.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let Some(sh) = wb.sheet_mut(si) else { continue };
|
||||
for (c, r) in updates {
|
||||
if let Some(cell) = sh.cells.get_mut(c) {
|
||||
cell.value = gridcraft_core::Value::Number(r);
|
||||
if !structural {
|
||||
changed.push((si, c));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether any row or column style in `lo..=hi` differs between two sheets' line maps.
|
||||
fn line_styles_differ(
|
||||
before: &std::collections::BTreeMap<u32, gridcraft_model::LineInfo>,
|
||||
after: &std::collections::BTreeMap<u32, gridcraft_model::LineInfo>,
|
||||
lo: u32,
|
||||
hi: u32,
|
||||
) -> bool {
|
||||
let style = |m: &std::collections::BTreeMap<u32, gridcraft_model::LineInfo>, k: u32| m.get(&k).and_then(|l| l.style);
|
||||
after.range(lo..=hi).any(|(k, l)| l.style != style(before, *k)) || before.range(lo..=hi).any(|(k, l)| l.style != style(after, *k))
|
||||
}
|
||||
|
||||
/// Turning "Set precision as displayed" on rounds every number constant in the workbook to its
|
||||
/// format, permanently (Excel warns "Data will permanently lose accuracy"). Formulas are rounded
|
||||
/// by the recalculation that follows.
|
||||
pub(crate) fn round_all_constants_as_displayed(wb: &mut Workbook) {
|
||||
let mut prec = gridcraft_calc::DisplayedPrecision::new();
|
||||
for si in 0..wb.sheets.len() {
|
||||
let Some(sh) = wb.sheets.get(si) else { continue };
|
||||
let updates = displayed_updates(&mut prec, wb, si, sh.cells.iter().map(|(c, _)| c));
|
||||
if updates.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let Some(sh) = wb.sheet_mut(si) else { continue };
|
||||
for (c, r) in updates {
|
||||
if let Some(cell) = sh.cells.get_mut(c) {
|
||||
cell.value = gridcraft_core::Value::Number(r);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The constants among `cells` on sheet `si` whose value the option changes, with their rounded
|
||||
/// values. A locked constant on a protected sheet keeps its value: protection keeps its content,
|
||||
/// and rounding it by its format would change the content that the sheet protects.
|
||||
fn displayed_updates(
|
||||
prec: &mut gridcraft_calc::DisplayedPrecision,
|
||||
wb: &Workbook,
|
||||
si: usize,
|
||||
cells: impl Iterator<Item = CellRef>,
|
||||
) -> Vec<(CellRef, f64)> {
|
||||
let Some(sh) = wb.sheet(si) else { return Vec::new() };
|
||||
cells
|
||||
.filter(|&c| !(sh.is_protected() && wb.styles.get(sh.style_id(c)).protection.locked))
|
||||
.filter_map(|c| {
|
||||
let cell = sh.cell(c)?;
|
||||
match cell.value {
|
||||
gridcraft_core::Value::Number(n) if cell.formula.is_none() => {
|
||||
let r = prec.round(wb, si, c, n);
|
||||
(r.to_bits() != n.to_bits()).then_some((c, r))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
/// The most cells a dry run's touch log keeps; a footprint whose log is full may be missing writes
|
||||
/// that kept a cell's value, so a shift is refused then.
|
||||
pub(crate) const TOUCH_LOG_LIMIT: usize = 1_000_000;
|
||||
|
||||
@@ -21,7 +21,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Options",
|
||||
["File"],
|
||||
None,
|
||||
"{} → {calculation: {mode, iterative, maxIterations, maxChange}} for the active workbook (the interface opens File › Options; change settings with their own commands, e.g. formulas.calculationOptions, view.theme, app.language.set)",
|
||||
"{} → {calculation: {mode, iterative, maxIterations, maxChange, precisionAsDisplayed}} for the active workbook (the interface opens File › Options; change settings with their own commands, e.g. formulas.calculationOptions, view.theme, app.language.set)",
|
||||
always,
|
||||
options
|
||||
),
|
||||
@@ -230,7 +230,7 @@ fn options(s: &mut Session, _: &Json) -> Result<Json> {
|
||||
gridcraft_model::CalcMode::AutomaticExceptTables => "automaticExceptTables",
|
||||
gridcraft_model::CalcMode::Manual => "manual",
|
||||
};
|
||||
json!({"mode": mode, "iterative": c.iterative, "maxIterations": c.max_iterations, "maxChange": c.max_change})
|
||||
json!({"mode": mode, "iterative": c.iterative, "maxIterations": c.max_iterations, "maxChange": c.max_change, "precisionAsDisplayed": c.precision_as_displayed})
|
||||
});
|
||||
Ok(json!({"calculation": calculation}))
|
||||
}
|
||||
|
||||
@@ -101,7 +101,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Calculation Options",
|
||||
["Formulas", "Calculation"],
|
||||
None,
|
||||
"{mode?: automatic|automaticExceptTables|manual, iterative?: bool (Enable iterative calculation), maxIterations?: 1–32767 (100), maxChange?: number (0.001)} → the settings. File › Options › Formulas › Calculation options; stored in the workbook.",
|
||||
"{mode?: automatic|automaticExceptTables|manual, iterative?: bool (Enable iterative calculation), maxIterations?: 1–32767 (100), maxChange?: number (0.001), precisionAsDisplayed?: bool (Set precision as displayed: numbers are stored as their format shows them; turning it on rounds every number constant permanently)} → the settings. File › Options › Formulas › Calculation options; stored in the workbook.",
|
||||
has_doc,
|
||||
calc_options
|
||||
),
|
||||
@@ -954,14 +954,30 @@ fn calc_options(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
if let Some(v) = f64_param(p, "maxChange") {
|
||||
cx.wb.calc.set_max_change(v);
|
||||
}
|
||||
let precision = bool_param(p, "precisionAsDisplayed");
|
||||
if let Some(v) = precision {
|
||||
cx.wb.calc.precision_as_displayed = v;
|
||||
if v && !before.precision_as_displayed {
|
||||
crate::cmd::round_all_constants_as_displayed(&mut cx.wb);
|
||||
}
|
||||
}
|
||||
let c = &cx.wb.calc;
|
||||
// Switching to automatic, or changing how circular references are calculated outside
|
||||
// manual mode, recalculates the workbook.
|
||||
let iteration_changed = (c.iterative, c.max_iterations, c.max_change) != (before.iterative, before.max_iterations, before.max_change);
|
||||
if mode == Some(CalcMode::Automatic) || (iteration_changed && c.mode != CalcMode::Manual) {
|
||||
// Switching precision as displayed recalculates the workbook, in manual mode too, so the
|
||||
// values change as soon as it is switched on or off.
|
||||
let precision_changed = c.precision_as_displayed != before.precision_as_displayed;
|
||||
if mode == Some(CalcMode::Automatic) || (iteration_changed && c.mode != CalcMode::Manual) || precision_changed {
|
||||
cx.structural = true;
|
||||
}
|
||||
Ok(json!({"mode": format!("{:?}", c.mode), "iterative": c.iterative, "maxIterations": c.max_iterations, "maxChange": c.max_change}))
|
||||
Ok(json!({
|
||||
"mode": format!("{:?}", c.mode),
|
||||
"iterative": c.iterative,
|
||||
"maxIterations": c.max_iterations,
|
||||
"maxChange": c.max_change,
|
||||
"precisionAsDisplayed": c.precision_as_displayed,
|
||||
}))
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
+175
-1
@@ -3130,7 +3130,7 @@ fn calculation_options_enable_iterative_calculation() {
|
||||
assert!(!s.doc().unwrap().calc.circular.is_empty());
|
||||
// Switching it on recalculates the loop.
|
||||
let r = s.execute("formulas.calculationOptions", json!({"iterative": true})).unwrap();
|
||||
assert_eq!(r, json!({"mode": "Automatic", "iterative": true, "maxIterations": 100, "maxChange": 0.001}));
|
||||
assert_eq!(r, json!({"mode": "Automatic", "iterative": true, "maxIterations": 100, "maxChange": 0.001, "precisionAsDisplayed": false}));
|
||||
near(&s, "A1", 10.0);
|
||||
near(&s, "A2", 10.0);
|
||||
assert!(s.doc().unwrap().calc.circular.is_empty());
|
||||
@@ -4791,6 +4791,180 @@ fn paste_special_validation_moves_relative_references() {
|
||||
assert!(ok(&mut s, "C3", "20"));
|
||||
}
|
||||
|
||||
/// "Set precision as displayed" (formulas.calculationOptions `precisionAsDisplayed`). Behaviour
|
||||
/// observed in Excel through COM with synthetic values: turning it on rounds existing constants
|
||||
/// and formulas to their formats; constants typed or reformatted while it is on are rounded at
|
||||
/// once (permanently: a wider format later doesn't bring digits back); General keeps full
|
||||
/// precision; turning it off restores formulas on recalculation, not constants.
|
||||
#[test]
|
||||
fn precision_as_displayed_option() {
|
||||
let mut s = s();
|
||||
let num = |s: &Session, a: &str| v(s, a).as_f64().unwrap_or(f64::NAN);
|
||||
s.execute("cell.set", json!({"cell": "A1", "input": "1.23456"})).unwrap();
|
||||
s.execute("cell.set", json!({"cell": "A2", "input": "=A1*100"})).unwrap();
|
||||
s.execute("cell.set", json!({"cell": "A3", "input": "=7/3"})).unwrap();
|
||||
s.execute("cell.set", json!({"cell": "A4", "input": "0.333333333333333"})).unwrap();
|
||||
s.execute("home.numberFormat", json!({"range": "A1", "code": "0.00"})).unwrap();
|
||||
s.execute("home.numberFormat", json!({"range": "A3", "code": "0"})).unwrap();
|
||||
assert_eq!((num(&s, "A1"), num(&s, "A3")), (1.23456, 7.0 / 3.0));
|
||||
let r = s.execute("formulas.calculationOptions", json!({"precisionAsDisplayed": true})).unwrap();
|
||||
assert_eq!(r["precisionAsDisplayed"], true);
|
||||
assert_eq!((num(&s, "A1"), num(&s, "A2"), num(&s, "A3"), num(&s, "A4")), (1.23, 123.0, 2.0, 0.333333333333333));
|
||||
let r = s.execute("file.options", json!({})).unwrap();
|
||||
assert_eq!(r["calculation"]["precisionAsDisplayed"], true);
|
||||
// Typed while on: rounded to the cell's format, and dependents read the rounded value.
|
||||
s.execute("cell.set", json!({"cell": "A1", "input": "4.56789"})).unwrap();
|
||||
assert_eq!((num(&s, "A1"), num(&s, "A2")), (4.57, 457.0));
|
||||
s.execute("range.setValues", json!({"range": "A1", "values": [[5.43219]]})).unwrap();
|
||||
assert_eq!(num(&s, "A1"), 5.43);
|
||||
// Reformatting while on rounds a constant and a formula at once.
|
||||
s.execute("cell.set", json!({"cell": "B1", "input": "1.23456"})).unwrap();
|
||||
s.execute("cell.set", json!({"cell": "B2", "input": "=1.23456"})).unwrap();
|
||||
s.execute("cell.set", json!({"cell": "B3", "input": "=B1*100"})).unwrap();
|
||||
assert_eq!(num(&s, "B1"), 1.23456);
|
||||
s.execute("home.numberFormat", json!({"range": "B1:B2", "code": "0.0"})).unwrap();
|
||||
assert_eq!((num(&s, "B1"), num(&s, "B2"), num(&s, "B3")), (1.2, 1.2, 120.0));
|
||||
// The constant's digits are gone for good; the formula is rounded again from its result.
|
||||
s.execute("home.numberFormat", json!({"range": "B1:B2", "code": "0.0000"})).unwrap();
|
||||
assert_eq!((num(&s, "B1"), num(&s, "B2")), (1.2, 1.2346));
|
||||
// Undo brings the unrounded constant back.
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(num(&s, "B1"), 1.23456);
|
||||
// Saved with the workbook, and applied on recalculation after reopening.
|
||||
reopen(&mut s);
|
||||
assert!(s.doc().unwrap().wb.calc.precision_as_displayed);
|
||||
assert_eq!(num(&s, "A3"), 2.0);
|
||||
// Off: formulas get their full precision back, constants stay rounded.
|
||||
s.execute("formulas.calculationOptions", json!({"precisionAsDisplayed": false})).unwrap();
|
||||
assert_eq!((num(&s, "A1"), num(&s, "A3")), (5.43, 7.0 / 3.0));
|
||||
}
|
||||
|
||||
/// Reformatting a cell a dynamic array spills into rounds that element as displayed: the cell
|
||||
/// holds no stored value (the spill lives in `Sheet::spill`), so the edit changes only its
|
||||
/// style, and the anchor must be recalculated for the element to be rounded.
|
||||
#[test]
|
||||
fn precision_as_displayed_rounds_a_reformatted_spill_cell() {
|
||||
let mut s = s();
|
||||
let num = |s: &Session, a: &str| v(s, a).as_f64().unwrap_or(f64::NAN);
|
||||
s.execute("cell.set", json!({"cell": "F1", "input": "=SEQUENCE(3,1,0.123,0.111)"})).unwrap();
|
||||
s.execute("formulas.calculationOptions", json!({"precisionAsDisplayed": true})).unwrap();
|
||||
s.execute("cell.set", json!({"cell": "G1", "input": "=SUM(F1:F3)"})).unwrap();
|
||||
let (f2, f3) = (0.123 + 0.111, 0.123 + 0.111 * 2.0);
|
||||
assert_eq!((num(&s, "F1"), num(&s, "F2"), num(&s, "F3")), (0.123, f2, f3));
|
||||
// Formatting a spill destination (the anchor is not in the edit) rounds just that element.
|
||||
s.execute("home.numberFormat", json!({"range": "F2", "code": "0.0"})).unwrap();
|
||||
assert_eq!((num(&s, "F1"), num(&s, "F2"), num(&s, "F3")), (0.123, 0.2, f3));
|
||||
assert_eq!(num(&s, "G1"), 0.123 + 0.2 + f3);
|
||||
// A range covering anchor and spill, and a whole column, round every element.
|
||||
s.execute("home.numberFormat", json!({"range": "F:F", "code": "0.00"})).unwrap();
|
||||
assert_eq!((num(&s, "F1"), num(&s, "F2"), num(&s, "F3")), (0.12, 0.23, 0.35));
|
||||
assert_eq!(num(&s, "G1"), 0.12 + 0.23 + 0.35);
|
||||
// Formatting a cell that no spill covers changes nothing.
|
||||
s.execute("home.numberFormat", json!({"range": "F4", "code": "0.0"})).unwrap();
|
||||
assert_eq!(num(&s, "F3"), 0.35);
|
||||
}
|
||||
#[test]
|
||||
fn precision_as_displayed_enable_undoes_in_one_step() {
|
||||
let mut s = s();
|
||||
let num = |s: &Session, a: &str| v(s, a).as_f64().unwrap_or(f64::NAN);
|
||||
s.execute("cell.set", json!({"cell": "A1", "input": "1.23456"})).unwrap();
|
||||
s.execute("cell.set", json!({"cell": "A2", "input": "=A1*100"})).unwrap();
|
||||
s.execute("home.numberFormat", json!({"range": "A1", "code": "0.00"})).unwrap();
|
||||
s.execute("formulas.calculationOptions", json!({"precisionAsDisplayed": true})).unwrap();
|
||||
assert_eq!((num(&s, "A1"), num(&s, "A2")), (1.23, 123.0));
|
||||
// One undo brings back the constants and the flag together.
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert!(!s.doc().unwrap().wb.calc.precision_as_displayed);
|
||||
assert_eq!((num(&s, "A1"), num(&s, "A2")), (1.23456, 1.23456 * 100.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn precision_as_displayed_applies_in_manual_mode() {
|
||||
let mut s = s();
|
||||
let num = |s: &Session, a: &str| v(s, a).as_f64().unwrap_or(f64::NAN);
|
||||
s.execute("cell.set", json!({"cell": "A1", "input": "1.23456"})).unwrap();
|
||||
s.execute("cell.set", json!({"cell": "A2", "input": "=A1*100"})).unwrap();
|
||||
s.execute("home.numberFormat", json!({"range": "A1", "code": "0.00"})).unwrap();
|
||||
s.execute("formulas.calculationOptions", json!({"mode": "manual"})).unwrap();
|
||||
// Turning it on rounds the values at once, without a calculation.
|
||||
s.execute("formulas.calculationOptions", json!({"precisionAsDisplayed": true})).unwrap();
|
||||
assert_eq!((num(&s, "A1"), num(&s, "A2")), (1.23, 123.0));
|
||||
// Turning it off recalculates at once too: the formula reads the rounded constant.
|
||||
s.execute("formulas.calculationOptions", json!({"precisionAsDisplayed": false})).unwrap();
|
||||
assert_eq!((num(&s, "A1"), num(&s, "A2")), (1.23, 1.23 * 100.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn precision_as_displayed_on_a_protected_sheet_keeps_locked_constants() {
|
||||
let mut s = s();
|
||||
let num = |s: &Session, a: &str| v(s, a).as_f64().unwrap_or(f64::NAN);
|
||||
s.execute("cell.set", json!({"cell": "A1", "input": "1.23456"})).unwrap();
|
||||
s.execute("cell.set", json!({"cell": "A2", "input": "=A1*100"})).unwrap();
|
||||
s.execute("cell.set", json!({"cell": "B1", "input": "1.23456"})).unwrap();
|
||||
s.execute("home.numberFormat", json!({"range": "A1:B1", "code": "0.00"})).unwrap();
|
||||
s.execute("home.lockCell", json!({"range": "B1", "on": false})).unwrap();
|
||||
s.execute("review.protectSheet", json!({})).unwrap();
|
||||
// Turning the option on is not refused: the locked constant keeps its digits, the unlocked
|
||||
// one is rounded.
|
||||
s.execute("formulas.calculationOptions", json!({"precisionAsDisplayed": true})).unwrap();
|
||||
assert!(s.doc().unwrap().wb.calc.precision_as_displayed);
|
||||
assert_eq!((num(&s, "A1"), num(&s, "B1"), num(&s, "A2")), (1.23456, 1.23, 1.23456 * 100.0));
|
||||
// Reformatting a locked constant, on a sheet that allows formatting, keeps its digits too.
|
||||
s.execute("review.unprotectSheet", json!({})).unwrap();
|
||||
s.execute("review.protectSheet", json!({"formatCells": true})).unwrap();
|
||||
s.execute("home.numberFormat", json!({"range": "A1", "code": "0.0"})).unwrap();
|
||||
assert_eq!(num(&s, "A1"), 1.23456);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn precision_as_displayed_rounds_a_legacy_array_by_each_cell() {
|
||||
let mut s = s();
|
||||
let num = |s: &Session, a: &str| v(s, a).as_f64().unwrap_or(f64::NAN);
|
||||
s.execute("range.setValues", json!({"range": "A1", "values": [[1], [2]]})).unwrap();
|
||||
s.execute("home.numberFormat", json!({"range": "B1", "code": "0.00"})).unwrap();
|
||||
s.execute("home.numberFormat", json!({"range": "B2", "code": "0.0"})).unwrap();
|
||||
s.execute("selection.set", json!({"range": "B1:B2"})).unwrap();
|
||||
s.execute("cell.set", json!({"input": "=A1:A2/3", "array": true})).unwrap();
|
||||
assert_eq!((num(&s, "B1"), num(&s, "B2")), (1.0 / 3.0, 2.0 / 3.0));
|
||||
// Each element takes the format of the cell it lands in.
|
||||
s.execute("formulas.calculationOptions", json!({"precisionAsDisplayed": true})).unwrap();
|
||||
assert_eq!((num(&s, "B1"), num(&s, "B2")), (0.33, 0.7));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn precision_as_displayed_rounds_numbers_written_to_a_new_sheet() {
|
||||
let mut s = s();
|
||||
s.execute("range.setValues", json!({"range": "A1", "values": [[1], [2], [4], [7], [3], [5]]})).unwrap();
|
||||
// Histogram output on a new sheet, with the option off, keeps full precision.
|
||||
s.execute("data.analysisToolPak", json!({"tool": "histogram", "range": "Sheet1!A1:A6", "cumulative": true, "newSheet": "Raw"})).unwrap();
|
||||
let raw: Vec<(CellRef, f64)> = {
|
||||
let d = s.doc().unwrap();
|
||||
let sh = d.wb.sheet(d.wb.sheet_index("Raw").unwrap()).unwrap();
|
||||
sh.cells
|
||||
.iter()
|
||||
.filter_map(|(c, cell)| match cell.value {
|
||||
Value::Number(n) if cell.formula.is_none() => Some((c, n)),
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
s.execute("formulas.calculationOptions", json!({"precisionAsDisplayed": true})).unwrap();
|
||||
s.execute("data.analysisToolPak", json!({"tool": "histogram", "range": "Sheet1!A1:A6", "cumulative": true, "newSheet": "Rounded"})).unwrap();
|
||||
let d = s.doc().unwrap();
|
||||
let si = d.wb.sheet_index("Rounded").unwrap();
|
||||
let sh = d.wb.sheet(si).unwrap();
|
||||
let mut prec = gridcraft_calc::DisplayedPrecision::new();
|
||||
let mut rounded = 0;
|
||||
for (c, n) in &raw {
|
||||
let want = prec.round(&d.wb, si, *c, *n);
|
||||
assert_eq!(sh.value(*c), Value::Number(want), "{c:?}");
|
||||
rounded += usize::from(want != *n);
|
||||
}
|
||||
// Cumulative shares of six values are sixths, which the percent format rounds.
|
||||
assert!(rounded > 0, "the histogram's formatted cells should be rounded");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn editing_quote_prefixed_text_keeps_it_text() {
|
||||
let mut s = s();
|
||||
|
||||
@@ -323,6 +323,60 @@ impl NumberFormat {
|
||||
(Formatted { text: out.text, color: sec.color, fill: out.fill, numeric: true }, false)
|
||||
}
|
||||
|
||||
/// The number `n` as this format displays it: what Excel stores when "Set precision as
|
||||
/// displayed" is on. Fixed decimals, percent, thousands scaling (`0.0,,`), scientific and
|
||||
/// fraction sections round to what they show; the section is picked as for display, so
|
||||
/// `0.00;(0.0)` keeps one decimal for negatives. Observed in Excel (COM,
|
||||
/// `Workbook.PrecisionAsDisplayed`), these keep the full value: General, date and time
|
||||
/// formats (`mm:ss.0` included), text formats and sections without digits (`"x"`, `;;;`).
|
||||
/// The section is picked by [`NumberFormat::rounding_section`], which ignores conditions.
|
||||
/// Never returns `-0`.
|
||||
pub fn round_as_displayed(&self, n: f64) -> f64 {
|
||||
if self.is_general() || !n.is_finite() {
|
||||
return n;
|
||||
}
|
||||
let Some(sec) = self.rounding_section(n) else { return n };
|
||||
if sec.kind != SecKind::Number {
|
||||
return n;
|
||||
}
|
||||
let Some(m) = render::shown_value(sec, n) else { return n };
|
||||
if m == 0.0 {
|
||||
0.0
|
||||
} else if n < 0.0 {
|
||||
-m
|
||||
} else {
|
||||
m
|
||||
}
|
||||
}
|
||||
|
||||
/// The section whose digits "Set precision as displayed" rounds `n` by. Observed in Excel
|
||||
/// (COM), this differs from the section that displays `n`:
|
||||
/// - Conditions are ignored; the sign picks the section, the first for positives and the
|
||||
/// second for negatives. `[<1]0.00;0` shows 2.333 as `2` but stores 2.33, and stores -10/3
|
||||
/// as -3 although it shows `-3.00`. `[>1]0.0;0.000` stores 1/3 as 0.3.
|
||||
/// - A format with a condition and a single number section keeps negatives at full value
|
||||
/// (`[<1]0.00`, `[>=-100]0.0`); with an `@` section added (`[<1]0.00;@`) negatives use the
|
||||
/// first section, as every format without a condition does.
|
||||
/// - A completely empty section (no characters, not even a colour) passes to the next one:
|
||||
/// `0.0;;0.000` stores -2.333 as -2.333, `;0.0` stores 2.333 as 2.3, `;;0.0` too. A section
|
||||
/// with only a colour or a literal (`0.0;[Red];0.000`, `0.0;" "`) has no digits and keeps
|
||||
/// the full value.
|
||||
///
|
||||
/// `None` keeps the full value.
|
||||
fn rounding_section(&self, n: f64) -> Option<&Section> {
|
||||
let secs = self.num_sections();
|
||||
let conditional = secs.iter().any(|s| s.cond.is_some());
|
||||
let slot = if n < 0.0 && secs.len() >= 2 {
|
||||
1
|
||||
} else if n < 0.0 && conditional && self.inner.text_section.is_none() {
|
||||
return None;
|
||||
} else {
|
||||
0
|
||||
};
|
||||
let empty = |s: &Section| s.toks.is_empty() && s.color.is_none() && s.cond.is_none();
|
||||
secs.get(slot..)?.iter().find(|s| !empty(s))
|
||||
}
|
||||
|
||||
fn format_text(&self, t: &str) -> Formatted {
|
||||
match self.inner.text_section.and_then(|i| self.inner.sections.get(i)) {
|
||||
Some(sec) => {
|
||||
|
||||
+138
-43
@@ -230,9 +230,31 @@ fn digit_indices(toks: &[Tok], range: std::ops::Range<usize>) -> Vec<usize> {
|
||||
range.filter(|&i| matches!(toks.get(i), Some(Tok::Digit(_)))).collect()
|
||||
}
|
||||
|
||||
/// Renders a non-negative number through a number section.
|
||||
pub(crate) fn render_number(sec: &Section, v: f64) -> Out {
|
||||
let mut x = v.abs();
|
||||
/// Where a number section's placeholders are: the decimal point, the exponent, and the digit
|
||||
/// placeholders before the point (or exponent) and between the point and the exponent.
|
||||
struct NumberLayout {
|
||||
point: Option<usize>,
|
||||
exp: Option<usize>,
|
||||
int_idx: Vec<usize>,
|
||||
frac_idx: Vec<usize>,
|
||||
}
|
||||
|
||||
fn number_layout(toks: &[Tok]) -> NumberLayout {
|
||||
let n = toks.len();
|
||||
let point = toks.iter().position(|t| matches!(t, Tok::Point));
|
||||
let exp = toks.iter().position(|t| matches!(t, Tok::Exp { .. }));
|
||||
let int_end = point.or(exp).unwrap_or(n);
|
||||
let frac_end = exp.unwrap_or(n);
|
||||
let int_idx = digit_indices(toks, 0..int_end);
|
||||
let frac_idx = match point {
|
||||
Some(p) => digit_indices(toks, p + 1..frac_end),
|
||||
None => Vec::new(),
|
||||
};
|
||||
NumberLayout { point, exp, int_idx, frac_idx }
|
||||
}
|
||||
|
||||
/// The number a section works on: each `%` multiplies by 100, each scaling comma divides by 1000.
|
||||
fn scaled(sec: &Section, mut x: f64) -> f64 {
|
||||
// Observed: `%` in a scientific section is only a literal (`#%e+#` shows 123456.789 as
|
||||
// `1%e+5`).
|
||||
if sec.percent > 0 && !sec.exp {
|
||||
@@ -241,6 +263,75 @@ pub(crate) fn render_number(sec: &Section, v: f64) -> Out {
|
||||
if sec.scale > 0 {
|
||||
x /= 1000f64.powi(sec.scale.min(110));
|
||||
}
|
||||
x
|
||||
}
|
||||
|
||||
/// Scientific notation of a non-negative `x` with `int_count` integer and `frac_count` fraction
|
||||
/// placeholders: the exponent (a multiple of the integer placeholder count, engineering style)
|
||||
/// and the mantissa's integer and fraction digits.
|
||||
fn scientific_parts(x: f64, int_count: usize, frac_count: usize) -> (i32, String, String) {
|
||||
if x == 0.0 {
|
||||
// Observed: zero shows `0` in every integer placeholder, `#` and `?` too
|
||||
// (`#.#E+#` gives `0.E+0`, `####.####E+#` gives `0000.E+0`).
|
||||
return (0, "0".repeat(int_count), "0".repeat(frac_count));
|
||||
}
|
||||
let step = int_count.max(1) as i32;
|
||||
let (digits, xe) = decimal_digits(x);
|
||||
let mut e = xe - xe.rem_euclid(step);
|
||||
let (mut i, mut f) = fixed_from_digits(&digits, xe - e, frac_count);
|
||||
if i.len() > step as usize {
|
||||
e += step;
|
||||
(i, f) = fixed_from_digits(&digits, xe - e, frac_count);
|
||||
}
|
||||
(e, i, f)
|
||||
}
|
||||
|
||||
/// The number a number section shows for a non-negative `v`, as a value: what "Set precision as
|
||||
/// displayed" stores. `None` when the section shows no digits (`"x"`) or the result isn't finite.
|
||||
/// It uses the display's own rounding, so 1.005 with `0.00` gives 1.01, and 0.123456 with
|
||||
/// `0.00%` gives 0.1235 (the nearest double to the decimal shown).
|
||||
pub(crate) fn shown_value(sec: &Section, v: f64) -> Option<f64> {
|
||||
if !sec.has_digits || !v.is_finite() {
|
||||
return None;
|
||||
}
|
||||
let x = scaled(sec, v.abs());
|
||||
if !x.is_finite() {
|
||||
return None;
|
||||
}
|
||||
if x == 0.0 {
|
||||
return Some(0.0);
|
||||
}
|
||||
// `%` and scaling are undone by moving the decimal point in the digits shown, so the result
|
||||
// is the nearest double to that decimal rather than a product with its own rounding error.
|
||||
let pct = if sec.exp { 0 } else { sec.percent.min(20) };
|
||||
let shift = 3 * sec.scale.clamp(0, 110) - 2 * pct;
|
||||
let shown = if sec.fraction {
|
||||
let lay = fraction_layout(&sec.toks)?;
|
||||
let (whole, num, den) = fraction_parts(&lay, x);
|
||||
let den = den.max(1) as f64;
|
||||
(whole * den + num) / den * 10f64.powi(shift)
|
||||
} else {
|
||||
let NumberLayout { exp, frac_idx, .. } = number_layout(&sec.toks);
|
||||
let (e, int_s, frac_s) = match exp {
|
||||
// Observed: the stored value keeps one digit plus the fraction digits, whatever the
|
||||
// integer placeholders (`##0.0E+0` stores 123456.789 as 120000 though it shows
|
||||
// `123.5E+3`; `###.00E+0` keeps three digits). A `%` in a scientific section stores a
|
||||
// value that matches neither the display nor this rule: left as is.
|
||||
Some(_) if sec.percent > 0 => return None,
|
||||
Some(_) => scientific_parts(x, 1, frac_idx.len()),
|
||||
None => {
|
||||
let (i, f) = fixed(x, frac_idx.len());
|
||||
(0, i, f)
|
||||
}
|
||||
};
|
||||
format!("0{int_s}.{frac_s}0e{}", i64::from(e) + i64::from(shift)).parse::<f64>().ok()?
|
||||
};
|
||||
shown.is_finite().then_some(shown)
|
||||
}
|
||||
|
||||
/// Renders a non-negative number through a number section.
|
||||
pub(crate) fn render_number(sec: &Section, v: f64) -> Out {
|
||||
let mut x = scaled(sec, v.abs());
|
||||
if !x.is_finite() {
|
||||
x = f64::MAX;
|
||||
}
|
||||
@@ -256,32 +347,10 @@ pub(crate) fn render_number(sec: &Section, v: f64) -> Out {
|
||||
lits = vec![None; n];
|
||||
render_fraction(sec, x, &mut pieces, &mut lits);
|
||||
} else {
|
||||
let point = toks.iter().position(|t| matches!(t, Tok::Point));
|
||||
let exp = toks.iter().position(|t| matches!(t, Tok::Exp { .. }));
|
||||
let int_end = point.or(exp).unwrap_or(n);
|
||||
let frac_end = exp.unwrap_or(n);
|
||||
let int_idx = digit_indices(toks, 0..int_end);
|
||||
let frac_idx = match point {
|
||||
Some(p) => digit_indices(toks, p + 1..frac_end),
|
||||
None => Vec::new(),
|
||||
};
|
||||
let NumberLayout { point, exp, int_idx, frac_idx } = number_layout(toks);
|
||||
let (int_s, frac_s) = if let Some(e_at) = exp {
|
||||
let exp_idx = digit_indices(toks, e_at + 1..n);
|
||||
let step = int_idx.len().max(1) as i32;
|
||||
let (mut e, int_s, frac_s) = if x == 0.0 {
|
||||
// Observed: zero shows `0` in every integer placeholder, `#` and `?` too
|
||||
// (`#.#E+#` gives `0.E+0`, `####.####E+#` gives `0000.E+0`).
|
||||
(0i32, "0".repeat(int_idx.len()), "0".repeat(frac_idx.len()))
|
||||
} else {
|
||||
let (digits, xe) = decimal_digits(x);
|
||||
let mut e = xe - xe.rem_euclid(step);
|
||||
let (mut i, mut f) = fixed_from_digits(&digits, xe - e, frac_idx.len());
|
||||
if i.len() > step as usize {
|
||||
e += step;
|
||||
(i, f) = fixed_from_digits(&digits, xe - e, frac_idx.len());
|
||||
}
|
||||
(e, i, f)
|
||||
};
|
||||
let (mut e, int_s, frac_s) = scientific_parts(x, int_idx.len(), frac_idx.len());
|
||||
// Exponent text.
|
||||
let (upper, plus) = match toks.get(e_at) {
|
||||
Some(Tok::Exp { upper, plus }) => (*upper, *plus),
|
||||
@@ -424,19 +493,23 @@ fn blank_range(toks: &[Tok], range: std::ops::Range<usize>, spaces: bool, pieces
|
||||
}
|
||||
}
|
||||
|
||||
/// A fraction section is `[whole] [sep] num [lit] / [lit] den`: the numerator is the run of
|
||||
/// placeholders ending at the last one before the bar, the whole part is every placeholder
|
||||
/// before it (literals between them stay), `sep` the literals between the two parts.
|
||||
///
|
||||
/// Observed Excel behaviour: when the whole part shows no digit its separator disappears (or
|
||||
/// becomes spaces when the whole part or the numerator has a `?`); when the fraction is zero and
|
||||
/// the numerator has no `0` placeholder, the separator, numerator, bar and denominator disappear
|
||||
/// (or become spaces when any part has a `?`) and the whole part shows at least `0`.
|
||||
fn render_fraction(sec: &Section, x: f64, pieces: &mut [String], lits: &mut [Option<String>]) {
|
||||
let toks = &sec.toks;
|
||||
/// Where a fraction section's parts are (see [`render_fraction`]).
|
||||
struct FractionLayout {
|
||||
slash: usize,
|
||||
num_start: usize,
|
||||
whole_idx: Vec<usize>,
|
||||
num_idx: Vec<usize>,
|
||||
sep_start: usize,
|
||||
fixed_den: Option<u64>,
|
||||
den_start: usize,
|
||||
den_end: usize,
|
||||
den_idx: Vec<usize>,
|
||||
}
|
||||
|
||||
fn fraction_layout(toks: &[Tok]) -> Option<FractionLayout> {
|
||||
let is_digit = |i: usize| matches!(toks.get(i), Some(Tok::Digit(_)));
|
||||
let Some(slash) = toks.iter().position(|t| matches!(t, Tok::Slash)) else { return };
|
||||
let Some(num_last) = (0..slash).rev().find(|&i| is_digit(i)) else { return };
|
||||
let slash = toks.iter().position(|t| matches!(t, Tok::Slash))?;
|
||||
let num_last = (0..slash).rev().find(|&i| is_digit(i))?;
|
||||
let mut num_start = num_last;
|
||||
while num_start > 0 && is_digit(num_start - 1) {
|
||||
num_start -= 1;
|
||||
@@ -463,16 +536,20 @@ fn render_fraction(sec: &Section, x: f64, pieces: &mut [String], lits: &mut [Opt
|
||||
}
|
||||
}
|
||||
let den_idx = digit_indices(toks, den_start..den_end);
|
||||
let has_q = |idx: &[usize]| idx.iter().any(|&i| toks.get(i) == Some(&Tok::Digit('?')));
|
||||
let has_whole = !whole_idx.is_empty();
|
||||
Some(FractionLayout { slash, num_start, whole_idx, num_idx, sep_start, fixed_den, den_start, den_end, den_idx })
|
||||
}
|
||||
|
||||
/// The whole part, numerator and denominator a fraction section shows for a non-negative `x`.
|
||||
fn fraction_parts(lay: &FractionLayout, x: f64) -> (f64, f64, u64) {
|
||||
let has_whole = !lay.whole_idx.is_empty();
|
||||
let (mut whole, f) = if has_whole { (x.floor(), x - x.floor()) } else { (0.0, x) };
|
||||
// Without a whole part the numerator carries the whole value and can pass u64::MAX, so it
|
||||
// stays an f64 (a float-to-u64 cast saturates).
|
||||
let (mut num, den): (f64, u64) = match fixed_den {
|
||||
let (mut num, den): (f64, u64) = match lay.fixed_den {
|
||||
Some(d) => ((f * d as f64).round(), d),
|
||||
None if f >= 1e15 => (f.round(), 1),
|
||||
None => {
|
||||
let digits = den_idx.len().clamp(1, 7) as u32;
|
||||
let digits = lay.den_idx.len().clamp(1, 7) as u32;
|
||||
let (n, d) = approximate(f, 10u64.pow(digits) - 1);
|
||||
(n as f64, d)
|
||||
}
|
||||
@@ -483,6 +560,24 @@ fn render_fraction(sec: &Section, x: f64, pieces: &mut [String], lits: &mut [Opt
|
||||
whole += (n / den) as f64;
|
||||
num = (n % den) as f64;
|
||||
}
|
||||
(whole, num, den)
|
||||
}
|
||||
|
||||
/// A fraction section is `[whole] [sep] num [lit] / [lit] den`: the numerator is the run of
|
||||
/// placeholders ending at the last one before the bar, the whole part is every placeholder
|
||||
/// before it (literals between them stay), `sep` the literals between the two parts.
|
||||
///
|
||||
/// Observed Excel behaviour: when the whole part shows no digit its separator disappears (or
|
||||
/// becomes spaces when the whole part or the numerator has a `?`); when the fraction is zero and
|
||||
/// the numerator has no `0` placeholder, the separator, numerator, bar and denominator disappear
|
||||
/// (or become spaces when any part has a `?`) and the whole part shows at least `0`.
|
||||
fn render_fraction(sec: &Section, x: f64, pieces: &mut [String], lits: &mut [Option<String>]) {
|
||||
let toks = &sec.toks;
|
||||
let Some(lay) = fraction_layout(toks) else { return };
|
||||
let (whole, num, den) = fraction_parts(&lay, x);
|
||||
let FractionLayout { slash, num_start, whole_idx, num_idx, sep_start, fixed_den, den_start, den_end, den_idx } = lay;
|
||||
let has_q = |idx: &[usize]| idx.iter().any(|&i| toks.get(i) == Some(&Tok::Digit('?')));
|
||||
let has_whole = !whole_idx.is_empty();
|
||||
let whole_s = if whole >= 1.0 { fixed(whole, 0).0 } else { String::new() };
|
||||
let num_zero_ph = num_idx.iter().any(|&i| toks.get(i) == Some(&Tok::Digit('0')));
|
||||
if has_whole && num == 0.0 && !num_zero_ph {
|
||||
|
||||
@@ -901,3 +901,120 @@ fn closed_brackets_still_hold_their_semicolons() {
|
||||
assert_eq!(f("0\\[0", 5.0), "0[5");
|
||||
assert_eq!(f("\"[\"0;0", 5.0), "[5");
|
||||
}
|
||||
|
||||
/// "Set precision as displayed": the value each format stores. Expected values observed in Excel
|
||||
/// (COM, `Workbook.PrecisionAsDisplayed = True`) with synthetic values.
|
||||
#[test]
|
||||
fn round_as_displayed_matches_excel() {
|
||||
let r = |code: &str, n: f64| NumberFormat::parse(code).round_as_displayed(n);
|
||||
let cases: &[(&str, f64, f64)] = &[
|
||||
("0", 527.91, 528.0),
|
||||
("0", 2.5, 3.0),
|
||||
("0", -2.5, -3.0),
|
||||
("0.00", 1.005, 1.01),
|
||||
("0.00", 1.23456, 1.23),
|
||||
("#.##", 1.23456, 1.23),
|
||||
("#.##", 1.2, 1.2),
|
||||
("?.??", 1.23456, 1.23),
|
||||
("0.0??", 1.23456, 1.235),
|
||||
("0%", 0.123456, 0.12),
|
||||
("0.0%", 0.123456, 0.123),
|
||||
("0.00%", 0.123456, 0.1235),
|
||||
("$#,##0.00", 1234.5678, 1234.57),
|
||||
("#,##0", 1234.5678, 1235.0),
|
||||
("0.0,,", 1234567.891, 1200000.0),
|
||||
("#,##0,", 1234567.891, 1235000.0),
|
||||
("0.0,", 1234567.891, 1234600.0),
|
||||
("# ?/?", 0.3333333, 1.0 / 3.0),
|
||||
("# ??/??", 0.3333333, 1.0 / 3.0),
|
||||
("# ?/8", 0.3, 0.25),
|
||||
("# ?/4", 2.71829, 2.75),
|
||||
("?/?", 0.123, 0.125),
|
||||
("0.00E+00", 123456.789, 123000.0),
|
||||
("##0.0E+0", 123456.789, 120000.0),
|
||||
("0.0E+0", 0.000123456, 0.00012),
|
||||
("##0.E+0", 98765.4, 100000.0),
|
||||
("###.00E+0", 98765.4, 98800.0),
|
||||
("000.0E+0", 0.0123456, 0.012),
|
||||
("0.00E+0\" u\"", 98765.4, 98800.0),
|
||||
("0.0%E+0", 12345.0, 12345.0),
|
||||
("0.00;(0.00)", -1.23456, -1.23),
|
||||
("0.00;(0.0)", -1.23456, -1.2),
|
||||
("0.0 \"kg\"", 1.23456, 1.2),
|
||||
("0.00;-0.00;\"zero\"", 0.001, 0.0),
|
||||
("0.00_);(0.00)", 1.23456, 1.23),
|
||||
("#,##0.0_);[Red](#,##0.0)", 2.345, 2.3),
|
||||
// Kept at full precision.
|
||||
("General", 1.0 / 3.0, 1.0 / 3.0),
|
||||
("m/d/yyyy", 45000.75, 45000.75),
|
||||
("h:mm", 45000.123456, 45000.123456),
|
||||
("h:mm:ss", 45000.123456, 45000.123456),
|
||||
("[h]:mm", 1.2345678, 1.2345678),
|
||||
("mm:ss.0", 0.0001234567, 0.0001234567),
|
||||
("d", 45000.6, 45000.6),
|
||||
("@", 1.23456, 1.23456),
|
||||
("\"x\"", 1.23456, 1.23456),
|
||||
(";;;", 1.23456, 1.23456),
|
||||
// Conditions are ignored: the sign picks the section (first for positives, second for
|
||||
// negatives), whatever section displays the number.
|
||||
("[<1]0.00;0", 1.0 / 3.0, 0.33),
|
||||
("[<1]0.00;0", 2.0 + 1.0 / 3.0, 2.33),
|
||||
("[<1]0.00;0", 12345.678, 12345.68),
|
||||
("[<1]0.00;0", -10.0 / 3.0, -3.0),
|
||||
("[<1]0.00;0", -1.0 / 3.0, 0.0),
|
||||
("[>1]0.0;0.000", 1.0 / 3.0, 0.3),
|
||||
("[>1]0.0;0.000", -10.0 / 3.0, -3.333),
|
||||
("[>=100]0;0.000", 2.0 + 1.0 / 3.0, 2.0),
|
||||
("[>=100]0;0.000", -200.0 - 1.0 / 3.0, -200.333),
|
||||
("[<0]0.0;0.000", -2.0 - 1.0 / 3.0, -2.333),
|
||||
("[>100]0;[<-100]0.0;0.000", -2.0 - 1.0 / 3.0, -2.3),
|
||||
("[<=-1]0.0;[>=1]0.000;0.00000", -2.0 - 1.0 / 3.0, -2.333),
|
||||
("0.0;[<-5]0.000", -2.0 - 1.0 / 3.0, -2.333),
|
||||
("[<1]0.00%;0", 2.0 + 1.0 / 3.0, 2.3333),
|
||||
("[<1]0.00;0.0,", -2345.6789, -2300.0),
|
||||
("[<1]0.0E+0;0", 1.0 / 3.0, 0.33),
|
||||
("[<1]# ?/?;0", 2.3, 16.0 / 7.0),
|
||||
("[<1]0.00;?/8", -2.0 - 1.0 / 3.0, -2.375),
|
||||
("[<1]0.00;0.0;0.000;@", -2.0 - 1.0 / 3.0, -2.3),
|
||||
("[<1]\"x\";0.0", 2.0 + 1.0 / 3.0, 2.0 + 1.0 / 3.0),
|
||||
("[<1]\"x\";0.0", -2.0 - 1.0 / 3.0, -2.3),
|
||||
("[<1]m/d/yyyy;0.0", 2.0 + 1.0 / 3.0, 2.0 + 1.0 / 3.0),
|
||||
("[<1]0.0;m/d/yyyy", -2.0 - 1.0 / 3.0, -2.0 - 1.0 / 3.0),
|
||||
// One section with a condition: negatives keep the full value, unless an `@` section
|
||||
// follows (then the first section applies, as without a condition).
|
||||
("[<1]0.00", 2.0 + 1.0 / 3.0, 2.33),
|
||||
("[<1]0.00", -2.0 - 1.0 / 3.0, -2.0 - 1.0 / 3.0),
|
||||
("[>=-100]0.0", -2.0 - 1.0 / 3.0, -2.0 - 1.0 / 3.0),
|
||||
("[<1]0.00;@", -2.0 - 1.0 / 3.0, -2.33),
|
||||
("[<1]0.0%;@", -2.0 - 1.0 / 3.0, -2.333),
|
||||
("0.00;@", -2.0 - 1.0 / 3.0, -2.33),
|
||||
// A completely empty section passes to the next; a colour or a literal doesn't.
|
||||
("0.0;;0.000", -2.0 - 1.0 / 3.0, -2.333),
|
||||
("[<1]0.0;;0.000", -2.0 - 1.0 / 3.0, -2.333),
|
||||
("[<1]0.0;;0.0%", -2.0 - 1.0 / 3.0, -2.333),
|
||||
(";0.0", 2.0 + 1.0 / 3.0, 2.3),
|
||||
(";;0.0", 2.0 + 1.0 / 3.0, 2.3),
|
||||
(";;0.0", -2.0 - 1.0 / 3.0, -2.3),
|
||||
("0.0;;", -2.0 - 1.0 / 3.0, -2.0 - 1.0 / 3.0),
|
||||
("0.0;;@", -2.0 - 1.0 / 3.0, -2.0 - 1.0 / 3.0),
|
||||
("[<1]0.00;", -2.0 - 1.0 / 3.0, -2.0 - 1.0 / 3.0),
|
||||
("0.0;[Red];0.000", -2.0 - 1.0 / 3.0, -2.0 - 1.0 / 3.0),
|
||||
("[<1]0.0;\" \";0.000", -2.0 - 1.0 / 3.0, -2.0 - 1.0 / 3.0),
|
||||
("[<1];0.0;0.00", 2.0 + 1.0 / 3.0, 2.0 + 1.0 / 3.0),
|
||||
];
|
||||
for &(code, n, want) in cases {
|
||||
assert_eq!(r(code, n), want, "{code} with {n}");
|
||||
}
|
||||
// A negative that rounds to zero stores 0, not -0.
|
||||
let z = r("0.00", -0.004);
|
||||
assert!(z == 0.0 && z.is_sign_positive());
|
||||
// Non-finite and extreme inputs come back unchanged or finite, never a panic.
|
||||
assert!(r("0.00", f64::NAN).is_nan());
|
||||
assert_eq!(r("0.00", f64::INFINITY), f64::INFINITY);
|
||||
assert_eq!(r("0", 1e300 * 10.0), 1e301);
|
||||
assert_eq!(r("0.0,,,,,,,,,,", 1.234e35), 1.234e35);
|
||||
assert_eq!(r("0.000000000000000000000000000000", 1.0 / 3.0), 0.333333333333333);
|
||||
assert_eq!(r("0%%%%%%%%%%%%%%%%%%%%%%%%", f64::MAX), f64::MAX);
|
||||
assert_eq!(r("0", f64::MAX), f64::MAX);
|
||||
assert_eq!(r("0.0E+0", f64::MIN_POSITIVE), 2.2e-308);
|
||||
}
|
||||
@@ -89,7 +89,8 @@ This improves series preparation; it does not change the model-fitting or season
|
||||
| YEARFRAC basis 0, February-end start to day 31 | done: Excel 16.0 returns 31/360 for `YEARFRAC(2021-02-28,2021-03-31,0)` (0.08611111111111111); regression also covers leap day and reversed dates |
|
||||
| Trim references `A1:.E10` (trailing), `A1.:E10` (leading), `A1.:.E10` (both), also on whole rows/columns (`A:.A`) | done; evaluated as `TRIMRANGE(range, code, code)`, adjusted on insert/delete; files store `_xlfn.TRIMRANGE(range,code,code)` (Excel's own file spelling unconfirmed in public docs), `_xlfn._TRO_ALL/_TRO_LEADING/_TRO_TRAILING(...)` read too |
|
||||
| LET, LAMBDA, named LAMBDAs, ISOMITTED, MAP/REDUCE/SCAN/BYROW/BYCOL/MAKEARRAY | done. A LAMBDA that calls itself more than 140 times, or a formula nested past the depth limit, gives `#NUM!` (the target app allows deeper recursion, #1581). Calling a defined name that holds an error value gives that error, not `#NAME?` |
|
||||
| Iterative calculation for circular references | done: with Enable Iterative Calculation on (`formulas.calculationOptions` `{iterative, maxIterations, maxChange}`, saved as `<calcPr iterate iterateCount iterateDelta>`), loops are evaluated again from their previous values (blank = 0) until no cell on a loop changes by Maximum Change or Maximum Iterations is reached; off, cycles give `#CIRC!`. Set in File › Options › Formulas (Enable iterative calculation, Maximum Iterations 1 to 32767, Maximum Change 0 or more; the two fields are disabled while iteration is off). Differences: GridCraft recalculates on open, so a loop that never settles (`=A1+1`) advances again each time the file is opened
|
||||
| Set precision as displayed | done: with the option on (`formulas.calculationOptions` `{precisionAsDisplayed}`, read and saved as `<calcPr fullPrecision="0">` and the `.xls` CalcPrecision record), a formula result, each element of a spill, and a number typed or reformatted are stored as the cell's number format shows them, and other formulas read the rounded value. Fixed decimals, percent, thousands scaling (`0.0,,`), fractions and scientific formats round; General, date and time, text and digitless formats keep the full value, as observed in Excel. Conditions don't pick the section that rounds: the sign does, the first section for positives and the second for negatives, so `[<1]0.00;0` stores 2+1/3 as 2.33 though it shows `2`, and -10/3 as -3; with a condition and a single number section, negatives keep the full value; a completely empty section (`0.0;;0.000`) passes to the next. Turning it on rounds every number constant (permanently); turning it off restores formulas only. Differences: Excel also rounds General constants it shows in scientific notation when the option is turned on (GridCraft leaves General alone); a `%` inside a scientific format is left unrounded; no Options dialog checkbox yet (command only) |
|
||||
| Iterative calculation for circular references | done: with Enable Iterative Calculation on (`formulas.calculationOptions` `{iterative, maxIterations, maxChange}`, saved as `<calcPr iterate iterateCount iterateDelta>`), loops are evaluated again from their previous values (blank = 0) until no cell on a loop changes by Maximum Change or Maximum Iterations is reached; off, cycles give `#CIRC!`. Set in File › Options › Formulas (Enable iterative calculation, Maximum Iterations 1 to 32767, Maximum Change 0 or more; the two fields are disabled while iteration is off). Differences: GridCraft recalculates on open, so a loop that never settles (`=A1+1`) advances again each time the file is opened |
|
||||
| 15-significant-digit comparison semantics | done (#57): `=0.1+0.2=0.3` is TRUE, and MATCH and COUNTIF compare the same way. Arithmetic keeps full precision, as Excel does — `=(0.1+0.2)-0.3` is 5.55E-17 |
|
||||
| IMSQRT and the other IM* functions against Excel's text | IMSQRT follows Excel's polar route, so a negative real gives Excel's stray real part: `IMSQRT("-4")` is `1.22514845490862E-16+2i` (checked in Excel 16.0 on 79 inputs). Differences kept: IMSQRT of a number with a part above about 1E154 or below about 1E-155 gives the exact root (Excel gives `#NUM!` or 0); IMPOWER, IMEXP, IMSIN, IMCOS, IMSINH and IMCOSH keep exact results where Excel shows the same kind of noise (`IMPOWER("-4",2)` is `16-3.92047505570758E-15i`) or different trailing digits near multiples of pi/2; IMCSC and IMCSCH differ by one unit in the 15th digit; IMARGUMENT returns 16–17 digits where Excel returns 15; the text of IM* and COMPLEX results differs for some magnitudes (Excel writes `COMPLEX(1E-10,1)` as `0.0000000001+i` but `COMPLEX(0.000000123456789012345,1)` as `1.23456789012345E-07+i`, and an integer past 15 digits rounded and zero-padded) |
|
||||
| MOD quotient limit | done: Excel 16.0 returns `#NUM!` when `ABS(number/divisor) >= 1.1259E12`; lower quotients retain the remainder |
|
||||
@@ -171,6 +172,7 @@ workbooks; results checked in as data, not Excel files) and fixing what it finds
|
||||
| 2026-10-14 | minor | MATCH, XMATCH, VLOOKUP, HLOOKUP, XLOOKUP and LOOKUP compare numbers exactly in every match and search mode, as Excel 365 does: `MATCH(0.1+0.2,{0.3},0)` is `#N/A` and 0.3 sorts below 0.30000000000000004 in approximate and binary search. `=`, SWITCH and the criteria functions (COUNTIF(S), SUMIF(S), AVERAGEIF(S), MAXIFS…) keep comparing at 15 significant digits, also as observed |
|
||||
| 2026-10-14 | minor | Analysis ToolPak value arguments (EOMONTH, WORKDAY, YEARFRAC, COUPDAYS, DISC, BIN2DEC, IM* and the rest, observed in Excel 365) give `#VALUE!` for a multi-cell reference instead of lifting it |
|
||||
| 2026-10-13 | minor | Clear reports changed stored cells sparsely, including those beyond the first million coordinates; dependent formulas and removed spills update, while clearing empty merges still unblocks spills (#1032) |
|
||||
| 2026-10-14 | minor | Set precision as displayed: results, spills and typed or reformatted numbers are stored as their format shows them when the option is on (#1343) |
|
||||
| 2026-10-14 | minor | Bounded references up to 1,048,576 cells (`D3:AZ400`) keep their full shape instead of stopping at the used range, so `SUMPRODUCT(D3:AZ400*B3:B400,(A3:A400=x)*(D1:AZ1=y))` broadcasts like Excel and `ROWS`, `COUNTBLANK` and `--(r="")` count the blanks; references that run to the edge of the sheet (`A:A`) still stop at the used range (#1010) |
|
||||
| 2026-10-12 | minor | USDOLLAR added as a Compatibility function (not on Microsoft's list, so the counts don't change); MIRR with no positive flow is -1, COUNTIF's bare `"<="` / `">="` skip empty strings, INDIRECT with `a1` = FALSE gives `#REF!` for A1-style text (#738) |
|
||||
| 2026-10-11 | minor | TEXT / number formats fixed against Excel-cached results: fraction literals and zero fractions, `0` denominators zero padded, `?` pads the thousands separator, booleans use the text section (226 mismatches → 0 in 352 TEXT cells) |
|
||||
|
||||
Reference in new issue
Block a user