diff --git a/api/src/parser/readers/xlsx.py b/api/src/parser/readers/xlsx.py
index 065963e..bfa38e0 100644
--- a/api/src/parser/readers/xlsx.py
+++ b/api/src/parser/readers/xlsx.py
@@ -1,7 +1,11 @@
"""Чтение 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
@@ -10,15 +14,79 @@ 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(data), data_only=True, read_only=False)
+ 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 создал бы пустые ячейки на весь
diff --git a/api/tests/unit/parser/test_readers.py b/api/tests/unit/parser/test_readers.py
index 96f68d1..f1dd5ee 100644
--- a/api/tests/unit/parser/test_readers.py
+++ b/api/tests/unit/parser/test_readers.py
@@ -195,6 +195,86 @@ def test_zip_without_workbook_bin_goes_to_openpyxl_and_is_unreadable() -> None:
read_book(_zip_with("hello.txt"), "XLSX")
+def _renamed_parts(data: bytes, renames: dict[str, str]) -> bytes:
+ """Та же книга, у частей из `renames` — другое имя в архиве; ссылки `[Content_Types].xml` и `*.rels` прежние."""
+ buffer = io.BytesIO()
+ with zipfile.ZipFile(io.BytesIO(data)) as source, zipfile.ZipFile(buffer, "w", zipfile.ZIP_DEFLATED) as target:
+ for info in source.infolist():
+ target.writestr(renames.get(info.filename, info.filename), source.read(info))
+ return buffer.getvalue()
+
+
+def _xlsx_with_shared_strings(part_name: str) -> bytes:
+ """Минимальная книга с таблицей общих строк (openpyxl пишет строки inline) под именем `part_name` в архиве;
+ `[Content_Types].xml` и `workbook.xml.rels` ссылаются на `xl/sharedStrings.xml`."""
+ main = "http://schemas.openxmlformats.org/spreadsheetml/2006/main"
+ rel = "http://schemas.openxmlformats.org/officeDocument/2006/relationships"
+ ct = "application/vnd.openxmlformats-officedocument.spreadsheetml"
+ parts = {
+ "[Content_Types].xml": (
+ ''
+ ''
+ ''
+ f''
+ f''
+ f''
+ ""
+ ),
+ "_rels/.rels": (
+ ''
+ f''
+ ""
+ ),
+ "xl/workbook.xml": (
+ f''
+ ''
+ ),
+ "xl/_rels/workbook.xml.rels": (
+ ''
+ f''
+ f''
+ ""
+ ),
+ "xl/worksheets/sheet1.xml": (
+ f''
+ '01500
'
+ ""
+ ),
+ part_name: f'Счет 62',
+ }
+ buffer = io.BytesIO()
+ with zipfile.ZipFile(buffer, "w", zipfile.ZIP_DEFLATED) as archive:
+ for name, xml in parts.items():
+ archive.writestr(name, '' + xml)
+ return buffer.getvalue()
+
+
+def test_shared_strings_part_name_is_case_insensitive_like_in_excel() -> None:
+ """Некоторые выгрузки кладут таблицу строк как `xl/SharedStrings.xml` (AURORA-1707); Excel их открывает."""
+ sheets, warnings = read_book(_xlsx_with_shared_strings("xl/SharedStrings.xml"), "XLSX")
+
+ assert (sheets, warnings) == read_book(_xlsx_with_shared_strings("xl/sharedStrings.xml"), "XLSX")
+ assert sheets[0].cell(1, 1) == Cell("Счет 62", "text")
+
+
+def test_two_parts_matching_one_reference_are_not_guessed() -> None:
+ data = _xlsx_with_shared_strings("xl/SharedStrings.xml")
+ buffer = io.BytesIO(data)
+ with zipfile.ZipFile(buffer, "a") as archive:
+ archive.writestr("xl/SHAREDSTRINGS.xml", archive.read("xl/SharedStrings.xml"))
+
+ with pytest.raises(UnreadableError, match="sharedStrings.xml"):
+ read_book(buffer.getvalue(), "XLSX")
+
+
+def test_part_found_by_relationship_is_case_insensitive_too() -> None:
+ """Лист openpyxl находит по ссылке из `workbook.xml.rels`, стили — по `[Content_Types].xml`."""
+ original = _synthetic_xlsx()
+ renames = {"xl/worksheets/sheet1.xml": "xl/worksheets/Sheet1.xml", "xl/styles.xml": "XL/Styles.XML"}
+
+ assert read_book(_renamed_parts(original, renames), "XLSX") == read_book(original, "XLSX")
+
+
@pytest.mark.parametrize(
("value", "expected_text"),
[