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>
This commit is contained in:
parent
40287e1a31
commit
73f9e9c2d8
@ -1,7 +1,11 @@
|
|||||||
"""Чтение XLSX через openpyxl (полное чтение: нужны merged, outline level и шрифт)."""
|
"""Чтение XLSX через openpyxl (полное чтение: нужны merged, outline level и шрифт)."""
|
||||||
|
|
||||||
import io
|
import io
|
||||||
|
import posixpath
|
||||||
|
import zipfile
|
||||||
|
from collections import Counter
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
from xml.etree import ElementTree
|
||||||
|
|
||||||
import openpyxl
|
import openpyxl
|
||||||
|
|
||||||
@ -10,15 +14,79 @@ from src.parser.readers._common import make_cell
|
|||||||
|
|
||||||
__all__ = ["read_xlsx"]
|
__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, ...]:
|
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:
|
try:
|
||||||
return tuple(_sheet(worksheet) for worksheet in book.worksheets)
|
return tuple(_sheet(worksheet) for worksheet in book.worksheets)
|
||||||
finally:
|
finally:
|
||||||
book.close()
|
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:
|
def _sheet(ws: Any) -> Sheet:
|
||||||
cells: dict[tuple[int, int], Cell] = {}
|
cells: dict[tuple[int, int], Cell] = {}
|
||||||
# `_cells` — разреженное хранилище openpyxl (версия закреплена): iter_rows создал бы пустые ячейки на весь
|
# `_cells` — разреженное хранилище openpyxl (версия закреплена): iter_rows создал бы пустые ячейки на весь
|
||||||
|
|||||||
@ -195,6 +195,86 @@ def test_zip_without_workbook_bin_goes_to_openpyxl_and_is_unreadable() -> None:
|
|||||||
read_book(_zip_with("hello.txt"), "XLSX")
|
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": (
|
||||||
|
'<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">'
|
||||||
|
'<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>'
|
||||||
|
'<Default Extension="xml" ContentType="application/xml"/>'
|
||||||
|
f'<Override PartName="/xl/workbook.xml" ContentType="{ct}.sheet.main+xml"/>'
|
||||||
|
f'<Override PartName="/xl/worksheets/sheet1.xml" ContentType="{ct}.worksheet+xml"/>'
|
||||||
|
f'<Override PartName="/xl/sharedStrings.xml" ContentType="{ct}.sharedStrings+xml"/>'
|
||||||
|
"</Types>"
|
||||||
|
),
|
||||||
|
"_rels/.rels": (
|
||||||
|
'<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">'
|
||||||
|
f'<Relationship Id="rId1" Type="{rel}/officeDocument" Target="xl/workbook.xml"/>'
|
||||||
|
"</Relationships>"
|
||||||
|
),
|
||||||
|
"xl/workbook.xml": (
|
||||||
|
f'<workbook xmlns="{main}" xmlns:r="{rel}">'
|
||||||
|
'<sheets><sheet name="ОСВ" sheetId="1" r:id="rId1"/></sheets></workbook>'
|
||||||
|
),
|
||||||
|
"xl/_rels/workbook.xml.rels": (
|
||||||
|
'<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">'
|
||||||
|
f'<Relationship Id="rId1" Type="{rel}/worksheet" Target="worksheets/sheet1.xml"/>'
|
||||||
|
f'<Relationship Id="rId2" Type="{rel}/sharedStrings" Target="sharedStrings.xml"/>'
|
||||||
|
"</Relationships>"
|
||||||
|
),
|
||||||
|
"xl/worksheets/sheet1.xml": (
|
||||||
|
f'<worksheet xmlns="{main}"><sheetData>'
|
||||||
|
'<row r="1"><c r="A1" t="s"><v>0</v></c><c r="B1"><v>1500</v></c></row>'
|
||||||
|
"</sheetData></worksheet>"
|
||||||
|
),
|
||||||
|
part_name: f'<sst xmlns="{main}" count="1" uniqueCount="1"><si><t>Счет 62</t></si></sst>',
|
||||||
|
}
|
||||||
|
buffer = io.BytesIO()
|
||||||
|
with zipfile.ZipFile(buffer, "w", zipfile.ZIP_DEFLATED) as archive:
|
||||||
|
for name, xml in parts.items():
|
||||||
|
archive.writestr(name, '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' + 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(
|
@pytest.mark.parametrize(
|
||||||
("value", "expected_text"),
|
("value", "expected_text"),
|
||||||
[
|
[
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user