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
pip install playwright
playwright install --with-deps chromium # browser + the system libraries it needs
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() takes the options you would expect:
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 withpage.route(), or render in an isolated network. - Image size.
playwright install --with-deps chromiumadds a few hundred MB of browser and libraries to the image; Microsoft also publishes ready Playwright Docker images.
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 was archived on 2 January 2023, the last release (0.12.6) is from June 2020, and the project's own status page 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 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 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:
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
422because the slug is taken; change a template withPATCH /v1/templates/{id}. Built-in templates such asblog-ogneed 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-autofitshrinks a long title until it fits its.fitbox. - While designing,
POST /v1/templates/previewrenders 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). 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.
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, PHP, C#, and which npm package to choose. To capture a live URL rather than your own HTML, see the Playwright screenshot guide or the screenshot API.