api/tests/synthetic: детерминированный генератор s01…s13 (openpyxl, метки времени книги и zip зафиксированы), минимальный писатель BIFF12 для s13.xlsb (ruling R-9, читается pyxlsb), тесты сценариев спеки osv-parsing на файлах из fixtures/osv/synthetic/ (таблица «файл → ожидания», s13 равен s02 кроме предупреждения xlsb), тест побайтовой воспроизводимости генерации. synthetic_files() учитывает только файлы Excel (README.md каталога не файл набора). Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2
192 lines
8.2 KiB
Python
192 lines
8.2 KiB
Python
"""Минимальный писатель 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 = (
|
||
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n'
|
||
'<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">'
|
||
'<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>'
|
||
'<Default Extension="xml" ContentType="application/xml"/>'
|
||
'<Override PartName="/xl/workbook.bin" ContentType="application/vnd.ms-excel.sheet.binary.macroEnabled.main"/>'
|
||
"{sheets}"
|
||
'<Override PartName="/xl/sharedStrings.bin" '
|
||
'ContentType="application/vnd.ms-excel.sharedStrings"/>'
|
||
"</Types>"
|
||
)
|
||
_SHEET_OVERRIDE = '<Override PartName="/xl/worksheets/sheet{n}.bin" ContentType="application/vnd.ms-excel.worksheet"/>'
|
||
_ROOT_RELS = (
|
||
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n'
|
||
'<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">'
|
||
'<Relationship Id="rId1" '
|
||
'Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" '
|
||
'Target="xl/workbook.bin"/>'
|
||
"</Relationships>"
|
||
)
|
||
_WORKBOOK_RELS = (
|
||
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n'
|
||
'<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">'
|
||
"{sheets}"
|
||
'<Relationship Id="rId{strings}" '
|
||
'Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/sharedStrings" '
|
||
'Target="sharedStrings.bin"/>'
|
||
"</Relationships>"
|
||
)
|
||
_SHEET_REL = (
|
||
'<Relationship Id="rId{n}" '
|
||
'Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" '
|
||
'Target="worksheets/sheet{n}.bin"/>'
|
||
)
|
||
|
||
|
||
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("<H", record_id)
|
||
size = len(payload)
|
||
length = bytearray()
|
||
while True:
|
||
chunk = size & _LEN_PAYLOAD_MASK
|
||
size >>= _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("<I", len(text)) + text.encode("utf-16-le")
|
||
|
||
|
||
def _shared_strings(strings: Sequence[str], total: int) -> bytes:
|
||
body = [_record(_BEGIN_SST, struct.pack("<II", total, len(strings)))]
|
||
body += [_record(_SST_ITEM, b"\x00" + _wide_string(text)) for text in strings]
|
||
body.append(_record(_END_SST))
|
||
return b"".join(body)
|
||
|
||
|
||
def _workbook(names: Sequence[str]) -> 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("<II", 0, number) + _wide_string(f"rId{number}") + _wide_string(name))
|
||
)
|
||
body += [_record(_END_BUNDLE_SHS), _record(_END_BOOK)]
|
||
return b"".join(body)
|
||
|
||
|
||
def _number(value: int | Decimal | float) -> 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("<IIII", 0, last_row - 1, 0, last_col - 1)),
|
||
_record(_BEGIN_SHEET_DATA),
|
||
]
|
||
for row in sorted(rows):
|
||
# rw, ixfe, miyRw, флаги, число диапазонов колонок
|
||
body.append(_record(_ROW_HDR, struct.pack("<IIHBBI", row - 1, 0, _ROW_HEIGHT, 0, 0, 0)))
|
||
for col in sorted(rows[row]):
|
||
value = rows[row][col]
|
||
head = struct.pack("<II", col - 1, 0) # колонка, стиль
|
||
if isinstance(value, str):
|
||
body.append(_record(_CELL_ISST, head + struct.pack("<I", string_ids[value])))
|
||
else:
|
||
body.append(_record(_CELL_REAL, head + struct.pack("<d", _number(value))))
|
||
body += [_record(_END_SHEET_DATA), _record(_END_SHEET)]
|
||
return b"".join(body)
|
||
|
||
|
||
def write_xlsb(sheets: Mapping[str, SheetCells]) -> 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()
|