Andrey Epifancev b5a3b480b2 feat(parser): модель листа Sheet и читатели xlsx/xls/xlsb
grid.py: Cell, MergedRange, Sheet (1-based, только непустые ячейки, ленивые
индексы строк и merged; диапазоны больше 10 000 ячеек проверяются перебором),
column_letter для диагностики. errors.py: ParserError и коды контракта.
readers: read_book определяет формат по сигнатуре (zip → XLSX/XLSB по
xl/workbook.bin, OLE2 → XLS), предупреждает о несовпадении с file_type;
ошибки библиотек → UnreadableError одной строкой. Числа — Decimal(str(value)),
bool отдельно, даты как datetime; xlsb без outline/merged с предупреждением.
Тесты: синтетическая книга openpyxl, подмена file_type, мусор/обрезанные
файлы, xlsb через подменённый pyxlsb; 66 реальных ОСВ читаются < 5 с с
outline и merged; xls «62 за 3 квартал 2024»: строка 15 — outline 3.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2
2026-10-05 12:11:16 +04:00

45 lines
1.5 KiB
Python

"""Чтение XLSX через openpyxl (полное чтение: нужны merged, outline level и шрифт)."""
import io
from typing import Any
import openpyxl
from src.parser.grid import Cell, MergedRange, Sheet
from src.parser.readers._common import make_cell
__all__ = ["read_xlsx"]
def read_xlsx(data: bytes) -> tuple[Sheet, ...]:
book = openpyxl.load_workbook(io.BytesIO(data), data_only=True, read_only=False)
try:
return tuple(_sheet(worksheet) for worksheet in book.worksheets)
finally:
book.close()
def _sheet(ws: Any) -> Sheet:
cells: dict[tuple[int, int], Cell] = {}
# `_cells` — разреженное хранилище openpyxl (версия закреплена): iter_rows создал бы пустые ячейки на весь
# прямоугольник листа
for source in ws._cells.values():
cell = make_cell(source.value, bold=bool(source.font.bold), indent=int(source.alignment.indent or 0))
if cell is not None:
cells[(source.row, source.column)] = cell
merged = tuple(
MergedRange(top=span.min_row, left=span.min_col, bottom=span.max_row, right=span.max_col)
for span in ws.merged_cells.ranges
)
row_outline = {
row: int(dimension.outline_level) for row, dimension in ws.row_dimensions.items() if dimension.outline_level
}
return Sheet(
name=str(ws.title),
cells=cells,
merged=merged,
row_outline=row_outline,
n_rows=int(ws.max_row),
n_cols=int(ws.max_column),
)