diff --git a/errors.go b/errors.go index bbac74b6ef..b13717e666 100644 --- a/errors.go +++ b/errors.go @@ -27,6 +27,9 @@ var ( // ErrCellCharsLength defined the error message for receiving a cell // characters length that exceeds the limit. ErrCellCharsLength = fmt.Errorf("cell value must be 0-%d characters", TotalCellChars) + // ErrCellNoFormula defined the error message on attempting to refresh the + // cached value of a cell that does not hold a formula. + ErrCellNoFormula = errors.New("cell does not contain a formula") // ErrCellStyles defined the error message on cell styles exceeds the limit. ErrCellStyles = fmt.Errorf("the cell styles exceeds the %d limit", MaxCellStyles) // ErrColumnNumber defined the error message on receive an invalid column diff --git a/recalc.go b/recalc.go new file mode 100644 index 0000000000..4b304ea006 --- /dev/null +++ b/recalc.go @@ -0,0 +1,104 @@ +// Copyright 2016 - 2026 The excelize Authors. All rights reserved. Use of +// this source code is governed by a BSD-style license that can be found in +// the LICENSE file. +// +// Package excelize providing a set of functions that allow you to write to and +// read from XLAM / XLSM / XLSX / XLTM / XLTX files. Supports reading and +// writing spreadsheet documents generated by Microsoft Excelâ„¢ 2007 and later. +// Supports complex components by high compatibility, and provided streaming +// API for generating or reading data from a worksheet with huge amounts of +// data. This library needs Go version 1.25.0 or later. + +package excelize + +import "strconv" + +// RecalcCell evaluates the formula in the given cell and persists the +// typed result into its cached / pair. The cell's element, +// and its shared-formula master, ref span, and si index when present, +// are left untouched. Dependency resolution uses the same recursive +// evaluator as CalcCellValue, so a single call converges a chain of +// formulas that feed the target. Circular references are bounded by +// the workbook's MaxCalcIterations option. +// +// Returns ErrCellNoFormula when the target cell does not hold a +// formula. Any evaluation error from the calc engine is returned +// unwrapped so callers can match on it directly. +func (f *File) RecalcCell(sheet, cell string) error { + ctx := &calcContext{ + entry: sheet + "!" + cell, + maxCalcIterations: f.options.MaxCalcIterations, + iterations: make(map[string]uint), + iterationsCache: make(map[string]formulaArg), + } + arg, err := f.calcCellValue(ctx, sheet, cell) + if err != nil { + return err + } + return f.setCellCachedValue(sheet, cell, arg) +} + +// setCellCachedValue writes the cached value of a formula cell from a +// typed formulaArg without touching the cell's formula. The cell must +// already hold an element or ErrCellNoFormula is returned. For +// shared-formula children the child's / are updated in isolation: +// the master's formula body and sibling children remain untouched. +func (f *File) setCellCachedValue(sheet, cell string, v formulaArg) error { + f.mu.Lock() + ws, err := f.workSheetReader(sheet) + if err != nil { + f.mu.Unlock() + return err + } + f.mu.Unlock() + ws.mu.Lock() + defer ws.mu.Unlock() + c, _, _, err := ws.prepareCell(cell) + if err != nil { + return err + } + if c.F == nil { + return ErrCellNoFormula + } + setCachedArg(c, v) + return nil +} + +// setCachedArg maps a typed formulaArg onto the xlsxC value/type pair +// exactly as Excel would store the cached result of a formula: numeric +// booleans as t="b", plain numbers as t="" (implicit numeric), strings +// as t="str" (the inline shape Excel uses for formula results), errors +// as t="e" with the error code in . ArgMatrix and ArgList collapse +// to their first scalar element, matching formulaArg.Value. ArgEmpty +// and ArgUnknown both clear the cache. Any inline-string remnant from +// a prior cache is cleared so a string-to-number transition does not +// leak the old . +func setCachedArg(c *xlsxC, v formulaArg) { + c.IS = nil + switch v.Type { + case ArgNumber: + if v.Boolean { + c.T, c.V = setCellBool(v.Number != 0) + return + } + c.T, c.V = "", strconv.FormatFloat(v.Number, 'f', -1, 64) + case ArgString: + c.T, c.V = "str", v.String + case ArgError: + c.T, c.V = "e", v.Error + case ArgEmpty, ArgUnknown: + c.T, c.V = "", "" + case ArgMatrix: + if head := v.ToList(); len(head) > 0 && head[0].Type != ArgMatrix { + setCachedArg(c, head[0]) + return + } + c.T, c.V = "", "" + case ArgList: + if len(v.List) > 0 && v.List[0].Type != ArgMatrix && v.List[0].Type != ArgList { + setCachedArg(c, v.List[0]) + return + } + c.T, c.V = "", "" + } +} diff --git a/recalc_test.go b/recalc_test.go new file mode 100644 index 0000000000..c51932137d --- /dev/null +++ b/recalc_test.go @@ -0,0 +1,191 @@ +// Copyright 2016 - 2026 The excelize Authors. All rights reserved. Use of +// this source code is governed by a BSD-style license that can be found in +// the LICENSE file. + +package excelize + +import ( + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +// cellXML fetches the raw xlsxC for a cell so the test can inspect +// , , and directly without going through GetCellValue's +// formatting layer. +func cellXML(t *testing.T, f *File, sheet, addr string) *xlsxC { + t.Helper() + ws, err := f.workSheetReader(sheet) + assert.NoError(t, err) + col, row, err := CellNameToCoordinates(addr) + assert.NoError(t, err) + assert.LessOrEqual(t, row, len(ws.SheetData.Row), addr) + rowData := ws.SheetData.Row[row-1] + assert.LessOrEqual(t, col, len(rowData.C), addr) + return &rowData.C[col-1] +} + +func TestRecalcCellTypes(t *testing.T) { + cases := []struct { + name string + formula string + inputs map[string]int + wantT string + wantV string + }{ + {"numeric", "SUM(A1:A2)", map[string]int{"A1": 10, "A2": 32}, "", "42"}, + {"boolean", "A1>0", map[string]int{"A1": 5}, "b", "1"}, + {"chained", "A1*3", map[string]int{"A1": 7}, "", "21"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + f := NewFile() + for k, v := range tc.inputs { + assert.NoError(t, f.SetCellInt("Sheet1", k, int64(v))) + } + assert.NoError(t, f.SetCellFormula("Sheet1", "B1", tc.formula)) + assert.NoError(t, f.RecalcCell("Sheet1", "B1")) + c := cellXML(t, f, "Sheet1", "B1") + assert.Equal(t, tc.formula, c.F.Content, "formula preserved") + assert.Equal(t, tc.wantT, c.T) + assert.Equal(t, tc.wantV, c.V) + }) + } +} + +func TestRecalcCellNoFormula(t *testing.T) { + f := NewFile() + assert.NoError(t, f.SetCellInt("Sheet1", "A1", 5)) + assert.ErrorIs(t, f.RecalcCell("Sheet1", "A1"), ErrCellNoFormula) +} + +func TestRecalcCellReturnsFormulaError(t *testing.T) { + f := NewFile() + assert.NoError(t, f.SetCellFormula("Sheet1", "A1", "NONEXISTENTFUNC(1)")) + assert.Error(t, f.RecalcCell("Sheet1", "A1")) +} + +func TestRecalcCellNoTypeSRegression(t *testing.T) { + // Previously a recalc path round-tripped numeric results through + // string, storing cells as t="s" (shared string). Downstream + // aggregates over that blob silently summed to 0. RecalcCell must + // persist numeric results with an implicit t attribute. + f := NewFile() + assert.NoError(t, f.SetCellInt("Sheet1", "A1", 3)) + assert.NoError(t, f.SetCellInt("Sheet1", "A2", 4)) + assert.NoError(t, f.SetCellFormula("Sheet1", "A3", "SUM(A1:A2)")) + assert.NoError(t, f.RecalcCell("Sheet1", "A3")) + c := cellXML(t, f, "Sheet1", "A3") + assert.NotEqual(t, "s", c.T) + assert.Equal(t, "", c.T) + assert.Equal(t, "7", c.V) +} + +func TestRecalcCellSharedFormulaBand(t *testing.T) { + // Row 40 shared-formula group with master G40; RecalcCell on each + // member must leave the shared metadata intact. + f := NewFile() + for col, v := range map[string]int64{"G": 2, "H": 3, "I": 4, "J": 5} { + assert.NoError(t, f.SetCellInt("Sheet1", col+"1", v)) + } + ref := "G40:J40" + sharedType := STCellFormulaTypeShared + assert.NoError(t, f.SetCellFormula("Sheet1", "G40", "SUM(G1:G39)", FormulaOpts{ + Type: &sharedType, + Ref: &ref, + })) + master := cellXML(t, f, "Sheet1", "G40") + si := *master.F.Si + + for _, addr := range []string{"G40", "H40", "I40", "J40"} { + assert.NoError(t, f.RecalcCell("Sheet1", addr)) + } + + master = cellXML(t, f, "Sheet1", "G40") + assert.Equal(t, "SUM(G1:G39)", master.F.Content) + assert.Equal(t, ref, master.F.Ref) + assert.Equal(t, si, *master.F.Si) + assert.Equal(t, "2", master.V) + for _, child := range []string{"H40", "I40", "J40"} { + c := cellXML(t, f, "Sheet1", child) + assert.Equal(t, sharedType, c.F.T, child) + assert.Equal(t, "", c.F.Ref, child) + assert.Equal(t, si, *c.F.Si, child) + } +} + +func TestRecalcCellCircularReferenceBounded(t *testing.T) { + f := NewFile() + assert.NoError(t, f.SetCellFormula("Sheet1", "A1", "B1+1")) + assert.NoError(t, f.SetCellFormula("Sheet1", "B1", "A1+1")) + done := make(chan struct{}) + go func() { + _ = f.RecalcCell("Sheet1", "A1") + close(done) + }() + select { + case <-done: + case <-time.After(2 * time.Second): + t.Fatal("RecalcCell did not terminate on circular reference") + } +} + +func TestRecalcCellRoundTrip(t *testing.T) { + f := NewFile() + assert.NoError(t, f.SetCellInt("Sheet1", "A1", 2)) + assert.NoError(t, f.SetCellInt("Sheet1", "A2", 3)) + assert.NoError(t, f.SetCellFormula("Sheet1", "A3", "A1*A2")) + assert.NoError(t, f.RecalcCell("Sheet1", "A3")) + + buf, err := f.WriteToBuffer() + assert.NoError(t, err) + f2, err := OpenReader(strings.NewReader(buf.String())) + assert.NoError(t, err) + defer func() { _ = f2.Close() }() + + formula, err := f2.GetCellFormula("Sheet1", "A3") + assert.NoError(t, err) + assert.Equal(t, "A1*A2", formula) + value, err := f2.GetCellValue("Sheet1", "A3", Options{RawCellValue: true}) + assert.NoError(t, err) + assert.Equal(t, "6", value) +} + +func TestSetCellCachedValueRejectsBadTarget(t *testing.T) { + f := NewFile() + + assert.Error(t, f.setCellCachedValue("Nope", "A1", newNumberFormulaArg(1))) + assert.Error(t, f.setCellCachedValue("Sheet1", "bad-ref", newNumberFormulaArg(1))) +} + +func TestSetCachedArgTypes(t *testing.T) { + cases := []struct { + name string + arg formulaArg + wantT string + wantV string + }{ + {"string", newStringFormulaArg("ready"), "str", "ready"}, + {"formula_error", newErrorFormulaArg(formulaErrorVALUE, formulaErrorVALUE), "e", formulaErrorVALUE}, + {"empty", newEmptyFormulaArg(), "", ""}, + {"unknown", formulaArg{Type: ArgUnknown}, "", ""}, + {"matrix_scalar_head", newMatrixFormulaArg([][]formulaArg{{newStringFormulaArg("top")}}), "str", "top"}, + {"matrix_nested_head", newMatrixFormulaArg([][]formulaArg{{newMatrixFormulaArg([][]formulaArg{{newNumberFormulaArg(1)}})}}), "", ""}, + {"list_scalar_head", newListFormulaArg([]formulaArg{newNumberFormulaArg(12)}), "", "12"}, + {"list_nested_head", newListFormulaArg([]formulaArg{newListFormulaArg([]formulaArg{newNumberFormulaArg(2)})}), "", ""}, + {"list_empty", newListFormulaArg(nil), "", ""}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + c := &xlsxC{T: "inlineStr", V: "stale", IS: &xlsxSI{}} + + setCachedArg(c, tc.arg) + + assert.Nil(t, c.IS) + assert.Equal(t, tc.wantT, c.T) + assert.Equal(t, tc.wantV, c.V) + }) + } +}