feat(backend): add biblical parser with book/chapter/verse reconstruction

This commit is contained in:
omp committed 2026-11-01 19:15:22 -03:00
1 parent b75b746a40
commit 6dc7f634ea
2 files changed
+549

No files matched your search

+288
View File
@@ -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
+261
View File
@@ -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