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
139 lines
5.8 KiB
Python
139 lines
5.8 KiB
Python
"""Единая модель листа (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))
|