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.
#1 Best Overall
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:
| 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:
Rank #2
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:
Do these 3 things before closing this tab:
1Repair Windows errors before they cause bigger problems2Fix the driver behind crashes, sound loss and screen glitches3Clear out junk files and repair common Windows errorspage.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.
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.
Quick wins for a faster PC:
Repair Windows errors before they cause bigger problemsFix Now →Scan for outdated or missing drivers - takes under a minuteDriver Scan →Clear out junk files and repair common Windows errorsFree Scan →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.
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.
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.
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.
Best Value
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.
PC Slower Than It Used to Be?
A free scan shows the junk files, broken settings and background clutter dragging Windows down - then fixes them in one click.Free scan · Windows 10 & 11Outdated Drivers Are Slowing You Down
One free scan finds every outdated or missing driver and matches the right update for your exact hardware.Free scan · exact hardware matchIs 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.
Quick Recap
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.

