I needed to pull a chart from a PDF report and stick it into a slide deck last week. Screenshot looked terrible. Copy-paste from the PDF viewer? Worse.
The clean way is converting PDF pages to actual image files — PNG or JPEG. And doing it in the browser means the document never touches a server.
In this post, I'll show you how to render PDF pages to images client-side using Mozilla's PDF.js, with code I've been running in production for a while.
The Stack: pdfjs-dist
Mozilla's PDF.js is the standard for rendering PDFs in browsers. It parses PDF structure, renders pages to a <canvas> element, and lets you export that canvas as an image.
npm install pdfjs-dist
The library uses a Web Worker for parsing (so the main thread stays responsive), which is great until you try to bundle it with Vite and the worker file 404s in production. More on that later.
Basic Conversion: One Page to PNG
Here's the simplest possible flow: load a PDF, render page 1 to a canvas, export as PNG.
import * as pdfjsLib from 'pdfjs-dist';
import PdfWorker from 'pdfjs-dist/build/pdf.worker?worker';
// Initialize worker
pdfjsLib.GlobalWorkerOptions.workerPort = new PdfWorker();
async function pdfPageToImage(file, pageNumber = 1, scale = 2) {
const arrayBuffer = await file.arrayBuffer();
const pdf = await pdfjsLib.getDocument({ data: arrayBuffer }).promise;
const page = await pdf.getPage(pageNumber);
// Set up canvas
const viewport = page.getViewport({ scale });
const canvas = document.createElement('canvas');
const context = canvas.getContext('2d');
canvas.width = viewport.width;
canvas.height = viewport.height;
// Render PDF page to canvas
await page.render({
canvasContext: context,
viewport: viewport
}).promise;
// Export as PNG
const blob = await new Promise(resolve =>
canvas.toBlob(resolve, 'image/png')
);
return blob;
}
// Usage
const imageBlob = await pdfPageToImage(file, 1, 2);
downloadBlob(imageBlob, 'page-1.png');
What's happening:
- Load the PDF into pdfjs-dist
- Get the specific page
- Create a canvas sized to the page dimensions (multiplied by scale)
- Render the PDF page onto the canvas
- Convert the canvas to a PNG blob
The scale parameter is the key quality lever. scale: 1 gives you 72 DPI (screen resolution). scale: 2 doubles both dimensions, effectively giving you 144 DPI. For print-quality output, you'd use scale: 4 (288 DPI).
DPI Control: Matching Output to Use Case
PDF.js doesn't have a direct "DPI" setting. It works in CSS pixels, where 1 unit = 1/96 inch. To hit specific DPI targets:
function getScaleForDPI(targetDPI) {
// PDF default is 72 DPI
// scale = targetDPI / 72
return targetDPI / 72;
}
const scales = {
screen: getScaleForDPI(96), // 1.33
standard: getScaleForDPI(150), // 2.08
print: getScaleForDPI(300), // 4.17
};
Practical impact on file size:
| Scale | Effective DPI | 8.5x11" Page Size | Typical PNG Size |
|---|---|---|---|
| 1.33 | 96 | 816x1056 px | ~200KB |
| 2.08 | 150 | 1275x1650 px | ~500KB |
| 4.17 | 300 | 2550x3300 px | ~2MB |
I learned the hard way that rendering every page at 300 DPI produces massive files. Most of my users just need 150 DPI for slides and documents. 300 DPI is overkill unless you're sending something to a print shop.
Format Choice: PNG vs JPEG
Once you have the canvas, you can export to either format:
// PNG — lossless, sharp text, larger files
const pngBlob = await new Promise(resolve =>
canvas.toBlob(resolve, 'image/png')
);
// JPEG — compressed, smaller files, configurable quality
const jpegBlob = await new Promise(resolve =>
canvas.toBlob(resolve, 'image/jpeg', 0.85) // 85% quality
);
My heuristic:
- Documents with fine text, diagrams, or line art → PNG. JPEG compression artifacts ruin crisp edges.
- Photo-heavy PDFs (brochures, magazines) → JPEG at 85-90% quality. File size is 3-5x smaller with no visible loss.
- Mixed content → PNG if under 10 pages, JPEG if larger (email-friendly).
Batch Conversion: Multiple Pages
Rendering one page is easy. Rendering all 50 pages without freezing the UI is harder.
async function convertAllPages(file, format = 'png', scale = 2) {
const arrayBuffer = await file.arrayBuffer();
const pdf = await pdfjsLib.getDocument({ data: arrayBuffer }).promise;
const images = [];
for (let i = 1; i <= pdf.numPages; i++) {
const page = await pdf.getPage(i);
const viewport = page.getViewport({ scale });
const canvas = document.createElement('canvas');
const context = canvas.getContext('2d');
canvas.width = viewport.width;
canvas.height = viewport.height;
await page.render({ canvasContext: context, viewport }).promise;
const mimeType = format === 'png' ? 'image/png' : 'image/jpeg';
const blob = await new Promise(resolve =>
canvas.toBlob(resolve, mimeType, 0.9)
);
images.push({
name: `page-${i}.${format}`,
blob
});
// Yield to UI thread between pages
await new Promise(r => setTimeout(r, 0));
}
return images;
}
The setTimeout(..., 0) is critical. Without it, the browser locks up for the entire duration. With 50 pages at high DPI, that's 5-10 seconds of frozen UI. Yielding between pages keeps the tab responsive and lets you show a progress bar.
For very large documents (200+ pages), I use requestIdleCallback to render only when the browser isn't busy:
function renderWithIdleCallback(pdf, pageNumber, scale) {
return new Promise((resolve) => {
requestIdleCallback(async () => {
const page = await pdf.getPage(pageNumber);
// ... render logic
resolve(blob);
});
});
}
Memory Management: The Canvas Problem
Here's a trap I fell into: creating 50 canvases and holding them all in memory.
Each canvas at scale: 4 for a letter-size page is about 2MB of pixel data. 50 pages = 100MB of canvas buffers. On a mobile browser, that's an out-of-memory crash waiting to happen.
Fix: Convert to blob and immediately discard the canvas.
// BAD: keeps all canvases in memory
const canvases = [];
for (const page of pages) {
const canvas = await renderPage(page);
canvases.push(canvas); // Memory hoarding
}
// GOOD: render, export, release
for (const page of pages) {
const canvas = await renderPage(page);
const blob = await canvasToBlob(canvas);
// Release canvas memory immediately
canvas.width = 0;
canvas.height = 0;
saveBlob(blob);
}
Setting canvas.width = 0 forces the browser to release the pixel buffer. Without this, the garbage collector might not run until much later — or until the tab crashes.
The Worker File 404 (Vite Users)
I mentioned this earlier. In development, pdfjs-dist's worker loads fine. In production, Vite's bundler moves or renames the worker file.
Fix for Vite:
import PdfWorker from 'pdfjs-dist/build/pdf.worker?worker';
pdfjsLib.GlobalWorkerOptions.workerPort = new PdfWorker();
If that fails in production, pin the worker path explicitly:
import workerSrc from 'pdfjs-dist/build/pdf.worker.js?url';
pdfjsLib.GlobalWorkerOptions.workerSrc = workerSrc;
Performance: Real Numbers
I tested batch conversion on a 20-page, 12MB PDF:
| Scale | Format | Time | Output Size |
|---|---|---|---|
| 1.33 (96 DPI) | PNG | 2.1s | 4.2MB total |
| 2.08 (150 DPI) | PNG | 3.8s | 11MB total |
| 2.08 (150 DPI) | JPEG 85% | 3.5s | 3.1MB total |
| 4.17 (300 DPI) | JPEG 90% | 8.2s | 12MB total |
For most real-world use (slides, documents, web), 150 DPI JPEG at 85% quality is the sweet spot.
Try the Live Tool
If you want to convert PDF pages to images without writing code:
Free, no signup, browser-based. Pick your pages, choose format and DPI, download as ZIP.
The source code is open source if you want to see the full Vue 3 + PDF.js integration.
Have you built browser-based document tools? How do you handle large batch conversions or memory limits?
Top comments (0)