Skip to main content

image2ppt — Python client

Official Python client for the image2ppt API. Turn a batch of images or PDF pages into one editable PowerPoint (.pptx).

Install

pip install image2ppt

Requires Python 3.9+. Depends on requests and Pillow (Pillow powers optional client-side image pre-compression — see below).

Get an API key

Sign in at 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.

Server-side only. Keep your key on your backend. Never embed it in a browser, mobile app, or anything a user can inspect.

Quick start

One shot — submit, wait, download:

from image2ppt import Image2PPTClient, JobCancelledError

client = Image2PPTClient(api_key="i2p_live_your_key")

job = client.convert(
    ["slide1.png", "slide2.png", "report.pdf"],
    dest_path="out.pptx",
    locale="zh-CN",       # optional: "zh-CN" (default) or "en"
    aspect_ratio="16:9",  # optional: "auto" (default) / "16:9" / "4:3"
)
print("done — credits used:", job.credits_used, "refunded:", job.credits_refunded)

Step by step, if you want to control polling:

job = client.submit(["slide1.png"], aspect_ratio="4:3")
print("job:", job.job_id, "credits reserved:", job.credits_reserved)

job = client.wait(job.job_id, poll_interval=5, timeout=1800)
client.download(job.job_id, "out.pptx")

Cancel a job you no longer need:

result = client.cancel(job.job_id)
if result.finalizing:
    print("cancellation accepted; running pages are still winding down")

try:
    done = client.wait(job.job_id)
    # At least one page completed: the partial deck remains downloadable.
    client.download(done.job_id, "partial.pptx")
except JobCancelledError:
    # No page completed, so the reservation was refunded and there is no deck.
    pass

Cancellation is graceful: pages already running finish and are billed if successful; pages that have not started are skipped and refunded. A page being dispatched at the very moment the cancellation arrives may still run to completion and be billed — this is a drain, not a hard stop. The call is idempotent. A job with retained pages finishes as completed; without any deliverable it finishes as failed, and wait() raises JobCancelledError (a subclass of JobFailedError).

If the request comes too late — the job already finished, or it is past the point where cancelling could still change the outcome — you get JobAlreadyFinishedError instead. Fetch the job with get_job() and work with the result it already has.

Check your balance:

info = client.account()
print(info["email"], "credits:", info["credits"])

Which pages made it — job.page_results

Once a job is terminal, it reports what happened to every page, in page order, one entry per page. credits_refunded tells you how many pages did not convert; page_results tells you which ones, and what to do about them.

job = client.wait(job_id)

if job.page_results is None:
    print("this job reported no per-page ledger")
else:
    for page in job.page_results:
        if page.status == "converted":
            continue
        # ``error`` is None when the entry carried none this client could read.
        # Say so rather than guessing: neither "where is it" nor "is it worth
        # resubmitting" is knowable without it.
        if page.error is None:
            print(f"page {page.page_number} failed, with no reason given")
            continue
        if page.error.code == "PAGE_NOT_ATTEMPTED":
            print(f"page {page.page_number} is NOT in the deck at all")
        else:
            print(f"page {page.page_number} is in the deck as the original image")
        if page.error.retryable:
            print("  resubmitting this one is worth a try")

A failed page ends up one of two ways, and the difference is what you act on. PAGE_NOT_ATTEMPTED means the page never started and is not in the delivered deck at all — the deck is short by that page, and its credit was refunded. Every other failure code means the page is in the deck, as the original image rather than editable content.

The per-page error.code values the contract defines today are exactly CONVERSION_FAILED, CONVERSION_TIMEOUT, and PAGE_NOT_ATTEMPTED. Treat a code you do not recognise as CONVERSION_FAILED. Note this is a finer set than the job-level job.error["code"], which still has only its two long-standing values — the two levels differ deliberately, and the API reference explains why.

error.retryable says whether resubmitting the same image could succeed. Every code above carries True today — branch on the field anyway rather than hardcoding it, since a code added later may carry False.

None and [] are different facts. page_results is None when the job reported no ledger at all: it is still running (while it is, "this page failed" and "this page has not had its turn" are indistinguishable), or it is an early job with no per-page record. An empty list would mean a job with no pages. Check is not None before iterating.

What language error messages come back in

Error message text follows the request's Accept-Language header. This client sends none by default, so you get English. Set accept_language to change that:

client = Image2PPTClient(api_key="i2p_live_your_key", accept_language="zh-CN")

It is sent verbatim on every request, and it is a free-form HTTP header value — the full Accept-Language syntax works ("fr-CH, fr;q=0.9, en;q=0.8").

accept_language is not locale. locale is a per-submission option that decides what language the generated PPTX is written in. accept_language is a client-level option that decides what language error messages come back in. They are unrelated, they take different kinds of value, and setting one does nothing to the other — you can ask for a Chinese deck while reading English errors, or the reverse.

Whatever the language, code never changes with it. Keep branching on code.

How it works

  • 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.
  • One job = one PPTX. All files in a submission are merged into a single deck, in upload order.
  • Billed per page. 1 page = 1 credit, reserved at submit and settled on completion. If some pages fail but others succeed, the job still completeds with the good pages and the failed pages' credits are refunded (credits_refunded) — page_results says which pages those were.
  • Limits. Each file ≤ 35MB; the files in one request ≤ 45MB in total; ≤ 50 pages per job (images count as 1, PDFs as their page count). All three are checked locally before upload — note the per-file limit is the stricter one, so a 40MB PDF is refused even though it fits a request. The sizes counted are the ones that actually go on the wire: for an image that is its size after client-side compression, so a 40MB PNG that compresses to 1MB is fine. (The Node SDK compresses before upload the same way, so both clients reach the same verdict on the same file.)
  • The check is never stricter than the documented limit. 45MB of file content is meant to be usable, so a submission sitting exactly on it goes through. Auto-batching is the one place that is deliberately conservative — it fills a batch only to 40MB, because starting one more batch costs nothing while refusing something the server would have accepted does not.
  • Only the formats the API accepts. png, jpg/jpeg, webp, gif, pdf. Anything else raises InvalidFileError locally — the batch calls check every file before submitting the first one, so an unsupported file at the end of the pile cannot leave you paying for the batches ahead of it.
  • The local page check is a lower bound. The client does not parse PDFs, so it counts each one as at least 1 page. That is enough to refuse combinations that can never work (50 images plus any PDF is already 51 pages), but a submission that passes locally can still come back TOO_MANY_SLIDES — a 30-page PDF counts as 1 here and 30 on the server.
  • Going over the request limit is not a polite error. Past that the connection is cut before the API can answer, so the caller sees a write timeout or a broken pipe instead of a status code. The client therefore checks locally before uploading and raises InvalidFileError (code="PAYLOAD_TOO_LARGE") without sending a byte.
  • A failed submission is never retried automatically. A connection error only tells you the exchange broke — not whether the request body arrived. The job may not exist, or it may exist with credits already reserved and only the response lost. Retrying the second case charges you twice, and there is no idempotency key to tell them apart, so the error is raised as-is. Check account() or your job list before resending. (Rate limits are retried by submit_all() / convert_all(): a 429 is the server saying it did not take the submission.)
  • Downloads are all-or-nothing. download() writes to a temporary file next to the destination and renames it into place at the end, so a dropped connection cannot leave a truncated .pptx behind — or destroy a good deck already sitting at that path.
  • The 60-second request timeout is idle time, not total time. timeout (default 60) is how long one request may go with no data moving — it is not a cap on how long a request may take. A 40MB upload or a large PPTX download that keeps making progress runs as long as it needs to; only a transfer that actually stalls is given up on, as APITimeoutError. A request that never gets a response at all is covered by the same clock. The Node SDK's timeoutMs means exactly the same thing, so the two clients behave the same way on a slow link.
  • Every request identifies the client with a User-Agent of image2ppt-python/<version>. The service uses this to tell SDK versions apart — it is not part of authentication and never changes a request's outcome.
  • A deprecated SDK version logs one warning. If this version is below the lowest the service still supports, the response carries a Deprecation header and the client warns once (logger image2ppt). Pass warn_on_deprecated=False to Image2PPTClient to silence it.
  • Client-side pre-compression. Images are compressed before upload (≤2000px, ≤1MB, JPEG) — the same shape the API works from, so you send fewer bytes without changing the result. PDFs are uploaded as-is.

More files than one request can hold

convert() is one job, one PPTX. For a pile too big for a single request, convert_all() splits it and writes one PPTX per batch (no server-side merge — N batches means N decks):

paths = client.convert_all(image_paths, dest_dir="decks/")
print(paths)  # ['decks/part-01.pptx', 'decks/part-02.pptx']

Batches hold at most 40MB of file content and at most 50 images; every PDF goes in a batch of its own, because the client does not parse PDFs and only the server knows their page count. submit_all() does the same splitting and hands back the jobs if you want to drive polling yourself. To see the plan without uploading anything, use plan_batches().

Rate limits are waited out, not raised. A pile big enough to need batching will hit the account's per-minute page quota (and its cap on concurrently active jobs). Both arrive as a 429 with a Retry-After; both are handled the same way — sleep that long, retry the same batch. Retrying a 429 is free: the server is saying it did not take the submission, so nothing was created and nothing was charged. Total waiting is capped by rate_limit_max_wait (default 30 min) — and only waiting counts against it, not the time the uploads themselves take, so a slow link cannot quietly turn the cap into "do not wait at all". A single batch is also retried at most 10 times, whatever the budget says: every retry re-uploads the whole batch, and a service still refusing after ten tries will not be talked round by more of them.

If a batch call does fail partway, the jobs it already created come back on the exception:

from image2ppt import Image2PPTError

try:
    paths = client.convert_all(image_paths, dest_dir="decks/")
except Image2PPTError as e:
    # These are already running with credits reserved — collect them, don't resubmit.
    for job in e.submitted_jobs:
        print("still running:", job.job_id)
    raise

Rate limits

Per account (all keys share the budget): ≤ 10 concurrent jobs, ≤ 60 pages/minute submitted. Over the limit returns 429 with a Retry-After hint. Only submissions are rate limited — polling job status is not.

submit_all() / convert_all() wait these out for you: a pile big enough to need batching is a pile big enough to hit the quota, so a 429 mid-pile is the normal path, not an error. submit() and convert() do not — they submit exactly once, so catch RateLimitedError and honor retry_after yourself:

import time
from image2ppt import RateLimitedError

while True:
    try:
        job = client.submit(paths)
        break
    except RateLimitedError as e:
        time.sleep(e.retry_after if e.retry_after is not None else 5)

Errors

Every exception this client raises about a request subclasses Image2PPTError and carries status_code, code, and message. Branch on code, not message. A raw requests exception never reaches you — a dropped connection, a per-request timeout, and a response body this client cannot parse all arrive as the SDK types below, with the original exception kept as __cause__.

Your own filesystem is the exception, deliberately. If download cannot write where you asked it to, you get the operating system's OSError — ENOSPC, EACCES, ENOENT — because that names the thing you have to go and fix, and no error of ours would say it better. So catch OSError alongside Image2PPTError around download. The Node client draws the same line.

Exception HTTP code
AuthenticationError 401 / 403 INVALID_API_KEY, API_KEY_REQUIRED, ACCOUNT_DELETED
InvalidFileError 400 / 413 INVALID_FILE, INVALID_PDF, PAYLOAD_TOO_LARGE (the size checks also fire locally, before upload)
UploadAbortedError 400 UPLOAD_ABORTED — the body never finished arriving and the server took nothing, so resending the same files is safe
MalformedUploadError 400 MALFORMED_UPLOAD — the body was not valid multipart/form-data; resending identical bytes will not help
NoFilesError 400 NO_FILES — no files reached the server
InvalidAspectRatioError 400 INVALID_ASPECT_RATIO — use auto, 16:9, or 4:3
TooManySlidesError 400 TOO_MANY_SLIDES
PageRateExceededError 400 PAGE_RATE_EXCEEDED — this one submission has more pages than a minute's quota, so waiting will not help; split it
InsufficientCreditsError 402 INSUFFICIENT_CREDITS
RateLimitedError 429 RATE_LIMITED (has retry_after)
JobNotFoundError 404 JOB_NOT_FOUND
JobAlreadyFinishedError 409 JOB_ALREADY_FINISHED — the cancellation came too late to change anything: the job had already finished, or was past the point where cancelling could still change the outcome
NotReadyError 409 NOT_READY
OutputExpiredError 410 OUTPUT_EXPIRED
JobCancelledError — JOB_CANCELLED — cancellation settled with no deliverable; subclasses JobFailedError
JobFailedError — job's error.code (raised by wait(); e.job is the snapshot)
ServerError 5xx JOB_CANCEL_FAILED — the service could not accept the cancellation; retrying is safe. Every other 5xx lands here too; branch on e.code.
APIConnectionError — — (the request never completed: connection refused or reset, DNS or TLS failure, a body that stopped arriving)
APITimeoutError — REQUEST_TIMEOUT — one HTTP request ran past the client's timeout; subclasses APIConnectionError
MalformedResponseError — — (a 2xx that is not JSON, or a body missing a field the contract guarantees)
Image2PPTTimeoutError — — (wait() exceeded its timeout; job may still be running)

Changed in 0.5.0: a 5xx used to arrive as the base Image2PPTError and now arrives as ServerError. ServerError subclasses Image2PPTError, so except Image2PPTError code is unaffected; only code that checked for the base class exactly sees a difference.

Two different timeouts, and they are not interchangeable. APITimeoutError means a single HTTP request ran past the client's per-request timeout — nothing came back. Image2PPTTimeoutError means wait() hit its own overall deadline after any number of perfectly healthy polls; no request failed at all, the job is just taking longer. Re-wait() on the job id for the second one.

from image2ppt import APIConnectionError, Image2PPTError, JobFailedError

try:
    job = client.convert(paths, "out.pptx")
except JobFailedError as e:
    print("conversion failed:", e.code, e.message)
except APIConnectionError as e:
    # Covers APITimeoutError too. The underlying exception is e.__cause__.
    print("could not reach the service:", e.message)
except Image2PPTError as e:
    print("request error:", e.status_code, e.code, e.message)

Which failures are worth retrying

Every Image2PPTError carries is_transient, and it is the same question wait() asks itself before polling again: would repeating this exact read plausibly work? (A raw OSError from download has no such attribute; that is the same exception as above, and it is never transient — free the space or fix the permission first.)

except Image2PPTError as e:
    if e.is_transient:
        time.sleep(5)  # a 5xx, a rate limit, or a network blip

It is True for ServerError (any 5xx), RateLimitedError, APIConnectionError and APITimeoutError; False for everything else — including MalformedResponseError, on purpose: a response this client cannot parse means something other than the API answered, or the contract moved, and neither gets better by asking again.

It says nothing about submitting. submit() is never retried on this signal, even for a transport failure that is_transient marks True. A lost response cannot be told apart from a rejected request, so retrying could create the same job twice and charge for it twice. Only a RateLimitedError is retried on the submit path — a 429 is the service explicitly saying it took nothing.

Full API reference

See https://image2ppt.com/en/docs/api for the complete HTTP contract (endpoints, fields, error codes). 中文版:https://image2ppt.com/docs/api。

License

MIT

Metadata

Release files for image2ppt 0.5.3

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for image2ppt 0.5.3
File Size Uploaded Provenance
image2ppt-0.5.3.tar.gz 38.2 kB Without provenance Details

Built distribution (wheel)

Table of built distributions (wheels) for image2ppt 0.5.3
File Interpreter ABI Platform
image2ppt-0.5.3-py3-none-any.whl Python 3 none any Details

Total release size: 81.0 kB

Release files / image2ppt-0.5.3.tar.gz

Download URL image2ppt-0.5.3.tar.gz
Size 38.2 kB
Tags Source
SHA-256 checksum
How to use checksums
5de89e946b3b39fb883bad4e35a68779d469febcfefe30fcab2bd6795250bd9c
BLAKE2b-256 checksum
How to use checksums
625a825c66353ad56c8018cffcce191e891351c5c22d35057edc87f276f93c99
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via uv/0.12.8 {"installer":{"name":"uv","version":"0.12.8","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"macOS","version":null,"id":null,"libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}

Release files / image2ppt-0.5.3-py3-none-any.whl

Download URL image2ppt-0.5.3-py3-none-any.whl
Size 42.8 kB
Tags Python 3
SHA-256 checksum
How to use checksums
dc3a84aa73233c9bc39225f08448ca651c0fc8131da48e4d58863458706efe97
BLAKE2b-256 checksum
How to use checksums
55f1c2bf3e5e875a26f58f716164c56f590c95cc899db226bfd57d907a9d8400
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via uv/0.12.8 {"installer":{"name":"uv","version":"0.12.8","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"macOS","version":null,"id":null,"libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}

Security for image2ppt 0.5.3

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

No provenance found

None of the files in this release were uploaded with provenance metadata.

A note on package security

PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page