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"), [