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