The most reliable way to convert an HTML table to an image in Python is to render the table in a real browser, then capture either the table element or the complete page with Playwright. Browser rendering preserves CSS, fonts, column sizing, borders, wrapping, and responsive behavior; parsing HTML and drawing cells yourself usually does not.
This guide shows a complete local workflow, including pandas-generated tables, asynchronous content, PNG/JPEG/WebP output, full-page captures, high-density scaling, transparency, scrollable containers, troubleshooting, and an API alternative.
Install Python and Playwright
Use a current Python 3 environment and install Playwright:
python -m pip install playwright
python -m playwright install chromium
The second command downloads the Chromium browser used by Playwright. Run it once for each environment (virtual environment, CI image, or container) in which you capture tables.
Recommended Free Tools
#1 Best Overall
Convert an existing HTML table to PNG
This synchronous example writes only the rendered <table> to table.png:
from playwright.sync_api import sync_playwright
html = """
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<style>
body { margin: 24px; font-family: Arial, sans-serif; }
table { border-collapse: collapse; width: 420px; }
th, td { border: 1px solid #cbd5e1; padding: 8px 10px; text-align: left; }
th { background: #0f172a; color: white; }
tr:nth-child(even) { background: #f8fafc; }
</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(viewport={"width": 900, "height": 700})
page.set_content(html, wait_until="load")
page.locator("#sales").screenshot(path="table.png")
browser.close()
Playwright’s screenshot API supports locator captures and PNG, JPEG, and WebP output. A locator screenshot follows the element’s rendered bounding box, so margins outside the table are not included.
Capture the whole page instead
Use a page screenshot when the table needs its title, explanatory text, or surrounding layout:
page.screenshot(path="page.png", full_page=True)
full_page=True creates a tall image covering the page’s full scrollable area. It is different from a viewport screenshot, which includes only what is currently visible.
Quick wins for a faster PC:
Scan for outdated or missing drivers - takes under a minuteDriver Scan →Clear out junk files and repair common Windows errorsFree Scan →Fix the driver behind crashes, sound loss and screen glitchesFind Drivers →Generate the table from pandas
DataFrame.to_html() creates table markup, while Styler.to_html() adds CSS and formatting. The browser must receive the resulting HTML before the screenshot is taken.
Rank #2
import pandas as pd
from playwright.sync_api import sync_playwright
df = pd.DataFrame({
"Product": ["Keyboard", "Mouse", "Monitor"],
"Units": [18, 42, 7],
"Revenue": [1299.50, 840.00, 2100.00],
})
# Plain table markup:
table_html = df.to_html(index=False, classes="report")
# For CSS-aware formatting, use this instead:
# table_html = df.style.format({"Revenue": "${:,.2f}"}).to_html()
html = f"""
{table_html}"""
with sync_playwright() as p:
browser = p.chromium.launch()
page = browser.new_page(viewport={"width": 1000, "height": 800})
page.set_content(html, wait_until="load")
page.locator("table.report").screenshot(path="pandas-table.png", type="png")
browser.close()
Use df.to_html() when the DataFrame’s values are enough. Use df.style.to_html() when you need number formats, conditional colors, gradients, or other Styler-generated CSS. pandas documents both the DataFrame HTML writer and Styler’s HTML/CSS output (DataFrame HTML documentation; Styler API).
Choose the image scope and format
| Requirement | Playwright choice | Result |
|---|---|---|
| Only one table | page.locator("table").screenshot(...) |
Element-sized image |
| One specific table | Use an ID, class, or CSS selector | Avoids capturing other tables |
| Entire document | page.screenshot(full_page=True) |
Full scrollable page |
| PNG | type="png" or omit type |
Lossless default; quality is not used |
| JPEG | type="jpeg", quality=85 |
Smaller, opaque image; quality is 0–100 |
| WebP | type="webp", quality=90 |
Modern compressed format |
| More pixels | scale="device" |
Device-pixel output instead of CSS-pixel sizing |
Playwright also supports clipping, background handling, and screenshot bytes. To upload the image without creating a temporary file, omit path:
image_bytes = page.locator("table").screenshot(type="webp", quality=90)
with open("table.webp", "wb") as f:
f.write(image_bytes)
Transparent backgrounds are available for page screenshots when the background is omitted, but JPEG cannot represent transparency. Use PNG or WebP when transparent output matters.
Do these 3 things before closing this tab:
1Fix the driver behind crashes, sound loss and screen glitches2Clear out junk files and repair common Windows errors3Scan for outdated or missing drivers - takes under a minuteWait for data, fonts, and remote styles
Capture only after the content that determines the table’s appearance is ready. For a table populated by JavaScript, wait for a selector or a meaningful row:
page.goto("https://example.com/report", wait_until="domcontentloaded")
page.locator("table#report tbody tr").first.wait_for(state="visible")
page.locator("table#report").screenshot(path="report.png")
If the page uses a known loading indicator, wait for it to disappear. If a web font changes column widths, wait for fonts before capture:
page.wait_for_function("document.fonts && document.fonts.status === 'loaded'")
A fixed delay such as page.wait_for_timeout(1000) can be a fallback, but a selector or state-based wait is less fragile. Playwright’s documentation describes the screenshot options and loading behavior; its Page and Locator APIs cover the relevant wait methods.
Handle tables inside scrolling containers
A locator screenshot captures the element’s rendered box. If a table is inside a container with overflow: auto, the visible scrollport may exclude rows that are outside it. Prefer a layout in which the table grows to its full height before capture:
Free tools Windows power users keep installed
One-click scans. No signup required.
page.locator("#table-wrapper").evaluate("el => { el.style.overflow = 'visible'; el.style.height = 'auto'; }")
page.locator("#table-wrapper table").screenshot(path="all-rows.png")
For virtualized tables, rows not currently rendered in the DOM cannot be photographed by any screenshot call. Disable virtualization or export a non-virtualized print view first. For a horizontally clipped table, increase the viewport or remove the container’s horizontal clipping before capturing.
Control dimensions, scaling, and layout
Set a deterministic viewport
Responsive CSS can produce different images on different machines. Set the viewport explicitly:
page = browser.new_page(viewport={"width": 1400, "height": 900}, device_scale_factor=1)
Use a larger width to prevent unwanted wrapping. Use device_scale_factor=2 or scale="device" when a higher-density image is required; the file will contain more pixels and may be larger.
Inject capture-only CSS
Hide controls, constrain widths, or set print colors without changing your source page:
The Tool Desk
Outbyte PC Repair FREERepair Windows errors before they cause bigger problemsFix Now →Outbyte Driver Updater FREEScan for outdated or missing drivers - takes under a minuteDriver Scan →page.add_style_tag(content="""
.export-button, .cookie-banner { display: none !important; }
table { break-inside: avoid; }
* { print-color-adjust: exact; }
""")
Keep table content accessible and semantic in the HTML; capture-time CSS should change presentation, not data.
Save a screenshot for an existing web URL
For a remote page, navigate first and then select the table:
from playwright.sync_api import sync_playwright
with sync_playwright() as p:
browser = p.chromium.launch()
page = browser.new_page(viewport={"width": 1280, "height": 900})
page.goto("https://example.com/report", wait_until="networkidle")
page.locator("table#report").screenshot(path="report.png")
browser.close()
networkidle can be unsuitable for pages with long-lived analytics or streaming requests. In that case, use domcontentloaded and wait for the table’s actual ready condition.
Common errors and fixes
- “Executable doesn’t exist” or browser launch failure: run
python -m playwright install chromiumin the same environment as your script. - “Locator resolved to hidden or empty content”: verify the selector, wait for the table, and check that JavaScript has inserted rows.
- Missing CSS or images: use an absolute URL for remote assets, allow the page to finish loading, and wait for the relevant selector or font.
- Screenshot is cropped: capture the table locator rather than a parent with fixed dimensions; remove clipping and overflow rules when all rows are required.
- Text wraps differently in CI: set the viewport, install the same fonts, and use a fixed device scale factor.
- Blank image: inspect the HTML with
page.locator("table").count()and savepage.content()for debugging before capture. - JPEG has an unexpected background: JPEG is opaque; choose PNG or WebP for transparency.
- Very tall image fails or is unwieldy: capture the table in sections, create a print/PDF layout, or reduce unnecessary row spacing and pixel scale.
Performance, reliability, and repeatability
- Reuse one browser process for multiple tables or URLs, creating a new page per capture.
- Set explicit timeouts and close pages in a
finallyblock in production jobs. - Prefer selector-based waits over arbitrary sleeps.
- Use PNG for archival fidelity, WebP for smaller modern assets, and JPEG for photographic pages where minor compression is acceptable.
- Record the viewport, browser version, CSS, and input data when image diffs must be reproducible.
- For untrusted HTML, isolate the browser context and avoid granting unnecessary file or network access.
Or skip the browser setup
ScreenshotNeo is a website screenshot API and MCP server. It renders a URL remotely, removes cookie banners, newsletter popups, and chat widgets before capture, and bills only clean shots: bot checks or CAPTCHAs, blank pages, timeouts, failed loads, and cache hits are not billed. Its response identifies the result with X-Page-Verdict and X-Billed headers.
For a table hosted at a public URL, one GET request returns an image. The API accepts PNG, JPEG, or WebP and supports full-page or element capture, custom CSS and JavaScript, waits, device presets, retina scale, headers, cookies, authentication, and other capture controls. See the ScreenshotNeo API documentation.
Best Value
cURL
curl -G "https://api.screenshotneo.com/v1/shot" -d access_key=YOUR_API_KEY --data-urlencode url=https://example.com/report -o report.webp
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("report.webp", "wb").write(r.content)
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(`${res.status} ${res.statusText}`);
const body = Buffer.from(await res.arrayBuffer());
require('fs').writeFileSync('report.webp', body);
ScreenshotNeo also offers an MCP server with take_screenshot, get_page_info, and capture_pdf tools for Claude, Cursor, and other MCP clients. Every feature is on every plan: 1,000 shots per month are free with no card; paid plans start at $5 for 3,000 shots. Create a free ScreenshotNeo account.
Which method should you use?
| Situation | Best choice |
|---|---|
| HTML or pandas data is generated in your Python process | Playwright with set_content() |
| You need the exact table element and local CSS | Playwright locator screenshot |
| You need surrounding headings and page context | Playwright full-page screenshot |
| The page is public and you do not want browser installation or maintenance | ScreenshotNeo API |
| An AI agent must perform captures | ScreenshotNeo MCP server |
Frequently Asked Questions
Can I convert an HTML table without a browser?
You can redraw cells with a graphics library, but that is a different rendering system and will not automatically reproduce browser CSS. Use Playwright when browser fidelity matters.
Can Playwright capture only one table on a page?
Yes. Target it with a locator such as page.locator("#sales") and call screenshot().
Why are some rows missing from my image?
The table may be inside a scrollable or virtualized container. Make the full table render and remove clipping before capture.
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.

