From c6a768dc706f2c184f6425f7495ef06467b2d411 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Mon, 5 Oct 2026 12:16:28 +0400 Subject: [PATCH] =?UTF-8?q?fix(parser):=20=D1=8F=D1=87=D0=B5=D0=B9=D0=BA?= =?UTF-8?q?=D0=B8=20=D0=B8=D0=B7=20=D0=BE=D0=B4=D0=BD=D0=B8=D1=85=20=D0=BF?= =?UTF-8?q?=D1=80=D0=BE=D0=B1=D0=B5=D0=BB=D0=BE=D0=B2=20=D0=BD=D0=B5=20?= =?UTF-8?q?=D1=85=D1=80=D0=B0=D0=BD=D1=8F=D1=82=D1=81=D1=8F=20=D0=B2=20She?= =?UTF-8?q?et.cells=20(T2.1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit make_cell возвращает None для строки, у которой strip() пуст (пробелы, NBSP и прочие Unicode-пробелы): «пробелы = пусто» на уровне хранения, sheet.cell даёт EMPTY_CELL. Тесты: пробельные ячейки xlsx/xlsb отсутствуют в cells, строка из пробелов пуста, merged-диапазон с пробельным origin даёт "". Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2 --- api/src/parser/readers/_common.py | 5 ++++- api/tests/unit/parser/test_readers.py | 22 ++++++++++++++++------ 2 files changed, 20 insertions(+), 7 deletions(-) diff --git a/api/src/parser/readers/_common.py b/api/src/parser/readers/_common.py index a2d2c98..4e457a3 100644 --- a/api/src/parser/readers/_common.py +++ b/api/src/parser/readers/_common.py @@ -19,7 +19,8 @@ def make_cell(value: object, *, bold: bool = False, indent: int = 0) -> Cell | N """Ячейка из значения библиотеки чтения; `None` — пустая ячейка (в `Sheet.cells` не попадает). Числа — `Decimal(str(value))` без квантования (его делает `numbers.py`); bool проверяется раньше int. - Строка из пробелов остаётся `text` (значение как пришло), её `Cell.text` пуст. Время без даты — текстом. + Строка из одних пробельных символов (включая NBSP) — пустая ячейка (`None`): «пробелы» = пусто на уровне + хранения, а не только в аксессорах `Sheet`. Время без даты — текстом. """ if value is None or value == "": return None @@ -30,6 +31,8 @@ def make_cell(value: object, *, bold: bool = False, indent: int = 0) -> Cell | N if isinstance(value, datetime.date): # datetime.datetime — подкласс date return Cell(value, "date", bold=bold, indent=indent) text = value if isinstance(value, str) else str(value) + if not text.strip(): + return None return Cell(text, "text", bold=bold, indent=indent or leading_spaces(text)) diff --git a/api/tests/unit/parser/test_readers.py b/api/tests/unit/parser/test_readers.py index 101e264..4c689ea 100644 --- a/api/tests/unit/parser/test_readers.py +++ b/api/tests/unit/parser/test_readers.py @@ -14,13 +14,14 @@ from openpyxl.styles import Alignment, Font from src.contract import ErrorCode from src.parser.errors import ParserError, UnreadableError, UnsupportedFormatError -from src.parser.grid import Cell, MergedRange +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 def _synthetic_xlsx() -> bytes: - """Две страницы: шапка с merged-заголовками, outline 0/1/2, bold, отступ, число, дата, bool, пустые строки.""" + """Две страницы: шапка с merged-заголовками, outline 0/1/2, bold, отступ, число, дата, bool, пустые строки, + ячейки из пробелов и NBSP, merged-диапазон с пробельным origin.""" book = openpyxl.Workbook() osv = book.active assert osv is not None @@ -44,6 +45,9 @@ def _synthetic_xlsx() -> bytes: osv["B7"] = datetime.datetime(2024, 9, 30) osv.row_dimensions[7].outline_level = 2 osv["A9"] = " " + osv["B9"] = "\xa0" + osv["D3"] = " " + osv.merge_cells("D3:E3") osv["A10"] = "Итого" osv["C10"] = True other = book.create_sheet("Прочее") @@ -67,14 +71,19 @@ def test_xlsx_reader_builds_sheet_model() -> None: assert osv.cell(7, 1) == Cell(" Контрагент", "text", indent=3) assert osv.cell(7, 2) == Cell(datetime.datetime(2024, 9, 30), "date") assert osv.cell(10, 3) == Cell(True, "bool") - assert osv.cell(9, 1).kind == "text" + assert osv.cell(9, 1) is EMPTY_CELL + assert osv.cell(9, 2) is EMPTY_CELL + assert (9, 1) not in osv.cells + assert (9, 2) not in osv.cells assert osv.is_row_empty(8) assert osv.is_row_empty(9) - assert set(osv.merged) == {MergedRange(3, 1, 4, 1), MergedRange(3, 2, 3, 3)} + assert osv.text_at(3, 5) == "" + assert osv.merged_origin(3, 5) == (3, 4) + assert set(osv.merged) == {MergedRange(3, 1, 4, 1), MergedRange(3, 2, 3, 3), MergedRange(3, 4, 3, 5)} assert osv.text_at(4, 1) == "Счет" assert osv.text_at(3, 3) == "Сальдо на начало периода" assert [osv.outline(row) for row in range(1, 11)] == [0, 0, 0, 0, 0, 1, 2, 0, 0, 0] - assert (osv.n_rows, osv.n_cols) == (10, 3) + assert (osv.n_rows, osv.n_cols) == (10, 5) assert all(cell.kind != "empty" for cell in osv.cells.values()) assert sheets[1].row_cells(1) == [(1, Cell("x", "text"))] @@ -168,7 +177,8 @@ def test_xlsb_signature_is_read_by_pyxlsb_without_outline(monkeypatch: pytest.Mo (sheet,) = sheets assert sheet.name == "Лист1" assert sheet.cell(1, 1) == Cell("Счет", "text") - assert sheet.cell(1, 3) == Cell(" ", "text") + assert sheet.cell(1, 3) is EMPTY_CELL + assert (1, 3) not in sheet.cells assert sheet.cell(3, 2) == Cell(Decimal("0.1"), "number") assert sheet.cell(3, 3) == Cell(False, "bool") assert sheet.merged == ()