novel-downloader 1.4.1__py3-none-any.whl → 1.4.2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- novel_downloader/__init__.py +1 -1
- novel_downloader/cli/download.py +69 -10
- novel_downloader/config/adapter.py +42 -9
- novel_downloader/core/downloaders/base.py +26 -22
- novel_downloader/core/downloaders/common.py +41 -5
- novel_downloader/core/downloaders/qidian.py +60 -32
- novel_downloader/core/exporters/common/epub.py +153 -68
- novel_downloader/core/exporters/epub_util.py +1358 -0
- novel_downloader/core/exporters/linovelib/epub.py +147 -190
- novel_downloader/core/fetchers/qidian/browser.py +62 -10
- novel_downloader/core/interfaces/downloader.py +13 -12
- novel_downloader/locales/en.json +2 -0
- novel_downloader/locales/zh.json +2 -0
- novel_downloader/models/__init__.py +2 -0
- novel_downloader/models/config.py +8 -0
- novel_downloader/tui/screens/home.py +5 -4
- novel_downloader/utils/constants.py +0 -29
- {novel_downloader-1.4.1.dist-info → novel_downloader-1.4.2.dist-info}/METADATA +4 -2
- {novel_downloader-1.4.1.dist-info → novel_downloader-1.4.2.dist-info}/RECORD +23 -28
- novel_downloader/core/exporters/epub_utils/__init__.py +0 -40
- novel_downloader/core/exporters/epub_utils/css_builder.py +0 -75
- novel_downloader/core/exporters/epub_utils/image_loader.py +0 -131
- novel_downloader/core/exporters/epub_utils/initializer.py +0 -100
- novel_downloader/core/exporters/epub_utils/text_to_html.py +0 -178
- novel_downloader/core/exporters/epub_utils/volume_intro.py +0 -60
- {novel_downloader-1.4.1.dist-info → novel_downloader-1.4.2.dist-info}/WHEEL +0 -0
- {novel_downloader-1.4.1.dist-info → novel_downloader-1.4.2.dist-info}/entry_points.txt +0 -0
- {novel_downloader-1.4.1.dist-info → novel_downloader-1.4.2.dist-info}/licenses/LICENSE +0 -0
- {novel_downloader-1.4.1.dist-info → novel_downloader-1.4.2.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,1358 @@
|
|
1
|
+
#!/usr/bin/env python3
|
2
|
+
"""
|
3
|
+
novel_downloader.core.exporters.epub_util
|
4
|
+
-----------------------------------------
|
5
|
+
|
6
|
+
"""
|
7
|
+
|
8
|
+
import zipfile
|
9
|
+
from collections.abc import Sequence
|
10
|
+
from contextlib import suppress
|
11
|
+
from datetime import UTC, datetime
|
12
|
+
from pathlib import Path
|
13
|
+
from typing import NotRequired, Self, TypedDict
|
14
|
+
from zipfile import ZIP_DEFLATED, ZIP_STORED
|
15
|
+
|
16
|
+
from lxml import etree, html
|
17
|
+
from lxml.etree import _Element
|
18
|
+
|
19
|
+
from novel_downloader.utils.constants import (
|
20
|
+
CSS_VOLUME_INTRO_PATH,
|
21
|
+
VOLUME_BORDER_IMAGE_PATH,
|
22
|
+
)
|
23
|
+
|
24
|
+
_ROOT_PATH = "OEBPS"
|
25
|
+
_IMAGE_FOLDER = "Images"
|
26
|
+
_TEXT_FOLDER = "Text"
|
27
|
+
_CSS_FOLDER = "Styles"
|
28
|
+
|
29
|
+
_IMAGE_MEDIA_TYPES: dict[str, str] = {
|
30
|
+
"png": "image/png",
|
31
|
+
"jpg": "image/jpeg",
|
32
|
+
"jpeg": "image/jpeg",
|
33
|
+
"gif": "image/gif",
|
34
|
+
"svg": "image/svg+xml",
|
35
|
+
"webp": "image/webp",
|
36
|
+
}
|
37
|
+
|
38
|
+
_CONTAINER_TEMPLATE = """<?xml version="1.0" encoding="UTF-8"?>
|
39
|
+
<container version="1.0" xmlns="urn:oasis:names:tc:opendocument:xmlns:container">
|
40
|
+
<rootfiles>
|
41
|
+
<rootfile full-path="{root_path}/content.opf"
|
42
|
+
media-type="application/oebps-package+xml"/>
|
43
|
+
</rootfiles>
|
44
|
+
</container>"""
|
45
|
+
|
46
|
+
_COVER_IMAGE_TEMPLATE = (
|
47
|
+
f'<div style="text-align: center; margin: 0; padding: 0;">'
|
48
|
+
f'<img src="../{_IMAGE_FOLDER}/cover.{{ext}}" alt="cover" '
|
49
|
+
f'style="max-width: 100%; height: auto;" />'
|
50
|
+
f"</div>"
|
51
|
+
)
|
52
|
+
|
53
|
+
|
54
|
+
class ChapterEntry(TypedDict):
|
55
|
+
id: str
|
56
|
+
label: str
|
57
|
+
src: str
|
58
|
+
chapters: NotRequired[list["ChapterEntry"]]
|
59
|
+
|
60
|
+
|
61
|
+
class VolumeEntry(TypedDict):
|
62
|
+
id: str
|
63
|
+
label: str
|
64
|
+
src: str
|
65
|
+
chapters: list[ChapterEntry]
|
66
|
+
|
67
|
+
|
68
|
+
class ManifestEntry(TypedDict):
|
69
|
+
id: str
|
70
|
+
href: str
|
71
|
+
media_type: str
|
72
|
+
properties: str | None
|
73
|
+
|
74
|
+
|
75
|
+
class SpineEntry(TypedDict):
|
76
|
+
idref: str
|
77
|
+
properties: str | None
|
78
|
+
|
79
|
+
|
80
|
+
class NavPoint:
|
81
|
+
def __init__(
|
82
|
+
self,
|
83
|
+
id: str,
|
84
|
+
label: str,
|
85
|
+
src: str,
|
86
|
+
children: list[Self] | None = None,
|
87
|
+
):
|
88
|
+
self._id = id
|
89
|
+
self._label = label
|
90
|
+
self._src = src
|
91
|
+
self._children = children or []
|
92
|
+
|
93
|
+
def add_child(self, point: Self) -> None:
|
94
|
+
"""
|
95
|
+
Append a child nav point under this one.
|
96
|
+
"""
|
97
|
+
self._children.append(point)
|
98
|
+
|
99
|
+
@property
|
100
|
+
def id(self) -> str:
|
101
|
+
"""
|
102
|
+
Unique identifier for this navigation point.
|
103
|
+
"""
|
104
|
+
return self._id
|
105
|
+
|
106
|
+
@property
|
107
|
+
def label(self) -> str:
|
108
|
+
"""
|
109
|
+
Display text shown in the TOC for this point.
|
110
|
+
"""
|
111
|
+
return self._label
|
112
|
+
|
113
|
+
@property
|
114
|
+
def src(self) -> str:
|
115
|
+
"""
|
116
|
+
Path to the target content file (e.g., chapter XHTML).
|
117
|
+
"""
|
118
|
+
return self._src
|
119
|
+
|
120
|
+
@property
|
121
|
+
def children(self) -> list[Self]:
|
122
|
+
"""
|
123
|
+
Nested navigation points under this one, if any.
|
124
|
+
"""
|
125
|
+
return self._children
|
126
|
+
|
127
|
+
|
128
|
+
class EpubResource:
|
129
|
+
def __init__(
|
130
|
+
self,
|
131
|
+
id: str,
|
132
|
+
filename: str,
|
133
|
+
media_type: str,
|
134
|
+
):
|
135
|
+
self._id = id
|
136
|
+
self._filename = filename
|
137
|
+
self._media_type = media_type
|
138
|
+
|
139
|
+
@property
|
140
|
+
def id(self) -> str:
|
141
|
+
return self._id
|
142
|
+
|
143
|
+
@property
|
144
|
+
def filename(self) -> str:
|
145
|
+
return self._filename
|
146
|
+
|
147
|
+
@property
|
148
|
+
def media_type(self) -> str:
|
149
|
+
return self._media_type
|
150
|
+
|
151
|
+
|
152
|
+
class StyleSheet(EpubResource):
|
153
|
+
def __init__(
|
154
|
+
self,
|
155
|
+
id: str,
|
156
|
+
content: str,
|
157
|
+
filename: str = "style.css",
|
158
|
+
):
|
159
|
+
super().__init__(
|
160
|
+
id=id,
|
161
|
+
filename=filename,
|
162
|
+
media_type="text/css",
|
163
|
+
)
|
164
|
+
self._content = content
|
165
|
+
|
166
|
+
@property
|
167
|
+
def content(self) -> str:
|
168
|
+
return self._content
|
169
|
+
|
170
|
+
|
171
|
+
class ImageResource(EpubResource):
|
172
|
+
def __init__(
|
173
|
+
self,
|
174
|
+
id: str,
|
175
|
+
data: bytes,
|
176
|
+
media_type: str,
|
177
|
+
filename: str,
|
178
|
+
):
|
179
|
+
super().__init__(
|
180
|
+
id=id,
|
181
|
+
filename=filename,
|
182
|
+
media_type=media_type,
|
183
|
+
)
|
184
|
+
self._data = data
|
185
|
+
|
186
|
+
@property
|
187
|
+
def data(self) -> bytes:
|
188
|
+
return self._data
|
189
|
+
|
190
|
+
|
191
|
+
class NavDocument(EpubResource):
|
192
|
+
def __init__(
|
193
|
+
self,
|
194
|
+
title: str = "未命名",
|
195
|
+
language: str = "zh-CN",
|
196
|
+
id: str = "nav",
|
197
|
+
filename: str = "nav.xhtml",
|
198
|
+
):
|
199
|
+
super().__init__(
|
200
|
+
id=id,
|
201
|
+
filename=filename,
|
202
|
+
media_type="application/xhtml+xml",
|
203
|
+
)
|
204
|
+
self._title = title
|
205
|
+
self._language = language
|
206
|
+
self._content_items: list[ChapterEntry | VolumeEntry] = []
|
207
|
+
|
208
|
+
def add_chapter(
|
209
|
+
self,
|
210
|
+
id: str,
|
211
|
+
label: str,
|
212
|
+
src: str,
|
213
|
+
) -> None:
|
214
|
+
"""
|
215
|
+
Add a top-level chapter entry to the navigation structure.
|
216
|
+
|
217
|
+
:param id: The unique ID for the chapter.
|
218
|
+
:param label: The display title for the chapter.
|
219
|
+
:param src: The href target for the chapter's XHTML file.
|
220
|
+
"""
|
221
|
+
self._content_items.append(
|
222
|
+
{
|
223
|
+
"id": id,
|
224
|
+
"label": label,
|
225
|
+
"src": src,
|
226
|
+
}
|
227
|
+
)
|
228
|
+
|
229
|
+
def add_volume(
|
230
|
+
self,
|
231
|
+
id: str,
|
232
|
+
label: str,
|
233
|
+
src: str,
|
234
|
+
chapters: list[ChapterEntry],
|
235
|
+
) -> None:
|
236
|
+
"""
|
237
|
+
Add a volume entry with nested chapters to the navigation.
|
238
|
+
|
239
|
+
:param id: The unique ID for the volume.
|
240
|
+
:param label: The display title for the volume.
|
241
|
+
:param src: The href target for the volume's intro XHTML file.
|
242
|
+
:param chapters: A list of chapter entries under this volume.
|
243
|
+
"""
|
244
|
+
self._content_items.append(
|
245
|
+
{
|
246
|
+
"id": id,
|
247
|
+
"label": label,
|
248
|
+
"src": src,
|
249
|
+
"chapters": chapters,
|
250
|
+
}
|
251
|
+
)
|
252
|
+
|
253
|
+
@property
|
254
|
+
def title(self) -> str:
|
255
|
+
return self._title
|
256
|
+
|
257
|
+
@property
|
258
|
+
def language(self) -> str:
|
259
|
+
return self._language
|
260
|
+
|
261
|
+
@property
|
262
|
+
def content_items(self) -> list[ChapterEntry | VolumeEntry]:
|
263
|
+
return self._content_items
|
264
|
+
|
265
|
+
|
266
|
+
class NCXDocument(EpubResource):
|
267
|
+
def __init__(
|
268
|
+
self,
|
269
|
+
title: str = "未命名",
|
270
|
+
uid: str = "",
|
271
|
+
id: str = "ncx",
|
272
|
+
filename: str = "toc.ncx",
|
273
|
+
):
|
274
|
+
super().__init__(
|
275
|
+
id=id,
|
276
|
+
filename=filename,
|
277
|
+
media_type="application/x-dtbncx+xml",
|
278
|
+
)
|
279
|
+
self._title = title
|
280
|
+
self._uid = uid
|
281
|
+
self._nav_points: list[NavPoint] = []
|
282
|
+
|
283
|
+
def add_chapter(
|
284
|
+
self,
|
285
|
+
id: str,
|
286
|
+
label: str,
|
287
|
+
src: str,
|
288
|
+
) -> None:
|
289
|
+
"""
|
290
|
+
Add a single flat chapter entry to the NCX nav map.
|
291
|
+
"""
|
292
|
+
self._nav_points.append(NavPoint(id=id, label=label, src=src))
|
293
|
+
|
294
|
+
def add_volume(
|
295
|
+
self,
|
296
|
+
id: str,
|
297
|
+
label: str,
|
298
|
+
src: str,
|
299
|
+
chapters: list[ChapterEntry],
|
300
|
+
) -> None:
|
301
|
+
"""
|
302
|
+
Add a volume with nested chapters to the NCX nav map.
|
303
|
+
"""
|
304
|
+
children = [
|
305
|
+
NavPoint(id=c["id"], label=c["label"], src=c["src"]) for c in chapters
|
306
|
+
]
|
307
|
+
self._nav_points.append(
|
308
|
+
NavPoint(id=id, label=label, src=src, children=children)
|
309
|
+
)
|
310
|
+
|
311
|
+
@property
|
312
|
+
def nav_points(self) -> list[NavPoint]:
|
313
|
+
return self._nav_points
|
314
|
+
|
315
|
+
@property
|
316
|
+
def title(self) -> str:
|
317
|
+
return self._title
|
318
|
+
|
319
|
+
@property
|
320
|
+
def uid(self) -> str:
|
321
|
+
return self._uid
|
322
|
+
|
323
|
+
|
324
|
+
class OpfDocument(EpubResource):
|
325
|
+
def __init__(
|
326
|
+
self,
|
327
|
+
title: str,
|
328
|
+
author: str = "",
|
329
|
+
description: str = "",
|
330
|
+
uid: str = "",
|
331
|
+
subject: list[str] | None = None,
|
332
|
+
language: str = "zh-CN",
|
333
|
+
id: str = "opf",
|
334
|
+
filename: str = "content.opf",
|
335
|
+
):
|
336
|
+
super().__init__(
|
337
|
+
id=id,
|
338
|
+
filename=filename,
|
339
|
+
media_type="application/oebps-package+xml",
|
340
|
+
)
|
341
|
+
self._title = title
|
342
|
+
self._author = author
|
343
|
+
self._description = description
|
344
|
+
self._uid = uid
|
345
|
+
self._language = language
|
346
|
+
self._include_cover = False
|
347
|
+
self._subject: list[str] = subject or []
|
348
|
+
self._manifest: list[ManifestEntry] = []
|
349
|
+
self._spine: list[SpineEntry] = []
|
350
|
+
|
351
|
+
def add_manifest_item(
|
352
|
+
self,
|
353
|
+
id: str,
|
354
|
+
href: str,
|
355
|
+
media_type: str,
|
356
|
+
properties: str | None = None,
|
357
|
+
) -> None:
|
358
|
+
self._manifest.append(
|
359
|
+
{
|
360
|
+
"id": id,
|
361
|
+
"href": href,
|
362
|
+
"media_type": media_type,
|
363
|
+
"properties": properties,
|
364
|
+
}
|
365
|
+
)
|
366
|
+
|
367
|
+
def add_spine_item(
|
368
|
+
self,
|
369
|
+
idref: str,
|
370
|
+
properties: str | None = None,
|
371
|
+
) -> None:
|
372
|
+
self._spine.append({"idref": idref, "properties": properties})
|
373
|
+
|
374
|
+
def set_subject(self, subject: list[str]) -> None:
|
375
|
+
self._subject = subject
|
376
|
+
|
377
|
+
@property
|
378
|
+
def title(self) -> str:
|
379
|
+
"""
|
380
|
+
Book title metadata.
|
381
|
+
"""
|
382
|
+
return self._title
|
383
|
+
|
384
|
+
@property
|
385
|
+
def author(self) -> str:
|
386
|
+
"""
|
387
|
+
Author metadata.
|
388
|
+
"""
|
389
|
+
return self._author
|
390
|
+
|
391
|
+
@property
|
392
|
+
def description(self) -> str:
|
393
|
+
"""
|
394
|
+
Book description metadata.
|
395
|
+
"""
|
396
|
+
return self._description
|
397
|
+
|
398
|
+
@property
|
399
|
+
def subject(self) -> list[str]:
|
400
|
+
return self._subject
|
401
|
+
|
402
|
+
@property
|
403
|
+
def uid(self) -> str:
|
404
|
+
"""
|
405
|
+
Unique identifier for the book, used in dc:identifier and NCX UID.
|
406
|
+
"""
|
407
|
+
return self._uid
|
408
|
+
|
409
|
+
@property
|
410
|
+
def language(self) -> str:
|
411
|
+
return self._language
|
412
|
+
|
413
|
+
@property
|
414
|
+
def include_cover(self) -> bool:
|
415
|
+
"""
|
416
|
+
Whether to include a cover item in the <guide> section.
|
417
|
+
"""
|
418
|
+
return self._include_cover
|
419
|
+
|
420
|
+
@include_cover.setter
|
421
|
+
def include_cover(self, value: bool) -> None:
|
422
|
+
self._include_cover = value
|
423
|
+
|
424
|
+
@property
|
425
|
+
def manifest(self) -> list[ManifestEntry]:
|
426
|
+
"""
|
427
|
+
All resources used by the book (XHTML, CSS, images, nav, etc.).
|
428
|
+
"""
|
429
|
+
return self._manifest
|
430
|
+
|
431
|
+
@property
|
432
|
+
def spine(self) -> list[SpineEntry]:
|
433
|
+
"""
|
434
|
+
Defines the reading order of the book's contents.
|
435
|
+
"""
|
436
|
+
return self._spine
|
437
|
+
|
438
|
+
|
439
|
+
class Chapter(EpubResource):
|
440
|
+
def __init__(
|
441
|
+
self,
|
442
|
+
id: str,
|
443
|
+
title: str,
|
444
|
+
content: str,
|
445
|
+
css: list[StyleSheet] | None = None,
|
446
|
+
filename: str | None = None,
|
447
|
+
):
|
448
|
+
filename = filename or f"{id}.xhtml"
|
449
|
+
super().__init__(
|
450
|
+
id=id,
|
451
|
+
filename=filename,
|
452
|
+
media_type="application/xhtml+xml",
|
453
|
+
)
|
454
|
+
self._title = title
|
455
|
+
self._content = content
|
456
|
+
self._css = css or []
|
457
|
+
|
458
|
+
@property
|
459
|
+
def title(self) -> str:
|
460
|
+
return self._title
|
461
|
+
|
462
|
+
def to_xhtml(self, lang: str = "zh-CN") -> str:
|
463
|
+
# Prepare namespace map
|
464
|
+
NSMAP = {
|
465
|
+
None: "http://www.w3.org/1999/xhtml",
|
466
|
+
"epub": "http://www.idpf.org/2007/ops",
|
467
|
+
}
|
468
|
+
# Create <html> root with xml:lang and lang
|
469
|
+
html_el = etree.Element(
|
470
|
+
"{http://www.w3.org/1999/xhtml}html",
|
471
|
+
nsmap=NSMAP,
|
472
|
+
attrib={
|
473
|
+
"{http://www.w3.org/XML/1998/namespace}lang": lang,
|
474
|
+
"lang": lang,
|
475
|
+
},
|
476
|
+
)
|
477
|
+
|
478
|
+
# Build <head>
|
479
|
+
head = etree.SubElement(html_el, "head")
|
480
|
+
title = etree.SubElement(head, "title")
|
481
|
+
title.text = self._title
|
482
|
+
|
483
|
+
# Add stylesheet links
|
484
|
+
for css in self._css:
|
485
|
+
etree.SubElement(
|
486
|
+
head,
|
487
|
+
"link",
|
488
|
+
attrib={
|
489
|
+
"href": f"../{_CSS_FOLDER}/{css.filename}",
|
490
|
+
"rel": "stylesheet",
|
491
|
+
"type": css.media_type,
|
492
|
+
},
|
493
|
+
)
|
494
|
+
|
495
|
+
# Build <body>
|
496
|
+
body = etree.SubElement(html_el, "body")
|
497
|
+
wrapper = html.fromstring(
|
498
|
+
f'<div xmlns="http://www.w3.org/1999/xhtml">{self._content}</div>'
|
499
|
+
)
|
500
|
+
for node in wrapper:
|
501
|
+
body.append(node)
|
502
|
+
|
503
|
+
xhtml_bytes: bytes = etree.tostring(
|
504
|
+
html_el,
|
505
|
+
pretty_print=True,
|
506
|
+
xml_declaration=False, # we'll do it ourselves
|
507
|
+
encoding="utf-8",
|
508
|
+
method="xml",
|
509
|
+
)
|
510
|
+
doctype = (
|
511
|
+
'<?xml version="1.0" encoding="utf-8"?>\n'
|
512
|
+
"<!DOCTYPE html PUBLIC "
|
513
|
+
'"-//W3C//DTD XHTML 1.1//EN" '
|
514
|
+
'"http://www.w3.org/TR/xhtml11/DTD/xhtml11.dtd">\n'
|
515
|
+
)
|
516
|
+
return doctype + xhtml_bytes.decode("utf-8")
|
517
|
+
|
518
|
+
|
519
|
+
class Volume:
|
520
|
+
def __init__(
|
521
|
+
self,
|
522
|
+
id: str,
|
523
|
+
title: str,
|
524
|
+
intro: str = "",
|
525
|
+
cover: Path | None = None,
|
526
|
+
chapters: list[Chapter] | None = None,
|
527
|
+
):
|
528
|
+
self._id = id
|
529
|
+
self._title = title
|
530
|
+
self._intro = intro
|
531
|
+
self._cover = cover
|
532
|
+
self._chapters = chapters or []
|
533
|
+
|
534
|
+
def add_chapter(self, chapter: Chapter) -> None:
|
535
|
+
"""
|
536
|
+
Append a chapter to this volume.
|
537
|
+
"""
|
538
|
+
self._chapters.append(chapter)
|
539
|
+
|
540
|
+
@property
|
541
|
+
def id(self) -> str:
|
542
|
+
return self._id
|
543
|
+
|
544
|
+
@property
|
545
|
+
def title(self) -> str:
|
546
|
+
return self._title
|
547
|
+
|
548
|
+
@property
|
549
|
+
def intro(self) -> str:
|
550
|
+
"""
|
551
|
+
Optional volume description or introduction text.
|
552
|
+
"""
|
553
|
+
return self._intro
|
554
|
+
|
555
|
+
@property
|
556
|
+
def cover(self) -> Path | None:
|
557
|
+
"""
|
558
|
+
Optional volume-specific cover image.
|
559
|
+
"""
|
560
|
+
return self._cover
|
561
|
+
|
562
|
+
@property
|
563
|
+
def chapters(self) -> list[Chapter]:
|
564
|
+
return self._chapters
|
565
|
+
|
566
|
+
|
567
|
+
class Book:
|
568
|
+
def __init__(
|
569
|
+
self,
|
570
|
+
title: str,
|
571
|
+
author: str = "",
|
572
|
+
description: str = "",
|
573
|
+
cover_path: Path | None = None,
|
574
|
+
subject: list[str] | None = None,
|
575
|
+
serial_status: str = "",
|
576
|
+
word_count: str = "",
|
577
|
+
uid: str = "",
|
578
|
+
language: str = "zh-CN",
|
579
|
+
):
|
580
|
+
self._title = title
|
581
|
+
self._author = author
|
582
|
+
self._description = description
|
583
|
+
self._language = language
|
584
|
+
|
585
|
+
self._subject: list[str] = subject or []
|
586
|
+
self._serial_status = serial_status
|
587
|
+
self._word_count = word_count
|
588
|
+
|
589
|
+
self._content_items: list[Chapter] = []
|
590
|
+
self._images: list[ImageResource] = []
|
591
|
+
self._img_set: set[Path] = set()
|
592
|
+
self._stylesheets: list[StyleSheet] = []
|
593
|
+
self._vol_idx = 0
|
594
|
+
|
595
|
+
self._nav = NavDocument(title=title, language=language)
|
596
|
+
self._ncx = NCXDocument(title=title, uid=uid)
|
597
|
+
self._opf = OpfDocument(
|
598
|
+
title=title,
|
599
|
+
author=author,
|
600
|
+
description=description,
|
601
|
+
uid=uid,
|
602
|
+
subject=subject,
|
603
|
+
language=language,
|
604
|
+
)
|
605
|
+
self._opf.add_manifest_item(
|
606
|
+
id="ncx",
|
607
|
+
href="toc.ncx",
|
608
|
+
media_type="application/x-dtbncx+xml",
|
609
|
+
)
|
610
|
+
self._opf.add_manifest_item(
|
611
|
+
id="nav",
|
612
|
+
href="nav.xhtml",
|
613
|
+
media_type="application/xhtml+xml",
|
614
|
+
properties="nav",
|
615
|
+
)
|
616
|
+
|
617
|
+
self._vol_intro_css = StyleSheet(
|
618
|
+
id="volume_style",
|
619
|
+
content=CSS_VOLUME_INTRO_PATH.read_text(encoding="utf-8"),
|
620
|
+
filename="volume_style.css",
|
621
|
+
)
|
622
|
+
with suppress(FileNotFoundError):
|
623
|
+
self._images.append(
|
624
|
+
ImageResource(
|
625
|
+
id="img-volume-border",
|
626
|
+
data=VOLUME_BORDER_IMAGE_PATH.read_bytes(),
|
627
|
+
media_type="image/png",
|
628
|
+
filename="volume_border.png",
|
629
|
+
)
|
630
|
+
)
|
631
|
+
self._opf.add_manifest_item(
|
632
|
+
id="img-volume-border",
|
633
|
+
href=f"{_IMAGE_FOLDER}/volume_border.png",
|
634
|
+
media_type="image/png",
|
635
|
+
)
|
636
|
+
self._opf.add_manifest_item(
|
637
|
+
id="volume_style",
|
638
|
+
href=f"{_CSS_FOLDER}/volume_style.css",
|
639
|
+
media_type="text/css",
|
640
|
+
)
|
641
|
+
self._stylesheets.append(self._vol_intro_css)
|
642
|
+
|
643
|
+
if cover_path and cover_path.exists() and cover_path.is_file():
|
644
|
+
ext = cover_path.suffix.lower().lstrip(".")
|
645
|
+
media_type = _IMAGE_MEDIA_TYPES.get(ext)
|
646
|
+
if media_type:
|
647
|
+
data = cover_path.read_bytes()
|
648
|
+
|
649
|
+
# create the CoverImage
|
650
|
+
self._images.append(
|
651
|
+
ImageResource(
|
652
|
+
id="cover-img",
|
653
|
+
data=data,
|
654
|
+
media_type=media_type,
|
655
|
+
filename=f"cover.{ext}",
|
656
|
+
)
|
657
|
+
)
|
658
|
+
self._content_items.append(
|
659
|
+
Chapter(
|
660
|
+
id="cover",
|
661
|
+
title="Cover",
|
662
|
+
content=_COVER_IMAGE_TEMPLATE.format(ext=ext),
|
663
|
+
filename="cover.xhtml",
|
664
|
+
)
|
665
|
+
)
|
666
|
+
|
667
|
+
self._opf.add_manifest_item(
|
668
|
+
id="cover-img",
|
669
|
+
href=f"{_IMAGE_FOLDER}/cover.{ext}",
|
670
|
+
media_type=media_type,
|
671
|
+
properties="cover-image",
|
672
|
+
)
|
673
|
+
|
674
|
+
self._opf.add_manifest_item(
|
675
|
+
id="cover",
|
676
|
+
href=f"{_TEXT_FOLDER}/cover.xhtml",
|
677
|
+
media_type="application/xhtml+xml",
|
678
|
+
)
|
679
|
+
self._opf.add_spine_item(
|
680
|
+
idref="cover",
|
681
|
+
properties="duokan-page-fullscreen",
|
682
|
+
)
|
683
|
+
|
684
|
+
self._opf.include_cover = True
|
685
|
+
|
686
|
+
# intro
|
687
|
+
intro_html = _gene_book_intro(
|
688
|
+
book_name=title,
|
689
|
+
author=author,
|
690
|
+
serial_status=serial_status,
|
691
|
+
word_count=word_count,
|
692
|
+
summary=description,
|
693
|
+
)
|
694
|
+
self._content_items.append(
|
695
|
+
Chapter(
|
696
|
+
id="intro",
|
697
|
+
title="书籍简介",
|
698
|
+
content=intro_html,
|
699
|
+
filename="intro.xhtml",
|
700
|
+
)
|
701
|
+
)
|
702
|
+
self._opf.add_manifest_item(
|
703
|
+
id="intro",
|
704
|
+
href=f"{_TEXT_FOLDER}/intro.xhtml",
|
705
|
+
media_type="application/xhtml+xml",
|
706
|
+
)
|
707
|
+
self._opf.add_spine_item(
|
708
|
+
idref="intro",
|
709
|
+
)
|
710
|
+
self._nav.add_chapter(
|
711
|
+
id="intro",
|
712
|
+
label="书籍简介",
|
713
|
+
src=f"{_TEXT_FOLDER}/intro.xhtml",
|
714
|
+
)
|
715
|
+
self._ncx.add_chapter(
|
716
|
+
id="intro",
|
717
|
+
label="书籍简介",
|
718
|
+
src=f"{_TEXT_FOLDER}/intro.xhtml",
|
719
|
+
)
|
720
|
+
|
721
|
+
def export(self, output_path: str | Path) -> bool:
|
722
|
+
"""
|
723
|
+
Build and export the current book as an EPUB file.
|
724
|
+
|
725
|
+
:param output_path: Path to save the final .epub file.
|
726
|
+
"""
|
727
|
+
return _build_epub(
|
728
|
+
book=self,
|
729
|
+
output_path=Path(output_path),
|
730
|
+
)
|
731
|
+
|
732
|
+
@property
|
733
|
+
def content_items(self) -> list[Chapter]:
|
734
|
+
"""
|
735
|
+
Ordered list of contents.
|
736
|
+
"""
|
737
|
+
return self._content_items
|
738
|
+
|
739
|
+
@property
|
740
|
+
def images(self) -> list[ImageResource]:
|
741
|
+
return self._images
|
742
|
+
|
743
|
+
@property
|
744
|
+
def stylesheets(self) -> list[StyleSheet]:
|
745
|
+
return self._stylesheets
|
746
|
+
|
747
|
+
@property
|
748
|
+
def nav(self) -> NavDocument:
|
749
|
+
return self._nav
|
750
|
+
|
751
|
+
@property
|
752
|
+
def ncx(self) -> NCXDocument:
|
753
|
+
return self._ncx
|
754
|
+
|
755
|
+
@property
|
756
|
+
def opf(self) -> OpfDocument:
|
757
|
+
return self._opf
|
758
|
+
|
759
|
+
def add_chapter(self, chapter: Chapter) -> None:
|
760
|
+
self._ncx.add_chapter(
|
761
|
+
id=chapter.id,
|
762
|
+
label=chapter.title,
|
763
|
+
src=f"{_TEXT_FOLDER}/{chapter.filename}",
|
764
|
+
)
|
765
|
+
self._nav.add_chapter(
|
766
|
+
id=chapter.id,
|
767
|
+
label=chapter.title,
|
768
|
+
src=f"{_TEXT_FOLDER}/{chapter.filename}",
|
769
|
+
)
|
770
|
+
self._opf.add_manifest_item(
|
771
|
+
id=chapter.id,
|
772
|
+
href=f"{_TEXT_FOLDER}/{chapter.filename}",
|
773
|
+
media_type=chapter.media_type,
|
774
|
+
)
|
775
|
+
self._opf.add_spine_item(idref=chapter.id)
|
776
|
+
|
777
|
+
self._content_items.append(chapter)
|
778
|
+
|
779
|
+
def add_volume(self, volume: Volume) -> None:
|
780
|
+
if volume.cover:
|
781
|
+
cover = (
|
782
|
+
f'<img class="width100" src="../{_IMAGE_FOLDER}/{volume.cover.name}"/>'
|
783
|
+
)
|
784
|
+
self._content_items.append(
|
785
|
+
Chapter(
|
786
|
+
id=f"vol_{self._vol_idx}_cover",
|
787
|
+
title=volume.title,
|
788
|
+
content=cover,
|
789
|
+
filename=f"vol_{self._vol_idx}_cover.xhtml",
|
790
|
+
)
|
791
|
+
)
|
792
|
+
self.add_image(volume.cover)
|
793
|
+
self._opf.add_manifest_item(
|
794
|
+
id=f"vol_{self._vol_idx}_cover",
|
795
|
+
href=f"{_TEXT_FOLDER}/vol_{self._vol_idx}_cover.xhtml",
|
796
|
+
media_type="application/xhtml+xml",
|
797
|
+
)
|
798
|
+
self._opf.add_spine_item(
|
799
|
+
idref=f"vol_{self._vol_idx}_cover",
|
800
|
+
properties="duokan-page-fullscreen",
|
801
|
+
)
|
802
|
+
|
803
|
+
self._content_items.append(
|
804
|
+
Chapter(
|
805
|
+
id=f"vol_{self._vol_idx}",
|
806
|
+
title=volume.title,
|
807
|
+
content=_create_volume_intro(volume.title, volume.intro),
|
808
|
+
filename=f"vol_{self._vol_idx}.xhtml",
|
809
|
+
css=[self._vol_intro_css],
|
810
|
+
)
|
811
|
+
)
|
812
|
+
self._opf.add_manifest_item(
|
813
|
+
id=f"vol_{self._vol_idx}",
|
814
|
+
href=f"{_TEXT_FOLDER}/vol_{self._vol_idx}.xhtml",
|
815
|
+
media_type="application/xhtml+xml",
|
816
|
+
)
|
817
|
+
self._opf.add_spine_item(
|
818
|
+
idref=f"vol_{self._vol_idx}",
|
819
|
+
)
|
820
|
+
vol_chapters: list[ChapterEntry] = []
|
821
|
+
for chap in volume.chapters:
|
822
|
+
chap_id = chap.id
|
823
|
+
chap_label = chap.title
|
824
|
+
chap_src = f"{_TEXT_FOLDER}/{chap.filename}"
|
825
|
+
vol_chapters.append(
|
826
|
+
{
|
827
|
+
"id": chap_id,
|
828
|
+
"label": chap_label,
|
829
|
+
"src": chap_src,
|
830
|
+
}
|
831
|
+
)
|
832
|
+
self._opf.add_manifest_item(
|
833
|
+
id=chap_id,
|
834
|
+
href=chap_src,
|
835
|
+
media_type=chap.media_type,
|
836
|
+
)
|
837
|
+
self._opf.add_spine_item(
|
838
|
+
idref=chap_id,
|
839
|
+
)
|
840
|
+
self._ncx.add_volume(
|
841
|
+
id=f"vol_{self._vol_idx}",
|
842
|
+
label=volume.title,
|
843
|
+
src=f"{_TEXT_FOLDER}/vol_{self._vol_idx}.xhtml",
|
844
|
+
chapters=vol_chapters,
|
845
|
+
)
|
846
|
+
self._nav.add_volume(
|
847
|
+
id=f"vol_{self._vol_idx}",
|
848
|
+
label=volume.title,
|
849
|
+
src=f"{_TEXT_FOLDER}/vol_{self._vol_idx}.xhtml",
|
850
|
+
chapters=vol_chapters,
|
851
|
+
)
|
852
|
+
self._content_items.extend(volume.chapters)
|
853
|
+
self._vol_idx += 1
|
854
|
+
|
855
|
+
def add_image(self, image_path: Path) -> bool:
|
856
|
+
if image_path in self._img_set:
|
857
|
+
return False
|
858
|
+
self._img_set.add(image_path)
|
859
|
+
if not image_path.exists() or not image_path.is_file():
|
860
|
+
return False
|
861
|
+
|
862
|
+
ext = image_path.suffix.lower().lstrip(".")
|
863
|
+
media_type = _IMAGE_MEDIA_TYPES.get(ext)
|
864
|
+
if media_type is None:
|
865
|
+
return False
|
866
|
+
|
867
|
+
filename = image_path.name
|
868
|
+
resource_id = f"img_{filename}"
|
869
|
+
data = image_path.read_bytes()
|
870
|
+
href = f"{_IMAGE_FOLDER}/{filename}"
|
871
|
+
|
872
|
+
img_res = ImageResource(
|
873
|
+
id=resource_id,
|
874
|
+
data=data,
|
875
|
+
media_type=media_type,
|
876
|
+
filename=filename,
|
877
|
+
)
|
878
|
+
self._images.append(img_res)
|
879
|
+
|
880
|
+
self._opf.add_manifest_item(
|
881
|
+
id=resource_id,
|
882
|
+
href=href,
|
883
|
+
media_type=media_type,
|
884
|
+
)
|
885
|
+
|
886
|
+
return True
|
887
|
+
|
888
|
+
def add_stylesheet(self, css: StyleSheet) -> None:
|
889
|
+
self._stylesheets.append(css)
|
890
|
+
self._opf.add_manifest_item(
|
891
|
+
id=css.id,
|
892
|
+
href=f"{_CSS_FOLDER}/{css.filename}",
|
893
|
+
media_type=css.media_type,
|
894
|
+
)
|
895
|
+
|
896
|
+
|
897
|
+
def generate_container_xml(
|
898
|
+
root_path: str = _ROOT_PATH,
|
899
|
+
) -> str:
|
900
|
+
"""
|
901
|
+
Generate the XML content for META-INF/container.xml in an EPUB archive.
|
902
|
+
|
903
|
+
:param root_path: The folder where the OPF file is stored.
|
904
|
+
:return: A string containing the full XML for container.xml.
|
905
|
+
"""
|
906
|
+
return _CONTAINER_TEMPLATE.format(root_path=root_path)
|
907
|
+
|
908
|
+
|
909
|
+
def generate_nav_xhtml(nav: NavDocument) -> str:
|
910
|
+
"""
|
911
|
+
Generate the XHTML content for nav.xhtml based on the NavDocument.
|
912
|
+
|
913
|
+
:param nav: A NavDocument instance containing navigation data.
|
914
|
+
:return: A string containing the full XHTML for nav.xhtml.
|
915
|
+
"""
|
916
|
+
XHTML_NS = "http://www.w3.org/1999/xhtml"
|
917
|
+
EPUB_NS = "http://www.idpf.org/2007/ops"
|
918
|
+
XML_NS = "http://www.w3.org/XML/1998/namespace"
|
919
|
+
|
920
|
+
nsmap_root = {
|
921
|
+
None: XHTML_NS,
|
922
|
+
"epub": EPUB_NS,
|
923
|
+
}
|
924
|
+
|
925
|
+
html = etree.Element(
|
926
|
+
f"{{{XHTML_NS}}}html",
|
927
|
+
nsmap=nsmap_root,
|
928
|
+
lang=nav.language,
|
929
|
+
)
|
930
|
+
# xml:lang
|
931
|
+
html.set(f"{{{XML_NS}}}lang", nav.language)
|
932
|
+
|
933
|
+
# <head><title>
|
934
|
+
head = etree.SubElement(html, f"{{{XHTML_NS}}}head")
|
935
|
+
title_el = etree.SubElement(head, f"{{{XHTML_NS}}}title")
|
936
|
+
title_el.text = nav.title
|
937
|
+
|
938
|
+
# <body><nav epub:type="toc" id="..." role="doc-toc">
|
939
|
+
body = etree.SubElement(html, f"{{{XHTML_NS}}}body")
|
940
|
+
nav_el = etree.SubElement(
|
941
|
+
body,
|
942
|
+
f"{{{XHTML_NS}}}nav",
|
943
|
+
{
|
944
|
+
f"{{{EPUB_NS}}}type": "toc",
|
945
|
+
"id": nav.id,
|
946
|
+
"role": "doc-toc",
|
947
|
+
},
|
948
|
+
)
|
949
|
+
|
950
|
+
h2 = etree.SubElement(nav_el, f"{{{XHTML_NS}}}h2")
|
951
|
+
h2.text = nav.title
|
952
|
+
|
953
|
+
# <ol> ... </ol>
|
954
|
+
def _add_items(
|
955
|
+
parent_ol: _Element,
|
956
|
+
items: Sequence[ChapterEntry | VolumeEntry],
|
957
|
+
) -> None:
|
958
|
+
for item in items:
|
959
|
+
li = etree.SubElement(parent_ol, f"{{{XHTML_NS}}}li")
|
960
|
+
a = etree.SubElement(li, f"{{{XHTML_NS}}}a", href=item["src"])
|
961
|
+
a.text = item["label"]
|
962
|
+
if "chapters" in item and item["chapters"]:
|
963
|
+
sub_ol = etree.SubElement(li, f"{{{XHTML_NS}}}ol")
|
964
|
+
_add_items(sub_ol, item["chapters"])
|
965
|
+
|
966
|
+
top_ol = etree.SubElement(nav_el, f"{{{XHTML_NS}}}ol")
|
967
|
+
_add_items(top_ol, nav.content_items)
|
968
|
+
|
969
|
+
xml_bytes: bytes = etree.tostring(
|
970
|
+
html,
|
971
|
+
xml_declaration=True,
|
972
|
+
encoding="utf-8",
|
973
|
+
pretty_print=True,
|
974
|
+
doctype="<!DOCTYPE html>",
|
975
|
+
)
|
976
|
+
return xml_bytes.decode("utf-8")
|
977
|
+
|
978
|
+
|
979
|
+
def generate_ncx_xml(ncx: NCXDocument) -> str:
|
980
|
+
"""
|
981
|
+
Generate the XML content for toc.ncx used in EPUB 2 navigation.
|
982
|
+
|
983
|
+
:param ncx: An NCXDocument instance representing the table of contents.
|
984
|
+
:return: A string containing the full NCX XML document.
|
985
|
+
"""
|
986
|
+
nsmap_root = {None: "http://www.daisy.org/z3986/2005/ncx/"}
|
987
|
+
root = etree.Element("ncx", nsmap=nsmap_root, version="2005-1")
|
988
|
+
|
989
|
+
# head
|
990
|
+
head = etree.SubElement(root, "head")
|
991
|
+
etree.SubElement(head, "meta", name="dtb:uid", content=ncx.uid)
|
992
|
+
|
993
|
+
def _depth(points: list[NavPoint]) -> int:
|
994
|
+
if not points:
|
995
|
+
return 0
|
996
|
+
return 1 + max(_depth(p.children) for p in points)
|
997
|
+
|
998
|
+
depth = _depth(ncx.nav_points)
|
999
|
+
etree.SubElement(head, "meta", name="dtb:depth", content=str(depth))
|
1000
|
+
etree.SubElement(head, "meta", name="dtb:totalPageCount", content="0")
|
1001
|
+
etree.SubElement(head, "meta", name="dtb:maxPageNumber", content="0")
|
1002
|
+
|
1003
|
+
# docTitle
|
1004
|
+
docTitle = etree.SubElement(root, "docTitle")
|
1005
|
+
text = etree.SubElement(docTitle, "text")
|
1006
|
+
text.text = ncx.title
|
1007
|
+
|
1008
|
+
# navMap
|
1009
|
+
navMap = etree.SubElement(root, "navMap")
|
1010
|
+
play_order = 1
|
1011
|
+
|
1012
|
+
def _add_navpoint(point: NavPoint, parent: _Element) -> None:
|
1013
|
+
nonlocal play_order
|
1014
|
+
np = etree.SubElement(
|
1015
|
+
parent,
|
1016
|
+
"navPoint",
|
1017
|
+
id=point.id,
|
1018
|
+
playOrder=str(play_order),
|
1019
|
+
)
|
1020
|
+
play_order += 1
|
1021
|
+
|
1022
|
+
navLabel = etree.SubElement(np, "navLabel")
|
1023
|
+
lbl_text = etree.SubElement(navLabel, "text")
|
1024
|
+
lbl_text.text = point.label
|
1025
|
+
|
1026
|
+
etree.SubElement(np, "content", src=point.src)
|
1027
|
+
|
1028
|
+
for child in point.children:
|
1029
|
+
_add_navpoint(child, np)
|
1030
|
+
|
1031
|
+
for pt in ncx.nav_points:
|
1032
|
+
_add_navpoint(pt, navMap)
|
1033
|
+
|
1034
|
+
xml_bytes: bytes = etree.tostring(
|
1035
|
+
root,
|
1036
|
+
xml_declaration=True,
|
1037
|
+
encoding="utf-8",
|
1038
|
+
pretty_print=True,
|
1039
|
+
)
|
1040
|
+
return xml_bytes.decode("utf-8")
|
1041
|
+
|
1042
|
+
|
1043
|
+
def generate_opf_xml(opf: OpfDocument) -> str:
|
1044
|
+
"""
|
1045
|
+
Generate the content.opf XML, which defines metadata, manifest, and spine.
|
1046
|
+
|
1047
|
+
This function outputs a complete OPF package document that includes:
|
1048
|
+
- <metadata>: title, author, language, identifiers, etc.
|
1049
|
+
- <manifest>: all resource entries
|
1050
|
+
- <spine>: the reading order of the content
|
1051
|
+
- <guide>: optional references like cover page
|
1052
|
+
|
1053
|
+
:param opf: An OpfDocument instance with metadata and content listings.
|
1054
|
+
:return: A string containing the full OPF XML content.
|
1055
|
+
"""
|
1056
|
+
OPF_NS = "http://www.idpf.org/2007/opf"
|
1057
|
+
DC_NS = "http://purl.org/dc/elements/1.1/"
|
1058
|
+
# package root
|
1059
|
+
nsmap_root = {None: OPF_NS}
|
1060
|
+
meta_nsmap = {
|
1061
|
+
"dc": DC_NS,
|
1062
|
+
"opf": OPF_NS,
|
1063
|
+
}
|
1064
|
+
|
1065
|
+
# <package>
|
1066
|
+
pkg_attrib = {
|
1067
|
+
"version": "3.0",
|
1068
|
+
"unique-identifier": "id",
|
1069
|
+
"prefix": "rendition: http://www.idpf.org/vocab/rendition/#",
|
1070
|
+
}
|
1071
|
+
package = etree.Element(f"{{{OPF_NS}}}package", attrib=pkg_attrib, nsmap=nsmap_root)
|
1072
|
+
|
1073
|
+
# <metadata>
|
1074
|
+
metadata = etree.SubElement(package, f"{{{OPF_NS}}}metadata", nsmap=meta_nsmap)
|
1075
|
+
|
1076
|
+
now = datetime.now(UTC).replace(microsecond=0).isoformat()
|
1077
|
+
m = etree.SubElement(
|
1078
|
+
metadata,
|
1079
|
+
f"{{{OPF_NS}}}meta",
|
1080
|
+
attrib={"property": "dcterms:modified"},
|
1081
|
+
)
|
1082
|
+
m.text = now
|
1083
|
+
|
1084
|
+
dc_id = etree.SubElement(
|
1085
|
+
metadata,
|
1086
|
+
f"{{{DC_NS}}}identifier",
|
1087
|
+
id="id",
|
1088
|
+
)
|
1089
|
+
dc_id.text = opf.uid
|
1090
|
+
|
1091
|
+
dc_title = etree.SubElement(
|
1092
|
+
metadata,
|
1093
|
+
f"{{{DC_NS}}}title",
|
1094
|
+
)
|
1095
|
+
dc_title.text = opf.title
|
1096
|
+
|
1097
|
+
dc_lang = etree.SubElement(
|
1098
|
+
metadata,
|
1099
|
+
f"{{{DC_NS}}}language",
|
1100
|
+
)
|
1101
|
+
dc_lang.text = opf.language
|
1102
|
+
|
1103
|
+
if opf.author:
|
1104
|
+
dc_creator = etree.SubElement(
|
1105
|
+
metadata,
|
1106
|
+
f"{{{DC_NS}}}creator",
|
1107
|
+
id="creator",
|
1108
|
+
)
|
1109
|
+
dc_creator.text = opf.author
|
1110
|
+
|
1111
|
+
if opf.description:
|
1112
|
+
dc_desc = etree.SubElement(
|
1113
|
+
metadata,
|
1114
|
+
f"{{{DC_NS}}}description",
|
1115
|
+
)
|
1116
|
+
dc_desc.text = opf.description
|
1117
|
+
|
1118
|
+
if opf.subject:
|
1119
|
+
subj_text = ",".join(opf.subject)
|
1120
|
+
dc_subject = etree.SubElement(
|
1121
|
+
metadata,
|
1122
|
+
f"{{{DC_NS}}}subject",
|
1123
|
+
)
|
1124
|
+
dc_subject.text = subj_text
|
1125
|
+
|
1126
|
+
if opf.include_cover:
|
1127
|
+
cover = next(
|
1128
|
+
(m for m in opf.manifest if m["properties"] == "cover-image"),
|
1129
|
+
None,
|
1130
|
+
)
|
1131
|
+
if cover:
|
1132
|
+
etree.SubElement(
|
1133
|
+
metadata,
|
1134
|
+
f"{{{OPF_NS}}}meta",
|
1135
|
+
name="cover",
|
1136
|
+
content=cover["id"],
|
1137
|
+
)
|
1138
|
+
|
1139
|
+
# <manifest>
|
1140
|
+
manifest = etree.SubElement(package, f"{{{OPF_NS}}}manifest")
|
1141
|
+
for item in opf.manifest:
|
1142
|
+
attrs = {
|
1143
|
+
"href": item["href"],
|
1144
|
+
"id": item["id"],
|
1145
|
+
"media-type": item["media_type"],
|
1146
|
+
}
|
1147
|
+
if item["properties"]:
|
1148
|
+
attrs["properties"] = item["properties"]
|
1149
|
+
etree.SubElement(manifest, f"{{{OPF_NS}}}item", attrib=attrs)
|
1150
|
+
|
1151
|
+
spine_attrs = {}
|
1152
|
+
toc_item = next(
|
1153
|
+
(m for m in opf.manifest if m["media_type"] == "application/x-dtbncx+xml"),
|
1154
|
+
None,
|
1155
|
+
)
|
1156
|
+
if toc_item:
|
1157
|
+
spine_attrs["toc"] = toc_item["id"]
|
1158
|
+
spine = etree.SubElement(package, f"{{{OPF_NS}}}spine", **spine_attrs)
|
1159
|
+
for ref in opf.spine:
|
1160
|
+
attrs = {"idref": ref["idref"]}
|
1161
|
+
if ref["properties"]:
|
1162
|
+
attrs["properties"] = ref["properties"]
|
1163
|
+
etree.SubElement(spine, f"{{{OPF_NS}}}itemref", attrib=attrs)
|
1164
|
+
|
1165
|
+
# <guide>
|
1166
|
+
if opf.include_cover:
|
1167
|
+
cover_ref = next((m for m in opf.manifest if m["id"] == "cover"), None)
|
1168
|
+
if cover_ref:
|
1169
|
+
guide = etree.SubElement(package, f"{{{OPF_NS}}}guide")
|
1170
|
+
etree.SubElement(
|
1171
|
+
guide,
|
1172
|
+
f"{{{OPF_NS}}}reference",
|
1173
|
+
type="cover",
|
1174
|
+
title="Cover",
|
1175
|
+
href=cover_ref["href"],
|
1176
|
+
)
|
1177
|
+
|
1178
|
+
xml_bytes: bytes = etree.tostring(
|
1179
|
+
package,
|
1180
|
+
xml_declaration=True,
|
1181
|
+
encoding="utf-8",
|
1182
|
+
pretty_print=True,
|
1183
|
+
)
|
1184
|
+
return xml_bytes.decode("utf-8")
|
1185
|
+
|
1186
|
+
|
1187
|
+
def _split_volume_title(volume_title: str) -> tuple[str, str]:
|
1188
|
+
"""
|
1189
|
+
Split volume title into two parts for better display.
|
1190
|
+
|
1191
|
+
:param volume_title: Original volume title string.
|
1192
|
+
:return: Tuple of (line1, line2)
|
1193
|
+
"""
|
1194
|
+
if " " in volume_title:
|
1195
|
+
parts = volume_title.split(" ")
|
1196
|
+
elif "-" in volume_title:
|
1197
|
+
parts = volume_title.split("-")
|
1198
|
+
else:
|
1199
|
+
return volume_title, ""
|
1200
|
+
|
1201
|
+
return parts[0], "".join(parts[1:])
|
1202
|
+
|
1203
|
+
|
1204
|
+
def _create_volume_intro(
|
1205
|
+
volume_title: str,
|
1206
|
+
volume_intro_text: str = "",
|
1207
|
+
) -> str:
|
1208
|
+
"""
|
1209
|
+
Generate the HTML snippet for a volume's introduction section.
|
1210
|
+
|
1211
|
+
:param volume_title: Title of the volume.
|
1212
|
+
:param volume_intro_text: Optional introduction text for the volume.
|
1213
|
+
:return: HTML string representing the volume's intro section.
|
1214
|
+
"""
|
1215
|
+
line1, line2 = _split_volume_title(volume_title)
|
1216
|
+
|
1217
|
+
def _make_border_img(class_name: str) -> str:
|
1218
|
+
return (
|
1219
|
+
f'<div class="{class_name}">'
|
1220
|
+
f'<img alt="" class="{class_name}" '
|
1221
|
+
f'src="../{_IMAGE_FOLDER}/volume_border.png" />'
|
1222
|
+
f"</div>"
|
1223
|
+
)
|
1224
|
+
|
1225
|
+
html_parts = [_make_border_img("border1")]
|
1226
|
+
html_parts.append(f'<h1 class="volume-title-line1">{line1}</h1>')
|
1227
|
+
html_parts.append(_make_border_img("border2"))
|
1228
|
+
if line2:
|
1229
|
+
html_parts.append(f'<p class="volume-title-line2">{line2}</p>')
|
1230
|
+
|
1231
|
+
if volume_intro_text:
|
1232
|
+
lines = [line.strip() for line in volume_intro_text.split("\n") if line.strip()]
|
1233
|
+
html_parts.extend(f'<p class="intro">{line}</p>' for line in lines)
|
1234
|
+
|
1235
|
+
return "\n".join(html_parts)
|
1236
|
+
|
1237
|
+
|
1238
|
+
def _gene_book_intro(
|
1239
|
+
book_name: str,
|
1240
|
+
author: str,
|
1241
|
+
serial_status: str,
|
1242
|
+
word_count: str,
|
1243
|
+
summary: str,
|
1244
|
+
) -> str:
|
1245
|
+
"""
|
1246
|
+
Generate HTML string for a book's information and summary.
|
1247
|
+
|
1248
|
+
:return: An HTML-formatted string presenting the book's information.
|
1249
|
+
"""
|
1250
|
+
# Start composing the HTML output
|
1251
|
+
html_parts = ["<h1>书籍简介</h1>", '<div class="list">', "<ul>"]
|
1252
|
+
|
1253
|
+
if book_name:
|
1254
|
+
html_parts.append(f"<li>书名: 《{book_name}》</li>")
|
1255
|
+
if author:
|
1256
|
+
html_parts.append(f"<li>作者: {author}</li>")
|
1257
|
+
|
1258
|
+
if word_count:
|
1259
|
+
html_parts.append(f"<li>字数: {word_count}</li>")
|
1260
|
+
if serial_status:
|
1261
|
+
html_parts.append(f"<li>状态: {serial_status}</li>")
|
1262
|
+
|
1263
|
+
html_parts.append("</ul>")
|
1264
|
+
html_parts.append("</div>")
|
1265
|
+
html_parts.append('<p class="new-page-after"><br/></p>')
|
1266
|
+
|
1267
|
+
if summary:
|
1268
|
+
html_parts.append("<h2>简介</h2>")
|
1269
|
+
for paragraph in summary.split("\n"):
|
1270
|
+
paragraph = paragraph.strip()
|
1271
|
+
if paragraph:
|
1272
|
+
html_parts.append(f"<p>{paragraph}</p>")
|
1273
|
+
|
1274
|
+
return "\n".join(html_parts)
|
1275
|
+
|
1276
|
+
|
1277
|
+
def _build_epub(
|
1278
|
+
book: Book,
|
1279
|
+
output_path: Path,
|
1280
|
+
) -> bool:
|
1281
|
+
"""
|
1282
|
+
Build an EPUB file at `output_path` from the given `book`.
|
1283
|
+
|
1284
|
+
Returns True on success, False on failure.
|
1285
|
+
"""
|
1286
|
+
# make sure output directory exists
|
1287
|
+
output_path.parent.mkdir(parents=True, exist_ok=True)
|
1288
|
+
|
1289
|
+
# generate all the XML/XHTML strings up front
|
1290
|
+
container_xml = generate_container_xml()
|
1291
|
+
nav_xhtml = generate_nav_xhtml(book.nav)
|
1292
|
+
ncx_xml = generate_ncx_xml(book.ncx)
|
1293
|
+
opf_xml = generate_opf_xml(book.opf)
|
1294
|
+
|
1295
|
+
try:
|
1296
|
+
with zipfile.ZipFile(output_path, "w") as epub:
|
1297
|
+
# 1) The very first file must be the uncompressed mimetype
|
1298
|
+
epub.writestr(
|
1299
|
+
"mimetype",
|
1300
|
+
"application/epub+zip",
|
1301
|
+
compress_type=ZIP_STORED,
|
1302
|
+
)
|
1303
|
+
|
1304
|
+
# 2) META-INF/container.xml
|
1305
|
+
epub.writestr(
|
1306
|
+
"META-INF/container.xml",
|
1307
|
+
container_xml,
|
1308
|
+
compress_type=ZIP_DEFLATED,
|
1309
|
+
)
|
1310
|
+
|
1311
|
+
# 3) OEBPS/nav.xhtml, toc.ncx, content.opf
|
1312
|
+
epub.writestr(
|
1313
|
+
f"{_ROOT_PATH}/nav.xhtml",
|
1314
|
+
nav_xhtml,
|
1315
|
+
compress_type=ZIP_DEFLATED,
|
1316
|
+
)
|
1317
|
+
epub.writestr(
|
1318
|
+
f"{_ROOT_PATH}/toc.ncx",
|
1319
|
+
ncx_xml,
|
1320
|
+
compress_type=ZIP_DEFLATED,
|
1321
|
+
)
|
1322
|
+
epub.writestr(
|
1323
|
+
f"{_ROOT_PATH}/content.opf",
|
1324
|
+
opf_xml,
|
1325
|
+
compress_type=ZIP_DEFLATED,
|
1326
|
+
)
|
1327
|
+
|
1328
|
+
# 4) CSS files
|
1329
|
+
for css in book.stylesheets:
|
1330
|
+
css_path = f"{_ROOT_PATH}/{_CSS_FOLDER}/{css.filename}"
|
1331
|
+
epub.writestr(
|
1332
|
+
css_path,
|
1333
|
+
css.content,
|
1334
|
+
compress_type=ZIP_DEFLATED,
|
1335
|
+
)
|
1336
|
+
|
1337
|
+
# 5) XHTML content items (chapters, etc.)
|
1338
|
+
for item in book.content_items:
|
1339
|
+
chap_path = f"{_ROOT_PATH}/{_TEXT_FOLDER}/{item.filename}"
|
1340
|
+
epub.writestr(
|
1341
|
+
chap_path,
|
1342
|
+
item.to_xhtml(),
|
1343
|
+
compress_type=ZIP_DEFLATED,
|
1344
|
+
)
|
1345
|
+
|
1346
|
+
# 6) images
|
1347
|
+
for img in book.images:
|
1348
|
+
img_path = f"{_ROOT_PATH}/{_IMAGE_FOLDER}/{img.filename}"
|
1349
|
+
epub.writestr(
|
1350
|
+
img_path,
|
1351
|
+
img.data, # bytes
|
1352
|
+
compress_type=ZIP_DEFLATED,
|
1353
|
+
)
|
1354
|
+
|
1355
|
+
return True
|
1356
|
+
|
1357
|
+
except Exception:
|
1358
|
+
return False
|