mangalib-python-sdk 0.1.0__tar.gz → 0.2.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 (21) hide show
  1. {mangalib_python_sdk-0.1.0 → mangalib_python_sdk-0.2.0}/PKG-INFO +30 -14
  2. {mangalib_python_sdk-0.1.0 → mangalib_python_sdk-0.2.0}/README.md +23 -9
  3. {mangalib_python_sdk-0.1.0 → mangalib_python_sdk-0.2.0}/pyproject.toml +10 -5
  4. {mangalib_python_sdk-0.1.0 → mangalib_python_sdk-0.2.0}/pyproject.toml.orig +11 -5
  5. mangalib_python_sdk-0.2.0/src/mangalib/exporters/__init__.py +71 -0
  6. mangalib_python_sdk-0.2.0/src/mangalib/exporters/_shared.py +33 -0
  7. mangalib_python_sdk-0.2.0/src/mangalib/exporters/_writing.py +27 -0
  8. mangalib_python_sdk-0.2.0/src/mangalib/exporters/cbz.py +143 -0
  9. mangalib_python_sdk-0.2.0/src/mangalib/exporters/pdf.py +83 -0
  10. mangalib_python_sdk-0.2.0/src/mangalib/images.py +183 -0
  11. {mangalib_python_sdk-0.1.0 → mangalib_python_sdk-0.2.0}/src/mangalib/models.py +3 -0
  12. mangalib_python_sdk-0.2.0/src/mangalib/sdk.py +524 -0
  13. mangalib_python_sdk-0.1.0/src/mangalib/images.py +0 -89
  14. mangalib_python_sdk-0.1.0/src/mangalib/sdk.py +0 -207
  15. {mangalib_python_sdk-0.1.0 → mangalib_python_sdk-0.2.0}/src/mangalib/__init__.py +0 -0
  16. {mangalib_python_sdk-0.1.0 → mangalib_python_sdk-0.2.0}/src/mangalib/_http.py +0 -0
  17. {mangalib_python_sdk-0.1.0 → mangalib_python_sdk-0.2.0}/src/mangalib/cache.py +0 -0
  18. {mangalib_python_sdk-0.1.0 → mangalib_python_sdk-0.2.0}/src/mangalib/catalog.py +0 -0
  19. {mangalib_python_sdk-0.1.0 → mangalib_python_sdk-0.2.0}/src/mangalib/client.py +0 -0
  20. {mangalib_python_sdk-0.1.0 → mangalib_python_sdk-0.2.0}/src/mangalib/exceptions.py +0 -0
  21. {mangalib_python_sdk-0.1.0 → mangalib_python_sdk-0.2.0}/src/mangalib/py.typed +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mangalib-python-sdk
3
- Version: 0.1.0
3
+ Version: 0.2.0
4
4
  Summary: Async Python SDK for mangalib.me: title metadata, chapters, translations, catalog and page images
5
5
  Keywords: mangalib,manga,manhwa,comics,lib.social,sdk
6
6
  Author: LLIJIYAPNIK
@@ -18,22 +18,24 @@ Classifier: Typing :: Typed
18
18
  Requires-Dist: httpx>=0.27
19
19
  Requires-Dist: libsocial-core>=1.0,<2
20
20
  Requires-Dist: pydantic>=2.7
21
+ Requires-Dist: img2pdf>=0.5 ; extra == 'pdf'
21
22
  Requires-Python: >=3.11
22
- Project-URL: Homepage, https://github.com/LLIJIYAPNIK/ranobelib-python-sdk
23
- Project-URL: Repository, https://github.com/LLIJIYAPNIK/ranobelib-python-sdk
24
- Project-URL: Issues, https://github.com/LLIJIYAPNIK/ranobelib-python-sdk/issues
25
- Project-URL: Changelog, https://github.com/LLIJIYAPNIK/ranobelib-python-sdk/blob/main/packages/mangalib/CHANGELOG.md
23
+ Project-URL: Homepage, https://github.com/LLIJIYAPNIK/libsocial-sdk
24
+ Project-URL: Repository, https://github.com/LLIJIYAPNIK/libsocial-sdk
25
+ Project-URL: Issues, https://github.com/LLIJIYAPNIK/libsocial-sdk/issues
26
+ Project-URL: Changelog, https://github.com/LLIJIYAPNIK/libsocial-sdk/blob/main/packages/mangalib/CHANGELOG.md
27
+ Provides-Extra: pdf
26
28
  Description-Content-Type: text/markdown
27
29
 
28
30
  # mangalib-python-sdk
29
31
 
30
32
  [![PyPI](https://img.shields.io/pypi/v/mangalib-python-sdk)](https://pypi.org/project/mangalib-python-sdk/)
31
- [![License: MIT](https://img.shields.io/github/license/LLIJIYAPNIK/ranobelib-python-sdk)](../../LICENSE)
33
+ [![License: MIT](https://img.shields.io/github/license/LLIJIYAPNIK/libsocial-sdk)](../../LICENSE)
32
34
  [![Python versions](https://img.shields.io/badge/python-3.11%20%7C%203.12%20%7C%203.13-blue)](pyproject.toml)
33
- [![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)
35
+ [![CI](https://github.com/LLIJIYAPNIK/libsocial-sdk/actions/workflows/ci.yml/badge.svg)](https://github.com/LLIJIYAPNIK/libsocial-sdk/actions/workflows/ci.yml)
34
36
 
35
37
  Async Python SDK for [mangalib.me](https://mangalib.me): title metadata, table of contents,
36
- translations, catalog search, and chapter pages as images.
38
+ translations, catalog search, chapter pages as images, and export to CBZ or PDF.
37
39
 
38
40
  Like [`ranobelib-python-sdk`](https://pypi.org/project/ranobelib-python-sdk/), it talks to
39
41
  the undocumented but open JSON API behind the site (`api.cdnlibs.org`, shared by the whole
@@ -41,9 +43,8 @@ lib.social network) instead of scraping HTML. The shared part (API client, reque
41
43
  cache, titles, chapters, catalog) comes from `libsocial-core`, installed with it, so a
42
44
  process using both SDKs paces its requests to the API together.
43
45
 
44
- > **Status:** early. Titles, chapters, translations, the catalog and page downloads work;
45
- > export to CBZ/PDF is planned. There is no login, so paid and early-access chapters can't
46
- > be read: fetching one raises `AuthRequiredError`.
46
+ > **Status:** early. There is no login, so paid and early-access chapters can't be read:
47
+ > fetching one raises `AuthRequiredError`, and a whole-title download leaves them out.
47
48
 
48
49
  ## Installation
49
50
 
@@ -57,7 +58,9 @@ or
57
58
  pip install mangalib-python-sdk
58
59
  ```
59
60
 
60
- Requires Python 3.11+.
61
+ PDF export needs the `pdf` extra (img2pdf): `uv add "mangalib-python-sdk[pdf]"` /
62
+ `pip install "mangalib-python-sdk[pdf]"`. Without it, `"pdf"` just isn't one of the export
63
+ formats. Requires Python 3.11+.
61
64
 
62
65
  ## Quickstart
63
66
 
@@ -76,6 +79,13 @@ async with MangaLib("https://mangalib.me/ru/manga/7965--chainsaw-man") as lib:
76
79
  print(page.number, page.width, page.height)
77
80
  images = await lib.download_pages(chapter) # list[bytes], in reading order
78
81
 
82
+ # One chapter, several, a volume, or the whole title, into one file.
83
+ await lib.export([chapter], fmt="cbz", path="chainsaw-man-1.cbz")
84
+ volumes = await lib.download_title(translation_index=0, on_chapter=print)
85
+ await lib.export(volumes, fmt="pdf", path="chainsaw-man.pdf", on_chapter=print)
86
+
87
+ size = await lib.estimate_title_size() # bytes of page images, from a sample
88
+
79
89
  async with Catalog() as catalog:
80
90
  page = await catalog.list_titles(query="chainsaw")
81
91
  ```
@@ -86,11 +96,17 @@ async with Catalog() as catalog:
86
96
  - `ChapterBranch.is_open` (from `get_table_of_contents()` / `get_translations()`) says
87
97
  whether a translation can be read without an account.
88
98
  - `download_page()` / `download_pages()` fall back from one image server to the next; see
89
- `list_image_servers()` and the API notes for why `compress` is tried first.
99
+ `list_image_servers()` and the API notes for why `compress` is tried first. Requests to
100
+ an image host are paced across the process and retried on 429/5xx; `concurrency=` sets
101
+ how many pages are fetched at once.
102
+ - `export()` writes `"cbz"` (pages in order + `ComicInfo.xml`) or `"pdf"` (a page per image,
103
+ an outline entry per chapter); `mangalib.exporters.EXPORTERS` lists what's available.
104
+ - `chapter_size()` / `volume_size()` give the exact size of the page images, from `HEAD`
105
+ requests; `estimate_title_size()` extrapolates from a sample of chapters.
90
106
 
91
107
  ## Documentation
92
108
 
93
- Full API reference: **<https://LLIJIYAPNIK.github.io/ranobelib-python-sdk/>**. What the API
109
+ Full API reference: **<https://LLIJIYAPNIK.github.io/libsocial-sdk/>**. What the API
94
110
  returns for MangaLib, and what was checked: the "MangaLib" section of
95
111
  [`docs/api-notes.md`](../../docs/api-notes.md). [`CHANGELOG.md`](CHANGELOG.md) lists what
96
112
  changed in each release.
@@ -1,12 +1,12 @@
1
1
  # mangalib-python-sdk
2
2
 
3
3
  [![PyPI](https://img.shields.io/pypi/v/mangalib-python-sdk)](https://pypi.org/project/mangalib-python-sdk/)
4
- [![License: MIT](https://img.shields.io/github/license/LLIJIYAPNIK/ranobelib-python-sdk)](../../LICENSE)
4
+ [![License: MIT](https://img.shields.io/github/license/LLIJIYAPNIK/libsocial-sdk)](../../LICENSE)
5
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)
6
+ [![CI](https://github.com/LLIJIYAPNIK/libsocial-sdk/actions/workflows/ci.yml/badge.svg)](https://github.com/LLIJIYAPNIK/libsocial-sdk/actions/workflows/ci.yml)
7
7
 
8
8
  Async Python SDK for [mangalib.me](https://mangalib.me): title metadata, table of contents,
9
- translations, catalog search, and chapter pages as images.
9
+ translations, catalog search, chapter pages as images, and export to CBZ or PDF.
10
10
 
11
11
  Like [`ranobelib-python-sdk`](https://pypi.org/project/ranobelib-python-sdk/), it talks to
12
12
  the undocumented but open JSON API behind the site (`api.cdnlibs.org`, shared by the whole
@@ -14,9 +14,8 @@ lib.social network) instead of scraping HTML. The shared part (API client, reque
14
14
  cache, titles, chapters, catalog) comes from `libsocial-core`, installed with it, so a
15
15
  process using both SDKs paces its requests to the API together.
16
16
 
17
- > **Status:** early. Titles, chapters, translations, the catalog and page downloads work;
18
- > export to CBZ/PDF is planned. There is no login, so paid and early-access chapters can't
19
- > be read: fetching one raises `AuthRequiredError`.
17
+ > **Status:** early. There is no login, so paid and early-access chapters can't be read:
18
+ > fetching one raises `AuthRequiredError`, and a whole-title download leaves them out.
20
19
 
21
20
  ## Installation
22
21
 
@@ -30,7 +29,9 @@ or
30
29
  pip install mangalib-python-sdk
31
30
  ```
32
31
 
33
- Requires Python 3.11+.
32
+ PDF export needs the `pdf` extra (img2pdf): `uv add "mangalib-python-sdk[pdf]"` /
33
+ `pip install "mangalib-python-sdk[pdf]"`. Without it, `"pdf"` just isn't one of the export
34
+ formats. Requires Python 3.11+.
34
35
 
35
36
  ## Quickstart
36
37
 
@@ -49,6 +50,13 @@ async with MangaLib("https://mangalib.me/ru/manga/7965--chainsaw-man") as lib:
49
50
  print(page.number, page.width, page.height)
50
51
  images = await lib.download_pages(chapter) # list[bytes], in reading order
51
52
 
53
+ # One chapter, several, a volume, or the whole title, into one file.
54
+ await lib.export([chapter], fmt="cbz", path="chainsaw-man-1.cbz")
55
+ volumes = await lib.download_title(translation_index=0, on_chapter=print)
56
+ await lib.export(volumes, fmt="pdf", path="chainsaw-man.pdf", on_chapter=print)
57
+
58
+ size = await lib.estimate_title_size() # bytes of page images, from a sample
59
+
52
60
  async with Catalog() as catalog:
53
61
  page = await catalog.list_titles(query="chainsaw")
54
62
  ```
@@ -59,11 +67,17 @@ async with Catalog() as catalog:
59
67
  - `ChapterBranch.is_open` (from `get_table_of_contents()` / `get_translations()`) says
60
68
  whether a translation can be read without an account.
61
69
  - `download_page()` / `download_pages()` fall back from one image server to the next; see
62
- `list_image_servers()` and the API notes for why `compress` is tried first.
70
+ `list_image_servers()` and the API notes for why `compress` is tried first. Requests to
71
+ an image host are paced across the process and retried on 429/5xx; `concurrency=` sets
72
+ how many pages are fetched at once.
73
+ - `export()` writes `"cbz"` (pages in order + `ComicInfo.xml`) or `"pdf"` (a page per image,
74
+ an outline entry per chapter); `mangalib.exporters.EXPORTERS` lists what's available.
75
+ - `chapter_size()` / `volume_size()` give the exact size of the page images, from `HEAD`
76
+ requests; `estimate_title_size()` extrapolates from a sample of chapters.
63
77
 
64
78
  ## Documentation
65
79
 
66
- Full API reference: **<https://LLIJIYAPNIK.github.io/ranobelib-python-sdk/>**. What the API
80
+ Full API reference: **<https://LLIJIYAPNIK.github.io/libsocial-sdk/>**. What the API
67
81
  returns for MangaLib, and what was checked: the "MangaLib" section of
68
82
  [`docs/api-notes.md`](../../docs/api-notes.md). [`CHANGELOG.md`](CHANGELOG.md) lists what
69
83
  changed in each release.
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "mangalib-python-sdk"
3
- version = "0.1.0"
3
+ version = "0.2.0"
4
4
  description = "Async Python SDK for mangalib.me: title metadata, chapters, translations, catalog and page images"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -34,11 +34,14 @@ dependencies = [
34
34
  name = "LLIJIYAPNIK"
35
35
  email = "kas4wot@yandex.ru"
36
36
 
37
+ [project.optional-dependencies]
38
+ pdf = ["img2pdf>=0.5"]
39
+
37
40
  [project.urls]
38
- Homepage = "https://github.com/LLIJIYAPNIK/ranobelib-python-sdk"
39
- Repository = "https://github.com/LLIJIYAPNIK/ranobelib-python-sdk"
40
- Issues = "https://github.com/LLIJIYAPNIK/ranobelib-python-sdk/issues"
41
- Changelog = "https://github.com/LLIJIYAPNIK/ranobelib-python-sdk/blob/main/packages/mangalib/CHANGELOG.md"
41
+ Homepage = "https://github.com/LLIJIYAPNIK/libsocial-sdk"
42
+ Repository = "https://github.com/LLIJIYAPNIK/libsocial-sdk"
43
+ Issues = "https://github.com/LLIJIYAPNIK/libsocial-sdk/issues"
44
+ Changelog = "https://github.com/LLIJIYAPNIK/libsocial-sdk/blob/main/packages/mangalib/CHANGELOG.md"
42
45
 
43
46
  [build-system]
44
47
  requires = ["uv_build>=0.11.26,<0.12.0"]
@@ -52,6 +55,8 @@ workspace = true
52
55
 
53
56
  [dependency-groups]
54
57
  dev = [
58
+ "img2pdf>=0.5",
59
+ "pypdf>=6.14.2",
55
60
  "pytest>=8.0",
56
61
  "pytest-asyncio>=0.24",
57
62
  "pytest-cov>=5.0",
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "mangalib-python-sdk"
3
- version = "0.1.0"
3
+ version = "0.2.0"
4
4
  description = "Async Python SDK for mangalib.me: title metadata, chapters, translations, catalog and page images"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -26,11 +26,15 @@ dependencies = [
26
26
  "pydantic>=2.7",
27
27
  ]
28
28
 
29
+ [project.optional-dependencies]
30
+ # PDF export. Without it, "pdf" just isn't one of the export formats.
31
+ pdf = ["img2pdf>=0.5"]
32
+
29
33
  [project.urls]
30
- Homepage = "https://github.com/LLIJIYAPNIK/ranobelib-python-sdk"
31
- Repository = "https://github.com/LLIJIYAPNIK/ranobelib-python-sdk"
32
- Issues = "https://github.com/LLIJIYAPNIK/ranobelib-python-sdk/issues"
33
- Changelog = "https://github.com/LLIJIYAPNIK/ranobelib-python-sdk/blob/main/packages/mangalib/CHANGELOG.md"
34
+ Homepage = "https://github.com/LLIJIYAPNIK/libsocial-sdk"
35
+ Repository = "https://github.com/LLIJIYAPNIK/libsocial-sdk"
36
+ Issues = "https://github.com/LLIJIYAPNIK/libsocial-sdk/issues"
37
+ Changelog = "https://github.com/LLIJIYAPNIK/libsocial-sdk/blob/main/packages/mangalib/CHANGELOG.md"
34
38
 
35
39
  [build-system]
36
40
  requires = ["uv_build>=0.11.26,<0.12.0"]
@@ -47,6 +51,8 @@ libsocial-core = { workspace = true }
47
51
  # mangalib-python-sdk` installs the package's own groups, not the root's.
48
52
  [dependency-groups]
49
53
  dev = [
54
+ "img2pdf>=0.5",
55
+ "pypdf>=6.14.2",
50
56
  "pytest>=8.0",
51
57
  "pytest-asyncio>=0.24",
52
58
  "pytest-cov>=5.0",
@@ -0,0 +1,71 @@
1
+ """Exporter protocol and format registry — the same contract as ``ranobelib.exporters``.
2
+
3
+ A new export format is a new module in this package: define a class implementing
4
+ ``Exporter`` and decorate it with ``@register`` — no changes needed here or in ``sdk.py``.
5
+ Every module in this package is auto-imported below so its ``@register`` runs on its own.
6
+ A format whose optional dependency isn't installed (``pdf`` needs ``img2pdf``) skips its
7
+ ``@register``, so it's simply not in ``EXPORTERS``.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import importlib
13
+ import pkgutil
14
+ from collections.abc import Awaitable, Callable
15
+ from pathlib import Path
16
+ from typing import ClassVar, Protocol, TypeVar, runtime_checkable
17
+
18
+ from mangalib.models import Chapter, Title
19
+
20
+ FetchPages = Callable[[Chapter], Awaitable[list[bytes]]]
21
+ """Downloads a chapter's page images, in reading order — ``MangaLib.download_pages()``."""
22
+
23
+
24
+ @runtime_checkable
25
+ class Exporter(Protocol):
26
+ """Renders a title's chapters, as page images, to a single file in some format."""
27
+
28
+ format: ClassVar[str]
29
+ """The registry key this exporter is selected by, e.g. ``"cbz"``."""
30
+
31
+ async def export(
32
+ self,
33
+ title: Title,
34
+ chapters: list[Chapter],
35
+ output_path: Path,
36
+ *,
37
+ fetch_pages: FetchPages,
38
+ on_chapter: Callable[[], None] | None = None,
39
+ ) -> Path:
40
+ """Write ``chapters`` (in the given order) to ``output_path``.
41
+
42
+ Args:
43
+ title: The chapters' parent title, for metadata (name, authors, ...).
44
+ chapters: The chapters to include, in the order they should appear — fetched
45
+ ones, with ``pages``.
46
+ output_path: Where to write the exported file.
47
+ fetch_pages: Downloads one chapter's page images. The exporter calls it once per
48
+ chapter, in order, so it never holds more than it has to in memory.
49
+ on_chapter: Called once per chapter written, if given — drives
50
+ ``MangaLib.export()``'s progress reporting.
51
+
52
+ Returns:
53
+ ``output_path``, once the file has been written.
54
+ """
55
+ ...
56
+
57
+
58
+ ExporterT = TypeVar("ExporterT", bound=type[Exporter])
59
+
60
+ EXPORTERS: dict[str, type[Exporter]] = {}
61
+ """Registered exporters, keyed by ``Exporter.format``."""
62
+
63
+
64
+ def register(exporter: ExporterT) -> ExporterT:
65
+ """Class decorator: register ``exporter`` under its ``format`` key."""
66
+ EXPORTERS[exporter.format] = exporter
67
+ return exporter
68
+
69
+
70
+ for _module_info in pkgutil.iter_modules(__path__, f"{__name__}."):
71
+ importlib.import_module(_module_info.name)
@@ -0,0 +1,33 @@
1
+ """Helpers shared by the exporters (not public API)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from mangalib.models import Chapter
6
+
7
+ _SIGNATURES = (
8
+ (b"\xff\xd8\xff", "jpg"),
9
+ (b"\x89PNG\r\n\x1a\n", "png"),
10
+ (b"GIF87a", "gif"),
11
+ (b"GIF89a", "gif"),
12
+ )
13
+
14
+
15
+ def image_extension(data: bytes) -> str:
16
+ """The file extension for an image, from its bytes rather than its name.
17
+
18
+ A page's file name isn't a reliable guide: the ``compress`` server sends JPEG for
19
+ ``.png`` names (see docs/api-notes.md). Unrecognized data gets ``"jpg"``, what the
20
+ servers send nearly always.
21
+ """
22
+ if data[:4] == b"RIFF" and data[8:12] == b"WEBP":
23
+ return "webp"
24
+ for signature, extension in _SIGNATURES:
25
+ if data.startswith(signature):
26
+ return extension
27
+ return "jpg"
28
+
29
+
30
+ def chapter_label(chapter: Chapter) -> str:
31
+ """``"Том 1 Глава 5"`` plus the chapter's name, as the site shows a chapter."""
32
+ label = f"Том {chapter.volume} Глава {chapter.number}"
33
+ return f"{label} — {chapter.name}" if chapter.name else label
@@ -0,0 +1,27 @@
1
+ """Writing an export file atomically (not public API)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import contextlib
6
+ import os
7
+ import tempfile
8
+ from collections.abc import Iterator
9
+ from pathlib import Path
10
+
11
+
12
+ @contextlib.contextmanager
13
+ def atomic_output(path: Path) -> Iterator[Path]:
14
+ """A temporary file next to ``path`` that replaces it only if the block succeeds.
15
+
16
+ An export downloads every page as it goes, so it can fail halfway (a page no server
17
+ serves, a closed chapter); without this, that would leave a truncated file behind, or
18
+ clobber a good one from an earlier run.
19
+ """
20
+ descriptor, name = tempfile.mkstemp(dir=path.parent, prefix=f".{path.name}.", suffix=".part")
21
+ os.close(descriptor)
22
+ temporary = Path(name)
23
+ try:
24
+ yield temporary
25
+ os.replace(temporary, path)
26
+ finally:
27
+ temporary.unlink(missing_ok=True)
@@ -0,0 +1,143 @@
1
+ """CBZ exporter: a zip of the page images in reading order, with ``ComicInfo.xml``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import xml.etree.ElementTree as ET
6
+ import zipfile
7
+ from collections.abc import Callable
8
+ from pathlib import Path
9
+ from typing import ClassVar
10
+
11
+ from mangalib._http import SITE_ORIGIN
12
+ from mangalib.exporters import FetchPages, register
13
+ from mangalib.exporters._shared import chapter_label, image_extension
14
+ from mangalib.exporters._writing import atomic_output
15
+ from mangalib.models import Chapter, Title
16
+
17
+ _RIGHT_TO_LEFT_TYPES = frozenset({"Манга"})
18
+ """Title types (``Title.country``) read right to left — ``ComicInfo``'s
19
+ ``Manga=YesAndRightToLeft``. Manhwa, manhua and the rest are read left to right, and the
20
+ field is left out for them rather than guessed further."""
21
+
22
+
23
+ @register
24
+ class CbzExporter:
25
+ """Writes chapters as one CBZ: every page image in reading order, plus ``ComicInfo.xml``.
26
+
27
+ Pages are stored without recompression (they're JPEGs already), named by their position
28
+ in the book with zero padding (``0001.jpg``, ``0002.jpg``, ...) so every reader sorts
29
+ them right, with the extension taken from the image's bytes. Where each chapter starts is
30
+ recorded as a ``Bookmark`` on its first page in ``ComicInfo.xml`` (schema 2.1, as read by
31
+ Komga, Kavita, ComicRack and others), which also carries the title's metadata.
32
+ """
33
+
34
+ format: ClassVar[str] = "cbz"
35
+
36
+ async def export(
37
+ self,
38
+ title: Title,
39
+ chapters: list[Chapter],
40
+ output_path: Path,
41
+ *,
42
+ fetch_pages: FetchPages,
43
+ on_chapter: Callable[[], None] | None = None,
44
+ ) -> Path:
45
+ """Write ``chapters`` to ``output_path`` as a CBZ — see ``Exporter.export()``."""
46
+ total_pages = sum(len(chapter.pages) for chapter in chapters)
47
+ width = max(4, len(str(total_pages)))
48
+ entries: list[_PageEntry] = []
49
+ with (
50
+ atomic_output(output_path) as temporary,
51
+ zipfile.ZipFile(temporary, "w", zipfile.ZIP_STORED) as archive,
52
+ ):
53
+ for chapter in chapters:
54
+ images = await fetch_pages(chapter)
55
+ for position, (page, data) in enumerate(zip(chapter.pages, images, strict=True)):
56
+ index = len(entries)
57
+ archive.writestr(f"{index + 1:0{width}}.{image_extension(data)}", data)
58
+ entries.append(
59
+ _PageEntry(
60
+ size=len(data),
61
+ width=page.width,
62
+ height=page.height,
63
+ bookmark=chapter_label(chapter) if position == 0 else None,
64
+ )
65
+ )
66
+ if on_chapter is not None:
67
+ on_chapter()
68
+ archive.writestr("ComicInfo.xml", comic_info(title, chapters, entries))
69
+ return output_path
70
+
71
+
72
+ class _PageEntry:
73
+ """What ``ComicInfo.xml`` says about one page of the book."""
74
+
75
+ def __init__(
76
+ self, *, size: int, width: int | None, height: int | None, bookmark: str | None
77
+ ) -> None:
78
+ self.size = size
79
+ self.width = width
80
+ self.height = height
81
+ self.bookmark = bookmark
82
+
83
+
84
+ def comic_info(title: Title, chapters: list[Chapter], pages: list[_PageEntry]) -> bytes:
85
+ """``ComicInfo.xml`` for a book of ``chapters`` (not public API).
86
+
87
+ Elements are written in the schema's order, only when there's a value. ``Number`` (and
88
+ a chapter's name as ``Title``) is set for a single chapter, ``Volume`` when every chapter
89
+ is in the same numbered volume — a book spanning several has neither.
90
+ """
91
+ root = ET.Element(
92
+ "ComicInfo",
93
+ {
94
+ "xmlns:xsi": "http://www.w3.org/2001/XMLSchema-instance",
95
+ "xmlns:xsd": "http://www.w3.org/2001/XMLSchema",
96
+ },
97
+ )
98
+
99
+ def add(tag: str, value: object) -> None:
100
+ if value not in (None, "", []):
101
+ ET.SubElement(root, tag).text = str(value)
102
+
103
+ volumes = {chapter.volume for chapter in chapters}
104
+ single = chapters[0] if len(chapters) == 1 else None
105
+ volume = next(iter(volumes)) if len(volumes) == 1 else None
106
+ if single is not None:
107
+ book_title = single.name
108
+ elif volume is not None:
109
+ book_title = f"Том {volume}"
110
+ else:
111
+ book_title = None
112
+ teams = dict.fromkeys(team.name for chapter in chapters for team in chapter.teams)
113
+
114
+ add("Title", book_title)
115
+ add("Series", title.rus_name or title.name)
116
+ add("Number", single.number if single is not None else None)
117
+ add("Volume", volume if volume is not None and volume.isdigit() else None)
118
+ add("Summary", title.summary)
119
+ add("Year", title.release_date if (title.release_date or "").isdigit() else None)
120
+ add("Writer", ", ".join(person.name for person in title.authors))
121
+ add("Penciller", ", ".join(person.name for person in title.artists))
122
+ add("Translator", ", ".join(teams))
123
+ add("Genre", ", ".join(genre.name for genre in title.genres))
124
+ add("Tags", ", ".join(tag.name for tag in title.tags))
125
+ add("Web", f"{SITE_ORIGIN}/ru/manga/{title.slug_url}")
126
+ add("PageCount", len(pages))
127
+ add("LanguageISO", "ru")
128
+ if title.country is not None and title.country.name in _RIGHT_TO_LEFT_TYPES:
129
+ add("Manga", "YesAndRightToLeft")
130
+
131
+ pages_element = ET.SubElement(root, "Pages")
132
+ for index, page in enumerate(pages):
133
+ attributes = {"Image": str(index), "ImageSize": str(page.size)}
134
+ if page.width is not None and page.height is not None:
135
+ attributes["ImageWidth"] = str(page.width)
136
+ attributes["ImageHeight"] = str(page.height)
137
+ if page.bookmark is not None:
138
+ attributes["Bookmark"] = page.bookmark
139
+ ET.SubElement(pages_element, "Page", attributes)
140
+
141
+ ET.indent(root)
142
+ data: bytes = ET.tostring(root, encoding="utf-8", xml_declaration=True)
143
+ return data
@@ -0,0 +1,83 @@
1
+ """PDF exporter: one PDF page per page image, via img2pdf (``mangalib-python-sdk[pdf]``)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import tempfile
7
+ from collections.abc import Callable
8
+ from pathlib import Path
9
+ from typing import ClassVar
10
+
11
+ from mangalib.exporters import FetchPages, register
12
+ from mangalib.exporters._shared import chapter_label, image_extension
13
+ from mangalib.exporters._writing import atomic_output
14
+ from mangalib.models import Chapter, Title
15
+
16
+ try:
17
+ import img2pdf
18
+ import pikepdf
19
+ except ImportError:
20
+ img2pdf = None
21
+ pikepdf = None # type: ignore[assignment]
22
+ # The "pdf" extra isn't installed: "pdf" just isn't registered below, and
23
+ # MangaLib.export(fmt="pdf") raises the same ValueError as for any unknown format.
24
+
25
+
26
+ class PdfExporter:
27
+ """Writes chapters as one PDF: a page per page image, an outline entry per chapter.
28
+
29
+ JPEGs are embedded as they are, without recompression (img2pdf), so the file is about
30
+ the size of the images. Each PDF page is the image's own size; a tall webtoon strip
31
+ beyond the PDF page-size limit is scaled with ``/UserUnit`` rather than cut. The PDF is
32
+ assembled in memory, so for a whole long title CBZ is the lighter choice.
33
+ """
34
+
35
+ format: ClassVar[str] = "pdf"
36
+
37
+ async def export(
38
+ self,
39
+ title: Title,
40
+ chapters: list[Chapter],
41
+ output_path: Path,
42
+ *,
43
+ fetch_pages: FetchPages,
44
+ on_chapter: Callable[[], None] | None = None,
45
+ ) -> Path:
46
+ """Write ``chapters`` to ``output_path`` as a PDF — see ``Exporter.export()``."""
47
+ with tempfile.TemporaryDirectory(prefix="mangalib-pdf-") as directory:
48
+ files: list[Path] = []
49
+ outline: list[tuple[str, int]] = []
50
+ for chapter in chapters:
51
+ images = await fetch_pages(chapter)
52
+ if images:
53
+ outline.append((chapter_label(chapter), len(files)))
54
+ for data in images:
55
+ path = Path(directory) / f"{len(files):06}.{image_extension(data)}"
56
+ path.write_bytes(data)
57
+ files.append(path)
58
+ if on_chapter is not None:
59
+ on_chapter()
60
+ with atomic_output(output_path) as temporary:
61
+ await asyncio.to_thread(_write_pdf, title, files, outline, temporary)
62
+ return output_path
63
+
64
+
65
+ def _write_pdf(title: Title, files: list[Path], outline: list[tuple[str, int]], path: Path) -> None:
66
+ """Assemble the PDF (CPU-bound and synchronous, so it runs off the event loop)."""
67
+ with path.open("wb") as stream:
68
+ img2pdf.convert(
69
+ [str(file) for file in files],
70
+ outputstream=stream,
71
+ title=title.rus_name or title.name,
72
+ author=", ".join(person.name for person in title.authors) or None,
73
+ nodate=True,
74
+ )
75
+ with pikepdf.open(path, allow_overwriting_input=True) as pdf:
76
+ with pdf.open_outline() as document_outline:
77
+ for label, page_index in outline:
78
+ document_outline.root.append(pikepdf.OutlineItem(label, page_index))
79
+ pdf.save(path)
80
+
81
+
82
+ if img2pdf is not None:
83
+ register(PdfExporter)