diff --git a/api/src/normalizer/ocr/__init__.py b/api/src/normalizer/ocr/__init__.py new file mode 100644 index 0000000..776b1fa --- /dev/null +++ b/api/src/normalizer/ocr/__init__.py @@ -0,0 +1,3 @@ +"""Нормализатор OCR: конвейер чистых функций `text` → `dates` → `amounts` → `layout` → `rows` → `header` → `document` +(D-4, §11.1). Проверенные на стенде функции старого ядра (`../reference-old-arm/api/src/core/PDFWorker/`) перенесены +дословно; DB-обвязка не переносится.""" diff --git a/api/src/normalizer/ocr/text.py b/api/src/normalizer/ocr/text.py new file mode 100644 index 0000000..2ddc6bf --- /dev/null +++ b/api/src/normalizer/ocr/text.py @@ -0,0 +1,70 @@ +"""Очистка OCR-текста (§11.1 п. 1, 6). Перенос из `date_utils._normalize`, `utils._clear_value`, +`_keep_digits_only`, `_remove_spec_space`, `_normalize_header`, `_is_code_header`, `_is_value_header` +старого ядра; алгоритмы без изменений.""" + +import re +from collections.abc import Mapping + +__all__ = [ + "clean_value", + "collapse_whitespace", + "digits_only", + "fix_lookalikes", + "is_code_header", + "is_value_header", + "normalize_header", +] + +_YEAR_RE = re.compile(r"\d{4}") +# первая строка значения из цифр, пробелов, `|`, точек и т. п. — `|` в ней разделяет клетки формы, а не единица +_CELL_SEPARATOR_LINE = re.compile(r"[\s\d|•·.:,]*") + + +def fix_lookalikes(text: str, lookalikes: Mapping[str, str]) -> str: + """Буквы-двойники → цифры только вплотную к цифре, в несколько проходов («2ОО5» → «2005»); `|` — разделитель + клеток, если первая строка состоит из цифр, пробелов и пунктуации («2|0|2|4|» → «2 0 2 4 »).""" + text = text.replace(" ", " ") + if _CELL_SEPARATOR_LINE.fullmatch(text.split("\n", 1)[0]): + text = text.replace("|", " ") + if not lookalikes: + return text + chars = re.escape("".join(lookalikes)) + near_digit = re.compile(rf"(?<=\d)[{chars}]|[{chars}](?=\d)") + table = str.maketrans(dict(lookalikes)) + previous = None + while previous != text: # «2ОО5» требует двух проходов + previous = text + text = near_digit.sub(lambda match: match.group(0).translate(table), text) + return text + + +def clean_value(text: str) -> str: + """Остаются буквы, цифры, пробел и точка; переносы строк и табуляции — пробел; края обрезаются.""" + cleaned = "".join( + char for char in text.replace("\n", " ").replace("\t", " ") if char.isdigit() or char.isalpha() or char in " ." + ) + return cleaned.strip() + + +def digits_only(text: str) -> str: + return "".join(char for char in text if char.isdigit()) + + +def collapse_whitespace(text: str) -> str: + return text.replace("\n", " ").replace("\t", " ").strip() + + +def normalize_header(text: object) -> str: + """Нижний регистр, всё не-буквенно-цифровое → пробел, пробелы схлопнуты (для сравнения заголовков и label).""" + lowered = str(text or "").lower() + return " ".join("".join(char if char.isalnum() else " " for char in lowered).split()) + + +def is_code_header(text: str) -> bool: + """Ячейка шапки «Код…» (текст уже нормализован `normalize_header`).""" + return text.startswith("код") + + +def is_value_header(text: str) -> bool: + """Ячейка шапки колонки значений: есть четыре цифры года или текст начинается с «на » / «за ».""" + return bool(_YEAR_RE.search(text)) or text.startswith(("на ", "за ")) diff --git a/api/tests/unit/normalizer/test_text.py b/api/tests/unit/normalizer/test_text.py new file mode 100644 index 0000000..d36e7db --- /dev/null +++ b/api/tests/unit/normalizer/test_text.py @@ -0,0 +1,99 @@ +"""Очистка OCR-текста (§11.1 п. 1, 6; D-4 `text.py`): перенос функций старого ядра, поведение — как в источнике.""" + +import pytest + +from src.normalizer.ocr.text import ( + clean_value, + collapse_whitespace, + digits_only, + fix_lookalikes, + is_code_header, + is_value_header, + normalize_header, +) + +LOOKALIKES = { + "О": "0", + "о": "0", + "O": "0", + "o": "0", + "З": "3", + "з": "3", + "l": "1", + "I": "1", + "|": "1", + "і": "1", + "б": "6", + "S": "5", + "s": "5", + "Ч": "4", +} + + +class TestFixLookalikes: + @pytest.mark.parametrize( + ("raw", "expected"), + [ + ("2ОО5", "2005"), # два прохода: О после 2, затем О после 0 + ("3l.12.2О24", "31.12.2024"), + ("Общество", "Общество"), # буквы без соседней цифры не трогаются + ("2|0|2|4|", "2 0 2 4 "), # первая строка — только цифры и |: это разделитель клеток + ("3l", "31"), # | не разделитель, если есть буквы + ("", ""), + ], + ) + def test_replaces_only_next_to_digits(self, raw: str, expected: str) -> None: + assert fix_lookalikes(raw, LOOKALIKES) == expected + + def test_separator_rule_looks_at_first_line_only(self) -> None: + assert fix_lookalikes("2|0|2|4|\nмусор", LOOKALIKES) == "2 0 2 4 \nмусор" + + def test_empty_table_keeps_text(self) -> None: + assert fix_lookalikes("2ОО5", {}) == "2ОО5" + + +@pytest.mark.parametrize( + ("raw", "expected"), + [ + ("13 070", "13 070"), + ("(7744536}", "7744536"), + ("изо :", "изо"), + ("На 30 июня\n2025 г", "На 30 июня 2025 г"), + ("", ""), + ], +) +def test_clean_value(raw: str, expected: str) -> None: + assert clean_value(raw) == expected + + +def test_digits_only() -> None: + assert digits_only("3|1 21 2| о! О' 0 0; 3: 51") == "3121200351" + assert digits_only("+") == "" + + +def test_collapse_whitespace() -> None: + assert collapse_whitespace(" На 31 декабря\n2024 г.\t") == "На 31 декабря 2024 г." + + +class TestHeaders: + def test_normalize_header(self) -> None: + assert normalize_header("Наименование\nпоказателя:") == "наименование показателя" + assert normalize_header(None) == "" + + def test_code_header(self) -> None: + assert is_code_header(normalize_header("Код")) + assert is_code_header(normalize_header("Код строки")) + assert not is_code_header(normalize_header("Наименование")) + + @pytest.mark.parametrize( + ("text", "expected"), + [ + ("на 30 июня 2025 г", True), + ("за январь июнь 2025 г", True), + ("2024", True), + ("пояснения", False), + ("", False), + ], + ) + def test_value_header(self, text: str, expected: bool) -> None: + assert is_value_header(text) is expected