---
title: "How to Convert HTML to an Image in Python"
description: "Generate PNGs from HTML in Python with Playwright or the typed HCTI client. Fill a template from a dict, wait for web fonts and render a whole batch."
published: "2026-10-14"
author: "Jeffrey Needles"
canonical: "https://htmlcsstoimage.com/blog/convert-html-to-image-python"
---


Conference schedules usually live in a spreadsheet or a database. When someone asks for an image per session, the first Python answer is usually Pillow and a stack of `draw.text()` calls with hand-measured coordinates. That works until a speaker submits a talk title twice as long as the one you designed for.

It's much easier to describe the card in HTML and CSS, fill it in from Python and let a browser lay it out. Long titles wrap, web fonts load, and changing the design means editing CSS instead of redoing pixel math.

In this post we'll build a conference session card from a Python dictionary, render it to a PNG with Playwright, and then render a whole schedule in one run. At the end, we'll send the same HTML to the HTML/CSS to Image Python client to get a hosted image URL instead.

## The Short Version

1. Install Playwright and its Chromium browser, or install the HCTI Python client if you don't want a local browser.
2. Fill an HTML template with escaped values from your Python data.
3. Wait for the fonts and images the card needs. Don't use a fixed sleep.
4. Capture the card with `page.locator(selector).screenshot()`, or the whole document with `page.screenshot(full_page=True)`.
5. For a hosted image, send a `CreateHtmlCssImageRequest`, check `result.success` and read `result.url`.

## 1. Set Up Playwright for Python

You'll need Python 3.10 or newer, which also covers the HCTI client later on. Create a virtual environment and install Playwright and its Chromium build:

```bash
python -m venv .venv
source .venv/bin/activate
python -m pip install playwright
python -m playwright install chromium
```

On Windows, activate the environment with `.venv\Scripts\Activate.ps1` in PowerShell.

The two install commands catch people out the first time. `pip install playwright` only installs the library. `playwright install chromium` downloads the browser it controls. Playwright's [library setup guide](https://playwright.dev/python/docs/library){target=_blank} covers both its sync and async APIs; we'll use the sync API here.

If you've looked into this before, you've probably come across `imgkit` and `html2image`. [`imgkit`](https://github.com/jarrekk/imgkit){target=_blank} wraps `wkhtmltoimage`, whose [repository is archived](https://github.com/wkhtmltopdf/wkhtmltopdf){target=_blank}. It runs on an old QtWebKit engine (the [project status page](https://wkhtmltopdf.org/status.html){target=_blank} explains why), so modern CSS can come out differently than in Chrome. `html2image` is a thin wrapper around a browser's built-in headless screenshot mode. It's quick to try, but it gives you much less control than Playwright over waiting and cropping. PhantomJS still turns up in old Stack Overflow answers too, but [its development is suspended](https://github.com/ariya/phantomjs){target=_blank}.

If you're starting something new, I'd go straight to Chromium.

## 2. Build the HTML from Python Data

The card is split into two files that live in the same directory. `build_card.py` holds the session data and fills in the template. It can also write a `session-card.html` preview that you can open in a normal browser.

The template uses placeholders like `$speaker` and `$title`. We fill them with Python's [`string.Template`](https://docs.python.org/3/library/string.html#template-strings){target=_blank} rather than `str.format()`, because `str.format()` would choke on every curly brace in the CSS. Save this as `build_card.py`:

```python
from html import escape
from pathlib import Path
from string import Template

BASE_DIR = Path(__file__).resolve().parent

session = {
    "conference": "Data & Design 2026",
    "track": "Practical Python",
    "title": "Small data, useful dashboards",
    "speaker": "Dr. Amara Reed",
    "role": "Research engineer at Field Notes",
    "when": "October 22 · 10:30 AM",
    "room": "Studio 2 · 35 minutes",
}


def make_card(values: dict[str, str]) -> str:
    template = Template(
        (BASE_DIR / "session-card.template.html").read_text(encoding="utf-8")
    )
    return template.substitute(
        {name: escape(value, quote=True) for name, value in values.items()}
    )


if __name__ == "__main__":
    output = BASE_DIR / "session-card.html"
    output.write_text(make_card(session), encoding="utf-8")
    print(output)
```

[`html.escape()`](https://docs.python.org/3/library/html.html#html.escape){target=_blank} turns the `&` in `Data & Design` into `&amp;`, so a speaker name or talk title can't break the markup. It's the right tool for plain text values like these. If users can submit actual HTML, you'll need a real sanitizer.

Save the template as `session-card.template.html`:

```html
<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>$title</title>
  <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;700&display=swap">
  <style>
    * { box-sizing: border-box; }
    body { margin: 0; padding: 32px; background: #e8ecef; }
    .session-card {
      width: 960px; height: 600px; padding: 48px;
      display: flex; flex-direction: column;
      background: #fff7e7; color: #173436;
      font-family: "DM Sans", sans-serif;
      border-top: 12px solid #e06840;
    }
    .topline, .details { display: flex; justify-content: space-between; }
    .topline { align-items: center; font-size: 22px; }
    .track {
      padding: 8px 16px; border: 1px solid #173436;
      border-radius: 999px; font-size: 16px;
    }
    h1 {
      max-width: 810px; margin: 42px 0 24px;
      font-size: 64px; line-height: 1.08; letter-spacing: -2px;
    }
    .speaker { margin: 0 0 8px; font-size: 28px; font-weight: 700; }
    .role { margin: 0; font-size: 21px; color: #526968; }
    .details {
      margin-top: auto; padding-top: 24px;
      border-top: 1px solid #c9d1c7; font-size: 22px;
    }
  </style>
</head>
<body>
  <article class="session-card" data-screenshot="session">
    <header class="topline">
      <strong>$conference</strong>
      <span class="track">$track</span>
    </header>
    <h1>$title</h1>
    <p class="speaker">$speaker</p>
    <p class="role">$role</p>
    <footer class="details">
      <span>$when</span>
      <span>$room</span>
    </footer>
  </article>
  <script>
    async function prepareCard() {
      const sample = document.querySelector(".session-card").textContent;
      for (const weight of [400, 700]) {
        const faces = await document.fonts.load(`$${weight} 24px "DM Sans"`, sample);
        if (faces.length === 0) throw new Error("DM Sans is not available");
      }
      await document.fonts.ready;
      await Promise.all([...document.images].map(async (image) => {
        image.loading = "eager";
        await image.decode();
      }));
      document.documentElement.dataset.cardReady = "true";
      if (typeof window.ScreenshotReady === "function") window.ScreenshotReady();
    }
    prepareCard().catch((error) => {
      document.documentElement.dataset.cardError = String(error);
      console.error(error);
    });
  </script>
</body>
</html>
```

> [!TIP]
> `string.Template` also treats the `$` in JavaScript template literals as a placeholder. That's why the script writes `$${weight}`: substitution turns it back into `${weight}` before the browser ever sees it. It also means the template file on its own isn't valid JavaScript, so open the generated `session-card.html` when you want to preview it.

Run `python build_card.py` to write that preview.

The card is a fixed 960 × 600 box. The page around it has padding and a grey background, but we'll screenshot the card element directly, so neither ends up in the image. Here's the result, rendered with HCTI from the same HTML:

![A cream and teal conference session card with speaker, talk title, room and time](https://content-images.htmlcsstoimage.com/media-assets/05f63c1ae3e8ecc7a42e2dbac67d439577aaf1073725eddd1db7ee099315b227/example-output.png)

## 3. Wait for the Font Before You Screenshot

This is the step most HTML-to-image scripts skip. If the screenshot runs before DM Sans has downloaded, the title is drawn in a fallback font. That font has different character widths, so the title wraps somewhere else. A `time.sleep(1)` usually hides the problem on your laptop and then fails on a slower server.

Instead, the template checks for itself. `prepareCard()` runs in the browser, not in Python:

- [`document.fonts.load()`](https://developer.mozilla.org/en-US/docs/Web/API/FontFaceSet/load){target=_blank} requests the regular and bold weights of DM Sans. An empty result means no matching web font was found, so the script throws instead of quietly using a fallback.
- [`document.fonts.ready`](https://developer.mozilla.org/en-US/docs/Web/API/FontFaceSet/ready){target=_blank} waits for any remaining font loading and layout.
- [`decode()`](https://developer.mozilla.org/en-US/docs/Web/API/HTMLImageElement/decode){target=_blank} waits for every `<img>` to be ready to draw. This card doesn't have any images yet, but you'll want the check once you add a speaker photo or sponsor logo. CSS background images aren't covered by it.

When everything has loaded, the script sets `data-card-ready="true"` on `<html>`. If something fails, it sets `data-card-error` instead. The Python side waits for one or the other.

If your page fetches data or draws a chart after loading, finish that work before setting the marker. [How to Wait for a Page to Finish Rendering Before Taking a Screenshot](/blog/wait-for-page-before-screenshot) goes through those cases.

## 4. Render the Card as a PNG

Save this as `render_local.py`, next to `build_card.py` and the template:

```python
from playwright.sync_api import sync_playwright

from build_card import BASE_DIR, make_card, session

html = make_card(session)
selector = '[data-screenshot="session"]'

with sync_playwright() as playwright:
    browser = playwright.chromium.launch()
    try:
        page = browser.new_page(
            viewport={"width": 1024, "height": 720},
            device_scale_factor=1,
        )
        page.set_content(html, wait_until="load")
        page.wait_for_function(
            "document.documentElement.dataset.cardReady === 'true' || "
            "document.documentElement.dataset.cardError !== undefined",
            timeout=30_000,
        )
        error = page.evaluate("document.documentElement.dataset.cardError")
        if error:
            raise RuntimeError(error)

        page.locator(selector).screenshot(
            path=BASE_DIR / "session-card.png",
            animations="disabled",
        )
    finally:
        browser.close()
```

Run `python render_local.py` and you'll get `session-card.png` next to the script.

A few of the settings are deliberate. `device_scale_factor=1` gives one image pixel per CSS pixel; bump it to 2 if you want a sharper image at the same layout size. The viewport is a bit larger than the card so that no responsive CSS kicks in. The browser is closed in a `finally` block, so a missing font won't leave a Chromium process behind.

### Rendering an HTML String vs. a File

[`page.set_content()`](https://playwright.dev/python/docs/api/class-page#page-set-content){target=_blank} loads HTML from a string, so the page has no idea which directory it came from. A relative image like `src="speaker.jpg"` won't resolve. Use an absolute URL, or add a [`<base href>`](https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/base){target=_blank}.

If your assets sit next to the HTML on disk, load the file instead:

```python
page.goto((BASE_DIR / "session-card.html").as_uri(), wait_until="load")
```

For a page your app serves over HTTP, pass its URL to `page.goto()`. Either way, keep the readiness check after navigation.

### Capturing the Whole Page

The example crops to the card with [`locator.screenshot()`](https://playwright.dev/python/docs/api/class-locator#locator-screenshot){target=_blank}. To capture the full document, swap that call for:

```python
page.screenshot(path=BASE_DIR / "schedule.png", full_page=True)
```

Leave out `full_page=True` and you get just the viewport. Playwright's [screenshot guide](https://playwright.dev/python/docs/screenshots){target=_blank} shows both.

If the card has a shadow or a badge hanging off its edge, the element crop will cut it off. In that case, wrap the card in a padded container and screenshot the wrapper. [How to Take a Screenshot of a Specific HTML Element](/blog/screenshot-specific-html-element) explains where those crop edges come from.

## 5. Render a Whole Schedule

One card is a demo. The real job is usually every session in the schedule. Launching Chromium is the slow part, so launch it once and open a fresh page for each card:

```python
from playwright.sync_api import sync_playwright

from build_card import BASE_DIR, make_card, session

sessions = {
    "small-data": session,
    "type-scale": {
        **session,
        "title": "Choosing a type scale that survives real data",
        "speaker": "Jonah Mills",
        "role": "Design systems lead at Paperline",
        "when": "October 22 · 1:15 PM",
    },
}

READY = (
    "document.documentElement.dataset.cardReady === 'true' || "
    "document.documentElement.dataset.cardError !== undefined"
)

with sync_playwright() as playwright:
    browser = playwright.chromium.launch()
    try:
        for session_id, values in sessions.items():
            page = browser.new_page(
                viewport={"width": 1024, "height": 720},
                device_scale_factor=1,
            )
            try:
                page.set_content(make_card(values), wait_until="load")
                page.wait_for_function(READY, timeout=30_000)
                error = page.evaluate("document.documentElement.dataset.cardError")
                if error:
                    raise RuntimeError(f"{session_id}: {error}")

                page.locator('[data-screenshot="session"]').screenshot(
                    path=BASE_DIR / f"{session_id}.png",
                    animations="disabled",
                )
            finally:
                page.close()
    finally:
        browser.close()
```

In a real project, `sessions` would come from your database or a CSV export. Keying the images by your own session ID makes it easy to match each PNG back to its row later.

A fresh page per card means nothing from the previous card can leak into the next one. As written, one bad card stops the run. If you'd rather keep going, catch the error, log the session ID and move on to the next card.

Rendering one card at a time is plenty for a conference schedule. If you have thousands of images, Playwright's async API lets you render a few pages in parallel. Keep the number small, though, because every page is a real browser tab using real memory.

## 6. Get a Hosted Image with the HTML/CSS to Image Python Client

Playwright makes sense when you want local PNG files and you're happy running a browser. If the images are going on a website or into emails anyway, you might not want Chromium on your server at all. In that case, you can send the same HTML to HTML/CSS to Image and get a URL back.

Install the [official typed Python client](https://github.com/htmlcsstoimage/python-client){target=_blank}:

```bash
python -m pip install html-css-to-image
```

Then set `HCTI_API_ID` and `HCTI_API_KEY` as environment variables on the server, using the credentials from your [dashboard](https://htmlcsstoimage.com/dashboard){target=_blank}.

Save this as `render_hcti.py`:

```python
import httpx

from html_css_to_image import (
    CreateHtmlCssImageRequest,
    HtmlCssToImageClient,
    UnexpectedResponseError,
)

from build_card import make_card, session

request = CreateHtmlCssImageRequest(
    html=make_card(session),
    selector='[data-screenshot="session"]',
    viewport_width=1024,
    viewport_height=720,
    device_scale=1,
    format="png",
    render_when_ready=True,
)

try:
    with HtmlCssToImageClient.from_env() as client:
        result = client.create_image(request)
except (httpx.HTTPError, UnexpectedResponseError) as error:
    raise SystemExit(f"Image request failed: {error}") from error

if not result.success:
    raise SystemExit(
        f"HCTI returned {result.status_code}: {result.error}. "
        f"{result.message or ''}"
    )

print(result.url)
```

Run `python render_hcti.py` and it prints the hosted PNG URL.

[`selector`](https://docs.htmlcsstoimage.com/parameters/selector/){target=_blank} uses the same card selector as the local script. [`render_when_ready`](https://docs.htmlcsstoimage.com/parameters/render_when_ready/){target=_blank} is what makes the font check from step 3 pay off a second time. It tells HCTI to wait until the page calls `ScreenshotReady()`, which our template does once DM Sans has loaded. Because of the `typeof` guard, the same HTML still works in Playwright, where that function doesn't exist.

There are two ways the request can fail. If it never reaches the API, you get an exception. If the API rejects it, you get a result with `success` set to false. The script handles both before it trusts `result.url`.

Asset URLs in the HTML need to be public, or embedded as data URLs. Our remote browser can't read files from your machine.

For the schedule, the client also has batch methods, so you can send all the cards together instead of making a request per card. [Batch templated images](/blog/batch-templated-images) shows how batches work. For other request options, see [HTML to PNG in Python](https://htmlcsstoimage.com/features/html-to-png/python){target=_blank} and the [Python API documentation](https://docs.htmlcsstoimage.com/example-code/python/){target=_blank}.

All the sample files are available to download: the [HTML template](https://content-images.htmlcsstoimage.com/media-assets/31ca46135083f718deed1d63add8e8d489f89f8f330117ae15b161aabed2c72a/session-card.template.html), the [template builder](https://content-images.htmlcsstoimage.com/media-assets/4852e5eca7355e20f1f97bc51c9e8a3b4bfa67530aeaf6aa542bdb1700ca1a87/build_card.py), the [single-card renderer](https://content-images.htmlcsstoimage.com/media-assets/000b55c7f2119329b6be00028ef174eab51905d6a0984aa1cec65cdb2418402c/render_local.py), the [schedule renderer](https://content-images.htmlcsstoimage.com/media-assets/d4be49447b992339e1ec4c62d9fc91094cf671d75aacc6e85236eec367ff6a94/render_schedule.py) and the [HCTI renderer](https://content-images.htmlcsstoimage.com/media-assets/c6718304555f984f25f2a7689d90e53892476f294f9b9a4b87756f8e3134d601/render_hcti.py). Keep them in the same directory.

## Frequently Asked Questions

#### Can I Use This in Django, Flask or FastAPI?

Yes, but watch for two things. First, Playwright's sync API refuses to run inside an asyncio event loop. In FastAPI or any other async code, import from `playwright.async_api` instead; the calls are the same, with `await` in front. Second, a render takes long enough that it's better done in a background job (Celery, RQ or whatever you already run) than inside the request.

#### Can I Get the PNG Bytes Instead of Writing a File?

Yes. Leave out `path` and use the return value: `png_bytes = page.locator(selector).screenshot()`. From there you can return it from an endpoint or upload it to storage. HCTI gives you a URL instead of bytes, and you can download the image from that URL if you need the file.

#### Why Does the Title Wrap Differently on My Server?

It's almost always the font. Your server probably doesn't have the same fonts installed as your laptop, and a fallback font with different character widths will move the line breaks. That's why the template loads DM Sans explicitly and fails if it can't. If it's not the font, check that the viewport and device scale match between environments.
