diff --git a/api/src/parser/readers/__init__.py b/api/src/parser/readers/__init__.py index c6883ae..372db75 100644 --- a/api/src/parser/readers/__init__.py +++ b/api/src/parser/readers/__init__.py @@ -26,7 +26,7 @@ from src.parser.readers._limits import ( check_xlsx_merged_area, check_zip_uncompressed_size, ) -from src.parser.readers.xls import read_xls +from src.parser.readers.xls import NO_CODEPAGE_WARNING, has_no_codepage, read_xls from src.parser.readers.xlsb import OUTLINE_UNAVAILABLE_WARNING, read_xlsb from src.parser.readers.xlsx import read_xlsx @@ -87,6 +87,8 @@ def read_book_detected(data: bytes, file_type: FileType, *, max_uncompressed_byt check_merged_area(sheets) if detected == "XLSB": warnings.append(OUTLINE_UNAVAILABLE_WARNING) + if detected == "XLS" and has_no_codepage(data): + warnings.append(NO_CODEPAGE_WARNING) return BookRead(sheets, warnings, detected) diff --git a/api/src/parser/readers/xls.py b/api/src/parser/readers/xls.py index 349f4cf..39d1b2a 100644 --- a/api/src/parser/readers/xls.py +++ b/api/src/parser/readers/xls.py @@ -8,12 +8,35 @@ import xlrd from src.parser.grid import Cell, MergedRange, Sheet from src.parser.readers._common import make_cell -__all__ = ["read_xls"] +__all__ = ["NO_CODEPAGE_WARNING", "has_no_codepage", "read_xls"] + +# Кодовая страница 1С и Excel в русской локали: в BIFF5/7 она задаёт кодировку всех байтовых строк книги +FALLBACK_ENCODING = "cp1251" +NO_CODEPAGE_WARNING = f"xls без кодовой страницы — строки прочитаны как {FALLBACK_ENCODING}" +# BIFF8 хранит строки в Unicode и от CODEPAGE не зависит; `biff_version` xlrd для него — 80 +_BIFF8 = 80 + + +def has_no_codepage(data: bytes) -> bool: + """В книге BIFF5/7 нет записи CODEPAGE: xlrd в этом случае декодирует байтовые строки как latin-1 (кириллица + превращается в мусор). Проверяется только глобальный раздел книги (`on_demand`: листы не разбираются).""" + try: + book = xlrd.open_workbook(file_contents=data, on_demand=True, logfile=io.StringIO()) + except Exception: # noqa: BLE001 - нечитаемая книга: ошибку чтения даёт сам читатель, проба лишь не мешает ему + return False + try: + return bool(book.biff_version < _BIFF8 and book.codepage is None) + finally: + book.release_resources() def read_xls(data: bytes) -> tuple[Sheet, ...]: # logfile: xlrd пишет замечания о структуре BIFF в stdout — в выводе сервиса им не место - book = xlrd.open_workbook(file_contents=data, formatting_info=True, logfile=io.StringIO()) + # Нет CODEPAGE — читаем как cp1251, не угадывая кодировку по содержимому (решение владельца, AURORA-1662) + override = FALLBACK_ENCODING if has_no_codepage(data) else None + book = xlrd.open_workbook( + file_contents=data, formatting_info=True, logfile=io.StringIO(), encoding_override=override + ) try: return tuple(_sheet(book, sheet) for sheet in book.sheets()) finally: diff --git a/api/tests/synthetic/xls_writer.py b/api/tests/synthetic/xls_writer.py new file mode 100644 index 0000000..259bbac --- /dev/null +++ b/api/tests/synthetic/xls_writer.py @@ -0,0 +1,170 @@ +"""Минимальный писатель XLS BIFF5/7 для тестов: значения ячеек, имена листов, кодовая страница по выбору. + +Это НЕ файл Excel: нет стилей (одна гарнитура и один XF), объединений, outline и свойств документа. Структура — +ровно то, что нужно `xlrd` с `formatting_info=True`: поток `Book` в контейнере OLE2 (v3, сектор 512 байт), globals +(`BOF`, [`CODEPAGE`], `FONT`, `XF`, `BOUNDSHEET`, `EOF`) и по подпотоку на лист (`BOF`, `DIMENSIONS`, `LABEL`/`NUMBER`, +`EOF`). Байтовые строки записываются в кодировке книги (cp1251 для русской локали 1С/Excel). + +Назначение — xls без записи CODEPAGE (AURORA-1662): такие книги пишет 1С и старые выгрузки, а Excel и LibreOffice +запись CODEPAGE всегда добавляют, поэтому получить их из обычных инструментов нельзя. Запись детерминирована. +""" + +import struct +from collections.abc import Mapping +from decimal import Decimal + +__all__ = ["write_xls"] + +CellValue = str | int | Decimal | float +SheetCells = Mapping[tuple[int, int], CellValue] # (строка, колонка) 1-based -> значение + +CP1251 = 1251 + +_BOF = 0x0809 +_EOF = 0x000A +_CODEPAGE = 0x0042 +_FONT = 0x0031 +_XF = 0x00E0 +_BOUNDSHEET = 0x0085 +_DIMENSIONS = 0x0000 +_NUMBER = 0x0203 +_LABEL = 0x0204 + +_BIFF5 = 0x0500 +_TYPE_GLOBALS = 0x0005 +_TYPE_SHEET = 0x0010 +_MAX_STRING = 255 + +# --- контейнер OLE2 --- +_SECTOR = 512 +_MINI_STREAM_CUTOFF = 4096 # поток короче лежит в мини-потоке — писатель его не делает, поэтому добивает поток нулями +_FAT_ENTRIES = _SECTOR // 4 +_ENDOFCHAIN = 0xFFFFFFFE +_FATSECT = 0xFFFFFFFD +_FREESECT = 0xFFFFFFFF +_NOSTREAM = 0xFFFFFFFF +_DIR_ENTRY = 128 +_OLE_SIGNATURE = bytes.fromhex("D0CF11E0A1B11AE1") + + +def _record(opcode: int, payload: bytes = b"") -> bytes: + return struct.pack(" bytes: + # версия BIFF5, тип подпотока, номер сборки и год — как у Excel 95/97 в режиме BIFF5 + return _record(_BOF, struct.pack(" bytes: + raw = text.encode(encoding, errors="replace")[:_MAX_STRING] + return _record(_LABEL, struct.pack(" bytes: + return _record(_NUMBER, struct.pack(" bytes: + rows = [row for row, _ in cells] + cols = [col for _, col in cells] + body = [_bof(_TYPE_SHEET)] + body.append( + _record( + _DIMENSIONS, + struct.pack( + " bytes: + """Globals; смещения листов зависят от длины самих globals, поэтому длина считается по заготовке с нулями.""" + + def build(offsets: list[int]) -> bytes: + parts = [_bof(_TYPE_GLOBALS)] + if codepage is not None: + parts.append(_record(_CODEPAGE, struct.pack(" bytes: + def entry(name: str, kind: int, child: int, start: int, size: int) -> bytes: + encoded = name.encode("utf-16-le") + b"\x00\x00" if name else b"" + fields = struct.pack( + "<64sHBBIII16sIQQIII", + encoded, + len(encoded), + kind, + 1, + _NOSTREAM, + _NOSTREAM, + child, + b"", + 0, + 0, + 0, + start, + size, + 0, + ) + return fields + + empty = entry("", 0, _NOSTREAM, 0, 0) + entries = [entry("Root Entry", 5, 1, _ENDOFCHAIN, 0), entry("Book", 2, _NOSTREAM, 2, stream_size), empty, empty] + return b"".join(entries) + + +def _ole2(stream: bytes) -> bytes: + """Контейнер OLE2 с единственным потоком `Book`: сектор 0 — FAT, 1 — каталог, дальше — поток.""" + padded = stream.ljust(max(len(stream), _MINI_STREAM_CUTOFF), b"\x00") + padded = padded.ljust(-(-len(padded) // _SECTOR) * _SECTOR, b"\x00") + count = len(padded) // _SECTOR + if count + 2 > _FAT_ENTRIES: + raise ValueError(f"книга {len(padded)} байт не помещается в один сектор FAT писателя") + fat = [_FATSECT, _ENDOFCHAIN, *range(3, count + 2), _ENDOFCHAIN] + fat += [_FREESECT] * (_FAT_ENTRIES - len(fat)) + header = struct.pack( + "<8s16sHHHHH6sIIIIIIIII", + _OLE_SIGNATURE, b"", 0x003E, 3, 0xFFFE, 9, 6, b"", + 0, 1, 1, 0, _MINI_STREAM_CUTOFF, _ENDOFCHAIN, 0, _ENDOFCHAIN, 0, + ) # fmt: skip + difat = struct.pack("<109I", 0, *([_FREESECT] * 108)) + return ( + (header + difat).ljust(_SECTOR, b"\x00") + + struct.pack(f"<{_FAT_ENTRIES}I", *fat) + + _directory(len(padded)).ljust(_SECTOR, b"\x00") + + padded + ) + + +def write_xls(sheets: Mapping[str, SheetCells], *, codepage: int | None = CP1251, encoding: str = "cp1251") -> bytes: + """xls BIFF5 с листами `sheets` (имя -> ячейки). `codepage=None` — записи CODEPAGE в книге нет (AURORA-1662); + строки при этом всё равно записаны в `encoding`: так их пишет 1С в русской локали.""" + names = [name.encode(encoding, errors="replace") for name in sheets] + streams = [_sheet_stream(cells, encoding) for cells in sheets.values()] + globals_part = _globals_stream(names, [len(item) for item in streams], codepage) + return _ole2(globals_part + b"".join(streams)) diff --git a/api/tests/unit/parser/test_readers.py b/api/tests/unit/parser/test_readers.py index 2a65490..c1e6615 100644 --- a/api/tests/unit/parser/test_readers.py +++ b/api/tests/unit/parser/test_readers.py @@ -18,6 +18,8 @@ from src.parser.grid import EMPTY_CELL, Cell, MergedRange from src.parser.readers import read_book from src.parser.readers import xlsb as xlsb_reader from src.parser.readers._common import make_cell +from src.parser.readers.xls import NO_CODEPAGE_WARNING +from tests.synthetic.xls_writer import write_xls def _synthetic_xlsx() -> bytes: @@ -208,3 +210,27 @@ def test_integral_float_is_read_without_fraction(value: float, expected_text: st assert cell is not None assert (cell.kind, cell.text) == ("number", expected_text) assert cell.value == Decimal(str(value)) + + +# --- xls без записи CODEPAGE (AURORA-1662) --- + +_CYRILLIC_CELLS = {(1, 1): "Оборотно-сальдовая ведомость", (2, 2): Decimal("12.5")} + + +def test_xls_without_codepage_is_read_as_cp1251_with_warning() -> None: + data = write_xls({"ОСВ 60": _CYRILLIC_CELLS}, codepage=None) + + sheets, warnings = read_book(data, "XLS") + + assert [sheet.name for sheet in sheets] == ["ОСВ 60"] + assert sheets[0].text_at(1, 1) == "Оборотно-сальдовая ведомость" + assert warnings == [NO_CODEPAGE_WARNING] + + +def test_xls_with_codepage_is_read_as_before_without_warning() -> None: + data = write_xls({"ОСВ 60": _CYRILLIC_CELLS}, codepage=1251) + + sheets, warnings = read_book(data, "XLS") + + assert sheets[0].text_at(1, 1) == "Оборотно-сальдовая ведомость" + assert warnings == []