fix(backend): detect corrupted PDF text layer by control character density

This commit is contained in:
omp committed 2026-11-01 19:30:07 -03:00
1 parent b25bf8ff7d
commit 8f79993646
2 files changed
+109 -2

No files matched your search

+35 -1
View File
@@ -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).
+74 -1
View File
@@ -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())