DEV Community

sunshey
sunshey

Posted on • Originally published at smartontools.blogspot.com

How to Convert Images to PDF in the Browser Using JavaScript (No Server)


I needed to turn 12 renovation photos into a single PDF for an insurance claim last week. The usual approach? Upload to some online converter, hope they don't keep the files, download the result.

There's a better way: do it entirely in the browser. No server, no upload, no privacy risk.

In this post, I'll show you how to build a client-side image-to-PDF converter using pdf-lib, with code I've been running in production.


Why Client-Side?

Most image-to-PDF services work like this:

  1. You upload images to their server
  2. They process and convert
  3. You download the PDF

Problems:

  • Privacy: your images sit on a stranger's server
  • Network: uploading 50MB of photos takes time
  • Dependency: service goes down, you're stuck

Client-side processing solves all three. The images never leave the user's device. It works offline after the page loads. And you're not paying for server bandwidth.


The Stack: pdf-lib

pdf-lib is a JavaScript library for creating and modifying PDFs. It runs in browsers and Node.js, doesn't require native dependencies, and produces spec-compliant PDFs.

npm install pdf-lib
Enter fullscreen mode Exit fullscreen mode

For image embedding, we'll also need to handle different image formats:

npm install pdf-lib @pdf-lib/png-js @pdf-lib/jpeg-js
Enter fullscreen mode Exit fullscreen mode

Basic Conversion: Single Image to PDF

Here's the simplest flow: load an image, embed it into a new PDF, save.

import { PDFDocument } from 'pdf-lib';

async function imageToPdf(imageFile) {
  // Read image as ArrayBuffer
  const imageBytes = await imageFile.arrayBuffer();

  // Create a new PDF document
  const pdfDoc = await PDFDocument.create();

  // Determine image type and embed
  let image;
  if (imageFile.type === 'image/png') {
    image = await pdfDoc.embedPng(imageBytes);
  } else if (imageFile.type === 'image/jpeg') {
    image = await pdfDoc.embedJpg(imageBytes);
  } else {
    throw new Error('Unsupported image format');
  }

  // Add a page sized to the image dimensions
  const page = pdfDoc.addPage([image.width, image.height]);

  // Draw the image on the page
  page.drawImage(image, {
    x: 0,
    y: 0,
    width: image.width,
    height: image.height,
  });

  // Save the PDF
  const pdfBytes = await pdfDoc.save();
  return pdfBytes;
}

// Usage
const input = document.getElementById('fileInput');
input.addEventListener('change', async (e) => {
  const file = e.target.files[0];
  const pdfBytes = await imageToPdf(file);

  // Download
  const blob = new Blob([pdfBytes], { type: 'application/pdf' });
  const url = URL.createObjectURL(blob);
  const a = document.createElement('a');
  a.href = url;
  a.download = 'converted.pdf';
  a.click();
});
Enter fullscreen mode Exit fullscreen mode

What's happening:

  1. Read the image file into memory as bytes
  2. Create a blank PDF document
  3. Embed the image (PNG or JPEG) into the PDF
  4. Add a page exactly sized to the image
  5. Draw the image at full resolution
  6. Save and trigger download

Multiple Images: One PDF Per Image

Usually you want multiple images, each on its own page.

async function imagesToPdf(imageFiles) {
  const pdfDoc = await PDFDocument.create();

  for (const file of imageFiles) {
    const imageBytes = await file.arrayBuffer();
    let image;

    if (file.type === 'image/png') {
      image = await pdfDoc.embedPng(imageBytes);
    } else if (file.type === 'image/jpeg') {
      image = await pdfDoc.embedJpg(imageBytes);
    } else {
      continue; // Skip unsupported formats
    }

    const page = pdfDoc.addPage([image.width, image.height]);
    page.drawImage(image, {
      x: 0,
      y: 0,
      width: image.width,
      height: image.height,
    });
  }

  return await pdfDoc.save();
}
Enter fullscreen mode Exit fullscreen mode

Page Size Control: A4, Letter, Original

Real users don't always want the PDF page to match the image exactly. They might want all pages to be A4 for printing.

function getPageSize(format) {
  const sizes = {
    a4: [595, 842],        // 72 DPI
    letter: [612, 792],    // 72 DPI
  };
  return sizes[format] || null; // null = use image dimensions
}

async function imagesToPdfWithSize(imageFiles, pageFormat = 'original') {
  const pdfDoc = await PDFDocument.create();

  for (const file of imageFiles) {
    const imageBytes = await file.arrayBuffer();
    let image;

    if (file.type === 'image/png') {
      image = await pdfDoc.embedPng(imageBytes);
    } else {
      image = await pdfDoc.embedJpg(imageBytes);
    }

    // Determine page dimensions
    let pageWidth, pageHeight;
    const presetSize = getPageSize(pageFormat);

    if (presetSize) {
      [pageWidth, pageHeight] = presetSize;
    } else {
      [pageWidth, pageHeight] = [image.width, image.height];
    }

    const page = pdfDoc.addPage([pageWidth, pageHeight]);

    // Calculate scaled dimensions to fit page while maintaining aspect ratio
    const scale = Math.min(
      pageWidth / image.width,
      pageHeight / image.height
    );

    const scaledWidth = image.width * scale;
    const scaledHeight = image.height * scale;

    // Center the image on the page
    const x = (pageWidth - scaledWidth) / 2;
    const y = (pageHeight - scaledHeight) / 2;

    page.drawImage(image, {
      x, y,
      width: scaledWidth,
      height: scaledHeight,
    });
  }

  return await pdfDoc.save();
}
Enter fullscreen mode Exit fullscreen mode

Key detail: When fitting an image to a fixed page size (like A4), you need to:

  1. Calculate the scale factor that fits the image within the page
  2. Maintain aspect ratio so the image doesn't stretch
  3. Center the image so there's equal whitespace on all sides

Memory Management: Large Image Handling

Here's a trap: loading a 20MB JPEG into memory, then embedding it into a PDF, then holding both copies.

// BAD: Keeps both image and PDF in memory
const imageBytes = await file.arrayBuffer(); // 20MB
const image = await pdfDoc.embedJpg(imageBytes); // Creates another copy
// imageBytes is still in memory, unused

// GOOD: Release image bytes after embedding
let imageBytes = await file.arrayBuffer();
const image = await pdfDoc.embedJpg(imageBytes);
imageBytes = null; // Allow GC to reclaim
Enter fullscreen mode Exit fullscreen mode

For batch conversion of many large images, I process them in chunks rather than all at once:

async function processInBatches(files, batchSize = 5) {
  const pdfDoc = await PDFDocument.create();

  for (let i = 0; i < files.length; i += batchSize) {
    const batch = files.slice(i, i + batchSize);

    for (const file of batch) {
      // Process file...
      const imageBytes = await file.arrayBuffer();
      const image = await pdfDoc.embedJpg(imageBytes);
      // Add page, draw image...
    }

    // Yield to event loop between batches
    await new Promise(r => setTimeout(r, 0));
  }

  return await pdfDoc.save();
}
Enter fullscreen mode Exit fullscreen mode

The setTimeout(..., 0) yields control back to the browser, keeping the UI responsive and allowing garbage collection to run between batches.


Web Workers: Don't Block the Main Thread

For large jobs (50+ images), move the conversion to a Web Worker so the UI doesn't freeze:

// worker.js
import { PDFDocument } from 'pdf-lib';

self.onmessage = async (e) => {
  const { images } = e.data;
  const pdfDoc = await PDFDocument.create();

  for (const { bytes, type } of images) {
    const image = type === 'png' 
      ? await pdfDoc.embedPng(bytes)
      : await pdfDoc.embedJpg(bytes);

    const page = pdfDoc.addPage([image.width, image.height]);
    page.drawImage(image, { x: 0, y: 0, width: image.width, height: image.height });
  }

  const pdfBytes = await pdfDoc.save();
  self.postMessage({ pdfBytes }, [pdfBytes.buffer]);
};
Enter fullscreen mode Exit fullscreen mode
// main.js
const worker = new Worker('worker.js', { type: 'module' });

async function convertWithWorker(files) {
  const images = await Promise.all(
    files.map(async (file) => ({
      bytes: new Uint8Array(await file.arrayBuffer()),
      type: file.type.includes('png') ? 'png' : 'jpg',
    }))
  );

  return new Promise((resolve) => {
    worker.onmessage = (e) => resolve(e.data.pdfBytes);
    worker.postMessage({ images });
  });
}
Enter fullscreen mode Exit fullscreen mode

Supported Image Formats

pdf-lib handles PNG and JPEG natively. For WebP, BMP, and GIF, you'll need to convert them first.

I use Canvas for format conversion:

async function convertToSupportedFormat(file) {
  if (file.type === 'image/png' || file.type === 'image/jpeg') {
    return file;
  }

  // Convert WebP/BMP/GIF to PNG via Canvas
  const img = new Image();
  const url = URL.createObjectURL(file);
  img.src = url;

  await new Promise((resolve) => {
    img.onload = resolve;
  });

  const canvas = document.createElement('canvas');
  canvas.width = img.width;
  canvas.height = img.height;
  const ctx = canvas.getContext('2d');
  ctx.drawImage(img, 0, 0);

  URL.revokeObjectURL(url);

  const blob = await new Promise(resolve => 
    canvas.toBlob(resolve, 'image/png')
  );

  return new File([blob], 'converted.png', { type: 'image/png' });
}
Enter fullscreen mode Exit fullscreen mode

Try the Live Tool

If you want to convert images to PDF without writing code:

👉 en.sotool.top/image-to-pdf

Free, no signup, browser-based. Drag images, reorder, pick page size, download as PDF.

The source code is open source if you want to see the full Vue 3 + pdf-lib integration.


Have you built browser-based document tools? How do you handle large batch conversions or memory limits in the browser?

Top comments (0)