Turning a stack of images into a single PDF is one of those small tasks that comes up constantly: expense receipts, photographed whiteboards, scanned contracts, screenshots for a bug report. The usual answer is an online converter — upload your files to someone's server and download the result. It works, but your images take a detour through infrastructure you don't control.
There's a better default: convert entirely in the browser, with zero uploads. Modern browsers give you everything needed to read image files, lay them out, and emit a real PDF without a single network request.
Why "no upload" matters
For casual conversions the stakes look low, but images are often the most sensitive files people handle. A photo of a receipt shows where you were and what you bought; a scanned ID or contract is self-evidently private. Uploading those to a free converter means trusting its retention policy, its security practices, and every third-party script it loads. A no-upload tool sidesteps all of that: the file never leaves your device, so there's nothing to leak, retain, or subpoena.
There are practical wins too. No upload means no waiting on slow connections, no file-size caps designed to push you toward a paid tier, and the tool keeps working offline or on restricted networks.
Two ways to build it
If you're implementing this yourself, two approaches cover nearly every use case.
jsPDF. The pragmatic choice. Load your images, create a jsPDF instance, and call addImage() once per page. It handles JPEG and PNG natively, lets you set page dimensions per image, and produces a valid PDF in a handful of lines:
import { jsPDF } from "jspdf";
const pdf = new jsPDF({ unit: "pt" });
images.forEach((img, i) => {
const pageWidth = pdf.internal.pageSize.getWidth();
const pageHeight = (img.height / img.width) * pageWidth;
if (i > 0) pdf.addPage([pageWidth, pageHeight]);
pdf.addImage(img.dataUrl, "JPEG", 0, 0, pageWidth, pageHeight);
});
pdf.save("images.pdf");
You get multi-page output, per-page sizing, and metadata support almost for free. The trade-off is bundle size (a few hundred KB) and less control over low-level PDF structure — fine for 99% of cases.
Canvas-first, hand-rolled layout. Draw each image onto a <canvas>, then feed the canvas into your PDF writer. This shines when you want multiple images per page, captions, margins, or watermarks — you compose the exact page in canvas pixels first, then export. It's more code and more decisions (DPI handling, text rendering), but you own every pixel.
My rule of thumb: reach for jsPDF when each image gets its own full page; reach for canvas composition when the page itself is a designed layout.
Putting it together: the moving parts
Whichever route you take, the surrounding plumbing is the same:
-
Ingest. A file input or drag-and-drop zone,
URL.createObjectURL()for each file, andcreateImageBitmap()or anImageelement to read dimensions. Validate types up front — browsers happily hand you a.heicrenamed to.jpg, so sniff the actual format. - Order. Thumbnails with drag-to-reorder. Order is the one piece of state users always want to fix after the fact.
- Layout choice. Fit-to-image pages (each page matches its image's aspect ratio) versus fixed pages like A4/Letter with the image centered. Fit-to-image is the most common expectation.
- Memory. Process and release images one at a time on large batches; revoke object URLs when done. A 50-photo batch can otherwise eat a gigabyte of RAM.
If you'd rather skip the code
Not every conversion deserves a coding session. FileOnTap's image-to-PDF tool implements exactly the approach above: drop in your images, reorder them, and export a PDF — all locally in your browser, no uploads, no account, no watermark.
Disclosure: I'm the developer of FileOnTap.
Takeaway
The browser is a legitimate document-processing runtime now. File APIs for ingest, canvas for layout, and libraries like jsPDF for output cover the whole pipeline. Whether you build it or use a ready-made tool, keeping the conversion local is the simplest privacy policy there is: your files never go anywhere.













