fix(backend): detect corrupted PDF text layer by control character density
This commit is contained in:
1 parent
b25bf8ff7d
commit
8f79993646
2 files changed
+109
-2
No files matched your search
@@ -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).
|
||||
|
||||
|
||||
@@ -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())
|
||||
|
||||
Reference in new issue
Block a user