fix(parser): ячейки из одних пробелов не хранятся в Sheet.cells (T2.1)

make_cell возвращает None для строки, у которой strip() пуст (пробелы, NBSP и
прочие Unicode-пробелы): «пробелы = пусто» на уровне хранения, sheet.cell даёт
EMPTY_CELL. Тесты: пробельные ячейки xlsx/xlsb отсутствуют в cells, строка из
пробелов пуста, merged-диапазон с пробельным origin даёт "".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2
This commit is contained in:
Andrey Epifancev 2026-10-05 12:16:28 +04:00
parent ed401a00a3
commit c6a768dc70
2 changed files with 20 additions and 7 deletions

View File

@ -19,7 +19,8 @@ def make_cell(value: object, *, bold: bool = False, indent: int = 0) -> Cell | N
"""Ячейка из значения библиотеки чтения; `None` — пустая ячейка (в `Sheet.cells` не попадает). """Ячейка из значения библиотеки чтения; `None` — пустая ячейка (в `Sheet.cells` не попадает).
Числа — `Decimal(str(value))` без квантования (его делает `numbers.py`); bool проверяется раньше int. Числа — `Decimal(str(value))` без квантования (его делает `numbers.py`); bool проверяется раньше int.
Строка из пробелов остаётся `text` (значение как пришло), её `Cell.text` пуст. Время без даты — текстом. Строка из одних пробельных символов (включая NBSP) — пустая ячейка (`None`): «пробелы» = пусто на уровне
хранения, а не только в аксессорах `Sheet`. Время без даты — текстом.
""" """
if value is None or value == "": if value is None or value == "":
return None 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 if isinstance(value, datetime.date): # datetime.datetime — подкласс date
return Cell(value, "date", bold=bold, indent=indent) return Cell(value, "date", bold=bold, indent=indent)
text = value if isinstance(value, str) else str(value) 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)) return Cell(text, "text", bold=bold, indent=indent or leading_spaces(text))

View File

@ -14,13 +14,14 @@ from openpyxl.styles import Alignment, Font
from src.contract import ErrorCode from src.contract import ErrorCode
from src.parser.errors import ParserError, UnreadableError, UnsupportedFormatError 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 read_book
from src.parser.readers import xlsb as xlsb_reader from src.parser.readers import xlsb as xlsb_reader
def _synthetic_xlsx() -> bytes: def _synthetic_xlsx() -> bytes:
"""Две страницы: шапка с merged-заголовками, outline 0/1/2, bold, отступ, число, дата, bool, пустые строки.""" """Две страницы: шапка с merged-заголовками, outline 0/1/2, bold, отступ, число, дата, bool, пустые строки,
ячейки из пробелов и NBSP, merged-диапазон с пробельным origin."""
book = openpyxl.Workbook() book = openpyxl.Workbook()
osv = book.active osv = book.active
assert osv is not None assert osv is not None
@ -44,6 +45,9 @@ def _synthetic_xlsx() -> bytes:
osv["B7"] = datetime.datetime(2024, 9, 30) osv["B7"] = datetime.datetime(2024, 9, 30)
osv.row_dimensions[7].outline_level = 2 osv.row_dimensions[7].outline_level = 2
osv["A9"] = " " osv["A9"] = " "
osv["B9"] = "\xa0"
osv["D3"] = " "
osv.merge_cells("D3:E3")
osv["A10"] = "Итого" osv["A10"] = "Итого"
osv["C10"] = True osv["C10"] = True
other = book.create_sheet("Прочее") 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, 1) == Cell(" Контрагент", "text", indent=3)
assert osv.cell(7, 2) == Cell(datetime.datetime(2024, 9, 30), "date") assert osv.cell(7, 2) == Cell(datetime.datetime(2024, 9, 30), "date")
assert osv.cell(10, 3) == Cell(True, "bool") 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(8)
assert osv.is_row_empty(9) 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(4, 1) == "Счет"
assert osv.text_at(3, 3) == "Сальдо на начало периода" 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.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 all(cell.kind != "empty" for cell in osv.cells.values())
assert sheets[1].row_cells(1) == [(1, Cell("x", "text"))] 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 (sheet,) = sheets
assert sheet.name == "Лист1" assert sheet.name == "Лист1"
assert sheet.cell(1, 1) == Cell("Счет", "text") 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, 2) == Cell(Decimal("0.1"), "number")
assert sheet.cell(3, 3) == Cell(False, "bool") assert sheet.cell(3, 3) == Cell(False, "bool")
assert sheet.merged == () assert sheet.merged == ()