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)