diff --git a/ROADMAP.md b/ROADMAP.md index 4219bad02..59b3bf2d8 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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 | diff --git a/crates/calc/src/lib.rs b/crates/calc/src/lib.rs index 27c9e1961..2cc6bf4cf 100644 --- a/crates/calc/src/lib.rs +++ b/crates/calc/src/lib.rs @@ -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)] diff --git a/crates/calc/src/precision.rs b/crates/calc/src/precision.rs new file mode 100644 index 000000000..c60f52079 --- /dev/null +++ b/crates/calc/src/precision.rs @@ -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>, +} + +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, + } + } +} diff --git a/crates/calc/src/recalc/pass_host.rs b/crates/calc/src/recalc/pass_host.rs index 5909369fd..9c56b2912 100644 --- a/crates/calc/src/recalc/pass_host.rs +++ b/crates/calc/src/recalc/pass_host.rs @@ -64,6 +64,9 @@ pub(super) struct PassHost<'a> { pub(super) tainted: HashSet, /// 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, /// `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. diff --git a/crates/calc/src/tests.rs b/crates/calc/src/tests.rs index d63590737..d10c2f983 100644 --- a/crates/calc/src/tests.rs +++ b/crates/calc/src/tests.rs @@ -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 diff --git a/crates/engine/src/cmd.rs b/crates/engine/src/cmd.rs index bd18996bd..da130fe2a 100644 --- a/crates/engine/src/cmd.rs +++ b/crates/engine/src/cmd.rs @@ -406,7 +406,7 @@ pub(crate) fn commit(d: &mut DocState, f: impl FnOnce(&mut Ctx) -> Result) 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(d: &mut DocState, f: impl FnOnce(&mut Ctx) -> Result) 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(d: &mut DocState, f: impl FnOnce(&mut Ctx) -> Result) 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, 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 = 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, + after: &std::collections::BTreeMap, + lo: u32, + hi: u32, +) -> bool { + let style = |m: &std::collections::BTreeMap, 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, +) -> 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; diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index b3b515c2e..aafb0850d 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -21,7 +21,7 @@ pub fn specs() -> Vec { "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 { 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})) } diff --git a/crates/engine/src/cmd/formulas.rs b/crates/engine/src/cmd/formulas.rs index d72f814a1..a62dc46f1 100644 --- a/crates/engine/src/cmd/formulas.rs +++ b/crates/engine/src/cmd/formulas.rs @@ -101,7 +101,7 @@ pub fn specs() -> Vec { "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 { 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, + })) }) } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index ab443bbe6..b83879c16 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -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(); diff --git a/crates/numfmt/src/lib.rs b/crates/numfmt/src/lib.rs index 52cada2c7..cbb11bc31 100644 --- a/crates/numfmt/src/lib.rs +++ b/crates/numfmt/src/lib.rs @@ -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) => { diff --git a/crates/numfmt/src/render.rs b/crates/numfmt/src/render.rs index a610954c2..bb7fd1514 100644 --- a/crates/numfmt/src/render.rs +++ b/crates/numfmt/src/render.rs @@ -230,9 +230,31 @@ fn digit_indices(toks: &[Tok], range: std::ops::Range) -> Vec { 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, + exp: Option, + int_idx: Vec, + frac_idx: Vec, +} + +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 { + 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::().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, 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]) { - let toks = &sec.toks; +/// Where a fraction section's parts are (see [`render_fraction`]). +struct FractionLayout { + slash: usize, + num_start: usize, + whole_idx: Vec, + num_idx: Vec, + sep_start: usize, + fixed_den: Option, + den_start: usize, + den_end: usize, + den_idx: Vec, +} + +fn fraction_layout(toks: &[Tok]) -> Option { 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]) { + 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 { diff --git a/crates/numfmt/src/tests.rs b/crates/numfmt/src/tests.rs index 4b2ee089d..b2709b320 100644 --- a/crates/numfmt/src/tests.rs +++ b/crates/numfmt/src/tests.rs @@ -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); +} diff --git a/docs/function-parity.md b/docs/function-parity.md index 97bd5401a..87081e460 100644 --- a/docs/function-parity.md +++ b/docs/function-parity.md @@ -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 ``), 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 `` 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 ``), 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) |