I kept needing a PNG of one PDF page.
Every site I tried wanted the file uploaded first. For a tax form or a contract, that felt wrong. So I built the conversion in the browser instead.
No backend job. No "we're processing your file." The PDF stays in the tab, gets drawn to a canvas, and you download a PNG.
The actual problem
Most "PDF to PNG" tools are a form + a server.
That is fine for a flyer. It is not fine when the PDF has an address, a passport scan, or an invoice. Andergrove puts it bluntly: never upload a document you would not email to a stranger. Once you press upload, copies exist on someone else's disk until they delete them.
PrivaPDF's write-up has the test I now run on any "free converter": open DevTools → Network, drop a file, and watch for a POST. If the file leaves the tab, it is not local.
A browser can already do this. Mozilla's PDF.js can parse the file, page.render() can paint a page onto a canvas, and you export a PNG. The server never sees the bytes.
The core loop
The official PDF.js examples are the skeleton. The only extra is feeding getDocument the bytes from a <input type="file"> instead of a URL (getDocument docs):
import * as pdfjsLib from 'pdfjs-dist'
const pdf = await pdfjsLib.getDocument({ data: new Uint8Array(await file.arrayBuffer()) }).promise
const page = await pdf.getPage(pageNumber)
const scale = dpi / 72
const viewport = page.getViewport({ scale })
const canvas = document.createElement('canvas')
canvas.width = viewport.width
canvas.height = viewport.height
const ctx = canvas.getContext('2d')
ctx.fillStyle = '#ffffff'
ctx.fillRect(0, 0, canvas.width, canvas.height)
await page.render({ canvasContext: ctx, viewport }).promise
const png = canvas.toDataURL('image/png')
await pdf.destroy()
dpi / 72 is the only scale math that matters. Adobe's own docs say default user space is 1/72 of an inch. PDF.js says the same thing in the examples: the viewport at scale 1 is 72 DPI. So 150 / 72 is "render this page at 150 DPI." ISO 32000 calls that unit a user unit; iText has a readable walkthrough if you want the spec language.
150 DPI is sharp on a screen without melting a phone.
Point GlobalWorkerOptions.workerSrc at pdfjs-dist/build/pdf.worker.min.mjs or the main thread will stall on a 20-page file. The PDF.js FAQ exists because everyone forgets this once.
Things I hit that the happy-path tutorial skips
The file has to stay in memory. A React state object with fileName: "report.pdf" is not a PDF. If you only persist the name, you will render a fake page and the user will download the wrong PNG. Keep the File in a ref. getDocument needs the bytes (API).
High DPI will OOM the tab. Cap the long side (I use 2400px). 300 DPI on A4 is already a huge canvas. 50 pages at that size will lock Chrome. Chromium even has an internal GPU budget for canvases so the compositor does not starve (HTMLCanvasElement.cpp).
Page count comes from the PDF, not the UI. pdf.numPages is the source of truth. A leftover default of 3 will convert the wrong range.
Transparent background is a lie on most real PDFs. If the page already painted white, clearRect does nothing useful. PDF.js lets you pass background: 'transparent' on render(), but a flattened Canva export is still an opaque bitmap.
toDataURL is fine for a preview. Download via a Blob. MDN now warns on toDataURL: it builds one giant in-memory string and can overflow URL length limits. Prefer toBlob() + URL.createObjectURL.
Why I put a bunch of these in one place
Once the PDF path worked, the same pattern showed up everywhere: convert in the tab, don't upload, let people pin the tools they actually reuse.
That's what I shipped as Utily — a pile of small file/dev tools that run locally. The one this post is about is PDF to PNG.
I'm not going to list 20 others here. If you only need this conversion, the snippet above is enough to build your own.
What I would not do
Don't send the ArrayBuffer to your own API "just to be safe." You just rebuilt the thing you were avoiding.
Don't promise "unlimited pages at 600 DPI" in a browser. Say the cap. People trust that more.
If you want the hosted version, it's on Utily. If you want to own it, the loop above is the whole product.
Top comments (1)
The DevTools-Network test you borrowed is the right instinct, but it is worth naming what it can and cannot prove: it shows this run did not upload, not that the tool cannot. A conditional POST - only for files over some size, only on a retry path, only after a flag flips next week - passes that test every time you run it.
The version that is actually enforceable is a Content-Security-Policy on your own page: connect-src 'none' plus form-action 'none'. That covers fetch, XHR, WebSocket and sendBeacon in one rule, it is enforced by the browser rather than promised by you, and a violation shows up in the console instead of silently succeeding. It also survives the case your post is really about, which is not you - it is the next maintainer, or a dependency that decides to add telemetry. "I checked the Network tab" does not survive a transitive dependency; a CSP does.
One wrinkle that matters for your setup specifically: point workerSrc at a self-hosted copy rather than a CDN, or the page needs network on first load to do anything at all. Once it is local, you get the strongest demo there is - turn the network off entirely and convert a file anyway. That is a claim a user can verify in five seconds without opening DevTools, and it is much harder to argue with than a screenshot of an empty request list.
Curious whether you hit a CSP conflict with the pdf.js worker - does a blob: or worker-src exception end up being needed, or does self-hosting keep the policy clean?