"""Extração de texto de PDFs com fallback para OCR. A camada textual de muitos PDFs de Bíblia usa fontes sem mapeamento Unicode: o extrator devolve sequências de caracteres de controle em vez de letras. Detectamos esse caso e rasterizamos a página para o Tesseract. O resultado do OCR é cacheado por (sha256 do arquivo, página) para que reindexações não repitam horas de OCR. """ from __future__ import annotations import hashlib import logging import shutil import subprocess import tempfile import unicodedata from dataclasses import dataclass from pathlib import Path from typing import Iterator, Sequence logger = logging.getLogger(__name__) try: # pragma: no cover - dependência real do ambiente import pymupdf except ImportError: # pragma: no cover - permite rodar testes sem MuPDF pymupdf = None # type: ignore[assignment] class OcrError(RuntimeError): """Falha ao executar o Tesseract.""" @dataclass(frozen=True) class PageText: """Texto extraído de uma página do PDF.""" page: int text: str failed: bool = False def legible_ratio(text: str) -> float: """Proporção de caracteres imprimíveis legíveis no texto. Um texto normal de Bíblia fica acima de 0.9; a camada corrompida do PDF real fica abaixo de 0.05. Espaços em branco não contam como denominador. """ if not text: return 0.0 meaningful = [ch for ch in text if not ch.isspace()] if not meaningful: return 0.0 legible = sum(1 for ch in meaningful if _is_legible_char(ch)) 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"): # C0/C1 de controle: sinal de fonte sem ToUnicode. return False return ch.isalnum() or unicodedata.category(ch).startswith(("L", "N", "P", "S", "Z")) def _is_blank_pixmap(pixmap) -> bool: """Diz se a página rasterizada está visualmente em branco. Usa as amostras do pixmap: uma página de texto tem variância alta entre pixels; uma página em branco tem todos os pixels iguais. """ try: samples = pixmap.samples stride = max(1, pixmap.n if getattr(pixmap, "n", 1) else 1) total = pixmap.width * pixmap.height if total == 0: return True sample = samples[0] return all(s == sample for s in samples) except Exception: # pragma: no cover return False def file_sha256(path: Path, chunk_size: int = 1024 * 1024) -> str: """SHA-256 do conteúdo do arquivo.""" digest = hashlib.sha256() with path.open("rb") as f: while chunk := f.read(chunk_size): digest.update(chunk) return digest.hexdigest() class PdfExtractor: """Extrai texto de PDFs página a página, com OCR como fallback.""" def __init__( self, cache_dir: Path, dpi: int = 200, 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) self.dpi = dpi 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( self, path: Path, file_hash: str | None = None ) -> Iterator[PageText]: """Extrai o texto de todas as páginas do PDF.""" if self._pymupdf is None: # pragma: no cover raise OcrError("PyMuPDF não está disponível") path = Path(path) file_hash = file_hash or file_sha256(path) document = self._pymupdf.open(path) try: for index in range(len(document)): page = document[index] yield self.extract_page(page, file_hash, index + 1) finally: document.close() def extract_pages_parallel( self, pages: Sequence[tuple[int, str]], file_hash: str, path: Path | None = None, workers: int = 4, ) -> list[PageText]: """Extrai várias páginas em paralelo, acelerando o OCR. `pages` é uma sequência de `(número_da_página, texto_da_camada)`. As páginas que precisam de OCR são rasterizadas e enviadas ao Tesseract em paralelo; as demais são tratadas em sequência, pois a camada textual é extraída diretamente. """ results: list[PageText | None] = [None] * len(pages) needs_ocr: list[tuple[int, int]] = [] for position, (page_number, text) in enumerate(pages): cache_file = self._cache_path(file_hash, page_number) if cache_file.exists(): results[position] = PageText( page=page_number, text=cache_file.read_text(encoding="utf-8"), ) continue if self._is_corrupted(text): needs_ocr.append((position, page_number)) else: results[position] = PageText(page=page_number, text=text) self._write_cache(cache_file, text) if needs_ocr: def ocr_one(entry: tuple[int, int]) -> tuple[int, str, bool]: _, page_number = entry text, failed = self._ocr_page_number(path, page_number) return page_number, text, failed for outcome in parallel_map( needs_ocr, ocr_one, workers=workers, default=None ): if outcome is None: continue page_number, text, failed = outcome results[page_number - 1] = PageText( page=page_number, text=text, failed=failed ) self._write_cache(self._cache_path(file_hash, page_number), text) return [result for result in results if result is not None] def _ocr_page_number( self, path: Path | None, page_number: int ) -> tuple[str, bool]: """Abre o documento e aplica OCR na página indicada.""" if path is None or self._pymupdf is None: # pragma: no cover return "", True try: with self._pymupdf.open(path) as document: page = document[page_number - 1] return self._ocr_page(page) except Exception as exc: # pragma: no cover - PDF malformado logger.warning("Falha no OCR da página %s: %s", page_number, exc) return "", True def extract_page(self, page, file_hash: str, page_number: int) -> PageText: """Extrai o texto de uma página, usando cache quando disponível.""" cache_file = self._cache_path(file_hash, page_number) if cache_file.exists(): return PageText(page=page_number, text=cache_file.read_text(encoding="utf-8")) try: text = page.get_text("text") or "" except Exception as exc: # pragma: no cover - PDF malformado logger.warning("Falha ao extrair texto da página %s: %s", page_number, exc) text = "" failed = False 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). Uma página visualmente em branco não é falha: o documento pode ter separadores. Ela é detectada pela rasterização e não gasta OCR. Falha é o OCR que não pôde ser executado. """ try: pixmap = page.get_pixmap(dpi=self.dpi) except Exception as exc: logger.warning("Falha ao rasterizar página para OCR: %s", exc) return "", True if _is_blank_pixmap(pixmap): return "", False try: text = self._run_tesseract(pixmap, self.ocr_lang, self.ocr_timeout) except OcrError as exc: logger.warning("OCR falhou: %s", exc) return "", True return text, False def _run_tesseract(self, pixmap, language: str, timeout: int) -> str: """Executa o Tesseract sobre o pixmap e devolve o texto.""" if shutil.which("tesseract") is None: raise OcrError("tesseract não está instalado no sistema") with tempfile.TemporaryDirectory() as tmp: image_path = Path(tmp) / "page.png" pixmap.save(str(image_path)) try: completed = subprocess.run( [ "tesseract", str(image_path), "stdout", "-l", language, "--psm", "6", ], capture_output=True, text=True, timeout=timeout, check=False, ) except subprocess.TimeoutExpired as exc: raise OcrError(f"tesseract excedeu o timeout de {timeout}s") from exc if completed.returncode != 0: raise OcrError(f"tesseract falhou: {completed.stderr.strip()[:200]}") return completed.stdout def _cache_path(self, file_hash: str, page_number: int) -> Path: return self.cache_dir / file_hash / f"{page_number:05d}.txt" def _write_cache(self, cache_file: Path, text: str) -> None: try: cache_file.parent.mkdir(parents=True, exist_ok=True) cache_file.write_text(text, encoding="utf-8") except OSError as exc: # pragma: no cover - disco cheio / permissões logger.warning("Não foi possível gravar cache de OCR em %s: %s", cache_file, exc)