"""Минимальный писатель XLSB (BIFF12) для тестов: значения ячеек и имена листов, без стилей и объединений. Это НЕ файл Excel: в книге нет `styles.bin`, свойств документа и прочего, что пишет Excel. Структура — ровно то, что нужно читателю `pyxlsb` (его использует `src.parser.readers.xlsb`): `[Content_Types].xml`, `_rels/.rels`, `xl/workbook.bin` (+ `.rels`), `xl/sharedStrings.bin`, `xl/worksheets/sheetN.bin`. Строки лежат в таблице общих строк (`BrtCellIsst`), числа — `BrtCellReal` (double). Запись детерминирована: порядок частей фиксирован, метки времени в zip постоянны. Назначение — получить `s13_no_outline_indent.xlsb` без Excel/LibreOffice (ruling R-9); совместимость с Excel не проверялась. Реальный xlsb, сохранённый Excel, остаётся нерешённым вопросом доверия к читателю (см. README fixtures). """ import io import struct import zipfile from collections.abc import Mapping, Sequence from decimal import Decimal __all__ = ["write_xlsb"] # Метка времени всех частей архива постоянна: файл байт в байт воспроизводим ZIP_DATE_TIME = (2026, 1, 1, 0, 0, 0) # Идентификаторы записей BIFF12 (MS-XLSB 2.3) _ROW_HDR = 0x0000 _CELL_REAL = 0x0005 _CELL_ISST = 0x0007 _SST_ITEM = 0x0013 _BEGIN_BOOK = 0x0183 _END_BOOK = 0x0184 _BEGIN_SHEET = 0x0181 _END_SHEET = 0x0182 _BEGIN_BUNDLE_SHS = 0x018F _END_BUNDLE_SHS = 0x0190 _BUNDLE_SH = 0x019C _BEGIN_SHEET_DATA = 0x0191 _END_SHEET_DATA = 0x0192 _DIMENSION = 0x0194 _BEGIN_SST = 0x019F _END_SST = 0x01A0 _ID_SINGLE_BYTE_LIMIT = 0x80 _LEN_CONTINUATION = 0x80 _LEN_PAYLOAD_MASK = 0x7F _LEN_BITS = 7 _ROW_HEIGHT = 300 # 15 pt в двадцатых долях пункта CellValue = str | int | Decimal | float SheetCells = Mapping[tuple[int, int], CellValue] # (строка, колонка) 1-based -> значение _CONTENT_TYPES = ( '\n' '' '' '' '' "{sheets}" '' "" ) _SHEET_OVERRIDE = '' _ROOT_RELS = ( '\n' '' '' "" ) _WORKBOOK_RELS = ( '\n' '' "{sheets}" '' "" ) _SHEET_REL = ( '' ) def _record(record_id: int, payload: bytes = b"") -> bytes: """Запись BIFF12: идентификатор (1–2 байта), длина (7-битные группы), данные.""" head = bytes([record_id]) if record_id < _ID_SINGLE_BYTE_LIMIT else struct.pack(">= _LEN_BITS length.append(chunk | (_LEN_CONTINUATION if size else 0)) if not size: break return head + bytes(length) + payload def _wide_string(text: str) -> bytes: """XLWideString: число символов (uint32) и UTF-16LE.""" return struct.pack(" bytes: body = [_record(_BEGIN_SST, struct.pack(" bytes: body = [_record(_BEGIN_BOOK), _record(_BEGIN_BUNDLE_SHS)] for number, name in enumerate(names, start=1): # hsState (видимый), iTabID, strRelID, strName body.append( _record(_BUNDLE_SH, struct.pack(" float: return float(value) def _worksheet(cells: SheetCells, string_ids: dict[str, int]) -> bytes: rows: dict[int, dict[int, CellValue]] = {} for (row, col), value in cells.items(): rows.setdefault(row, {})[col] = value last_row = max(rows, default=1) last_col = max((col for row in rows.values() for col in row), default=1) body = [ _record(_BEGIN_SHEET), # 0-based границы: строки, колонки _record(_DIMENSION, struct.pack(" bytes: """Книга XLSB: имя листа → ячейки `{(строка, колонка): значение}` (1-based; текст или число).""" names = list(sheets) strings: list[str] = [] string_ids: dict[str, int] = {} total = 0 for cells in sheets.values(): for _, value in sorted(cells.items()): if isinstance(value, str): total += 1 if value not in string_ids: string_ids[value] = len(strings) strings.append(value) parts: list[tuple[str, bytes]] = [ ( "[Content_Types].xml", _CONTENT_TYPES.format( sheets="".join(_SHEET_OVERRIDE.format(n=n) for n in range(1, len(names) + 1)) ).encode(), ), ("_rels/.rels", _ROOT_RELS.encode()), ( "xl/_rels/workbook.bin.rels", _WORKBOOK_RELS.format( sheets="".join(_SHEET_REL.format(n=n) for n in range(1, len(names) + 1)), strings=len(names) + 1 ).encode(), ), ("xl/workbook.bin", _workbook(names)), ("xl/sharedStrings.bin", _shared_strings(strings, total)), ] parts += [(f"xl/worksheets/sheet{n}.bin", _worksheet(sheets[name], string_ids)) for n, name in enumerate(names, 1)] buffer = io.BytesIO() with zipfile.ZipFile(buffer, "w", zipfile.ZIP_DEFLATED) as archive: for name, data in parts: info = zipfile.ZipInfo(name, date_time=ZIP_DATE_TIME) info.compress_type = zipfile.ZIP_DEFLATED archive.writestr(info, data) return buffer.getvalue()