Turning PDF Pages Into JPG and PNG in the Browser With pdf.js
A job application form accepts JPG or PNG, and your certificate is a PDF. A group chat shows a PDF as a grey icon that nobody opens, but a picture of the page everyone sees. Turning a page into an image sounds like the easiest thing a PDF tool can do. In a browser, it turned out to be the one with the most hidden parts.
This is how ToolOnTap’s PDF to JPG and PDF to PNG work, and the three things I had to get right for them to work on real documents.
Two PDF libraries, two jobs
The site’s other PDF tools use pdf-lib, which reads and writes the structure of a PDF: pages, fonts, objects. It can copy a page, but it can’t draw one. Drawing a page means interpreting every text and graphics instruction in it, decoding its fonts and images, and painting the result. For that I use pdf.js, Mozilla’s PDF renderer, the same project that draws PDFs inside Firefox.
I use its legacy build (pdfjs-dist/legacy/build/pdf.mjs, version 6.3.289 at the time of writing). The default build targets current browsers only; the legacy one also runs on older Safari and Android versions, which still matter for a site whose visitors are often on phones.
pdf.js is a big library, so it’s loaded the first time someone picks a PDF, never with the page itself. Until then, a visitor who only reads the page never downloads it.
What DPI means in pixels
PDF measures pages in points, and a point is 1/72 of an inch. A US Letter page is 612 × 792 points; an A4 page is about 595 × 842. So “DPI” in this tool is just a scale factor: render at dpi / 72 times the page’s size in points.
const wanted = page.getViewport({ scale: dpi / 72 })
const fit = clampPixels({ width: wanted.width, height: wanted.height })
const viewport = page.getViewport({ scale: (dpi / 72) * (fit.width / wanted.width) })
Here is what that gives for common page sizes:
| Resolution | Scale | US Letter | A4 |
|---|---|---|---|
| 72 DPI | 1× | 612 × 792 | about 595 × 842 |
| 150 DPI | 2.08× | 1275 × 1650 | about 1240 × 1754 |
| 300 DPI | 4.17× | 2550 × 3300 | about 2480 × 3508 |
The tool defaults to 150 DPI: sharp on any screen, and small enough to send. 300 DPI is for printing or zooming into small print, and the files are much larger, since four times the pixels of 150 DPI means four times the work for the encoder.
The middle line, clampPixels, is there because of a browser limit. iOS Safari won’t draw a canvas bigger than 16,777,216 pixels (4096 × 4096). Ask for more and it doesn’t throw an error; it draws nothing, and you get a blank image. So every render is scaled down, keeping the aspect ratio, until it fits. An A3 page at 300 DPI is about 3508 × 4961, roughly 17.4 million pixels, just over the limit. It’s rendered slightly smaller, and the result list marks that image so the size isn’t a surprise.
Each page is drawn on a white background, the way a PDF viewer shows it. Many pages have no background of their own, and without a white fill a PNG would come out transparent and look wrong on a dark background. JPGs are saved at 92% quality. After each page is encoded, its canvas is shrunk to 0 × 0, because Safari otherwise keeps those pixels in memory until garbage collection, and on a 40-page document at 300 DPI that adds up quickly. The tool shows a warning above 40 pages at 300 DPI for the same reason.
Why scanned pages can come out blank
Out of the box, pdf.js renders most PDFs fine and can still leave some pages empty or missing text. The reason is a set of files it loads at runtime, from URLs you give it:
- CMaps, which map character codes to glyphs in many Chinese, Japanese and Korean fonts. Without them, that text can fail to render.
- Standard font data, for PDFs that use one of the standard fonts (such as Helvetica or Times) without embedding it, so the text gets the right shapes and widths.
- Wasm image decoders for JBIG2 and JPEG 2000. Some scanned PDFs store their pages in these formats. When the decoder isn’t there, the scan image isn’t drawn, and a page that is one big scanned image comes out blank.
- ICC color profiles and a small color management module, for images in CMYK and other color spaces that need converting.
pdfjs-dist ships all of these as folders next to the library, but a bundler doesn’t know about them. So nuxt.config.ts publishes them from node_modules at build time:
const pdfjsVersion: string = JSON.parse(readFileSync('node_modules/pdfjs-dist/package.json', 'utf8')).version
const pdfjsAssets = ['cmaps', 'standard_fonts', 'wasm', 'iccs'].map(dir => ({
dir: `../node_modules/pdfjs-dist/${dir}`,
baseURL: `/pdfjs/${pdfjsVersion}/${dir}`,
maxAge: 31536000,
}))
The version in the path does two jobs. The files can be cached for a year, because a new pdf.js version gets a new URL. And they can never drift from the library: the decoders the browser downloads always match the code that calls them. pdf.js requests them as a document needs them, not all up front.
One worker, served as plain JavaScript
pdf.js does its parsing in a Web Worker, so a heavy document doesn’t freeze the page. By default, every document you open starts its own worker. The tool pages open PDFs for previews and for conversion, and Merge PDF opens one per file, so I create a single worker and share it:
lib ??= Promise.all([
import('pdfjs-dist/legacy/build/pdf.mjs'),
import('pdfjs-dist/legacy/build/pdf.worker.min.mjs?worker'),
]).then(([pdfjs, { default: PdfWorker }]) => ({ pdfjs, worker: pdfjs.PDFWorker.create({ port: new PdfWorker() }) }))
The ??= makes this run once, however many PDFs are opened. Closing a document frees its data inside the worker; the worker itself stays for the next file.
The ?worker suffix matters too. It asks Vite to bundle the worker as an ordinary .js file. The site sends X-Content-Type-Options: nosniff on every response, which tells the browser to refuse a script whose MIME type isn’t a JavaScript one, instead of guessing. A .js file is served as JavaScript by every host. A stray .mjs file depends on the host’s MIME table, and if it.s wrong, the worker can fail to load, and then no page renders at all.
One more detail: pdf.js moves the file’s bytes into its worker, which leaves the original buffer empty. So pdf.js gets its own copy of the bytes. The copy pdf-lib reads stays intact, which matters on Split PDF and the page organizer, where pdf-lib saves the result after the previews have loaded.
Previews that don’t render 300 pages at once
Before you convert, the tool shows every page. A long document would be slow if all of them rendered up front, so each preview waits until it’s within 300 pixels of the screen, using an IntersectionObserver. Each one is rendered at twice its display width for sharp screens, then stored as a small JPEG at 80% quality instead of a live canvas, which takes a fraction of the memory.
JPG or PNG
| PDF to JPG | PDF to PNG | |
|---|---|---|
| Best for | scans, photos, sharing | text, charts, diagrams, editing |
| Edges of text | can blur slightly | stay perfectly sharp |
| File size | small | larger, often much larger for photo-heavy pages |
Either way, the result is a picture of the page. Its text can’t be selected or searched, so keep the PDF if you need that.
Try it
PDF to JPG and PDF to PNG convert all pages or the ones you type (like 1, 3, 5-7), at 72, 150 or 300 DPI, and give you a ZIP for many pages. The whole render happens on your device, with the file never uploaded. More PDF tools are on the PDF tools page.