Scriptorium / backend /epub.py
mattkevan's picture
First in space
af4851b
Raw
History Blame Contribute Delete
5.3 kB
from dataclasses import dataclass
from pathlib import Path
from typing import Dict, Iterable, List, Optional, Union
import re
import zipfile
from bs4 import BeautifulSoup
from ebooklib import ITEM_DOCUMENT, epub
from backend.config import CHARS_PER_MINUTE, MAX_EPUB_BYTES, MAX_ESTIMATED_MINUTES
from backend.types import Chapter
@dataclass
class EpubConfig:
max_file_bytes: int = MAX_EPUB_BYTES
chars_per_minute: int = CHARS_PER_MINUTE
max_estimated_minutes: int = MAX_ESTIMATED_MINUTES
def _clean_text(raw_html: str) -> str:
soup = BeautifulSoup(raw_html, "html.parser")
text = soup.get_text("\n", strip=True)
text = re.sub(r"\n{3,}", "\n\n", text)
return text.strip()
def _chapter_title(item: epub.EpubItem, text: str, index: int) -> str:
soup = BeautifulSoup(item.get_content(), "html.parser")
heading = soup.find(["h1", "h2", "title"])
if heading and heading.get_text(strip=True):
return heading.get_text(strip=True)
if item.title:
return str(item.title)
first_line = text.splitlines()[0].strip() if text.splitlines() else ""
return first_line[:80] or f"Chapter {index + 1}"
def _iter_spine_documents(book: epub.EpubBook) -> Iterable[epub.EpubItem]:
seen = set()
for spine_item in book.spine:
item_id = spine_item[0] if isinstance(spine_item, tuple) else spine_item
if item_id == "nav":
continue
item = book.get_item_with_id(item_id)
if item is None:
continue
seen.add(item.get_id())
yield item
for item in book.get_items_of_type(ITEM_DOCUMENT):
if item.get_id() in seen:
continue
if item.get_id() == "nav" or getattr(item, "file_name", "") == "nav.xhtml":
continue
yield item
def _metadata_first(book: epub.EpubBook, name: str) -> Optional[str]:
values = book.get_metadata("DC", name)
if not values:
return None
value = values[0][0]
return str(value).strip() if value else None
def _validate_epub_container(path: Path) -> None:
if not zipfile.is_zipfile(path):
raise ValueError("Invalid EPUB: uploaded file is not a valid zip container")
try:
with zipfile.ZipFile(path) as archive:
names = set(archive.namelist())
mimetype = None
if "mimetype" in names:
mimetype = archive.read("mimetype").decode("utf-8", errors="ignore").strip()
has_container = "META-INF/container.xml" in names
except zipfile.BadZipFile as exc:
raise ValueError("Invalid EPUB: could not read zip container") from exc
if mimetype == "application/epub+zip" or has_container:
return
raise ValueError("Invalid EPUB: missing EPUB container metadata")
def parse_epub(epub_path: Union[Path, str], config: Optional[EpubConfig] = None) -> Dict[str, object]:
config = config or EpubConfig()
path = Path(epub_path)
if not path.exists():
raise ValueError("Invalid EPUB: file does not exist")
if path.stat().st_size > config.max_file_bytes:
raise ValueError("EPUB upload too large")
_validate_epub_container(path)
try:
book = epub.read_epub(str(path))
except Exception as exc: # pragma: no cover - exercised by malformed input
raise ValueError("Invalid EPUB: could not parse file") from exc
chapters: List[Chapter] = []
for index, item in enumerate(_iter_spine_documents(book)):
text = _clean_text(item.get_content())
if not text:
continue
chars = len(text)
est_minutes = max(1, round(chars / config.chars_per_minute))
chapters.append(
Chapter(
id=item.get_id() or f"chapter-{index + 1}",
label=_roman(index + 1),
title=_chapter_title(item, text, index),
text=text,
chars=chars,
est_minutes=est_minutes,
)
)
if not chapters:
raise ValueError("Invalid EPUB: no readable chapters found")
total_minutes = sum(ch.est_minutes for ch in chapters)
if total_minutes > config.max_estimated_minutes:
raise ValueError("EPUB exceeds maximum supported runtime")
title = _metadata_first(book, "title") or path.stem.replace("-", " ").title()
author = _metadata_first(book, "creator") or "Unknown author"
language = _metadata_first(book, "language") or "Unknown language"
rights = _metadata_first(book, "rights") or "Rights unknown"
return {
"title": title,
"author": author,
"meta": f"{language.upper()} \u00b7 {rights}",
"cover_url": None,
"chapters": [chapter.to_dict() for chapter in chapters],
"estimated_minutes": total_minutes,
}
def _roman(number: int) -> str:
numerals = (
(1000, "m"),
(900, "cm"),
(500, "d"),
(400, "cd"),
(100, "c"),
(90, "xc"),
(50, "l"),
(40, "xl"),
(10, "x"),
(9, "ix"),
(5, "v"),
(4, "iv"),
(1, "i"),
)
result = []
remainder = number
for value, numeral in numerals:
count, remainder = divmod(remainder, value)
result.append(numeral * count)
return "".join(result)