Andrey Epifancev 73f9e9c2d8 xlsx: имена частей без учёта регистра, как в Excel (#66, AURORA-1707)
Часть, отличающаяся от ссылки [Content_Types].xml или *.rels только регистром
(xl/SharedStrings.xml при ссылке на sharedStrings.xml), до openpyxl
переименовывается по ссылке: раньше такая книга была UNREADABLE, а лист с
другим регистром имени openpyxl терял молча. Две части под одну ссылку не
угадываются.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 13:06:35 +04:00

118 lines
5.5 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.

"""Чтение 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),
)