Andrey Epifancev e1559c9770 test: генератор синтетики и сценарии спеки на синтетических файлах
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
2026-10-05 15:04:50 +04:00

192 lines
8.2 KiB
Python
Raw Permalink 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.

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