Andrey Epifancev 591b3f4434 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>
2026-10-18 11:50:39 +04:00

171 lines
7.2 KiB
Python
Raw 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.

"""Минимальный писатель 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))