
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:
- You upload images to their server
- They process and convert
- 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
For image embedding, we'll also need to handle different image formats:
npm install pdf-lib @pdf-lib/png-js @pdf-lib/jpeg-js
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();
});
What's happening:
- Read the image file into memory as bytes
- Create a blank PDF document
- Embed the image (PNG or JPEG) into the PDF
- Add a page exactly sized to the image
- Draw the image at full resolution
- 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();
}
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();
}
Key detail: When fitting an image to a fixed page size (like A4), you need to:
- Calculate the scale factor that fits the image within the page
- Maintain aspect ratio so the image doesn't stretch
- 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
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();
}
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]);
};
// 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 });
});
}
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' });
}
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)