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..d424ed41c1 --- /dev/null +++ b/recalc.go @@ -0,0 +1,220 @@ +// 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 ( + "errors" + "fmt" + "strconv" +) + +// RecalcOptions narrows the scope of Recalc. +type RecalcOptions struct { + // Sheet limits recalc to a single worksheet when non-empty. An + // empty value recalculates every formula in the workbook. + Sheet string + // Ref limits recalc to cells inside the given A1-style range + // (e.g. B2:D10). Requires Sheet to be set. + Ref string +} + +// Recalc evaluates every formula in scope and persists each result +// into the cell's cached / via RecalcCell. The element and +// any shared-formula grouping are preserved. Typical use: +// +// f, _ := excelize.OpenFile(path) +// defer f.Close() +// _ = f.Recalc() +// _ = f.Save() +// +// Scope is controlled by an optional RecalcOptions value. The default +// (no options) walks the whole workbook. Setting Sheet narrows to one +// worksheet; setting Ref (requires Sheet) narrows further to an A1 +// range. +// +// Dependency resolution uses the same recursive evaluator as +// CalcCellValue, so chained formulas across sheets converge in a +// single pass. Circular references are bounded by the workbook's +// MaxCalcIterations option, matching existing calc engine behaviour. +// +// When any formula cannot be evaluated, Recalc continues with the +// remaining cells. Each failure is wrapped as +// fmt.Errorf("!: %w", err); Recalc returns the joined +// collection via errors.Join so errors.Is / errors.As descend into +// the underlying causes. Cells that did compute are still persisted. +func (f *File) Recalc(opts ...RecalcOptions) error { + var o RecalcOptions + if len(opts) > 0 { + o = opts[0] + } + cells, err := f.collectRecalcCells(o) + if err != nil { + return err + } + f.clearCalcCache() + var failures []error + for _, t := range cells { + if err := f.RecalcCell(t.sheet, t.cell); err != nil { + failures = append(failures, fmt.Errorf("%s!%s: %w", t.sheet, t.cell, err)) + } + } + f.clearCalcCache() + return errors.Join(failures...) +} + +// recalcTarget identifies a formula cell scheduled for recalculation. +type recalcTarget struct{ sheet, cell string } + +// collectRecalcCells walks the workbook and returns every formula +// cell inside the scope specified by RecalcOptions. The workbook is +// walked in sheet-list order; within a sheet, cells are visited by +// row then column. Ref is parsed relative to Sheet's origin. +func (f *File) collectRecalcCells(o RecalcOptions) ([]recalcTarget, error) { + if o.Ref != "" && o.Sheet == "" { + return nil, fmt.Errorf("recalc: Ref requires Sheet: %w", ErrParameterRequired) + } + var sheets []string + if o.Sheet != "" { + if idx, err := f.GetSheetIndex(o.Sheet); err != nil || idx < 0 { + return nil, fmt.Errorf("recalc: sheet %q not found", o.Sheet) + } + sheets = []string{o.Sheet} + } else { + sheets = f.GetSheetList() + } + inScope := func(_, _ int) bool { return true } + if o.Ref != "" { + coords, err := rangeRefToCoordinates(o.Ref) + if err != nil { + return nil, fmt.Errorf("recalc: invalid Ref: %w", err) + } + _ = sortCoordinates(coords) + colStart, rowStart, colEnd, rowEnd := coords[0], coords[1], coords[2], coords[3] + inScope = func(col, row int) bool { + return col >= colStart && col <= colEnd && row >= rowStart && row <= rowEnd + } + } + var cells []recalcTarget + for _, sn := range sheets { + ws, err := f.workSheetReader(sn) + if err != nil { + return nil, err + } + for r := range ws.SheetData.Row { + row := &ws.SheetData.Row[r] + for i := range row.C { + if row.C[i].F == nil { + continue + } + col, rowNum, cellErr := CellNameToCoordinates(row.C[i].R) + if cellErr != nil { + continue + } + if !inScope(col, rowNum) { + continue + } + cells = append(cells, recalcTarget{sheet: sn, cell: row.C[i].R}) + } + } + } + return cells, nil +} + +// 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..6d04cef620 --- /dev/null +++ b/recalc_test.go @@ -0,0 +1,316 @@ +// 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 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 TestRecalcChainedAcrossSheet(t *testing.T) { + // A1 = 5, A2 = A1*2, A3 = A2+A1, A4 = SUM(A1:A3), plus a second + // sheet with its own chain. One Recalc call covers everything. + f := NewFile() + _, err := f.NewSheet("Other") + assert.NoError(t, err) + assert.NoError(t, f.SetCellInt("Sheet1", "A1", 5)) + assert.NoError(t, f.SetCellFormula("Sheet1", "A2", "A1*2")) + assert.NoError(t, f.SetCellFormula("Sheet1", "A3", "A2+A1")) + assert.NoError(t, f.SetCellFormula("Sheet1", "A4", "SUM(A1:A3)")) + assert.NoError(t, f.SetCellInt("Other", "A1", 7)) + assert.NoError(t, f.SetCellFormula("Other", "A2", "A1*3")) + + assert.NoError(t, f.Recalc()) + + assert.Equal(t, "10", cellXML(t, f, "Sheet1", "A2").V) + assert.Equal(t, "15", cellXML(t, f, "Sheet1", "A3").V) + assert.Equal(t, "30", cellXML(t, f, "Sheet1", "A4").V) + assert.Equal(t, "21", cellXML(t, f, "Other", "A2").V) +} + +func TestRecalcIdempotent(t *testing.T) { + f := NewFile() + assert.NoError(t, f.SetCellInt("Sheet1", "A1", 7)) + assert.NoError(t, f.SetCellFormula("Sheet1", "A2", "A1*3")) + assert.NoError(t, f.Recalc()) + first := cellXML(t, f, "Sheet1", "A2").V + assert.NoError(t, f.Recalc()) + assert.Equal(t, first, cellXML(t, f, "Sheet1", "A2").V) + assert.Equal(t, "21", first) +} + +func TestRecalcWorkbookWithoutFormulas(t *testing.T) { + f := NewFile() + assert.NoError(t, f.SetCellInt("Sheet1", "A1", 42)) + assert.NoError(t, f.Recalc()) +} + +func TestRecalcRejectsChartSheet(t *testing.T) { + f := NewFile() + assert.NoError(t, f.SetCellInt("Sheet1", "A1", 1)) + assert.NoError(t, f.SetCellInt("Sheet1", "A2", 2)) + assert.NoError(t, f.AddChartSheet("Chart1", &Chart{ + Type: Col, + Series: []ChartSeries{{ + Categories: "Sheet1!$A$1:$A$2", + Values: "Sheet1!$A$1:$A$2", + }}, + })) + + assert.Error(t, f.Recalc(RecalcOptions{Sheet: "Chart1"})) +} + +func TestRecalcAggregatesFailures(t *testing.T) { + // An unsupported function on one cell must not prevent other cells + // from being recalculated. The returned error is the join of + // per-cell failures, each wrapped as "sheet!cell: ". + f := NewFile() + assert.NoError(t, f.SetCellFormula("Sheet1", "A1", "NONEXISTENTFUNC(1)")) + assert.NoError(t, f.SetCellInt("Sheet1", "B1", 10)) + assert.NoError(t, f.SetCellFormula("Sheet1", "B2", "B1*2")) + + err := f.Recalc() + assert.Error(t, err) + assert.Contains(t, err.Error(), "Sheet1!A1:") + // Joined error exposes causes via Unwrap() []error. + unwrapped, ok := err.(interface{ Unwrap() []error }) + if assert.True(t, ok, "expected errors.Join result") { + assert.Len(t, unwrapped.Unwrap(), 1) + } + // Working cell was still recalculated. + assert.Equal(t, "20", cellXML(t, f, "Sheet1", "B2").V) +} + +func TestRecalcNonASCIISheetName(t *testing.T) { + f := NewFile() + assert.NoError(t, f.SetSheetName("Sheet1", "Bérénice")) + assert.NoError(t, f.SetCellInt("Bérénice", "A1", 3)) + assert.NoError(t, f.SetCellFormula("Bérénice", "A2", "A1*A1")) + assert.NoError(t, f.Recalc()) + assert.Equal(t, "9", cellXML(t, f, "Bérénice", "A2").V) +} + +func TestRecalcSheetScope(t *testing.T) { + f := NewFile() + _, err := f.NewSheet("Other") + assert.NoError(t, err) + assert.NoError(t, f.SetCellInt("Sheet1", "A1", 1)) + assert.NoError(t, f.SetCellInt("Other", "A1", 1)) + assert.NoError(t, f.SetCellFormula("Sheet1", "A2", "A1+A1")) + assert.NoError(t, f.SetCellFormula("Other", "A2", "A1+A1")) + + assert.NoError(t, f.Recalc(RecalcOptions{Sheet: "Other"})) + + assert.Empty(t, cellXML(t, f, "Sheet1", "A2").V, "Sheet1 cache untouched") + assert.Equal(t, "2", cellXML(t, f, "Other", "A2").V) +} + +func TestRecalcRefScope(t *testing.T) { + f := NewFile() + assert.NoError(t, f.SetCellInt("Sheet1", "A1", 1)) + assert.NoError(t, f.SetCellInt("Sheet1", "B1", 1)) + assert.NoError(t, f.SetCellFormula("Sheet1", "A2", "A1+A1")) + assert.NoError(t, f.SetCellFormula("Sheet1", "B2", "B1+B1")) + + assert.NoError(t, f.Recalc(RecalcOptions{Sheet: "Sheet1", Ref: "A1:A10"})) + + assert.Equal(t, "2", cellXML(t, f, "Sheet1", "A2").V) + assert.Empty(t, cellXML(t, f, "Sheet1", "B2").V, "B2 cache remains empty") +} + +func TestRecalcOptionRejects(t *testing.T) { + f := NewFile() + assert.ErrorContains(t, f.Recalc(RecalcOptions{Ref: "A1:B2"}), "requires Sheet") + assert.ErrorContains(t, f.Recalc(RecalcOptions{Sheet: "Nope"}), `"Nope"`) + assert.ErrorContains(t, f.Recalc(RecalcOptions{Sheet: "Sheet1", Ref: "not-a-range"}), "invalid Ref") +} + +func TestCollectRecalcCellsSkipsMalformedCellReference(t *testing.T) { + f := NewFile() + assert.NoError(t, f.SetCellFormula("Sheet1", "A1", "1+1")) + assert.NoError(t, f.SetCellFormula("Sheet1", "B1", "2+2")) + ws, err := f.workSheetReader("Sheet1") + assert.NoError(t, err) + ws.SheetData.Row[0].C[0].R = "bad-ref" + + cells, err := f.collectRecalcCells(RecalcOptions{Sheet: "Sheet1"}) + assert.NoError(t, err) + assert.Equal(t, []recalcTarget{{sheet: "Sheet1", cell: "B1"}}, cells) +} + +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) + }) + } +}