373 lines
9.9 KiB
Go
373 lines
9.9 KiB
Go
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
|
||
}
|