Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

Use a real browser renderer, not string manipulation, to turn an HTML table into an image in Python. Playwright renders the table with its CSS, then captures either the table element or the entire page as PNG, JPEG, or WebP. For a pandas table, generate HTML with DataFrame.to_html() or Styler.to_html(), load it in Playwright, wait for the content and assets you need, and call a screenshot method.

What you need

The examples use Playwright’s synchronous Python API. Install the package and its Chromium browser before running a capture:

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

Playwright’s screenshot API is documented at playwright.dev/python/docs/screenshots. A screenshot is the browser-rendered result, so the browser must be able to load the HTML, CSS, fonts and images that you expect to see.

  • Use an element (locator) screenshot when the output should contain one table.
  • Use a full-page screenshot when the table belongs in the context of the surrounding page.
  • Use PNG for lossless text and lines, JPEG for a smaller photographic-style file, or WebP when you want the format’s quality controls and compact output.

Convert an existing HTML table to PNG

This is the smallest complete example. page.set_content() puts the HTML into a browser document, and the locator targets the table rather than the whole viewport.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.
from playwright.sync_api import sync_playwright

html = """
<!doctype html>
<html>
  <head>
    <meta charset="utf-8">
    <style>
      table { border-collapse: collapse; font-family: Arial, sans-serif; }
      th, td { border: 1px solid #cbd5e1; padding: 8px 12px; text-align: left; }
      th { background: #f1f5f9; }
    </style>
  </head>
  <body>
    <table id="sales">
      <thead><tr><th>Fruit</th><th>Count</th></tr></thead>
      <tbody><tr><td>Apples</td><td>12</td></tr></tbody>
    </table>
  </body>
</html>
"""

with sync_playwright() as p:
    browser = p.chromium.launch()
    page = browser.new_page()
    page.set_content(html)
    page.locator("#sales").screenshot(path="table.png")
    browser.close()

The result is table.png, cropped to the table’s rendered bounding box. A locator should identify the intended element; an ID such as #sales is less ambiguous than a broad selector when a page contains several tables.

Build the table from a pandas DataFrame

pandas provides two useful HTML paths. DataFrame.to_html() renders the data as a table. df.style.to_html() emits the Styler-generated HTML and CSS, which is the better choice when you need formatting such as number formats, colors or conditional styles. pandas documents both APIs in its HTML output guide and the Styler reference.

import pandas as pd
from playwright.sync_api import sync_playwright

df = pd.DataFrame({
    "Fruit": ["Apples", "Oranges", "Pears"],
    "Count": [12, 8, 19],
    "Revenue": [42.50, 31.25, 58.10],
})

# Basic table markup:
# table_html = df.to_html(index=False)

# Styled markup, including CSS generated by Styler:
styler = (
    df.style
      .format({"Revenue": "${:,.2f}"})
      .set_caption("Weekly fruit sales")
)
table_html = styler.to_html()

html = f"""
<!doctype html>
<html>
  <head>
    <meta charset="utf-8">
    <style>
      body {{ margin: 24px; background: white; }}
      table {{ border-collapse: collapse; font-family: system-ui, sans-serif; }}
    </style>
  </head>
  <body>{table_html}</body>
</html>
"""

with sync_playwright() as p:
    browser = p.chromium.launch()
    page = browser.new_page(viewport={"width": 1200, "height": 800})
    page.set_content(html)
    page.locator("table").screenshot(path="sales.png")
    browser.close()

Set index=False with to_html() when the DataFrame index should not become an extra image column. Styler may create generated class names, so target the table itself or a stable wrapper rather than depending on one generated class.

Capture one table or the whole page

These two calls answer different visual requirements:

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.
Goal Playwright call What appears in the image
Focused table asset page.locator("table").screenshot(path="table.png") The matched element’s rendered area.
Page context page.screenshot(path="page.png", full_page=True) The complete scrollable page, including content above and below the viewport.
In-memory processing page.locator("table").screenshot() Image bytes instead of a file, ready for upload or post-processing.

The page screenshot API supports PNG, JPEG and WebP output. PNG is the default. JPEG quality settings do not affect PNG, and WebP quality 100 is lossless according to the API documentation. Choose a device-pixel scale when a higher-density image is required; CSS-pixel scale produces dimensions closer to the page’s CSS layout. The screenshot API also supports clipping and background controls. Omitting the background can make a page capture transparent where supported, but JPEG cannot represent transparency.

Choose a predictable viewport

Browser layout responds to viewport width. Set it explicitly when a line break, column width or responsive breakpoint matters:

page = browser.new_page(viewport={"width": 1440, "height": 900}, device_scale_factor=2)

A larger device scale factor produces a larger raster while retaining the same CSS layout. Keep the viewport and scale fixed for repeatable assets.

Wait for content, styles and assets

Capture only after the material that affects the table has loaded. Static markup can use set_content() directly. For a page that fills the table with JavaScript, wait for a specific selector or condition rather than relying on an arbitrary sleep:

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.
page.goto("https://example.com/report", wait_until="domcontentloaded")
page.locator("table#report").wait_for(state="visible")
page.locator("table#report tbody tr").first.wait_for(state="visible")
page.locator("table#report").screenshot(path="report.png")

Use a delay only when the page has a known animation or delayed render that cannot be observed with a selector. If external fonts or images change dimensions, wait for those resources or for a page-specific ready marker before taking the screenshot. The correct load condition depends on how that page works; Playwright’s screenshot documentation and Page and Locator API reference describe the available page and locator methods.

Make a reusable conversion function

This function accepts complete HTML, optionally selects a table, and returns image bytes. Returning bytes avoids a temporary file when the next operation is an object-storage upload, HTTP response or image transformation.

from pathlib import Path
from typing import Optional
from playwright.sync_api import sync_playwright

def html_table_image(
    html: str,
    output: Optional[str] = None,
    selector: str = "table",
    image_type: str = "png",
    quality: Optional[int] = None,
    full_page: bool = False,
) -> bytes:
    with sync_playwright() as p:
        browser = p.chromium.launch()
        try:
            page = browser.new_page(
                viewport={"width": 1400, "height": 900},
                device_scale_factor=1,
            )
            page.set_content(html)
            if full_page:
                options = {"type": image_type, "full_page": True}
                if quality is not None and image_type in {"jpeg", "webp"}:
                    options["quality"] = quality
                image = page.screenshot(**options)
            else:
                locator = page.locator(selector)
                locator.wait_for(state="visible")
                options = {"type": image_type}
                if quality is not None and image_type in {"jpeg", "webp"}:
                    options["quality"] = quality
                image = locator.screenshot(**options)
            if output:
                Path(output).write_bytes(image)
            return image
        finally:
            browser.close()

# Example: save and also retain the bytes for another operation.
image_bytes = html_table_image(
    table_html,
    output="sales.webp",
    selector="table",
    image_type="webp",
    quality=90,
)
print(f"Wrote {len(image_bytes)} bytes")

The quality argument is meaningful for JPEG and WebP, not PNG. For a whole-page image, set full_page=True; the function then ignores the element selector because it captures the page’s full scrollable area.

Handle large and scrollable tables

A locator screenshot targets the matched element, but an element inside a scrollable container can expose only the container’s currently visible content. If rows are hidden behind an internal scrollbar, the image may omit them. Before capture, prefer a layout in which the table expands to its full content, or capture an appropriate outer element after changing the container’s scroll behavior. A full-page screenshot captures the page’s scrollable area, but it does not automatically expand an independently scrolling table container.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

For very wide tables, set a viewport wide enough to avoid unwanted responsive wrapping, or deliberately capture the wrapper that provides horizontal context. Check the resulting pixel dimensions and inspect the image once: a successful API call can still produce a visually clipped table if the CSS layout itself clips content.

Capture a remote page with its existing CSS

When the table already lives on a URL, navigate to that URL rather than copying its markup. Supply any required authentication or headers using Playwright’s browser-context facilities, then wait for the table’s ready state:

from playwright.sync_api import sync_playwright

with sync_playwright() as p:
    browser = p.chromium.launch()
    context = browser.new_context(viewport={"width": 1365, "height": 900})
    page = context.new_page()
    page.goto("https://example.com/report", wait_until="networkidle")
    table = page.locator("table#report")
    table.wait_for(state="visible")
    table.screenshot(path="remote-report.png", type="png")
    browser.close()

networkidle can be useful for a page whose data and styles are loaded through requests, but a page-specific selector is usually a stronger indication that the table is ready. Do not use a global network-idle assumption if the site keeps analytics or live connections open indefinitely.

Troubleshooting common failures

Browser executable is missing

Symptom: Playwright raises an error about a missing Chromium executable. Fix: run python -m playwright install chromium in the same environment where the script runs. In a container or CI job, install the browser during the image or job setup.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

The selector matches nothing

Symptom: a locator screenshot times out. Fix: inspect the actual DOM selector, wait for the page or table to load, and verify that the table is not inside an iframe. If it is in an iframe, obtain the corresponding frame locator before selecting the table.

The image is blank or missing rows

Symptom: the file exists but contains an empty table or only the visible portion. Fix: wait for the row or a page-specific ready marker, check for an internal scroll container, and ensure that the data-producing JavaScript completed before capture. For lazy-loaded content, scroll or otherwise trigger the page’s loading behavior before taking the screenshot.

Styles or fonts differ from the browser

Symptom: the table has default fonts, wrong widths or unstyled cells. Fix: include the stylesheet in the HTML passed to set_content(), use a URL that can reach its CSS and font resources, or wait until those resources have loaded. A screenshot reflects what the browser actually rendered, not the CSS you intended to load.

The table is clipped

Symptom: columns or rows are cut off at the edge. Fix: increase the viewport, capture the table’s outer wrapper, remove restrictive overflow for the capture, or use full_page=True when the content is page-scrolling rather than container-scrolling.

What’s actually slowing this PC down?

Pick the symptom - the matching free tool is one click away.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

JPEG transparency does not work

Symptom: a transparent-background request produces an opaque JPEG. Fix: use PNG or WebP for transparency; JPEG does not support an alpha channel.

Reliability, performance and repeatability

  • Reuse a browser process for batches. Launching Chromium for every table adds startup overhead. Keep one browser open, create isolated contexts or pages per job, and close them when the batch ends.
  • Keep capture conditions fixed. Set the viewport, device scale factor, color-sensitive CSS and target selector explicitly so that a changed screen size does not alter wrapping.
  • Use deterministic readiness checks. Waiting for the actual table or a known row is more reliable than a fixed sleep, especially when network speed varies.
  • Control external dependencies. A remote font, image or stylesheet can change the result or fail independently of the table. Self-contained HTML and inline CSS are easier to reproduce.
  • Validate output dimensions. Store the returned bytes or inspect the file size and pixel dimensions before publishing an image. This catches an unexpectedly empty or clipped render early.

For a one-off local conversion, the minimal script is sufficient. For a service, isolate untrusted pages, impose your own timeouts, close pages in a finally block and retain the returned bytes only as long as your workflow needs them.

Independent reader supportYour contribution helps us test, update, and keep practical guides available for everyone.Support on Ko-Fi

Or skip the browser setup

ScreenshotNeo is a website screenshot API and MCP server for developers. It accepts one GET request and returns a PNG, JPEG, WebP or PDF. Before capture it accepts cookie and consent banners like a visitor and removes more than 60 known consent platforms, newsletter popups and chat widgets; each step can be turned off. Bot checks and CAPTCHAs, blank pages, timeouts, failed loads and cache hits are not billed, and the response identifies the result with X-Page-Verdict and X-Billed headers. The request still needs a publicly reachable page containing the table.

For a hosted table URL, the simplest call is:

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

The same request in Python:

import requests

r = requests.get(
    "https://api.screenshotneo.com/v1/shot",
    params={"access_key": "YOUR_API_KEY", "url": "https://example.com/report"},
    timeout=90,
)
r.raise_for_status()
open("table.webp", "wb").write(r.content)

And in Node.js:

const q = new URLSearchParams({ access_key: 'YOUR_API_KEY', url: 'https://example.com/report' });
const res = await fetch(`https://api.screenshotneo.com/v1/shot?${q}`);
if (!res.ok) throw new Error(`Screenshot failed: ${res.status}`);
const fs = await import('node:fs/promises');
await fs.writeFile('table.webp', Buffer.from(await res.arrayBuffer()));

See the complete option names and authentication details in the ScreenshotNeo documentation. Its 63 options include full-page capture with lazy images loaded, CSS-selector element capture, dark mode, device presets or custom viewports, retina scale, PDF controls, custom CSS and JavaScript, click and wait actions, ad and tracker blocking, custom headers, cookies, user agents and authorization, timezone and geolocation, transparent backgrounds, resizing, configurable caching, signed links, asynchronous jobs with signed webhooks, bulk capture of up to 100 URLs per call, a usage API and an OpenAPI specification. Parameter names used by other screenshot APIs also work when switching.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

ScreenshotNeo also provides an MCP server with take_screenshot, get_page_info and capture_pdf tools for Claude, Cursor and other MCP clients, so an AI agent can request the capture without you managing a browser process. Every feature is included on every plan: the Free plan includes 1,000 shots per month with no card, Starter is $5 for 3,000, Growth $15 for 15,000, Pro $39 for 60,000, Scale $99 for 250,000 and Business $249 for 1,000,000; yearly billing gives two months free.

Create a free ScreenshotNeo account to use the 1,000 monthly shots without a card.

Frequently Asked Questions

Can I capture several tables from one HTML document?

Yes. Give each table a stable selector and call locator.screenshot() for each one while keeping the same page and browser context. This preserves one consistent viewport and stylesheet environment across the resulting files.

How can I include a caption or note with the table image?

Wrap the table and its caption in a containing element, then screenshot that wrapper instead of the table locator. The wrapper’s padding, background and typography will be rendered along with the table.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

Is the result an image of the HTML source?

No. It is a rasterization of the browser’s final layout. CSS, loaded fonts, responsive rules, JavaScript-generated rows and the chosen viewport all affect the pixels in the output.

The Bottom Line

For faithful Python conversion, render the HTML in Playwright, wait for the table’s real ready state, and choose a locator screenshot for a focused table or full_page=True for page context. Generate pandas markup with to_html() or Styler.to_html(), set the viewport deliberately, and account for internal scrolling before saving the image.

Product prices and availability are accurate as of the date/time indicated and are subject to change. Any price and availability information displayed on Amazon at the time of purchase will apply.