| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489 |
- package office
- /*
- xlsx_reader.go - Parse an Excel (.xlsx) file into a Workbook.
- Handles the common SpreadsheetML subset: shared + inline strings,
- numbers, booleans, formulas, cell styles (bold/italic/underline, font
- color/size, fill, alignment, wrap), number formats (mapped back to the
- webapp's fmt names), column widths, row heights, merged cells and
- frozen panes. Bar/line/pie charts are mapped back to the webapp chart
- model (xlsx_charts.go); pivot tables and conditional formatting are
- ignored. Legacy binary .xls is rejected up front.
- */
- import (
- "archive/zip"
- "bytes"
- "errors"
- "io"
- "path"
- "strconv"
- "strings"
- )
- // ParseXlsx converts raw .xlsx bytes into a Workbook
- func ParseXlsx(data []byte) (*Workbook, error) {
- if len(data) > 8 && data[0] == 0xD0 && data[1] == 0xCF {
- return nil, errors.New("legacy binary .xls files are not supported - save the file as .xlsx first")
- }
- zr, err := zip.NewReader(bytes.NewReader(data), int64(len(data)))
- if err != nil {
- return nil, errors.New("not a valid xlsx (zip) file")
- }
- files := map[string][]byte{}
- for _, f := range zr.File {
- name := path.Clean(f.Name)
- if strings.HasSuffix(name, ".xml") || strings.HasSuffix(name, ".rels") {
- rc, err := f.Open()
- if err != nil {
- continue
- }
- b, err := io.ReadAll(rc)
- rc.Close()
- if err != nil {
- continue
- }
- files[name] = b
- }
- }
- wbXML, ok := files["xl/workbook.xml"]
- if !ok {
- return nil, errors.New("xlsx is missing xl/workbook.xml")
- }
- wbTree, err := parseXMLTree(wbXML)
- if err != nil {
- return nil, errors.New("cannot parse workbook.xml: " + err.Error())
- }
- rels := parseRels(files["xl/_rels/workbook.xml.rels"])
- shared := parseSharedStrings(files["xl/sharedStrings.xml"])
- styleMap := parseXlsxStyles(files["xl/styles.xml"])
- wb := &Workbook{Sheets: []*WorkSheet{}, Active: 0}
- chartIDSeq := 0
- if bv := wbTree.path("bookViews", "workbookView"); bv != nil {
- if at, err := strconv.Atoi(bv.attr("activeTab")); err == nil {
- wb.Active = at
- }
- }
- sheetsNode := wbTree.first("sheets")
- if sheetsNode == nil {
- return nil, errors.New("workbook has no sheets")
- }
- for _, sn := range sheetsNode.all("sheet") {
- name := sn.attr("name")
- rid := ""
- for _, a := range sn.Attrs {
- if a.Name.Local == "id" && strings.HasPrefix(a.Value, "rId") {
- rid = a.Value
- }
- }
- target, ok2 := rels[rid]
- if !ok2 {
- continue
- }
- partPath := resolvePartPath("xl", target)
- raw, ok3 := files[partPath]
- if !ok3 {
- continue
- }
- tree, err := parseXMLTree(raw)
- if err != nil {
- continue
- }
- ws := parseWorksheet(tree, shared, styleMap)
- ws.Name = name
- parseSheetDrawing(files, partPath, tree, ws, &chartIDSeq)
- parseSheetComments(files, partPath, ws)
- wb.Sheets = append(wb.Sheets, ws)
- }
- if len(wb.Sheets) == 0 {
- return nil, errors.New("no readable worksheets found in xlsx")
- }
- if wb.Active < 0 || wb.Active >= len(wb.Sheets) {
- wb.Active = 0
- }
- return wb, nil
- }
- /* ---------- shared strings ---------- */
- func parseSharedStrings(data []byte) []string {
- if data == nil {
- return nil
- }
- tree, err := parseXMLTree(data)
- if err != nil {
- return nil
- }
- var out []string
- for _, si := range tree.all("si") {
- var texts []string
- collectText(si, &texts)
- out = append(out, strings.Join(texts, ""))
- }
- return out
- }
- /* ---------- styles ---------- */
- type xlsxXfInfo struct {
- style *CellStyle // nil = plain
- }
- // parseXlsxStyles maps every cellXfs index to a webapp CellStyle
- func parseXlsxStyles(data []byte) []xlsxXfInfo {
- if data == nil {
- return nil
- }
- tree, err := parseXMLTree(data)
- if err != nil {
- return nil
- }
- // custom number format codes
- numCodes := map[int]string{}
- if nf := tree.first("numFmts"); nf != nil {
- for _, n := range nf.all("numFmt") {
- if id, err := strconv.Atoi(n.attr("numFmtId")); err == nil {
- numCodes[id] = n.attr("formatCode")
- }
- }
- }
- type fontInfo struct {
- b, i, u bool
- color string
- sizePx float64
- }
- var fonts []fontInfo
- if fs := tree.first("fonts"); fs != nil {
- for _, f := range fs.all("font") {
- fi := fontInfo{}
- if f.first("b") != nil {
- fi.b = true
- }
- if f.first("i") != nil {
- fi.i = true
- }
- if f.first("u") != nil {
- fi.u = true
- }
- if c := f.first("color"); c != nil {
- if rgb := c.attr("rgb"); len(rgb) == 8 {
- fi.color = "#" + strings.ToLower(rgb[2:])
- }
- }
- if sz := f.first("sz"); sz != nil {
- if v, err := strconv.ParseFloat(sz.attr("val"), 64); err == nil {
- fi.sizePx = v * 96.0 / 72.0
- }
- }
- fonts = append(fonts, fi)
- }
- }
- var fills []string
- if fl := tree.first("fills"); fl != nil {
- for _, f := range fl.all("fill") {
- bg := ""
- if pf := f.first("patternFill"); pf != nil && pf.attr("patternType") == "solid" {
- if fg := pf.first("fgColor"); fg != nil {
- if rgb := fg.attr("rgb"); len(rgb) == 8 {
- bg = "#" + strings.ToLower(rgb[2:])
- }
- }
- }
- fills = append(fills, bg)
- }
- }
- var out []xlsxXfInfo
- if cx := tree.first("cellXfs"); cx != nil {
- for _, xf := range cx.all("xf") {
- st := &CellStyle{}
- any := false
- if fid, err := strconv.Atoi(xf.attr("fontId")); err == nil && fid >= 0 && fid < len(fonts) {
- fi := fonts[fid]
- if fi.b {
- st.B = true
- any = true
- }
- if fi.i {
- st.I = true
- any = true
- }
- if fi.u {
- st.U = true
- any = true
- }
- if fi.color != "" && fi.color != "#000000" {
- st.Fc = fi.color
- any = true
- }
- if fi.sizePx > 0 && (fi.sizePx < 14 || fi.sizePx > 15.5) { // != default 11pt
- st.Fs = fi.sizePx
- any = true
- }
- }
- if flid, err := strconv.Atoi(xf.attr("fillId")); err == nil && flid >= 0 && flid < len(fills) {
- if fills[flid] != "" {
- st.Bg = fills[flid]
- any = true
- }
- }
- if bid, err := strconv.Atoi(xf.attr("borderId")); err == nil && bid > 0 {
- st.Bd = 1
- any = true
- }
- if al := xf.first("alignment"); al != nil {
- switch al.attr("horizontal") {
- case "left":
- st.Al = "l"
- any = true
- case "center":
- st.Al = "c"
- any = true
- case "right":
- st.Al = "r"
- any = true
- }
- if al.attr("wrapText") == "1" || al.attr("wrapText") == "true" {
- st.Wrap = true
- any = true
- }
- }
- if nid, err := strconv.Atoi(xf.attr("numFmtId")); err == nil && nid > 0 {
- fmtName, dec := numFmtIDToName(nid, numCodes)
- if fmtName != "" {
- st.Fmt = fmtName
- if dec >= 0 {
- d := dec
- st.Dec = &d
- }
- any = true
- }
- }
- if any {
- out = append(out, xlsxXfInfo{style: st})
- } else {
- out = append(out, xlsxXfInfo{})
- }
- }
- }
- return out
- }
- // numFmtIDToName maps builtin/custom number format ids to webapp fmt names
- func numFmtIDToName(id int, custom map[int]string) (string, int) {
- switch {
- case id >= 1 && id <= 2:
- return "number", decimalsInCode("0.00")
- case id == 3:
- return "number", 0
- case id == 4:
- return "number", 2
- case id == 9:
- return "percent", 0
- case id == 10:
- return "percent", 2
- case id >= 14 && id <= 17 || id == 22:
- return "date", -1
- case id == 44 || id == 5 || id == 6 || id == 7 || id == 8 || id == 42:
- return "currency", 2
- case id == 49:
- return "text", -1
- }
- code, ok := custom[id]
- if !ok {
- return "", -1
- }
- lc := strings.ToLower(code)
- switch {
- case strings.Contains(lc, "%"):
- return "percent", decimalsInCode(code)
- case strings.Contains(code, "$") || strings.Contains(code, "¤"):
- return "currency", decimalsInCode(code)
- case strings.Contains(lc, "yy") || strings.Contains(lc, "dd") ||
- (strings.Contains(lc, "mm") && !strings.Contains(lc, "0")):
- return "date", -1
- case code == "@":
- return "text", -1
- case strings.Contains(code, "0"):
- return "number", decimalsInCode(code)
- }
- return "", -1
- }
- func decimalsInCode(code string) int {
- i := strings.Index(code, ".")
- if i < 0 {
- return 0
- }
- n := 0
- for j := i + 1; j < len(code) && code[j] == '0'; j++ {
- n++
- }
- return n
- }
- /* ---------- worksheet ---------- */
- func parseWorksheet(tree *xnode, shared []string, styleMap []xlsxXfInfo) *WorkSheet {
- ws := &WorkSheet{
- Cells: map[string]*WorkCell{},
- ColW: map[string]float64{},
- RowH: map[string]float64{},
- }
- // frozen panes
- if pane := tree.path("sheetViews", "sheetView", "pane"); pane != nil && pane.attr("state") == "frozen" {
- fz := &FreezePane{}
- if x, err := strconv.Atoi(pane.attr("xSplit")); err == nil {
- fz.C = x
- }
- if y, err := strconv.Atoi(pane.attr("ySplit")); err == nil {
- fz.R = y
- }
- if fz.R > 0 || fz.C > 0 {
- ws.Freeze = fz
- }
- }
- // column widths
- if cols := tree.first("cols"); cols != nil {
- for _, c := range cols.all("col") {
- min, e1 := strconv.Atoi(c.attr("min"))
- max, e2 := strconv.Atoi(c.attr("max"))
- w, e3 := strconv.ParseFloat(c.attr("width"), 64)
- if e1 != nil || e2 != nil || e3 != nil {
- continue
- }
- if max-min > 64 {
- max = min + 64 // ignore column-range floods
- }
- for i := min; i <= max; i++ {
- ws.ColW[strconv.Itoa(i-1)] = float64(int(colCharsToPx(w)))
- }
- }
- }
- maxCol, maxRow := 0, 0
- if sd := tree.first("sheetData"); sd != nil {
- for _, row := range sd.all("row") {
- rIdx, err := strconv.Atoi(row.attr("r"))
- if err != nil {
- continue
- }
- if ht, err := strconv.ParseFloat(row.attr("ht"), 64); err == nil && row.attr("customHeight") == "1" {
- ws.RowH[strconv.Itoa(rIdx-1)] = float64(int(rowPtToPx(ht)))
- }
- for _, c := range row.all("c") {
- ref := c.attr("r")
- col, rw, ok := parseCellRef(ref)
- if !ok {
- continue
- }
- cell := parseXlsxCell(c, shared)
- var st *CellStyle
- if sIdx, err := strconv.Atoi(c.attr("s")); err == nil && sIdx >= 0 && sIdx < len(styleMap) {
- st = styleMap[sIdx].style
- }
- if cell == "" && st == nil {
- continue
- }
- wc := &WorkCell{V: cell}
- if st != nil {
- cp := *st
- wc.S = &cp
- }
- ws.Cells[cellRef(col, rw)] = wc
- if col > maxCol {
- maxCol = col
- }
- if rw > maxRow {
- maxRow = rw
- }
- }
- }
- }
- ws.Cols = maxCol + 6
- if ws.Cols < 26 {
- ws.Cols = 26
- }
- ws.Rows = maxRow + 21
- if ws.Rows < 200 {
- ws.Rows = 200
- }
- // merges
- if mc := tree.first("mergeCells"); mc != nil {
- for _, m := range mc.all("mergeCell") {
- if ref := m.attr("ref"); ref != "" {
- ws.Merges = append(ws.Merges, ref)
- }
- }
- }
- return ws
- }
- // parseXlsxCell extracts the raw editor value from a <c> element
- func parseXlsxCell(c *xnode, shared []string) string {
- // formulas win: the webapp recalculates them
- if f := c.first("f"); f != nil && strings.TrimSpace(f.Text) != "" {
- return "=" + f.Text
- }
- t := c.attr("t")
- switch t {
- case "inlineStr":
- if is := c.first("is"); is != nil {
- var texts []string
- collectText(is, &texts)
- return textAsRaw(strings.Join(texts, ""))
- }
- return ""
- case "s":
- if v := c.first("v"); v != nil {
- if idx, err := strconv.Atoi(strings.TrimSpace(v.Text)); err == nil && idx >= 0 && idx < len(shared) {
- return textAsRaw(shared[idx])
- }
- }
- return ""
- case "b":
- if v := c.first("v"); v != nil {
- if strings.TrimSpace(v.Text) == "1" {
- return "TRUE"
- }
- return "FALSE"
- }
- return ""
- case "str":
- if v := c.first("v"); v != nil {
- return textAsRaw(v.Text)
- }
- return ""
- default: // "n" or absent = number
- if v := c.first("v"); v != nil {
- return strings.TrimSpace(v.Text)
- }
- return ""
- }
- }
- // textAsRaw keeps string-typed values as strings in the editor: text that
- // would re-parse as a number/bool gets Excel's leading-quote escape
- func textAsRaw(s string) string {
- t := strings.TrimSpace(s)
- if t == "" {
- return s
- }
- up := strings.ToUpper(t)
- if looksNumeric(t) || up == "TRUE" || up == "FALSE" || strings.HasPrefix(t, "=") || strings.HasPrefix(t, "'") {
- return "'" + s
- }
- return s
- }
|