xls без записи CODEPAGE читается как cp1251 (#47, AURORA-1662)
В BIFF5/7 без CODEPAGE xlrd декодировал байтовые строки как latin-1: кириллица превращалась в мусор, лист ОСВ не находился (INVALID_DOCUMENT). Теперь такая книга читается с encoding_override=cp1251 и получает предупреждение уровня книги «xls без кодовой страницы — строки прочитаны как cp1251». Книги с CODEPAGE и BIFF8 читаются как прежде. Проба CODEPAGE — on_demand-открытие (только globals). Для теста добавлен минимальный писатель BIFF5 в контейнере OLE2 (tests/synthetic/xls_writer.py), умеющий не писать CODEPAGE. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
3f740c969b
commit
591b3f4434
@ -26,7 +26,7 @@ from src.parser.readers._limits import (
|
|||||||
check_xlsx_merged_area,
|
check_xlsx_merged_area,
|
||||||
check_zip_uncompressed_size,
|
check_zip_uncompressed_size,
|
||||||
)
|
)
|
||||||
from src.parser.readers.xls import read_xls
|
from src.parser.readers.xls import NO_CODEPAGE_WARNING, has_no_codepage, read_xls
|
||||||
from src.parser.readers.xlsb import OUTLINE_UNAVAILABLE_WARNING, read_xlsb
|
from src.parser.readers.xlsb import OUTLINE_UNAVAILABLE_WARNING, read_xlsb
|
||||||
from src.parser.readers.xlsx import read_xlsx
|
from src.parser.readers.xlsx import read_xlsx
|
||||||
|
|
||||||
@ -87,6 +87,8 @@ def read_book_detected(data: bytes, file_type: FileType, *, max_uncompressed_byt
|
|||||||
check_merged_area(sheets)
|
check_merged_area(sheets)
|
||||||
if detected == "XLSB":
|
if detected == "XLSB":
|
||||||
warnings.append(OUTLINE_UNAVAILABLE_WARNING)
|
warnings.append(OUTLINE_UNAVAILABLE_WARNING)
|
||||||
|
if detected == "XLS" and has_no_codepage(data):
|
||||||
|
warnings.append(NO_CODEPAGE_WARNING)
|
||||||
return BookRead(sheets, warnings, detected)
|
return BookRead(sheets, warnings, detected)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -8,12 +8,35 @@ import xlrd
|
|||||||
from src.parser.grid import Cell, MergedRange, Sheet
|
from src.parser.grid import Cell, MergedRange, Sheet
|
||||||
from src.parser.readers._common import make_cell
|
from src.parser.readers._common import make_cell
|
||||||
|
|
||||||
__all__ = ["read_xls"]
|
__all__ = ["NO_CODEPAGE_WARNING", "has_no_codepage", "read_xls"]
|
||||||
|
|
||||||
|
# Кодовая страница 1С и Excel в русской локали: в BIFF5/7 она задаёт кодировку всех байтовых строк книги
|
||||||
|
FALLBACK_ENCODING = "cp1251"
|
||||||
|
NO_CODEPAGE_WARNING = f"xls без кодовой страницы — строки прочитаны как {FALLBACK_ENCODING}"
|
||||||
|
# BIFF8 хранит строки в Unicode и от CODEPAGE не зависит; `biff_version` xlrd для него — 80
|
||||||
|
_BIFF8 = 80
|
||||||
|
|
||||||
|
|
||||||
|
def has_no_codepage(data: bytes) -> bool:
|
||||||
|
"""В книге BIFF5/7 нет записи CODEPAGE: xlrd в этом случае декодирует байтовые строки как latin-1 (кириллица
|
||||||
|
превращается в мусор). Проверяется только глобальный раздел книги (`on_demand`: листы не разбираются)."""
|
||||||
|
try:
|
||||||
|
book = xlrd.open_workbook(file_contents=data, on_demand=True, logfile=io.StringIO())
|
||||||
|
except Exception: # noqa: BLE001 - нечитаемая книга: ошибку чтения даёт сам читатель, проба лишь не мешает ему
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
return bool(book.biff_version < _BIFF8 and book.codepage is None)
|
||||||
|
finally:
|
||||||
|
book.release_resources()
|
||||||
|
|
||||||
|
|
||||||
def read_xls(data: bytes) -> tuple[Sheet, ...]:
|
def read_xls(data: bytes) -> tuple[Sheet, ...]:
|
||||||
# logfile: xlrd пишет замечания о структуре BIFF в stdout — в выводе сервиса им не место
|
# logfile: xlrd пишет замечания о структуре BIFF в stdout — в выводе сервиса им не место
|
||||||
book = xlrd.open_workbook(file_contents=data, formatting_info=True, logfile=io.StringIO())
|
# Нет CODEPAGE — читаем как cp1251, не угадывая кодировку по содержимому (решение владельца, AURORA-1662)
|
||||||
|
override = FALLBACK_ENCODING if has_no_codepage(data) else None
|
||||||
|
book = xlrd.open_workbook(
|
||||||
|
file_contents=data, formatting_info=True, logfile=io.StringIO(), encoding_override=override
|
||||||
|
)
|
||||||
try:
|
try:
|
||||||
return tuple(_sheet(book, sheet) for sheet in book.sheets())
|
return tuple(_sheet(book, sheet) for sheet in book.sheets())
|
||||||
finally:
|
finally:
|
||||||
|
|||||||
170
api/tests/synthetic/xls_writer.py
Normal file
170
api/tests/synthetic/xls_writer.py
Normal file
@ -0,0 +1,170 @@
|
|||||||
|
"""Минимальный писатель XLS BIFF5/7 для тестов: значения ячеек, имена листов, кодовая страница по выбору.
|
||||||
|
|
||||||
|
Это НЕ файл Excel: нет стилей (одна гарнитура и один XF), объединений, outline и свойств документа. Структура —
|
||||||
|
ровно то, что нужно `xlrd` с `formatting_info=True`: поток `Book` в контейнере OLE2 (v3, сектор 512 байт), globals
|
||||||
|
(`BOF`, [`CODEPAGE`], `FONT`, `XF`, `BOUNDSHEET`, `EOF`) и по подпотоку на лист (`BOF`, `DIMENSIONS`, `LABEL`/`NUMBER`,
|
||||||
|
`EOF`). Байтовые строки записываются в кодировке книги (cp1251 для русской локали 1С/Excel).
|
||||||
|
|
||||||
|
Назначение — xls без записи CODEPAGE (AURORA-1662): такие книги пишет 1С и старые выгрузки, а Excel и LibreOffice
|
||||||
|
запись CODEPAGE всегда добавляют, поэтому получить их из обычных инструментов нельзя. Запись детерминирована.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import struct
|
||||||
|
from collections.abc import Mapping
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
__all__ = ["write_xls"]
|
||||||
|
|
||||||
|
CellValue = str | int | Decimal | float
|
||||||
|
SheetCells = Mapping[tuple[int, int], CellValue] # (строка, колонка) 1-based -> значение
|
||||||
|
|
||||||
|
CP1251 = 1251
|
||||||
|
|
||||||
|
_BOF = 0x0809
|
||||||
|
_EOF = 0x000A
|
||||||
|
_CODEPAGE = 0x0042
|
||||||
|
_FONT = 0x0031
|
||||||
|
_XF = 0x00E0
|
||||||
|
_BOUNDSHEET = 0x0085
|
||||||
|
_DIMENSIONS = 0x0000
|
||||||
|
_NUMBER = 0x0203
|
||||||
|
_LABEL = 0x0204
|
||||||
|
|
||||||
|
_BIFF5 = 0x0500
|
||||||
|
_TYPE_GLOBALS = 0x0005
|
||||||
|
_TYPE_SHEET = 0x0010
|
||||||
|
_MAX_STRING = 255
|
||||||
|
|
||||||
|
# --- контейнер OLE2 ---
|
||||||
|
_SECTOR = 512
|
||||||
|
_MINI_STREAM_CUTOFF = 4096 # поток короче лежит в мини-потоке — писатель его не делает, поэтому добивает поток нулями
|
||||||
|
_FAT_ENTRIES = _SECTOR // 4
|
||||||
|
_ENDOFCHAIN = 0xFFFFFFFE
|
||||||
|
_FATSECT = 0xFFFFFFFD
|
||||||
|
_FREESECT = 0xFFFFFFFF
|
||||||
|
_NOSTREAM = 0xFFFFFFFF
|
||||||
|
_DIR_ENTRY = 128
|
||||||
|
_OLE_SIGNATURE = bytes.fromhex("D0CF11E0A1B11AE1")
|
||||||
|
|
||||||
|
|
||||||
|
def _record(opcode: int, payload: bytes = b"") -> bytes:
|
||||||
|
return struct.pack("<HH", opcode, len(payload)) + payload
|
||||||
|
|
||||||
|
|
||||||
|
def _bof(kind: int) -> bytes:
|
||||||
|
# версия BIFF5, тип подпотока, номер сборки и год — как у Excel 95/97 в режиме BIFF5
|
||||||
|
return _record(_BOF, struct.pack("<HHHH", _BIFF5, kind, 0x1CF2, 0x07CC))
|
||||||
|
|
||||||
|
|
||||||
|
def _label(row: int, col: int, text: str, encoding: str) -> bytes:
|
||||||
|
raw = text.encode(encoding, errors="replace")[:_MAX_STRING]
|
||||||
|
return _record(_LABEL, struct.pack("<HHHH", row - 1, col - 1, 0, len(raw)) + raw)
|
||||||
|
|
||||||
|
|
||||||
|
def _number(row: int, col: int, value: Decimal | float | int) -> bytes:
|
||||||
|
return _record(_NUMBER, struct.pack("<HHHd", row - 1, col - 1, 0, float(value)))
|
||||||
|
|
||||||
|
|
||||||
|
def _sheet_stream(cells: SheetCells, encoding: str) -> bytes:
|
||||||
|
rows = [row for row, _ in cells]
|
||||||
|
cols = [col for _, col in cells]
|
||||||
|
body = [_bof(_TYPE_SHEET)]
|
||||||
|
body.append(
|
||||||
|
_record(
|
||||||
|
_DIMENSIONS,
|
||||||
|
struct.pack(
|
||||||
|
"<HHHH", min(rows, default=1) - 1, max(rows, default=0), min(cols, default=1) - 1, max(cols, default=0)
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
for (row, col), value in sorted(cells.items()):
|
||||||
|
if isinstance(value, str):
|
||||||
|
body.append(_label(row, col, value, encoding))
|
||||||
|
else:
|
||||||
|
body.append(_number(row, col, value))
|
||||||
|
body.append(_record(_EOF))
|
||||||
|
return b"".join(body)
|
||||||
|
|
||||||
|
|
||||||
|
def _globals_stream(names: list[bytes], sheet_sizes: list[int], codepage: int | None) -> bytes:
|
||||||
|
"""Globals; смещения листов зависят от длины самих globals, поэтому длина считается по заготовке с нулями."""
|
||||||
|
|
||||||
|
def build(offsets: list[int]) -> bytes:
|
||||||
|
parts = [_bof(_TYPE_GLOBALS)]
|
||||||
|
if codepage is not None:
|
||||||
|
parts.append(_record(_CODEPAGE, struct.pack("<H", codepage)))
|
||||||
|
arial = b"Arial"
|
||||||
|
parts.append(_record(_FONT, struct.pack("<HHHHHBBBBB", 200, 0, 0x7FFF, 400, 0, 0, 0, 0, 0, len(arial)) + arial))
|
||||||
|
# XF BIFF5 (16 байт): шрифт 0, формат General (0), стиль-родитель, без выравнивания/границ/заливки
|
||||||
|
parts.append(_record(_XF, struct.pack("<HHHBBIi", 0, 0, 0xFFF5, 0, 0, 0, 0)))
|
||||||
|
for name, offset in zip(names, offsets, strict=True):
|
||||||
|
parts.append(_record(_BOUNDSHEET, struct.pack("<IBBB", offset, 0, 0, len(name)) + name))
|
||||||
|
parts.append(_record(_EOF))
|
||||||
|
return b"".join(parts)
|
||||||
|
|
||||||
|
head = len(build([0] * len(names)))
|
||||||
|
offsets: list[int] = []
|
||||||
|
position = head
|
||||||
|
for size in sheet_sizes:
|
||||||
|
offsets.append(position)
|
||||||
|
position += size
|
||||||
|
return build(offsets)
|
||||||
|
|
||||||
|
|
||||||
|
def _directory(stream_size: int) -> bytes:
|
||||||
|
def entry(name: str, kind: int, child: int, start: int, size: int) -> bytes:
|
||||||
|
encoded = name.encode("utf-16-le") + b"\x00\x00" if name else b""
|
||||||
|
fields = struct.pack(
|
||||||
|
"<64sHBBIII16sIQQIII",
|
||||||
|
encoded,
|
||||||
|
len(encoded),
|
||||||
|
kind,
|
||||||
|
1,
|
||||||
|
_NOSTREAM,
|
||||||
|
_NOSTREAM,
|
||||||
|
child,
|
||||||
|
b"",
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
start,
|
||||||
|
size,
|
||||||
|
0,
|
||||||
|
)
|
||||||
|
return fields
|
||||||
|
|
||||||
|
empty = entry("", 0, _NOSTREAM, 0, 0)
|
||||||
|
entries = [entry("Root Entry", 5, 1, _ENDOFCHAIN, 0), entry("Book", 2, _NOSTREAM, 2, stream_size), empty, empty]
|
||||||
|
return b"".join(entries)
|
||||||
|
|
||||||
|
|
||||||
|
def _ole2(stream: bytes) -> bytes:
|
||||||
|
"""Контейнер OLE2 с единственным потоком `Book`: сектор 0 — FAT, 1 — каталог, дальше — поток."""
|
||||||
|
padded = stream.ljust(max(len(stream), _MINI_STREAM_CUTOFF), b"\x00")
|
||||||
|
padded = padded.ljust(-(-len(padded) // _SECTOR) * _SECTOR, b"\x00")
|
||||||
|
count = len(padded) // _SECTOR
|
||||||
|
if count + 2 > _FAT_ENTRIES:
|
||||||
|
raise ValueError(f"книга {len(padded)} байт не помещается в один сектор FAT писателя")
|
||||||
|
fat = [_FATSECT, _ENDOFCHAIN, *range(3, count + 2), _ENDOFCHAIN]
|
||||||
|
fat += [_FREESECT] * (_FAT_ENTRIES - len(fat))
|
||||||
|
header = struct.pack(
|
||||||
|
"<8s16sHHHHH6sIIIIIIIII",
|
||||||
|
_OLE_SIGNATURE, b"", 0x003E, 3, 0xFFFE, 9, 6, b"",
|
||||||
|
0, 1, 1, 0, _MINI_STREAM_CUTOFF, _ENDOFCHAIN, 0, _ENDOFCHAIN, 0,
|
||||||
|
) # fmt: skip
|
||||||
|
difat = struct.pack("<109I", 0, *([_FREESECT] * 108))
|
||||||
|
return (
|
||||||
|
(header + difat).ljust(_SECTOR, b"\x00")
|
||||||
|
+ struct.pack(f"<{_FAT_ENTRIES}I", *fat)
|
||||||
|
+ _directory(len(padded)).ljust(_SECTOR, b"\x00")
|
||||||
|
+ padded
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def write_xls(sheets: Mapping[str, SheetCells], *, codepage: int | None = CP1251, encoding: str = "cp1251") -> bytes:
|
||||||
|
"""xls BIFF5 с листами `sheets` (имя -> ячейки). `codepage=None` — записи CODEPAGE в книге нет (AURORA-1662);
|
||||||
|
строки при этом всё равно записаны в `encoding`: так их пишет 1С в русской локали."""
|
||||||
|
names = [name.encode(encoding, errors="replace") for name in sheets]
|
||||||
|
streams = [_sheet_stream(cells, encoding) for cells in sheets.values()]
|
||||||
|
globals_part = _globals_stream(names, [len(item) for item in streams], codepage)
|
||||||
|
return _ole2(globals_part + b"".join(streams))
|
||||||
@ -18,6 +18,8 @@ from src.parser.grid import EMPTY_CELL, Cell, MergedRange
|
|||||||
from src.parser.readers import read_book
|
from src.parser.readers import read_book
|
||||||
from src.parser.readers import xlsb as xlsb_reader
|
from src.parser.readers import xlsb as xlsb_reader
|
||||||
from src.parser.readers._common import make_cell
|
from src.parser.readers._common import make_cell
|
||||||
|
from src.parser.readers.xls import NO_CODEPAGE_WARNING
|
||||||
|
from tests.synthetic.xls_writer import write_xls
|
||||||
|
|
||||||
|
|
||||||
def _synthetic_xlsx() -> bytes:
|
def _synthetic_xlsx() -> bytes:
|
||||||
@ -208,3 +210,27 @@ def test_integral_float_is_read_without_fraction(value: float, expected_text: st
|
|||||||
assert cell is not None
|
assert cell is not None
|
||||||
assert (cell.kind, cell.text) == ("number", expected_text)
|
assert (cell.kind, cell.text) == ("number", expected_text)
|
||||||
assert cell.value == Decimal(str(value))
|
assert cell.value == Decimal(str(value))
|
||||||
|
|
||||||
|
|
||||||
|
# --- xls без записи CODEPAGE (AURORA-1662) ---
|
||||||
|
|
||||||
|
_CYRILLIC_CELLS = {(1, 1): "Оборотно-сальдовая ведомость", (2, 2): Decimal("12.5")}
|
||||||
|
|
||||||
|
|
||||||
|
def test_xls_without_codepage_is_read_as_cp1251_with_warning() -> None:
|
||||||
|
data = write_xls({"ОСВ 60": _CYRILLIC_CELLS}, codepage=None)
|
||||||
|
|
||||||
|
sheets, warnings = read_book(data, "XLS")
|
||||||
|
|
||||||
|
assert [sheet.name for sheet in sheets] == ["ОСВ 60"]
|
||||||
|
assert sheets[0].text_at(1, 1) == "Оборотно-сальдовая ведомость"
|
||||||
|
assert warnings == [NO_CODEPAGE_WARNING]
|
||||||
|
|
||||||
|
|
||||||
|
def test_xls_with_codepage_is_read_as_before_without_warning() -> None:
|
||||||
|
data = write_xls({"ОСВ 60": _CYRILLIC_CELLS}, codepage=1251)
|
||||||
|
|
||||||
|
sheets, warnings = read_book(data, "XLS")
|
||||||
|
|
||||||
|
assert sheets[0].text_at(1, 1) == "Оборотно-сальдовая ведомость"
|
||||||
|
assert warnings == []
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user