cat / 012
clone

a spreadsheet for the terminal: lotus 1-2-3 looks, google sheets keys and formulas, one go binary

Add opt-in decimal arithmetic to the engine

Dami committed e454d5c
A per-workbook setting, saved as "arithmetic": "decimal" (older builds
ignore it and compute in binary, so no version bump), makes + - * /, %,
SUM, AVERAGE, ROUND, ROUNDUP, ROUNDDOWN and TRUNC compute in decimal:
=0.1+0.2=0.3 is TRUE, INT($4.35*100) is 435 and ten 0.1s sum to 1.
Values stay float64; each step reads its operands as their shortest
round-tripping decimal and stores the nearest float64 to the exact (or,
for division, 34-digit) result, so comparisons and every other function
see clean values. Anything the decimal context can't represent falls
back to the binary result. Toggling is an undo step that recalculates.

The engine touch points are small: a dec flag on operator nodes, one
branch in eval, and the bound formula passed through arith() in recalc.

cockroachdb/apd/v3 does the math: pure Go, correct at 34 digits and over
the whole float64 range. govalues/decimal is faster (about 90 ns and no
allocations per step against apd's 250 ns) but holds only 19 digits, so
1E20 or 1E-20 fail; shopspring/decimal rounds quotients to 16 digits
(2/3 is off by an ulp) and is slowest, with 16+ allocations per step.
commit
e454d5c0b7263563f969ec3690f4fd905d90276b
parent
847d80168c0f8f7c2efda21c93a4e170d8835939
author
Dami <dami.etoile@gmail.com>
12 files changed, +498 -9
Mgo.mod1+
Mgo.sum4+
Ainternal/sheet/decimal.go252++++++++++++++++++++++++++++++
Ainternal/sheet/decimal_test.go199++++++++++++++++++++++++
Minternal/sheet/eval.go10+
Minternal/sheet/history.go3+
Minternal/sheet/parse.go6+-
Minternal/sheet/refs_test.go2+-
Minternal/sheet/sheet.go2+-
Minternal/sheet/view.go23++-
Moracle/go.mod1+
Moracle/go.sum4+
M go.mod +1 -0
M go.sum +4 -0
@@ -32,6 +32,8 @@ github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSE
3232 github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0=3333 github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk=3434 github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=35+github.com/cockroachdb/apd/v3 v3.2.3 h1:4Zx+I3R35bFXMnltzmjP79i2cravE4jTRL6ps9Aux80=36+github.com/cockroachdb/apd/v3 v3.2.3/go.mod h1:klXJcjp+FffLTHlhIG69tezTDvdP065naDsHzKhYSqc=3537 github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=3638 github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=3739 github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
@@ -50,6 +52,8 @@ github.com/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2
5052 github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw=5153 github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=5254 github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=55+github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=56+github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=5357 github.com/lucasb-eyer/go-colorful v1.4.1 h1:1EO+WB73+EH8EVbzlrG3KLAfEypQWVHIBqlTf+2hNss=5458 github.com/lucasb-eyer/go-colorful v1.4.1/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=5559 github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=
A internal/sheet/decimal.go +252 -0
@@ -0,0 +1,252 @@
1+package sheet2+3+import (4+ "sync"5+6+ "github.com/cockroachdb/apd/v3"7+)8+9+// Decimal arithmetic is an opt-in, per-workbook calculation setting for10+// money: with it, 0.1+0.2 is exactly 0.3, so =0.1+0.2=0.3 is TRUE and a11+// column of cents sums to what an accountant expects. Sheets and Excel use12+// binary floating point, so it is off by default.13+//14+// The boundary is deliberately small. Values stay float64 everywhere (in15+// cells, the file, formats and every other function); only these steps16+// compute in decimal:17+//18+// - the operators + - * / and the postfix %19+// - SUM and AVERAGE20+// - ROUND, ROUNDUP, ROUNDDOWN and TRUNC21+//22+// Each step reads its float64 operands as the shortest decimal that23+// round-trips (what the cell shows and what was typed: 0.1, not24+// 0.1000000000000000055...), computes exactly or to 34 significant digits25+// (IEEE decimal128) for division, and stores the float64 nearest to that26+// decimal result. Comparisons, lookups and everything else then work on27+// those float64s, so they see 0.3 rather than 0.30000000000000004.28+// Exponents (^), statistics, finance and date functions stay binary. A29+// step the decimal context can't represent (overflow past float64 range)30+// falls back to the binary result, so the setting never adds errors.31+//32+// Like any decimal system, it doesn't make 1/3*3 equal 1: the quotient is33+// rounded, and a float64 holds about 17 digits of it.34+35+// decCtx is the context of every decimal step: 34 digits, as decimal128.36+var decCtx = func() *apd.Context {37+ c := apd.BaseContext.WithPrecision(34)38+ c.Rounding = apd.RoundHalfEven39+ return c40+}()41+42+// fromDec returns the value nearest to d, or false if it is out of43+// float64 range.44+func fromDec(d *apd.Decimal) (Value, bool) {45+ f, err := d.Float64()46+ if err != nil {47+ return Value{}, false48+ }49+ return num(f), true50+}51+52+// decArith computes a op b in decimal, or reports false for operators53+// that stay binary and for results it can't represent.54+func decArith(op string, a, b float64) (Value, bool) {55+ var x, y, z apd.Decimal56+ if _, err := x.SetFloat64(a); err != nil {57+ return Value{}, false58+ }59+ if _, err := y.SetFloat64(b); err != nil {60+ return Value{}, false61+ }62+ var err error63+ switch op {64+ case "+":65+ _, err = decCtx.Add(&z, &x, &y)66+ case "-":67+ _, err = decCtx.Sub(&z, &x, &y)68+ case "*":69+ _, err = decCtx.Mul(&z, &x, &y)70+ case "/":71+ if b == 0 {72+ return ErrDiv0, true73+ }74+ _, err = decCtx.Quo(&z, &x, &y)75+ default:76+ return Value{}, false77+ }78+ if err != nil {79+ return Value{}, false80+ }81+ return fromDec(&z)82+}83+84+// decRounding maps ROUND's modes to apd's: half away from zero, away85+// from zero, toward zero.86+var decRounding = [...]apd.Rounder{roundHalfUp: apd.RoundHalfUp, roundUp: apd.RoundUp, roundDown: apd.RoundDown}87+88+// decRound rounds x to places decimal places (negative places round to89+// tens, hundreds...) in decimal.90+func decRound(x float64, places int, mode roundMode) (Value, bool) {91+ var d, z apd.Decimal92+ if _, err := d.SetFloat64(x); err != nil {93+ return Value{}, false94+ }95+ c := *decCtx96+ c.Rounding = decRounding[mode]97+ if _, err := c.Quantize(&z, &d, int32(-places)); err != nil {98+ return Value{}, false99+ }100+ return fromDec(&z)101+}102+103+// decSum adds the numbers SUM would, in decimal, and counts them.104+func decSum(args []Node, get lookup) (sum apd.Decimal, n int, e *Value, ok bool) {105+ ok = true106+ var x apd.Decimal107+ e = each(args, get, func(v Value, direct bool) *Value {108+ switch v.Kind {109+ case Error:110+ return &v111+ case Empty:112+ return nil113+ }114+ if v.Kind != Number && !direct {115+ return nil116+ }117+ f, err := toNum(v)118+ if err != nil {119+ return err120+ }121+ n++122+ if _, err := x.SetFloat64(f); err != nil {123+ ok = false124+ } else if _, err := decCtx.Add(&sum, &sum, &x); err != nil {125+ ok = false126+ }127+ return nil128+ })129+ return sum, n, e, ok130+}131+132+// decEvals are the decimal versions of functions, by name. Each reports133+// false to fall back to the binary version.134+var decEvals = map[string]func(args []Node, get lookup) (Value, bool){135+ "SUM": func(args []Node, get lookup) (Value, bool) {136+ sum, _, e, ok := decSum(args, get)137+ if e != nil {138+ return *e, true139+ }140+ if !ok {141+ return Value{}, false142+ }143+ return fromDec(&sum)144+ },145+ "AVERAGE": func(args []Node, get lookup) (Value, bool) {146+ sum, n, e, ok := decSum(args, get)147+ switch {148+ case e != nil:149+ return *e, true150+ case n == 0:151+ return ErrDiv0, true152+ case !ok:153+ return Value{}, false154+ }155+ var q apd.Decimal156+ if _, err := decCtx.Quo(&q, &sum, apd.New(int64(n), 0)); err != nil {157+ return Value{}, false158+ }159+ return fromDec(&q)160+ },161+ "ROUND": decRounder(roundHalfUp),162+ "ROUNDUP": decRounder(roundUp),163+ "ROUNDDOWN": decRounder(roundDown),164+ "TRUNC": decRounder(roundDown),165+}166+167+func decRounder(mode roundMode) func([]Node, lookup) (Value, bool) {168+ return func(args []Node, get lookup) (Value, bool) {169+ x, err := numArg(args[0], get)170+ if err != nil {171+ return *err, true172+ }173+ places, err := intArg(args, 1, 0, get)174+ if err != nil {175+ return *err, true176+ }177+ return decRound(x, clampInt(places, -308, 308), mode)178+ }179+}180+181+// decFuncs returns the decimal twins of the functions in decEvals: the182+// same definitions (name, help, format inference) with a decimal eval183+// that falls back to the binary one. Built on first use, after every184+// function is defined.185+var decFuncs = sync.OnceValue(func() map[string]*FuncDef {186+ out := map[string]*FuncDef{}187+ for name, dec := range decEvals {188+ bin := funcs[name]189+ twin := *bin190+ twin.eval = func(args []Node, get lookup) Value {191+ if v, ok := dec(args, get); ok {192+ return v193+ }194+ return bin.eval(args, get)195+ }196+ out[name] = &twin197+ }198+ return out199+})200+201+// decimalize returns a copy of n that evaluates in decimal: operators are202+// marked, and functions with a decimal twin call it.203+func decimalize(n Node) Node {204+ switch n := n.(type) {205+ case unaryNode:206+ n.x, n.dec = decimalize(n.x), true207+ return n208+ case binaryNode:209+ n.l, n.r, n.dec = decimalize(n.l), decimalize(n.r), true210+ return n211+ case callNode:212+ args := make([]Node, len(n.args))213+ for i, a := range n.args {214+ args[i] = decimalize(a)215+ }216+ n.args = args217+ if twin, ok := decFuncs()[n.fn.Name]; ok {218+ n.fn = twin219+ }220+ return n221+ }222+ return n223+}224+225+// arith returns the formula to evaluate under the workbook's arithmetic226+// setting.227+func (s *Sheet) arith(n Node) Node {228+ if s.view.decimal {229+ return decimalize(n)230+ }231+ return n232+}233+234+// Decimal reports whether the workbook computes in decimal.235+func (s *Sheet) Decimal() bool { return s.view.decimal }236+237+// SetDecimal turns decimal arithmetic on or off, as one undo step, and238+// recalculates every formula.239+func (s *Sheet) SetDecimal(on bool) {240+ if on == s.view.decimal {241+ return242+ }243+ label := "turn off decimal arithmetic"244+ if on {245+ label = "turn on decimal arithmetic"246+ }247+ s.change(label, Rect{}, func() {248+ s.recordView()249+ s.view.decimal = on250+ s.hist.dirty = append(s.hist.dirty, s.Addrs()...)251+ })252+}
A internal/sheet/decimal_test.go +199 -0
@@ -0,0 +1,199 @@
1+package sheet2+3+import (4+ "bytes"5+ "strconv"6+ "strings"7+ "testing"8+)9+10+// decimalSheet returns a sheet with inputs placed down column A from A111+// and the formula in B1, in the given arithmetic.12+func decimalSheet(t *testing.T, decimal bool, formula string, inputs ...string) *Sheet {13+ t.Helper()14+ s := New()15+ for i, in := range inputs {16+ if err := s.Set(Addr{Row: i}, in); err != nil {17+ t.Fatal(err)18+ }19+ }20+ if err := s.Set(at("B1"), formula); err != nil {21+ t.Fatal(err)22+ }23+ s.SetDecimal(decimal)24+ return s25+}26+27+// The cases where binary and decimal arithmetic differ, and some where28+// they must agree. Values are compared as General shows them with every29+// digit (strconv's shortest form), so 0.30000000000000004 is visible.30+func TestDecimalArithmetic(t *testing.T) {31+ tenCents := strings.Split(strings.Repeat("0.1,", 10), ",")[:10]32+ tests := []struct {33+ name string34+ formula string35+ inputs []string36+ binary, decim string37+ }{38+ {"sum of two", "=0.1+0.2", nil, "0.30000000000000004", "0.3"},39+ {"comparison", "=0.1+0.2=0.3", nil, "FALSE", "TRUE"},40+ {"residue", "=1.1-1-0.1", nil, "8.326672684688674E-17", "0"},41+ {"product", "=0.07*3", nil, "0.21000000000000002", "0.21"},42+ {"cents to whole", "=INT(A1*100)", []string{"$4.35"}, "434", "435"},43+ {"currency sum", "=SUM(A1:A10)", tenCents, "0.9999999999999999", "1"},44+ {"currency sum equals", "=SUM(A1:A10)=1", tenCents, "FALSE", "TRUE"},45+ {"average", "=AVERAGE(0.1, 0.2)", nil, "0.15000000000000002", "0.15"},46+ {"division", "=0.3/0.1", nil, "2.9999999999999996", "3"},47+ {"postfix percent", "=1.1%", nil, "0.011000000000000001", "0.011"},48+ {"round of a residue", "=ROUND(0.1+0.2-0.3, 20)", nil, "5.551E-17", "0"},49+ // Rounding already works on the digits a cell shows, so these agree.50+ {"round half up", "=ROUND(2.675, 2)", nil, "2.68", "2.68"},51+ {"round 1.005", "=ROUND(1.005, 2)", nil, "1.01", "1.01"},52+ {"round negative half", "=ROUND(-2.5)", nil, "-3", "-3"},53+ {"round tens", "=ROUND(1234.5, -2)", nil, "1200", "1200"},54+ {"roundup", "=ROUNDUP(2.301, 1)", nil, "2.4", "2.4"},55+ {"rounddown", "=ROUNDDOWN(-2.39, 1)", nil, "-2.3", "-2.3"},56+ {"trunc", "=TRUNC((0.1+0.7)*10)", nil, "8", "8"},57+ // Typed percentages are exact decimals on entry in both modes.58+ {"typed percent", "=A1", []string{"0.7%"}, "0.007", "0.007"},59+ // Division rounds: decimal doesn't make thirds whole again.60+ {"thirds", "=1/3*3", nil, "1", "0.9999999999999999"},61+ {"one third", "=1/3", nil, "0.3333333333333333", "0.3333333333333333"},62+ // Outside the boundary, and errors, nothing changes.63+ {"power stays binary", "=1.1^2", nil, "1.2100000000000002", "1.2100000000000002"},64+ {"sqrt stays binary", "=SQRT(2)", nil, "1.4142135623730951", "1.4142135623730951"},65+ {"div by zero", "=1/0", nil, "#DIV/0!", "#DIV/0!"},66+ {"overflow", "=1E308*10", nil, "#NUM!", "#NUM!"},67+ {"text", `="a"+1`, nil, "#VALUE!", "#VALUE!"},68+ {"sum of text", `=SUM("a")`, nil, "#VALUE!", "#VALUE!"},69+ {"average of nothing", "=AVERAGE(A1:A2)", nil, "#DIV/0!", "#DIV/0!"},70+ {"sum ignores text in ranges", "=SUM(A1:A3)", []string{"0.1", "x", "0.2"}, "0.30000000000000004", "0.3"},71+ {"huge sum falls back", "=SUM(1E308, 1E308)", nil, "#NUM!", "#NUM!"},72+ }73+ show := func(v Value) string {74+ if v.Kind == Number {75+ return formatShortest(v.Num)76+ }77+ return v.String()78+ }79+ for _, tt := range tests {80+ for _, dec := range []bool{false, true} {81+ want := tt.binary82+ if dec {83+ want = tt.decim84+ }85+ s := decimalSheet(t, dec, tt.formula, tt.inputs...)86+ if got := show(s.Value(at("B1"))); got != want {87+ t.Errorf("%s: %s with decimal=%v = %s, want %s", tt.name, tt.formula, dec, got, want)88+ }89+ }90+ }91+}92+93+// Turning the setting on and off recalculates, is one undo step, and94+// undo and redo recalculate too.95+func TestDecimalUndo(t *testing.T) {96+ s := decimalSheet(t, false, "=0.1+0.2=0.3")97+ s.ClearHistory()98+ check := func(when string, want bool) {99+ t.Helper()100+ if got := s.Value(at("B1")); got != boolean(want) || s.Decimal() != want {101+ t.Errorf("%s: B1 = %v, Decimal() = %v; want %v", when, got, s.Decimal(), want)102+ }103+ }104+ check("start", false)105+ s.SetDecimal(true)106+ check("on", true)107+ s.SetDecimal(true) // no-op, no step108+ if c, ok := s.Undo(); !ok || c.Label != "turn on decimal arithmetic" {109+ t.Fatalf("undo = %+v, %v", c, ok)110+ }111+ check("undone", false)112+ if s.CanUndo() {113+ t.Error("the no-op made an undo step")114+ }115+ s.Redo()116+ check("redone", true)117+ s.SetDecimal(false)118+ check("off", false)119+}120+121+// The setting round-trips through the file without a version bump, and122+// a file without it computes in binary.123+func TestDecimalFile(t *testing.T) {124+ s := decimalSheet(t, true, "=A1+A2", "0.1", "0.2")125+ var b bytes.Buffer126+ if err := s.Write(&b); err != nil {127+ t.Fatal(err)128+ }129+ out := b.String()130+ if !strings.Contains(out, `"version": 2,`) || !strings.Contains(out, `"arithmetic": "decimal",`) {131+ t.Fatalf("file:\n%s", out)132+ }133+ r, err := Read(strings.NewReader(out))134+ if err != nil {135+ t.Fatal(err)136+ }137+ if !r.Decimal() || r.Value(at("B1")).Num != 0.3 {138+ t.Errorf("read back: decimal %v, B1 %v", r.Decimal(), r.Value(at("B1")))139+ }140+ if r.CanUndo() {141+ t.Error("loading made an undo step")142+ }143+ for _, mode := range []string{"", `"arithmetic": "binary",`, `"arithmetic": "quantum",`} {144+ in := strings.Replace(out, `"arithmetic": "decimal",`, mode, 1)145+ r, err := Read(strings.NewReader(in))146+ if err != nil || r.Decimal() {147+ t.Errorf("%q: decimal %v, %v", mode, r != nil && r.Decimal(), err)148+ }149+ }150+ s.SetDecimal(false)151+ b.Reset()152+ if err := s.Write(&b); err != nil {153+ t.Fatal(err)154+ }155+ if strings.Contains(b.String(), "arithmetic") {156+ t.Errorf("binary sheet mentions arithmetic:\n%s", b.String())157+ }158+}159+160+// Formats inferred from the formula survive the decimal twins: a sum of161+// currency is currency in both modes.162+func TestDecimalKeepsFormats(t *testing.T) {163+ for _, dec := range []bool{false, true} {164+ s := decimalSheet(t, dec, "=SUM(A1:A2)", "$1.10", "$2.20")165+ if f := s.DisplayFormat(at("B1")); f.Kind != FmtCurrency {166+ t.Errorf("decimal=%v: format %v", dec, f)167+ }168+ if got, _ := Display(s.Value(at("B1")), s.DisplayFormat(at("B1")), 12); got != "$3.30" {169+ t.Errorf("decimal=%v: shows %q", dec, got)170+ }171+ }172+}173+174+func formatShortest(f float64) string {175+ return strings.ToUpper(strconv.FormatFloat(f, 'g', -1, 64))176+}177+178+func BenchmarkArithmetic(b *testing.B) {179+ for _, dec := range []bool{false, true} {180+ name := "binary"181+ if dec {182+ name = "decimal"183+ }184+ b.Run(name, func(b *testing.B) {185+ s := New()186+ for r := range 1000 {187+ s.Set(Addr{Row: r}, "19.99")188+ s.Set(Addr{Col: 1, Row: r}, "=A"+strconv.Itoa(r+1)+"*1.0825+0.01")189+ }190+ s.Set(at("C1"), "=SUM(B1:B1000)")191+ s.SetDecimal(dec)192+ b.ReportAllocs()193+ b.ResetTimer()194+ for range b.N {195+ s.Set(at("A1"), "20.01")196+ }197+ })198+ }199+}
M internal/sheet/eval.go +10 -0
M internal/sheet/history.go +3 -0
M internal/sheet/parse.go +4 -2
M internal/sheet/refs_test.go +1 -1
M internal/sheet/sheet.go +1 -1
M internal/sheet/view.go +18 -5
@@ -8,11 +8,12 @@ import (
88 )99 1010 // View state that belongs to the worksheet rather than to a cell: frozen11-// rows and columns, and the filter. It is saved in the file and every12-// change to it is an undo step, as in Sheets.11+// rows and columns, the filter and the arithmetic setting. It is saved in12+// the file and every change to it is an undo step, as in Sheets.1313 type viewState struct {1414 frozenRows, frozenCols int1515 filter *Filter // never modified in place; replaced whole16+ decimal bool // decimal arithmetic, see decimal.go1617 }1718 1819 // Frozen returns how many rows and columns are frozen at the top and left.
@@ -69,7 +70,7 @@ func (s *Sheet) recordView() {
6970 }7071 7172 func (v viewState) equal(w viewState) bool {72- return v.frozenRows == w.frozenRows && v.frozenCols == w.frozenCols &&73+ return v.frozenRows == w.frozenRows && v.frozenCols == w.frozenCols && v.decimal == w.decimal &&7374 (v.filter == w.filter || reflect.DeepEqual(v.filter, w.filter))7475 }7576
@@ -119,9 +120,15 @@ func (s *Sheet) shiftView(rows bool, sp span) {
119120 //120121 // "freeze": {"rows": 1},121122 // "filter": {"range": "A1:C20", "columns": {"B": {"condition": "gt", "value": "100"}}},123+//124+// and the arithmetic setting, which needs no version bump: earlier125+// builds ignore it and compute in binary, as Sheets would.126+//127+// "arithmetic": "decimal",122128 type fileView struct {123- Freeze *fileFreeze `json:"freeze,omitempty"`124- Filter *fileFilter `json:"filter,omitempty"`129+ Arithmetic string `json:"arithmetic,omitempty"`130+ Freeze *fileFreeze `json:"freeze,omitempty"`131+ Filter *fileFilter `json:"filter,omitempty"`125132 }126133 127134 type fileFreeze struct {
@@ -144,6 +151,9 @@ type fileCriteria struct {
144151 func (s *Sheet) writeView(b *bytes.Buffer) error {145152 var keys []string146153 var parts []any154+ if s.view.decimal {155+ keys, parts = append(keys, "arithmetic"), append(parts, "decimal")156+ }147157 if v := s.view; v.frozenRows > 0 || v.frozenCols > 0 {148158 keys, parts = append(keys, "freeze"), append(parts, fileFreeze{Rows: v.frozenRows, Cols: v.frozenCols})149159 }
@@ -166,6 +176,9 @@ func (s *Sheet) writeView(b *bytes.Buffer) error {
166176 167177 // readView restores the frozen panes and the filter from a file.168178 func (s *Sheet) readView(fv fileView) error {179+ // Anything but "decimal" (say, a mode from a later build) computes180+ // in binary, as the file would in a build without the setting.181+ s.view.decimal = fv.Arithmetic == "decimal"169182 if fz := fv.Freeze; fz != nil {170183 s.view.frozenRows, s.view.frozenCols = clampInt(fz.Rows, 0, MaxFrozen), clampInt(fz.Cols, 0, MaxFrozen)171184 }
M oracle/go.mod +1 -0
M oracle/go.sum +4 -0