File size: 5,300 Bytes
af4851b | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 | 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)
|