Часть, отличающаяся от ссылки [Content_Types].xml или *.rels только регистром (xl/SharedStrings.xml при ссылке на sharedStrings.xml), до openpyxl переименовывается по ссылке: раньше такая книга была UNREADABLE, а лист с другим регистром имени openpyxl терял молча. Две части под одну ссылку не угадываются. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
118 lines
5.5 KiB
Python
118 lines
5.5 KiB
Python
"""Чтение 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),
|
||
)
|