ScreenshotNeo

BlogHow-to

How to Convert HTML to an Image in Flask

Render Flask pages as PNG, JPEG, or WebP with Playwright. Learn how to capture HTML safely, return image bytes, handle assets, and troubleshoot production issues.

By the ScreenshotNeo team29 September 202612 min read

How to Convert HTML to an Image in Flask

To convert HTML to an image in Flask, render the HTML in a real browser and take a screenshot. Playwright for Python is a practical route: it can navigate to a Flask page, capture the full page or a selected element, and either save the image to a file or return image bytes. The important design choice is where the HTML lives: a reachable Flask URL, a template rendered into a string, or a page already open in the browser.

This guide uses Playwright to create PNG screenshots and return them from Flask. It also covers JPEG and WebP, waiting for dynamic content, local assets, deployment, and the cases where PDF output or a hosted screenshot API fits better.

1. Install Playwright and its browser

Install the Python package and Chromium. Playwright requires browser binaries in addition to the Python package; install them in the same environment used to run the app. The library also offers synchronous and asynchronous APIs. See the Playwright Python installation guide and screenshot API documentation.

python -m pip install playwright flask
python -m playwright install chromium

For a container or server, include the browser installation step in the image build or deployment setup. A package install alone does not guarantee that Chromium is available. The examples below use the synchronous API for readability; an async Flask deployment can use Playwright’s asynchronous API where appropriate.

2. Capture a rendered Flask page

Start with a route that is reachable from the process doing the capture. This example captures a card route and returns PNG bytes directly. It is a minimal shape: adapt authentication, host binding, error handling, and browser lifecycle to your deployment.

A browser renders the Flask page before Playwright returns screenshot bytes.
A browser renders the Flask page before Playwright returns screenshot bytes.
from flask import Flask, Response, url_for
from playwright.sync_api import sync_playwright

app = Flask(__name__)

@app.get("/card")
def card():
    return """<html><body><article class='card'>
      <h1>Release notes</h1><p>Version 2.4 is available.</p>
    </article></body></html>"""

@app.get("/card.png")
def card_png():
    target = "http://127.0.0.1:5000/card"
    with sync_playwright() as p:
        browser = p.chromium.launch()
        page = browser.new_page(viewport={"width": 1200, "height": 800})
        page.goto(target, wait_until="networkidle", timeout=30000)
        image_bytes = page.locator("article.card").screenshot(type="png")
        browser.close()
    return Response(image_bytes, mimetype="image/png")

if __name__ == "__main__":
    app.run(port=5000)

Run the app, then request /card.png. The browser must be able to reach target. This loopback address works only when Flask is actually listening there in the same network namespace. In containers, multiple workers, or remote deployments, configure an internal reachable URL rather than assuming 127.0.0.1:5000 points to the desired app.

Playwright’s page screenshot is PNG by default. locator.screenshot() captures the element’s bounding box, while page.screenshot(full_page=True) captures the full scrollable page. Screenshot calls can return bytes, so there is no need to write a temporary file when the Flask response can use the bytes directly.

3. Choose a source: URL, template, or HTML string

Capture a URL

Navigating to a URL is usually the simplest choice for a fully rendered application page. It lets Flask resolve its template, CSS, fonts, and images using the same URL rules as a browser. Make sure the screenshot worker can access the app and any required assets. If the route requires a session, pass the necessary authentication state or provide a controlled internal capture route.

Render a template into HTML

If you want to avoid a second HTTP request to Flask, render a Jinja template to a string and set it as page content. Relative asset URLs need special care: a string has no natural URL base, so use absolute asset paths or set a base URL in the document. An alternative is to navigate to an application route that already serves the rendered template.

from flask import render_template_string
from playwright.sync_api import sync_playwright

html = render_template_string("""
<!doctype html>
<html><head>
  <style>body { font: 16px sans-serif; padding: 24px; }</style>
</head><body>
  <h1>{{ title }}</h1>
  <p>{{ summary }}</p>
</body></html>
""", title="Weekly report", summary="Three items need review.")

with sync_playwright() as p:
    browser = p.chromium.launch()
    page = browser.new_page()
    page.set_content(html, wait_until="load")
    image_bytes = page.screenshot(type="png")
    browser.close()

In this example, CSS is inline, so no asset host is needed. For external stylesheets, fonts, or images, use absolute URLs or a page loaded from a real application URL. If you control the generated document, provide a suitable <base href="..."> to resolve relative URLs. Treat untrusted HTML as untrusted input; do not give arbitrary markup access to internal network addresses, credentials, or local files.

Capture one element or the full page

Use a locator when the output is a card, chart, invoice, or other component rather than the whole document. The locator must match an element after the page has rendered. Use a full-page capture when you want all vertically scrollable content.

# One element, returned as PNG bytes
image_bytes = page.locator(".social-card").screenshot(type="png")

# Entire scrollable page
image_bytes = page.screenshot(full_page=True, type="png")

# Save a page capture to a file
page.screenshot(path="output.png", full_page=True)

Element screenshots can be more predictable for fixed-size share cards. Full-page screenshots may be very tall and consume more memory. If content is lazy-loaded below the fold, the application may need to scroll it into view or otherwise trigger loading before capture.

4. Return the image from a Flask route

Flask can serve screenshot bytes with an image MIME type. Use image/png, image/jpeg, or image/webp to match the encoded output. Here is the compact response pattern:

return Response(image_bytes, mimetype="image/png")

When you need a downloadable file rather than inline display, set a Content-Disposition header. For generated images used repeatedly, consider storing the resulting bytes and serving the stored object rather than launching a browser for every viewer request.

For JPEG, specify type="jpeg" and a quality value supported by the screenshot API. For WebP, specify type="webp" where supported by your Playwright/browser version. PNG is lossless and is the default; JPEG is useful for photographic content where a smaller lossy file is acceptable. Choose format based on the image and downstream use, and inspect the actual file size for your own page rather than assuming a fixed savings.

5. Wait for the page to be ready

A screenshot is only as complete as the page state at capture time. Navigation’s wait_until option controls which loading milestone Playwright waits for, but it cannot know when your application-specific rendering is finished. A single-page app may finish network activity before a chart or data component is ready; conversely, analytics or polling can keep the network busy indefinitely.

Prefer a concrete readiness condition, such as a selector appearing, and use a bounded timeout:

page.goto(target, wait_until="domcontentloaded", timeout=30000)
page.locator(".report-ready").wait_for(state="visible", timeout=15000)
image_bytes = page.locator(".report").screenshot(type="png", timeout=15000)

If images or fonts affect layout, wait for those assets as well. A short fixed delay can help with a known animation or delayed widget, but it is less reliable than waiting on an application signal. Disable animation or hide transient elements with page styles only when that matches the desired output.

6. Configuration choices that affect the result

Need Playwright choice Notes
Viewport size browser.new_page(viewport={"width": 1200, "height": 800}) Responsive layouts can change substantially with viewport dimensions.
Retina density device_scale_factor=2 in the browser context Produces more device pixels for a CSS-sized viewport and increases output size.
Full page page.screenshot(full_page=True) Captures beyond the current viewport; very tall documents can use significant memory.
One component page.locator(".target").screenshot() Wait for the selector and ensure it is visible and has nonzero dimensions.
Transparent background Use a page without an opaque background and a format supporting transparency, such as PNG JPEG does not preserve transparency.
Output file or bytes path="output.png" or capture return value Bytes fit a Flask response or object storage upload.
Browser engine Chromium, Firefox, or WebKit Install the selected browser binaries and use the engine that matches the rendering requirement.

Playwright’s screenshot API also documents image type and quality, page and element captures, and additional screenshot options. Confirm option availability and exact argument spelling against the version you install. A device preset can be useful when the capture should resemble a particular mobile viewport; otherwise an explicit viewport makes the layout dimensions clear.

7. Production concerns: lifecycle, concurrency, and security

The minimal example launches and closes Chromium within every Flask request. That is easy to understand, but browser startup has a cost and concurrent requests can create many browser processes. For sustained traffic, use a controlled browser lifecycle, a bounded queue or semaphore, and request timeouts. Reuse a browser process only with deliberate context and page isolation; do not share mutable page state across requests. Test the exact lifecycle with your worker model and shutdown hooks.

Flask request handlers are synchronous in many deployments. Long captures occupy a worker while the browser navigates and renders. For occasional captures this may be acceptable; for bursts or long-running jobs, move rendering to a background worker and return a job identifier. Put limits on input URLs, HTML size, navigation time, output dimensions, and concurrent browser work.

URL capture can create a server-side request forgery risk if callers can choose arbitrary destinations. Restrict allowed hosts and schemes, block private and link-local addresses, and consider network-level egress restrictions. Never expose secrets or privileged internal routes to arbitrary page content. If HTML comes from users, treat scripts and resource references as active content and render it in a constrained environment.

Reliability improves when the capture route has explicit timeouts, an application readiness selector, and a defined failure response. Close contexts and browsers even after exceptions, using try/finally or context managers as appropriate. Log a request identifier, destination host, capture duration, and failure category without logging sensitive HTML, cookies, or authorization headers.

8. Cache repeated captures and control cost

Browser rendering consumes CPU and memory, and repeated identical captures waste that work. If the source content changes on a known schedule, generate at publish time or cache the output by a key that includes the page/version, viewport, format, and relevant rendering options. Set a freshness policy that matches the source data. Invalidate when content or assets change. Avoid caching personalized output under a shared key.

For a hosted rendering API, account for external request latency, credentials, service terms, data transfer, and the provider’s charging model. The html2img documentation includes a Flask client example and says rendering endpoints consume credits; it recommends caching when repeated requests would otherwise trigger repeated renders. Check its live docs for current terms and limits before adopting it. Do not assume an API is cheaper or faster than self-hosting without workload-specific evidence.

9. When PDF is the right output

If the actual requirement is a printable document, Flask-WeasyPrint is a different tool path. Its Flask integration can resolve application URLs in a request context and generate a PDF. That documented integration is for PDF output; do not treat it as a direct PNG screenshot API. If you need a raster image, render the page in a browser or convert a PDF in a separate step with a suitable image conversion tool.

Likewise, the html2image Python package documents screenshots from URLs, HTML/CSS files, and HTML/CSS strings. It is another possible rendering route, but check its browser/runtime requirements against your environment. The available research does not establish a performance ranking among these options.

10. Troubleshooting common problems

Symptom Likely cause Fix
Browser executable missing Playwright package installed, browser binary absent from the runtime. Run python -m playwright install chromium in the deployment image/environment.
Navigation connection refused The capture process cannot reach the configured Flask host or port. Use a reachable internal URL and confirm bind address, port, container network, and authentication.
Blank or incomplete image Capture occurs before app content, fonts, or images are ready. Wait for a stable selector or app readiness signal; explicitly wait for critical assets.
Timeout with network idle Long polling, analytics, streaming, or background requests keep the network active. Use a different navigation milestone and wait for the specific content needed in the image.
Element not found Selector is wrong, content is conditional, or the component has not rendered. Check the page state, wait for the selector, and verify the selector matches one visible element.
Missing CSS, fonts, or images Relative URLs resolve against the wrong base or assets are unreachable. Capture a real route, use absolute asset URLs, or define an appropriate base URL.
Unexpected responsive layout Viewport differs from the intended design width. Set explicit viewport dimensions and device scale factor.
Worker exhaustion or slow responses Each request starts a browser or too many captures run at once. Bound concurrency, reuse browser infrastructure carefully, cache output, or render asynchronously.
Unexpected public data in output Capture route lacks the intended authorization/session state. Use controlled authentication state and ensure cache keys separate user-specific output.
A screenshot service can remove common overlays before capturing a page.
A screenshot service can remove common overlays before capturing a page.

11. Or skip the browser setup

ScreenshotNeo is a website screenshot API and MCP server. Its one-call endpoint can capture a URL as an image, while its MCP tools let AI agents take screenshots. It removes cookie banners, newsletter popups, and chat widgets before capture; bot checks, blank pages, and failed loads are never billed. One thousand screenshots a month are free with no card, and paid plans start at $5 for 3,000. See the ScreenshotNeo API documentation.

curl -G "https://api.screenshotneo.com/v1/shot" -d access_key=YOUR_API_KEY --data-urlencode url=https://your-site.example/card -o shot.webp

Replace the example URL with a page reachable by the service and supply your API key. This route captures a URL; it does not submit an arbitrary in-memory Flask template string. For private pages, review the API’s supported request options and handle credentials according to the docs.

import requests

r = requests.get(
    "https://api.screenshotneo.com/v1/shot",
    params={"access_key": "YOUR_API_KEY", "url": "https://your-site.example/card"},
    timeout=90,
)
open("shot.webp", "wb").write(r.content)
const q = new URLSearchParams({
  access_key: 'YOUR_API_KEY',
  url: 'https://your-site.example/card'
});
const res = await fetch(`https://api.screenshotneo.com/v1/shot?${q}`);
if (!res.ok) throw new Error(`Screenshot request failed: ${res.status}`);
await Bun.write('shot.webp', res);

The Node.js example uses Bun.write to save the response body; with Node.js, use await import('node:fs/promises').then(fs => fs.writeFile('shot.webp', Buffer.from(await res.arrayBuffer()))) instead. ScreenshotNeo returns a clean screenshot in PNG, JPEG, or WebP, or a PDF. The response includes page-verdict and billed headers, so you can distinguish capture outcomes. Try it with 1,000 free screenshots a month, with no card required.

12. FAQ

Can I generate an image without starting a local HTTP server?

Yes. Render the template to an HTML string and pass it to Playwright’s page content API. Provide inline styles or ensure external assets resolve with absolute URLs or an appropriate base URL.

Can I create a social preview at a fixed size?

Yes. Set an explicit viewport and capture the target card element. Keep the CSS dimensions and device scale factor intentional so output pixel dimensions match the consuming platform’s needs.

Should every image be regenerated on request?

No. If the underlying content is stable between updates, cache or pre-generate the image and refresh it when the source changes. This reduces repeated browser work and makes response time less dependent on rendering.

Does WeasyPrint directly create PNG screenshots?

The cited Flask integration documents PDF generation. Use a browser screenshot API for direct page raster capture, or add a separate PDF-to-image conversion stage if PDF is the required intermediate.

Sources