feat(backend): add biblical parser with book/chapter/verse reconstruction
This commit is contained in:
1 parent
b75b746a40
commit
6dc7f634ea
2 files changed
+549
No files matched your search
@@ -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
|
||||
@@ -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
|
||||
Reference in new issue
Block a user