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)