From 8f79993646b0b445341cee688151214932f00ed4 Mon Sep 17 00:00:00 2001 From: omp Date: Sun, 1 Nov 2026 19:30:07 -0300 Subject: [PATCH] fix(backend): detect corrupted PDF text layer by control character density --- backend/app/extraction.py | 36 ++++++++++++++- backend/tests/test_extraction.py | 75 +++++++++++++++++++++++++++++++- 2 files changed, 109 insertions(+), 2 deletions(-) diff --git a/backend/app/extraction.py b/backend/app/extraction.py index b526506..a17d534 100644 --- a/backend/app/extraction.py +++ b/backend/app/extraction.py @@ -54,6 +54,27 @@ def legible_ratio(text: str) -> float: return legible / len(meaningful) +def control_char_ratio(text: str) -> float: + """Proporção de caracteres de controle C0 (fora de tab/newline/CR). + + É o sintoma direto de uma camada textual sem mapeamento Unicode: o extrator + devolve bytes de controle no lugar das letras. A razão de caracteres + legíveis não basta — um cabeçalho como "ÍNDICE" entre letras soltas eleva + o placar acima do limiar, e a página seguiria corrompida. + """ + if not text: + return 0.0 + meaningful = [ch for ch in text if not ch.isspace()] + if not meaningful: + return 0.0 + controls = sum( + 1 + for ch in meaningful + if unicodedata.category(ch) == "Cc" and ch not in "\t\n\r" + ) + return controls / len(meaningful) + + def _is_legible_char(ch: str) -> bool: """Caractere que um humano consegue ler.""" if unicodedata.category(ch).startswith("C"): @@ -102,6 +123,7 @@ class PdfExtractor: ocr_lang: str = "por+eng", ocr_timeout: int = 120, min_legible_ratio: float = 0.35, + max_control_ratio: float = 0.01, pymupdf_module=None, ): self.cache_dir = Path(cache_dir) @@ -109,6 +131,7 @@ class PdfExtractor: self.ocr_lang = ocr_lang self.ocr_timeout = ocr_timeout self.min_legible_ratio = min_legible_ratio + self.max_control_ratio = max_control_ratio self._pymupdf = pymupdf_module if pymupdf_module is not None else pymupdf def extract_document( @@ -141,12 +164,23 @@ class PdfExtractor: text = "" failed = False - if legible_ratio(text) < self.min_legible_ratio: + if self._is_corrupted(text): text, failed = self._ocr_page(page) self._write_cache(cache_file, text) return PageText(page=page_number, text=text, failed=failed) + def _is_corrupted(self, text: str) -> bool: + """Diz se a camada textual é inutilizável e exige OCR. + + Dois sinais independentes: texto pouco legível e densidade de + caracteres de controle. O segundo é o que pega a página do índice do + PDF real, em que letras avulsas elevam a legibilidade acima do limiar. + """ + if control_char_ratio(text) > self.max_control_ratio: + return True + return legible_ratio(text) < self.min_legible_ratio + def _ocr_page(self, page) -> tuple[str, bool]: """Rasteriza a página e aplica OCR. Devolve (texto, falhou). diff --git a/backend/tests/test_extraction.py b/backend/tests/test_extraction.py index dc23449..d232b12 100644 --- a/backend/tests/test_extraction.py +++ b/backend/tests/test_extraction.py @@ -13,7 +13,12 @@ import pytest sys.path.insert(0, str(Path(__file__).resolve().parents[1])) -from app.extraction import OcrError, PdfExtractor, legible_ratio # noqa: E402 +from app.extraction import ( # noqa: E402 + OcrError, + PdfExtractor, + control_char_ratio, + legible_ratio, +) class FakePage: @@ -95,6 +100,40 @@ def test_legible_ratio_is_low_for_control_characters(): assert legible_ratio(text) < 0.35 +def _corrupted_index_layer() -> str: + """Reproduz a camada textual corrompida da página 3 do índice real. + + Medido no PDF: 442 caracteres não-branco, sendo 276 controles C0 (62%) e + 166 legíveis (38%) — pontuação e letras soltas. A letra é o que empurra a + razão de legibilidade para 0.376, acima do limiar de 0.35. + """ + controls = "".join(chr(index) for index in range(1, 32)) * 9 # 279 controles + legible = "!\"#$%&'()*+,-./0123:;<=>?Gênesis:6" * 8 # letras e pontuação + return f"ÍNDICE\n{controls}{legible}\n" + + +def test_control_char_ratio_is_high_for_corrupted_layer(): + """A camada corrompida do PDF real mistura letras avulsas e controles. + + A razão de caracteres legíveis não basta para detectar o problema: no + índice do PDF, letras soltas entre os controles elevam o placar de + legibilidade acima do limiar. + """ + corrupted = _corrupted_index_layer() + assert legible_ratio(corrupted) > 0.35, "premissa do teste: legível alto" + assert control_char_ratio(corrupted) > 0.01 + + +def test_control_char_ratio_is_low_for_clean_text(): + text = "GÊNESIS\n[1]\n1 No princípio criou Deus os céus e a terra." + assert control_char_ratio(text) < 0.01 + + +def test_control_char_ratio_of_empty_text_is_zero(): + assert control_char_ratio("") == 0.0 + assert control_char_ratio(" \n\t ") == 0.0 + + def test_legible_ratio_of_empty_text_is_zero(): assert legible_ratio("") == 0.0 assert legible_ratio(" \n\t ") == 0.0 @@ -140,6 +179,40 @@ def test_corrupted_text_layer_triggers_ocr(tmp_cache, tmp_path, monkeypatch): assert pages[0].text.startswith("1 No princípio") +def test_partially_legible_layer_with_controls_still_triggers_ocr( + tmp_cache, tmp_path, monkeypatch +): + """Regressão do índice do PDF real. + + A página do índice tem "ÍNDICE" legível e o resto corrompido. A razão de + legibilidade fica acima de 0.35, mas os caracteres de controle revelam a + corrupção — sem esta regra a página seria indexada com lixo. + """ + corrupted = _corrupted_index_layer() + assert legible_ratio(corrupted) > 0.35 + + page = FakePage(text=corrupted, pixmap=FakePixmap()) + extractor = PdfExtractor( + cache_dir=tmp_cache, + pymupdf_module=FakePyMuPDF(FakeDocument([page])), + ) + + calls: list[int] = [] + + def fake_tesseract(pixmap, language, timeout): + calls.append(pixmap.width) + return "ÍNDICE\nGênesis: 6" + + monkeypatch.setattr(extractor, "_run_tesseract", fake_tesseract) + + pdf = tmp_path / "biblia.pdf" + pdf.write_bytes(b"%PDF-1.3") + pages = list(extractor.extract_document(pdf)) + + assert calls == [10], "OCR deveria ter sido chamado apesar da legibilidade alta" + assert "Gênesis" in pages[0].text + + def test_ocr_result_is_cached_and_reused(tmp_cache, tmp_path, monkeypatch): corrupted = "".join(chr(i) for i in range(1, 32)) page = FakePage(text=corrupted, pixmap=FakePixmap())