ranobelib-python-sdk 0.2.0__tar.gz → 0.4.0__tar.gz

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 (23) hide show
  1. ranobelib_python_sdk-0.4.0/PKG-INFO +113 -0
  2. ranobelib_python_sdk-0.4.0/README.md +84 -0
  3. {ranobelib_python_sdk-0.2.0 → ranobelib_python_sdk-0.4.0}/pyproject.toml +3 -1
  4. {ranobelib_python_sdk-0.2.0 → ranobelib_python_sdk-0.4.0}/pyproject.toml.orig +3 -1
  5. {ranobelib_python_sdk-0.2.0 → ranobelib_python_sdk-0.4.0}/src/ranobelib/__init__.py +3 -0
  6. ranobelib_python_sdk-0.4.0/src/ranobelib/console.py +72 -0
  7. {ranobelib_python_sdk-0.2.0 → ranobelib_python_sdk-0.4.0}/src/ranobelib/exporters/__init__.py +13 -1
  8. {ranobelib_python_sdk-0.2.0 → ranobelib_python_sdk-0.4.0}/src/ranobelib/exporters/epub.py +13 -1
  9. {ranobelib_python_sdk-0.2.0 → ranobelib_python_sdk-0.4.0}/src/ranobelib/exporters/fb2.py +14 -3
  10. {ranobelib_python_sdk-0.2.0 → ranobelib_python_sdk-0.4.0}/src/ranobelib/exporters/pdf.py +18 -4
  11. {ranobelib_python_sdk-0.2.0 → ranobelib_python_sdk-0.4.0}/src/ranobelib/exporters/txt.py +12 -1
  12. {ranobelib_python_sdk-0.2.0 → ranobelib_python_sdk-0.4.0}/src/ranobelib/sdk.py +111 -5
  13. ranobelib_python_sdk-0.4.0/src/ranobelib/sizing.py +62 -0
  14. ranobelib_python_sdk-0.2.0/PKG-INFO +0 -478
  15. ranobelib_python_sdk-0.2.0/README.md +0 -451
  16. {ranobelib_python_sdk-0.2.0 → ranobelib_python_sdk-0.4.0}/src/ranobelib/cache.py +0 -0
  17. {ranobelib_python_sdk-0.2.0 → ranobelib_python_sdk-0.4.0}/src/ranobelib/client.py +0 -0
  18. {ranobelib_python_sdk-0.2.0 → ranobelib_python_sdk-0.4.0}/src/ranobelib/exceptions.py +0 -0
  19. {ranobelib_python_sdk-0.2.0 → ranobelib_python_sdk-0.4.0}/src/ranobelib/exporters/_illustrations.py +0 -0
  20. {ranobelib_python_sdk-0.2.0 → ranobelib_python_sdk-0.4.0}/src/ranobelib/exporters/_shared.py +0 -0
  21. {ranobelib_python_sdk-0.2.0 → ranobelib_python_sdk-0.4.0}/src/ranobelib/models.py +0 -0
  22. {ranobelib_python_sdk-0.2.0 → ranobelib_python_sdk-0.4.0}/src/ranobelib/numbering.py +0 -0
  23. {ranobelib_python_sdk-0.2.0 → ranobelib_python_sdk-0.4.0}/src/ranobelib/py.typed +0 -0
@@ -0,0 +1,113 @@
1
+ Metadata-Version: 2.4
2
+ Name: ranobelib-python-sdk
3
+ Version: 0.4.0
4
+ Summary: Async Python SDK for ranobelib.me: title metadata, chapter/volume downloads, and export to epub/fb2/txt/pdf
5
+ Keywords: ranobelib,ranobe,novel,webnovel,epub,fb2,sdk
6
+ Author: LLIJIYAPNIK
7
+ Author-email: LLIJIYAPNIK <kas4wot@yandex.ru>
8
+ License-Expression: MIT
9
+ Classifier: Development Status :: 2 - Pre-Alpha
10
+ Classifier: Intended Audience :: Developers
11
+ Classifier: Operating System :: OS Independent
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Programming Language :: Python :: 3.11
14
+ Classifier: Programming Language :: Python :: 3.12
15
+ Classifier: Programming Language :: Python :: 3.13
16
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
17
+ Classifier: Typing :: Typed
18
+ Requires-Dist: ebooklib>=0.20
19
+ Requires-Dist: httpx>=0.27
20
+ Requires-Dist: pydantic>=2.7
21
+ Requires-Dist: rich>=15.0.0
22
+ Requires-Dist: weasyprint>=69.0
23
+ Requires-Python: >=3.11
24
+ Project-URL: Homepage, https://github.com/LLIJIYAPNIK/ranobelib-python-sdk
25
+ Project-URL: Repository, https://github.com/LLIJIYAPNIK/ranobelib-python-sdk
26
+ Project-URL: Issues, https://github.com/LLIJIYAPNIK/ranobelib-python-sdk/issues
27
+ Project-URL: Changelog, https://github.com/LLIJIYAPNIK/ranobelib-python-sdk/blob/main/CHANGELOG.md
28
+ Description-Content-Type: text/markdown
29
+
30
+ # ranobelib-python-sdk
31
+
32
+ [![PyPI](https://img.shields.io/pypi/v/ranobelib-python-sdk)](https://pypi.org/project/ranobelib-python-sdk/)
33
+ [![License: MIT](https://img.shields.io/github/license/LLIJIYAPNIK/ranobelib-python-sdk)](LICENSE)
34
+ [![Python versions](https://img.shields.io/badge/python-3.11%20%7C%203.12%20%7C%203.13-blue)](pyproject.toml)
35
+ [![CI](https://github.com/LLIJIYAPNIK/ranobelib-python-sdk/actions/workflows/ci.yml/badge.svg)](https://github.com/LLIJIYAPNIK/ranobelib-python-sdk/actions/workflows/ci.yml)
36
+ [![Coverage Status](https://coveralls.io/repos/github/LLIJIYAPNIK/ranobelib-python-sdk/badge.svg?branch=main)](https://coveralls.io/github/LLIJIYAPNIK/ranobelib-python-sdk?branch=main)
37
+ [![Docs](https://github.com/LLIJIYAPNIK/ranobelib-python-sdk/actions/workflows/docs.yml/badge.svg)](https://github.com/LLIJIYAPNIK/ranobelib-python-sdk/actions/workflows/docs.yml)
38
+
39
+ Async Python SDK for [ranobelib.me](https://ranobelib.me): fetch title metadata, download
40
+ chapters and volumes, and export to epub, fb2, txt, or pdf.
41
+
42
+ The SDK talks to the undocumented but open JSON API behind the site (`api.cdnlibs.org`,
43
+ part of the lib.social network) instead of scraping HTML.
44
+
45
+ > **Status:** all the calls in the quickstart below are implemented — metadata, table of
46
+ > contents, single/bulk chapter and volume downloads, whole-title bulk download, translation
47
+ > selection, and export to
48
+ > `"txt"`, `"fb2"`, `"epub"`, or `"pdf"` (cover + in-chapter illustrations embedded in
49
+ > epub/pdf). Raw API responses are cached to disk, and the underlying client bounds
50
+ > concurrency, paces requests, and retries 429/5xx with backoff. `fmt="pdf"` needs
51
+ > WeasyPrint's native dependencies (Pango/cairo/GTK3) installed on the system — `pip
52
+ > install`/`uv add` alone doesn't guarantee that on every platform; without them,
53
+ > `export(fmt="pdf")` raises the same "unknown format" error the other three formats don't.
54
+ > Authorization (private/18+/paid content) is out of scope — see `CLAUDE.md` for the full
55
+ > architecture and design decisions behind all of this.
56
+
57
+ ## Installation
58
+
59
+ ```bash
60
+ uv add ranobelib-python-sdk
61
+ ```
62
+
63
+ or
64
+
65
+ ```bash
66
+ pip install ranobelib-python-sdk
67
+ ```
68
+
69
+ Requires Python 3.11+.
70
+
71
+ ## Quickstart
72
+
73
+ ```python
74
+ from ranobelib import RanobeLib
75
+
76
+ async with RanobeLib("https://ranobelib.me/ru/book/6712--high-school-dxd-novel") as lib:
77
+ info = await lib.get_info()
78
+ toc = await lib.get_table_of_contents()
79
+
80
+ chapter = await lib.get_chapter(volume=6, number="51.6")
81
+ volume = await lib.get_volume(volume=6)
82
+
83
+ chapters = await lib.get_chapters([(6, "51.6"), (6, "52")])
84
+ volumes = await lib.get_volumes([1, 2, 3])
85
+
86
+ translations = await lib.get_translations(volume=6, number="51.6")
87
+ chapter = await lib.get_chapter(volume=6, number="51.6", branch_id=...)
88
+
89
+ all_volumes = await lib.download_title(translation_index=0, chapter_delay=0.5)
90
+
91
+ await lib.export(chapters, fmt="epub", path="output.epub")
92
+ ```
93
+
94
+ This is the full current public API — everything above is implemented and works today.
95
+
96
+ ## Examples
97
+
98
+ Complete, runnable scripts for every feature above — title metadata, table of contents,
99
+ single/bulk chapter and volume downloads, translation selection, whole-title bulk download,
100
+ disk caching, export to all four formats, and error handling — live in
101
+ **[`examples/`](examples/)**, one heavily commented file per feature. Start there for
102
+ copy-pasteable code.
103
+
104
+ ## Documentation
105
+
106
+ Full API reference and guides: **<https://LLIJIYAPNIK.github.io/ranobelib-python-sdk/>**
107
+ (built with `mkdocs-material` + `mkdocstrings`, deployed on push to `main`). `CLAUDE.md`
108
+ documents the full architecture, public API contract, and the reasoning behind the SDK's
109
+ design decisions. [`CHANGELOG.md`](CHANGELOG.md) lists what changed in each release.
110
+
111
+ ## License
112
+
113
+ MIT — see [LICENSE](LICENSE).
@@ -0,0 +1,84 @@
1
+ # ranobelib-python-sdk
2
+
3
+ [![PyPI](https://img.shields.io/pypi/v/ranobelib-python-sdk)](https://pypi.org/project/ranobelib-python-sdk/)
4
+ [![License: MIT](https://img.shields.io/github/license/LLIJIYAPNIK/ranobelib-python-sdk)](LICENSE)
5
+ [![Python versions](https://img.shields.io/badge/python-3.11%20%7C%203.12%20%7C%203.13-blue)](pyproject.toml)
6
+ [![CI](https://github.com/LLIJIYAPNIK/ranobelib-python-sdk/actions/workflows/ci.yml/badge.svg)](https://github.com/LLIJIYAPNIK/ranobelib-python-sdk/actions/workflows/ci.yml)
7
+ [![Coverage Status](https://coveralls.io/repos/github/LLIJIYAPNIK/ranobelib-python-sdk/badge.svg?branch=main)](https://coveralls.io/github/LLIJIYAPNIK/ranobelib-python-sdk?branch=main)
8
+ [![Docs](https://github.com/LLIJIYAPNIK/ranobelib-python-sdk/actions/workflows/docs.yml/badge.svg)](https://github.com/LLIJIYAPNIK/ranobelib-python-sdk/actions/workflows/docs.yml)
9
+
10
+ Async Python SDK for [ranobelib.me](https://ranobelib.me): fetch title metadata, download
11
+ chapters and volumes, and export to epub, fb2, txt, or pdf.
12
+
13
+ The SDK talks to the undocumented but open JSON API behind the site (`api.cdnlibs.org`,
14
+ part of the lib.social network) instead of scraping HTML.
15
+
16
+ > **Status:** all the calls in the quickstart below are implemented — metadata, table of
17
+ > contents, single/bulk chapter and volume downloads, whole-title bulk download, translation
18
+ > selection, and export to
19
+ > `"txt"`, `"fb2"`, `"epub"`, or `"pdf"` (cover + in-chapter illustrations embedded in
20
+ > epub/pdf). Raw API responses are cached to disk, and the underlying client bounds
21
+ > concurrency, paces requests, and retries 429/5xx with backoff. `fmt="pdf"` needs
22
+ > WeasyPrint's native dependencies (Pango/cairo/GTK3) installed on the system — `pip
23
+ > install`/`uv add` alone doesn't guarantee that on every platform; without them,
24
+ > `export(fmt="pdf")` raises the same "unknown format" error the other three formats don't.
25
+ > Authorization (private/18+/paid content) is out of scope — see `CLAUDE.md` for the full
26
+ > architecture and design decisions behind all of this.
27
+
28
+ ## Installation
29
+
30
+ ```bash
31
+ uv add ranobelib-python-sdk
32
+ ```
33
+
34
+ or
35
+
36
+ ```bash
37
+ pip install ranobelib-python-sdk
38
+ ```
39
+
40
+ Requires Python 3.11+.
41
+
42
+ ## Quickstart
43
+
44
+ ```python
45
+ from ranobelib import RanobeLib
46
+
47
+ async with RanobeLib("https://ranobelib.me/ru/book/6712--high-school-dxd-novel") as lib:
48
+ info = await lib.get_info()
49
+ toc = await lib.get_table_of_contents()
50
+
51
+ chapter = await lib.get_chapter(volume=6, number="51.6")
52
+ volume = await lib.get_volume(volume=6)
53
+
54
+ chapters = await lib.get_chapters([(6, "51.6"), (6, "52")])
55
+ volumes = await lib.get_volumes([1, 2, 3])
56
+
57
+ translations = await lib.get_translations(volume=6, number="51.6")
58
+ chapter = await lib.get_chapter(volume=6, number="51.6", branch_id=...)
59
+
60
+ all_volumes = await lib.download_title(translation_index=0, chapter_delay=0.5)
61
+
62
+ await lib.export(chapters, fmt="epub", path="output.epub")
63
+ ```
64
+
65
+ This is the full current public API — everything above is implemented and works today.
66
+
67
+ ## Examples
68
+
69
+ Complete, runnable scripts for every feature above — title metadata, table of contents,
70
+ single/bulk chapter and volume downloads, translation selection, whole-title bulk download,
71
+ disk caching, export to all four formats, and error handling — live in
72
+ **[`examples/`](examples/)**, one heavily commented file per feature. Start there for
73
+ copy-pasteable code.
74
+
75
+ ## Documentation
76
+
77
+ Full API reference and guides: **<https://LLIJIYAPNIK.github.io/ranobelib-python-sdk/>**
78
+ (built with `mkdocs-material` + `mkdocstrings`, deployed on push to `main`). `CLAUDE.md`
79
+ documents the full architecture, public API contract, and the reasoning behind the SDK's
80
+ design decisions. [`CHANGELOG.md`](CHANGELOG.md) lists what changed in each release.
81
+
82
+ ## License
83
+
84
+ MIT — see [LICENSE](LICENSE).
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "ranobelib-python-sdk"
3
- version = "0.2.0"
3
+ version = "0.4.0"
4
4
  description = "Async Python SDK for ranobelib.me: title metadata, chapter/volume downloads, and export to epub/fb2/txt/pdf"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -29,6 +29,7 @@ dependencies = [
29
29
  "ebooklib>=0.20",
30
30
  "httpx>=0.27",
31
31
  "pydantic>=2.7",
32
+ "rich>=15.0.0",
32
33
  "weasyprint>=69.0",
33
34
  ]
34
35
 
@@ -40,6 +41,7 @@ email = "kas4wot@yandex.ru"
40
41
  Homepage = "https://github.com/LLIJIYAPNIK/ranobelib-python-sdk"
41
42
  Repository = "https://github.com/LLIJIYAPNIK/ranobelib-python-sdk"
42
43
  Issues = "https://github.com/LLIJIYAPNIK/ranobelib-python-sdk/issues"
44
+ Changelog = "https://github.com/LLIJIYAPNIK/ranobelib-python-sdk/blob/main/CHANGELOG.md"
43
45
 
44
46
  [build-system]
45
47
  requires = ["uv_build>=0.11.26,<0.12.0"]
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "ranobelib-python-sdk"
3
- version = "0.2.0"
3
+ version = "0.4.0"
4
4
  description = "Async Python SDK for ranobelib.me: title metadata, chapter/volume downloads, and export to epub/fb2/txt/pdf"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -24,6 +24,7 @@ dependencies = [
24
24
  "ebooklib>=0.20",
25
25
  "httpx>=0.27",
26
26
  "pydantic>=2.7",
27
+ "rich>=15.0.0",
27
28
  "weasyprint>=69.0",
28
29
  ]
29
30
 
@@ -31,6 +32,7 @@ dependencies = [
31
32
  Homepage = "https://github.com/LLIJIYAPNIK/ranobelib-python-sdk"
32
33
  Repository = "https://github.com/LLIJIYAPNIK/ranobelib-python-sdk"
33
34
  Issues = "https://github.com/LLIJIYAPNIK/ranobelib-python-sdk/issues"
35
+ Changelog = "https://github.com/LLIJIYAPNIK/ranobelib-python-sdk/blob/main/CHANGELOG.md"
34
36
 
35
37
  [build-system]
36
38
  requires = ["uv_build>=0.11.26,<0.12.0"]
@@ -13,6 +13,7 @@ from ranobelib.exceptions import (
13
13
  )
14
14
  from ranobelib.models import Chapter, ChapterBranch, Title, Volume
15
15
  from ranobelib.sdk import RanobeLib
16
+ from ranobelib.sizing import chapter_size, volume_size
16
17
 
17
18
  __all__ = [
18
19
  "AmbiguousChapter",
@@ -29,4 +30,6 @@ __all__ = [
29
30
  "TitleNotFoundError",
30
31
  "Volume",
31
32
  "VolumeNotFoundError",
33
+ "chapter_size",
34
+ "volume_size",
32
35
  ]
@@ -0,0 +1,72 @@
1
+ """Console output for RanobeLib: progress bars and full-mode operation logging.
2
+
3
+ Behind ``RanobeLib``'s ``verbosity`` parameter (``False`` by default, ``"progress_only"``,
4
+ or ``"full"`` — see CLAUDE.md's roadmap entry for why these three, and why this prints
5
+ directly via ``rich`` rather than going through ``logging``). ``Reporter`` is the single
6
+ object that knows which of the three modes is active; every call site (``sdk.py``, the
7
+ exporters) just calls ``log()``/``progress()`` unconditionally and lets ``Reporter`` decide
8
+ whether that produces any output.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from collections.abc import Callable, Iterator
14
+ from contextlib import contextmanager
15
+ from typing import Literal
16
+
17
+ from rich.console import Console
18
+ from rich.progress import BarColumn, MofNCompleteColumn, Progress, TextColumn, TimeElapsedColumn
19
+
20
+ Verbosity = Literal["full", "progress_only"] | Literal[False]
21
+ """``False``: silent. ``"progress_only"``: progress bars only. ``"full"``: progress bars
22
+ plus a line logged for every title/chapters-list/chapter-content fetch (cache hit or API)."""
23
+
24
+
25
+ class Reporter:
26
+ """Console output gated by a ``Verbosity`` level, backed by a single ``rich.Console``."""
27
+
28
+ def __init__(self, verbosity: Verbosity, *, console: Console | None = None) -> None:
29
+ """Initialize the reporter.
30
+
31
+ Args:
32
+ verbosity: The active output level.
33
+ console: Custom ``rich.console.Console`` to print through, e.g. one writing to
34
+ an in-memory stream for tests. Defaults to a new ``Console()`` (stdout) —
35
+ only constructed at all when ``verbosity`` isn't ``False``, so a silent
36
+ ``RanobeLib`` never touches the terminal.
37
+ """
38
+ self._verbosity = verbosity
39
+ if not verbosity:
40
+ self._console = None
41
+ else:
42
+ self._console = console if console is not None else Console()
43
+
44
+ def log(self, message: str) -> None:
45
+ """Print ``message``, only when verbosity is ``"full"``.
46
+
47
+ ``markup=False``: messages embed literal ``[cache]``/``[api]`` prefixes (see
48
+ ``sdk.py``'s choke points), which rich's default markup parsing would otherwise try
49
+ to interpret as style tags.
50
+ """
51
+ if self._verbosity == "full" and self._console is not None:
52
+ self._console.log(message, markup=False)
53
+
54
+ @contextmanager
55
+ def progress(self, description: str, total: int) -> Iterator[Callable[[], None]]:
56
+ """A progress bar shown for ``"progress_only"``/``"full"``, a no-op otherwise.
57
+
58
+ Yields a zero-argument callable: call it once per completed step.
59
+ """
60
+ if not self._verbosity or total == 0:
61
+ yield lambda: None
62
+ return
63
+
64
+ with Progress(
65
+ TextColumn("[progress.description]{task.description}"),
66
+ BarColumn(),
67
+ MofNCompleteColumn(),
68
+ TimeElapsedColumn(),
69
+ console=self._console,
70
+ ) as progress:
71
+ task_id = progress.add_task(description, total=total)
72
+ yield lambda: progress.advance(task_id)
@@ -9,6 +9,7 @@ from __future__ import annotations
9
9
 
10
10
  import importlib
11
11
  import pkgutil
12
+ from collections.abc import Callable
12
13
  from pathlib import Path
13
14
  from typing import ClassVar, Protocol, TypeVar, runtime_checkable
14
15
 
@@ -22,7 +23,14 @@ class Exporter(Protocol):
22
23
  format: ClassVar[str]
23
24
  """The registry key this exporter is selected by, e.g. ``"txt"``."""
24
25
 
25
- async def export(self, title: Title, chapters: list[Chapter], output_path: Path) -> Path:
26
+ async def export(
27
+ self,
28
+ title: Title,
29
+ chapters: list[Chapter],
30
+ output_path: Path,
31
+ *,
32
+ on_chapter: Callable[[], None] | None = None,
33
+ ) -> Path:
26
34
  """Write ``chapters`` (in the given order) to ``output_path``.
27
35
 
28
36
  ``async`` since embedding illustrations (epub, pdf) requires downloading them —
@@ -33,6 +41,10 @@ class Exporter(Protocol):
33
41
  title: The chapters' parent title, for metadata (name, authors, ...).
34
42
  chapters: The chapters to include, in the order they should appear.
35
43
  output_path: Where to write the exported file.
44
+ on_chapter: Called once per chapter processed, if given — drives
45
+ ``RanobeLib.export()``'s progress bar (see CLAUDE.md's roadmap step 23).
46
+ For epub/pdf this covers the per-chapter embedding step, not the earlier
47
+ illustration-download step, which isn't itself progress-reported.
36
48
 
37
49
  Returns:
38
50
  ``output_path``, once the file has been written.
@@ -3,6 +3,7 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import html
6
+ from collections.abc import Callable
6
7
  from pathlib import Path
7
8
  from typing import ClassVar
8
9
 
@@ -39,7 +40,14 @@ class EpubExporter:
39
40
  """
40
41
  self._transport = transport
41
42
 
42
- async def export(self, title: Title, chapters: list[Chapter], output_path: Path) -> Path:
43
+ async def export(
44
+ self,
45
+ title: Title,
46
+ chapters: list[Chapter],
47
+ output_path: Path,
48
+ *,
49
+ on_chapter: Callable[[], None] | None = None,
50
+ ) -> Path:
43
51
  """Write ``chapters`` to ``output_path`` as an EPUB file.
44
52
 
45
53
  Downloads the title's cover and every image referenced in the chapters' content and
@@ -50,6 +58,8 @@ class EpubExporter:
50
58
  title: The chapters' parent title; supplies metadata, cover, and authors.
51
59
  chapters: The chapters to include, in the order they should appear.
52
60
  output_path: Where to write the ``.epub`` file.
61
+ on_chapter: Called once per chapter embedded, if given — after illustrations
62
+ have already been downloaded, see the ``Exporter`` protocol's docstring.
53
63
 
54
64
  Returns:
55
65
  ``output_path``.
@@ -107,6 +117,8 @@ class EpubExporter:
107
117
  book.add_item(doc)
108
118
  spine.append(doc)
109
119
  toc.append(doc)
120
+ if on_chapter is not None:
121
+ on_chapter()
110
122
 
111
123
  book.toc = toc
112
124
  book.add_item(epub.EpubNcx())
@@ -4,6 +4,7 @@ from __future__ import annotations
4
4
 
5
5
  import datetime
6
6
  import xml.etree.ElementTree as ET
7
+ from collections.abc import Callable
7
8
  from html.parser import HTMLParser
8
9
  from pathlib import Path
9
10
  from typing import ClassVar
@@ -126,7 +127,7 @@ def _build_description(title: Title) -> ET.Element:
126
127
  return description
127
128
 
128
129
 
129
- def _build_body(chapters: list[Chapter]) -> ET.Element:
130
+ def _build_body(chapters: list[Chapter], on_chapter: Callable[[], None] | None) -> ET.Element:
130
131
  body = ET.Element(_tag("body"))
131
132
  for chapter in chapters:
132
133
  section = ET.SubElement(body, _tag("section"))
@@ -134,6 +135,8 @@ def _build_body(chapters: list[Chapter]) -> ET.Element:
134
135
  ET.SubElement(section_title, _tag("p")).text = chapter_heading(chapter)
135
136
  for paragraph in html_to_fb2_paragraphs(chapter.content or ""):
136
137
  section.append(paragraph)
138
+ if on_chapter is not None:
139
+ on_chapter()
137
140
  return body
138
141
 
139
142
 
@@ -143,20 +146,28 @@ class Fb2Exporter:
143
146
 
144
147
  format: ClassVar[str] = "fb2"
145
148
 
146
- async def export(self, title: Title, chapters: list[Chapter], output_path: Path) -> Path:
149
+ async def export(
150
+ self,
151
+ title: Title,
152
+ chapters: list[Chapter],
153
+ output_path: Path,
154
+ *,
155
+ on_chapter: Callable[[], None] | None = None,
156
+ ) -> Path:
147
157
  """Write ``chapters`` to ``output_path`` as FB2 XML.
148
158
 
149
159
  Args:
150
160
  title: The chapters' parent title; supplies ``description`` metadata.
151
161
  chapters: The chapters to include, in the order they should appear.
152
162
  output_path: Where to write the ``.fb2`` file.
163
+ on_chapter: Called once per chapter written, if given.
153
164
 
154
165
  Returns:
155
166
  ``output_path``.
156
167
  """
157
168
  root = ET.Element(_tag("FictionBook"))
158
169
  root.append(_build_description(title))
159
- root.append(_build_body(chapters))
170
+ root.append(_build_body(chapters, on_chapter))
160
171
 
161
172
  tree = ET.ElementTree(root)
162
173
  ET.indent(tree, space=" ")
@@ -5,6 +5,7 @@ from __future__ import annotations
5
5
  import asyncio
6
6
  import base64
7
7
  import html
8
+ from collections.abc import Callable
8
9
  from pathlib import Path
9
10
  from typing import ClassVar
10
11
 
@@ -98,13 +99,23 @@ if weasyprint is not None:
98
99
  """
99
100
  self._transport = transport
100
101
 
101
- async def export(self, title: Title, chapters: list[Chapter], output_path: Path) -> Path:
102
+ async def export(
103
+ self,
104
+ title: Title,
105
+ chapters: list[Chapter],
106
+ output_path: Path,
107
+ *,
108
+ on_chapter: Callable[[], None] | None = None,
109
+ ) -> Path:
102
110
  """Write ``chapters`` to ``output_path`` as a PDF file.
103
111
 
104
112
  Args:
105
113
  title: The chapters' parent title; supplies metadata, cover, and authors.
106
114
  chapters: The chapters to include, in the order they should appear.
107
115
  output_path: Where to write the ``.pdf`` file.
116
+ on_chapter: Called once per chapter rendered to HTML, if given — after
117
+ illustrations have already been downloaded, see the ``Exporter``
118
+ protocol's docstring.
108
119
 
109
120
  Returns:
110
121
  ``output_path``.
@@ -135,9 +146,12 @@ if weasyprint is not None:
135
146
  for url, data in images.items()
136
147
  }
137
148
 
138
- body = _title_page_html(title, cover_data_uri) + "".join(
139
- _chapter_html(chapter, url_to_data_uri) for chapter in chapters
140
- )
149
+ chapter_html_parts = []
150
+ for chapter in chapters:
151
+ chapter_html_parts.append(_chapter_html(chapter, url_to_data_uri))
152
+ if on_chapter is not None:
153
+ on_chapter()
154
+ body = _title_page_html(title, cover_data_uri) + "".join(chapter_html_parts)
141
155
  document = (
142
156
  f"<html><head><meta charset='utf-8'><style>{_PAGE_CSS}</style></head>"
143
157
  f"<body>{body}</body></html>"
@@ -2,6 +2,7 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ from collections.abc import Callable
5
6
  from html.parser import HTMLParser
6
7
  from pathlib import Path
7
8
  from typing import ClassVar
@@ -69,13 +70,21 @@ class TxtExporter:
69
70
 
70
71
  format: ClassVar[str] = "txt"
71
72
 
72
- async def export(self, title: Title, chapters: list[Chapter], output_path: Path) -> Path:
73
+ async def export(
74
+ self,
75
+ title: Title,
76
+ chapters: list[Chapter],
77
+ output_path: Path,
78
+ *,
79
+ on_chapter: Callable[[], None] | None = None,
80
+ ) -> Path:
73
81
  """Write ``chapters`` to ``output_path`` as plain text.
74
82
 
75
83
  Args:
76
84
  title: The chapters' parent title; only its name is used.
77
85
  chapters: The chapters to include, in the order they should appear.
78
86
  output_path: Where to write the ``.txt`` file.
87
+ on_chapter: Called once per chapter written, if given.
79
88
 
80
89
  Returns:
81
90
  ``output_path``.
@@ -84,6 +93,8 @@ class TxtExporter:
84
93
  for chapter in chapters:
85
94
  body = html_to_text(chapter.content or "")
86
95
  sections.append(f"{chapter_heading(chapter)}\n\n{body}")
96
+ if on_chapter is not None:
97
+ on_chapter()
87
98
 
88
99
  output_path.write_text("\n\n\n".join(sections) + "\n", encoding="utf-8")
89
100
  return output_path