DEV Community

Cover image for How I Built an In-Browser, Zero-Upload PDF Merger & Converter Using WebAssembly and Canvas
Taqi Raza
Taqi Raza

Posted on Originally published at swiftutils.net

How I Built an In-Browser, Zero-Upload PDF Merger & Converter Using WebAssembly and Canvas

Whenever you search for a simple utility like "Merge PDF" or "Convert WebP to PNG", you are usually greeted by the same frustrating experience:

  1. A 15MB file upload limit behind an aggressive paywall.
  2. A subscription pop-up asking for $12/month.
  3. Your sensitive personal documents (tax returns, signed contracts, medical records) getting uploaded to an untrusted remote server.

As developers, we know that modern browsers have hardware-accelerated GPUs, multi-threaded Web Workers, and WebAssembly. There is zero technical justification for uploading a document to a cloud server just to merge two pages or change an image format.

So I decided to build SwiftUtils — a suite of 100% client-side, zero-upload web utilities that process everything directly in browser memory.

Here is the exact technical architecture behind how it works.


1. In-Browser PDF Merging with Zero Cloud Leaks

Most online PDF services transmit your PDFs across the wire to a backend running poppler or pdfcpu. Instead, we can load the document structure directly into browser memory using pdf-lib:

import { PDFDocument } from 'pdf-lib';

async function mergePDFsInBrowser(fileList) {
  // 1. Create a fresh document in browser memory
  const mergedPdf = await PDFDocument.create();

  for (const file of fileList) {
    const arrayBuffer = await file.arrayBuffer();
    // 2. Parse the PDF structure locally
    const pdf = await PDFDocument.load(arrayBuffer);
    const copiedPages = await mergedPdf.copyPages(pdf, pdf.getPageIndices());

    // 3. Append pages to the merged document
    copiedPages.forEach((page) => mergedPdf.addPage(page));
  }

  // 4. Save and generate a local memory blob
  const pdfBytes = await mergedPdf.save();
  const blob = new Blob([pdfBytes], { type: 'application/pdf' });
  return URL.createObjectURL(blob);
}
Enter fullscreen mode Exit fullscreen mode

Why This Matters for Performance:

  • Zero Network Bottleneck: A 50MB PDF merges in ~300ms locally rather than waiting 45 seconds on an upload/download roundtrip.
  • True Privacy: The network tab shows zero outbound POST requests containing file data.

2. High-Fidelity 32-Bit Alpha Rasterization with HTML5 Canvas

When converting modern image formats like WebP, HEIC, or SVG into PNG, many online tools fail because they discard the alpha channel, turning transparent backgrounds into solid black or white squares.

By utilizing the browser's native CanvasRenderingContext2D, we maintain complete 32-bit RGBA color channels:

function convertToLosslessPNG(imageSource) {
  return new Promise((resolve, reject) => {
    const img = new Image();
    img.crossOrigin = 'anonymous';

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

      const ctx = canvas.getContext('2d');
      // Sub-pixel drawing preserving 100% alpha transparency
      ctx.drawImage(img, 0, 0);

      canvas.toBlob((blob) => {
        if (blob) resolve(blob);
        else reject(new Error('Canvas rasterization failed'));
      }, 'image/png');
    };

    img.onerror = () => reject(new Error('Failed to load image'));
    img.src = imageSource;
  });
}
Enter fullscreen mode Exit fullscreen mode

3. Handling Apple HEIC Photos with WebAssembly

Apple's iPhone cameras save images in HEIC/HEIF format. Browsers cannot natively display .heic files in standard <img> tags.

To bypass this without a server, we compile libde265 into WebAssembly using heic2any:

async function decodeHeicInBrowser(heicFile) {
  // Processed via local WebAssembly thread
  const pngBlob = await window.heic2any({
    blob: heicFile,
    toType: 'image/png'
  });

  return Array.isArray(pngBlob) ? pngBlob[0] : pngBlob;
}
Enter fullscreen mode Exit fullscreen mode

4. Avoiding Memory Leaks During Large Batch Conversions

When users drop 50+ high-resolution photos into the browser, allocating dozens of object URLs will quickly exhaust browser RAM.

To prevent memory bloat, we always pair blob creation with lifecycle garbage collection:

// Once download completes or row is removed from UI:
URL.revokeObjectURL(temporaryPreviewUrl);
Enter fullscreen mode Exit fullscreen mode

Try It Live & Check the Code

If you find this useful or want to contribute more client-side conversion algorithms, feel free to drop a star on the repo or suggest new tools in the comments!

Top comments (0)