# HTML to image in Python: Playwright, imgkit or an API

> Convert HTML to PNG in Python with Playwright and real Chromium, why imgkit and Pillow fall short, and the same image from an API with requests.

Published 2026-11-04 · https://shareglint.com/en/guides/html-to-image-python

To convert HTML to an image in Python, load the markup into a headless Chromium with **Playwright** and take a screenshot: `page.set_content(html)` then `page.locator(".card").screenshot(path="card.png")`. That gives you real Chrome rendering — CSS grid, gradients, web fonts, emoji — in about ten lines. The older shortcut, imgkit, wraps an archived WebKit tool, and Pillow cannot read HTML at all. If you would rather not run a browser in production, an HTML to image API does the same job with one `requests.post`.

Below: working Playwright code (we ran every snippet on Playwright 1.64.1, Python 3.11), what it costs to run in production, and the API version.

## Playwright for Python: HTML to PNG in real Chromium

```bash
pip install playwright
playwright install --with-deps chromium   # browser + the system libraries it needs
```

```python
from playwright.sync_api import sync_playwright

HTML = """<!doctype html>
<html>
<head>
  <meta charset="utf-8">
  <style>
    body { margin: 0; }
    .card {
      width: 1200px; height: 630px; box-sizing: border-box;
      display: grid; align-content: center; gap: 16px; padding: 80px;
      background: linear-gradient(135deg, #0f172a, #1e3a8a);
      color: #f8fafc; font: 700 64px/1.15 system-ui, sans-serif;
    }
    .card small { font-size: 28px; font-weight: 400; color: #93c5fd; }
  </style>
</head>
<body>
  <div class="card">
    <small>Release notes · v2.4</small>
    HTML to image in Python, rendered by Chromium
  </div>
</body>
</html>"""

def html_to_png(html: str, path: str) -> None:
    with sync_playwright() as p:
        browser = p.chromium.launch()
        page = browser.new_page(
            viewport={"width": 1200, "height": 630},
            device_scale_factor=2,  # 2400×1260 file, sharp on retina screens
        )
        page.set_content(html, wait_until="load")   # waits for <img> and stylesheets
        page.evaluate("document.fonts.ready")        # and for web fonts
        page.locator(".card").screenshot(path=path)  # just the element's box
        browser.close()

html_to_png(HTML, "card.png")
```

`locator.screenshot()` crops to the element, which is usually what you want for a card. For other shapes, [`page.screenshot()`](https://playwright.dev/python/docs/api/class-page#page-screenshot) takes the options you would expect:

```python
page.screenshot(path="clip.png", clip={"x": 0, "y": 0, "width": 400, "height": 200})
page.screenshot(path="full.png", full_page=True)              # the whole scrollable page
page.screenshot(path="transparent.png", omit_background=True) # PNG with alpha where nothing is painted
page.screenshot(path="card.jpg", type="jpeg", quality=85)
png_bytes = page.screenshot()                                  # no path: returns bytes
```

`omit_background` only gives transparency where the page paints nothing: a `body` or card with a background colour stays opaque. In an async app (FastAPI, aiohttp) use `playwright.async_api` with the same method names and `await`.

## What running Chromium yourself costs

The code above is correct; production is where the work starts.

- **Start-up time.** Launching Chromium per image costs most of a second. Keep one browser open in a long-lived worker and open a fresh page (or context) per render.
- **Memory and crashes.** Each page is a renderer process. Cap concurrency, put a timeout on every render, and restart the browser after it has served a few hundred pages.
- **Fonts.** Chromium can only use fonts that are installed or loaded with `@font-face`. When we ran the sample on a machine with no emoji font, a 🚀 in the title rendered as an empty box. Slim Docker images have the same problem with CJK and emoji; install the fonts or embed them.
- **HTML you did not write.** If users control the HTML, they control a browser on your network: an `<img src>` or `<iframe>` can point at internal addresses or a cloud metadata endpoint. Sanitise the markup, block outbound requests with `page.route()`, or render in an isolated network.
- **Image size.** `playwright install --with-deps chromium` adds a few hundred MB of browser and libraries to the image; Microsoft also publishes ready [Playwright Docker images](https://playwright.dev/python/docs/docker).

If you already run browsers for end-to-end tests, none of this is new, and Playwright is the right tool.

## imgkit and wkhtmltoimage: avoid for new code

Many older answers recommend `imgkit.from_string(html, "out.png")`. imgkit is a thin wrapper that shells out to **wkhtmltoimage**, and that project is over. Its [GitHub repository](https://github.com/wkhtmltopdf/wkhtmltopdf) was archived on 2 January 2023, the last release (0.12.6) is from June 2020, and the [project's own status page](https://wkhtmltopdf.org/status.html) says it renders with a Qt WebKit years behind current WebKit and warns: "Do not use wkhtmltopdf with any untrusted HTML". imgkit itself last shipped (1.2.3) in February 2023.

In practice that means no CSS grid, unreliable flexbox, and missing modern properties — your card will not look like it does in your browser. Existing code keeps working; new code should use Playwright.

## Why Pillow cannot render HTML

Pillow is a pixel library. `ImageDraw` draws text, lines and shapes at coordinates you calculate; there is no HTML parser, no CSS, no text layout beyond what you write yourself. It is excellent for a simple card with a logo and one line of text — the [Django guide](https://shareglint.com/en/guides/dynamic-og-images-django) has a complete Pillow example — but "convert this HTML to PNG" is not something Pillow can do.

## The same image from an API with requests

An [HTML to image API](https://shareglint.com/en/html-to-image) runs the browser for you. With Shareglint you save the HTML and CSS once as a template with named fields, then each image is one request with the values that change:

```python
import os

import requests

API = "https://shareglint.com/v1"
HEADERS = {"Authorization": f"Bearer {os.environ['IMAGE_API_KEY']}"}

# 1. Save the template once: a deploy script, a migration, or by hand.
requests.post(f"{API}/templates", headers=HEADERS, timeout=30, json={
    "name": "Article card",
    "slug": "article-card",
    "width": 1200,
    "height": 630,
    "fields": ["kicker", "title", "author"],
    "html": '<div class="card"><p class="kicker">{{ kicker }}</p>'
            '<div class="fitbox"><h1 class="fit" data-autofit>{{ title }}</h1></div>'
            '<p class="by">{{ author }}</p></div>',
    "css": ".card{height:100%;padding:72px;display:grid;grid-template-rows:auto 1fr auto;"
           "gap:24px;background:#0f172a;color:#f8fafc;font-family:Roboto,sans-serif}"
           ".kicker{color:#fbbf24;font-size:26px;text-transform:uppercase}"
           "h1{font-size:76px;line-height:1.08}.by{font-size:28px;color:#94a3b8}",
}).raise_for_status()

# 2. One request per image.
response = requests.post(f"{API}/images?wait=true", headers=HEADERS, timeout=60, json={
    "template": "article-card",
    "format": "png",
    "modifications": {
        "kicker": "Engineering",
        "title": "How we cut our build time in half",
        "author": "Ada Lovelace",
    },
})
response.raise_for_status()
print(response.json()["url"])   # a 2400×1260 PNG (scale 2 is the default)
```

What to know before you use it:

- **Step 1 runs once.** Running it again answers `422` because the slug is taken; change a template with `PATCH /v1/templates/{id}`. Built-in templates such as `blog-og` need no step 1 at all.
- **Same fields, same file.** Sending identical fields again returns the existing URL and uses no quota, so retries are free. `data-autofit` shrinks a long title until it fits its `.fitbox`.
- **While designing**, `POST /v1/templates/preview` renders a draft you have not saved and uses no quota.
- **It is sandboxed, so it is stricter than your own Chromium.** Scripts are removed on save, external URLs in HTML and CSS are dropped (images come in through image fields or `data:` URIs), and fonts come from the installed list (`GET /v1/fonts`) — no font uploads. If you need JavaScript or your own font files, Playwright is the better fit.
- **Formats**: PNG, JPEG, WebP, PDF, GIF, MP4, with `"format"`, `"quality"` and `"scale"` in the request.

Free accounts get 100 images a month with a small watermark; Starter is $9 a month for 2,000 ([pricing](https://shareglint.com/en/pricing)). If your HTML is completely different on every request, a service that takes the full HTML each time, such as HTMLCSStoImage, fits that model better — see the [comparison](https://shareglint.com/en/alternatives/htmlcsstoimage).

## Which one to pick

- **Playwright** when you already operate browsers, need JavaScript in the page, or want your own fonts.
- **An API** when the image is a template filled with data (social cards, certificates, receipts) and you do not want Chromium in your deployment.
- **Pillow** for a logo and one line of text, drawn by hand.

The same comparison for other languages: [JavaScript](https://shareglint.com/en/guides/html-to-image-javascript), [PHP](https://shareglint.com/en/guides/html-to-image-php), [C#](https://shareglint.com/en/guides/html-to-image-csharp), and [which npm package](https://shareglint.com/en/guides/html-to-image-npm) to choose. To capture a live URL rather than your own HTML, see the [Playwright screenshot guide](https://shareglint.com/en/guides/playwright-screenshot) or the [screenshot API](https://shareglint.com/en/screenshot-api).

## FAQ

### What is the best way to convert HTML to an image in Python?

Playwright for Python. It drives a real Chromium: page.set_content(html) loads your markup and page.screenshot() or locator.screenshot() returns a PNG or JPEG, so grid, flexbox, web fonts and emoji render exactly as in Chrome.

### Can Pillow convert HTML to PNG?

No. Pillow draws pixels, text and shapes onto a canvas you size yourself; it has no HTML parser and no CSS layout engine. You can rebuild a simple card with ImageDraw calls, but you cannot hand it HTML.

### Is imgkit still a good choice?

Not for new code. imgkit wraps wkhtmltoimage, whose GitHub repository was archived on 2 January 2023; its last release, 0.12.6, dates from June 2020 and renders with an old Qt WebKit that misses modern CSS. Its own status page warns against feeding it untrusted HTML.

### How do I get a sharp, retina-quality PNG from Playwright?

Create the page with device_scale_factor=2 (or 3). A 1200×630 viewport then produces a 2400×1260 file, with text rendered at the higher density rather than upscaled.
