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