🍱 Lunchbox Hands

svg

Why Your SVG Converts to a Blank or Wrong-Size PNG

Seven reasons an SVG rasterizes to nothing, to 300×150, or to solid black — missing xmlns, no intrinsic size, currentColor, blocked external images and fonts, and the foreignObject that taints the canvas. All measured in Chrome.

An SVG that looks perfect in the browser, in Figma, and in your editor converts to a 300×150 PNG of nothing. Or it converts at the right size with the icon missing. Or the text comes out in Times New Roman. None of this is a bug in the converter: an SVG used as an image is a different, deliberately crippled execution environment from an SVG inlined in a page, and almost every “blank PNG” is one of seven specific consequences of that. Here they are, each one measured in Chrome 153 on macOS.

What conversion actually does

Every browser-side SVG→PNG converter, ours included, does the same three things: point an <img> at the SVG, draw it into a <canvas>, export the canvas as PNG.

const img = new Image();
img.onload = () => {
  const canvas = document.createElement('canvas');
  canvas.width  = img.naturalWidth  * scale;
  canvas.height = img.naturalHeight * scale;
  canvas.getContext('2d').drawImage(img, 0, 0, canvas.width, canvas.height);
  canvas.toBlob(blob => …, 'image/png');
};
img.src = svgUrl;

Three lines, three separate places to fail: the image may refuse to load, naturalWidth may not be what you think, and toBlob may throw.

1. Missing xmlns — the image never loads at all

The single most common cause. Editors and hand-written snippets often omit the namespace because it doesn’t matter when the markup is inlined in HTML:

<!-- renders fine inline, fails as an image -->
<svg width="40" height="40"><rect width="40" height="40" fill="red"/></svg>

As an image it’s parsed as XML, and without xmlns="http://www.w3.org/2000/svg" on the root element the document isn’t SVG. The <img> fires error, and browsers give you no diagnostic beyond that. Measured: with the namespace, naturalWidth is 100; without it, onerror fires.

Fix: put the namespace on the root <svg>. Ours now says so explicitly rather than reporting a generic failure.

2. No width/height — hello, 300×150

This is the wrong-size case, and the numbers surprise people because they aren’t arbitrary. An SVG with only a viewBox has an intrinsic aspect ratio but no intrinsic size. CSS resolves that by falling back to the default object size — for a replaced element, CSS 2.2 sets width to 300px and height to “the height of the largest rectangle that has a 2:1 ratio, has a height not greater than 150px” — and then, per CSS Images 3, if no dimension is given “its size is resolved as a contain constraint against the default object size.”

Contain-fit an aspect ratio into a 300×150 box and you get:

SVG rootnaturalWidth × naturalHeight in Chrome
width="100" height="50" viewBox="0 0 100 50"100 × 50
viewBox="0 0 100 50" (2:1)300 × 150
viewBox="0 0 100 100" (1:1)150 × 150
viewBox="0 0 400 100" (4:1)300 × 75
no width, no height, no viewBox300 × 150
width="800" + viewBox="0 0 100 100"800 × 800

Two things fall out. First, a square logo with no explicit size rasterizes at 150×150 — small and blurry, not blank, which is why people blame the converter’s quality. Second, giving just one dimension is enough: the aspect ratio supplies the other, so width="1024" on a square icon produces a 1024×1024 PNG.

Fix: put real width and height attributes on the root <svg>, keep the viewBox, and scale from there. Optimizers are worth a check on this point — SVGO has a removeDimensions plugin that deletes exactly the attributes you need, and build pipelines sometimes enable it. Ours doesn’t: the SVG optimizer runs SVGO 4’s preset-default, which leaves both width/height and viewBox on the root element intact (verified on all three shapes above). It won’t create this problem, and it won’t fix it either.

3. Percentages resolve to nothing; em resolves to 16px

width="100%" height="100%" viewBox="0 0 100 50"  ->  300 × 150
width="10em" height="5em"  viewBox="0 0 100 50"  ->  160 × 80

A percentage needs a containing block, and an image doesn’t have one, so 100% is no better than omitting the attribute. Font-relative units do resolve — against the 16px initial font size, so 10em is exactly 160px. If your icon set uses em dimensions, that’s where the 160px PNGs come from.

4. fill="currentColor" rasterizes black

Icon libraries are built around currentColor so the icon inherits the surrounding text colour. As an image there is no surrounding text, so color keeps its initial value and every stroke and fill comes out black. Measured by sampling the canvas pixel:

<rect fill="currentColor"/>                          -> rgba(0, 0, 0, 255)
<style>svg{color:tomato}</style> + currentColor       -> rgba(255, 99, 71, 255)

A black icon on a transparent background is the classic “my export is broken” report from anyone converting a Feather, Lucide, or Heroicons file. The fix is inside the file: either hard-code the fills, or add an inline <style> — inline CSS works, because it isn’t an external resource.

5. External images and web fonts are silently dropped

This is the rule that explains several symptoms at once. SVG 2 defines a “secure static mode” for exactly this situation — “an SVG document is to be used as a non-animated image that is not allowed to resolve external references” — with script execution, external references, declarative animation and interactivity all disabled. An SVG referenced by <img> is processed in that mode.

So:

<image href="https://example.com/photo.png"/>   -> rgba(0,0,0,0)   nothing drawn, no error
<image href="data:image/gif;base64,…"/>         -> drawn correctly

A linked bitmap vanishes. So does an @font-face pointing at a remote font file. Rendering the same string with an inline @font-face that loads a real remote .woff2, and then with no @font-face at all, produced pixel-identical output — 3,225 ink pixels either way. The web font simply never loaded; the text fell back.

Locally installed fonts named in font-family do resolve, which is why the same file looks right on the designer’s machine and wrong on yours. Rendering Hamburgefonstiv at 40px through SVG-as-image:

font-familyInk pixelsRightmost ink
Zapfino4,973394px
Helvetica4,013301px
Courier New2,743359px
NoSuchFontXYZ3,225278px
serif3,225278px

Three installed faces, three distinct renderings; the unknown family falls back to the default serif exactly. How font files work covers the formats involved.

Fix: inline everything the SVG needs. Embed raster images as data: URIs, and convert text to paths before rasterizing if the typeface matters.

6. Scripts and animation don’t run

Same rule, same source. A <script> inside the SVG that recolours an element at load time does nothing — measured, the rect stayed its original colour. SMIL animation and CSS animation are frozen at their initial state. Whatever the PNG captures is frame zero of a static document.

7. <foreignObject> taints the canvas — and that one throws

The nastiest failure, because it isn’t visual at all. Chrome treats an SVG containing <foreignObject> as a potential cross-origin leak — it can render arbitrary HTML — and flags the canvas it was drawn into as not origin-clean. The drawing succeeds. The export throws:

SecurityError: Failed to execute 'toBlob' on 'HTMLCanvasElement':
Tainted canvases may not be exported.

Mermaid diagrams, Excalidraw exports, and anything that lays out HTML text inside an SVG hit this. The detail that matters is that the taint depends on how the SVG was loaded, not on its content. Measured in Chrome 153 with identical markup:

Source of the SVGgetImageDatatoDataURL / toBlob
blob: URL (URL.createObjectURL)SecurityErrorSecurityError
data: URL, percent-encodedworksworks
data: URL, base64worksworks

This was a real bug in our own converter, and writing this post is how it was found. The tool built its <img> source with URL.createObjectURL, so any SVG containing a <foreignObject> tainted the canvas and toBlob() threw — from inside the img.onload handler, where the throw escaped the promise entirely. The promise never settled, so the button sat on “Converting…” forever with no error message. Two changes fixed it: the SVG is now loaded from a data: URL, which both avoids the taint and makes <foreignObject> diagrams convert correctly, and the rasterizing block is wrapped so any remaining failure rejects with a readable message instead of hanging. Percent-encoding rather than btoa matters too — btoa throws on any character outside Latin-1, which includes every accented word and emoji in your SVG’s text.

Making an SVG that rasterizes predictably

  1. xmlns="http://www.w3.org/2000/svg" on the root element. Non-negotiable.
  2. Explicit width and height in absolute units, plus a viewBox. One dimension plus a viewBox also works.
  3. No currentColor, or an inline <style> that defines color.
  4. No external <image href>, no external @font-face, no <use xlink:href="other.svg#id">.
  5. Text either converted to paths or set in a font you know the rasterizing machine has.
  6. Expect <foreignObject> to be a compatibility cliff even where it works.
  7. Export at 2× or 3× and downscale, rather than exporting at 1× and hoping.

Then run it through the SVG to PNG converter — it does all of the above in your browser with nothing uploaded. If the goal is smaller vector output rather than a raster, the SVG optimizer is the other half of the job, and SVG optimizer vs SVG to PNG vs SVG to React is the decision guide for which conversion you actually need. Going the other way — turning a raster back into vectors — is PNG to SVG, with the caveats that tracing always brings.