DEV Community

Viktor_Dev
Viktor_Dev

Posted on Fully Autonomous

Converting WebP and PNG to JPG in the browser: why transparent pixels turn black

You right-click an image on a website, hit "Save image as", and get a .webp file. Your image viewer refuses it, and the upload form on some government site only accepts JPG.

I build CompressPhoto, a set of free image tools that run in the browser. Converting WebP, PNG and HEIC to JPG was one of the first tools on it. The basic version is about 30 lines of JavaScript. Getting it right took a few more details, and this post covers them.

The minimal converter

The idea is simple. Decode the file with an , draw it onto a , and export the canvas as JPEG.

js
function convertToJpg(file) {
return new Promise((resolve, reject) => {
const img = new Image();
const url = URL.createObjectURL(file);

img.onload = () => {
  const canvas = document.createElement("canvas");
  canvas.width = img.naturalWidth;
  canvas.height = img.naturalHeight;

  const ctx = canvas.getContext("2d");
  ctx.drawImage(img, 0, 0);
  URL.revokeObjectURL(url);

  canvas.toBlob(
    (blob) => (blob ? resolve(blob) : reject(new Error("toBlob failed"))),
    "image/jpeg",
    0.9
  );
};

img.onerror = () => {
  URL.revokeObjectURL(url);
  reject(new Error("The browser can't decode this image"));
};

img.src = url;
Enter fullscreen mode Exit fullscreen mode

});
}

The file never leaves the device. There's no upload, no server and no queue. For a privacy-minded user, that's the main selling point.

It works for a JPG-looking WebP photo. Then someone drops in a logo with a transparent background.

Why transparent areas turn black

JPEG has no alpha channel. Every pixel needs a real color, so the transparent ones have to become something.

The HTML spec decides what. When you serialize a canvas to a format without alpha, the bitmap is composited onto an opaque black background. A fresh canvas is filled with transparent black, rgba(0, 0, 0, 0). So anything you didn't paint over comes out as solid black.

That's why so many online converters turn a transparent PNG or WebP into a black square. The fix is one fill before drawing:

js
const ctx = canvas.getContext("2d");
// JPEG has no alpha: paint a white background first,
// or transparent areas end up black
ctx.fillStyle = "#ffffff";
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.drawImage(img, 0, 0);

White is a safe default for documents, print and forms. If a user needs to keep the transparency, the answer isn't JPG at all. Send them to PNG instead and skip the fill.

HEIC needs a separate decoder

iPhone photos are HEIC. Chrome and Firefox can't decode HEIC in an , so img.onerror fires. I use the heic2any library for those files.

It's large, about 1.3 MB, and most visitors never upload a HEIC file. So it's loaded with a dynamic import, only when needed:

js
const isHeic = (file) =>
file.type === "image/heic" ||
file.type === "image/heif" ||
/.(heic|heif)$/i.test(file.name);

async function toJpg(file) {
if (!isHeic(file)) return convertToJpg(file);

const heic2any = (await import("heic2any")).default;
const result = await heic2any({ blob: file, toType: "image/jpeg", quality: 0.9 });
// a HEIC can hold several images; heic2any then returns an array
return Array.isArray(result) ? result[0] : result;
}

Also check the file extension, not just file.type. Some browsers report an empty MIME type for HEIC, so a type-only check silently rejects valid files.

Things that surprised users

The JPG is bigger than the WebP. That's expected. WebP compresses better: Google's own numbers put lossy WebP at 25–34% smaller than JPEG of comparable quality. If an upload form has a size limit, compress the JPG as a separate step.

Animated WebP becomes a single frame. drawImage paints one frame of an animated image, and JPEG can't store animation anyway. Tell users rather than leave them guessing.

toBlob can return null. Mobile browsers limit how large a canvas can be. With a very large photo, the export can fail without throwing. That's why the callback checks for null and rejects with a readable error.

Quality 0.9. I picked 90% as the default. Higher values grow the file with little visible gain. Much lower values start to show JPEG blocking on gradients and skies.

Try it

The finished tool is at compressphoto.org/en/convert-to-jpg. It takes up to 20 files at a time, up to 30 MB each, and runs entirely in your browser.

If you're on the user side of this problem, I also wrote a non-technical guide: What WebP is, how to open it and convert it to JPG.

How do you handle transparency when exporting from canvas? Do you fill with white, or let the user choose a color? I'd like to hear in the comments.

Top comments (0)