DEV Community

monkeymore studio
monkeymore studio

Posted on

Building a Browser-Based Image Format Converter

Introduction

In this article, we'll explore how to implement a fully client-side image format converter that runs entirely in the browser. This powerful tool supports converting between over 13 different image formats, leveraging WebAssembly technology for high-performance processing.

Why Browser-Based Conversion?

There are several compelling reasons to implement image conversion in the browser:

1. Privacy Protection

When users convert images in the browser, their photos never leave their device. This is essential for:

  • Sensitive business documents
  • Personal photos requiring privacy
  • Healthcare records or legal documents

2. Zero Server Costs

Traditional server-based image conversion requires:

  • Powerful servers with adequate CPU resources
  • Bandwidth for uploading and downloading files
  • Storage for temporary files

By processing in the browser, we eliminate all infrastructure costs.

3. Instant Feedback

Processing locally means no network round-trip delay. Users get immediate results.

4. Offline Capability

Once the processing library is loaded, users can convert images without an internet connection.

Technical Architecture

Core Implementation

1. Data Structures

interface ImageFile {
  id: string;
  file: File;
  previewUrl: string;
  convertedUrl?: string;
  convertedFileName?: string;
  error?: string;
}

type OutputFormat = 
  | "jpeg" | "png" | "webp" | "gif" | "tiff" | "avif" | "heif" | "pdf" 
  | "jp2" | "jpxl" | "bmp" | "ppm" | "exr" | "fits";
Enter fullscreen mode Exit fullscreen mode

2. Supported Formats

const POPULAR_FORMATS: { value: OutputFormat; label: string; ext: string }[] = [
  { value: "png", label: "PNG", ext: "png" },
  { value: "jpeg", label: "JPEG", ext: "jpg" },
  { value: "webp", label: "WebP", ext: "webp" },
  { value: "heif", label: "HEIF", ext: "heif" },
  { value: "avif", label: "AVIF", ext: "avif" },
];

const ALL_FORMATS: { value: OutputFormat; label: string; ext: string }[] = [
  { value: "jpeg", label: "JPEG", ext: "jpg" },
  { value: "png", label: "PNG", ext: "png" },
  { value: "webp", label: "WebP", ext: "webp" },
  { value: "gif", label: "GIF", ext: "gif" },
  { value: "tiff", label: "TIFF", ext: "tiff" },
  { value: "avif", label: "AVIF", ext: "avif" },
  { value: "heif", label: "HEIF", ext: "heif" },
  { value: "pdf", label: "PDF", ext: "pdf" },
  { value: "jp2", label: "JPEG 2000", ext: "jp2" },
  { value: "jpxl", label: "JPEG XL", ext: "jxl" },
  { value: "bmp", label: "BMP", ext: "bmp" },
  { value: "ppm", label: "PPM", ext: "ppm" },
  { value: "exr", label: "OpenEXR", ext: "exr" },
  { value: "fits", label: "FITS", ext: "fits" },
];
Enter fullscreen mode Exit fullscreen mode

3. Loading libvips WebAssembly

The application uses wasm-vips, a WebAssembly port of the powerful libvips image processing library:

const VIPS_CDN = "https://cdn.jsdelivr.net/npm/wasm-vips@0.0.16/lib";

const WASM_FILES = [
  { name: "vips.js", size: 200 * 1024 },
  { name: "vips.wasm", size: 3 * 1024 * 1024 },
  { name: "vips-heif.wasm", size: 500 * 1024 },
  { name: "vips-jxl.wasm", size: 500 * 1024 },
];

async function downloadVipsFiles(onProgress?: (progress: number) => void) {
  const fetchWithProgress = async (url: string, fileSize: number, index: number) => {
    const response = await fetch(url);
    const reader = response.body?.getReader();
    const chunks: Uint8Array[] = [];
    let receivedLength = 0;

    while (true) {
      const { done, value } = await reader.read();
      if (done) break;
      chunks.push(value);
      receivedLength += value.length;
      if (onProgress) {
        const fileProgress = (receivedLength / fileSize) * 100;
        const totalProgress = ((index * 100 + fileProgress) / WASM_FILES.length);
        onProgress(Math.min(Math.round(totalProgress), 95));
      }
    }

    const totalLength = chunks.reduce((acc, val) => acc + val.length, 0);
    const result = new Uint8Array(totalLength);
    let offset = 0;
    for (const chunk of chunks) {
      result.set(chunk, offset);
      offset += chunk.length;
    }
    return new Blob([result]);
  };

  const [vipsJsBlob, vipsWasmBlob, vipsHeifBlob, vipsJxlBlob] = await Promise.all([
    fetchWithProgress(`${baseUrl}/vips.js`, WASM_FILES[0].size, 0),
    fetchWithProgress(`${baseUrl}/vips.wasm`, WASM_FILES[1].size, 1),
    fetchWithProgress(`${baseUrl}/vips-heif.wasm`, WASM_FILES[2].size, 2),
    fetchWithProgress(`${baseUrl}/vips-jxl.wasm`, WASM_FILES[3].size, 3),
  ]);

  // Create Blob URLs for WASM files
  const vipsWasmUrl = URL.createObjectURL(vipsWasmBlob);
  const vipsHeifUrl = URL.createObjectURL(vipsHeifBlob);
  const vipsJxlUrl = URL.createObjectURL(vipsJxlBlob);

  // Patch vips.js to use local Blob URLs
  const locateFileWrap = `
    var __origLocateFile = n.locateFile;
    n.locateFile = function(a) {
      if (a === "vips.wasm") return __vipsWasmUrl;
      if (a === "vips-heif.wasm") return __vipsHeifUrl;
      if (a === "vips-jxl.wasm") return __vipsJxlUrl;
      return __vipsWasmUrl;
    };
  `;

  const modifiedJs = vipsJsText
    .replace('var fa="";', locateFileSetup + 'var fa="";')
    .replace('var fa="";', 'var fa="";' + locateFileWrap);

  return URL.createObjectURL(new Blob([modifiedJs], { type: "application/javascript" }));
}
Enter fullscreen mode Exit fullscreen mode

4. Converting a Single Image

const convertSingleImage = async (imageFile: ImageFile, vips): Promise<{ buffer: Uint8Array; fileName: string }> => {
  const { file } = imageFile;
  const uint8Array = new Uint8Array(await file.arrayBuffer());

  // Load image with thumbnail (auto-rotates and limits size)
  let image;
  try {
    image = vips.Image.thumbnailBuffer(uint8Array, 4000, {
      height: 4000,
      size: true,
      no_rotate: true,
    });
  } catch {
    // Fallback for formats that don't support thumbnails
    image = vips.Image.newFromBuffer(uint8Array);
  }

  // Convert to target format
  let outputBuffer: Uint8Array;
  const formatInfo = ALL_FORMATS.find(f => f.value === outputFormat);
  const ext = formatInfo?.ext || outputFormat;
  const baseName = file.name.replace(/\.[^/.]+$/, "");

  switch (outputFormat) {
    case "jpeg":
      outputBuffer = image.jpegsaveBuffer({ Q: 90 });
      break;
    case "png":
      outputBuffer = image.pngsaveBuffer({ compression: 6 });
      break;
    case "webp":
      outputBuffer = image.webpsaveBuffer({ Q: 90 });
      break;
    case "gif":
      outputBuffer = image.gifsaveBuffer();
      break;
    case "tiff":
      outputBuffer = image.tiffsaveBuffer({ compression: "lzw" });
      break;
    case "avif":
      outputBuffer = image.heifsaveBuffer({ compression: "av1", Q: 80 });
      break;
    case "heif":
      outputBuffer = image.heifsaveBuffer({ compression: "hevc", Q: 80 });
      break;
    case "pdf":
      outputBuffer = image.pdfsaveBuffer();
      break;
    case "jp2":
      outputBuffer = image.jp2ksaveBuffer({ Q: 90 });
      break;
    case "jpxl":
      outputBuffer = image.jxlsaveBuffer({ Q: 90 });
      break;
    case "bmp":
      outputBuffer = image.bmpsaveBuffer();
      break;
    case "ppm":
      outputBuffer = image.ppmsaveBuffer();
      break;
    case "exr":
      outputBuffer = image.exrsaveBuffer();
      break;
    case "fits":
      outputBuffer = image.fitssaveBuffer();
      break;
    default:
      outputBuffer = image.pngsaveBuffer({ compression: 6 });
  }

  return {
    buffer: outputBuffer,
    fileName: `${baseName}.${ext}`,
  };
};
Enter fullscreen mode Exit fullscreen mode

5. Batch Conversion

const convertAllImages = useCallback(async () => {
  if (selectedFiles.length === 0 || !vipsRef.current) return;

  setIsConverting(true);
  const vips = vipsRef.current;
  const convertedFiles: ImageFile[] = [];

  for (const imageFile of selectedFiles) {
    try {
      const { buffer, fileName } = await convertSingleImage(imageFile, vips);
      const blob = new Blob([new Uint8Array(buffer)]);
      const convertedUrl = URL.createObjectURL(blob);

      convertedFiles.push({
        ...imageFile,
        convertedUrl,
        convertedFileName: fileName,
      });
    } catch (err) {
      console.error("Conversion error:", err);
      convertedFiles.push({
        ...imageFile,
        error: `Failed to convert ${imageFile.file.name}`,
      });
    }
  }

  setSelectedFiles(convertedFiles);
  setIsConverting(false);
}, [selectedFiles, convertSingleImage]);
Enter fullscreen mode Exit fullscreen mode

Service Worker for WASM Caching

The WebAssembly files are cached by a Service Worker for faster subsequent loads:

const CACHE_NAME = 'convert-wasm-cache-v1';
const VIPS_CDN = 'https://cdn.jsdelivr.net/npm/wasm-vips@0.0.16/lib';

const WASM_FILES = [
  `${VIPS_CDN}/vips.wasm`,
  `${VIPS_CDN}/vips-heif.wasm`,
  `${VIPS_CDN}/vips-jxl.wasm`,
];

self.addEventListener('fetch', (event) => {
  const url = new URL(event.request.url);

  // Only handle WASM file requests from wasm-vips
  if (url.pathname.endsWith('.wasm') && url.href.includes('wasm-vips')) {
    event.respondWith(
      caches.match(event.request).then((cachedResponse) => {
        if (cachedResponse) {
          return cachedResponse;
        }
        return fetch(event.request).then((networkResponse) => {
          const responseToCache = networkResponse.clone();
          caches.open(CACHE_NAME).then((cache) => {
            cache.put(event.request, responseToCache);
          });
          return networkResponse;
        });
      })
    );
  }
});
Enter fullscreen mode Exit fullscreen mode

Processing Flow

Key Technologies Used

Technology Purpose
wasm-vips WebAssembly port of libvips
libvips Powerful image processing library
Service Worker Cache WASM files for offline use
Blob URLs Handle converted images without server

Supported Formats

Format Extension Description
JPEG .jpg Best for photos, lossy compression
PNG .png Lossless, supports transparency
WebP .webp Modern format, excellent compression
GIF .gif Animated images, limited colors
TIFF .tiff High quality, professional use
AVIF .avif Latest format, best compression
HEIF .heif Apple's modern format
PDF .pdf Document format
JPEG 2000 .jp2 Wavelet-based compression
JPEG XL .jxl Next-gen JPEG
BMP .bmp Uncompressed bitmap
PPM .ppm Portable pixel map
OpenEXR .exr High dynamic range
FITS .fits Scientific imaging

Performance Characteristics

  1. Library Loading: ~4-8 seconds for full wasm-vips (including HEIF and JPEG XL support)
  2. Conversion Speed: 1-5 seconds per image depending on size and format
  3. Memory Usage: Typically 2-4x the image size
  4. WASM Caching: After first load, library works offline

Format Conversion Examples

Original Target Notes
PNG (10MB) JPEG ~90% size reduction
JPEG (5MB) WebP ~60% size reduction
HEIF (3MB) PNG Best for transparency
TIFF (15MB) PDF Single page document

Browser Support

The wasm-vips library works in modern browsers:

  • Chrome 89+
  • Firefox 89+
  • Safari 15+
  • Edge 89+

Conclusion

Browser-based image format conversion is a powerful solution that protects user privacy while providing excellent performance. Using WebAssembly-based libvips (via wasm-vips) enables professional-grade image processing entirely in the browser. The Service Worker ensures fast subsequent loads by caching the WASM modules.

This implementation supports 13+ formats including modern formats like AVIF, HEIF, and JPEG XL, making it one of the most capable browser-based image converters available.


Try it yourself at Free Image Tools

Experience the power of browser-based image conversion. No upload required - your images stay on your device!

Top comments (0)