Converting 100 SVGs to PNGs on Windows: The Selenium Headless Trick
Table of Contents
We had 100 SVG artworks generated by quantum computers for the Quantum Genesis collection. We needed PNGs to upload to IPFS (SVG support varies across NFT platforms and wallets). On Linux or macOS, you'd use cairosvg and be done in five minutes.
We were on Windows. It took considerably longer than five minutes.
This post documents the problem, the failed attempts, and the solution that actually worked: using Selenium with headless Chrome to screenshot SVGs into pixel-perfect PNGs.
The Problem: SVG to PNG on Windows
SVG (Scalable Vector Graphics) is an XML-based format. Converting it to a raster format like PNG requires an SVG rendering engine — something that can parse the XML, execute the gradient definitions, apply filters, composite the layers, and output pixels.
The Python ecosystem's go-to library is cairosvg. It wraps the Cairo 2D graphics library and produces excellent PNG output. One line of code:
# This works beautifully on Linux and macOS
import cairosvg
cairosvg.svg2png(url="input.svg", write_to="output.png", output_width=1000)
On Windows, you get this:
OSError: no library called "cairo-2" was found
no library called "cairo" was found
no library called "libcairo-2" was found
Cairo is a C library. On Linux, it's available through package managers (apt install libcairo2-dev). On macOS, brew install cairo. On Windows, there's no straightforward installation path. You need to:
- Download pre-compiled Cairo DLLs (from GTK's Windows binaries or MSYS2)
- Add them to your system PATH
- Hope the specific DLL version matches what the Python binding expects
- Deal with missing dependencies (libpng, zlib, pixman, fontconfig, freetype)
We spent an hour on this before giving up. Life is too short for DLL hell.
Alternatives We Tried (and Why They Failed)
1. Inkscape Command Line
# Inkscape can convert SVG to PNG
inkscape input.svg --export-type=png --export-filename=output.png
This works, but Inkscape takes 3-5 seconds to start up for each file. For 100 files, that's 5-8 minutes of just waiting for Inkscape to load. Plus, Inkscape's SVG rendering doesn't always match browser rendering — our gradient filters looked different.
2. svglib + reportlab
from svglib.svglib import svg2rlg
from reportlab.graphics import renderPM
drawing = svg2rlg("input.svg")
renderPM.drawToFile(drawing, "output.png", fmt="PNG")
Pure Python, no native dependencies. But svglib has limited SVG support. Our artworks use feTurbulence filters, radial gradients with spreadMethod, and stroke-linecap attributes that svglib simply ignores. The output was missing half the visual elements.
3. Wand (ImageMagick binding)
from wand.image import Image
with Image(filename="input.svg") as img:
img.save(filename="output.png")
Requires ImageMagick installed on Windows. ImageMagick's SVG renderer (RSVG or its built-in) also struggled with our filter effects. The noise texture overlay was completely missing.
4. Puppeteer / Playwright
These are Node.js tools, and they work great. But our entire pipeline was Python. Adding a Node.js dependency just for SVG conversion felt wrong. We wanted a Python-native solution.
Which brought us to Selenium.
The Selenium Solution
The insight: Chrome's SVG renderer is the best SVG renderer that exists. Every web browser has spent decades perfecting SVG rendering. If we can get Chrome to render our SVG and then take a screenshot, we get pixel-perfect output.
Selenium drives a browser programmatically. With headless mode, Chrome runs without a visible window. The approach:
- Launch headless Chrome with Selenium
- Open the SVG file as a local file URL
- Set the viewport to match the SVG dimensions
- Take a screenshot
- Save as PNG
Chrome renders every SVG feature we use: gradients, filters, bezier curves, opacity blending, feTurbulence noise. The output matches what you'd see in a browser tab.
Quantum Genesis #50 — converted from SVG to PNG using headless Chrome. Every gradient and filter rendered perfectly.
Setup: Chrome + ChromeDriver + Selenium
You need three things:
1. Google Chrome (already installed on most Windows machines)
2. Selenium for Python
pip install selenium
3. ChromeDriver (automatic since Selenium 4.6+)
Modern Selenium includes selenium-manager, which automatically downloads the correct ChromeDriver version. You don't need to manually download anything:
from selenium import webdriver
from selenium.webdriver.chrome.options import Options
# Selenium 4.6+ handles ChromeDriver automatically
options = Options()
options.add_argument("--headless=new") # New headless mode (Chrome 112+)
driver = webdriver.Chrome(options=options) # Auto-downloads chromedriver
If you're on an older Selenium version, install webdriver-manager:
pip install webdriver-manager
from selenium.webdriver.chrome.service import Service
from webdriver_manager.chrome import ChromeDriverManager
service = Service(ChromeDriverManager().install())
driver = webdriver.Chrome(service=service, options=options)
Converting a Single SVG
Here's the complete code for converting one SVG to PNG:
import os
from selenium import webdriver
from selenium.webdriver.chrome.options import Options
def svg_to_png(svg_path: str, png_path: str, width: int = 1000, height: int = 1000):
"""Convert an SVG file to PNG using headless Chrome."""
# Configure headless Chrome
options = Options()
options.add_argument("--headless=new")
options.add_argument("--disable-gpu")
options.add_argument("--no-sandbox")
options.add_argument("--disable-dev-shm-usage")
options.add_argument(f"--window-size={width},{height}")
# Force device scale factor to 1 (prevents HiDPI scaling)
options.add_argument("--force-device-scale-factor=1")
driver = webdriver.Chrome(options=options)
try:
# Set viewport size exactly
driver.set_window_size(width, height)
# Open the SVG as a file:// URL
abs_path = os.path.abspath(svg_path)
file_url = f"file:///{abs_path.replace(os.sep, '/')}"
driver.get(file_url)
# Remove any default margins/padding Chrome adds
driver.execute_script("""
document.body.style.margin = '0';
document.body.style.padding = '0';
document.body.style.overflow = 'hidden';
var svg = document.querySelector('svg');
if (svg) {
svg.style.display = 'block';
svg.style.width = '100%';
svg.style.height = '100%';
}
""")
# Take screenshot
driver.save_screenshot(png_path)
print(f"Converted: {svg_path} -> {png_path}")
finally:
driver.quit()
# Usage
svg_to_png("quantum_genesis_001.svg", "quantum_genesis_001.png")
Key details:
--headless=new: Chrome's new headless mode (since Chrome 112) renders identically to headed mode. The old--headlessflag used a different rendering path that could produce visual differences.--force-device-scale-factor=1: On HiDPI/Retina displays, Chrome would render at 2x resolution by default. This forces 1:1 pixel mapping.- The JavaScript injection removes Chrome's default body margins, which would otherwise add 8px of white space around the SVG.
driver.quit()in a finally block ensures Chrome closes even if an error occurs. Leftover Chrome processes are a common leak.
Batch Processing 100 Files
Converting 100 files by starting and stopping Chrome 100 times is slow (~2 seconds per start/stop). Instead, we reuse a single browser instance:
import os
import time
from selenium import webdriver
from selenium.webdriver.chrome.options import Options
def batch_svg_to_png(
input_dir: str,
output_dir: str,
width: int = 1000,
height: int = 1000
):
"""Batch convert all SVGs in a directory to PNG."""
os.makedirs(output_dir, exist_ok=True)
# Get sorted list of SVG files
svg_files = sorted([
f for f in os.listdir(input_dir) if f.endswith('.svg')
])
total = len(svg_files)
print(f"Found {total} SVG files to convert")
# Configure headless Chrome ONCE
options = Options()
options.add_argument("--headless=new")
options.add_argument("--disable-gpu")
options.add_argument("--no-sandbox")
options.add_argument("--disable-dev-shm-usage")
options.add_argument(f"--window-size={width},{height}")
options.add_argument("--force-device-scale-factor=1")
# Disable animations (prevents inconsistent screenshots)
options.add_experimental_option("prefs", {
"webkit.webprefs.animation_policy": 2 # IMAGE_ANIMATION_POLICY_NO_ANIMATION
})
driver = webdriver.Chrome(options=options)
driver.set_window_size(width, height)
start_time = time.time()
converted = 0
errors = 0
try:
for i, svg_file in enumerate(svg_files, 1):
svg_path = os.path.join(input_dir, svg_file)
png_file = svg_file.replace('.svg', '.png')
png_path = os.path.join(output_dir, png_file)
try:
# Load SVG
abs_path = os.path.abspath(svg_path)
file_url = f"file:///{abs_path.replace(os.sep, '/')}"
driver.get(file_url)
# Remove margins, ensure SVG fills viewport
driver.execute_script("""
document.body.style.margin = '0';
document.body.style.padding = '0';
document.body.style.overflow = 'hidden';
document.body.style.background = 'transparent';
var svg = document.querySelector('svg');
if (svg) {
svg.style.display = 'block';
svg.style.width = '100%';
svg.style.height = '100%';
}
""")
# Brief pause for rendering (filters need time)
time.sleep(0.1)
# Screenshot
driver.save_screenshot(png_path)
converted += 1
# Progress
elapsed = time.time() - start_time
rate = converted / elapsed
remaining = (total - i) / rate if rate > 0 else 0
print(f"[{i}/{total}] {svg_file} -> {png_file} "
f"({rate:.1f}/s, ~{remaining:.0f}s remaining)")
except Exception as e:
errors += 1
print(f"[{i}/{total}] ERROR: {svg_file}: {e}")
finally:
driver.quit()
elapsed = time.time() - start_time
print(f"\nDone: {converted} converted, {errors} errors, {elapsed:.1f}s total")
# Usage
batch_svg_to_png(
input_dir="nft-output/svg",
output_dir="nft-output/png",
width=1000,
height=1000
)
Performance with a single reused browser instance: ~0.3 seconds per file, or about 30 seconds for all 100 SVGs. That's 6-7x faster than starting a new Chrome instance per file.
Quantum Genesis #88 — one of 100 SVGs batch-converted in ~30 seconds total.
Quality Considerations
Resolution
Our SVGs are 1000x1000. The PNG screenshots are also 1000x1000 — a 1:1 mapping. For higher resolution output, you can scale:
# For 2x resolution (2000x2000 PNG from 1000x1000 SVG):
options.add_argument("--force-device-scale-factor=2")
driver.set_window_size(1000, 1000) # Viewport stays 1000x1000
# Screenshot will be 2000x2000 pixels
The SVG scales perfectly (it's vector), so you get crisp 2x output without any interpolation artifacts.
Color Accuracy
Chrome uses sRGB color space by default, which is what most displays and web platforms expect. Our HSL colors are rendered correctly. If you need a specific color profile, you can force it:
options.add_argument("--force-color-profile=srgb")
Transparency
The save_screenshot() method produces a PNG with a white background. If your SVG has transparency, the white background will show through. For transparent PNGs, you need an extra step with Pillow (see next section).
Our artworks all have opaque backgrounds (dark gradients), so this wasn't an issue.
The 0.1-Second Pause
We add time.sleep(0.1) before taking the screenshot. This gives Chrome time to finish rendering SVG filters (like feTurbulence), which are computationally expensive. Without this pause, some screenshots captured partially-rendered noise textures. 100ms was enough for our 1000x1000 SVGs; more complex SVGs might need longer.
Post-Processing with Pillow
After conversion, we used Pillow for optional post-processing:
from PIL import Image
import os
def post_process_png(png_path: str, output_path: str = None):
"""Optional post-processing: optimize file size."""
if output_path is None:
output_path = png_path
img = Image.open(png_path)
# Verify dimensions
assert img.size == (1000, 1000), f"Unexpected size: {img.size}"
# Convert RGBA to RGB (removes alpha channel, reduces file size)
if img.mode == 'RGBA':
background = Image.new('RGB', img.size, (0, 0, 0))
background.paste(img, mask=img.split()[3])
img = background
# Save with optimization
img.save(output_path, 'PNG', optimize=True)
original_size = os.path.getsize(png_path)
new_size = os.path.getsize(output_path)
saved = ((original_size - new_size) / original_size) * 100
print(f"Optimized: {original_size:,} -> {new_size:,} bytes ({saved:.1f}% smaller)")
# Batch post-process
for f in sorted(os.listdir("nft-output/png")):
if f.endswith('.png'):
post_process_png(os.path.join("nft-output/png", f))
Pillow's PNG optimization typically reduced file sizes by 10-25% without any visual quality loss. For our 100 files, this saved about 15MB of total IPFS storage.
Verifying Output
We also used Pillow to verify that no screenshots were blank (all black or all white), which can happen if Chrome fails to load the SVG:
from PIL import Image, ImageStat
def verify_png(png_path: str) -> bool:
"""Check that the PNG isn't blank."""
img = Image.open(png_path)
stat = ImageStat.Stat(img)
# Check if all channels have very low variance (= solid color)
if all(s < 1.0 for s in stat.stddev):
print(f"WARNING: {png_path} appears to be a solid color!")
return False
# Check file size (blank PNGs are very small)
size = os.path.getsize(png_path)
if size < 5000: # Less than 5KB is suspicious for 1000x1000
print(f"WARNING: {png_path} is suspiciously small ({size} bytes)")
return False
return True
Summary
The complete pipeline for converting 100 quantum-generated SVGs to PNGs on Windows:
- Install:
pip install selenium Pillow - Convert: Headless Chrome via Selenium (reuse one browser instance)
- Post-process: Pillow for optimization and verification
- Upload: Optimized PNGs to IPFS via Pinata
Total time for 100 files: ~45 seconds (30s conversion + 15s post-processing).
The Selenium approach isn't elegant, but it produces output identical to what Chrome displays in a browser tab. When your SVGs use advanced features (filters, complex gradients, blend modes), browser-based rendering is the only reliable option on Windows.
All 100 PNGs are stored permanently on IPFS and viewable through our Pinata gateway. Every piece of art in the collection went through exactly this pipeline.
Desenvolvo sistemas que usam hardware quântico real (IBM Quantum, Origin Quantum) para gerar arte e NFTs com proveniência verificável on-chain. Escrevo sobre computação quântica aplicada, criptografia, Web3 e arte generativa — tudo com código que roda em processadores quânticos de verdade.
Comentários
Postar um comentário