How to Convert HTML to an Image in Python

By Jeffrey Needles

October 14, 2026
Tutorial
Html to Image
Python

How to Convert HTML to an Image in 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:

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 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 wraps wkhtmltoimage, whose repository is archived. It runs on an old QtWebKit engine (the project status page 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.

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 rather than str.format(), because str.format() would choke on every curly brace in the CSS. Save this as build_card.py:

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() turns the & in Data & Design into &, 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:

<!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

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() 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 waits for any remaining font loading and layout.
  • decode() 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 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:

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() 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>.

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

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(). To capture the full document, swap that call for:

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 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 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:

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:

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.

Save this as render_hcti.py:

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 uses the same card selector as the local script. render_when_ready 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 shows how batches work. For other request options, see HTML to PNG in Python and the Python API documentation.

All the sample files are available to download: the HTML template, the template builder, the single-card renderer, the schedule renderer and the HCTI renderer. 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.

Loading newsletter signup…

Please wait a moment.

Have a question?

We'd love to hear about what you're building.

Contact us

Get Started

You'll be up and running in 5 minutes.

Grab an API Key

Keep reading

More posts

View all posts
Get Started Now

NO CREDIT CARD NEEDED. 50 FREE IMAGES EVERY MONTH.