package api import ( "archive/zip" "bytes" "encoding/csv" "encoding/xml" "errors" "fmt" "io" "path" "regexp" "strconv" "strings" "unicode/utf8" "golang.org/x/text/encoding/charmap" ) // readTable читает первый лист xlsx или csv (разделитель ; или ,; кодировка UTF-8 или Windows-1251) // и возвращает строки таблицы. Формулы не вычисляет — берёт сохранённое значение ячейки. func readTable(filename string, data []byte) ([][]string, error) { switch strings.ToLower(path.Ext(filename)) { case ".xlsx": return readXLSX(data) case ".csv": return readCSV(data) default: return nil, errors.New("Нужен файл .xlsx или .csv") } } func readCSV(data []byte) ([][]string, error) { data = bytes.TrimPrefix(data, []byte("\ufeff")) if !utf8.Valid(data) { dec, err := charmap.Windows1251.NewDecoder().Bytes(data) if err != nil { return nil, errors.New("Не удалось прочитать кодировку CSV") } data = dec } first := data if i := bytes.IndexByte(first, '\n'); i >= 0 { first = first[:i] } r := csv.NewReader(bytes.NewReader(data)) r.Comma = ';' if bytes.Count(first, []byte(",")) > bytes.Count(first, []byte(";")) { r.Comma = ',' } r.FieldsPerRecord = -1 r.LazyQuotes = true rows, err := r.ReadAll() if err != nil { return nil, fmt.Errorf("CSV не читается: %v", err) } return rows, nil } type xlsxCell struct { Ref string `xml:"r,attr"` Type string `xml:"t,attr"` Value string `xml:"v"` Inline struct { Text string `xml:"t"` Runs []struct { Text string `xml:"t"` } `xml:"r"` } `xml:"is"` } type xlsxSheet struct { Rows []struct { R int `xml:"r,attr"` Cells []xlsxCell `xml:"c"` } `xml:"sheetData>row"` } var cellColRE = regexp.MustCompile(`^[A-Z]+`) // колонка ячейки «C12» → 2 func cellColumn(ref string) int { col := 0 for _, ch := range cellColRE.FindString(ref) { col = col*26 + int(ch-'A'+1) } return col - 1 } func readXLSX(data []byte) ([][]string, error) { zr, err := zip.NewReader(bytes.NewReader(data), int64(len(data))) if err != nil { return nil, errors.New("Файл не похож на xlsx — сохраните его в Excel как «Книга Excel (.xlsx)»") } files := map[string]*zip.File{} for _, f := range zr.File { files[f.Name] = f } read := func(name string, max int64) ([]byte, error) { f := files[name] if f == nil { return nil, nil } rc, err := f.Open() if err != nil { return nil, err } defer rc.Close() // защита от «zip-бомбы»: распакованный лист не больше max b, err := io.ReadAll(io.LimitReader(rc, max+1)) if int64(len(b)) > max { return nil, errors.New("Лист слишком большой для отчёта") } return b, err } shared, err := readShared(read) if err != nil { return nil, err } sheetName := "xl/worksheets/sheet1.xml" if files[sheetName] == nil { for name := range files { if strings.HasPrefix(name, "xl/worksheets/sheet") && strings.HasSuffix(name, ".xml") { sheetName = name break } } } b, err := read(sheetName, 128<<20) if err != nil { return nil, err } if b == nil { return nil, errors.New("В файле нет листов") } return parseSheet(b, shared) } func readShared(read func(string, int64) ([]byte, error)) ([]string, error) { b, err := read("xl/sharedStrings.xml", 64<<20) if err != nil || b == nil { return nil, err } var sst struct { Items []struct { Text string `xml:"t"` Runs []struct { Text string `xml:"t"` } `xml:"r"` } `xml:"si"` } if err := xml.Unmarshal(b, &sst); err != nil { return nil, errors.New("Не удалось прочитать текст ячеек xlsx") } shared := make([]string, 0, len(sst.Items)) for _, it := range sst.Items { t := it.Text for _, r := range it.Runs { t += r.Text } shared = append(shared, t) } return shared, nil } // parseSheet — строки листа; у формул берётся сохранённое в файле значение. func parseSheet(b []byte, shared []string) ([][]string, error) { return parseSheetStream(bytes.NewReader(b), shared, nil) } type xlsxRow struct { R int `xml:"r,attr"` Cells []xlsxCell `xml:"c"` } // parseSheetStream читает лист построчно и сообщает, сколько байт XML уже разобрано: // по этому считается процент чтения книги — большие листы читаются секунды. func parseSheetStream(r io.Reader, shared []string, onBytes func(n int64)) ([][]string, error) { d := xml.NewDecoder(r) var out [][]string var row xlsxRow rowsSeen := 0 for { tok, err := d.Token() if err == io.EOF { break } if err != nil { return nil, errors.New("не удалось прочитать лист") } se, ok := tok.(xml.StartElement) if !ok || se.Name.Local != "row" { continue } row = xlsxRow{} if err := d.DecodeElement(&row, &se); err != nil { return nil, errors.New("не удалось прочитать строку листа") } rowsSeen++ if onBytes != nil && rowsSeen%300 == 0 { onBytes(d.InputOffset()) } // пустые строки Excel не пишет — добиваем их, чтобы индекс = номер строки − 1 for row.R > len(out)+1 && row.R-len(out) < 100000 { out = append(out, nil) } var cells []string for i, c := range row.Cells { col := i if c.Ref != "" { col = cellColumn(c.Ref) } if col < 0 || col > 200 { continue } for len(cells) <= col { cells = append(cells, "") } v := c.Value switch c.Type { case "s": if n, err := strconv.Atoi(v); err == nil && n >= 0 && n < len(shared) { v = shared[n] } case "inlineStr": v = c.Inline.Text for _, r := range c.Inline.Runs { v += r.Text } } cells[col] = v } out = append(out, cells) } if onBytes != nil { onBytes(d.InputOffset()) } return out, nil } // Sheet — лист книги: название и строки. type Sheet struct { Name string Rows [][]string } // workbookOpts — какие листы пропустить (служебные, с результатами формул) и куда // сообщать ход чтения: сколько байт XML прочитано из скольких. type workbookOpts struct { Skip func(name string) bool Progress func(done, total int64) } // readWorkbook читает все листы xlsx по порядку, с их названиями. CSV — один лист «Лист1». func readWorkbook(filename string, data []byte) ([]Sheet, error) { return readWorkbookOpts(filename, data, workbookOpts{}) } func readWorkbookOpts(filename string, data []byte, opts workbookOpts) ([]Sheet, error) { if strings.ToLower(path.Ext(filename)) == ".csv" { rows, err := readCSV(data) if err != nil { return nil, err } return []Sheet{{Name: "Лист1", Rows: rows}}, nil } if strings.ToLower(path.Ext(filename)) != ".xlsx" { return nil, errors.New("Нужен файл .xlsx или .csv") } zr, err := zip.NewReader(bytes.NewReader(data), int64(len(data))) if err != nil { return nil, errors.New("Файл не похож на xlsx — сохраните его в Excel как «Книга Excel (.xlsx)»") } files := map[string]*zip.File{} for _, f := range zr.File { files[f.Name] = f } read := func(name string, max int64) ([]byte, error) { f := files[name] if f == nil { return nil, nil } rc, err := f.Open() if err != nil { return nil, err } defer rc.Close() b, err := io.ReadAll(io.LimitReader(rc, max+1)) if int64(len(b)) > max { return nil, errors.New("Лист слишком большой для отчёта") } return b, err } shared, err := readShared(read) if err != nil { return nil, err } wbXML, err := read("xl/workbook.xml", 8<<20) if err != nil || wbXML == nil { return nil, errors.New("Не удалось прочитать список листов") } var wb struct { Sheets []struct { Name string `xml:"name,attr"` RID string `xml:"http://schemas.openxmlformats.org/officeDocument/2006/relationships id,attr"` } `xml:"sheets>sheet"` } if err := xml.Unmarshal(wbXML, &wb); err != nil { return nil, errors.New("Не удалось прочитать список листов") } relsXML, _ := read("xl/_rels/workbook.xml.rels", 8<<20) var rels struct { Items []struct { ID string `xml:"Id,attr"` Target string `xml:"Target,attr"` } `xml:"Relationship"` } _ = xml.Unmarshal(relsXML, &rels) target := map[string]string{} for _, r := range rels.Items { t := strings.TrimPrefix(r.Target, "/") if !strings.HasPrefix(t, "xl/") { t = "xl/" + t } target[r.ID] = t } type part struct { name string f *zip.File } var parts []part var total, done int64 for i, s := range wb.Sheets { if opts.Skip != nil && opts.Skip(s.Name) { continue } name := target[s.RID] if name == "" { name = fmt.Sprintf("xl/worksheets/sheet%d.xml", i+1) } if f := files[name]; f != nil { parts = append(parts, part{s.Name, f}) total += int64(f.UncompressedSize64) } } var out []Sheet for _, pt := range parts { if pt.f.UncompressedSize64 > 128<<20 { return nil, fmt.Errorf("Лист «%s» слишком большой для отчёта", pt.name) } rc, err := pt.f.Open() if err != nil { return nil, err } base := done rows, err := parseSheetStream(io.LimitReader(rc, 128<<20), shared, func(n int64) { if opts.Progress != nil { opts.Progress(base+n, total) } }) rc.Close() if err != nil { return nil, fmt.Errorf("Лист «%s»: %v", pt.name, err) } done += int64(pt.f.UncompressedSize64) out = append(out, Sheet{Name: pt.name, Rows: rows}) } if len(out) == 0 { return nil, errors.New("В файле нет листов") } return out, nil }