novel-downloader 1.4.0__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.
Files changed (31) hide show
  1. novel_downloader/__init__.py +1 -1
  2. novel_downloader/cli/download.py +69 -10
  3. novel_downloader/config/adapter.py +42 -9
  4. novel_downloader/core/downloaders/base.py +26 -22
  5. novel_downloader/core/downloaders/common.py +41 -5
  6. novel_downloader/core/downloaders/qidian.py +60 -32
  7. novel_downloader/core/exporters/common/epub.py +153 -68
  8. novel_downloader/core/exporters/epub_util.py +1358 -0
  9. novel_downloader/core/exporters/linovelib/epub.py +147 -190
  10. novel_downloader/core/fetchers/linovelib/browser.py +15 -0
  11. novel_downloader/core/fetchers/linovelib/session.py +15 -0
  12. novel_downloader/core/fetchers/qidian/browser.py +62 -10
  13. novel_downloader/core/interfaces/downloader.py +13 -12
  14. novel_downloader/locales/en.json +2 -0
  15. novel_downloader/locales/zh.json +2 -0
  16. novel_downloader/models/__init__.py +2 -0
  17. novel_downloader/models/config.py +8 -0
  18. novel_downloader/tui/screens/home.py +5 -4
  19. novel_downloader/utils/constants.py +0 -29
  20. {novel_downloader-1.4.0.dist-info → novel_downloader-1.4.2.dist-info}/METADATA +4 -2
  21. {novel_downloader-1.4.0.dist-info → novel_downloader-1.4.2.dist-info}/RECORD +25 -30
  22. novel_downloader/core/exporters/epub_utils/__init__.py +0 -40
  23. novel_downloader/core/exporters/epub_utils/css_builder.py +0 -75
  24. novel_downloader/core/exporters/epub_utils/image_loader.py +0 -131
  25. novel_downloader/core/exporters/epub_utils/initializer.py +0 -100
  26. novel_downloader/core/exporters/epub_utils/text_to_html.py +0 -178
  27. novel_downloader/core/exporters/epub_utils/volume_intro.py +0 -60
  28. {novel_downloader-1.4.0.dist-info → novel_downloader-1.4.2.dist-info}/WHEEL +0 -0
  29. {novel_downloader-1.4.0.dist-info → novel_downloader-1.4.2.dist-info}/entry_points.txt +0 -0
  30. {novel_downloader-1.4.0.dist-info → novel_downloader-1.4.2.dist-info}/licenses/LICENSE +0 -0
  31. {novel_downloader-1.4.0.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