Converting 100 SVGs to PNGs on Windows: The Selenium Headless Trick

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:

  1. Download pre-compiled Cairo DLLs (from GTK's Windows binaries or MSYS2)
  2. Add them to your system PATH
  3. Hope the specific DLL version matches what the Python binding expects
  4. 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:

  1. Launch headless Chrome with Selenium
  2. Open the SVG file as a local file URL
  3. Set the viewport to match the SVG dimensions
  4. Take a screenshot
  5. 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 NFT #50 - converted from SVG to PNG using the Selenium method

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 --headless flag 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 NFT #88 - batch converted from SVG using Selenium

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:

  1. Install: pip install selenium Pillow
  2. Convert: Headless Chrome via Selenium (reuse one browser instance)
  3. Post-process: Pillow for optimization and verification
  4. 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.

@media (prefers-color-scheme: dark) { background:#1a1a2e; border-color:#2d2d4a; box-shadow:0 1px 3px rgba(0,0,0,0.3); }
Marcelo Santos
Marcelo Santos
Engenheiro Quântico • Artista Generativo • Founder Quantum Art Lab

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

Postagens mais visitadas deste blog

How to List NFTs on OpenSea: The Complete 2026 Guide

Polygon vs Ethereum for NFTs: Why We Chose Polygon (and Saved $490)

Quantum Error Correction Explained: Why Your Qubits Need Backup