471 lines
14 KiB
Go
471 lines
14 KiB
Go
package sberhealth
|
||
|
||
import (
|
||
"bytes"
|
||
"encoding/binary"
|
||
"fmt"
|
||
"io"
|
||
"math"
|
||
"strconv"
|
||
"unicode/utf16"
|
||
|
||
"github.com/extrame/ole2"
|
||
)
|
||
|
||
// Минимальный читатель книг Excel 97–2003 (BIFF8). Нужен потому, что общая таблица строк (SST)
|
||
// у больших выгрузок разбивается на записи CONTINUE посреди строки, и библиотека extrame/xls на
|
||
// этих границах смещает значения: часть ячеек получала чужие данные, а все строки ниже сдвигались.
|
||
// Здесь строки собираются строго по спецификации: у продолжения строки первым байтом идёт флаг
|
||
// кодировки (1 или 2 байта на символ), а блоки форматирования и фонетики могут пересекать границу.
|
||
//
|
||
// Поддерживаются только ячейки, которые встречаются в выгрузках: LABELSST, LABEL, NUMBER, RK, MULRK
|
||
// (и пустые BLANK/MULBLANK). Формулы, ошибки и прочее — явная ошибка: данные нельзя тихо терять.
|
||
|
||
const (
|
||
recEOF = 0x000A
|
||
recFormula = 0x0006
|
||
recBoundSheet = 0x0085
|
||
recMulRK = 0x00BD
|
||
recMulBlank = 0x00BE
|
||
recRString = 0x00D6
|
||
recSST = 0x00FC
|
||
recLabelSST = 0x00FD
|
||
recContinue = 0x003C
|
||
recBlank = 0x0201
|
||
recNumber = 0x0203
|
||
recLabel = 0x0204
|
||
recBoolErr = 0x0205
|
||
recString = 0x0207
|
||
recRK = 0x027E
|
||
recBOF = 0x0809
|
||
|
||
biff8Version = 0x0600
|
||
maxSheetRows = 50000
|
||
maxSheetCols = 100
|
||
maxSSTItems = 1_000_000
|
||
)
|
||
|
||
type record struct {
|
||
id uint16
|
||
data []byte
|
||
off int
|
||
}
|
||
|
||
type biffSheet struct {
|
||
name string
|
||
rows [][]string
|
||
}
|
||
|
||
// workbookStream достаёт из контейнера OLE2 поток Workbook (или Book) целиком.
|
||
func workbookStream(data []byte) ([]byte, error) {
|
||
o, err := ole2.Open(bytes.NewReader(data), "utf-8")
|
||
if err != nil || o == nil {
|
||
return nil, fmt.Errorf("контейнер XLS не открылся")
|
||
}
|
||
dir, err := o.ListDir()
|
||
if err != nil {
|
||
return nil, fmt.Errorf("каталог XLS не прочитан")
|
||
}
|
||
var book, root *ole2.File
|
||
for _, f := range dir {
|
||
switch f.Name() {
|
||
case "Workbook", "Book":
|
||
book = f
|
||
case "Root Entry":
|
||
root = f
|
||
}
|
||
}
|
||
if book == nil {
|
||
return nil, fmt.Errorf("в XLS нет потока Workbook")
|
||
}
|
||
stream, err := io.ReadAll(io.LimitReader(o.OpenFile(book, root), maxDownload+1))
|
||
if err != nil {
|
||
return nil, fmt.Errorf("поток Workbook не дочитался")
|
||
}
|
||
if len(stream) > maxDownload {
|
||
return nil, fmt.Errorf("поток Workbook слишком велик")
|
||
}
|
||
return stream, nil
|
||
}
|
||
|
||
func readRecords(stream []byte) ([]record, error) {
|
||
var recs []record
|
||
for pos := 0; pos < len(stream); {
|
||
if pos+4 > len(stream) {
|
||
if allZero(stream[pos:]) {
|
||
break
|
||
}
|
||
return nil, fmt.Errorf("оборванная запись в конце книги")
|
||
}
|
||
id := binary.LittleEndian.Uint16(stream[pos:])
|
||
n := int(binary.LittleEndian.Uint16(stream[pos+2:]))
|
||
if id == 0 && n == 0 && allZero(stream[pos:]) {
|
||
break // выравнивание нулями после последней записи
|
||
}
|
||
if pos+4+n > len(stream) {
|
||
return nil, fmt.Errorf("запись выходит за пределы книги")
|
||
}
|
||
recs = append(recs, record{id: id, data: stream[pos+4 : pos+4+n], off: pos})
|
||
pos += 4 + n
|
||
if len(recs) > 2_000_000 {
|
||
return nil, fmt.Errorf("слишком много записей в книге")
|
||
}
|
||
}
|
||
return recs, nil
|
||
}
|
||
|
||
func allZero(b []byte) bool {
|
||
for _, v := range b {
|
||
if v != 0 {
|
||
return false
|
||
}
|
||
}
|
||
return true
|
||
}
|
||
|
||
// chunkReader читает данные, разбитые на записи SST и CONTINUE.
|
||
type chunkReader struct {
|
||
chunks [][]byte
|
||
ci int
|
||
pos int
|
||
}
|
||
|
||
func (r *chunkReader) avail() int {
|
||
if r.ci >= len(r.chunks) {
|
||
return 0
|
||
}
|
||
return len(r.chunks[r.ci]) - r.pos
|
||
}
|
||
|
||
// advance переходит к следующему непустому фрагменту; false — данные кончились.
|
||
func (r *chunkReader) advance() bool {
|
||
for {
|
||
r.ci++
|
||
r.pos = 0
|
||
if r.ci >= len(r.chunks) {
|
||
return false
|
||
}
|
||
if len(r.chunks[r.ci]) > 0 {
|
||
return true
|
||
}
|
||
}
|
||
}
|
||
|
||
// take отдаёт n непрерывных байт из текущего фрагмента (заголовки полей через границу не переходят).
|
||
func (r *chunkReader) take(n int) ([]byte, error) {
|
||
if r.avail() == 0 && !r.advance() {
|
||
return nil, fmt.Errorf("таблица строк оборвана")
|
||
}
|
||
if r.avail() < n {
|
||
return nil, fmt.Errorf("поле таблицы строк разорвано границей записи")
|
||
}
|
||
b := r.chunks[r.ci][r.pos : r.pos+n]
|
||
r.pos += n
|
||
return b, nil
|
||
}
|
||
|
||
// skip пропускает n байт, переходя между фрагментами без флага кодировки.
|
||
func (r *chunkReader) skip(n int) error {
|
||
for n > 0 {
|
||
if r.avail() == 0 && !r.advance() {
|
||
return fmt.Errorf("таблица строк оборвана при пропуске форматирования")
|
||
}
|
||
k := min(n, r.avail())
|
||
r.pos += k
|
||
n -= k
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// readString читает одну строку Unicode (cch, флаги, символы, блоки форматирования/фонетики).
|
||
func (r *chunkReader) readString() (string, error) {
|
||
head, err := r.take(3)
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
cch := int(binary.LittleEndian.Uint16(head))
|
||
flags := head[2]
|
||
high := flags&0x01 != 0
|
||
runs, ext := 0, 0
|
||
if flags&0x08 != 0 {
|
||
b, err := r.take(2)
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
runs = int(binary.LittleEndian.Uint16(b))
|
||
}
|
||
if flags&0x04 != 0 {
|
||
b, err := r.take(4)
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
ext = int(binary.LittleEndian.Uint32(b))
|
||
}
|
||
units := make([]uint16, 0, cch)
|
||
for remaining := cch; remaining > 0; {
|
||
if r.avail() == 0 {
|
||
// Продолжение строки: первый байт нового фрагмента — флаг кодировки.
|
||
if !r.advance() {
|
||
return "", fmt.Errorf("таблица строк оборвана внутри строки")
|
||
}
|
||
b, err := r.take(1)
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
high = b[0]&0x01 != 0
|
||
}
|
||
width := 1
|
||
if high {
|
||
width = 2
|
||
}
|
||
n := min(remaining, r.avail()/width)
|
||
if n == 0 {
|
||
return "", fmt.Errorf("символ разорван границей записи")
|
||
}
|
||
raw, _ := r.take(n * width)
|
||
for i := 0; i < n; i++ {
|
||
if high {
|
||
units = append(units, binary.LittleEndian.Uint16(raw[i*2:]))
|
||
} else {
|
||
units = append(units, uint16(raw[i]))
|
||
}
|
||
}
|
||
remaining -= n
|
||
}
|
||
if err := r.skip(runs*4 + ext); err != nil {
|
||
return "", err
|
||
}
|
||
return string(utf16.Decode(units)), nil
|
||
}
|
||
|
||
// parseSST разбирает общую таблицу строк, начиная с записи SST (i) и её CONTINUE.
|
||
func parseSST(recs []record, i int) ([]string, error) {
|
||
data := recs[i].data
|
||
if len(data) < 8 {
|
||
return nil, fmt.Errorf("слишком короткая таблица строк")
|
||
}
|
||
unique := int(binary.LittleEndian.Uint32(data[4:]))
|
||
if unique < 0 || unique > maxSSTItems {
|
||
return nil, fmt.Errorf("неверный размер таблицы строк")
|
||
}
|
||
chunks := [][]byte{data[8:]}
|
||
for j := i + 1; j < len(recs) && recs[j].id == recContinue; j++ {
|
||
chunks = append(chunks, recs[j].data)
|
||
}
|
||
r := &chunkReader{chunks: chunks}
|
||
out := make([]string, 0, unique)
|
||
for k := 0; k < unique; k++ {
|
||
s, err := r.readString()
|
||
if err != nil {
|
||
return nil, fmt.Errorf("строка %d из %d: %w", k+1, unique, err)
|
||
}
|
||
out = append(out, s)
|
||
}
|
||
return out, nil
|
||
}
|
||
|
||
func decodeRK(v uint32) float64 {
|
||
var f float64
|
||
if v&2 != 0 {
|
||
f = float64(int32(v) >> 2)
|
||
} else {
|
||
f = math.Float64frombits(uint64(v&0xFFFFFFFC) << 32)
|
||
}
|
||
if v&1 != 0 {
|
||
f /= 100
|
||
}
|
||
return f
|
||
}
|
||
|
||
func numberString(f float64) string {
|
||
if f == math.Trunc(f) && math.Abs(f) < 1e15 {
|
||
return strconv.FormatInt(int64(f), 10)
|
||
}
|
||
return strconv.FormatFloat(f, 'f', -1, 64)
|
||
}
|
||
|
||
type boundSheet struct {
|
||
name string
|
||
offset int
|
||
kind byte
|
||
}
|
||
|
||
func parseBoundSheet(d []byte) (boundSheet, error) {
|
||
if len(d) < 8 {
|
||
return boundSheet{}, fmt.Errorf("повреждённая запись листа")
|
||
}
|
||
cch, flags := int(d[6]), d[7]
|
||
width := 1
|
||
if flags&1 != 0 {
|
||
width = 2
|
||
}
|
||
if len(d) < 8+cch*width {
|
||
return boundSheet{}, fmt.Errorf("повреждённое имя листа")
|
||
}
|
||
units := make([]uint16, cch)
|
||
for i := range units {
|
||
if width == 2 {
|
||
units[i] = binary.LittleEndian.Uint16(d[8+i*2:])
|
||
} else {
|
||
units[i] = uint16(d[8+i])
|
||
}
|
||
}
|
||
return boundSheet{name: string(utf16.Decode(units)), offset: int(binary.LittleEndian.Uint32(d)), kind: d[5]}, nil
|
||
}
|
||
|
||
// parseBIFF8 читает все листы книги из потока Workbook.
|
||
func parseBIFF8(stream []byte) ([]biffSheet, error) {
|
||
recs, err := readRecords(stream)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if len(recs) == 0 || recs[0].id != recBOF || len(recs[0].data) < 4 ||
|
||
binary.LittleEndian.Uint16(recs[0].data) != biff8Version {
|
||
return nil, fmt.Errorf("поддерживается только формат Excel 97–2003 (BIFF8)")
|
||
}
|
||
byOffset := make(map[int]int, len(recs))
|
||
for i, rec := range recs {
|
||
byOffset[rec.off] = i
|
||
}
|
||
var sst []string
|
||
var bounds []boundSheet
|
||
for i, rec := range recs {
|
||
if i > 0 && rec.id == recBOF {
|
||
break // началась первая страница: общая часть книги закончилась
|
||
}
|
||
switch rec.id {
|
||
case recSST:
|
||
if sst, err = parseSST(recs, i); err != nil {
|
||
return nil, err
|
||
}
|
||
case recBoundSheet:
|
||
b, err := parseBoundSheet(rec.data)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
bounds = append(bounds, b)
|
||
}
|
||
}
|
||
sheets := make([]biffSheet, 0, len(bounds))
|
||
for _, b := range bounds {
|
||
start, ok := byOffset[b.offset]
|
||
if !ok || recs[start].id != recBOF {
|
||
return nil, fmt.Errorf("лист %q: не найдено начало данных", b.name)
|
||
}
|
||
rows, err := readSheet(recs, start, sst)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("лист %q: %w", b.name, err)
|
||
}
|
||
sheets = append(sheets, biffSheet{name: b.name, rows: rows})
|
||
}
|
||
return sheets, nil
|
||
}
|
||
|
||
type cellValue struct {
|
||
row, col int
|
||
text string
|
||
}
|
||
|
||
func readSheet(recs []record, start int, sst []string) ([][]string, error) {
|
||
var cells []cellValue
|
||
maxRow, maxCol := -1, -1
|
||
put := func(row, col int, text string) error {
|
||
if row >= maxSheetRows || col >= maxSheetCols {
|
||
return fmt.Errorf("лист слишком велик (строка %d, столбец %d)", row+1, col+1)
|
||
}
|
||
cells = append(cells, cellValue{row, col, text})
|
||
maxRow, maxCol = max(maxRow, row), max(maxCol, col)
|
||
return nil
|
||
}
|
||
depth := 0
|
||
for i := start; i < len(recs); i++ {
|
||
rec := recs[i]
|
||
d := rec.data
|
||
switch rec.id {
|
||
case recBOF:
|
||
depth++
|
||
continue
|
||
case recEOF:
|
||
depth--
|
||
if depth == 0 {
|
||
i = len(recs)
|
||
}
|
||
continue
|
||
}
|
||
if depth != 1 {
|
||
continue
|
||
}
|
||
switch rec.id {
|
||
case recLabelSST:
|
||
if len(d) < 10 {
|
||
return nil, fmt.Errorf("повреждённая ячейка LABELSST")
|
||
}
|
||
idx := int(binary.LittleEndian.Uint32(d[6:]))
|
||
if idx < 0 || idx >= len(sst) {
|
||
return nil, fmt.Errorf("ячейка ссылается на несуществующую строку %d", idx)
|
||
}
|
||
if err := put(int(binary.LittleEndian.Uint16(d)), int(binary.LittleEndian.Uint16(d[2:])), sst[idx]); err != nil {
|
||
return nil, err
|
||
}
|
||
case recLabel:
|
||
if len(d) < 8 {
|
||
return nil, fmt.Errorf("повреждённая ячейка LABEL")
|
||
}
|
||
r := &chunkReader{chunks: [][]byte{d[6:]}}
|
||
s, err := r.readString()
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if err := put(int(binary.LittleEndian.Uint16(d)), int(binary.LittleEndian.Uint16(d[2:])), s); err != nil {
|
||
return nil, err
|
||
}
|
||
case recNumber:
|
||
if len(d) < 14 {
|
||
return nil, fmt.Errorf("повреждённая ячейка NUMBER")
|
||
}
|
||
f := math.Float64frombits(binary.LittleEndian.Uint64(d[6:]))
|
||
if err := put(int(binary.LittleEndian.Uint16(d)), int(binary.LittleEndian.Uint16(d[2:])), numberString(f)); err != nil {
|
||
return nil, err
|
||
}
|
||
case recRK:
|
||
if len(d) < 10 {
|
||
return nil, fmt.Errorf("повреждённая ячейка RK")
|
||
}
|
||
if err := put(int(binary.LittleEndian.Uint16(d)), int(binary.LittleEndian.Uint16(d[2:])),
|
||
numberString(decodeRK(binary.LittleEndian.Uint32(d[6:])))); err != nil {
|
||
return nil, err
|
||
}
|
||
case recMulRK:
|
||
if len(d) < 6 {
|
||
return nil, fmt.Errorf("повреждённая запись MULRK")
|
||
}
|
||
row, first := int(binary.LittleEndian.Uint16(d)), int(binary.LittleEndian.Uint16(d[2:]))
|
||
n := (len(d) - 6) / 6
|
||
for k := 0; k < n; k++ {
|
||
v := binary.LittleEndian.Uint32(d[4+k*6+2:])
|
||
if err := put(row, first+k, numberString(decodeRK(v))); err != nil {
|
||
return nil, err
|
||
}
|
||
}
|
||
case recBlank, recMulBlank, recString:
|
||
// пустые ячейки значения не несут
|
||
case recFormula, recBoolErr, recRString:
|
||
return nil, fmt.Errorf("неподдерживаемый тип ячейки 0x%04X: выгрузка не должна содержать формул и ошибок", rec.id)
|
||
}
|
||
}
|
||
rows := make([][]string, maxRow+1)
|
||
width := make([]int, maxRow+1)
|
||
for _, c := range cells {
|
||
width[c.row] = max(width[c.row], c.col+1)
|
||
}
|
||
for r := range rows {
|
||
if width[r] > 0 {
|
||
rows[r] = make([]string, width[r])
|
||
}
|
||
}
|
||
for _, c := range cells {
|
||
rows[c.row][c.col] = c.text
|
||
}
|
||
return rows, nil
|
||
}
|