"""Минимальный писатель 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(" bytes: # версия BIFF5, тип подпотока, номер сборки и год — как у Excel 95/97 в режиме BIFF5 return _record(_BOF, struct.pack(" bytes: raw = text.encode(encoding, errors="replace")[:_MAX_STRING] return _record(_LABEL, struct.pack(" bytes: return _record(_NUMBER, struct.pack(" bytes: rows = [row for row, _ in cells] cols = [col for _, col in cells] body = [_bof(_TYPE_SHEET)] body.append( _record( _DIMENSIONS, struct.pack( " bytes: """Globals; смещения листов зависят от длины самих globals, поэтому длина считается по заготовке с нулями.""" def build(offsets: list[int]) -> bytes: parts = [_bof(_TYPE_GLOBALS)] if codepage is not None: parts.append(_record(_CODEPAGE, struct.pack(" 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))