"""Parser do texto bíblico: reconstrói livro, capítulo e versículo. O PDF do projeto (Almeida Revista e Corrigida) traz o índice com a página inicial de cada livro, capítulos no formato `[N]` e versículos numerados no início da linha. O parser usa o índice quando disponível e cai para o cabeçalho da página quando não. """ from __future__ import annotations import re import unicodedata from dataclasses import dataclass from typing import Iterable, Mapping from .extraction import PageText # Os livros canônicos presentes nesta edição da Almeida (L.C.C./eBooksBrasil). # A edição não inclui Neemias nem Ester: o índice do PDF vai de Esdras (p. 1407) # direto para Jó (p. 1440), e as páginas 1407–1439 são o livro de Esdras. BOOKS: tuple[str, ...] = ( # Antigo Testamento "Gênesis", "Êxodo", "Levítico", "Números", "Deuteronômio", "Josué", "Juízes", "Rute", "I Samuel", "II Samuel", "I Reis", "II Reis", "I Crônicas", "II Crônicas", "Esdras", "Jó", "Salmos", "Provérbios", "Eclesiastes", "Cântico dos Cânticos", "Isaias", "Jeremias", "Lamentações", "Ezequiel", "Daniel", "Oséias", "Joel", "Amós", "Abdias", "Jonas", "Miqueias", "Naum", "Habacuque", "Sofonias", "Ageu", "Zacarias", "Malaquias", # Novo Testamento "Mateus", "Marcos", "Lucas", "João", "Atos", "Romanos", "I Coríntios", "II Coríntios", "Galatas", "Efésios", "Filipenses", "Colossenses", "I Tessalonicenses", "II Tessalonicenses", "I Timóteo", "II Timóteo", "Tito", "Filemom", "Hebreus", "Tiago", "I Pedro", "II Pedro", "I João", "II João", "III João", "Judas", "Apocalipse", ) # Variantes de grafia encontradas em digitalizações e em transcrições. BOOK_ALIASES: dict[str, str] = { "Lamentações de Jeremias": "Lamentações", "Cânticos": "Cântico dos Cânticos", "Cântico dos Canticos": "Cântico dos Cânticos", "Salmo": "Salmos", "Provérbios": "Provérbios", "Eclesiastes": "Eclesiastes", "Apocalipse": "Apocalipse", "Coríntios I": "I Coríntios", "Coríntios II": "II Coríntios", "Tessalonicenses I": "I Tessalonicenses", "Tessalonicenses II": "II Tessalonicenses", "Timóteo I": "I Timóteo", "Timóteo II": "II Timóteo", "Pedro I": "I Pedro", "Pedro II": "II Pedro", "João I": "I João", "João II": "II João", "João III": "III João", "Samuel I": "I Samuel", "Samuel II": "II Samuel", "Reis I": "I Reis", "Reis II": "II Reis", "Crônicas I": "I Crônicas", "Crônicas II": "II Crônicas", } # Livros do canoneto completo ausentes nesta edição (Neemias, Ester). Não são # reconhecidos, para que páginas de outras edições não sejam atribuídas a eles. _OPTIONAL_BOOKS: tuple[str, ...] = () def _normalize(text: str) -> str: """Remove acentos e normaliza espaços, para comparação tolerante.""" decomposed = unicodedata.normalize("NFKD", text) stripped = "".join(ch for ch in decomposed if not unicodedata.combining(ch)) return re.sub(r"\s+", " ", stripped).strip().casefold() _NORMALIZED_BOOKS: dict[str, str] = {_normalize(name): name for name in BOOKS} for _alias, _canonical in BOOK_ALIASES.items(): _NORMALIZED_BOOKS[_normalize(_alias)] = _canonical def canonical_book(name: str) -> str | None: """Devolve o nome canônico do livro, ou None se não reconhecido.""" return _NORMALIZED_BOOKS.get(_normalize(name)) @dataclass(frozen=True) class VerseChunk: """Um versículo indexável com seus metadados de origem.""" book: str chapter: int verse: int page: int file: str text: str @property def reference(self) -> str: """Referência no formato `Livro Capítulo:Versículo`.""" return f"{self.book} {self.chapter}:{self.verse}" @property def chunk_id(self) -> str: """ID determinístico do chunk no banco vetorial.""" import hashlib key = f"{self.file}|{self.book}|{self.chapter}|{self.verse}" return hashlib.sha1(key.encode("utf-8")).hexdigest() _CHAPTER_RE = re.compile(r"^\[\s*(\d{1,3})\s*\]$") _CHAPTER_TEXT_RE = re.compile(r"^(?:CAP[ÍI]TULO|Chapter)\s+(\d{1,3})$", re.IGNORECASE) _VERSE_RE = re.compile(r"^(\d{1,3})\s+(.*)$") _VERSE_ONLY_RE = re.compile(r"^(\d{1,3})$") _INDEX_ENTRY_RE = re.compile(r"^\s*([^:]{2,40}?)\s*:\s*(\d{1,5})\s*$") _HYPHEN_END_RE = re.compile(r"(\w)-\s*$") _HYPHEN_START_RE = re.compile(r"^\s*(\w)") def parse_book_index(text: str) -> dict[str, int]: """Extrai `{livro: página_inicial}` do índice de um PDF de Bíblia. Formato esperado (o do PDF do projeto): ANTIGO TESTAMENTO: 5 Gênesis: 6 Êxodo: 168 """ starts: dict[str, int] = {} for raw_line in text.splitlines(): line = raw_line.strip() if not line: continue match = _INDEX_ENTRY_RE.match(line) if not match: continue name, page = match.group(1).strip(), int(match.group(2)) if "TESTAMENTO" in name.upper(): continue canonical = canonical_book(name) if canonical is None: continue starts[canonical] = page return starts def _join_hyphenated(previous: str, following: str) -> str: """Religa palavra hifenizada no fim da linha. `esconde-` + `me.` vira `escondeme.`. Hifens lexicais (não no fim da linha) permanecem intactos. """ match = _HYPHEN_END_RE.search(previous) if not match: return f"{previous} {following}".strip() tail = _HYPHEN_START_RE.match(following) if not tail: return f"{previous} {following}".strip() word = match.group(1) continuation = tail.group(1) return f"{previous[: match.start(1)]}{word}{continuation}{following[tail.end(1):]}" class BibleParser: """Converte páginas extraídas em uma lista de versículos.""" def __init__(self) -> None: self._books = tuple(BOOKS) + _OPTIONAL_BOOKS def book_for_page( self, page: int, book_starts: Mapping[str, int] ) -> str | None: """Livro ao qual a página pertence, pela faixa do índice. A página pertence ao último livro cujo início é menor ou igual a ela. """ if not book_starts: return None candidates = [(start, book) for book, start in book_starts.items() if start <= page] if not candidates: return None return max(candidates, key=lambda item: item[0])[1] def parse( self, pages: Iterable[PageText], file_name: str, book_starts: Mapping[str, int] | None = None, ) -> list[VerseChunk]: """Converte as páginas do documento em versículos indexáveis.""" book_starts = book_starts or {} chunks: list[VerseChunk] = [] current_book: str | None = None current_chapter = 0 current_verse = 0 current_text: list[str] = [] current_page = 0 def flush() -> None: nonlocal current_text if current_book and current_chapter and current_verse and current_text: text = " ".join(part for part in current_text if part).strip() if text: chunks.append( VerseChunk( book=current_book, chapter=current_chapter, verse=current_verse, page=current_page, file=file_name, text=text, ) ) current_text = [] for page in pages: page_book = self.book_for_page(page.page, book_starts) lines = page.text.splitlines() header_book = self._detect_header_book(lines) if page_book is None and header_book is None: # Página fora de qualquer livro (índice, copyright): descarta. flush() current_book = None current_chapter = 0 current_verse = 0 continue if page_book is not None: if current_book != page_book: flush() current_book = page_book current_chapter = 0 current_verse = 0 elif header_book is not None: if current_book != header_book: flush() current_book = header_book current_chapter = 0 current_verse = 0 current_page = page.page for raw_line in lines: line = raw_line.strip() if not line: continue chapter_match = _CHAPTER_RE.match(line) or _CHAPTER_TEXT_RE.match(line) if chapter_match: flush() current_chapter = int(chapter_match.group(1)) current_verse = 0 continue verse_match = _VERSE_RE.match(line) if verse_match and current_chapter: flush() current_verse = int(verse_match.group(1)) current_text = [verse_match.group(2).strip()] continue verse_only = _VERSE_ONLY_RE.match(line) if verse_only and current_chapter: flush() current_verse = int(verse_only.group(1)) current_text = [] continue if current_verse and current_text: current_text[-1] = _join_hyphenated(current_text[-1], line) elif current_verse: current_text.append(line) flush() return chunks def _detect_header_book(self, lines: list[str]) -> str | None: """Procura o nome do livro nas primeiras linhas da página.""" for raw_line in lines[:5]: candidate = raw_line.strip() if not candidate or len(candidate) > 40: continue canonical = canonical_book(candidate) if canonical is not None: return canonical return None