Why Your SVG-to-PNG Conversion Looks Wrong (And How to Fix It in the Browser)
You export an icon to PNG and it comes back with the wrong font, a black background instead of transparency, or soft, blurry edges. Nine times out of ten the bug is not in your export code — it's in the assumption that a browser rasterizes SVG the same way a design tool does.
Converting SVG to PNG in the browser is really just three steps: turn the markup into an image, draw that image onto a canvas, and read the canvas back as a PNG. The canvas is doing all the work. Everything that looks wrong comes from what the browser refuses to load while doing it.
Here is the full pipeline plus the seven failure modes I hit while building a client-side converter with no server, no uploads, and no dependencies.
The pipeline
async function svgToPng(svgText, { scale = 2, background = null } = {}) {
// 1. Parse and normalise the SVG
const doc = new DOMParser().parseFromString(svgText, 'image/svg+xml');
const svg = doc.documentElement;
if (svg.querySelector('parsererror')) throw new Error('Invalid SVG');
// 2. Resolve the intrinsic size
const vb = svg.getAttribute('viewBox');
let [, , w, h] = (vb || '0 0 300 150').split(/[\s,]+/).map(Number);
w = w || Number(svg.getAttribute('width')) || 300;
h = h || Number(svg.getAttribute('height')) || 150;
// Force explicit pixel dimensions (see pitfall 3)
svg.setAttribute('width', w);
svg.setAttribute('height', h);
svg.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
// 3. Serialise to a blob URL the <img> can load
const blob = new Blob([new XMLSerializer().serializeToString(svg)], {
type: 'image/svg+xml;charset=utf-8',
});
const url = URL.createObjectURL(blob);
try {
const img = await loadImage(url);
// 4. Draw at scale for a crisp result
const canvas = document.createElement('canvas');
canvas.width = Math.round(w * scale);
canvas.height = Math.round(h * scale);
const ctx = canvas.getContext('2d');
if (background) {
ctx.fillStyle = background;
ctx.fillRect(0, 0, canvas.width, canvas.height);
}
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
// 5. Export
return await new Promise((resolve, reject) =>
canvas.toBlob(
(b) => (b ? resolve(b) : reject(new Error('Canvas is tainted'))),
'image/png'
)
);
} finally {
URL.revokeObjectURL(url);
}
}
function loadImage(src) {
return new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => resolve(img);
img.onerror = () => reject(new Error('SVG failed to rasterise'));
img.src = src;
});
}
Roughly 40 lines, and it works — for a subset of SVG. The rest of this post is about that subset.
Pitfall 1: external resources are silently blocked
This is the big one. When an SVG is loaded through an <img>, the browser treats it as an image, not a document. Images do not get to fetch subresources. So these all fail silently:
-
<image href="logo.png">pointing at a file or a URL -
@font-facerules inside a<style>block -
@importof a stylesheet - anything behind
url(#…)that resolves to a network request
There is no error. No console warning. The element just renders as nothing, and you get a PNG with a hole in it. The fix is to inline everything first — convert referenced images to data: URIs, and turn text into paths before export if the font matters (see next pitfall).
Pitfall 2: fonts fall back to whatever is installed
Same root cause, different symptom. Your SVG says font-family: "Inter", the browser rasterizing it has never heard of Inter, and you get Helvetica or DejaVu instead. Text reflows, line breaks move, and a logo turns into something slightly wrong that nobody can name.
Two reliable options:
-
Convert text to paths in the source file. The output is then font-independent everywhere, forever. This is what
text-to-path/object-to-pathdoes in your editor. -
Use only fonts you can guarantee — generic families (
sans-serif,serif,monospace) or system stacks. They render, just never the exact file you designed with.
If you keep live text, expect the browser's metrics, not your design tool's metrics.
Pitfall 3: no width/height, no pixels
An SVG with only a viewBox has no intrinsic size:
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
That's perfectly valid SVG, and Chrome will happily display it when sized by CSS. But when you load the same markup into an <img>, several browsers report naturalWidth as 0 — and drawImage with a zero-sized source throws or draws nothing. The export "succeeds" and produces an empty or 1×1 PNG.
Fix: always set explicit width and height attributes on the root <svg> before serialising, derived from the viewBox. That's the svg.setAttribute('width', w) line above, and it's the single most common cause of "my converter only works on some files."
Pitfall 4: blurry output on retina
A canvas has two sizes: its CSS size and its backing-store size. Only the backing store defines how many real pixels you get. If you set the canvas to 300×300 and draw a 300×300 icon into it, you get a 300×300 PNG — which is correct, and looks soft on a 2× display because there simply aren't enough pixels.
Set the backing store bigger and scale the drawing:
canvas.width = Math.round(w * scale); // scale = 2 for 2x
canvas.height = Math.round(h * scale);
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
Because SVG is resolution-independent, you can go as high as you like — scale = 4 on a 24px icon gives you 96×96 real pixels. Just remember the dimensions you multiply here are the output pixel dimensions; a 4000px export of a complex icon is a 4000px PNG, not a free lunch.
Pitfall 5: "transparent" isn't
canvas starts fully transparent, so toBlob('image/png') gives you real alpha by default. The surprise is the other direction: designers export SVGs with a white <rect> covering the artboard, so your "transparent" PNG arrives with a white box painted over the background. Nothing to fix in code — it's in the source file.
If you do want a flat background (say for a JPG or for email), paint it before drawing, since drawImage composites rather than replaces:
if (background) {
ctx.fillStyle = background;
ctx.fillRect(0, 0, canvas.width, canvas.height);
}
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
Pitfall 6: filters and foreignObject are renderer-specific
feGaussianBlur, feTurbulence, mix-blend-mode, and <foreignObject> are the parts of SVG that every engine implements a little differently. A blur that looks right in Chrome can look banded in Firefox and glossier in Safari. <foreignObject> — the trick for rendering HTML inside SVG — is the least portable feature in the spec, and it's also the fastest way to get a canvas that won't export (next pitfall).
If your target is a PNG that looks identical for everyone, flatten these effects in the source file or accept per-browser drift.
Pitfall 7: the tainted canvas
canvas.toBlob() throws SecurityError when the canvas is "tainted" — meaning something cross-origin was drawn into it. With pure SVG-as-image this is mostly moot because external resources are blocked anyway, but it comes straight back when you compose a design: draw a user's cross-origin photo onto the canvas, then try to export, and the export dies even though the pixels are right there on screen.
Two ways out:
- Serve the images with permissive CORS headers and set
img.crossOrigin = 'anonymous'before settingsrc. (Order matters; setting it after triggers a second, uncached request or an outright failure.) - Do the compositing server-side, which is exactly the dependency a client-side tool is trying to avoid.
This is also why the classic "paste a URL and convert" trick fails: the remote SVG has to be fetched with CORS enabled, or the browser blocklists it before a single pixel is drawn.
The checklist
Before you blame your code, check the source file:
- Everything inlined — images as
data:URIs, fonts as paths or generic families. - Explicit
widthandheighton the root<svg>, matching theviewBox. -
xmlns="http://www.w3.org/2000/svg"present after serialisation. - No
currentColorand novar(--…)— outside a page context there is nothing to inherit from, so they resolve to defaults (usually black). - No page CSS assumptions — styles must live inside the SVG's own
<style>or as attributes.
And in your code:
- Backing store scaled up for retina, not just CSS size.
-
URL.revokeObjectURLin afinallyblock, or you leak the blob on every conversion. - Paint a background before
drawImageif the format has no alpha channel.
I built one of these
These are the exact edge cases I hit while building imgloft.com — a free browser-side image toolset for SVG→PNG, PNG compression, resizing and cropping. It does the rasterisation on your device with the pipeline above, so nothing is uploaded and there's no queue to wait in. If you just want to convert a file rather than debug one:
- SVG to PNG converter — scale 1×–4×, transparent or flat background
- The longer guide version of this post — including a comparison table of browser-side vs server-side conversion
If you've hit a rasterisation bug that isn't on this list, I want to hear it — drop it in the comments and I'll reproduce it.
Top comments (1)
Some comments may only be visible to logged-in visitors. Sign in to view all comments.