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.
@@ -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