Skip to content

Commit a90c4ec

Browse files
committed
This closes #2303, support 3D references across sheet ranges
Formulas of the shape SUM(Sheet1:SheetN!A1) failed with #NAME? invalid reference because parseReference split the reference on ':' and handed the leading sheet token to parseRef, which treats it as a cell name. No code path recognised the 3D shape. Add parse3DRef / split3DRef / readSheetToken / expand3DSheetRange ahead of parseReference. On a match the reference is expanded across the workbook-order sheet range and each sheet's cell or range is evaluated recursively, yielding a matrix that existing aggregates (SUM, AVERAGE, MIN, MAX, COUNT, COUNTA, PRODUCT) consume unchanged. Non-ASCII sheet names work unquoted, e.g. SUM(Jänner:Dezember!M40), matching Excel's behaviour. The quoted-name form SUM('A':'B'!ref) is mis-tokenised by github.com/xuri/efp before parseReference sees it, so it is tracked separately and not handled here. Signed-off-by: Christopher Albert <albert@tugraz.at>
1 parent 6c1f7ea commit a90c4ec

2 files changed

Lines changed: 173 additions & 0 deletions

File tree

calc.go

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1627,6 +1627,9 @@ func (cr *cellRange) prepareCellRange(col, row bool, cellRef cellRef) error {
16271627
// characters and default sheet name.
16281628
func (f *File) parseReference(ctx *calcContext, sheet, reference string) (formulaArg, error) {
16291629
reference = strings.ReplaceAll(reference, "$", "")
1630+
if result, is3D, err := f.parse3DRef(ctx, reference); is3D {
1631+
return result, err
1632+
}
16301633
ranges, cellRanges, cellRefs := strings.Split(reference, ":"), list.New(), list.New()
16311634
if len(ranges) > 1 {
16321635
var cr cellRange
@@ -1666,6 +1669,104 @@ func (f *File) parseReference(ctx *calcContext, sheet, reference string) (formul
16661669
return f.rangeResolver(ctx, cellRefs, cellRanges)
16671670
}
16681671

1672+
// parse3DRef detects a 3D reference of the form
1673+
//
1674+
// Sheet1 : Sheet2 ! CellOrRange
1675+
//
1676+
// and expands it across the workbook-order sheet range. On a 3D hit it
1677+
// returns the expanded matrix as a formulaArg, is3D=true, and any
1678+
// evaluation error encountered. On a non-3D reference it returns
1679+
// is3D=false and callers fall through to the regular 2D parse path.
1680+
// The quoted-name form `SUM('A':'B'!ref)` is mis-tokenised by the efp
1681+
// tokeniser upstream of parseReference and is not handled here.
1682+
func (f *File) parse3DRef(ctx *calcContext, reference string) (formulaArg, bool, error) {
1683+
sheet1, sheet2, innerRef, ok := split3DRef(reference)
1684+
if !ok {
1685+
return formulaArg{}, false, nil
1686+
}
1687+
sheets, err := f.expand3DSheetRange(sheet1, sheet2)
1688+
if err != nil {
1689+
return newErrorFormulaArg(formulaErrorREF, formulaErrorREF), true, errors.New(formulaErrorREF)
1690+
}
1691+
var matrix [][]formulaArg
1692+
for _, sn := range sheets {
1693+
sub, subErr := f.parseReference(ctx, sn, innerRef)
1694+
if subErr != nil {
1695+
return sub, true, subErr
1696+
}
1697+
switch sub.Type {
1698+
case ArgMatrix:
1699+
matrix = append(matrix, sub.Matrix...)
1700+
default:
1701+
matrix = append(matrix, []formulaArg{sub})
1702+
}
1703+
}
1704+
return newMatrixFormulaArg(matrix), true, nil
1705+
}
1706+
1707+
// split3DRef parses `sheet1:sheet2!ref` with optional single-quote
1708+
// quoting of sheet names. Returns (sheet1, sheet2, innerRef, true) on a
1709+
// 3D shape; otherwise returns ok=false.
1710+
func split3DRef(reference string) (sheet1, sheet2, innerRef string, ok bool) {
1711+
bang := strings.Index(reference, "!")
1712+
if bang < 0 || bang == len(reference)-1 {
1713+
return
1714+
}
1715+
left, right := reference[:bang], reference[bang+1:]
1716+
s1, rest, ok := readSheetToken(left)
1717+
if !ok || len(rest) < 2 || rest[0] != ':' {
1718+
return "", "", "", false
1719+
}
1720+
s2, rest, ok := readSheetToken(rest[1:])
1721+
if !ok || rest != "" {
1722+
return "", "", "", false
1723+
}
1724+
if s1 == "" || s2 == "" {
1725+
return "", "", "", false
1726+
}
1727+
return s1, s2, right, true
1728+
}
1729+
1730+
// readSheetToken reads an unquoted sheet name from the front of s and
1731+
// returns (name, remaining, ok). A `:` ends the name; a `!` or `'` is
1732+
// rejected as malformed for the 3D shape parser.
1733+
func readSheetToken(s string) (name, rest string, ok bool) {
1734+
if s == "" {
1735+
return "", "", false
1736+
}
1737+
for i, r := range s {
1738+
if r == ':' {
1739+
return s[:i], s[i:], true
1740+
}
1741+
if r == '!' || r == '\'' {
1742+
return "", "", false
1743+
}
1744+
}
1745+
return s, "", true
1746+
}
1747+
1748+
// expand3DSheetRange returns the workbook-order slice of sheet names
1749+
// from sheet1 to sheet2 inclusive. Returns an error if either sheet is
1750+
// missing, or if sheet1's index is greater than sheet2's in workbook
1751+
// order. Callers surface the error as a #REF! formulaArg. Sheet lookup
1752+
// is case-insensitive (GetSheetIndex uses strings.EqualFold); the
1753+
// returned names preserve the workbook's stored casing.
1754+
func (f *File) expand3DSheetRange(sheet1, sheet2 string) ([]string, error) {
1755+
i1, err := f.GetSheetIndex(sheet1)
1756+
if err != nil || i1 < 0 {
1757+
return nil, fmt.Errorf("sheet %q not found", sheet1)
1758+
}
1759+
i2, err := f.GetSheetIndex(sheet2)
1760+
if err != nil || i2 < 0 {
1761+
return nil, fmt.Errorf("sheet %q not found", sheet2)
1762+
}
1763+
if i1 > i2 {
1764+
return nil, fmt.Errorf("sheet range %q:%q reversed in workbook order", sheet1, sheet2)
1765+
}
1766+
names := f.GetSheetList()
1767+
return names[i1 : i2+1], nil
1768+
}
1769+
16691770
// prepareValueRange prepare value range.
16701771
func prepareValueRange(cr cellRange, valueRange []int) {
16711772
if cr.From.Row < valueRange[0] || valueRange[0] == 0 {

calc_test.go

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7094,3 +7094,75 @@ func TestCalcImplicitIntersect(t *testing.T) {
70947094
newMatrixFormulaArg([][]formulaArg{{newNumberFormulaArg(1)}})).Type,
70957095
)
70967096
}
7097+
7098+
func TestCalc3DRef(t *testing.T) {
7099+
// setup builds a workbook with four sheets `Jan`, `Feb`, `Mar`, `Apr`
7100+
// (creation order = workbook order) and fills A1 / A2 / B1 with
7101+
// predictable values on each so range shapes can be asserted.
7102+
setup := func() *File {
7103+
f := NewFile()
7104+
assert.NoError(t, f.SetSheetName("Sheet1", "Jan"))
7105+
_, err := f.NewSheet("Feb")
7106+
assert.NoError(t, err)
7107+
_, err = f.NewSheet("Mar")
7108+
assert.NoError(t, err)
7109+
_, err = f.NewSheet("Apr")
7110+
assert.NoError(t, err)
7111+
for i, sn := range []string{"Jan", "Feb", "Mar", "Apr"} {
7112+
base := int64(i + 1)
7113+
assert.NoError(t, f.SetCellInt(sn, "A1", base*10))
7114+
assert.NoError(t, f.SetCellInt(sn, "A2", base))
7115+
assert.NoError(t, f.SetCellInt(sn, "B1", base*100))
7116+
}
7117+
return f
7118+
}
7119+
7120+
cases := map[string]string{
7121+
// Single cell per sheet across the whole range.
7122+
"SUM(Jan:Apr!A1)": "100", // 10+20+30+40
7123+
"SUM(Jan:Apr!$A$1)": "100",
7124+
"SUM(Jan:Mar!A1)": "60",
7125+
"SUM(Feb:Apr!A1)": "90",
7126+
"SUM(Jan:Jan!A1)": "10", // degenerate single-sheet range
7127+
// Multi-cell ranges on each sheet.
7128+
"SUM(Jan:Apr!A1:A2)": "110", // (10+1)+(20+2)+(30+3)+(40+4)
7129+
"SUM(Jan:Apr!A1:B1)": "1100",
7130+
// Other aggregates.
7131+
"AVERAGE(Jan:Apr!A1)": "25",
7132+
"MIN(Jan:Apr!A1)": "10",
7133+
"MAX(Jan:Apr!A1)": "40",
7134+
"COUNT(Jan:Apr!A1)": "4",
7135+
"COUNTA(Jan:Apr!A1)": "4",
7136+
"PRODUCT(Jan:Apr!A1)": "240000",
7137+
// Error paths.
7138+
"SUM(Apr:Jan!A1)": "#REF!", // reversed order
7139+
"SUM(Jan:Xyz!A1)": "#REF!", // missing end sheet
7140+
"SUM(Xyz:Mar!A1)": "#REF!", // missing start sheet
7141+
}
7142+
for formula, expected := range cases {
7143+
f := setup()
7144+
assert.NoError(t, f.SetCellFormula("Jan", "Z1", formula))
7145+
result, _ := f.CalcCellValue("Jan", "Z1")
7146+
assert.Equal(t, expected, result, formula)
7147+
}
7148+
7149+
// Non-ASCII sheet names must work unquoted, matching Excel's
7150+
// behaviour. Uses the month-name shape common in European workbooks.
7151+
t.Run("non-ascii sheet names", func(t *testing.T) {
7152+
f := NewFile()
7153+
assert.NoError(t, f.SetSheetName("Sheet1", "Jänner"))
7154+
_, err := f.NewSheet("Februar")
7155+
assert.NoError(t, err)
7156+
_, err = f.NewSheet("März")
7157+
assert.NoError(t, err)
7158+
_, err = f.NewSheet("Dezember")
7159+
assert.NoError(t, err)
7160+
for i, sn := range []string{"Jänner", "Februar", "März", "Dezember"} {
7161+
assert.NoError(t, f.SetCellInt(sn, "M40", int64((i+1)*100)))
7162+
}
7163+
assert.NoError(t, f.SetCellFormula("Jänner", "A1", "SUM(Jänner:Dezember!$M$40)"))
7164+
result, err := f.CalcCellValue("Jänner", "A1")
7165+
assert.NoError(t, err)
7166+
assert.Equal(t, "1000", result)
7167+
})
7168+
}

0 commit comments

Comments
 (0)