dana-medical-site/internal/api/tables.go
2026-10-14 13:33:13 +00:00

373 lines
9.9 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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
}