Reads workbook.xml, shared strings (one buffer), styles, and each worksheet a token at a time, expanding shared formulas. Bounds zip bombs, entry counts, nesting, token size, unsafe part names and references past Excel's limits. A differential test imports a corpus with excelize and the new reader and compares the results.
- commit
- 2d9d5cf90e74888334b00e07fd265d1c50a859bd
- parent
- e899946eda7e9f9ff682559197571ef5026d4838
- author
- Dami <dami.etoile@gmail.com>
| A | internal/ | 215 | +++++++++++++++++ |
| A | internal/ | 296 | +++++++++++++++++++++++ |
| A | internal/ | 286 | ++++++++++++++++++++++ |
| A | internal/ | 105 | ++++++++ |
| A | internal/ | 292 | +++++++++++++++++++++++ |
| A | internal/ | 268 | +++++++++++++++++++++ |
| A | internal/ | 385 | ++++++++++++++++++++++++++++++ |
| A | internal/ | 178 | ++++++++++++++ |
| A | internal/ | 223 | +++++++++++++++++ |
@@ -0,0 +1,215 @@
1+package fileio2+3+import (4+ "bytes"5+ "context"6+ "os"7+ "path/filepath"8+ "strings"9+ "testing"10+11+ "github.com/xuri/excelize/v2"12+13+ "github.com/FelineStateMachine/012/internal/sheet"14+ "github.com/FelineStateMachine/012/internal/stress"15+)16+17+// TestXLSXDifferential imports a corpus of workbooks with excelize (the18+// reader 012 used before its own) and with 012's streaming reader, and19+// checks the results are the same: every sheet's cells as saved (input,20+// format and style), column widths, named ranges, the sheet shown, the21+// rows counted and the notes. The corpus is workbooks written here, the22+// stress datasets' XLSX files when fetched (scripts/stress-data.sh), and23+// every .xlsx file in the directories listed in XLSX_CORPUS24+// (colon-separated), such as excelize's own test files.25+//26+// Known differences, where excelize's reading is wrong and 012's isn't,27+// are left out of the comparison (see compareXLSX).28+func TestXLSXDifferential(t *testing.T) {29+ for _, path := range xlsxCorpus(t) {30+ t.Run(filepath.Base(path), func(t *testing.T) {31+ want, errWant := importXLSX(context.Background(), path, Options{})32+ got, errGot := importXLSXStream(context.Background(), path, Options{})33+ if (errWant == nil) != (errGot == nil) {34+ t.Fatalf("excelize: %v; 012: %v", errWant, errGot)35+ }36+ if errWant != nil {37+ t.Logf("both fail: excelize: %v; 012: %v", errWant, errGot)38+ return39+ }40+ compareXLSX(t, path, want, got)41+ })42+ }43+}44+45+func xlsxCorpus(t *testing.T) []string {46+ dir := t.TempDir()47+ files := []string{writeParts(t, dir, "kitchen.xlsx", kitchenParts())}48+ files = append(files, writeExcelizeBook(t, dir), writeExportedBook(t, dir))49+ stressData := os.Getenv("STRESS_DIR")50+ if stressData == "" {51+ stressData = filepath.Join("..", "..", ".deps", "stress")52+ }53+ files = append(files, globXLSX(stressData)...)54+ for _, d := range filepath.SplitList(os.Getenv("XLSX_CORPUS")) {55+ files = append(files, globXLSX(d)...)56+ }57+ return files58+}59+60+func globXLSX(dir string) []string {61+ if dir == "" {62+ return nil63+ }64+ m, _ := filepath.Glob(filepath.Join(dir, "*.xlsx"))65+ return m66+}67+68+// writeExcelizeBook writes a workbook with excelize: sheets referring to69+// each other, styles, widths and names.70+func writeExcelizeBook(t *testing.T, dir string) string {71+ x := excelize.NewFile()72+ defer x.Close()73+ x.SetSheetName("Sheet1", "Q1")74+ x.NewSheet("Q2 plan")75+ x.SetSheetRow("Q1", "A1", &[]any{"Region", 1234.5, "=text", 45000, true, "12%", "TRUE", -0.5})76+ x.SetCellFormula("Q1", "A2", "'Q2 plan'!A1*2")77+ x.SetCellFormula("Q1", "B2", "SUM(B1,1)")78+ x.SetCellFormula("Q1", "C2", "CUBEVALUE(1)")79+ x.SetCellValue("Q1", "E4", "after a gap")80+ x.SetCellValue("Q2 plan", "A1", 42)81+ x.SetCellValue("Q2 plan", "B300", "far down")82+ bold, _ := x.NewStyle(&excelize.Style{NumFmt: 4, Font: &excelize.Font{Bold: true, Underline: "double"}})83+ date, _ := x.NewStyle(&excelize.Style{NumFmt: 15, Alignment: &excelize.Alignment{Horizontal: "right"}})84+ code := `[$$-409]#,##0.00`85+ money, _ := x.NewStyle(&excelize.Style{CustomNumFmt: &code, Font: &excelize.Font{Italic: true, Strike: true}})86+ x.SetCellStyle("Q1", "B1", "B1", bold)87+ x.SetCellStyle("Q1", "D1", "D1", date)88+ x.SetCellStyle("Q1", "A2", "C3", money)89+ x.SetColWidth("Q1", "A", "B", 14)90+ x.SetColWidth("Q2 plan", "C", "C", 30)91+ x.SetDefinedName(&excelize.DefinedName{Name: "Rent", RefersTo: "Q1!$B$1"})92+ x.SetDefinedName(&excelize.DefinedName{Name: "Mine", RefersTo: "Q1!$B$1", Scope: "Q2 plan"})93+ x.SetActiveSheet(1)94+ path := filepath.Join(dir, "excelize.xlsx")95+ if err := x.SaveAs(path); err != nil {96+ t.Fatal(err)97+ }98+ return path99+}100+101+// writeExportedBook writes a table and a sheet of formulas with 012's102+// exporter.103+func writeExportedBook(t *testing.T, dir string) string {104+ s := stress.Table(60, 10)105+ book := s.Book()106+ f, _ := book.AddSheet("Formulas", 1)107+ for row := range 40 {108+ f.Set(sheet.Addr{Col: 0, Row: row}, "=Sheet1!C"+itoa(row+2)+"*2")109+ f.Set(sheet.Addr{Col: 1, Row: row}, "=SUM($A$1:A"+itoa(row+1)+")")110+ }111+ f.SetColWidth(0, 20)112+ path := filepath.Join(dir, "exported.xlsx")113+ if _, err := Export(context.Background(), path, XLSX, SnapBook(f), ExportOptions{}); err != nil {114+ t.Fatal(err)115+ }116+ return path117+}118+119+// compareXLSX compares two imports of path.120+func compareXLSX(t *testing.T, path string, want, got *Result) {121+ t.Helper()122+ if want.Rows != got.Rows {123+ t.Errorf("rows %d, excelize %d", got.Rows, want.Rows)124+ }125+ if w, g := strings.Join(want.Notes, "; "), strings.Join(withoutHidden(got.Notes), "; "); w != g {126+ t.Errorf("notes %q, excelize %q", g, w)127+ }128+ if want.Sheet.Name() != got.Sheet.Name() {129+ t.Errorf("showing %s, excelize %s", got.Sheet.Name(), want.Sheet.Name())130+ }131+ skip := mergedCells(t, path)132+ w, g := savedLines(t, want.Sheet, skip), savedLines(t, got.Sheet, skip)133+ if w == g {134+ return135+ }136+ wl, gl := strings.Split(w, "\n"), strings.Split(g, "\n")137+ shown := 0138+ for i := 0; i < max(len(wl), len(gl)) && shown < 20; i++ {139+ var a, b string140+ if i < len(wl) {141+ a = wl[i]142+ }143+ if i < len(gl) {144+ b = gl[i]145+ }146+ if a != b && !sameSharedFormula(a, b) {147+ t.Errorf("line %d:\n 012: %s\n excelize: %s", i+1, b, a)148+ shown++149+ }150+ }151+}152+153+// sameSharedFormula reports whether two saved lines are the same formula154+// but for spaces. excelize expands a shared formula by parsing it and155+// writing it back, which drops the spaces between its tokens; 012 moves156+// the references in the text as written.157+func sameSharedFormula(a, b string) bool {158+ return strings.Contains(a, `": "=`) && strings.ReplaceAll(a, " ", "") == strings.ReplaceAll(b, " ", "")159+}160+161+func withoutHidden(notes []string) []string {162+ var out []string163+ for _, n := range notes {164+ if !strings.Contains(n, "hidden sheet") {165+ out = append(out, n)166+ }167+ }168+ return out169+}170+171+// savedLines is the workbook as 012 saves it, without the cells in skip172+// (by sheet), which excelize reads wrongly.173+func savedLines(t *testing.T, s *sheet.Sheet, skip map[string][]sheet.Rect) string {174+ t.Helper()175+ book := s.Book()176+ for i := range book.Len() {177+ ws := book.Sheet(i)178+ for _, r := range skip[ws.Name()] {179+ for _, a := range ws.Addrs() {180+ if r.Contains(a) && a != r.From {181+ ws.EraseRange(sheet.NewRect(a, a))182+ }183+ }184+ }185+ }186+ var buf bytes.Buffer187+ if err := s.Write(&buf); err != nil {188+ t.Fatal(err)189+ }190+ return buf.String()191+}192+193+// mergedCells are the merged ranges of each sheet. excelize reads any194+// cell of a merged range as its first cell, so a formula there spreads195+// to the others (when a cell after them in the row has a value); 012196+// reads each cell as written.197+func mergedCells(t *testing.T, path string) map[string][]sheet.Rect {198+ x, err := excelize.OpenFile(path)199+ if err != nil {200+ t.Fatal(err)201+ }202+ defer x.Close()203+ out := map[string][]sheet.Rect{}204+ for _, ws := range x.GetSheetList() {205+ merged, _ := x.GetMergeCells(ws)206+ for _, m := range merged {207+ if r, ok := sheet.ParseRange(m.GetStartAxis() + ":" + m.GetEndAxis()); ok {208+ out[ws] = append(out[ws], r)209+ }210+ }211+ }212+ return out213+}214+215+func itoa(n int) string { return numInput(float64(n)) }@@ -0,0 +1,296 @@
1+package fileio2+3+import (4+ "bytes"5+ "encoding/xml"6+ "fmt"7+ "io"8+ "path"9+ "strconv"10+)11+12+// xlsxBook is an open workbook: its sheets, names, shared strings and13+// cell styles. Worksheets are streamed one at a time by readSheet.14+type xlsxBook struct {15+ pkg *xlsxPackage16+ sheets []xlsxSheetInfo17+ active int // index of the sheet shown in Excel18+ names []xlsxName19+ date1904 bool20+ sst sharedStrings21+ styles xlsxStyles22+}23+24+// xlsxSheetInfo is a sheet as the workbook lists it.25+type xlsxSheetInfo struct {26+ name string27+ id int // sheetId28+ part string // the worksheet part, "" when missing29+ kind string // the relationship type: worksheet, chartsheet...30+ hidden bool // state hidden or veryHidden31+}32+33+// xlsxName is a defined name: sheet is the index of the sheet it is34+// scoped to, or -1 for the workbook.35+type xlsxName struct {36+ name, refersTo string37+ sheet int38+}39+40+// openXLSX opens a workbook and reads everything but its worksheets.41+func openXLSX(r io.ReaderAt, size int64, lim xlsxLimits) (*xlsxBook, error) {42+ p, err := openXLSXPackage(r, size, lim)43+ if err != nil {44+ return nil, err45+ }46+ bk := &xlsxBook{pkg: p}47+ wb := "xl/workbook.xml"48+ root, err := p.rels("")49+ if err != nil {50+ return nil, err51+ }52+ for _, rel := range root {53+ if rel.typ == "officeDocument" && p.has(rel.target) {54+ wb = rel.target55+ }56+ }57+ rels, err := p.rels(wb)58+ if err != nil {59+ return nil, err60+ }61+ ids, err := bk.readWorkbook(wb)62+ if err != nil {63+ return nil, err64+ }65+ dir := path.Dir(wb) + "/"66+ sst, styles := dir+"sharedStrings.xml", dir+"styles.xml"67+ for _, rel := range rels {68+ switch rel.typ {69+ case "sharedStrings":70+ sst = rel.target71+ case "styles":72+ styles = rel.target73+ }74+ }75+ for i, id := range ids {76+ if rel, ok := rels[id]; ok && p.has(rel.target) {77+ bk.sheets[i].part, bk.sheets[i].kind = rel.target, rel.typ78+ }79+ }80+ if err := bk.sst.read(p, sst); err != nil {81+ return nil, err82+ }83+ if err := bk.styles.read(p, styles); err != nil {84+ return nil, err85+ }86+ return bk, nil87+}88+89+// readWorkbook reads the workbook part: its sheets (returning their90+// relationship ids), the active tab, defined names and date system.91+func (bk *xlsxBook) readWorkbook(part string) (ids []string, err error) {92+ x, err := bk.pkg.open(part)93+ if err != nil {94+ return nil, err95+ }96+ if x == nil {97+ return nil, fmt.Errorf("not an Excel workbook: no %s", part)98+ }99+ defer x.close()100+ activeTab, views := 0, 0101+ for {102+ t, err := x.next()103+ if err == io.EOF {104+ break105+ }106+ if err != nil {107+ return nil, err108+ }109+ se, ok := t.(xml.StartElement)110+ if !ok {111+ continue112+ }113+ switch se.Name.Local {114+ case "sheet":115+ if len(bk.sheets) >= bk.pkg.lim.sheets {116+ return nil, fmt.Errorf("the workbook has more than %d sheets: %w", bk.pkg.lim.sheets, errXLSXLimit)117+ }118+ name, _ := attr(se, "name")119+ id, _ := attr(se, "sheetId")120+ state, _ := attr(se, "state")121+ n, _ := strconv.Atoi(id)122+ bk.sheets = append(bk.sheets, xlsxSheetInfo{name: name, id: n, hidden: state == "hidden" || state == "veryHidden"})123+ ids = append(ids, relAttr(se))124+ case "workbookView":125+ if views++; views == 1 {126+ v, _ := attr(se, "activeTab")127+ activeTab, _ = strconv.Atoi(v)128+ }129+ case "workbookPr":130+ v, _ := attr(se, "date1904")131+ bk.date1904 = v == "1" || v == "true"132+ case "definedName":133+ if err := bk.readName(x, se); err != nil {134+ return nil, err135+ }136+ }137+ }138+ bk.active = bk.activeIndex(activeTab)139+ return ids, nil140+}141+142+// activeIndex is the index of the sheet Excel showed, as excelize finds143+// it: the active tab's sheet by its sheetId, else the first.144+func (bk *xlsxBook) activeIndex(tab int) int {145+ if len(bk.sheets) == 0 {146+ return 0147+ }148+ id := bk.sheets[0].id149+ if tab >= 0 && tab < len(bk.sheets) && bk.sheets[tab].id != 0 {150+ id = bk.sheets[tab].id151+ }152+ for i, s := range bk.sheets {153+ if s.id == id {154+ return i155+ }156+ }157+ return 0158+}159+160+func (bk *xlsxBook) readName(x *xmlStream, se xml.StartElement) error {161+ if len(bk.names) >= bk.pkg.lim.styles {162+ return fmt.Errorf("the workbook has more than %d names: %w", bk.pkg.lim.styles, errXLSXLimit)163+ }164+ n := xlsxName{sheet: -1}165+ n.name, _ = attr(se, "name")166+ if v, ok := attr(se, "localSheetId"); ok {167+ if id, err := strconv.Atoi(v); err == nil && id >= 0 {168+ n.sheet = id169+ }170+ }171+ text, err := x.text(nil)172+ n.refersTo = string(text)173+ bk.names = append(bk.names, n)174+ return err175+}176+177+// sharedStrings holds the workbook's shared strings end to end in one178+// buffer, so a table of many short strings costs its text and four179+// bytes a string rather than a string header and allocation each.180+type sharedStrings struct {181+ buf []byte182+ ends []uint32183+}184+185+func (s *sharedStrings) get(i int) (string, bool) {186+ if i < 0 || i >= len(s.ends) {187+ return "", false188+ }189+ from := uint32(0)190+ if i > 0 {191+ from = s.ends[i-1]192+ }193+ return string(s.buf[from:s.ends[i]]), true194+}195+196+// read streams the shared strings part: each <si> is its <t> text, or197+// the text of its rich-text runs (<r><t>), leaving out phonetic hints.198+func (s *sharedStrings) read(p *xlsxPackage, part string) error {199+ x, err := p.open(part)200+ if x == nil || err != nil {201+ return err202+ }203+ defer x.close()204+ var item []byte205+ for {206+ t, err := x.next()207+ if err == io.EOF {208+ return nil209+ }210+ if err != nil {211+ return err212+ }213+ se, ok := t.(xml.StartElement)214+ if !ok || se.Name.Local != "si" {215+ continue216+ }217+ if item, err = inlineText(x, item[:0]); err != nil {218+ return err219+ }220+ if len(s.ends) >= p.lim.strings || len(s.buf)+len(item) > 1<<32-1 {221+ return fmt.Errorf("the workbook has more than %d shared strings: %w", p.lim.strings, errXLSXLimit)222+ }223+ s.buf = append(s.buf, unescapeOOXML(item)...)224+ s.ends = append(s.ends, uint32(len(s.buf)))225+ }226+}227+228+// inlineText reads the rest of an <si> or <is> element: the text of its229+// <t> children and of the <t> in its <r> runs, not in <rPh> hints.230+func inlineText(x *xmlStream, buf []byte) ([]byte, error) {231+ depth := x.depth232+ inT, skipDepth := false, 0233+ for {234+ t, err := x.next()235+ if err != nil {236+ return buf, eofAsUnexpected(err)237+ }238+ switch t := t.(type) {239+ case xml.StartElement:240+ switch {241+ case skipDepth > 0:242+ case t.Name.Local == "rPh" || t.Name.Local == "phoneticPr":243+ skipDepth = x.depth244+ case t.Name.Local == "t":245+ inT = true246+ }247+ case xml.EndElement:248+ if x.depth < depth {249+ return buf, nil250+ }251+ if skipDepth > 0 && x.depth < skipDepth {252+ skipDepth = 0253+ }254+ inT = false255+ case xml.CharData:256+ if inT && skipDepth == 0 {257+ buf = append(buf, t...)258+ }259+ }260+ }261+}262+263+// unescapeOOXML decodes _xHHHH_, how OOXML writes characters XML can't264+// hold (_x000D_ for a carriage return), and _x005F_, an escaped _.265+func unescapeOOXML(b []byte) []byte {266+ if !bytes.Contains(b, []byte("_x")) {267+ return b268+ }269+ out := make([]byte, 0, len(b))270+ for i := 0; i < len(b); {271+ if r, ok := ooxmlEscape(b[i:]); ok {272+ if r == '_' {273+ out = append(out, '_')274+ } else {275+ out = append(out, string(r)...)276+ }277+ i += 7278+ continue279+ }280+ out = append(out, b[i])281+ i++282+ }283+ return out284+}285+286+// ooxmlEscape decodes the _xHHHH_ at the start of b.287+func ooxmlEscape(b []byte) (rune, bool) {288+ if len(b) < 7 || b[0] != '_' || b[1] != 'x' || b[6] != '_' {289+ return 0, false290+ }291+ v, err := strconv.ParseUint(string(b[2:6]), 16, 16)292+ if err != nil {293+ return 0, false294+ }295+ return rune(v), true296+}@@ -0,0 +1,286 @@
1+package fileio2+3+import (4+ "context"5+ "fmt"6+ "math"7+ "os"8+ "slices"9+ "strconv"10+ "strings"11+ "time"12+13+ "github.com/FelineStateMachine/012/internal/sheet"14+)15+16+// importXLSXStream reads every sheet of a workbook, with its named17+// ranges, and returns the sheet that was active in Excel. Worksheets are18+// streamed a row at a time (see xlsxpkg.go).19+func importXLSXStream(ctx context.Context, name string, opt Options) (*Result, error) {20+ f, err := os.Open(name)21+ if err != nil {22+ return nil, err23+ }24+ defer f.Close()25+ st, err := f.Stat()26+ if err != nil {27+ return nil, err28+ }29+ bk, err := openXLSX(f, st.Size(), defaultXLSXLimits)30+ if err != nil {31+ return nil, err32+ }33+ return bk.importBook(ctx, opt.Progress)34+}35+36+func (bk *xlsxBook) importBook(ctx context.Context, prog *Progress) (*Result, error) {37+ if len(bk.sheets) == 0 {38+ return nil, fmt.Errorf("the workbook has no sheets")39+ }40+ // Rows across all sheets, for the progress bar.41+ total := 042+ for i := range bk.sheets {43+ total += bk.dimension(i)44+ }45+ b := newBuilder()46+ book := b.s.Book()47+ done := 048+ var err error49+ for i, info := range bk.sheets {50+ if i == 0 {51+ err = book.RenameSheet(b.s, info.name)52+ } else {53+ b.s, err = book.AddSheet(info.name, i)54+ }55+ if err != nil {56+ return nil, fmt.Errorf("sheet %s: %w", info.name, err)57+ }58+ rows, err := bk.importSheet(ctx, b, i, func(row int) {59+ prog.setRows(done + row)60+ prog.setFrac(int64(done+row), int64(total))61+ })62+ if err != nil {63+ return nil, err64+ }65+ done += rows66+ }67+ notes := bk.importNames(book)68+ notes = append(notes, bk.hiddenNote()...)69+ active := book.Sheet(clamp(bk.active, 0, book.Len()-1))70+ book.SetActive(active)71+ b.s = active72+ prog.setRows(done)73+ s, notes := b.finish(notes)74+ return &Result{Sheet: s, Rows: done, Notes: notes}, nil75+}76+77+// importSheet reads sheet i into b.s, reporting rows read, and returns78+// the number of its last row.79+func (bk *xlsxBook) importSheet(ctx context.Context, b *builder, i int, report func(int)) (int, error) {80+ r, err := bk.openSheet(i)81+ if err != nil {82+ return 0, err83+ }84+ defer r.close()85+ last := 086+ for n := 0; ; n++ {87+ ok, err := r.next()88+ if err != nil {89+ return 0, fmt.Errorf("sheet %s: %w", bk.sheets[i].name, err)90+ }91+ if !ok {92+ break93+ }94+ if n%128 == 0 {95+ if err := ctx.Err(); err != nil {96+ return 0, err97+ }98+ report(r.row.num)99+ }100+ bk.importRow(b, r)101+ last = max(last, r.row.num)102+ }103+ for c := range sheet.MaxCols {104+ w := r.colWidth(c + 1)105+ if math.Abs(w-excelDefaultWidth) < 0.01 || math.Abs(w-9.140625) < 0.01 || !(w > 0 && w < 1000) {106+ continue107+ }108+ b.s.SetColWidth(c, max(int(math.Round(w))+excelPadding, 1))109+ }110+ return last, nil111+}112+113+// importRow stores a row's cells. As Excel's cells are read, a cell114+// with neither a value nor a formula only counts for its format, and115+// only up to the row's last cell with one: formatted blanks after it are116+// left out.117+func (bk *xlsxBook) importRow(b *builder, r *xlsxSheetReader) {118+ row := &r.row119+ cells := row.cells120+ if !slices.IsSortedFunc(cells, func(a, b xlsxCell) int { return a.col - b.col }) {121+ slices.SortStableFunc(cells, func(a, b xlsxCell) int { return a.col - b.col })122+ }123+ lastCol := 0124+ for i := range cells {125+ if cells[i].kept() {126+ lastCol = cells[i].col127+ }128+ }129+ if lastCol == 0 || !b.fits(sheet.Addr{Col: lastCol - 1, Row: row.num - 1}) && row.num > sheet.MaxRows {130+ return131+ }132+ j := 0133+ for col := 1; col <= min(lastCol, sheet.MaxCols); col++ {134+ a := sheet.Addr{Col: col - 1, Row: row.num - 1}135+ for j < len(cells) && cells[j].col < col {136+ j++137+ }138+ var c *xlsxCell139+ id := 0140+ if j < len(cells) && cells[j].col == col {141+ c = &cells[j]142+ id = c.style143+ }144+ if id == 0 {145+ id = row.style146+ }147+ if id == 0 {148+ id = r.colStyle(col)149+ }150+ st := bk.styles.style(id)151+ if c == nil || !c.kept() {152+ b.put(a, "", st.format, st.style)153+ continue154+ }155+ bk.importCell(b, a, c, st)156+ }157+}158+159+func (bk *xlsxBook) importCell(b *builder, a sheet.Addr, c *xlsxCell, st xlsxStyle) {160+ if c.formula != "" {161+ b.formula(a, fromExcelFormula(c.formula), st.format, st.style, func() { bk.keep(b, a, c, st) })162+ return163+ }164+ if c.value == "" {165+ b.put(a, "", st.format, st.style)166+ return167+ }168+ bk.keep(b, a, c, st)169+}170+171+// keep stores a cell's value: its type says whether it is a boolean or172+// text; anything else that reads as a number is one.173+func (bk *xlsxBook) keep(b *builder, a sheet.Addr, c *xlsxCell, st xlsxStyle) {174+ raw := c.value175+ switch c.typ {176+ case "b":177+ b.boolean(a, raw == "1" || strings.EqualFold(raw, "TRUE"), st.style)178+ return179+ case "s", "inlineStr", "str", "e":180+ b.text(a, raw, st.format, st.style)181+ return182+ case "d":183+ if v, f, ok := isoDate(raw); ok {184+ if st.format.IsZero() {185+ st.format = f186+ }187+ b.number(a, v, st.format, st.style)188+ return189+ }190+ }191+ if v, err := strconv.ParseFloat(raw, 64); err == nil {192+ b.number(a, bk.serial(v, st.format), st.format, st.style)193+ return194+ }195+ b.text(a, raw, st.format, st.style)196+}197+198+// serial converts an Excel serial number to 012's: in the 1904 date199+// system, day 0 is January 1, 1904.200+func (bk *xlsxBook) serial(v float64, f sheet.Format) float64 {201+ if bk.date1904 && isDateFormat(f) {202+ return v + date1904Offset203+ }204+ return fromExcelSerial(v, f)205+}206+207+// date1904Offset is the serial of January 1, 1904 in 012's (and the 1900208+// system's) count.209+const date1904Offset = 1462210+211+// isoDate reads a t="d" cell's ISO 8601 date, time or both as 012's212+// serial number, with the format that shows it.213+func isoDate(s string) (float64, sheet.Format, bool) {214+ s = strings.TrimSpace(s)215+ for _, l := range []struct {216+ layout string217+ kind sheet.FormatKind218+ }{219+ {"2006-01-02T15:04:05.999999999Z07:00", sheet.FmtDateTime},220+ {"2006-01-02T15:04:05.999999999", sheet.FmtDateTime},221+ {"2006-01-02", sheet.FmtDate},222+ {"T15:04:05.999999999", sheet.FmtTime},223+ {"15:04:05.999999999", sheet.FmtTime},224+ } {225+ t, err := time.Parse(l.layout, s)226+ if err != nil {227+ continue228+ }229+ wall := time.Date(t.Year(), t.Month(), t.Day(), t.Hour(), t.Minute(), t.Second(), t.Nanosecond(), time.UTC)230+ base := time.Date(1899, 12, 30, 0, 0, 0, 0, time.UTC)231+ if l.kind == sheet.FmtTime {232+ base = time.Date(0, 1, 1, 0, 0, 0, 0, time.UTC)233+ }234+ return wall.Sub(base).Hours() / 24, sheet.Format{Kind: l.kind}, true235+ }236+ return 0, sheet.Format{}, false237+}238+239+// importNames defines the workbook's named ranges that are a range on240+// one sheet, such as Sales = Q3!$B$2:$B$20, and notes the others (sheet241+// scoped names, constants, formulas).242+func (bk *xlsxBook) importNames(book *sheet.Workbook) []string {243+ skipped := 0244+ example := ""245+ for _, dn := range bk.names {246+ if strings.HasPrefix(dn.name, "_xlnm.") {247+ continue // print areas and the like248+ }249+ ws, rest := sheet.SplitSheet(strings.TrimPrefix(dn.refersTo, "="))250+ r, ok := sheet.ParseRange(rest)251+ s := book.Lookup(ws)252+ if bk.scoped(dn) || !ok || s == nil || book.DefineName(dn.name, s, r) != nil {253+ skipped++254+ if example == "" {255+ example = dn.name256+ }257+ }258+ }259+ if skipped > 0 {260+ return []string{fmt.Sprintf("%s left out, e.g. %s", count(skipped, "named range", "named ranges"), example)}261+ }262+ return nil263+}264+265+// scoped reports whether a name belongs to one sheet. As excelize has266+// it, a name scoped to a sheet that doesn't exist, or to one called267+// Workbook, is the workbook's.268+func (bk *xlsxBook) scoped(dn xlsxName) bool {269+ return dn.sheet >= 0 && dn.sheet < len(bk.sheets) && bk.sheets[dn.sheet].name != "Workbook"270+}271+272+// hiddenNote says which sheets Excel hid: 012 shows every sheet.273+func (bk *xlsxBook) hiddenNote() []string {274+ n, example := 0, ""275+ for _, s := range bk.sheets {276+ if s.hidden {277+ if n++; n == 1 {278+ example = s.name279+ }280+ }281+ }282+ if n == 0 {283+ return nil284+ }285+ return []string{fmt.Sprintf("%s shown, e.g. %s", count(n, "hidden sheet", "hidden sheets"), example)}286+}@@ -0,0 +1,105 @@
1+package fileio2+3+import (4+ "archive/zip"5+ "bytes"6+ "maps"7+ "os"8+ "path/filepath"9+ "slices"10+ "testing"11+)12+13+// Hand-written SpreadsheetML for tests of the XLSX reader: what Excel14+// writes and excelize doesn't (shared formulas, inline and rich text,15+// row and column styles, cells without references).16+17+const (18+ contentTypes = `<?xml version="1.0" encoding="UTF-8"?><Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="xml" ContentType="application/xml"/><Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/><Override PartName="/xl/worksheets/sheet1.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/><Override PartName="/xl/worksheets/sheet2.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/><Override PartName="/xl/worksheets/sheet3.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/><Override PartName="/xl/sharedStrings.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sharedStrings+xml"/><Override PartName="/xl/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml"/></Types>`19+ rootRels = `<?xml version="1.0" encoding="UTF-8"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="xl/workbook.xml"/></Relationships>`20+ bookRels = `<?xml version="1.0" encoding="UTF-8"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet1.xml"/><Relationship Id="rId2" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="/xl/worksheets/sheet2.xml"/><Relationship Id="rId3" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet3.xml"/><Relationship Id="rId4" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/sharedStrings" Target="sharedStrings.xml"/><Relationship Id="rId5" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/></Relationships>`21+ mainNS = `xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"`22+23+ kitchenBook = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>24+<workbook ` + mainNS + `><workbookPr defaultThemeVersion="124226"/><bookViews><workbookView activeTab="2"/></bookViews><sheets><sheet name="Data" sheetId="1" r:id="rId1"/><sheet name="Hidden" sheetId="2" state="hidden" r:id="rId2"/><sheet name="Refs" sheetId="5" r:id="rId3"/></sheets><definedNames><definedName name="_xlnm.Print_Area" localSheetId="0">Data!$A$1:$F$8</definedName><definedName name="Total">Data!$A$2:$B$2</definedName><definedName name="Local" localSheetId="0">Data!$A$1</definedName><definedName name="Const">42</definedName></definedNames><calcPr calcId="152511"/></workbook>`25+26+ kitchenStrings = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>27+<sst ` + mainNS + ` count="6" uniqueCount="6"><si><t>Region</t></si><si><r><rPr><b/></rPr><t xml:space="preserve">Rich </t></r><r><t>text</t></r><rPh sb="0" eb="1"><t>hint</t></rPh></si><si><t>line_x000D_break _x005F_x0041_ tab	end</t></si><si><t>=not a formula</t></si><si><t>123</t></si><si><t/></si></sst>`28+29+ kitchenStyles = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>30+<styleSheet ` + mainNS + `><numFmts count="2"><numFmt numFmtId="164" formatCode="0.000"/><numFmt numFmtId="165" formatCode="yyyy\-mm\-dd"/></numFmts><fonts count="5"><font><sz val="11"/></font><font><b/><sz val="11"/></font><font><i/><u/></font><font><strike/><u val="none"/></font><font><b val="0"/></font></fonts><cellStyleXfs count="1"><xf numFmtId="0" fontId="1"/></cellStyleXfs><cellXfs count="9"><xf numFmtId="0" fontId="0"/><xf numFmtId="4" fontId="1" applyFont="1" applyNumberFormat="1"/><xf numFmtId="164" fontId="0"/><xf numFmtId="165" fontId="0"/><xf numFmtId="0" fontId="2"><alignment horizontal="center"/></xf><xf numFmtId="10" fontId="3" applyAlignment="0"><alignment horizontal="right"/></xf><xf numFmtId="5" fontId="0"/><xf numFmtId="0" fontId="4"><alignment horizontal="centerContinuous"/></xf><xf numFmtId="14" fontId="1" applyFont="0"><alignment horizontal="left"/></xf></cellXfs></styleSheet>`31+32+ kitchenData = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>33+<worksheet ` + mainNS + `><dimension ref="A1:F9"/><sheetFormatPr defaultColWidth="10" defaultRowHeight="15"/><cols><col min="1" max="1" width="20" customWidth="1"/><col min="3" max="5" width="5" style="2" customWidth="1"/><col min="6" max="6" style="4"/><col min="5" max="5" width="30"/></cols><sheetData>` +34+ `<row r="1"><c r="A1" t="s"><v>0</v></c><c r="B1" t="s"><v>1</v></c><c r="C1" t="inlineStr"><is><t>inline</t></is></c><c r="D1" t="b"><v>1</v></c><c r="E1" t="e"><v>#N/A</v></c><c r="F1" t="str"><f>A1&"x"</f><v>Regionx</v></c></row>` +35+ `<row r="2" s="3" customFormat="1"><c><v>1.5</v></c><c s="2"><v>2</v></c><c r="E2" s="8"><v>45000</v></c></row>` +36+ `<row r="3"><c r="A3"><f>SUM(A2:B2)</f><v>3.5</v></c><c r="B3"><f t="shared" ref="B3:D4" si="0">A3*2+$A$2+A$2+$A2+SUM(A1:A2)+"A1"</f><v>1</v></c><c r="C3"><f t="shared" si="0"/><v>2</v></c><c r="D3"><f t="shared" si="0"/></c></row>` +37+ `<row r="4"><c r="B4"><f t="shared" si="0"/><v>4</v></c><c r="C4"><f t="shared" si="7"/><v>9</v></c></row>` +38+ `<row r="5"><c r="A5" s="1"><v>10</v></c><c r="E5" s="5" t="s"><v>3</v></c><c r="F5" s="4"/></row>` +39+ `<row r="6"><c r="A6" s="7"/><c r="B6" t="s"><v> 4 </v></c><c r="C6" s="6"><v>12</v></c></row>` +40+ `<row r="7"><c r="A7"><f>CUBEVALUE(1)</f><v>7</v></c><c r="B7" t="str"><f>_xlfn.CONCAT(A1,"a")</f><v>Regiona</v></c><c r="C7" t="b"><f>CUBESET(1)</f><v>0</v></c><c r="D7" t="e"><f>CUBEMEMBER(1)</f><v>#REF!</v></c><c r="E7" t="s"><f>CUBEKPI(1)</f><v>0</v></c></row>` +41+ `<row r="8"><c r="A8" t="n"><v>1E+300</v></c><c r="B8"><v> 12</v></c><c r="C8"><v>abc</v></c><c r="D8" t="s"><v>99</v></c><c r="E8"><v>5</v></c><c r="F8" t="s"><v>5</v></c></row>` +42+ `<row r="9"><c r="B9" t="inlineStr"><is><r><t>in</t></r><r><t>_x0041_</t></r></is></c><c r="C9" t="b"><v>TRUE</v></c><c r="D9" s="8"><v>30</v></c><c r="E9" t="x"><v>8</v></c></row>` +43+ `</sheetData><mergeCells count="1"><mergeCell ref="E3:F3"/></mergeCells></worksheet>`44+45+ kitchenHidden = `<?xml version="1.0" encoding="UTF-8"?><worksheet ` + mainNS + `><sheetData><row><c><v>1</v></c></row><row><c/><c t="s"><v>0</v></c></row></sheetData></worksheet>`46+47+ kitchenRefs = `<?xml version="1.0" encoding="UTF-8"?><worksheet ` + mainNS + `><dimension ref="A1"/><sheetFormatPr baseColWidth="12"/><sheetData><row r="1"><c r="A1"><v>42</v></c><c r="B1"><f>Data!A5*2+SUM(Total)</f><v>84</v></c><c r="C1"><f>SUM(Hidden!A1:A2)</f></c></row></sheetData></worksheet>`48+)49+50+// kitchenParts is a workbook of three sheets using most of what the51+// reader reads.52+func kitchenParts() map[string]string {53+ return map[string]string{54+ "[Content_Types].xml": contentTypes,55+ "_rels/.rels": rootRels,56+ "xl/workbook.xml": kitchenBook,57+ "xl/_rels/workbook.xml.rels": bookRels,58+ "xl/sharedStrings.xml": kitchenStrings,59+ "xl/styles.xml": kitchenStyles,60+ "xl/worksheets/sheet1.xml": kitchenData,61+ "xl/worksheets/sheet2.xml": kitchenHidden,62+ "xl/worksheets/sheet3.xml": kitchenRefs,63+ }64+}65+66+// oneSheetParts is a workbook of one sheet with the given sheetData67+// content, and the workbook's properties.68+func oneSheetParts(workbookPr, sheetData string) map[string]string {69+ p := kitchenParts()70+ p["xl/workbook.xml"] = `<workbook ` + mainNS + `>` + workbookPr + `<sheets><sheet name="S" sheetId="1" r:id="rId1"/></sheets></workbook>`71+ p["xl/worksheets/sheet1.xml"] = `<worksheet ` + mainNS + `><sheetData>` + sheetData + `</sheetData></worksheet>`72+ delete(p, "xl/worksheets/sheet2.xml")73+ delete(p, "xl/worksheets/sheet3.xml")74+ return p75+}76+77+// zipParts packs parts into an XLSX file's bytes, in name order.78+func zipParts(t testing.TB, parts map[string]string) []byte {79+ t.Helper()80+ var buf bytes.Buffer81+ zw := zip.NewWriter(&buf)82+ for _, name := range slices.Sorted(maps.Keys(parts)) {83+ w, err := zw.Create(name)84+ if err != nil {85+ t.Fatal(err)86+ }87+ if _, err := w.Write([]byte(parts[name])); err != nil {88+ t.Fatal(err)89+ }90+ }91+ if err := zw.Close(); err != nil {92+ t.Fatal(err)93+ }94+ return buf.Bytes()95+}96+97+// writeParts writes parts as an XLSX file in dir and returns its path.98+func writeParts(t testing.TB, dir, name string, parts map[string]string) string {99+ t.Helper()100+ path := filepath.Join(dir, name)101+ if err := os.WriteFile(path, zipParts(t, parts), 0o644); err != nil {102+ t.Fatal(err)103+ }104+ return path105+}@@ -0,0 +1,292 @@
1+package fileio2+3+import (4+ "archive/zip"5+ "encoding/xml"6+ "errors"7+ "fmt"8+ "io"9+ "io/fs"10+ "path"11+ "strings"12+)13+14+// An XLSX file is a zip of XML parts (SpreadsheetML in an Open Packaging15+// Conventions package). 012 reads it with archive/zip and encoding/xml,16+// streaming each part a token at a time, so a worksheet is never held in17+// memory. The file is untrusted: xlsxLimits bound what it can make the18+// reader do.19+20+// xlsxLimits bound the work a file can cause. encoding/xml expands no21+// external or custom entities, so entity bombs can't happen; these cap22+// zip bombs, deep nesting and huge single tokens.23+type xlsxLimits struct {24+ entries int // files in the zip25+ part int64 // uncompressed bytes of one part26+ total int64 // uncompressed bytes of all parts read27+ ratio int64 // uncompressed / compressed, past slack bytes28+ slack int6429+ depth int // XML nesting30+ token int64 // bytes of one XML token (a tag or a run of text)31+ strings int // shared strings32+ styles int // cell formats, fonts and number formats, each33+ sheets int34+}35+36+var defaultXLSXLimits = xlsxLimits{37+ entries: 10_000,38+ part: 1 << 30,39+ total: 2 << 30,40+ ratio: 250,41+ slack: 16 << 20,42+ depth: 256,43+ token: 32 << 20,44+ strings: 1 << 24,45+ styles: 1 << 16,46+ sheets: 4096,47+}48+49+// errXLSXLimit is wrapped by every error for a file past a limit.50+var errXLSXLimit = errors.New("past the reader's limits")51+52+// xlsxPackage is an open XLSX zip: its parts by lower-case name (part53+// names are case-insensitive) and what has been read of them so far.54+type xlsxPackage struct {55+ parts map[string]*zip.File56+ lim xlsxLimits57+ read int64 // uncompressed bytes read, all parts58+}59+60+func openXLSXPackage(r io.ReaderAt, size int64, lim xlsxLimits) (*xlsxPackage, error) {61+ zr, err := zip.NewReader(r, size)62+ if err != nil && !errors.Is(err, zip.ErrInsecurePath) {63+ return nil, fmt.Errorf("not an Excel workbook, or an encrypted one (%w)", err)64+ }65+ if len(zr.File) > lim.entries {66+ return nil, fmt.Errorf("the workbook holds %d files, more than %d: %w", len(zr.File), lim.entries, errXLSXLimit)67+ }68+ p := &xlsxPackage{parts: make(map[string]*zip.File, len(zr.File)), lim: lim}69+ for _, f := range zr.File {70+ name := f.Name71+ if strings.HasSuffix(name, "/") {72+ continue // a directory73+ }74+ if !fs.ValidPath(name) || strings.ContainsRune(name, '\\') {75+ return nil, fmt.Errorf("the workbook holds a file with an unsafe name, %q", name)76+ }77+ key := strings.ToLower(name)78+ if _, dup := p.parts[key]; dup {79+ return nil, fmt.Errorf("the workbook holds %s twice", name)80+ }81+ p.parts[key] = f82+ }83+ return p, nil84+}85+86+// has reports whether the package holds a part.87+func (p *xlsxPackage) has(name string) bool {88+ _, ok := p.parts[strings.ToLower(name)]89+ return ok90+}91+92+// open starts streaming a part's XML, or returns nil, nil when there is93+// no such part.94+func (p *xlsxPackage) open(name string) (*xmlStream, error) {95+ f, ok := p.parts[strings.ToLower(name)]96+ if !ok {97+ return nil, nil98+ }99+ rc, err := f.Open()100+ if err != nil {101+ return nil, fmt.Errorf("%s: %w", name, err)102+ }103+ g := &guardReader{r: rc, c: rc, p: p, name: name, comp: int64(f.CompressedSize64)}104+ d := xml.NewDecoder(g)105+ return &xmlStream{d: d, g: g, name: name, maxDepth: p.lim.depth}, nil106+}107+108+// guardReader counts a part's uncompressed bytes as they are read and109+// fails past the limits, so a zip bomb stops early whatever its headers110+// claim.111+type guardReader struct {112+ r io.Reader113+ c io.Closer114+ p *xlsxPackage115+ name string116+ comp int64 // compressed size117+ n int64 // uncompressed bytes read118+ tok int64 // n when the current XML token began119+}120+121+func (g *guardReader) Read(b []byte) (int, error) {122+ k, err := g.r.Read(b)123+ g.n += int64(k)124+ g.p.read += int64(k)125+ lim := g.p.lim126+ switch {127+ case g.n > lim.part:128+ return k, fmt.Errorf("%s unpacks to more than %s: %w", g.name, mb(lim.part), errXLSXLimit)129+ case g.p.read > lim.total:130+ return k, fmt.Errorf("the workbook unpacks to more than %s: %w", mb(lim.total), errXLSXLimit)131+ case g.n > lim.slack && g.n/max(g.comp, 1) > lim.ratio:132+ return k, fmt.Errorf("%s is compressed over %d to 1, like a zip bomb: %w", g.name, lim.ratio, errXLSXLimit)133+ case g.n-g.tok > lim.token:134+ return k, fmt.Errorf("%s has a tag or text over %s: %w", g.name, mb(lim.token), errXLSXLimit)135+ }136+ return k, err137+}138+139+func mb(n int64) string { return fmt.Sprintf("%d MB", n>>20) }140+141+// xmlStream is a part's XML as tokens, checking nesting depth and the142+// size of each token.143+type xmlStream struct {144+ d *xml.Decoder145+ g *guardReader146+ name string147+ depth int148+ maxDepth int149+}150+151+// next returns the next token, or io.EOF at the end of the part. Char152+// data is only valid until the following call.153+func (x *xmlStream) next() (xml.Token, error) {154+ x.g.tok = x.g.n155+ t, err := x.d.Token()156+ if err != nil {157+ if err == io.EOF {158+ return nil, err159+ }160+ return nil, fmt.Errorf("%s: %w", x.name, err)161+ }162+ switch t.(type) {163+ case xml.StartElement:164+ x.depth++165+ if x.depth > x.maxDepth {166+ return nil, fmt.Errorf("%s nests elements more than %d deep: %w", x.name, x.maxDepth, errXLSXLimit)167+ }168+ case xml.EndElement:169+ x.depth--170+ }171+ return t, nil172+}173+174+// skip passes over the rest of the element just started.175+func (x *xmlStream) skip() error {176+ for depth := x.depth; ; {177+ t, err := x.next()178+ if err != nil {179+ return eofAsUnexpected(err)180+ }181+ if _, ok := t.(xml.EndElement); ok && x.depth < depth {182+ return nil183+ }184+ }185+}186+187+// text returns the text of the element just started, up to its end.188+func (x *xmlStream) text(buf []byte) ([]byte, error) {189+ for depth := x.depth; ; {190+ t, err := x.next()191+ if err != nil {192+ return buf, eofAsUnexpected(err)193+ }194+ switch t := t.(type) {195+ case xml.CharData:196+ buf = append(buf, t...)197+ case xml.EndElement:198+ if x.depth < depth {199+ return buf, nil200+ }201+ }202+ }203+}204+205+func (x *xmlStream) close() error { return x.g.c.Close() }206+207+func eofAsUnexpected(err error) error {208+ if err == io.EOF {209+ return io.ErrUnexpectedEOF210+ }211+ return err212+}213+214+// attr returns the value of the attribute with local name name.215+func attr(se xml.StartElement, name string) (string, bool) {216+ for _, a := range se.Attr {217+ if a.Name.Local == name {218+ return a.Value, true219+ }220+ }221+ return "", false222+}223+224+// relAttr returns the r:id of an element: the id attribute in the225+// relationships namespace (transitional or strict).226+func relAttr(se xml.StartElement) string {227+ for _, a := range se.Attr {228+ if a.Name.Local == "id" && strings.HasSuffix(a.Name.Space, "/relationships") {229+ return a.Value230+ }231+ }232+ return ""233+}234+235+// xlsxRel is a relationship from a part to another.236+type xlsxRel struct {237+ typ string // the last element of the type URI, e.g. "worksheet"238+ target string // the part's name, resolved239+}240+241+// rels reads the relationships of part (from its _rels part), resolving242+// targets to part names. Targets outside the package are left out.243+func (p *xlsxPackage) rels(part string) (map[string]xlsxRel, error) {244+ dir, base := path.Split(part)245+ x, err := p.open(dir + "_rels/" + base + ".rels")246+ if x == nil || err != nil {247+ return nil, err248+ }249+ defer x.close()250+ out := map[string]xlsxRel{}251+ for {252+ t, err := x.next()253+ if err == io.EOF {254+ return out, nil255+ }256+ if err != nil {257+ return nil, err258+ }259+ se, ok := t.(xml.StartElement)260+ if !ok || se.Name.Local != "Relationship" {261+ continue262+ }263+ if mode, _ := attr(se, "TargetMode"); strings.EqualFold(mode, "External") {264+ continue265+ }266+ id, _ := attr(se, "Id")267+ typ, _ := attr(se, "Type")268+ target, _ := attr(se, "Target")269+ if name, ok := resolvePart(dir, target); ok && len(out) < p.lim.entries {270+ out[id] = xlsxRel{typ: typ[strings.LastIndexByte(typ, '/')+1:], target: name}271+ }272+ }273+}274+275+// resolvePart resolves a relationship's target against the directory of276+// its source part: /xl/x.xml is from the root, x.xml from dir. A target277+// that climbs out of the package is refused.278+func resolvePart(dir, target string) (string, bool) {279+ if target == "" {280+ return "", false281+ }282+ if strings.HasPrefix(target, "/") {283+ target = target[1:]284+ } else {285+ target = dir + target286+ }287+ name := path.Clean(target)288+ if !fs.ValidPath(name) || name == "." {289+ return "", false290+ }291+ return name, true292+}@@ -0,0 +1,268 @@
1+package fileio2+3+import (4+ "bytes"5+ "context"6+ "errors"7+ "strings"8+ "testing"9+10+ "github.com/FelineStateMachine/012/internal/sheet"11+)12+13+// readParts imports a workbook of parts with the given limits.14+func readParts(t testing.TB, parts map[string]string, lim xlsxLimits) (*Result, error) {15+ t.Helper()16+ data := zipParts(t, parts)17+ bk, err := openXLSX(bytes.NewReader(data), int64(len(data)), lim)18+ if err != nil {19+ return nil, err20+ }21+ return bk.importBook(context.Background(), nil)22+}23+24+func TestXLSXReader(t *testing.T) {25+ res, err := readParts(t, kitchenParts(), defaultXLSXLimits)26+ if err != nil {27+ t.Fatal(err)28+ }29+ book := res.Sheet.Book()30+ if book.Len() != 3 || res.Sheet.Name() != "Refs" || res.Rows != 12 {31+ t.Fatalf("%d sheets, showing %s, %d rows", book.Len(), res.Sheet.Name(), res.Rows)32+ }33+ data := book.Sheet(0)34+ for cell, want := range map[string]string{35+ "A1": "Region", "B1": "Rich text", "C1": "inline", "D1": "TRUE", "E1": "#N/A", "F1": `=A1&"x"`,36+ "A3": "=SUM(A2:B2)",37+ "B3": `=A3*2+$A$2+A$2+$A2+SUM(A1:A2)+"A1"`,38+ "C3": `=B3*2+$A$2+B$2+$A2+SUM(B1:B2)+"A1"`,39+ "D3": `=C3*2+$A$2+C$2+$A2+SUM(C1:C2)+"A1"`,40+ "B4": `=A4*2+$A$2+A$2+$A3+SUM(A2:A3)+"A1"`,41+ "C4": "9", "E5": "'=not a formula", "B6": "'123", "C6": "12",42+ "A7": "7", "B7": `=CONCAT(A1,"a")`, "C7": "FALSE", "D7": "#REF!", "E7": "Region",43+ "A8": "1E+300", "B8": "' 12", "C8": "abc", "D8": "", "E8": "5",44+ "B9": "inA", "C9": "TRUE", "E9": "8",45+ } {46+ if g := input(data, addr(t, cell)); g != want {47+ t.Errorf("Data!%s input %q, want %q", cell, g, want)48+ }49+ }50+ if g := input(data, addr(t, "A1")); g != "Region" {51+ t.Errorf("A1 %q", g)52+ }53+ for _, cell := range []string{"C2", "D8", "A6"} {54+ if data.Cell(addr(t, cell)) == nil {55+ t.Errorf("formatted blank %s left out", cell)56+ }57+ }58+ for _, cell := range []string{"F5", "F8"} {59+ if c := data.Cell(addr(t, cell)); c != nil {60+ t.Errorf("%s kept: %+v", cell, c)61+ }62+ }63+ checkKitchenStyles(t, data)64+ for col, want := range map[int]int{0: 21, 1: 11, 2: 6, 3: 6, 4: 31, 5: 11, 200: 11} {65+ if w := data.ColWidth(col); w != want {66+ t.Errorf("Data column %d width %d, want %d", col, w, want)67+ }68+ }69+ if w := book.Sheet(2).ColWidth(7); w != 13 {70+ t.Errorf("Refs width %d", w)71+ }72+ if g := input(book.Sheet(1), addr(t, "B2")); g != "Region" {73+ t.Errorf("Hidden!B2 %q", g)74+ }75+ if g := shown(book.Sheet(2), addr(t, "B1")); g != "24.50" {76+ t.Errorf("Refs!B1 shows %q", g) // Data!A5*2 + SUM(Total), A2 a date: 2.577+ }78+ notes := strings.Join(res.Notes, "; ")79+ for _, want := range []string{80+ "2 named ranges left out, e.g. Local", "1 hidden sheet shown, e.g. Hidden",81+ "4 formulas kept as values, e.g. A7 =CUBEVALUE(1)",82+ } {83+ if !strings.Contains(notes, want) {84+ t.Errorf("notes %q, want %q", notes, want)85+ }86+ }87+}88+89+func checkKitchenStyles(t *testing.T, data *sheet.Sheet) {90+ t.Helper()91+ cell := func(s string) sheet.Cell {92+ if c := data.Cell(addr(t, s)); c != nil {93+ return *c94+ }95+ return sheet.Cell{}96+ }97+ if f := cell("A2").Format; f.Kind != sheet.FmtDate { // the row's style98+ t.Errorf("A2 format %+v", f)99+ }100+ if f := cell("B2").Format; f != (sheet.Format{Kind: sheet.FmtCustom, Pattern: "0.000"}) {101+ t.Errorf("B2 format %+v", f)102+ }103+ if c := cell("E2"); c.Format.Kind != sheet.FmtDate || c.Style.Bold || c.Style.Align != sheet.AlignLeft {104+ t.Errorf("E2 %+v %+v (applyFont=0 isn't bold)", c.Format, c.Style)105+ }106+ if c := cell("A5"); !c.Style.Bold || c.Format != (sheet.Format{Kind: sheet.FmtNumber, Decimals: 2}) {107+ t.Errorf("A5 %+v %+v", c.Format, c.Style)108+ }109+ if st := cell("E5").Style; !st.Strikethrough || st.Underline || st.Align != sheet.AlignAuto {110+ t.Errorf("E5 style %+v", st)111+ }112+ if st := cell("A6").Style; st.Bold || st.Align != sheet.AlignCenter {113+ t.Errorf("A6 style %+v", st)114+ }115+ if f := cell("C6").Format; !f.IsZero() {116+ t.Errorf("C6 format %+v (id 5 is no built-in to excelize)", f)117+ }118+ if c := cell("D9"); c.Format.Kind != sheet.FmtDate || shown(data, addr(t, "D9")) != "1/30/1900" {119+ t.Errorf("D9 %+v shows %s", c.Format, shown(data, addr(t, "D9")))120+ }121+}122+123+func TestShiftFormula(t *testing.T) {124+ for _, tc := range []struct {125+ in string126+ dCol, dRow int127+ want string128+ }{129+ {"A1+B2", 1, 1, "B2+C3"},130+ {"$A$1+$A1+A$1", 2, 3, "$A$1+$A4+C$1"},131+ {"SUM(A1:B2)*LOG10(A1)", 0, 1, "SUM(A2:B3)*LOG10(A2)"},132+ {"Sheet2!A1+'My sheet'!B$2", 1, 1, "Sheet2!B2+'My sheet'!C$2"},133+ {"SUM(A:A,1:1,$B:$B,$2:$2)", 1, 1, "SUM(B:B,2:2,$B:$B,$2:$2)"},134+ {`"A1"&A1&Total&TRUE&1E+5&0.5`, 1, 0, `"A1"&B1&Total&TRUE&1E+5&0.5`},135+ {"A1-1", -1, 0, "#REF!-1"},136+ {"XFD1+A1048576", 1, 1, "#REF!+#REF!"},137+ {"Table1[[#This Row],[A1]]+A1", 1, 0, "Table1[[#This Row],[A1]]+B1"},138+ {"_xlfn.XLOOKUP(A1,B:B,C:C)", 0, 5, "_xlfn.XLOOKUP(A6,B:B,C:C)"},139+ {"#REF!+A1", 0, 1, "#REF!+A2"},140+ {"A1 B1", 25, 0, "Z1 AA1"},141+ {"ZZ1", 1, 0, "AAA1"},142+ } {143+ if got := shiftFormula(tc.in, tc.dCol, tc.dRow); got != tc.want {144+ t.Errorf("shiftFormula(%q, %d, %d) = %q, want %q", tc.in, tc.dCol, tc.dRow, got, tc.want)145+ }146+ }147+}148+149+// Things excelize reads wrongly or not at all: shared formulas referring150+// to another sheet move, the 1904 date system, t="d" ISO dates, and151+// columns past the sheet's edge are all counted.152+func TestXLSXBeyondExcelize(t *testing.T) {153+ parts := oneSheetParts(`<workbookPr date1904="1"/>`,154+ `<row r="1"><c r="A1"><v>1</v></c><c r="B1"><f t="shared" ref="B1:B2" si="0">Other!A1+A1</f></c><c r="C1" s="8"><v>0</v></c><c r="D1" t="d"><v>2026-09-26T14:30:00</v></c><c r="E1" t="d"><v>2026-09-26</v></c><c r="F1" t="d"><v>soon</v></c><c r="IZ1"><v>1</v></c></row>`+155+ `<row r="2"><c r="B2"><f t="shared" si="0"/></c></row>`)156+ res, err := readParts(t, parts, defaultXLSXLimits)157+ if err != nil {158+ t.Fatal(err)159+ }160+ s := res.Sheet161+ if g := input(s, addr(t, "B2")); g != "=Other!A2+A2" {162+ t.Errorf("B2 %q", g)163+ }164+ for cell, want := range map[string]string{"C1": "1/1/1904", "D1": "9/26/2026 14:30:00", "E1": "9/26/2026", "F1": "soon"} {165+ if g := shown(s, addr(t, cell)); g != want {166+ t.Errorf("%s shows %q, want %q", cell, g, want)167+ }168+ }169+ if notes := strings.Join(res.Notes, "; "); !strings.Contains(notes, "4 columns left out") {170+ t.Errorf("notes %q", notes)171+ }172+}173+174+func TestXLSXLimits(t *testing.T) {175+ small := defaultXLSXLimits176+ small.part, small.total, small.slack, small.token, small.ratio, small.entries = 1<<20, 2<<20, 64<<10, 64<<10, 100, 50177+ bomb := oneSheetParts("", strings.Repeat(`<row><c><v>0</v></c></row>`, 100_000))178+ var rows, text strings.Builder179+ for i := range 60_000 {180+ rows.WriteString(`<row><c><v>` + itoa(i*7919%100_003) + `</v></c></row>`)181+ text.WriteString(itoa(i * 7919 % 100_003))182+ }183+ big := oneSheetParts("", rows.String())184+ long := oneSheetParts("", `<row><c t="inlineStr"><is><t>`+text.String()+`</t></is></c></row>`)185+ deep := oneSheetParts("", `<row><c><v>1</v><x>`+strings.Repeat("<y>", 300)+strings.Repeat("</y>", 300)+`</x></c></row>`)186+ many := kitchenParts()187+ for i := range small.entries {188+ many["xl/media/image"+itoa(i)+".png"] = ""189+ }190+ for name, tc := range map[string]struct {191+ parts map[string]string192+ want string193+ ratio int64 // instead of small.ratio194+ }{195+ "zip bomb": {bomb, "compressed over 100 to 1, like a zip bomb", 0},196+ "big part": {big, "unpacks to more than 1 MB", 0},197+ "long text": {long, "a tag or text over", 0},198+ "deep": {deep, "nests elements more than 256 deep", 0},199+ "many files": {many, "files, more than 50", 0},200+ "row past Excel": {oneSheetParts("", `<row r="1048577"><c><v>1</v></c></row>`), "past Excel's last row", 0},201+ "col past Excel": {oneSheetParts("", `<row><c r="XFE1"><v>1</v></c></row>`), `"XFE1" isn't a cell reference`, 0},202+ "bad reference": {oneSheetParts("", `<row><c r="A0"><v>1</v></c></row>`), `"A0" isn't a cell reference`, 0},203+ "too many cells": {oneSheetParts("", `<row>`+strings.Repeat(`<c r="A1"/>`, excelCols+1)+`</row>`), "more than 16,384 cells", 1 << 30},204+ "unsafe name": {mergeParts(kitchenParts(), "../evil.xml", "x"), "unsafe name", 0},205+ "absolute name": {mergeParts(kitchenParts(), "/etc/passwd", "x"), "unsafe name", 0},206+ "duplicate": {mergeParts(kitchenParts(), "XL/Workbook.xml", "x"), "twice", 0},207+ "entity": {oneSheetParts("", `<row><c t="inlineStr"><is><t>&bomb;</t></is></c></row>`), "invalid character entity &bomb;", 0},208+ "not xml": {oneSheetParts("", `<row><c>`), "closed by </sheetData>", 0},209+ } {210+ t.Run(name, func(t *testing.T) {211+ lim := small212+ if tc.ratio > 0 {213+ lim.ratio = tc.ratio214+ }215+ _, err := readParts(t, tc.parts, lim)216+ if err == nil || !strings.Contains(err.Error(), tc.want) {217+ t.Fatalf("error %v, want %q", err, tc.want)218+ }219+ })220+ }221+ // The limits error wraps errXLSXLimit.222+ if _, err := readParts(t, bomb, small); !errors.Is(err, errXLSXLimit) {223+ t.Errorf("%v isn't a limits error", err)224+ }225+ // A DTD declaring entities is refused, not expanded.226+ dtd := oneSheetParts("", "")227+ dtd["xl/sharedStrings.xml"] = `<?xml version="1.0"?><!DOCTYPE sst [<!ENTITY a "aaaaaaaaaa"><!ENTITY b "&a;&a;&a;&a;">]><sst ` + mainNS + `><si><t>&b;</t></si></sst>`228+ if _, err := readParts(t, dtd, small); err == nil || !strings.Contains(err.Error(), "invalid character entity &b;") {229+ t.Errorf("entity: %v", err)230+ }231+}232+233+func mergeParts(p map[string]string, name, content string) map[string]string {234+ p[name] = content235+ return p236+}237+238+// FuzzReadXLSX feeds the reader arbitrary files: it must fail cleanly,239+// never panic or run past its limits.240+func FuzzReadXLSX(f *testing.F) {241+ f.Add(zipParts(f, kitchenParts()))242+ f.Add(zipParts(f, oneSheetParts(`<workbookPr date1904="1"/>`, `<row><c r="B2" t="d"><v>2026-01-01</v></c></row>`)))243+ f.Fuzz(func(t *testing.T, data []byte) {244+ lim := defaultXLSXLimits245+ lim.part, lim.total = 4<<20, 8<<20246+ bk, err := openXLSX(bytes.NewReader(data), int64(len(data)), lim)247+ if err != nil {248+ return249+ }250+ bk.importBook(context.Background(), nil)251+ })252+}253+254+// FuzzReadXLSXParts fuzzes the XML of a worksheet, the shared strings255+// and the styles, zipped into a workbook, where the zip's checksums256+// would stop most mutations of a whole file.257+func FuzzReadXLSXParts(f *testing.F) {258+ k := kitchenParts()259+ f.Add(k["xl/worksheets/sheet1.xml"], k["xl/sharedStrings.xml"], k["xl/styles.xml"])260+ f.Add(`<worksheet><sheetData><row r="3"><c r="C3"><f t="shared" ref="C3:C9" si="0">A1:B$2+'x y'!A1</f></c></row><row><c r="C4"><f t="shared" si="0"/></c></row></sheetData></worksheet>`, "", "")261+ f.Fuzz(func(t *testing.T, ws, sst, styles string) {262+ p := kitchenParts()263+ p["xl/worksheets/sheet1.xml"], p["xl/sharedStrings.xml"], p["xl/styles.xml"] = ws, sst, styles264+ lim := defaultXLSXLimits265+ lim.part, lim.total = 4<<20, 8<<20266+ readParts(t, p, lim)267+ })268+}@@ -0,0 +1,385 @@
1+package fileio2+3+import (4+ "encoding/xml"5+ "fmt"6+ "io"7+ "strconv"8+ "strings"9+)10+11+// Excel's own limits on a worksheet. A reference past them is an error,12+// as it is in Excel.13+const (14+ excelRows = 1 << 2015+ excelCols = 1 << 1416+)17+18+// xlsxCell is a cell of a worksheet row, as the reader passes it on.19+type xlsxCell struct {20+ col int // from 121+ style int // the cell format index (s), 0 when not given22+ typ string // the type (t): s, str, inlineStr, b, e, n, d or ""23+ value string // the value, a shared string looked up24+ formula string // the formula, a shared formula expanded25+ hasF bool // the cell has a formula element, even an empty one26+}27+28+// kept reports whether excelize's row reader would return the cell: it29+// has a value or a formula element. The others are formatted blanks.30+func (c *xlsxCell) kept() bool { return c.value != "" || c.hasF }31+32+// xlsxCol is a <col> element: widths and styles for columns min to max.33+type xlsxCol struct {34+ min, max int35+ width float6436+ hasWidth bool37+ style int38+}39+40+// xlsxSheetReader streams a worksheet's rows. Opening it reads what41+// comes before the cells: column widths and styles, the default width.42+type xlsxSheetReader struct {43+ bk *xlsxBook44+ x *xmlStream45+ cols []xlsxCol46+ // sheetFormatPr's defaultColWidth and baseColWidth.47+ defaultWidth float6448+ baseWidth int49+50+ inData bool // inside <sheetData>51+ row xlsxRowData52+ shared map[int]sharedFormula53+ buf []byte54+}55+56+// xlsxRowData is the row just read.57+type xlsxRowData struct {58+ num int // from 159+ style int // the row's cell format index, for cells not written60+ cells []xlsxCell61+}62+63+// sharedFormula is the master cell of a shared formula.64+type sharedFormula struct {65+ text string66+ col, row int67+}68+69+// openSheet opens sheet i. A sheet with no worksheet part (a chart70+// sheet) reads as empty.71+func (bk *xlsxBook) openSheet(i int) (*xlsxSheetReader, error) {72+ info := bk.sheets[i]73+ if info.part == "" {74+ return nil, fmt.Errorf("sheet %s: its part is missing", info.name)75+ }76+ x, err := bk.pkg.open(info.part)77+ if err != nil {78+ return nil, err79+ }80+ r := &xlsxSheetReader{bk: bk, x: x, shared: map[int]sharedFormula{}}81+ if info.kind != "worksheet" {82+ return r, nil83+ }84+ return r, r.readHead()85+}86+87+func (r *xlsxSheetReader) close() error { return r.x.close() }88+89+// readHead reads up to the start of <sheetData>.90+func (r *xlsxSheetReader) readHead() error {91+ for {92+ t, err := r.x.next()93+ if err == io.EOF {94+ return nil95+ }96+ if err != nil {97+ return err98+ }99+ se, ok := t.(xml.StartElement)100+ if !ok || r.x.depth != 2 && se.Name.Local != "col" {101+ continue102+ }103+ switch se.Name.Local {104+ case "sheetData":105+ r.inData = true106+ return nil107+ case "sheetFormatPr":108+ r.defaultWidth, _ = strconv.ParseFloat(attrOr(se, "defaultColWidth", "0"), 64)109+ r.baseWidth = intAttr(se, "baseColWidth", 0)110+ case "col":111+ if r.x.depth == 3 && len(r.cols) < excelCols {112+ w, err := strconv.ParseFloat(attrOr(se, "width", ""), 64)113+ r.cols = append(r.cols, xlsxCol{114+ min: intAttr(se, "min", 0), max: intAttr(se, "max", 0),115+ width: w, hasWidth: err == nil, style: intAttr(se, "style", 0),116+ })117+ }118+ }119+ }120+}121+122+// colStyle is the cell format of column col (from 1): the first <col>123+// covering it with a style.124+func (r *xlsxSheetReader) colStyle(col int) int {125+ for _, c := range r.cols {126+ if c.min <= col && col <= c.max && c.style != 0 {127+ return c.style128+ }129+ }130+ return 0131+}132+133+// colWidth is column col's width in Excel's characters: the last <col>134+// covering it with a width, else the sheet's default.135+func (r *xlsxSheetReader) colWidth(col int) float64 {136+ w := 0.0137+ for _, c := range r.cols {138+ if c.min <= col && col <= c.max && c.hasWidth {139+ w = c.width140+ }141+ }142+ switch {143+ case w != 0:144+ return w145+ case r.defaultWidth > 0:146+ return r.defaultWidth147+ case r.baseWidth > 0:148+ return float64(r.baseWidth)149+ }150+ return excelDefaultWidth151+}152+153+// next reads the next row into r.row, returning false after the last.154+func (r *xlsxSheetReader) next() (bool, error) {155+ for r.inData {156+ t, err := r.x.next()157+ if err != nil {158+ return false, eofAsUnexpected(err)159+ }160+ switch t := t.(type) {161+ case xml.StartElement:162+ if t.Name.Local == "row" && r.x.depth == 3 {163+ return true, r.readRow(t)164+ }165+ case xml.EndElement:166+ if r.x.depth < 2 {167+ r.inData = false168+ }169+ }170+ }171+ return false, nil172+}173+174+// readRow reads a <row>. A row without a number follows the one before.175+func (r *xlsxSheetReader) readRow(se xml.StartElement) error {176+ num := intAttr(se, "r", 0)177+ if num <= 0 {178+ num = r.row.num + 1179+ }180+ if num > excelRows {181+ return fmt.Errorf("row %s is past Excel's last row, %s", thousands(num), thousands(excelRows))182+ }183+ r.row.num, r.row.style = num, intAttr(se, "s", 0)184+ r.row.cells = r.row.cells[:0]185+ col := 0186+ for {187+ t, err := r.x.next()188+ if err != nil {189+ return eofAsUnexpected(err)190+ }191+ switch t := t.(type) {192+ case xml.StartElement:193+ if t.Name.Local != "c" || r.x.depth != 4 {194+ if err := r.x.skip(); err != nil {195+ return err196+ }197+ continue198+ }199+ if len(r.row.cells) >= excelCols {200+ return fmt.Errorf("row %s has more than %s cells", thousands(num), thousands(excelCols))201+ }202+ if col, err = r.readCell(t, col); err != nil {203+ return err204+ }205+ case xml.EndElement:206+ return nil207+ }208+ }209+}210+211+// readCell reads a <c> following column prev and appends it to the row,212+// returning its column.213+func (r *xlsxSheetReader) readCell(se xml.StartElement, prev int) (int, error) {214+ c := xlsxCell{col: prev + 1, style: intAttr(se, "s", 0)}215+ c.typ, _ = attr(se, "t")216+ if ref, ok := attr(se, "r"); ok && ref != "" {217+ col, _, ok := parseCellRef(ref)218+ if !ok {219+ return 0, fmt.Errorf("row %s: %q isn't a cell reference", thousands(r.row.num), ref)220+ }221+ c.col = col222+ }223+ if c.col > excelCols {224+ return 0, fmt.Errorf("row %s: a cell past Excel's last column, XFD", thousands(r.row.num))225+ }226+ var v, is string227+ var hasIS bool228+ var f cellFormula229+ depth := r.x.depth230+ for {231+ t, err := r.x.next()232+ if err != nil {233+ return 0, eofAsUnexpected(err)234+ }235+ if _, ok := t.(xml.EndElement); ok && r.x.depth < depth {236+ break237+ }238+ se, ok := t.(xml.StartElement)239+ if !ok {240+ continue241+ }242+ switch se.Name.Local {243+ case "v":244+ r.buf, err = r.x.text(r.buf[:0])245+ v = string(r.buf)246+ case "f":247+ c.hasF = true248+ f, err = r.readFormula(se)249+ case "is":250+ r.buf, err = inlineText(r.x, r.buf[:0])251+ is, hasIS = string(unescapeOOXML(r.buf)), true252+ default:253+ err = r.x.skip()254+ }255+ if err != nil {256+ return 0, err257+ }258+ }259+ c.value = r.value(c.typ, v, is, hasIS)260+ if c.hasF {261+ c.formula = r.formula(f, c.col, r.row.num)262+ }263+ r.row.cells = append(r.row.cells, c)264+ return c.col, nil265+}266+267+// value is a cell's value: its shared string, its inline string, or the268+// text of its <v>.269+func (r *xlsxSheetReader) value(typ, v, is string, hasIS bool) string {270+ switch typ {271+ case "s":272+ if v == "" {273+ return ""274+ }275+ i, err := strconv.Atoi(strings.TrimSpace(v))276+ if err != nil {277+ return ""278+ }279+ s, _ := r.bk.sst.get(i)280+ return s281+ case "inlineStr":282+ if hasIS {283+ return is284+ }285+ }286+ return v287+}288+289+// cellFormula is an <f> element.290+type cellFormula struct {291+ text, typ, ref string292+ si int // -1 when not given293+}294+295+func (r *xlsxSheetReader) readFormula(se xml.StartElement) (cellFormula, error) {296+ f := cellFormula{si: intAttr(se, "si", -1)}297+ f.typ, _ = attr(se, "t")298+ f.ref, _ = attr(se, "ref")299+ var err error300+ r.buf, err = r.x.text(r.buf[:0])301+ f.text = string(r.buf)302+ return f, err303+}304+305+// formula is the formula of the cell at col, row: its own text, or for306+// a cell sharing a formula, the master's with its relative references307+// moved by the distance between the cells.308+func (r *xlsxSheetReader) formula(f cellFormula, col, row int) string {309+ if f.typ != "shared" || f.si < 0 {310+ return f.text311+ }312+ m, ok := r.shared[f.si]313+ if !ok {314+ if f.ref == "" {315+ return "" // the master is missing316+ }317+ m = sharedFormula{text: f.text, col: col, row: row}318+ r.shared[f.si] = m319+ }320+ return shiftFormula(m.text, col-m.col, row-m.row)321+}322+323+// parseCellRef parses an A1 reference (without $) into a column and row324+// from 1, within Excel's limits.325+func parseCellRef(ref string) (col, row int, ok bool) {326+ i := 0327+ for i < len(ref) && i < 4 && isLetter(ref[i]) {328+ col = col*26 + int(upper(ref[i])-'A'+1)329+ i++330+ }331+ if i == 0 || i == len(ref) || col > excelCols {332+ return 0, 0, false333+ }334+ for _, c := range []byte(ref[i:]) {335+ if !isDigitByte(c) {336+ return 0, 0, false337+ }338+ row = row*10 + int(c-'0')339+ if row > excelRows {340+ return 0, 0, false341+ }342+ }343+ return col, row, row >= 1344+}345+346+func isLetter(c byte) bool { return c >= 'A' && c <= 'Z' || c >= 'a' && c <= 'z' }347+348+func upper(c byte) byte {349+ if c >= 'a' && c <= 'z' {350+ return c - 'a' + 'A'351+ }352+ return c353+}354+355+// dimension is the last row of sheet i by its <dimension> element, for356+// the progress bar; 0 when it doesn't say.357+func (bk *xlsxBook) dimension(i int) int {358+ if bk.sheets[i].part == "" {359+ return 0360+ }361+ x, err := bk.pkg.open(bk.sheets[i].part)362+ if x == nil || err != nil {363+ return 0364+ }365+ defer x.close()366+ for {367+ t, err := x.next()368+ if err != nil {369+ return 0370+ }371+ se, ok := t.(xml.StartElement)372+ switch {373+ case !ok:374+ case se.Name.Local == "sheetData":375+ return 0376+ case se.Name.Local == "dimension":377+ ref, _ := attr(se, "ref")378+ if _, to, ok := strings.Cut(ref, ":"); ok {379+ _, row, _ := parseCellRef(strings.ReplaceAll(to, "$", ""))380+ return row381+ }382+ return 0383+ }384+ }385+}@@ -0,0 +1,178 @@
1+package fileio2+3+import (4+ "strconv"5+ "strings"6+)7+8+// shiftFormula moves the relative references of an Excel formula by9+// dCol columns and dRow rows, as Excel does to copy a shared formula10+// from its first cell to the others: A1 and A$1 move, $A$1 doesn't, and11+// whole columns (A:B) and rows (1:2) move along their axis. Text,12+// quoted sheet names and function names stay as they are. A reference13+// moved off the sheet becomes #REF!.14+func shiftFormula(f string, dCol, dRow int) string {15+ if dCol == 0 && dRow == 0 {16+ return f17+ }18+ var b strings.Builder19+ for i := 0; i < len(f); {20+ c := f[i]21+ switch {22+ case c == '"':23+ j := stringEnd(f, i)24+ if j < 0 {25+ j = len(f)26+ }27+ b.WriteString(f[i:j])28+ i = j29+ case c == '\'':30+ j := quoteEnd(f, i)31+ b.WriteString(f[i:j])32+ i = j33+ case c == '[':34+ // A structured reference or an external book's index.35+ j := strings.IndexByte(f[i:], ']')36+ if j < 0 {37+ j = len(f) - i - 138+ }39+ b.WriteString(f[i : i+j+1])40+ i += j + 141+ case isRefByte(c):42+ j := i43+ for j < len(f) && isRefByte(f[j]) {44+ j++45+ }46+ if word := f[i:j]; j < len(f) && f[j] == '(' {47+ b.WriteString(word) // a function48+ } else {49+ b.WriteString(shiftRef(word, dCol, dRow))50+ }51+ i = j52+ default:53+ b.WriteByte(c)54+ i++55+ }56+ }57+ return b.String()58+}59+60+func isRefByte(c byte) bool { return isIdentByte(c) || c == ':' || c == '!' }61+62+// refPart is one side of a reference: a cell (A1), a column (A) or a63+// row (1), each part with or without $.64+type refPart struct {65+ prefix string // a sheet name and !, if any66+ col, row int // from 1; 0 when absent67+ absCol, absRow bool68+}69+70+// shiftRef moves a reference such as A1, $A1:B$2, Sheet1!A1 or A:B, and71+// returns anything else (a name, a number) unchanged.72+func shiftRef(word string, dCol, dRow int) string {73+ sides := strings.Split(word, ":")74+ if len(sides) > 2 {75+ return word76+ }77+ var parts [2]refPart78+ for i, s := range sides {79+ p, ok := parseRefPart(s)80+ if !ok {81+ return word82+ }83+ parts[i] = p84+ }85+ first := parts[0]86+ for _, p := range parts[1:len(sides)] {87+ if (p.col > 0) != (first.col > 0) || (p.row > 0) != (first.row > 0) {88+ return word // mixed kinds: not a reference89+ }90+ }91+ if len(sides) == 1 && (first.col == 0 || first.row == 0) {92+ return word // a lone column or row is a name or a number93+ }94+ out := make([]string, len(sides))95+ for i, p := range parts[:len(sides)] {96+ if p.col > 0 && !p.absCol {97+ p.col += dCol98+ }99+ if p.row > 0 && !p.absRow {100+ p.row += dRow101+ }102+ if first.col > 0 && (p.col < 1 || p.col > excelCols) || first.row > 0 && (p.row < 1 || p.row > excelRows) {103+ return first.prefix + "#REF!"104+ }105+ out[i] = p.String()106+ }107+ return strings.Join(out, ":")108+}109+110+// parseRefPart parses [Sheet!][$]COL[$]ROW, [$]COL or [$]ROW.111+func parseRefPart(s string) (refPart, bool) {112+ var p refPart113+ if k := strings.LastIndexByte(s, '!'); k >= 0 {114+ p.prefix, s = s[:k+1], s[k+1:]115+ }116+ i := 0117+ dollar := i < len(s) && s[i] == '$'118+ if dollar {119+ i++120+ }121+ letters := i122+ for i < len(s) && isLetter(s[i]) && i-letters < 3 {123+ p.col = p.col*26 + int(upper(s[i])-'A'+1)124+ i++125+ }126+ if i == letters {127+ p.absRow = dollar // no column: a row, and the $ is the row's128+ } else {129+ p.absCol = dollar130+ if i < len(s) && s[i] == '$' {131+ p.absRow = true132+ i++133+ }134+ }135+ digits := i136+ for i < len(s) && isDigitByte(s[i]) && i-digits < 7 {137+ p.row = p.row*10 + int(s[i]-'0')138+ i++139+ }140+ switch {141+ case i != len(s), i == letters, p.col > excelCols, p.row > excelRows:142+ return p, false143+ case i > digits && p.row == 0, i == digits && p.absRow:144+ return p, false // row 0, or a $ with no row after it145+ }146+ return p, true147+}148+149+func (p refPart) String() string {150+ var b strings.Builder151+ b.WriteString(p.prefix)152+ if p.col > 0 {153+ if p.absCol {154+ b.WriteByte('$')155+ }156+ b.WriteString(excelColName(p.col))157+ }158+ if p.row > 0 {159+ if p.absRow {160+ b.WriteByte('$')161+ }162+ b.WriteString(strconv.Itoa(p.row))163+ }164+ return b.String()165+}166+167+// excelColName is the letters of column col, from 1 (A) to 16384 (XFD).168+func excelColName(col int) string {169+ var b [3]byte170+ i := len(b)171+ for col > 0 && i > 0 {172+ col--173+ i--174+ b[i] = byte('A' + col%26)175+ col /= 26176+ }177+ return string(b[i:])178+}@@ -0,0 +1,223 @@
1+package fileio2+3+import (4+ "encoding/xml"5+ "fmt"6+ "io"7+ "strconv"8+ "strings"9+10+ "github.com/FelineStateMachine/012/internal/sheet"11+)12+13+// xlsxStyles is what 012 reads of the styles part: custom number14+// formats, fonts, and the cell formats (cellXfs) that cells refer to by15+// index, resolved to 012 formats and styles on first use.16+type xlsxStyles struct {17+ numFmts map[int]string18+ fonts []xlsxFont19+ xfs []xlsxXf20+ cache map[int]xlsxStyle21+}22+23+type xlsxFont struct{ bold, italic, strike, underline bool }24+25+// xlsxXf is a cell format; numFmt and font are -1 when not given.26+type xlsxXf struct {27+ numFmt, font int28+ applyFont, applyAlign bool29+ align sheet.Align30+}31+32+// style is the cell format with index id, as 012 keeps it; an unknown33+// id is the default.34+func (s *xlsxStyles) style(id int) xlsxStyle {35+ if id <= 0 || id >= len(s.xfs) {36+ return xlsxStyle{}37+ }38+ if st, ok := s.cache[id]; ok {39+ return st40+ }41+ xf := s.xfs[id]42+ var out xlsxStyle43+ out.format = s.format(xf.numFmt)44+ if xf.applyFont && xf.font >= 0 && xf.font < len(s.fonts) {45+ f := s.fonts[xf.font]46+ out.style.Bold, out.style.Italic, out.style.Strikethrough, out.style.Underline = f.bold, f.italic, f.strike, f.underline47+ }48+ if xf.applyAlign {49+ out.style.Align = xf.align50+ }51+ if s.cache == nil {52+ s.cache = map[int]xlsxStyle{}53+ }54+ s.cache[id] = out55+ return out56+}57+58+// format maps a number format id to a 012 format. Built-in ids (and the59+// locale-dependent ones) mean their built-in code even when the file60+// defines them again; others are looked up among the custom codes.61+func (s *xlsxStyles) format(id int) sheet.Format {62+ if id < 0 {63+ return sheet.Format{}64+ }65+ if builtinNumFmt(id) {66+ return formatOf(id, "")67+ }68+ return formatOf(0, s.numFmts[id])69+}70+71+// builtinNumFmt reports whether id is one of Excel's built-in number72+// formats: the ones every locale shares, and the ranges each locale73+// defines for itself.74+func builtinNumFmt(id int) bool {75+ switch {76+ case id >= 0 && id <= 4, id >= 9 && id <= 22, id >= 37 && id <= 49:77+ return true78+ case id >= 27 && id <= 36, id >= 50 && id <= 62, id >= 67 && id <= 81:79+ return true80+ }81+ return false82+}83+84+// read streams the styles part.85+func (s *xlsxStyles) read(p *xlsxPackage, part string) error {86+ x, err := p.open(part)87+ if x == nil || err != nil {88+ return err89+ }90+ defer x.close()91+ s.numFmts = map[int]string{}92+ section := "" // numFmts, fonts or cellXfs, at depth 293+ var cur *xlsxXf // the <xf> being read94+ for {95+ t, err := x.next()96+ if err == io.EOF {97+ return nil98+ }99+ if err != nil {100+ return err101+ }102+ switch t := t.(type) {103+ case xml.StartElement:104+ if x.depth == 2 {105+ section = t.Name.Local106+ continue107+ }108+ if cur, err = s.element(x, t, section, cur); err != nil {109+ return err110+ }111+ case xml.EndElement:112+ if x.depth < 2 {113+ section = ""114+ }115+ }116+ }117+}118+119+// element reads one element inside a section of the styles part.120+func (s *xlsxStyles) element(x *xmlStream, se xml.StartElement, section string, cur *xlsxXf) (*xlsxXf, error) {121+ full := len(s.numFmts) >= x.g.p.lim.styles || len(s.fonts) >= x.g.p.lim.styles || len(s.xfs) >= x.g.p.lim.styles122+ switch {123+ case section == "numFmts" && se.Name.Local == "numFmt" && x.depth == 3:124+ if full {125+ return nil, fmt.Errorf("the workbook has more than %d styles: %w", x.g.p.lim.styles, errXLSXLimit)126+ }127+ id, err := strconv.Atoi(attrOr(se, "numFmtId", ""))128+ if err == nil {129+ s.numFmts[id], _ = attr(se, "formatCode")130+ }131+ case section == "fonts" && se.Name.Local == "font" && x.depth == 3:132+ if full {133+ return nil, fmt.Errorf("the workbook has more than %d fonts: %w", x.g.p.lim.styles, errXLSXLimit)134+ }135+ f, err := readFont(x)136+ if err != nil {137+ return nil, err138+ }139+ s.fonts = append(s.fonts, f)140+ case section == "cellXfs" && se.Name.Local == "xf" && x.depth == 3:141+ if full {142+ return nil, fmt.Errorf("the workbook has more than %d cell formats: %w", x.g.p.lim.styles, errXLSXLimit)143+ }144+ s.xfs = append(s.xfs, xlsxXf{145+ numFmt: intAttr(se, "numFmtId", -1), font: intAttr(se, "fontId", -1),146+ applyFont: boolAttr(se, "applyFont", true), applyAlign: boolAttr(se, "applyAlignment", true),147+ })148+ return &s.xfs[len(s.xfs)-1], nil149+ case section == "cellXfs" && se.Name.Local == "alignment" && x.depth == 4 && cur != nil:150+ switch h, _ := attr(se, "horizontal"); h {151+ case "left":152+ cur.align = sheet.AlignLeft153+ case "center", "centerContinuous":154+ cur.align = sheet.AlignCenter155+ case "right":156+ cur.align = sheet.AlignRight157+ }158+ }159+ return cur, nil160+}161+162+// readFont reads the rest of a <font>: bold, italic, strikethrough and163+// underline (any but u val="none").164+func readFont(x *xmlStream) (xlsxFont, error) {165+ var f xlsxFont166+ depth := x.depth167+ for {168+ t, err := x.next()169+ if err != nil {170+ return f, eofAsUnexpected(err)171+ }172+ switch t := t.(type) {173+ case xml.StartElement:174+ if x.depth != depth+1 {175+ continue176+ }177+ switch t.Name.Local {178+ case "b":179+ f.bold = boolAttr(t, "val", true)180+ case "i":181+ f.italic = boolAttr(t, "val", true)182+ case "strike":183+ f.strike = boolAttr(t, "val", true)184+ case "u":185+ f.underline = attrOr(t, "val", "single") != "none"186+ }187+ case xml.EndElement:188+ if x.depth < depth {189+ return f, nil190+ }191+ }192+ }193+}194+195+func attrOr(se xml.StartElement, name, def string) string {196+ if v, ok := attr(se, name); ok {197+ return v198+ }199+ return def200+}201+202+func intAttr(se xml.StartElement, name string, def int) int {203+ if v, ok := attr(se, name); ok {204+ if n, err := strconv.Atoi(strings.TrimSpace(v)); err == nil {205+ return n206+ }207+ }208+ return def209+}210+211+// boolAttr reads an xsd:boolean attribute; an empty value is true, and212+// one that isn't a boolean is false.213+func boolAttr(se xml.StartElement, name string, def bool) bool {214+ v, ok := attr(se, name)215+ if !ok {216+ return def217+ }218+ if v = strings.TrimSpace(v); v == "" {219+ return true220+ }221+ b, _ := strconv.ParseBool(v)222+ return b223+}