ranobelib-python-sdk 0.1.0__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.
- ranobelib/__init__.py +28 -0
- ranobelib/cache.py +64 -0
- ranobelib/client.py +210 -0
- ranobelib/exceptions.py +118 -0
- ranobelib/exporters/__init__.py +56 -0
- ranobelib/exporters/_illustrations.py +170 -0
- ranobelib/exporters/_shared.py +11 -0
- ranobelib/exporters/epub.py +117 -0
- ranobelib/exporters/fb2.py +164 -0
- ranobelib/exporters/pdf.py +147 -0
- ranobelib/exporters/txt.py +89 -0
- ranobelib/models.py +245 -0
- ranobelib/numbering.py +34 -0
- ranobelib/py.typed +0 -0
- ranobelib/sdk.py +321 -0
- ranobelib_python_sdk-0.1.0.dist-info/METADATA +100 -0
- ranobelib_python_sdk-0.1.0.dist-info/RECORD +18 -0
- ranobelib_python_sdk-0.1.0.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""EPUB exporter (cover and in-chapter illustrations included)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import html
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import ClassVar
|
|
8
|
+
|
|
9
|
+
import httpx
|
|
10
|
+
from ebooklib import epub
|
|
11
|
+
|
|
12
|
+
from ranobelib.exporters import register
|
|
13
|
+
from ranobelib.exporters._illustrations import (
|
|
14
|
+
download_images,
|
|
15
|
+
extract_image_urls,
|
|
16
|
+
guess_extension,
|
|
17
|
+
media_type_for,
|
|
18
|
+
pick_cover_url,
|
|
19
|
+
rewrite_image_srcs,
|
|
20
|
+
)
|
|
21
|
+
from ranobelib.exporters._shared import chapter_heading
|
|
22
|
+
from ranobelib.models import Chapter, Title
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@register
|
|
26
|
+
class EpubExporter:
|
|
27
|
+
"""Exports chapters as a single EPUB file, with cover and in-chapter illustrations."""
|
|
28
|
+
|
|
29
|
+
format: ClassVar[str] = "epub"
|
|
30
|
+
|
|
31
|
+
def __init__(self, *, transport: httpx.AsyncBaseTransport | None = None) -> None:
|
|
32
|
+
"""Initialize the exporter.
|
|
33
|
+
|
|
34
|
+
Args:
|
|
35
|
+
transport: Custom ``httpx`` transport for the image-downloading client, e.g. a
|
|
36
|
+
mock transport for tests. Not part of the ``Exporter`` protocol itself —
|
|
37
|
+
``RanobeLib.export()`` always constructs exporters with no arguments, so
|
|
38
|
+
this is only reachable by instantiating ``EpubExporter`` directly.
|
|
39
|
+
"""
|
|
40
|
+
self._transport = transport
|
|
41
|
+
|
|
42
|
+
async def export(self, title: Title, chapters: list[Chapter], output_path: Path) -> Path:
|
|
43
|
+
"""Write ``chapters`` to ``output_path`` as an EPUB file.
|
|
44
|
+
|
|
45
|
+
Downloads the title's cover and every image referenced in the chapters' content and
|
|
46
|
+
embeds them; a download that fails is skipped rather than failing the whole export
|
|
47
|
+
(see docs/api-notes.md's "Illustrations" notes).
|
|
48
|
+
|
|
49
|
+
Args:
|
|
50
|
+
title: The chapters' parent title; supplies metadata, cover, and authors.
|
|
51
|
+
chapters: The chapters to include, in the order they should appear.
|
|
52
|
+
output_path: Where to write the ``.epub`` file.
|
|
53
|
+
|
|
54
|
+
Returns:
|
|
55
|
+
``output_path``.
|
|
56
|
+
"""
|
|
57
|
+
book = epub.EpubBook()
|
|
58
|
+
book.set_identifier(title.slug_url)
|
|
59
|
+
book.set_title(title.name)
|
|
60
|
+
book.set_language("ru")
|
|
61
|
+
for author in title.authors:
|
|
62
|
+
book.add_author(author.name)
|
|
63
|
+
|
|
64
|
+
async with httpx.AsyncClient(
|
|
65
|
+
timeout=15.0, follow_redirects=True, transport=self._transport
|
|
66
|
+
) as client:
|
|
67
|
+
cover_url = pick_cover_url(title.cover)
|
|
68
|
+
if cover_url is not None:
|
|
69
|
+
cover_images = await download_images(client, [cover_url])
|
|
70
|
+
cover_bytes = cover_images.get(cover_url)
|
|
71
|
+
if cover_bytes is not None:
|
|
72
|
+
book.set_cover(f"cover{guess_extension(cover_url)}", cover_bytes)
|
|
73
|
+
|
|
74
|
+
image_urls = list(
|
|
75
|
+
dict.fromkeys(
|
|
76
|
+
url for chapter in chapters for url in extract_image_urls(chapter.content or "")
|
|
77
|
+
)
|
|
78
|
+
)
|
|
79
|
+
images = await download_images(client, image_urls)
|
|
80
|
+
|
|
81
|
+
url_to_local: dict[str, str] = {}
|
|
82
|
+
for index, (url, data) in enumerate(images.items(), start=1):
|
|
83
|
+
extension = guess_extension(url)
|
|
84
|
+
local_name = f"images/img{index:04d}{extension}"
|
|
85
|
+
url_to_local[url] = local_name
|
|
86
|
+
book.add_item(
|
|
87
|
+
epub.EpubItem(
|
|
88
|
+
uid=local_name,
|
|
89
|
+
file_name=local_name,
|
|
90
|
+
media_type=media_type_for(extension),
|
|
91
|
+
content=data,
|
|
92
|
+
)
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
spine: list[object] = ["nav"]
|
|
96
|
+
toc: list[object] = []
|
|
97
|
+
for index, chapter in enumerate(chapters, start=1):
|
|
98
|
+
heading = chapter_heading(chapter)
|
|
99
|
+
body = rewrite_image_srcs(chapter.content or "", url_to_local)
|
|
100
|
+
doc = epub.EpubHtml(
|
|
101
|
+
uid=f"chapter_{index}",
|
|
102
|
+
file_name=f"chapter_{index}.xhtml",
|
|
103
|
+
title=heading,
|
|
104
|
+
lang="ru",
|
|
105
|
+
)
|
|
106
|
+
doc.content = f"<h1>{html.escape(heading)}</h1>{body}"
|
|
107
|
+
book.add_item(doc)
|
|
108
|
+
spine.append(doc)
|
|
109
|
+
toc.append(doc)
|
|
110
|
+
|
|
111
|
+
book.toc = toc
|
|
112
|
+
book.add_item(epub.EpubNcx())
|
|
113
|
+
book.add_item(epub.EpubNav())
|
|
114
|
+
book.spine = spine
|
|
115
|
+
|
|
116
|
+
epub.write_epub(str(output_path), book)
|
|
117
|
+
return output_path
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
"""FB2 exporter."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import datetime
|
|
6
|
+
import xml.etree.ElementTree as ET
|
|
7
|
+
from html.parser import HTMLParser
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import ClassVar
|
|
10
|
+
|
|
11
|
+
from ranobelib.exporters import register
|
|
12
|
+
from ranobelib.exporters._shared import chapter_heading
|
|
13
|
+
from ranobelib.models import Chapter, Title
|
|
14
|
+
|
|
15
|
+
FB2_NS = "http://www.gribuser.ru/xml/fictionbook/2.0"
|
|
16
|
+
ET.register_namespace("", FB2_NS)
|
|
17
|
+
|
|
18
|
+
_BLOCK_TAGS = frozenset({"p", "div"})
|
|
19
|
+
_STRONG_TAGS = frozenset({"strong", "b"})
|
|
20
|
+
_EMPHASIS_TAGS = frozenset({"em", "i"})
|
|
21
|
+
|
|
22
|
+
_FALLBACK_GENRE = "prose_contemporary"
|
|
23
|
+
"""FB2's genre vocabulary is a fixed, controlled list with no reliable mapping from
|
|
24
|
+
ranobelib's free-text genres — guessing wrong would be worse than a generic, always-valid
|
|
25
|
+
fallback (title-info requires at least one genre), so genre mapping is out of scope for now.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _tag(name: str) -> str:
|
|
30
|
+
return f"{{{FB2_NS}}}{name}"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class _HtmlToFb2Paragraphs(HTMLParser):
|
|
34
|
+
"""Parses the SDK's normalized chapter-content HTML into FB2 ``<p>``/``<empty-line/>``
|
|
35
|
+
elements, preserving ``<strong>``/``<em>`` marks (mapped from ``b``/``i`` too — see
|
|
36
|
+
docs/api-notes.md's chapter-content notes on that tag variance).
|
|
37
|
+
|
|
38
|
+
Built on the stdlib parser rather than a regex, same reasoning as the txt exporter's
|
|
39
|
+
``html_to_text``: HTML-string-format chapters pass the site's own markup through as-is.
|
|
40
|
+
Images carry no text and are dropped, matching CLAUDE.md's "no illustrations in fb2/txt"
|
|
41
|
+
decision.
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
def __init__(self) -> None:
|
|
45
|
+
super().__init__(convert_charrefs=True)
|
|
46
|
+
self._paragraphs: list[ET.Element] = []
|
|
47
|
+
self._current: ET.Element | None = None
|
|
48
|
+
self._stack: list[ET.Element] = []
|
|
49
|
+
|
|
50
|
+
def _open_paragraph(self) -> None:
|
|
51
|
+
if self._current is None:
|
|
52
|
+
self._current = ET.Element(_tag("p"))
|
|
53
|
+
self._stack = [self._current]
|
|
54
|
+
|
|
55
|
+
def _close_paragraph(self) -> None:
|
|
56
|
+
if self._current is not None:
|
|
57
|
+
if (self._current.text and self._current.text.strip()) or len(self._current):
|
|
58
|
+
self._paragraphs.append(self._current)
|
|
59
|
+
self._current = None
|
|
60
|
+
self._stack = []
|
|
61
|
+
|
|
62
|
+
def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
|
|
63
|
+
if tag in _BLOCK_TAGS or tag == "br":
|
|
64
|
+
self._close_paragraph()
|
|
65
|
+
self._open_paragraph()
|
|
66
|
+
elif tag == "hr":
|
|
67
|
+
self._close_paragraph()
|
|
68
|
+
self._paragraphs.append(ET.Element(_tag("empty-line")))
|
|
69
|
+
elif tag in _STRONG_TAGS:
|
|
70
|
+
self._open_paragraph()
|
|
71
|
+
self._stack.append(ET.SubElement(self._stack[-1], _tag("strong")))
|
|
72
|
+
elif tag in _EMPHASIS_TAGS:
|
|
73
|
+
self._open_paragraph()
|
|
74
|
+
self._stack.append(ET.SubElement(self._stack[-1], _tag("emphasis")))
|
|
75
|
+
|
|
76
|
+
def handle_endtag(self, tag: str) -> None:
|
|
77
|
+
if tag in _BLOCK_TAGS:
|
|
78
|
+
self._close_paragraph()
|
|
79
|
+
elif (tag in _STRONG_TAGS or tag in _EMPHASIS_TAGS) and len(self._stack) > 1:
|
|
80
|
+
self._stack.pop()
|
|
81
|
+
|
|
82
|
+
def handle_data(self, data: str) -> None:
|
|
83
|
+
self._open_paragraph()
|
|
84
|
+
target = self._stack[-1]
|
|
85
|
+
if len(target) == 0:
|
|
86
|
+
target.text = (target.text or "") + data
|
|
87
|
+
else:
|
|
88
|
+
last = target[-1]
|
|
89
|
+
last.tail = (last.tail or "") + data
|
|
90
|
+
|
|
91
|
+
def get_paragraphs(self) -> list[ET.Element]:
|
|
92
|
+
self._close_paragraph()
|
|
93
|
+
return self._paragraphs
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def html_to_fb2_paragraphs(html_fragment: str) -> list[ET.Element]:
|
|
97
|
+
"""Convert an HTML chapter-content fragment to a list of FB2 body elements."""
|
|
98
|
+
parser = _HtmlToFb2Paragraphs()
|
|
99
|
+
parser.feed(html_fragment)
|
|
100
|
+
return parser.get_paragraphs()
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _build_description(title: Title) -> ET.Element:
|
|
104
|
+
description = ET.Element(_tag("description"))
|
|
105
|
+
|
|
106
|
+
title_info = ET.SubElement(description, _tag("title-info"))
|
|
107
|
+
ET.SubElement(title_info, _tag("genre")).text = _FALLBACK_GENRE
|
|
108
|
+
for author in title.authors:
|
|
109
|
+
author_el = ET.SubElement(title_info, _tag("author"))
|
|
110
|
+
ET.SubElement(author_el, _tag("nickname")).text = author.name
|
|
111
|
+
ET.SubElement(title_info, _tag("book-title")).text = title.name
|
|
112
|
+
if title.summary:
|
|
113
|
+
annotation = ET.SubElement(title_info, _tag("annotation"))
|
|
114
|
+
for paragraph in title.summary.split("\n\n"):
|
|
115
|
+
ET.SubElement(annotation, _tag("p")).text = paragraph
|
|
116
|
+
ET.SubElement(title_info, _tag("lang")).text = "ru"
|
|
117
|
+
|
|
118
|
+
document_info = ET.SubElement(description, _tag("document-info"))
|
|
119
|
+
document_author = ET.SubElement(document_info, _tag("author"))
|
|
120
|
+
ET.SubElement(document_author, _tag("nickname")).text = "ranobelib-python-sdk"
|
|
121
|
+
today = datetime.date.today().isoformat()
|
|
122
|
+
ET.SubElement(document_info, _tag("date"), value=today).text = today
|
|
123
|
+
ET.SubElement(document_info, _tag("id")).text = title.slug_url
|
|
124
|
+
ET.SubElement(document_info, _tag("version")).text = "1.0"
|
|
125
|
+
|
|
126
|
+
return description
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _build_body(chapters: list[Chapter]) -> ET.Element:
|
|
130
|
+
body = ET.Element(_tag("body"))
|
|
131
|
+
for chapter in chapters:
|
|
132
|
+
section = ET.SubElement(body, _tag("section"))
|
|
133
|
+
section_title = ET.SubElement(section, _tag("title"))
|
|
134
|
+
ET.SubElement(section_title, _tag("p")).text = chapter_heading(chapter)
|
|
135
|
+
for paragraph in html_to_fb2_paragraphs(chapter.content or ""):
|
|
136
|
+
section.append(paragraph)
|
|
137
|
+
return body
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
@register
|
|
141
|
+
class Fb2Exporter:
|
|
142
|
+
"""Exports chapters as a single FB2 (FictionBook 2) file, one section per chapter."""
|
|
143
|
+
|
|
144
|
+
format: ClassVar[str] = "fb2"
|
|
145
|
+
|
|
146
|
+
async def export(self, title: Title, chapters: list[Chapter], output_path: Path) -> Path:
|
|
147
|
+
"""Write ``chapters`` to ``output_path`` as FB2 XML.
|
|
148
|
+
|
|
149
|
+
Args:
|
|
150
|
+
title: The chapters' parent title; supplies ``description`` metadata.
|
|
151
|
+
chapters: The chapters to include, in the order they should appear.
|
|
152
|
+
output_path: Where to write the ``.fb2`` file.
|
|
153
|
+
|
|
154
|
+
Returns:
|
|
155
|
+
``output_path``.
|
|
156
|
+
"""
|
|
157
|
+
root = ET.Element(_tag("FictionBook"))
|
|
158
|
+
root.append(_build_description(title))
|
|
159
|
+
root.append(_build_body(chapters))
|
|
160
|
+
|
|
161
|
+
tree = ET.ElementTree(root)
|
|
162
|
+
ET.indent(tree, space=" ")
|
|
163
|
+
tree.write(output_path, encoding="utf-8", xml_declaration=True)
|
|
164
|
+
return output_path
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
"""PDF exporter (WeasyPrint, reuses the HTML template from the epub exporter)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import base64
|
|
7
|
+
import html
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import ClassVar
|
|
10
|
+
|
|
11
|
+
import httpx
|
|
12
|
+
|
|
13
|
+
from ranobelib.exporters import register
|
|
14
|
+
from ranobelib.exporters._illustrations import (
|
|
15
|
+
download_images,
|
|
16
|
+
extract_image_urls,
|
|
17
|
+
guess_extension,
|
|
18
|
+
media_type_for,
|
|
19
|
+
pick_cover_url,
|
|
20
|
+
rewrite_image_srcs,
|
|
21
|
+
)
|
|
22
|
+
from ranobelib.exporters._shared import chapter_heading
|
|
23
|
+
from ranobelib.models import Chapter, Title
|
|
24
|
+
|
|
25
|
+
try:
|
|
26
|
+
import weasyprint
|
|
27
|
+
except (ImportError, OSError):
|
|
28
|
+
weasyprint = None
|
|
29
|
+
# WeasyPrint's native dependencies (Pango/cairo/gobject, normally provided by a GTK3
|
|
30
|
+
# runtime on Windows/macOS; readily apt-installable on Linux) aren't available on this
|
|
31
|
+
# system -- see docs/api-notes.md's "PDF export" notes. The pip package itself installs
|
|
32
|
+
# fine everywhere; only this native check at weasyprint's own import time can fail. When
|
|
33
|
+
# it does, "pdf" simply isn't registered as an export format below -- RanobeLib.export(
|
|
34
|
+
# fmt="pdf") then raises the same ValueError it would for any other unknown format,
|
|
35
|
+
# rather than crashing `import ranobelib` itself.
|
|
36
|
+
|
|
37
|
+
_PAGE_CSS = """
|
|
38
|
+
@page { margin: 2cm 1.5cm; }
|
|
39
|
+
body { font-family: serif; line-height: 1.4; }
|
|
40
|
+
.titlepage { text-align: center; break-after: page; }
|
|
41
|
+
.titlepage img { max-width: 60%; max-height: 70vh; }
|
|
42
|
+
.chapter { break-before: page; }
|
|
43
|
+
.chapter:first-of-type { break-before: avoid; }
|
|
44
|
+
img { max-width: 100%; }
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _data_uri(media_type: str, data: bytes) -> str:
|
|
49
|
+
encoded = base64.b64encode(data).decode("ascii")
|
|
50
|
+
return f"data:{media_type};base64,{encoded}"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _title_page_html(title: Title, cover_data_uri: str | None) -> str:
|
|
54
|
+
cover_html = f'<img src="{cover_data_uri}" />' if cover_data_uri else ""
|
|
55
|
+
authors_html = ""
|
|
56
|
+
if title.authors:
|
|
57
|
+
names = ", ".join(html.escape(author.name) for author in title.authors)
|
|
58
|
+
authors_html = f"<p>{names}</p>"
|
|
59
|
+
heading = html.escape(title.name)
|
|
60
|
+
return f'<section class="titlepage">{cover_html}<h1>{heading}</h1>{authors_html}</section>'
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _chapter_html(chapter: Chapter, url_to_data_uri: dict[str, str]) -> str:
|
|
64
|
+
heading = html.escape(chapter_heading(chapter))
|
|
65
|
+
body = rewrite_image_srcs(chapter.content or "", url_to_data_uri)
|
|
66
|
+
return f'<section class="chapter"><h1>{heading}</h1>{body}</section>'
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
if weasyprint is not None:
|
|
70
|
+
|
|
71
|
+
def _render_pdf(document: str, output_path: Path) -> None:
|
|
72
|
+
weasyprint.HTML(string=document).write_pdf(str(output_path))
|
|
73
|
+
|
|
74
|
+
@register
|
|
75
|
+
class PdfExporter:
|
|
76
|
+
"""Exports chapters as a single PDF file, with cover and in-chapter illustrations.
|
|
77
|
+
|
|
78
|
+
Reuses the same illustration-downloading pipeline as ``EpubExporter``
|
|
79
|
+
(``exporters/_illustrations.py``) and the same chapter-heading/content shape — only
|
|
80
|
+
the final embedding step differs: images become base64 ``data:`` URIs directly in
|
|
81
|
+
the HTML string instead of separate files in a zip, since a PDF has no container to
|
|
82
|
+
put them in. WeasyPrint then renders that self-contained HTML/CSS to PDF; no network
|
|
83
|
+
access happens during rendering itself, only during the earlier httpx-based download
|
|
84
|
+
step, keeping all network I/O on the SDK's own async client (WeasyPrint has no async
|
|
85
|
+
API, so rendering runs in a thread via ``asyncio.to_thread`` rather than blocking the
|
|
86
|
+
event loop for however long a large book takes to lay out).
|
|
87
|
+
"""
|
|
88
|
+
|
|
89
|
+
format: ClassVar[str] = "pdf"
|
|
90
|
+
|
|
91
|
+
def __init__(self, *, transport: httpx.AsyncBaseTransport | None = None) -> None:
|
|
92
|
+
"""Initialize the exporter.
|
|
93
|
+
|
|
94
|
+
Args:
|
|
95
|
+
transport: Custom ``httpx`` transport for the image-downloading client, e.g.
|
|
96
|
+
a mock transport for tests. Not part of the ``Exporter`` protocol itself
|
|
97
|
+
— see ``EpubExporter.__init__`` for why.
|
|
98
|
+
"""
|
|
99
|
+
self._transport = transport
|
|
100
|
+
|
|
101
|
+
async def export(self, title: Title, chapters: list[Chapter], output_path: Path) -> Path:
|
|
102
|
+
"""Write ``chapters`` to ``output_path`` as a PDF file.
|
|
103
|
+
|
|
104
|
+
Args:
|
|
105
|
+
title: The chapters' parent title; supplies metadata, cover, and authors.
|
|
106
|
+
chapters: The chapters to include, in the order they should appear.
|
|
107
|
+
output_path: Where to write the ``.pdf`` file.
|
|
108
|
+
|
|
109
|
+
Returns:
|
|
110
|
+
``output_path``.
|
|
111
|
+
"""
|
|
112
|
+
async with httpx.AsyncClient(
|
|
113
|
+
timeout=15.0, follow_redirects=True, transport=self._transport
|
|
114
|
+
) as client:
|
|
115
|
+
cover_url = pick_cover_url(title.cover)
|
|
116
|
+
cover_data_uri = None
|
|
117
|
+
if cover_url is not None:
|
|
118
|
+
cover_images = await download_images(client, [cover_url])
|
|
119
|
+
cover_bytes = cover_images.get(cover_url)
|
|
120
|
+
if cover_bytes is not None:
|
|
121
|
+
cover_media_type = media_type_for(guess_extension(cover_url))
|
|
122
|
+
cover_data_uri = _data_uri(cover_media_type, cover_bytes)
|
|
123
|
+
|
|
124
|
+
image_urls = list(
|
|
125
|
+
dict.fromkeys(
|
|
126
|
+
url
|
|
127
|
+
for chapter in chapters
|
|
128
|
+
for url in extract_image_urls(chapter.content or "")
|
|
129
|
+
)
|
|
130
|
+
)
|
|
131
|
+
images = await download_images(client, image_urls)
|
|
132
|
+
|
|
133
|
+
url_to_data_uri = {
|
|
134
|
+
url: _data_uri(media_type_for(guess_extension(url)), data)
|
|
135
|
+
for url, data in images.items()
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
body = _title_page_html(title, cover_data_uri) + "".join(
|
|
139
|
+
_chapter_html(chapter, url_to_data_uri) for chapter in chapters
|
|
140
|
+
)
|
|
141
|
+
document = (
|
|
142
|
+
f"<html><head><meta charset='utf-8'><style>{_PAGE_CSS}</style></head>"
|
|
143
|
+
f"<body>{body}</body></html>"
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
await asyncio.to_thread(_render_pdf, document, output_path)
|
|
147
|
+
return output_path
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""Plain text exporter."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from html.parser import HTMLParser
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import ClassVar
|
|
8
|
+
|
|
9
|
+
from ranobelib.exporters import register
|
|
10
|
+
from ranobelib.exporters._shared import chapter_heading
|
|
11
|
+
from ranobelib.models import Chapter, Title
|
|
12
|
+
|
|
13
|
+
_BLOCK_TAGS = frozenset({"p", "div"})
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class _HtmlToText(HTMLParser):
|
|
17
|
+
"""Extracts readable plain text from the SDK's normalized chapter-content HTML.
|
|
18
|
+
|
|
19
|
+
Built on the stdlib parser rather than a regex, since chapter content isn't limited to
|
|
20
|
+
the small tag vocabulary ``Chapter.content`` normalizes prosemirror-doc chapters to —
|
|
21
|
+
HTML-string-format chapters pass the site's own markup through as-is, which can include
|
|
22
|
+
tags/entities a regex would mishandle (see docs/api-notes.md's chapter-content notes).
|
|
23
|
+
Images carry no text and are dropped automatically, matching the "no illustrations in
|
|
24
|
+
txt/fb2" decision in CLAUDE.md.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
def __init__(self) -> None:
|
|
28
|
+
super().__init__(convert_charrefs=True)
|
|
29
|
+
self._paragraphs: list[str] = []
|
|
30
|
+
self._current: list[str] = []
|
|
31
|
+
|
|
32
|
+
def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
|
|
33
|
+
if tag in _BLOCK_TAGS:
|
|
34
|
+
self._flush()
|
|
35
|
+
elif tag == "br":
|
|
36
|
+
self._current.append("\n")
|
|
37
|
+
elif tag == "hr":
|
|
38
|
+
self._flush()
|
|
39
|
+
self._paragraphs.append("---")
|
|
40
|
+
|
|
41
|
+
def handle_endtag(self, tag: str) -> None:
|
|
42
|
+
if tag in _BLOCK_TAGS:
|
|
43
|
+
self._flush()
|
|
44
|
+
|
|
45
|
+
def handle_data(self, data: str) -> None:
|
|
46
|
+
self._current.append(data)
|
|
47
|
+
|
|
48
|
+
def _flush(self) -> None:
|
|
49
|
+
text = "".join(self._current).strip()
|
|
50
|
+
if text:
|
|
51
|
+
self._paragraphs.append(text)
|
|
52
|
+
self._current = []
|
|
53
|
+
|
|
54
|
+
def get_text(self) -> str:
|
|
55
|
+
self._flush()
|
|
56
|
+
return "\n\n".join(self._paragraphs)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def html_to_text(html_fragment: str) -> str:
|
|
60
|
+
"""Convert an HTML chapter-content fragment to plain text paragraphs."""
|
|
61
|
+
parser = _HtmlToText()
|
|
62
|
+
parser.feed(html_fragment)
|
|
63
|
+
return parser.get_text()
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@register
|
|
67
|
+
class TxtExporter:
|
|
68
|
+
"""Exports chapters as a single plain-text file, one heading per chapter."""
|
|
69
|
+
|
|
70
|
+
format: ClassVar[str] = "txt"
|
|
71
|
+
|
|
72
|
+
async def export(self, title: Title, chapters: list[Chapter], output_path: Path) -> Path:
|
|
73
|
+
"""Write ``chapters`` to ``output_path`` as plain text.
|
|
74
|
+
|
|
75
|
+
Args:
|
|
76
|
+
title: The chapters' parent title; only its name is used.
|
|
77
|
+
chapters: The chapters to include, in the order they should appear.
|
|
78
|
+
output_path: Where to write the ``.txt`` file.
|
|
79
|
+
|
|
80
|
+
Returns:
|
|
81
|
+
``output_path``.
|
|
82
|
+
"""
|
|
83
|
+
sections = [title.name]
|
|
84
|
+
for chapter in chapters:
|
|
85
|
+
body = html_to_text(chapter.content or "")
|
|
86
|
+
sections.append(f"{chapter_heading(chapter)}\n\n{body}")
|
|
87
|
+
|
|
88
|
+
output_path.write_text("\n\n\n".join(sections) + "\n", encoding="utf-8")
|
|
89
|
+
return output_path
|