DEV Community

shaojie gong
shaojie gong

Posted on

Converting PDF pages to JPG entirely in the browser: PDF.js, DPI math and the iOS canvas limit


I'm the developer of MakeQuickWork, a small collection of free browser tools. This post is about how one of them, PDF to JPG, works under the hood.

Most "PDF to JPG" sites follow the same pattern: you upload the file, a server renders it, and you download a ZIP a few seconds later. That works, but it means a contract, a payslip or a medical letter sits on someone else's server for a while. I wanted a version where the PDF never leaves the browser tab. It turned out to be a nice little exercise in canvas, DPI math and browser quirks.

The tool is live at https://makequickwork.com/pdf/pdf-to-jpg/ if you want to follow along with DevTools open.

The pipeline

The whole thing is four steps, all client side:

  1. Read the File the user picked into an ArrayBuffer.
  2. Open it with PDF.js (Mozilla's PDF renderer).
  3. For each page, render to a <canvas> at the scale that matches the chosen DPI.
  4. Encode the canvas with canvas.toBlob() as JPEG or PNG, and if there are several pages, zip them with fflate.

One detail that matters for page speed: PDF.js is not small. The page itself is a static Astro page on Cloudflare Pages, and the PDF.js and fflate code is only downloaded after the user picks a file. Someone who lands on the page and leaves never pays for the library.

DPI to scale

PDF coordinates are in points, and a point is 1/72 inch. So a US Letter page reports a viewport of 612 × 792 at scale: 1. If you want an image at a given DPI, the scale factor is simply:

scale = dpi / 72
Enter fullscreen mode Exit fullscreen mode

The tool offers three presets:

Preset DPI Scale Letter page size
Screen 72 1.0 612 × 792 px
Standard 150 ~2.08 ~1275 × 1650 px
High 300 ~4.17 ~2550 × 3300 px

Standard is the default. It's sharp enough for chat apps and slides, and the files stay reasonable. High is for printing or zooming into small text.

The canvas size wall

This is the part that bit me. Browsers cap how big a canvas can be, and the caps differ. iOS Safari is the strictest: a canvas above roughly 16.7 million pixels simply fails, often silently, and you get a blank image. A normal Letter page at 300 DPI is about 8.4M pixels, so that's fine. But PDFs contain all kinds of pages: posters, architectural drawings, A0 sheets, a page someone exported at 200 inches wide. At 300 DPI those blow straight past the limit.

So the scale is clamped by both a maximum side length and a maximum pixel count:

// iOS Safari refuses canvases above ~16.7M pixels; stay below that everywhere.
const MAX_PIXELS = 16_000_000;
const MAX_SIDE = 16_000;

async function pageScale(doc, n, dpi) {
  const page = await doc.getPage(n);
  const { width, height } = page.getViewport({ scale: 1 });
  let scale = dpi / 72;
  scale = Math.min(
    scale,
    MAX_SIDE / width,
    MAX_SIDE / height,
    Math.sqrt(MAX_PIXELS / (width * height)),
  );
  return scale;
}
Enter fullscreen mode Exit fullscreen mode

The sqrt term is because pixel count grows with the square of the scale. I apply the same cap on every browser rather than sniffing for iOS. The trade-off is that a giant page comes out slightly smaller than the DPI you picked, and the tool page documents that, which beats producing a blank file.

White background, on purpose

PDF pages have no background by default. If you render to a fresh canvas and export PNG, you get transparent pixels wherever the page is "empty". That sounds nice until you open the PNG in a dark-mode chat app and your black text sits on a black background. JPEG doesn't support transparency at all, so transparent areas end up black or white depending on the encoder.

To keep JPG and PNG consistent, every page is rendered on white. PDF.js accepts a background option in page.render(), which saves a manual fillRect. It does mean the tool doesn't produce transparent PNGs, and I'd rather be upfront about that than surprise people.

Encoding and freeing memory

Encoding is just canvas.toBlob() wrapped in a promise. JPEG uses quality 0.92, PNG is lossless:

const canvas = await renderPage(doc, n, scale);
const blob = format === "jpg"
  ? await canvasToBlob(canvas, "image/jpeg", 0.92)
  : await canvasToBlob(canvas, "image/png");
// Free the bitmap right away; large PDFs would otherwise pile up memory.
canvas.width = canvas.height = 0;
Enter fullscreen mode Exit fullscreen mode

That last line matters more than it looks. A 300 DPI page is a ~34 MB RGBA bitmap. If you keep canvases around while processing a 60-page document, mobile browsers will kill the tab. Setting the dimensions to zero lets the browser release the backing store immediately instead of waiting for GC.

Pages are also processed one after another, not in parallel. It's a bit slower on a fast laptop, but memory stays flat, which is what decides whether it works on a phone.

Zipping without compressing

For "Download all", the images go into a ZIP with fflate's zipSync at level: 0, which means store only, no deflate. JPEG and PNG are already compressed, so deflating them again saves maybe 1–2% and costs real CPU time on a phone. Store-only zipping is close to a memcpy.

File names get zero-padded page numbers (report-page-01.jpg ... report-page-12.jpg) so they sort correctly in every file manager.

Thumbnails and single pages

After the file opens, each page gets a low-resolution thumbnail and its own Download button. That's the common case: you need page 3 of a PDF as an image to drop into a chat, not all 40 pages. Thumbnails render at a small fixed size, and the full-resolution render only happens when you actually download.

How do you know nothing is uploaded?

You don't have to trust me. Open the Network tab, pick a PDF, and convert it. You'll see the library scripts load and, if you allowed analytics, a few small Google Analytics requests (the site uses GA4 for anonymous visit counts), but no upload and no request carrying your file. The site also has automated tests that fail if any tool sends a request to a third party (other than that analytics) or makes any POST while processing a file, or if an analytics event contains the file name.

Note that you still need a connection to open the page and load the code; it's not an offline app.

Known limitations

  • Password-protected PDFs aren't supported yet.
  • Very large pages are scaled down slightly to stay under the canvas limit.
  • Output is always on white, no transparent PNGs.

Wrapping up

None of this is novel on its own, but putting it together carefully (lazy loading, the scale clamp, freeing canvases, store-only ZIP) is the difference between a demo and something that works on a five-year-old phone.

If you want to try it or poke at it in DevTools: https://makequickwork.com/pdf/pdf-to-jpg/. There's also a PNG-focused page at https://makequickwork.com/pdf/pdf-to-png/. I'd be curious whether anyone has found a cleaner way to detect the real canvas limit per browser instead of hard-coding a conservative one.

Top comments (0)