Today, I needed to turn SVGs into PNGs. I decided to use Deno to do it. Some cursory searching showed Puppeteer should be up to the task. I also found deno-puppeteer which seemed like it would provide a reasonable way to make this work.

To start, let’s set up a deno project

deno init deno-browser-screenshots
deno-browser-screenshots

Using puppeteer#

Now, add some code to render an SVG with Chrome via puppeteer.

import puppeteer from "https://deno.land/x/[email protected]/mod.ts";

const svgString = `
<svg width="512" height="512" xmlns="http://www.w3.org/2000/svg">
  <rect width="100%" height="100%" fill="#87CEEB"/>
  <circle cx="256" cy="256" r="100" fill="#FFD700"/>
  <path d="M 100 400 Q 256 300 412 400" stroke="#1E90FF" stroke-width="20" fill="none"/>
</svg>`;

if (import.meta.main) {
  try {
    const browser = await puppeteer.launch({
      headless: true,
      args: ["--no-sandbox"],
    });

    const page = await browser.newPage();

    await page.setViewport({ width: 512, height: 512 });
    await page.setContent(svgString);

    await page.screenshot({
      path: "output.png",
      clip: {
        x: 0,
        y: 0,
        width: 512,
        height: 512,
      },
    });

    await browser.close();
  } catch (error) {
    console.error("Error occurred:", error);
    console.error("Make sure Chrome is installed and the path is correct");
    throw error;
  }
}

When we run this code, we get the following error

deno run -A main.ts
Error occurred: Error: Could not find browser revision 1022525. Run "PUPPETEER_PRODUCT=chrome deno run -A --unstable https://deno.land/x/[email protected]/install.ts" to download a supported browser binary.
    at ChromeLauncher.launch (https://deno.land/x/[email protected]/src/deno/Launcher.ts:99:30)
    at eventLoopTick (ext:core/01_core.js:175:7)
    at async file:///Users/danielcorin/dev/lab/deno-puppeteer/main.ts:12:21
Make sure Chrome is installed and the path is correct
error: Uncaught (in promise) Error: Could not find browser revision 1022525. Run "PUPPETEER_PRODUCT=chrome deno run -A --unstable https://deno.land/x/[email protected]/install.ts" to download a supported browser binary.
      if (missingText) throw new Error(missingText);
                             ^
    at ChromeLauncher.launch (https://deno.land/x/[email protected]/src/deno/Launcher.ts:99:30)
    at eventLoopTick (ext:core/01_core.js:175:7)
    at async file:///Users/danielcorin/dev/lab/deno-puppeteer/main.ts:12:21

Using npm’s puppeteer, we can install Chrome via npx

npx puppeteer browsers install chrome

However, this still did not resolve the error for me. It turns out the npx command install the browser to ~/.cache/puppeteer.

To use it, we need the following modifications to our code to provide an executablePath.

import puppeteer from "https://deno.land/x/[email protected]/mod.ts";

const svgString = `
<svg width="512" height="512" xmlns="http://www.w3.org/2000/svg">
  <rect width="100%" height="100%" fill="#87CEEB"/>
  <circle cx="256" cy="256" r="100" fill="#FFD700"/>
  <path d="M 100 400 Q 256 300 412 400" stroke="#1E90FF" stroke-width="20" fill="none"/>
</svg>`;

if (import.meta.main) {
  try {
    const browser = await puppeteer.launch({
      headless: true,
      args: ["--no-sandbox"],
      executablePath: Deno.env.get("HOME") +
        "/.cache/puppeteer/chrome/mac_arm-131.0.6778.108/chrome-mac-arm64/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing",
    });

    const page = await browser.newPage();

    await page.setViewport({ width: 512, height: 512 });
    await page.setContent(`
      <style>
        body { margin: 0; background: transparent; }
        svg { display: block; }
      </style>
      ${svgString}
    `);

    await page.screenshot({
      path: "output.png",
      clip: {
        x: 0,
        y: 0,
        width: 512,
        height: 512,
      },
    });

    await browser.close();
  } catch (error) {
    console.error("Error occurred:", error);
    console.error("Make sure Chrome is installed and the path is correct");
    throw error;
  }
}

With that, the code runs successfully and we get output.png in our working directory.

Using astral#

There is another, Deno-native library called astral (no relation to the Python tooling company). I was curious to see how the DX compared for this simple use case.

The following code worked for me without issue

import { launch } from "jsr:@astral/astral";

const svgString = `
<svg width="512" height="512" xmlns="http://www.w3.org/2000/svg">
  <rect width="100%" height="100%" fill="#87CEEB"/>
  <circle cx="256" cy="256" r="100" fill="#FFD700"/>
  <path d="M 100 400 Q 256 300 412 400" stroke="#1E90FF" stroke-width="20" fill="none"/>
</svg>`;

if (import.meta.main) {
  try {
    const browser = await launch();
    const page = await browser.newPage();

    await page.setViewportSize({ width: 512, height: 512 });
    await page.setContent(`
      <style>
        body { margin: 0; background: transparent; }
        svg { display: block; }
      </style>
      ${svgString}
    `);

    const screenshot = await page.screenshot();
    Deno.writeFileSync("output_astral.png", screenshot);

    await browser.close();
  } catch (error) {
    console.error("Error occurred:", error);
    throw error;
  }
}

On first run, astral manages the downloading of Chrome for you.

deno run -A main_astral.ts
Downloading chrome 125.0.6400.0                                                    100.00%
Download complete (chrome version 125.0.6400.0)
Inflating /Users/danielcorin/Library/Caches/astral/125.0.6400.0                                        100.00%
Browser saved to /Users/danielcorin/Library/Caches/astral/125.0.6400.0

This code works but unexpectedly outputs a PNG with size 1024 x 1024 and I’m not entirely sure why.

Adding the following not-very-nice-looking code seemed to fix this issue - maybe there is a better way.

    await page.unsafelyGetCelestialBindings().Emulation
      .setDeviceMetricsOverride({
        width: 512,
        height: 512,
        deviceScaleFactor: 1,
        mobile: false,
      });

With each approach, there were different rough edges. Both were able to fit my use case with a few tweaks.