diff --git a/backend/app/bible_parser.py b/backend/app/bible_parser.py new file mode 100644 index 0000000..2b63df3 --- /dev/null +++ b/backend/app/bible_parser.py @@ -0,0 +1,288 @@ +"""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 diff --git a/backend/tests/test_bible_parser.py b/backend/tests/test_bible_parser.py new file mode 100644 index 0000000..00e09a2 --- /dev/null +++ b/backend/tests/test_bible_parser.py @@ -0,0 +1,261 @@ +"""Testes do parser bíblico. + +O parser reconstrói livro, capítulo e versículo a partir do texto extraído do PDF. +O caso de referência é o PDF do projeto (Almeida Revista e Corrigida), em que o +capítulo aparece como `[N]` e os versículos são numerados no início da linha. +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +import pytest + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +from app.bible_parser import ( # noqa: E402 + BOOKS, + BibleParser, + parse_book_index, +) +from app.extraction import PageText # noqa: E402 + + +def make_page(number: int, text: str) -> PageText: + return PageText(page=number, text=text) + + +# --------------------------------------------------------------------------- # +# Índice de livros +# --------------------------------------------------------------------------- # + +def test_parse_book_index_reads_book_and_start_page(): + text = """ +INDICE +ANTIGO TESTAMENTO: 5 +Gênesis: 6 +Êxodo: 168 +Levítico: 303 + +NOVO TESTAMENTO: 2542 +Mateus: 2543 +Apocalipse: 3314 +""" + starts = parse_book_index(text) + + assert starts["Gênesis"] == 6 + assert starts["Êxodo"] == 168 + assert starts["Apocalipse"] == 3314 + assert "ANTIGO TESTAMENTO" not in starts + assert "NOVO TESTAMENTO" not in starts + + +def test_parse_book_index_ignores_unknown_entries(): + starts = parse_book_index("Gênesis: 6\nPagina solta: 12\n") + assert starts == {"Gênesis": 6} + + +# --------------------------------------------------------------------------- # +# Atribuição de livro +# --------------------------------------------------------------------------- # + +def test_book_is_assigned_by_page_range(): + parser = BibleParser() + starts = {"Gênesis": 6, "Êxodo": 168} + + assert parser.book_for_page(6, starts) == "Gênesis" + assert parser.book_for_page(167, starts) == "Gênesis" + assert parser.book_for_page(168, starts) == "Êxodo" + assert parser.book_for_page(300, starts) == "Êxodo" + + +def test_page_before_first_book_has_no_book(): + parser = BibleParser() + assert parser.book_for_page(3, {"Gênesis": 6}) is None + + +def test_page_after_last_book_keeps_last_book(): + parser = BibleParser() + assert parser.book_for_page(3363, {"Gênesis": 6, "Êxodo": 168}) == "Êxodo" + + +def test_book_falls_back_to_page_header_when_index_is_missing(): + parser = BibleParser() + text = "GÊNESIS\n\n[1]\n1 No princípio criou Deus os céus e a terra." + + chunks = parser.parse([make_page(6, text)], "biblia.pdf", {}) + + assert chunks, "o fallback por cabeçalho deveria produzir chunks" + assert chunks[0].book == "Gênesis" + assert chunks[0].chapter == 1 + assert chunks[0].verse == 1 + + +# --------------------------------------------------------------------------- # +# Capítulos e versículos +# --------------------------------------------------------------------------- # + +def test_parses_genesis_1_1(): + parser = BibleParser() + text = ( + "GÊNESIS\n" + "\n" + "[1]\n" + "1 No princípio criou Deus os céus e a terra.\n" + "2 A terra era sem forma e vazia; e havia trevas\n" + "sobre a face do abismo, mas o Espírito de Deus\n" + "pairava sobre a face das águas.\n" + ) + + chunks = parser.parse([make_page(6, text)], "biblia.pdf", {"Gênesis": 6}) + + assert len(chunks) == 2 + first = chunks[0] + assert first.book == "Gênesis" + assert first.chapter == 1 + assert first.verse == 1 + assert first.page == 6 + assert first.file == "biblia.pdf" + assert first.text == "No princípio criou Deus os céus e a terra." + + second = chunks[1] + assert second.verse == 2 + assert second.text.endswith("pairava sobre a face das águas.") + + +def test_chapter_marker_in_brackets_increments_chapter(): + parser = BibleParser() + text = ( + "GÊNESIS\n" + "[1]\n" + "1 No princípio criou Deus os céus e a terra.\n" + "\n" + "[2]\n" + "1 E disse Deus: haja luz. E houve luz.\n" + ) + + chunks = parser.parse([make_page(6, text)], "biblia.pdf", {"Gênesis": 6}) + + assert {c.chapter for c in chunks} == {1, 2} + chapter_two = next(c for c in chunks if c.chapter == 2) + assert chapter_two.verse == 1 + + +def test_verse_number_alone_on_a_line_joins_the_next_line(): + parser = BibleParser() + text = ( + "GÊNESIS\n" + "[1]\n" + "1 No princípio criou Deus os céus e a terra.\n" + "2\n" + "A terra era sem forma e vazia.\n" + ) + + chunks = parser.parse([make_page(6, text)], "biblia.pdf", {"Gênesis": 6}) + + assert len(chunks) == 2 + assert chunks[1].verse == 2 + assert chunks[1].text == "A terra era sem forma e vazia." + + +def test_continuation_lines_are_appended_to_the_current_verse(): + parser = BibleParser() + text = ( + "GÊNESIS\n" + "[1]\n" + "1 No princípio criou Deus os céus e a terra.\n" + "sobre a face do abismo, mas o Espírito de Deus\n" + ) + + chunks = parser.parse([make_page(6, text)], "biblia.pdf", {"Gênesis": 6}) + + assert len(chunks) == 1 + assert chunks[0].text.endswith("sobre a face do abismo, mas o Espírito de Deus") + + +def test_hyphenated_word_at_line_end_is_joined(): + parser = BibleParser() + text = ( + "GÊNESIS\n" + "[3]\n" + "10 Respondeu-lhe o homem: Ouvi a tua voz no\n" + "jardim e tive medo, porque estava nu; e esconde-\n" + "me.\n" + ) + + chunks = parser.parse([make_page(12, text)], "biblia.pdf", {"Gênesis": 6}) + + assert len(chunks) == 1 + assert "escondeme" in chunks[0].text + assert "esconde-\n" not in chunks[0].text + + +def test_lexical_hyphen_is_preserved(): + parser = BibleParser() + text = ( + "GÊNESIS\n" + "[1]\n" + "1 E disse Deus: haja uma separação entre águas e águas,\n" + "para separar as águas.\n" + ) + + chunks = parser.parse([make_page(6, text)], "biblia.pdf", {"Gênesis": 6}) + + assert chunks + assert "para separar" in chunks[0].text + + +def test_pages_outside_any_book_are_discarded(): + parser = BibleParser() + index_text = "Gênesis: 6\nÊxodo: 168\n" + copyright_text = "L.C.C. Publicações Eletrônicas\nProibido todo uso comercial.\n" + + chunks = parser.parse( + [make_page(3, index_text), make_page(6, "GÊNESIS\n[1]\n1 No princípio.\n"), make_page(3364, copyright_text)], + "biblia.pdf", + {"Gênesis": 6, "Êxodo": 168}, + ) + + assert all(c.book == "Gênesis" for c in chunks) + assert all("Publicações" not in c.text for c in chunks) + + +def test_book_names_are_recognized_in_canonical_order(): + # Esta edição da Almeida não inclui Neemias nem Ester: 64 livros. + assert len(BOOKS) == 64 + assert BOOKS[0] == "Gênesis" + assert BOOKS[37] == "Mateus" + assert BOOKS[-1] == "Apocalipse" + assert "Neemias" not in BOOKS + assert "Ester" not in BOOKS + + +def test_esdras_is_followed_directly_by_job(): + """O índice do PDF vai de Esdras (1407) direto para Jó (1440).""" + assert BOOKS.index("Esdras") + 1 == BOOKS.index("Jó") + + +def test_reference_is_formatted_as_book_chapter_verse(): + parser = BibleParser() + text = "GÊNESIS\n[1]\n1 No princípio criou Deus os céus e a terra.\n" + + chunks = parser.parse([make_page(6, text)], "biblia.pdf", {"Gênesis": 6}) + + assert chunks[0].reference == "Gênesis 1:1" + + +@pytest.mark.parametrize( + "raw,expected", + [ + ("Gênesis", "Gênesis"), + ("Êxodo", "Êxodo"), + ("Números", "Números"), + ("Cântico dos Cânticos", "Cântico dos Cânticos"), + ("I Coríntios", "I Coríntios"), + ("II João", "II João"), + ], +) +def test_canonical_book_names_are_preserved(raw, expected): + assert raw in BOOKS + assert expected == raw