diff --git a/calc.go b/calc.go index 71d4bd3b1c..1ac7df6302 100644 --- a/calc.go +++ b/calc.go @@ -98,9 +98,13 @@ const ( tfmmss = `(([0-9])+):(([0-9])+\.([0-9])+)( (am|pm))?` tfhhmmss = `(([0-9])+):(([0-9])+):(([0-9])+(\.([0-9])+)?)( (am|pm))?` timeSuffix = `( (` + tfhh + `|` + tfhhmm + `|` + tfmmss + `|` + tfhhmmss + `))?$` + + tableRefPartsCnt = 3 ) var ( + errNotExistingTable = errors.New("not existing table") + errNotExistingColumn = errors.New("not existing column") // tokenPriority defined basic arithmetic operator priority tokenPriority = map[string]int{ "^": 5, @@ -211,6 +215,7 @@ var ( criteriaL, criteriaG, } + tableRefRe = regexp.MustCompile(`^(\w+)\[([^\]]+)\]$`) ) // calcContext defines the formula execution context. @@ -1494,6 +1499,7 @@ func parseRef(ref string) (cellRef, bool, bool, error) { cell = ref tokens = strings.Split(ref, "!") ) + if len(tokens) == 2 { // have a worksheet cr.Sheet, cell = tokens[0], tokens[1] } @@ -1509,6 +1515,58 @@ func parseRef(ref string) (cellRef, bool, bool, error) { return cr, false, false, err } +func pickColumnInTableRef(tblRef tableRef, colName string) (string, error) { + offset := -1 + + // Column ID is not reliable for order so we need to iterate through them. + for i, otherColName := range tblRef.columns { + if colName == otherColName { + offset = i + } + } + + if offset == -1 { + return "", fmt.Errorf("column `%s` not in table: %w", colName, errNotExistingColumn) + } + + // Tables having just a single cell are invalid. Hence it is safe to assume it should always be a range reference. + coords, err := rangeRefToCoordinates(tblRef.ref) + if err != nil { + return "", err + } + + col := coords[0] + offset + rangeRef, err := coordinatesToRangeRef([]int{col, coords[1] + 1, col, coords[3]}) + if err != nil { + return "", err + } + + return fmt.Sprintf("%s!%s", tblRef.sheet, rangeRef), nil +} + +func (f *File) tryParseAsTableRef(ref string) (string, error) { + submatch := tableRefRe.FindStringSubmatch(ref) + // Fallback to regular ref. + if len(submatch) != tableRefPartsCnt { + return ref, nil + } + + tableName := submatch[1] + colName := submatch[2] + + rawTblRef, ok := f.tableRefs.Load(tableName) + if !ok { + return "", fmt.Errorf("referencing table `%s`: %w", tableName, errNotExistingTable) + } + + tblRef, ok := rawTblRef.(tableRef) + if !ok { + panic(fmt.Sprintf("unexpected reference type %T", ref)) + } + + return pickColumnInTableRef(tblRef, colName) +} + // prepareCellRange checking and convert cell reference to a cell range. func (cr *cellRange) prepareCellRange(col, row bool, cellRef cellRef) error { if col { @@ -1542,6 +1600,11 @@ func (cr *cellRange) prepareCellRange(col, row bool, cellRef cellRef) error { // characters and default sheet name. func (f *File) parseReference(ctx *calcContext, sheet, reference string) (formulaArg, error) { reference = strings.ReplaceAll(reference, "$", "") + reference, err := f.tryParseAsTableRef(reference) + if err != nil { + return newErrorFormulaArg(formulaErrorNAME, "invalid reference"), err + } + ranges, cellRanges, cellRefs := strings.Split(reference, ":"), list.New(), list.New() if len(ranges) > 1 { var cr cellRange diff --git a/calc_test.go b/calc_test.go index fc28a05955..02915f1624 100644 --- a/calc_test.go +++ b/calc_test.go @@ -6447,6 +6447,85 @@ func TestCalcCellResolver(t *testing.T) { assert.EqualError(t, err, "XML syntax error on line 1: invalid UTF-8") } +func TestTableReference(t *testing.T) { + f := sheetWithTables(t) + + assert.NoError(t, f.SetCellFormula("Sheet1", "A1", "=INDEX(FieryTable[Column1], 1)"), "cell formula for A1") + assert.NoError(t, f.SetCellFormula("Sheet1", "B1", "=INDEX(FieryTable[Column2], 1)"), "cell formula for A2") + assert.NoError(t, f.SetCellFormula("Sheet1", "C1", "=B1*2"), "cell formula for A3") + assert.NoError(t, f.SetCellFormula("Sheet1", "D1", "=INDEX(FrostyTable[Column1], 1)"), "cell formula for A1") + + res, err := f.CalcCellValue("Sheet1", "A1") + assert.NoError(t, err, "calculating cell A1") + assert.Equal(t, "Foo", res, "A1 calc is wrong") + + res, err = f.CalcCellValue("Sheet1", "B1") + assert.NoError(t, err, "calculating cell B1") + assert.Equal(t, "12.5", res, "B1 calc is wrong") + + res, err = f.CalcCellValue("Sheet1", "C1") + assert.NoError(t, err, "calculating cell C1") + assert.Equal(t, "25", res, "C1 calc is wrong") + + res, err = f.CalcCellValue("Sheet1", "D1") + assert.NoError(t, err, "calculating cell D1") + assert.Equal(t, "Hedgehog", res, "D1 calc is wrong") +} + +func TestTableRefenceFromOtherSheet(t *testing.T) { + f := sheetWithTables(t) + + _, err := f.NewSheet("Sheet2") + assert.NoError(t, err, "creating Sheet2") + + assert.NoError(t, f.SetCellFormula("Sheet2", "A1", "=INDEX(FieryTable[Column1], 1)"), "cell formula for A1") + + res, err := f.CalcCellValue("Sheet2", "A1") + assert.NoError(t, err, "calculating cell A1") + assert.Equal(t, "Foo", res, "A1 calc is wrong") +} + +func TestTableReferenceWithDeletedTable(t *testing.T) { + f := sheetWithTables(t) + + assert.NoError(t, f.SetCellFormula("Sheet1", "A1", "=INDEX(FieryTable[Column1], 1)"), "cell formula for A1") + assert.NoError(t, f.DeleteTable("FieryTable"), "deleting table") + + _, err := f.CalcCellValue("Sheet1", "A1") + assert.Error(t, err, "A1 calc is wrong") +} + +func TestTableReferenceToNotExistingTable(t *testing.T) { + f := sheetWithTables(t) + assert.NoError(t, f.SetCellFormula("Sheet1", "A1", "=INDEX(NotExisting[Column1], 1)"), "cell formula for A1") + + _, err := f.CalcCellValue("Sheet1", "A1") + assert.Error(t, err, "A1 calc is wrong") +} + +func TestTableReferenceToNotExistingColumn(t *testing.T) { + f := sheetWithTables(t) + assert.NoError(t, f.SetCellFormula("Sheet1", "A1", "=INDEX(FieryTable[NotExisting], 1)"), "cell formula for A1") + + _, err := f.CalcCellValue("Sheet1", "A1") + assert.Error(t, err, "A1 calc is wrong") +} + +func sheetWithTables(t *testing.T) *File { + f := NewFile() + + // Multi column with default column names + assert.NoError(t, f.AddTable("Sheet1", &Table{Range: "A2:C5", Name: "FieryTable"}), "adding FieryTable") + assert.NoError(t, f.SetCellValue("Sheet1", "A3", "Foo"), "set A3") + assert.NoError(t, f.SetCellValue("Sheet1", "B3", "12.5"), "set A3") + + // Single column with renamed column + assert.NoError(t, f.AddTable("Sheet1", &Table{Range: "A8:A9", Name: "FrostyTable"}), "adding FrostyTable") + assert.NoError(t, f.SetCellValue("Sheet1", "A9", "Hedgehog"), "set A3") + + return f +} + func TestEvalInfixExp(t *testing.T) { f := NewFile() arg, err := f.evalInfixExp(nil, "Sheet1", "A1", []efp.Token{ diff --git a/excelize.go b/excelize.go index 4de1ac1104..3229f15c20 100644 --- a/excelize.go +++ b/excelize.go @@ -16,8 +16,10 @@ import ( "archive/zip" "bytes" "encoding/xml" + "fmt" "io" "os" + "path" "path/filepath" "strconv" "strings" @@ -26,6 +28,8 @@ import ( "golang.org/x/net/html/charset" ) +const targetModeExternal = "external" + // File define a populated spreadsheet file struct. type File struct { mu sync.Mutex @@ -39,6 +43,7 @@ type File struct { streams map[string]*StreamWriter tempFiles sync.Map xmlAttr sync.Map + tableRefs sync.Map CalcChain *xlsxCalcChain CharsetReader charsetTranscoderFn Comments map[string]*xlsxComments @@ -59,6 +64,19 @@ type File struct { WorkBook *xlsxWorkbook } +type tableRef struct { + ref string + sheet string + columns []string +} + +type relationMetadata struct { + wb *xlsxWorkbook + wbRels *xlsxRelationships + relsPerSheet map[string]*xlsxRelationships + tables map[string]*xlsxTable +} + // charsetTranscoderFn set user-defined codepage transcoder function for open // the spreadsheet from non-UTF-8 encoding. type charsetTranscoderFn func(charset string, input io.Reader) (rdr io.Reader, err error) @@ -140,6 +158,7 @@ func newFile() *File { checked: sync.Map{}, sheetMap: make(map[string]string), tempFiles: sync.Map{}, + tableRefs: sync.Map{}, Comments: make(map[string]*xlsxComments), Drawings: sync.Map{}, sharedStringsMap: make(map[string]int), @@ -204,6 +223,10 @@ func OpenReader(r io.Reader, opts ...Options) (*File, error) { for k, v := range file { f.Pkg.Store(k, v) } + + if err := f.storeRelations(file); err != nil { + return f, err + } if f.CalcChain, err = f.calcChainReader(); err != nil { return f, err } @@ -217,6 +240,136 @@ func OpenReader(r io.Reader, opts ...Options) (*File, error) { return f, err } +func (f *File) storeRelations(files map[string][]byte) error { + relMetadata, err := f.parseRelationMetadata(files) + if err != nil { + return err + } + if relMetadata.wb == nil || relMetadata.wbRels == nil { + return nil + } + + sheetRelIDs := make(map[string]string) + for _, sheet := range relMetadata.wb.Sheets.Sheet { + sheetRelIDs[sheet.ID] = sheet.Name + } + + sheetBaseToSheetNames := make(map[string]string) + for _, rel := range relMetadata.wbRels.Relationships { + sheetName, ok := sheetRelIDs[rel.ID] + + if !ok || strings.ToLower(rel.TargetMode) == targetModeExternal || rel.Type != SourceRelationshipWorkSheet { + continue + } + + sheetBaseToSheetNames[fmt.Sprintf("%s.rels", path.Base(rel.Target))] = sheetName + } + + tableBaseToSheetNames := make(map[string]string) + for key, sheetRels := range relMetadata.relsPerSheet { + sheetName, ok := sheetBaseToSheetNames[key] + if !ok { + continue + } + + for _, rel := range sheetRels.Relationships { + if strings.ToLower(rel.TargetMode) == targetModeExternal || rel.Type != SourceRelationshipTable { + continue + } + + tableBaseToSheetNames[path.Base(rel.Target)] = sheetName + } + } + + for key, t := range relMetadata.tables { + if sheetName, ok := tableBaseToSheetNames[key]; ok { + f.tableRefs.Store(t.Name, tableRefFromXLSXTable(t, sheetName)) + } + } + + return nil +} + +func (f *File) parseRelationMetadata(files map[string][]byte) (*relationMetadata, error) { + var err error + relMetadata := &relationMetadata{ + relsPerSheet: map[string]*xlsxRelationships{}, + tables: map[string]*xlsxTable{}, + } + + for k, v := range files { + switch { + case strings.Contains(k, "xl/workbook.xml") && v != nil: + relMetadata.wb, err = f.parseWorkbook(v) + if err != nil { + return nil, err + } + case strings.Contains(k, "xl/_rels/workbook.xml.rels") && v != nil: + relMetadata.wbRels, err = f.parseRelationships(v) + if err != nil { + return nil, fmt.Errorf("workbook rels: %w", err) + } + case strings.Contains(k, "xl/worksheets/_rels") && v != nil: + sheetRels, err := f.parseRelationships(v) + if err != nil { + return nil, fmt.Errorf("workbook sheet rel %s: %w", k, err) + } + relMetadata.relsPerSheet[path.Base(k)] = sheetRels + case strings.Contains(k, "xl/tables") && v != nil: + table, err := f.parseTable(v) + if err != nil { + return nil, fmt.Errorf("table %s: %w", k, err) + } + relMetadata.tables[path.Base(k)] = table + } + } + + return relMetadata, nil +} + +func (f *File) parseWorkbook(v []byte) (*xlsxWorkbook, error) { + var wb *xlsxWorkbook + + dec := f.xmlNewDecoder(bytes.NewReader(namespaceStrictToTransitional(v))) + if err := dec.Decode(&wb); err != nil && err != io.EOF { + return nil, fmt.Errorf("decoding workbook: %w", err) + } + + return wb, nil +} + +func (f *File) parseRelationships(v []byte) (*xlsxRelationships, error) { + var rels *xlsxRelationships + + dec := f.xmlNewDecoder(bytes.NewReader(namespaceStrictToTransitional(v))) + if err := dec.Decode(&rels); err != nil && err != io.EOF { + return nil, fmt.Errorf("decoding relationships: %w", err) + } + + return rels, nil +} + +func (f *File) parseTable(v []byte) (*xlsxTable, error) { + var table *xlsxTable + dec := f.xmlNewDecoder(bytes.NewReader(namespaceStrictToTransitional(v))) + if err := dec.Decode(&table); err != nil && err != io.EOF { + return nil, fmt.Errorf("parsing table: %w", err) + } + return table, nil +} + +func tableRefFromXLSXTable(t *xlsxTable, sheet string) tableRef { + tblRef := tableRef{ + ref: t.Ref, + sheet: sheet, + columns: make([]string, 0, t.TableColumns.Count), + } + for _, col := range t.TableColumns.TableColumn { + tblRef.columns = append(tblRef.columns, col.Name) + } + return tblRef +} + // getOptions provides a function to parse the optional settings for open // and reading spreadsheet. func (f *File) getOptions(opts ...Options) *Options { diff --git a/excelize_test.go b/excelize_test.go index 7416409a49..7ed65f6662 100644 --- a/excelize_test.go +++ b/excelize_test.go @@ -288,7 +288,7 @@ func TestOpenReader(t *testing.T) { defaultXMLPathWorkbookRels, } { _, err = OpenReader(preset(defaultXMLPath, false)) - assert.EqualError(t, err, "XML syntax error on line 1: invalid UTF-8") + assert.ErrorContains(t, err, "XML syntax error on line 1: invalid UTF-8") } // Test open workbook without internal XML parts for _, defaultXMLPath := range []string{ diff --git a/table.go b/table.go index 0fb8a7119f..c58270310a 100644 --- a/table.go +++ b/table.go @@ -187,6 +187,7 @@ func (f *File) DeleteTable(name string) error { if tbl.RID == table.rID { ws.TableParts.TableParts = append(ws.TableParts.TableParts[:i], ws.TableParts.TableParts[i+1:]...) f.Pkg.Delete(table.tableXML) + f.tableRefs.Delete(table.Name) _ = f.removeContentTypesPart(ContentTypeSpreadSheetMLTable, "/"+table.tableXML) f.deleteSheetRelationships(sheet, tbl.RID) break @@ -396,6 +397,7 @@ func (f *File) addTable(sheet, tableXML string, x1, y1, x2, y2, i int, opts *Tab } table, err := xml.Marshal(t) f.saveFileList(tableXML, table) + f.tableRefs.Store(t.Name, tableRefFromXLSXTable(&t, sheet)) return err }