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

471 lines
14 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 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
}