image2ppt 0.1.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.
@@ -0,0 +1,27 @@
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *.egg-info/
5
+ .eggs/
6
+ build/
7
+ dist/
8
+ .venv/
9
+ venv/
10
+ .pytest_cache/
11
+ .ruff_cache/
12
+ .mypy_cache/
13
+
14
+ # Node / TypeScript
15
+ node_modules/
16
+ typescript/dist/
17
+ *.tsbuildinfo
18
+ npm-debug.log*
19
+
20
+ # Editors / OS
21
+ .DS_Store
22
+ .idea/
23
+ .vscode/
24
+
25
+ # Local artifacts
26
+ *.pptx
27
+ out.pptx
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 image2ppt
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,144 @@
1
+ Metadata-Version: 2.4
2
+ Name: image2ppt
3
+ Version: 0.1.0
4
+ Summary: Official Python client for the image2ppt API — convert images and PDFs into editable PowerPoint (.pptx).
5
+ Project-URL: Homepage, https://image2ppt.com
6
+ Project-URL: Documentation, https://image2ppt.com/docs/api
7
+ Project-URL: Repository, https://github.com/shrektan/image2ppt-sdk
8
+ Project-URL: Issues, https://github.com/shrektan/image2ppt-sdk/issues
9
+ Author: image2ppt
10
+ License-Expression: MIT
11
+ License-File: LICENSE
12
+ Keywords: api,image-to-pptx,image2ppt,ocr,pdf,powerpoint,pptx,presentation,sdk,slides
13
+ Classifier: Development Status :: 4 - Beta
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: License :: OSI Approved :: MIT License
16
+ Classifier: Operating System :: OS Independent
17
+ Classifier: Programming Language :: Python :: 3
18
+ Classifier: Programming Language :: Python :: 3.9
19
+ Classifier: Programming Language :: Python :: 3.10
20
+ Classifier: Programming Language :: Python :: 3.11
21
+ Classifier: Programming Language :: Python :: 3.12
22
+ Classifier: Programming Language :: Python :: 3.13
23
+ Classifier: Topic :: Multimedia :: Graphics :: Presentation
24
+ Classifier: Topic :: Office/Business :: Office Suites
25
+ Classifier: Typing :: Typed
26
+ Requires-Python: >=3.9
27
+ Requires-Dist: pillow>=9.0
28
+ Requires-Dist: requests>=2.25
29
+ Provides-Extra: dev
30
+ Requires-Dist: pytest>=7; extra == 'dev'
31
+ Description-Content-Type: text/markdown
32
+
33
+ # image2ppt — Python client
34
+
35
+ Official Python client for the [image2ppt](https://image2ppt.com) API. Turn a batch of images or PDF pages into one **editable** PowerPoint (`.pptx`).
36
+
37
+ ## Install
38
+
39
+ ```bash
40
+ pip install image2ppt
41
+ ```
42
+
43
+ Requires Python 3.9+. Depends on `requests` and `Pillow` (Pillow powers optional client-side image pre-compression — see below).
44
+
45
+ ## Get an API key
46
+
47
+ Sign in at [image2ppt.com](https://image2ppt.com), open **Developer / API** from the account menu, and create a key (looks like `i2p_live_xxxx`). It's shown in full **once** — save it. API access is available to accounts with credits.
48
+
49
+ > **Server-side only.** Keep your key on your backend. Never embed it in a browser, mobile app, or anything a user can inspect.
50
+
51
+ ## Quick start
52
+
53
+ One shot — submit, wait, download:
54
+
55
+ ```python
56
+ from image2ppt import Image2PPTClient
57
+
58
+ client = Image2PPTClient(api_key="i2p_live_your_key")
59
+
60
+ job = client.convert(
61
+ ["slide1.png", "slide2.png", "report.pdf"],
62
+ dest_path="out.pptx",
63
+ locale="zh-CN", # optional: "zh-CN" (default) or "en"
64
+ aspect_ratio="16:9", # optional: "auto" (default) / "16:9" / "4:3"
65
+ )
66
+ print("done — credits used:", job.credits_used, "refunded:", job.credits_refunded)
67
+ ```
68
+
69
+ Step by step, if you want to control polling:
70
+
71
+ ```python
72
+ job = client.submit(["slide1.png"], aspect_ratio="4:3")
73
+ print("job:", job.job_id, "credits reserved:", job.credits_reserved)
74
+
75
+ job = client.wait(job.job_id, poll_interval=5, timeout=1800)
76
+ client.download(job.job_id, "out.pptx")
77
+ ```
78
+
79
+ Check your balance:
80
+
81
+ ```python
82
+ info = client.account()
83
+ print(info["email"], "credits:", info["credits"])
84
+ ```
85
+
86
+ ## How it works
87
+
88
+ - **Async.** `submit` returns a job id immediately; conversion runs in the background. A single page typically takes ~2 minutes; 90% of jobs finish within 3.
89
+ - **One job = one PPTX.** All files in a submission are merged into a single deck, in upload order.
90
+ - **Billed per page.** 1 page = 1 credit, reserved at submit and settled on completion. If some pages fail but others succeed, the job still `completed`s with the good pages and the failed pages' credits are refunded (`credits_refunded`).
91
+ - **Limits.** Each file ≤ 35MB; total ≤ 50 pages per job (images count as 1, PDFs as their page count).
92
+ - **Client-side pre-compression.** Images are compressed to the server's spec before upload (≤2000px, ≤1MB, JPEG), so the server's own pass is a no-op and you send fewer bytes. PDFs are uploaded as-is and rendered server-side.
93
+
94
+ ## Rate limits
95
+
96
+ Per account (all keys share the budget): ≤ 10 concurrent jobs, ≤ 60 pages/minute submitted. Over the limit returns `429` with a `Retry-After` hint. `wait()` handles 429 backoff for you automatically. If you call `submit()` directly, catch `RateLimitedError` and honor `retry_after`:
97
+
98
+ ```python
99
+ import time
100
+ from image2ppt import RateLimitedError
101
+
102
+ while True:
103
+ try:
104
+ job = client.submit(paths)
105
+ break
106
+ except RateLimitedError as e:
107
+ time.sleep(e.retry_after or 5)
108
+ ```
109
+
110
+ ## Errors
111
+
112
+ Every exception subclasses `Image2PPTError` and carries `status_code`, `code`, and `message`. Branch on `code`, not `message`.
113
+
114
+ | Exception | HTTP | code |
115
+ |---|---|---|
116
+ | `AuthenticationError` | 401 / 403 | `INVALID_API_KEY`, `API_KEY_REQUIRED`, `ACCOUNT_DELETED` |
117
+ | `InvalidFileError` | 400 | `INVALID_FILE` |
118
+ | `TooManySlidesError` | 400 | `TOO_MANY_SLIDES` |
119
+ | `InsufficientCreditsError` | 402 | `INSUFFICIENT_CREDITS` |
120
+ | `RateLimitedError` | 429 | `RATE_LIMITED` (has `retry_after`) |
121
+ | `JobNotFoundError` | 404 | `JOB_NOT_FOUND` |
122
+ | `NotReadyError` | 409 | `NOT_READY` |
123
+ | `OutputExpiredError` | 410 | `OUTPUT_EXPIRED` |
124
+ | `JobFailedError` | — | job's `error.code` (raised by `wait()`; `e.job` is the snapshot) |
125
+ | `Image2PPTTimeoutError` | — | — (`wait()` exceeded its `timeout`; job may still be running) |
126
+
127
+ ```python
128
+ from image2ppt import Image2PPTError, JobFailedError
129
+
130
+ try:
131
+ job = client.convert(paths, "out.pptx")
132
+ except JobFailedError as e:
133
+ print("conversion failed:", e.code, e.message)
134
+ except Image2PPTError as e:
135
+ print("request error:", e.status_code, e.code, e.message)
136
+ ```
137
+
138
+ ## Full API reference
139
+
140
+ See [../docs/api.md](../docs/api.md) for the complete HTTP contract (endpoints, fields, error codes).
141
+
142
+ ## License
143
+
144
+ [MIT](./LICENSE)
@@ -0,0 +1,112 @@
1
+ # image2ppt — Python client
2
+
3
+ Official Python client for the [image2ppt](https://image2ppt.com) API. Turn a batch of images or PDF pages into one **editable** PowerPoint (`.pptx`).
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ pip install image2ppt
9
+ ```
10
+
11
+ Requires Python 3.9+. Depends on `requests` and `Pillow` (Pillow powers optional client-side image pre-compression — see below).
12
+
13
+ ## Get an API key
14
+
15
+ Sign in at [image2ppt.com](https://image2ppt.com), open **Developer / API** from the account menu, and create a key (looks like `i2p_live_xxxx`). It's shown in full **once** — save it. API access is available to accounts with credits.
16
+
17
+ > **Server-side only.** Keep your key on your backend. Never embed it in a browser, mobile app, or anything a user can inspect.
18
+
19
+ ## Quick start
20
+
21
+ One shot — submit, wait, download:
22
+
23
+ ```python
24
+ from image2ppt import Image2PPTClient
25
+
26
+ client = Image2PPTClient(api_key="i2p_live_your_key")
27
+
28
+ job = client.convert(
29
+ ["slide1.png", "slide2.png", "report.pdf"],
30
+ dest_path="out.pptx",
31
+ locale="zh-CN", # optional: "zh-CN" (default) or "en"
32
+ aspect_ratio="16:9", # optional: "auto" (default) / "16:9" / "4:3"
33
+ )
34
+ print("done — credits used:", job.credits_used, "refunded:", job.credits_refunded)
35
+ ```
36
+
37
+ Step by step, if you want to control polling:
38
+
39
+ ```python
40
+ job = client.submit(["slide1.png"], aspect_ratio="4:3")
41
+ print("job:", job.job_id, "credits reserved:", job.credits_reserved)
42
+
43
+ job = client.wait(job.job_id, poll_interval=5, timeout=1800)
44
+ client.download(job.job_id, "out.pptx")
45
+ ```
46
+
47
+ Check your balance:
48
+
49
+ ```python
50
+ info = client.account()
51
+ print(info["email"], "credits:", info["credits"])
52
+ ```
53
+
54
+ ## How it works
55
+
56
+ - **Async.** `submit` returns a job id immediately; conversion runs in the background. A single page typically takes ~2 minutes; 90% of jobs finish within 3.
57
+ - **One job = one PPTX.** All files in a submission are merged into a single deck, in upload order.
58
+ - **Billed per page.** 1 page = 1 credit, reserved at submit and settled on completion. If some pages fail but others succeed, the job still `completed`s with the good pages and the failed pages' credits are refunded (`credits_refunded`).
59
+ - **Limits.** Each file ≤ 35MB; total ≤ 50 pages per job (images count as 1, PDFs as their page count).
60
+ - **Client-side pre-compression.** Images are compressed to the server's spec before upload (≤2000px, ≤1MB, JPEG), so the server's own pass is a no-op and you send fewer bytes. PDFs are uploaded as-is and rendered server-side.
61
+
62
+ ## Rate limits
63
+
64
+ Per account (all keys share the budget): ≤ 10 concurrent jobs, ≤ 60 pages/minute submitted. Over the limit returns `429` with a `Retry-After` hint. `wait()` handles 429 backoff for you automatically. If you call `submit()` directly, catch `RateLimitedError` and honor `retry_after`:
65
+
66
+ ```python
67
+ import time
68
+ from image2ppt import RateLimitedError
69
+
70
+ while True:
71
+ try:
72
+ job = client.submit(paths)
73
+ break
74
+ except RateLimitedError as e:
75
+ time.sleep(e.retry_after or 5)
76
+ ```
77
+
78
+ ## Errors
79
+
80
+ Every exception subclasses `Image2PPTError` and carries `status_code`, `code`, and `message`. Branch on `code`, not `message`.
81
+
82
+ | Exception | HTTP | code |
83
+ |---|---|---|
84
+ | `AuthenticationError` | 401 / 403 | `INVALID_API_KEY`, `API_KEY_REQUIRED`, `ACCOUNT_DELETED` |
85
+ | `InvalidFileError` | 400 | `INVALID_FILE` |
86
+ | `TooManySlidesError` | 400 | `TOO_MANY_SLIDES` |
87
+ | `InsufficientCreditsError` | 402 | `INSUFFICIENT_CREDITS` |
88
+ | `RateLimitedError` | 429 | `RATE_LIMITED` (has `retry_after`) |
89
+ | `JobNotFoundError` | 404 | `JOB_NOT_FOUND` |
90
+ | `NotReadyError` | 409 | `NOT_READY` |
91
+ | `OutputExpiredError` | 410 | `OUTPUT_EXPIRED` |
92
+ | `JobFailedError` | — | job's `error.code` (raised by `wait()`; `e.job` is the snapshot) |
93
+ | `Image2PPTTimeoutError` | — | — (`wait()` exceeded its `timeout`; job may still be running) |
94
+
95
+ ```python
96
+ from image2ppt import Image2PPTError, JobFailedError
97
+
98
+ try:
99
+ job = client.convert(paths, "out.pptx")
100
+ except JobFailedError as e:
101
+ print("conversion failed:", e.code, e.message)
102
+ except Image2PPTError as e:
103
+ print("request error:", e.status_code, e.code, e.message)
104
+ ```
105
+
106
+ ## Full API reference
107
+
108
+ See [../docs/api.md](../docs/api.md) for the complete HTTP contract (endpoints, fields, error codes).
109
+
110
+ ## License
111
+
112
+ [MIT](./LICENSE)
@@ -0,0 +1,62 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "image2ppt"
7
+ version = "0.1.0"
8
+ description = "Official Python client for the image2ppt API — convert images and PDFs into editable PowerPoint (.pptx)."
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ license = "MIT"
12
+ license-files = ["LICENSE"]
13
+ authors = [{ name = "image2ppt" }]
14
+ keywords = [
15
+ "image2ppt",
16
+ "pptx",
17
+ "powerpoint",
18
+ "presentation",
19
+ "slides",
20
+ "pdf",
21
+ "ocr",
22
+ "image-to-pptx",
23
+ "api",
24
+ "sdk",
25
+ ]
26
+ classifiers = [
27
+ "Development Status :: 4 - Beta",
28
+ "Intended Audience :: Developers",
29
+ "License :: OSI Approved :: MIT License",
30
+ "Operating System :: OS Independent",
31
+ "Programming Language :: Python :: 3",
32
+ "Programming Language :: Python :: 3.9",
33
+ "Programming Language :: Python :: 3.10",
34
+ "Programming Language :: Python :: 3.11",
35
+ "Programming Language :: Python :: 3.12",
36
+ "Programming Language :: Python :: 3.13",
37
+ "Topic :: Office/Business :: Office Suites",
38
+ "Topic :: Multimedia :: Graphics :: Presentation",
39
+ "Typing :: Typed",
40
+ ]
41
+ dependencies = [
42
+ "requests>=2.25",
43
+ "Pillow>=9.0",
44
+ ]
45
+
46
+ [project.urls]
47
+ Homepage = "https://image2ppt.com"
48
+ Documentation = "https://image2ppt.com/docs/api"
49
+ Repository = "https://github.com/shrektan/image2ppt-sdk"
50
+ Issues = "https://github.com/shrektan/image2ppt-sdk/issues"
51
+
52
+ [project.optional-dependencies]
53
+ dev = ["pytest>=7"]
54
+
55
+ [tool.hatch.build.targets.wheel]
56
+ packages = ["src/image2ppt"]
57
+
58
+ [tool.hatch.build.targets.sdist]
59
+ include = ["src", "README.md", "LICENSE"]
60
+
61
+ [tool.pytest.ini_options]
62
+ testpaths = ["tests"]
@@ -0,0 +1,50 @@
1
+ """Official Python client for the image2ppt API.
2
+
3
+ Convert images and PDFs into editable PowerPoint (.pptx) decks.
4
+
5
+ from image2ppt import Image2PPTClient
6
+
7
+ client = Image2PPTClient(api_key="i2p_live_...")
8
+ job = client.convert(["slide1.png", "report.pdf"], dest_path="out.pptx")
9
+ print("credits used:", job.credits_used)
10
+
11
+ See https://github.com/shrektan/image2ppt-sdk for docs and examples.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from .client import DEFAULT_BASE_URL, Image2PPTClient
17
+ from .errors import (
18
+ AuthenticationError,
19
+ Image2PPTError,
20
+ Image2PPTTimeoutError,
21
+ InsufficientCreditsError,
22
+ InvalidFileError,
23
+ JobFailedError,
24
+ JobNotFoundError,
25
+ NotReadyError,
26
+ OutputExpiredError,
27
+ RateLimitedError,
28
+ TooManySlidesError,
29
+ )
30
+ from .models import Job
31
+
32
+ __version__ = "0.1.0"
33
+
34
+ __all__ = [
35
+ "Image2PPTClient",
36
+ "Job",
37
+ "DEFAULT_BASE_URL",
38
+ "Image2PPTError",
39
+ "AuthenticationError",
40
+ "InvalidFileError",
41
+ "TooManySlidesError",
42
+ "InsufficientCreditsError",
43
+ "RateLimitedError",
44
+ "JobNotFoundError",
45
+ "NotReadyError",
46
+ "OutputExpiredError",
47
+ "JobFailedError",
48
+ "Image2PPTTimeoutError",
49
+ "__version__",
50
+ ]
@@ -0,0 +1,72 @@
1
+ """Client-side image compression, matched to the server's upload pipeline.
2
+
3
+ The server runs the same compression on every upload (``compressImageForUpload``).
4
+ By pre-compressing to the same spec, the server's pass becomes a passthrough — one
5
+ less redundant compute, fewer bytes on the wire. These constants must stay in sync
6
+ with the server; changing one means changing both.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import io
12
+
13
+ from PIL import Image
14
+
15
+ _UPLOAD_TARGET_BYTES = 1024 * 1024
16
+ _UPLOAD_MAX_DIM = 2000
17
+ _UPLOAD_QUALITY_LADDER = (90, 85, 80)
18
+ # Only PNG / JPEG pass through as-is; WebP / GIF are transcoded to JPEG even when
19
+ # small (matching the server, which transcodes anything that isn't PNG/JPEG first).
20
+ _PASSTHROUGH_MIMES = frozenset({"image/png", "image/jpeg"})
21
+ IMAGE_MIMES = frozenset({"image/png", "image/jpeg", "image/webp", "image/gif"})
22
+
23
+
24
+ def compress_image_for_upload(raw: bytes, mime: str) -> "tuple[bytes, str]":
25
+ """Compress an image to the server's spec; return ``(bytes, mime)``.
26
+
27
+ Rules mirror the server's ``compressImageForUpload``:
28
+ - PNG/JPEG with longest edge <= 2000px and <= 1MB -> returned as-is (passthrough).
29
+ - Otherwise: fit inside 2000x2000 (shrink only), flatten transparency onto
30
+ white, JPEG at quality 90 -> 85 -> 80 until <= 1MB or the ladder bottoms out.
31
+ - Fallback: if compression somehow yields a larger file (already-low-quality
32
+ sources do this) -> return the original, never "blurrier AND bigger".
33
+
34
+ Only images go through here; PDFs are uploaded as-is and rendered server-side.
35
+ """
36
+ with Image.open(io.BytesIO(raw)) as img:
37
+ img.load() # animated GIF / WebP: first frame only (Pillow default)
38
+ width, height = img.size
39
+ within_budget = (
40
+ len(raw) <= _UPLOAD_TARGET_BYTES and max(width, height) <= _UPLOAD_MAX_DIM
41
+ )
42
+ if within_budget and mime in _PASSTHROUGH_MIMES:
43
+ return raw, mime
44
+
45
+ scaled = img.copy()
46
+ # thumbnail = fit inside, no enlargement (server's fit:inside + withoutEnlargement).
47
+ if max(width, height) > _UPLOAD_MAX_DIM:
48
+ scaled.thumbnail((_UPLOAD_MAX_DIM, _UPLOAD_MAX_DIM), Image.LANCZOS)
49
+
50
+ # Flatten onto white, dropping alpha (server's .flatten({background:'#ffffff'})).
51
+ has_alpha = scaled.mode in ("RGBA", "LA") or (
52
+ scaled.mode == "P" and "transparency" in scaled.info
53
+ )
54
+ if has_alpha:
55
+ rgba = scaled.convert("RGBA")
56
+ flattened = Image.new("RGB", rgba.size, (255, 255, 255))
57
+ flattened.paste(rgba, mask=rgba.split()[-1])
58
+ scaled = flattened
59
+ else:
60
+ scaled = scaled.convert("RGB")
61
+
62
+ compressed = None
63
+ for quality in _UPLOAD_QUALITY_LADDER:
64
+ buffer = io.BytesIO()
65
+ scaled.save(buffer, format="JPEG", quality=quality)
66
+ compressed = buffer.getvalue()
67
+ if len(compressed) <= _UPLOAD_TARGET_BYTES:
68
+ break
69
+
70
+ if compressed is None or len(compressed) >= len(raw):
71
+ return raw, mime
72
+ return compressed, "image/jpeg"
@@ -0,0 +1,280 @@
1
+ """The image2ppt API client."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import mimetypes
6
+ import os
7
+ import time
8
+ from typing import Any, Dict, Optional, Sequence
9
+
10
+ import requests
11
+
12
+ from ._compress import IMAGE_MIMES, compress_image_for_upload
13
+ from .errors import (
14
+ Image2PPTError,
15
+ Image2PPTTimeoutError,
16
+ JobFailedError,
17
+ RateLimitedError,
18
+ exception_for,
19
+ )
20
+ from .models import Job
21
+
22
+ DEFAULT_BASE_URL = "https://image2ppt.com"
23
+
24
+
25
+ class Image2PPTClient:
26
+ """Client for the image2ppt API.
27
+
28
+ Args:
29
+ api_key: Your API key (looks like ``i2p_live_...``), created on the
30
+ Developer / API page.
31
+ base_url: Service base URL, defaults to ``https://image2ppt.com``.
32
+ timeout: Per-HTTP-request timeout in seconds (not the whole-job wait).
33
+ session: Optional ``requests.Session`` to inject (for testing or pooling).
34
+ """
35
+
36
+ #: Supported input extensions -> MIME type (for labeling multipart uploads).
37
+ _MIME_BY_EXT = {
38
+ ".png": "image/png",
39
+ ".jpg": "image/jpeg",
40
+ ".jpeg": "image/jpeg",
41
+ ".webp": "image/webp",
42
+ ".gif": "image/gif",
43
+ ".pdf": "application/pdf",
44
+ }
45
+
46
+ def __init__(
47
+ self,
48
+ api_key: str,
49
+ base_url: str = DEFAULT_BASE_URL,
50
+ *,
51
+ timeout: float = 60.0,
52
+ session: Optional[requests.Session] = None,
53
+ ) -> None:
54
+ if not api_key:
55
+ raise ValueError("api_key must not be empty")
56
+ self.base_url = base_url.rstrip("/")
57
+ self.timeout = timeout
58
+ self._session = session or requests.Session()
59
+ self._session.headers.update({"Authorization": f"Bearer {api_key}"})
60
+
61
+ # ----- public methods ---------------------------------------------- #
62
+ def submit(
63
+ self,
64
+ paths: Sequence[str],
65
+ *,
66
+ locale: Optional[str] = None,
67
+ aspect_ratio: Optional[str] = None,
68
+ ) -> Job:
69
+ """Submit a batch of files and create a conversion job.
70
+
71
+ Args:
72
+ paths: Local file paths (one or more). Supports png/jpeg/webp/gif/pdf,
73
+ each file <= 35MB. An image is 1 page, a PDF is its page count;
74
+ the total must be <= 50 pages.
75
+ locale: ``zh-CN`` (default) or ``en``.
76
+ aspect_ratio: ``auto`` (default) / ``16:9`` / ``4:3``.
77
+
78
+ Returns:
79
+ A ``Job`` with ``status`` ``pending``, plus ``slide_count`` and
80
+ ``credits_reserved`` (credits locked at submit time).
81
+
82
+ Raises:
83
+ AuthenticationError, InvalidFileError, TooManySlidesError,
84
+ InsufficientCreditsError, RateLimitedError.
85
+ """
86
+ paths = list(paths)
87
+ if not paths:
88
+ raise ValueError("at least one file is required")
89
+
90
+ data: Dict[str, str] = {}
91
+ if locale is not None:
92
+ data["locale"] = locale
93
+ if aspect_ratio is not None:
94
+ data["aspectRatio"] = aspect_ratio
95
+
96
+ opened = []
97
+ multipart = []
98
+ try:
99
+ for path in paths:
100
+ filename = os.path.basename(path)
101
+ mime = self._guess_mime(filename)
102
+ if mime in IMAGE_MIMES:
103
+ # Images: pre-compress to the server spec so its pass is a passthrough.
104
+ with open(path, "rb") as fh:
105
+ raw = fh.read()
106
+ payload, out_mime = compress_image_for_upload(raw, mime)
107
+ if out_mime == "image/jpeg" and not filename.lower().endswith(
108
+ (".jpg", ".jpeg")
109
+ ):
110
+ # Compressed to JPEG: align the extension so name matches content.
111
+ filename = os.path.splitext(filename)[0] + ".jpg"
112
+ multipart.append(("files", (filename, payload, out_mime)))
113
+ else:
114
+ # PDFs and other non-images: upload as-is (streamed), render server-side.
115
+ handle = open(path, "rb")
116
+ opened.append(handle)
117
+ multipart.append(("files", (filename, handle, mime)))
118
+ resp = self._session.post(
119
+ f"{self.base_url}/api/v1/jobs",
120
+ files=multipart,
121
+ data=data,
122
+ timeout=self.timeout,
123
+ )
124
+ finally:
125
+ for handle in opened:
126
+ handle.close()
127
+
128
+ return Job.from_dict(self._parse_json(resp))
129
+
130
+ def get_job(self, job_id: str) -> Job:
131
+ """Fetch the current job state as a ``Job`` snapshot. Raises JobNotFoundError."""
132
+ resp = self._session.get(
133
+ f"{self.base_url}/api/v1/jobs/{job_id}",
134
+ timeout=self.timeout,
135
+ )
136
+ return Job.from_dict(self._parse_json(resp))
137
+
138
+ def wait(
139
+ self,
140
+ job_id: str,
141
+ *,
142
+ poll_interval: float = 5.0,
143
+ timeout: float = 1800.0,
144
+ ) -> Job:
145
+ """Poll until the job reaches a terminal state; return the completed ``Job``.
146
+
147
+ The poll interval starts at ``poll_interval`` and backs off to 15s max. On a
148
+ 429 it waits the ``Retry-After`` seconds before continuing. A failed job
149
+ raises JobFailedError; exceeding ``timeout`` raises Image2PPTTimeoutError
150
+ (the job itself may still be running).
151
+
152
+ Args:
153
+ job_id: The job id.
154
+ poll_interval: Initial poll interval in seconds (default 5).
155
+ timeout: Overall wait cap in seconds (default 1800 = 30 min).
156
+ """
157
+ deadline = time.monotonic() + timeout
158
+ interval = poll_interval
159
+ while True:
160
+ try:
161
+ job = self.get_job(job_id)
162
+ except RateLimitedError as exc:
163
+ sleep_for = exc.retry_after if exc.retry_after is not None else interval
164
+ self._sleep_until(deadline, sleep_for, job_id)
165
+ continue
166
+
167
+ if job.is_completed:
168
+ return job
169
+ if job.is_failed:
170
+ err = job.error or {}
171
+ raise JobFailedError(
172
+ err.get("message") or "conversion failed",
173
+ code=err.get("code"),
174
+ job=job,
175
+ )
176
+
177
+ self._sleep_until(deadline, interval, job_id)
178
+ interval = min(interval * 1.5, 15.0)
179
+
180
+ def download(self, job_id: str, dest_path: str) -> str:
181
+ """Stream a completed job's PPTX to ``dest_path``; return that path.
182
+
183
+ Raises NotReadyError (409) if the job isn't done, JobNotFoundError (404) if
184
+ it doesn't exist, OutputExpiredError (410) if the deliverable was reaped.
185
+ """
186
+ resp = self._session.get(
187
+ f"{self.base_url}/api/v1/jobs/{job_id}/download",
188
+ stream=True,
189
+ timeout=self.timeout,
190
+ )
191
+ try:
192
+ if not resp.ok:
193
+ self._raise_for_error(resp)
194
+ with open(dest_path, "wb") as out:
195
+ for chunk in resp.iter_content(chunk_size=65536):
196
+ if chunk:
197
+ out.write(chunk)
198
+ finally:
199
+ resp.close()
200
+ return dest_path
201
+
202
+ def convert(
203
+ self,
204
+ paths: Sequence[str],
205
+ dest_path: str,
206
+ *,
207
+ locale: Optional[str] = None,
208
+ aspect_ratio: Optional[str] = None,
209
+ poll_interval: float = 5.0,
210
+ timeout: float = 1800.0,
211
+ ) -> Job:
212
+ """One-shot: submit -> wait for completion -> download to ``dest_path``.
213
+
214
+ Arguments mirror ``submit`` and ``wait``. For the synchronous
215
+ "give me a batch of images, hand me back a PPTX" case.
216
+ """
217
+ job = self.submit(paths, locale=locale, aspect_ratio=aspect_ratio)
218
+ completed = self.wait(job.job_id, poll_interval=poll_interval, timeout=timeout)
219
+ self.download(completed.job_id, dest_path)
220
+ return completed
221
+
222
+ def account(self) -> Dict[str, Any]:
223
+ """Return account info: ``{"email": ..., "credits": available_credits}``."""
224
+ resp = self._session.get(
225
+ f"{self.base_url}/api/v1/account",
226
+ timeout=self.timeout,
227
+ )
228
+ return self._parse_json(resp)
229
+
230
+ # ----- internal helpers -------------------------------------------- #
231
+ def _guess_mime(self, filename: str) -> str:
232
+ ext = os.path.splitext(filename)[1].lower()
233
+ if ext in self._MIME_BY_EXT:
234
+ return self._MIME_BY_EXT[ext]
235
+ guessed, _ = mimetypes.guess_type(filename)
236
+ return guessed or "application/octet-stream"
237
+
238
+ def _sleep_until(self, deadline: float, seconds: float, job_id: str) -> None:
239
+ """Sleep ``seconds``, but never past ``deadline``; raise TimeoutError if past."""
240
+ remaining = deadline - time.monotonic()
241
+ if remaining <= 0:
242
+ raise Image2PPTTimeoutError(f"timed out waiting for job {job_id}", job_id=job_id)
243
+ time.sleep(min(seconds, remaining))
244
+
245
+ def _parse_json(self, resp: requests.Response) -> Dict[str, Any]:
246
+ """Return the JSON body on 2xx; otherwise raise the mapped exception."""
247
+ if not resp.ok:
248
+ self._raise_for_error(resp)
249
+ return resp.json()
250
+
251
+ def _raise_for_error(self, resp: requests.Response) -> None:
252
+ """Parse the ``{"error": {code, message}}`` envelope and raise the mapped error."""
253
+ code: Optional[str] = None
254
+ message: Optional[str] = None
255
+ try:
256
+ body = resp.json()
257
+ err = body.get("error") if isinstance(body, dict) else None
258
+ if isinstance(err, dict):
259
+ code = err.get("code")
260
+ message = err.get("message")
261
+ except ValueError:
262
+ pass # non-JSON error body (e.g. a gateway HTML page): fall back to status text
263
+ message = message or f"request failed (HTTP {resp.status_code})"
264
+
265
+ raise exception_for(
266
+ status_code=resp.status_code,
267
+ code=code,
268
+ message=message,
269
+ retry_after=self._parse_retry_after(resp.headers.get("Retry-After")),
270
+ )
271
+
272
+ @staticmethod
273
+ def _parse_retry_after(value: Optional[str]) -> Optional[float]:
274
+ """Parse the Retry-After header as seconds (contract: integer seconds)."""
275
+ if not value:
276
+ return None
277
+ try:
278
+ return float(value)
279
+ except (TypeError, ValueError):
280
+ return None
@@ -0,0 +1,155 @@
1
+ """Exception hierarchy for the image2ppt client.
2
+
3
+ Every error carries the HTTP ``status_code``, the server error ``code`` (from the
4
+ ``{"error": {"code", "message"}}`` envelope), and a human-readable ``message``.
5
+ Branch on ``code``, not ``message`` — messages may be reworded.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import Any, Dict, Optional
11
+
12
+
13
+ class Image2PPTError(Exception):
14
+ """Base class for all client errors."""
15
+
16
+ def __init__(
17
+ self,
18
+ message: str,
19
+ *,
20
+ status_code: Optional[int] = None,
21
+ code: Optional[str] = None,
22
+ ) -> None:
23
+ super().__init__(message)
24
+ self.message = message
25
+ self.status_code = status_code
26
+ self.code = code
27
+
28
+ def __str__(self) -> str:
29
+ parts = []
30
+ if self.status_code is not None:
31
+ parts.append(f"HTTP {self.status_code}")
32
+ if self.code:
33
+ parts.append(self.code)
34
+ prefix = " ".join(parts)
35
+ return f"[{prefix}] {self.message}" if prefix else self.message
36
+
37
+
38
+ class AuthenticationError(Image2PPTError):
39
+ """API key is missing, invalid, or the account is gone (401 / 403)."""
40
+
41
+
42
+ class InvalidFileError(Image2PPTError):
43
+ """A file was rejected: unsupported format or over the 35MB per-file limit (400)."""
44
+
45
+
46
+ class TooManySlidesError(Image2PPTError):
47
+ """The submission exceeds the 50-page-per-job limit (400 TOO_MANY_SLIDES)."""
48
+
49
+
50
+ class InsufficientCreditsError(Image2PPTError):
51
+ """Not enough available credits to cover the submission (402)."""
52
+
53
+
54
+ class RateLimitedError(Image2PPTError):
55
+ """Rate limited (429 RATE_LIMITED).
56
+
57
+ ``retry_after`` is the server-suggested wait in seconds (from the
58
+ ``Retry-After`` header); retry after that long.
59
+ """
60
+
61
+ def __init__(
62
+ self,
63
+ message: str,
64
+ *,
65
+ status_code: Optional[int] = None,
66
+ code: Optional[str] = None,
67
+ retry_after: Optional[float] = None,
68
+ ) -> None:
69
+ super().__init__(message, status_code=status_code, code=code)
70
+ self.retry_after = retry_after
71
+
72
+
73
+ class JobNotFoundError(Image2PPTError):
74
+ """The job id doesn't exist, or isn't owned by this key's account (404)."""
75
+
76
+
77
+ class NotReadyError(Image2PPTError):
78
+ """The job hasn't finished yet, so the deliverable can't be downloaded (409)."""
79
+
80
+
81
+ class OutputExpiredError(Image2PPTError):
82
+ """The job finished, but its PPTX passed the retention window and was reaped (410)."""
83
+
84
+
85
+ class JobFailedError(Image2PPTError):
86
+ """The job ended in failure (raised by ``wait`` when it polls status=failed).
87
+
88
+ ``job`` is the failure snapshot; ``code`` / ``message`` come from its ``error`` field.
89
+ """
90
+
91
+ def __init__(
92
+ self,
93
+ message: str,
94
+ *,
95
+ code: Optional[str] = None,
96
+ job: Optional[Any] = None,
97
+ ) -> None:
98
+ super().__init__(message, code=code)
99
+ self.job = job
100
+
101
+
102
+ class Image2PPTTimeoutError(Image2PPTError):
103
+ """``wait`` exceeded its ``timeout`` before the job reached a terminal state.
104
+
105
+ This does not mean the job failed — it may still be running. Re-``wait`` on the
106
+ ``job_id`` later. (The prefix avoids shadowing the builtin ``TimeoutError``.)
107
+ """
108
+
109
+ def __init__(self, message: str, *, job_id: Optional[str] = None) -> None:
110
+ super().__init__(message)
111
+ self.job_id = job_id
112
+
113
+
114
+ # Server error code -> exception class. Unlisted codes fall back to the status-code
115
+ # map, then to the base class.
116
+ _CODE_TO_EXC: Dict[str, type] = {
117
+ "INVALID_API_KEY": AuthenticationError,
118
+ "API_KEY_REQUIRED": AuthenticationError,
119
+ "ACCOUNT_DELETED": AuthenticationError,
120
+ "INVALID_FILE": InvalidFileError,
121
+ "INVALID_PDF": InvalidFileError,
122
+ "TOO_MANY_SLIDES": TooManySlidesError,
123
+ "INSUFFICIENT_CREDITS": InsufficientCreditsError,
124
+ "RATE_LIMITED": RateLimitedError,
125
+ "JOB_NOT_FOUND": JobNotFoundError,
126
+ "NOT_READY": NotReadyError,
127
+ "OUTPUT_EXPIRED": OutputExpiredError,
128
+ }
129
+ _STATUS_TO_EXC: Dict[int, type] = {
130
+ 401: AuthenticationError,
131
+ 402: InsufficientCreditsError,
132
+ 404: JobNotFoundError,
133
+ 409: NotReadyError,
134
+ 410: OutputExpiredError,
135
+ 429: RateLimitedError,
136
+ }
137
+
138
+
139
+ def exception_for(
140
+ *,
141
+ status_code: int,
142
+ code: Optional[str],
143
+ message: str,
144
+ retry_after: Optional[float] = None,
145
+ ) -> Image2PPTError:
146
+ """Build the mapped exception for an error envelope."""
147
+ if status_code == 429:
148
+ return RateLimitedError(
149
+ message,
150
+ status_code=429,
151
+ code=code or "RATE_LIMITED",
152
+ retry_after=retry_after,
153
+ )
154
+ exc_cls = _CODE_TO_EXC.get(code or "") or _STATUS_TO_EXC.get(status_code, Image2PPTError)
155
+ return exc_cls(message, status_code=status_code, code=code)
@@ -0,0 +1,62 @@
1
+ """Data models returned by the client."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from typing import Any, Dict, Optional
7
+
8
+
9
+ @dataclass
10
+ class Job:
11
+ """A snapshot of a conversion job's state.
12
+
13
+ Which fields are populated depends on the source: a ``submit`` response only
14
+ carries ``credits_reserved``; a ``get_job`` response carries
15
+ ``credits_used`` / ``credits_refunded`` / ``download_url`` and friends.
16
+ """
17
+
18
+ job_id: str
19
+ status: str # pending | processing | completed | failed
20
+ slide_count: Optional[int] = None
21
+ progress: Optional[int] = None # 0-100
22
+ credits_reserved: Optional[int] = None # submit response: credits locked
23
+ credits_used: Optional[int] = None # settled: credits actually charged
24
+ credits_refunded: Optional[int] = None # partial success: refunded failed pages
25
+ created_at: Optional[str] = None
26
+ completed_at: Optional[str] = None
27
+ download_url: Optional[str] = None # completed only; relative path
28
+ error: Optional[Dict[str, Any]] = None # failed only; {code, message}
29
+ raw: Optional[Dict[str, Any]] = None # raw response body, for forward-compat fields
30
+
31
+ @property
32
+ def is_completed(self) -> bool:
33
+ """Whether the job finished successfully (deliverable downloadable)."""
34
+ return self.status == "completed"
35
+
36
+ @property
37
+ def is_failed(self) -> bool:
38
+ """Whether the job failed."""
39
+ return self.status == "failed"
40
+
41
+ @property
42
+ def is_terminal(self) -> bool:
43
+ """Whether the job reached a terminal state (completed or failed)."""
44
+ return self.status in ("completed", "failed")
45
+
46
+ @classmethod
47
+ def from_dict(cls, data: Dict[str, Any]) -> "Job":
48
+ """Build a Job from server JSON; handles both submit and status shapes."""
49
+ return cls(
50
+ job_id=data["jobId"],
51
+ status=data["status"],
52
+ slide_count=data.get("slideCount"),
53
+ progress=data.get("progress"),
54
+ credits_reserved=data.get("creditsReserved"),
55
+ credits_used=data.get("creditsUsed"),
56
+ credits_refunded=data.get("creditsRefunded"),
57
+ created_at=data.get("createdAt"),
58
+ completed_at=data.get("completedAt"),
59
+ download_url=data.get("downloadUrl"),
60
+ error=data.get("error"),
61
+ raw=data,
62
+ )
File without changes