"""Чтение XLSX через openpyxl (полное чтение: нужны merged, outline level и шрифт).""" import io import posixpath import zipfile from collections import Counter from typing import Any from xml.etree import ElementTree import openpyxl from src.parser.grid import Cell, MergedRange, Sheet from src.parser.readers._common import make_cell __all__ = ["read_xlsx"] _CONTENT_TYPES_PART = "[Content_Types].xml" _RELS_SUFFIX = ".rels" _RELS_DIR = "_rels" _LOCAL_OVERRIDE = "Override" _LOCAL_RELATIONSHIP = "Relationship" _EXTERNAL_TARGET_MODE = "External" def read_xlsx(data: bytes) -> tuple[Sheet, ...]: book = openpyxl.load_workbook(io.BytesIO(_with_referenced_part_names(data)), data_only=True, read_only=False) try: return tuple(_sheet(worksheet) for worksheet in book.worksheets) finally: book.close() def _with_referenced_part_names(data: bytes) -> bytes: """Книга, в которой части архива названы так, как на них ссылаются `[Content_Types].xml` и `*.rels`. Excel сравнивает имена частей без учёта регистра (OPC), openpyxl — точно: таблица строк `xl/SharedStrings.xml` при ссылке на `sharedStrings.xml` даёт KeyError, а лист `Sheet1.xml` при ссылке на `sheet1.xml` пропадает молча (AURORA-1707). Если часть отличается от ссылки только регистром, архив пересобирается в памяти без сжатия (распакованный размер уже ограничен `_detect`); иначе возвращаются те же байты. Две части, подходящие к одной ссылке, не переименовываются: какую из них имел в виду автор, неизвестно. """ with zipfile.ZipFile(io.BytesIO(data)) as archive: names = archive.namelist() present = set(names) wanted = {name.lower(): name for name in _referenced_part_names(archive, present) if name not in present} candidates = Counter(name.lower() for name in names if name.lower() in wanted) renames = {name: wanted[name.lower()] for name in names if candidates[name.lower()] == 1} if not renames: return data buffer = io.BytesIO() with zipfile.ZipFile(buffer, "w", zipfile.ZIP_STORED) as target: for info in archive.infolist(): target.writestr(renames.get(info.filename, info.filename), archive.read(info)) return buffer.getvalue() def _referenced_part_names(archive: zipfile.ZipFile, present: set[str]) -> set[str]: """Имена частей (без ведущего `/`) из `Override PartName` и из `Target` внутренних связей всех `*.rels`. Нечитаемый XML пропускается: книгу с ним не откроет и openpyxl, ошибку покажет он.""" referenced: set[str] = set() if _CONTENT_TYPES_PART in present: for element in _elements(archive, _CONTENT_TYPES_PART, _LOCAL_OVERRIDE): if part := element.get("PartName"): referenced.add(part.lstrip("/")) for rels in present: if not rels.endswith(_RELS_SUFFIX): continue # `xl/_rels/workbook.xml.rels` описывает связи `xl/workbook.xml`: цели относительно `xl/` rels_dir = posixpath.dirname(rels) base = posixpath.dirname(rels_dir) if posixpath.basename(rels_dir) == _RELS_DIR else "" for element in _elements(archive, rels, _LOCAL_RELATIONSHIP): target = element.get("Target") if not target or element.get("TargetMode") == _EXTERNAL_TARGET_MODE: continue path = target.lstrip("/") if target.startswith("/") else posixpath.join(base, target) referenced.add(posixpath.normpath(path)) return referenced def _elements(archive: zipfile.ZipFile, name: str, local_name: str) -> list[ElementTree.Element]: """Элементы части `name` с локальным именем `local_name` (пространство имён не важно).""" try: root = ElementTree.fromstring(archive.read(name)) except ElementTree.ParseError: return [] return [element for element in root.iter() if element.tag.rpartition("}")[2] == local_name] 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), number_format=source.number_format, ) 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), )