Andrey Epifancev 6f579b6403 fix: merge-bomb — потоковый expat-скан всех XML-частей (I-1б), after-read считает только индексируемые диапазоны
R-25. Регулярочный скан `<mergeCell ref>` в xl/worksheets/*.xml обходился файлом < 5 КБ четырьмя способами
(лист вне xl/worksheets, ссылка на символ в ref, атрибут a:ref перед ref, «>» в кавычках). Теперь до openpyxl —
потоковый разбор xml.parsers.expat (namespace-aware) всех записей zip *.xml и записей, похожих на XML (openpyxl идёт
к листу по цели связи и расширение не проверяет): элемент с локальным именем mergeCell в любом пространстве имён
(openpyxl берёт дочерние mergeCell по локальному имени), только ref без префикса, площадь по строке ref через
CellRange openpyxl, ранний отказ при превышении MERGED_AREA_LIMIT; битый XML части *.xml и неразбираемый ref —
UNREADABLE. Стоимость: 12 мс на самом большом реальном xlsx (openpyxl 0,58 с), 0,8 с на 2 млн ячеек (openpyxl 10 с).

N1: check_merged_area после чтения считает только диапазоны площадью ≤ grid.MAX_INDEXED_AREA (константа стала
публичной) — законный merged на весь столбец в xls больше не отвергается.
Спека и README: правила скана, не покрытые риски (объём ячеек, sharedStrings/стили, entity expansion); N2 —
слепая зона R-21: достаточно свёрнутого прямого родителя.

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

139 lines
5.8 KiB
Python
Raw 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.

"""Единая модель листа (design D-1): читатели xlsx/xls/xlsb приводят книгу к `Sheet`, детекторы работают только с ним.
Индексы строк и колонок — 1-based, как в Excel (`row_number` контракта). Это и `readers/` — единственные места
парсера, где допустимы индексы и буквы колонок; буквы нужны только для диагностики (`column_letter`).
"""
import datetime
from collections.abc import Mapping
from dataclasses import dataclass
from decimal import Decimal
from functools import cached_property
from typing import Literal
__all__ = ["EMPTY_CELL", "MAX_INDEXED_AREA", "Cell", "CellKind", "MergedRange", "Sheet", "column_letter"]
CellKind = Literal["empty", "text", "number", "date", "bool"]
CellValue = str | Decimal | datetime.date | datetime.datetime | bool | None
_ALPHABET_SIZE = 26
# Диапазоны больше этой площади не раскладываются в индекс «ячейка → диапазон», а проверяются перебором:
# merged на весь столбец/лист не должен порождать миллионы записей
MAX_INDEXED_AREA = 10_000
@dataclass(frozen=True, slots=True)
class Cell:
"""Значение ячейки как пришло из файла (первичка не мутируется) и признаки оформления для каскада иерархии."""
value: CellValue
kind: CellKind
bold: bool = False
indent: int = 0 # alignment.indent, а при его отсутствии — число ведущих пробелов текста
@property
def text(self) -> str:
"""Строка для сопоставления: `str(value).strip()`; `None` и строка из пробелов дают ""."""
if self.value is None:
return ""
return str(self.value).strip()
EMPTY_CELL = Cell(None, "empty")
@dataclass(frozen=True, slots=True)
class MergedRange:
"""Объединённый диапазон, границы 1-based включительно; значение лежит в (top, left)."""
top: int
left: int
bottom: int
right: int
@property
def area(self) -> int:
return (self.bottom - self.top + 1) * (self.right - self.left + 1)
def contains(self, row: int, col: int) -> bool:
return self.top <= row <= self.bottom and self.left <= col <= self.right
@dataclass(frozen=True)
class Sheet:
"""Лист книги: только непустые ячейки, merged-диапазоны, outline level строк, габариты."""
name: str
cells: Mapping[tuple[int, int], Cell] # (row, col) 1-based
merged: tuple[MergedRange, ...]
row_outline: Mapping[int, int] # только строки с уровнем > 0
n_rows: int
n_cols: int
def cell(self, row: int, col: int) -> Cell:
return self.cells.get((row, col), EMPTY_CELL)
def text_at(self, row: int, col: int) -> str:
"""Текст ячейки; внутри merged-диапазона — текст его origin (левой верхней ячейки)."""
return self.cell(*self.merged_origin(row, col)).text
def merged_origin(self, row: int, col: int) -> tuple[int, int]:
span = self.merged_span(row, col)
if span is None:
return row, col
return span.top, span.left
def merged_span(self, row: int, col: int) -> MergedRange | None:
span = self._merged_index.get((row, col))
if span is not None:
return span
for large in self._large_merged:
if large.contains(row, col):
return large
return None
def row_cells(self, row: int) -> list[tuple[int, Cell]]:
"""Непустые ячейки строки `(col, cell)` по возрастанию col; ячейки из одних пробелов пропускаются."""
return list(self._rows.get(row, ()))
def is_row_empty(self, row: int) -> bool:
return row not in self._rows
def outline(self, row: int) -> int:
return self.row_outline.get(row, 0)
# Индексы строятся лениво один раз: cached_property пишет в __dict__ экземпляра в обход frozen-__setattr__
@cached_property
def _rows(self) -> dict[int, tuple[tuple[int, Cell], ...]]:
rows: dict[int, list[tuple[int, Cell]]] = {}
for (row, col), cell in self.cells.items():
if cell.text:
rows.setdefault(row, []).append((col, cell))
return {row: tuple(sorted(items, key=lambda item: item[0])) for row, items in rows.items()}
@cached_property
def _merged_index(self) -> dict[tuple[int, int], MergedRange]:
index: dict[tuple[int, int], MergedRange] = {}
for span in self.merged:
if span.area > MAX_INDEXED_AREA:
continue
for row in range(span.top, span.bottom + 1):
for col in range(span.left, span.right + 1):
index[(row, col)] = span
return index
@cached_property
def _large_merged(self) -> tuple[MergedRange, ...]:
return tuple(span for span in self.merged if span.area > MAX_INDEXED_AREA)
def column_letter(index: int) -> str:
"""Буква колонки Excel по 1-based индексу (1 → "A", 27 → "AA"). Только для диагностики `columns`."""
if index < 1:
raise ValueError(f"индекс колонки 1-based, получено {index}")
letters: list[str] = []
while index:
index, remainder = divmod(index - 1, _ALPHABET_SIZE)
letters.append(chr(ord("A") + remainder))
return "".join(reversed(letters))