package officekit import ( "encoding/xml" "fmt" "sort" "strconv" "strings" ) // Sheet is one worksheet's data, as the model supplies it to // create_spreadsheet: a name and a grid of string cell values (numbers // are written as plain decimal strings; anything that parses as a // number is stored as a numeric cell so Excel treats it as one). type Sheet struct { Name string Rows [][]string } const xlsxWorkbookPart = "xl/workbook.xml" const xlsxWorkbookRelsPart = "xl/_rels/workbook.xml.rels" // xlsxSheetPart returns the conventional worksheet part path for the // Nth (1-based) sheet BuildXlsx writes — sheet1.xml, sheet2.xml, ... func xlsxSheetPart(n int) string { return fmt.Sprintf("xl/worksheets/sheet%d.xml", n) } // --- typed XML for the parts BuildXlsx/edit operations actually // generate from user-supplied content (worksheet XML, plus the small // workbook/rels indirection needed to support more than one sheet); // every other part is static boilerplate with no user content. --- type sWorksheet struct { XMLName xml.Name `xml:"worksheet"` Xmlns string `xml:"xmlns,attr"` SheetData sSheetData `xml:"sheetData"` } type sSheetData struct { Rows []sRow `xml:"row"` } type sRow struct { R int `xml:"r,attr"` Cells []sCell `xml:"c"` } // sCell's field order (after the r/t attrs) follows OOXML's CT_Cell // schema sequence — f (formula), v (value), is (inline string) — so a // formula cell with a cached value (f + v both set, as real Excel // always writes them) round-trips as a schema-valid element. type sCell struct { R string `xml:"r,attr"` Type string `xml:"t,attr,omitempty"` // "inlineStr" for text; omitted (numeric/formula) otherwise Formula *string `xml:"f,omitempty"` // formula cells ("=SUM(...)" minus the leading "=") Value *string `xml:"v,omitempty"` // numeric cells, and a formula's last-known/cached result Is *sIs `xml:"is,omitempty"` // inline-string cells } type sIs struct { T string `xml:"t"` } // sWorkbook is the minimal shape of xl/workbook.xml this package // cares about: just enough to resolve a sheet name/1-based index to // its relationship id, so ReadXlsxCells/SetXlsxCells can find the // right worksheet part even in a workbook with more than one sheet // (including ones not authored by BuildXlsx, e.g. a user upload). type sWorkbook struct { XMLName xml.Name `xml:"workbook"` Sheets []sWbName `xml:"sheets>sheet"` } type sWbName struct { Name string `xml:"name,attr"` RID string `xml:"id,attr"` // r:id — encoding/xml matches the local name regardless of the "r" namespace prefix. } // sRelationships is the minimal shape of a .rels part this package // needs: a relationship id -> target path. type sRelationships struct { XMLName xml.Name `xml:"Relationships"` Rel []sRelationship `xml:"Relationship"` } type sRelationship struct { ID string `xml:"Id,attr"` Target string `xml:"Target,attr"` } // colLetter converts a 0-based column index to its spreadsheet letter // (0 -> A, 25 -> Z, 26 -> AA, ...). func colLetter(i int) string { s := "" for { s = string(rune('A'+(i%26))) + s i = i/26 - 1 if i < 0 { break } } return s } func isNumeric(s string) bool { if s == "" { return false } _, err := strconv.ParseFloat(s, 64) return err == nil } func rowsToSheetXML(rows [][]string) ([]byte, error) { sd := sSheetData{} for ri, row := range rows { r := sRow{R: ri + 1} for ci, val := range row { cell := sCell{R: colLetter(ci) + strconv.Itoa(ri+1)} setCellValue(&cell, val) r.Cells = append(r.Cells, cell) } sd.Rows = append(sd.Rows, r) } ws := sWorksheet{Xmlns: "http://schemas.openxmlformats.org/spreadsheetml/2006/main", SheetData: sd} body, err := xml.Marshal(ws) if err != nil { return nil, fmt.Errorf("officekit: marshal worksheet xml: %w", err) } return []byte(xml.Header + string(body)), nil } // BuildXlsx renders one or more sheets into a complete, valid .xlsx // workbook — every sheet given is written as its own worksheet part // and listed in xl/workbook.xml in order, each with a matching // relationship in xl/_rels/workbook.xml.rels. An unnamed sheet gets // the conventional "SheetN" name. func BuildXlsx(sheets []Sheet) ([]byte, error) { if len(sheets) == 0 { sheets = []Sheet{{Name: "Sheet1"}} } parts := []zipPart{ {Name: "_rels/.rels", Body: xlsxRootRels}, {Name: "xl/styles.xml", Body: xlsxStyles}, {Name: "docProps/core.xml", Body: officeCoreProps}, } var sheetEntries, relEntries, overrides strings.Builder for i, sh := range sheets { idx := i + 1 name := sh.Name if name == "" { name = fmt.Sprintf("Sheet%d", idx) } sheetXML, err := rowsToSheetXML(sh.Rows) if err != nil { return nil, err } parts = append(parts, zipPart{Name: xlsxSheetPart(idx), Body: string(sheetXML)}) fmt.Fprintf(&sheetEntries, ``, xmlEscapeAttr(name), idx, idx) fmt.Fprintf(&relEntries, ``, idx, idx) fmt.Fprintf(&overrides, ``, idx) } // The styles relationship id just needs to be distinct from every // sheet's rId above; one past the last sheet always is. fmt.Fprintf(&relEntries, ``, len(sheets)+1) workbookXML := xml.Header + `` + sheetEntries.String() + `` workbookRels := xml.Header + `` + relEntries.String() + `` contentTypes := xml.Header + `` + `` + `` + `` + overrides.String() + `` + `` parts = append(parts, zipPart{Name: "[Content_Types].xml", Body: contentTypes}, zipPart{Name: xlsxWorkbookPart, Body: workbookXML}, zipPart{Name: xlsxWorkbookRelsPart, Body: workbookRels}, ) return buildZip(parts) } // xmlEscapeAttr escapes the handful of characters that matter inside // an XML attribute value. Used only for the places we interpolate // user text into a hand-written template string (sheet names in // workbook.xml) rather than through encoding/xml. func xmlEscapeAttr(s string) string { r := strings.NewReplacer(`&`, "&", `<`, "<", `>`, ">", `"`, """) return r.Replace(s) } // ReadXlsxSheetNames returns every sheet name in the workbook, in // order — used by read_spreadsheet to tell the model what else is in // a multi-sheet file it only asked to read one sheet of. func ReadXlsxSheetNames(data []byte) ([]string, error) { wb, err := readXlsxWorkbook(data) if err != nil { return nil, err } names := make([]string, len(wb.Sheets)) for i, s := range wb.Sheets { names[i] = s.Name } return names, nil } func readXlsxWorkbook(data []byte) (*sWorkbook, error) { raw, ok, err := readZipPart(data, xlsxWorkbookPart) if err != nil { return nil, err } if !ok { return nil, fmt.Errorf("officekit: %s not found (not a valid .xlsx)", xlsxWorkbookPart) } var wb sWorkbook if err := xml.Unmarshal(raw, &wb); err != nil { return nil, fmt.Errorf("officekit: parse workbook xml: %w", err) } return &wb, nil } // resolveXlsxSheetPart maps a sheet reference (empty for the first // sheet, a 1-based index as a string, or a sheet name) to that sheet's // actual worksheet part path, by reading xl/workbook.xml and // xl/_rels/workbook.xml.rels — not by assuming sheet1.xml is sheet // one, since that isn't guaranteed for a file this package didn't // write itself (a user-uploaded workbook whose sheets were reordered // or renumbered in Excel). Falls back to the sheet1.xml convention // only if the workbook's own bookkeeping can't be parsed at all. func resolveXlsxSheetPart(data []byte, sheetRef string) (partPath, sheetName string, err error) { wb, err := readXlsxWorkbook(data) if err != nil || len(wb.Sheets) == 0 { return xlsxSheetPart(1), "", nil } var target *sWbName switch { case sheetRef == "": target = &wb.Sheets[0] default: if n, convErr := strconv.Atoi(sheetRef); convErr == nil { if n < 1 || n > len(wb.Sheets) { return "", "", fmt.Errorf("officekit: sheet %d out of range (workbook has %d sheet(s))", n, len(wb.Sheets)) } target = &wb.Sheets[n-1] } else { for i := range wb.Sheets { if strings.EqualFold(wb.Sheets[i].Name, sheetRef) { target = &wb.Sheets[i] break } } if target == nil { return "", "", fmt.Errorf("officekit: no sheet named %q", sheetRef) } } } relsRaw, ok, err := readZipPart(data, xlsxWorkbookRelsPart) if err != nil || !ok { return xlsxSheetPart(1), target.Name, nil } var rels sRelationships if err := xml.Unmarshal(relsRaw, &rels); err != nil { return xlsxSheetPart(1), target.Name, nil } for _, r := range rels.Rel { if r.ID == target.RID { t := strings.TrimPrefix(r.Target, "/") if !strings.HasPrefix(t, "xl/") { t = "xl/" + t } return t, target.Name, nil } } return xlsxSheetPart(1), target.Name, nil } // ReadXlsxCells returns one worksheet's cells as a row-major grid of // display strings, for the read_spreadsheet tool. sheet selects which // one: empty for the first sheet, a 1-based index ("2"), or a sheet // name (case-insensitive); the resolved name is returned alongside so // callers can report it. func ReadXlsxCells(data []byte, sheet string) ([][]string, string, error) { partPath, sheetName, err := resolveXlsxSheetPart(data, sheet) if err != nil { return nil, "", err } raw, ok, err := readZipPart(data, partPath) if err != nil { return nil, "", err } if !ok { return nil, "", fmt.Errorf("officekit: %s not found (not a valid .xlsx)", partPath) } var ws sWorksheet if err := xml.Unmarshal(raw, &ws); err != nil { return nil, "", fmt.Errorf("officekit: parse worksheet xml: %w", err) } var out [][]string for _, row := range ws.SheetData.Rows { var line []string for _, c := range row.Cells { switch { case c.Is != nil: line = append(line, c.Is.T) case c.Value != nil: // A real Excel-authored formula cell always caches its // last-computed value alongside the formula itself; show // that computed result rather than the formula text. line = append(line, *c.Value) case c.Formula != nil: // A formula cell this package just wrote has no cached // value (nothing here evaluates formulas) — show the // formula itself rather than a misleading blank. line = append(line, "="+*c.Formula) default: line = append(line, "") } } out = append(out, line) } return out, sheetName, nil } // SetXlsxCells downloads-parses-modifies-reuploads: applies a sparse // set of cell writes (keyed by "A1"-style reference) to one worksheet // (selected the same way as ReadXlsxCells) and returns the new file // bytes. Cells outside the current grid extend it; existing rows/cells // are preserved otherwise. func SetXlsxCells(data []byte, sheet string, writes map[string]string) ([]byte, error) { partPath, _, err := resolveXlsxSheetPart(data, sheet) if err != nil { return nil, err } raw, ok, err := readZipPart(data, partPath) if err != nil { return nil, err } if !ok { return nil, fmt.Errorf("officekit: %s not found (not a valid .xlsx)", partPath) } var ws sWorksheet if err := xml.Unmarshal(raw, &ws); err != nil { return nil, fmt.Errorf("officekit: parse worksheet xml: %w", err) } // rowByNum maps a row number to its INDEX in ws.SheetData.Rows, not // a pointer into it. A pointer taken here (&ws.SheetData.Rows[i]) // is only valid for the backing array that exists at the moment // it's taken; the very next `append` below that grows the slice // past its capacity allocates a new backing array and copies into // it, silently stranding any earlier-taken pointer on the old, // now-orphaned one. Writes applied through a stranded pointer // still "succeed" from the caller's point of view — no error, no // panic — but vanish, because they land on memory nothing else // reads. An index has no such failure mode: ws.SheetData.Rows[idx] // always re-resolves against whatever backing array the slice // header currently points to. rowByNum := map[int]int{} for i := range ws.SheetData.Rows { rowByNum[ws.SheetData.Rows[i].R] = i } // Writes are applied in ascending (row, column) order rather than // map iteration order (which Go deliberately randomizes). This // doesn't affect correctness — sortSheetData below restores final // order regardless — but it keeps behavior deterministic and easy // to reason about/debug when a batch of cell writes is logged or // replayed. refs := make([]string, 0, len(writes)) for ref := range writes { refs = append(refs, ref) } sort.Slice(refs, func(a, b int) bool { ra, ca, _ := parseCellRef(refs[a]) rb, cb, _ := parseCellRef(refs[b]) if ra != rb { return ra < rb } return ca < cb }) for _, ref := range refs { val := writes[ref] rowNum, _, err := parseCellRef(ref) if err != nil { return nil, err } idx, ok := rowByNum[rowNum] if !ok { ws.SheetData.Rows = append(ws.SheetData.Rows, sRow{R: rowNum}) idx = len(ws.SheetData.Rows) - 1 rowByNum[rowNum] = idx } row := &ws.SheetData.Rows[idx] replaced := false for i := range row.Cells { if row.Cells[i].R == ref { setCellValue(&row.Cells[i], val) replaced = true break } } if !replaced { c := sCell{R: ref} setCellValue(&c, val) row.Cells = append(row.Cells, c) } } // Excel is strict about row/cell order within xl/worksheets/sheetN.xml // (rows ascending by r, cells within a row ascending by column) — // writes above can append a row/cell out of order (e.g. writing "A1" // after "A5" already existed), which a lenient reader shrugs off but // Excel itself can flag as needing "repair". Restore ascending order // before marshaling so a sparse, arbitrarily-ordered set of writes // always produces a schema-valid worksheet. sortSheetData(&ws.SheetData) body, err := xml.Marshal(ws) if err != nil { return nil, fmt.Errorf("officekit: marshal worksheet xml: %w", err) } return replaceZipPart(data, partPath, []byte(xml.Header+string(body))) } // sortSheetData reorders rows ascending by row number and, within each // row, cells ascending by column — the order SetXlsxCells' sparse, // map-driven writes don't otherwise guarantee. func sortSheetData(sd *sSheetData) { sort.Slice(sd.Rows, func(i, j int) bool { return sd.Rows[i].R < sd.Rows[j].R }) for i := range sd.Rows { cells := sd.Rows[i].Cells sort.Slice(cells, func(a, b int) bool { return cellColumn(cells[a].R) < cellColumn(cells[b].R) }) } } // cellColumn returns a cell reference's 0-based column index (e.g. // "B12" -> 1), or 0 if ref doesn't parse as one — used only to order // cells within a row for output, so a malformed ref sorts first rather // than failing a write this deep into the process. func cellColumn(ref string) int { _, col, err := parseCellRef(ref) if err != nil { return 0 } return col } // setCellValue classifies val and sets exactly the sCell fields that // representation needs, clearing the others: a leading "=" (with at // least one character after it) stores a formula, same convention // Excel itself uses to distinguish "=SUM(A1:A2)" from the literal text // "=SUM(A1:A2)"; otherwise a value that parses as a plain number is // numeric, and everything else is inline text. func setCellValue(c *sCell, val string) { switch { case strings.HasPrefix(val, "=") && len(val) > 1: f := val[1:] c.Formula, c.Value, c.Is, c.Type = &f, nil, nil, "" case isNumeric(val): v := val c.Formula, c.Value, c.Is, c.Type = nil, &v, nil, "" default: c.Formula, c.Value = nil, nil c.Is, c.Type = &sIs{T: val}, "inlineStr" } } // parseCellRef splits "B12" into (row=12, col=1). Only used to locate // a row bucket for SetXlsxCells; the column stays embedded in the "r" // attribute string as-is. func parseCellRef(ref string) (row, col int, err error) { i := 0 for i < len(ref) && ref[i] >= 'A' && ref[i] <= 'Z' { col = col*26 + int(ref[i]-'A'+1) i++ } if i == 0 || i == len(ref) { return 0, 0, fmt.Errorf("officekit: invalid cell reference %q (expected e.g. \"A1\")", ref) } row, err = strconv.Atoi(ref[i:]) if err != nil || row < 1 { return 0, 0, fmt.Errorf("officekit: invalid cell reference %q (expected e.g. \"A1\")", ref) } return row, col - 1, nil } const xlsxRootRels = xml.Header + ` ` const xlsxStyles = xml.Header + ` `