Most "free online PDF tools" work the same way: you upload a contract, a bank statement or a scan of your passport, a server does something to it, and you download the result. That's a lot of trust for merging two files.
We recently built a set of 18 free PDF tools that do the opposite: the file is read, changed and saved on your device, and its contents never reach a server. The browser can do most of this with two open-source libraries and a <canvas>. Here's how the pieces fit together, with the code that matters and the limits we ran into.
The building blocks
- pdf-lib edits the structure of a PDF: copy pages between documents, rotate them, read and write metadata, draw text and images. It doesn't render anything.
- PDF.js is Firefox's PDF renderer. It turns pages into pixels and gives you the text layer.
- The canvas decodes and re-encodes images, which is what you need for PDF to image and for compression.
Everything starts the same way: a file input, turned into bytes.
const input = document.querySelector('input[type="file"]');
input.addEventListener("change", async () => {
const files = await Promise.all(
[...input.files].map(async (file) => new Uint8Array(await file.arrayBuffer())),
);
// files is an array of Uint8Array, nothing has left the device
});
And everything ends the same way: bytes, turned back into a download.
function download(bytes, name, type = "application/pdf") {
const url = URL.createObjectURL(new Blob([bytes], { type }));
const link = Object.assign(document.createElement("a"), { href: url, download: name });
link.click();
setTimeout(() => URL.revokeObjectURL(url), 1000);
}
Structural edits with pdf-lib
Merging is the classic example. You create an empty document and copy every page of every source into it:
import { PDFDocument } from "pdf-lib";
export async function mergePdfs(files) {
const output = await PDFDocument.create();
for (const bytes of files) {
const source = await PDFDocument.load(bytes, { updateMetadata: false });
const pages = await output.copyPages(source, source.getPageIndices());
pages.forEach((page) => output.addPage(page));
}
return output.save({ useObjectStreams: true });
}
Split and extract are the same function with a different list of page indices. Two details are worth copying:
-
updateMetadata: falsestops pdf-lib from rewriting the producer and dates of the file you just opened. Set them deliberately when you save instead. - Encrypted PDFs throw on load. Catch that and say so in plain words, otherwise the user sees a stack trace for a file that opens fine in their viewer.
Rotation has one trap: pages can already carry a rotation, so you add to it instead of overwriting it.
import { degrees } from "pdf-lib";
function rotatePage(page, angle) {
const current = page.getRotation().angle;
const next = (((Math.round((current + angle) / 90) * 90) % 360) + 360) % 360;
page.setRotation(degrees(next));
}
Without that, rotating a scanned page that was already stored sideways turns it the wrong way, and you get bug reports you can't reproduce with your own test files.
Rendering pages with PDF.js
PDF to PNG or JPEG means rendering each page onto a canvas. PDF.js does the hard part, but its setup decides whether it works under a strict Content Security Policy.
PDF.js parses documents in a web worker and fetches extra data at runtime: character maps for CJK text, the standard fonts, and in recent versions WebAssembly image decoders and ICC color profiles. We self-host all of them next to the worker, so the CSP can stay at worker-src 'self' and no third-party origin ever sees a request.
import * as pdfjs from "pdfjs-dist";
const assets = `/vendor/pdfjs/${pdfjs.version}`;
pdfjs.GlobalWorkerOptions.workerSrc = `${assets}/pdf.worker.min.mjs`;
export async function openPdf(bytes) {
return pdfjs.getDocument({
// PDF.js transfers the buffer to its worker, so pass a copy and keep the original usable.
data: bytes.slice(),
cMapUrl: `${assets}/cmaps/`,
cMapPacked: true,
standardFontDataUrl: `${assets}/standard_fonts/`,
}).promise;
}
The bytes.slice() matters. Without it, the original array is detached after the first call, and any second operation on the same file fails with an empty buffer.
Rendering itself is a matter of scale. PDF coordinates are points, 72 per inch, so 150 dpi means a scale of 150 / 72:
const MAX_PIXELS = 16_000_000; // Safari refuses canvases much larger than this
export async function pageToImage(pdf, pageNumber, { dpi = 150, type = "image/png" } = {}) {
const page = await pdf.getPage(pageNumber);
const unit = page.getViewport({ scale: 1 });
let scale = dpi / 72;
const pixels = unit.width * unit.height * scale * scale;
if (pixels > MAX_PIXELS) scale *= Math.sqrt(MAX_PIXELS / pixels);
const viewport = page.getViewport({ scale });
const canvas = document.createElement("canvas");
canvas.width = Math.floor(viewport.width);
canvas.height = Math.floor(viewport.height);
await page.render({
canvas,
viewport,
// JPEG has no transparency: paint white behind pages without a background.
background: type === "image/jpeg" ? "#ffffff" : undefined,
}).promise;
const blob = await new Promise((resolve) => canvas.toBlob(resolve, type, 0.9));
page.cleanup();
canvas.width = canvas.height = 0; // release the canvas memory right away
return blob;
}
The pixel cap matters more than it looks. A large-format drawing at 300 dpi easily exceeds what Safari will allocate, and the render fails or comes back blank instead of throwing a useful error. Capping the area and scaling down keeps the output useful on every browser.
Compression without a server
Compression sounds like it needs a server. It doesn't, as long as you only touch images. Most of the weight in a typical oversized PDF is photos and scans, so our compressor leaves text, vector graphics and fonts alone and only touches embedded images:
- Walk the document's objects with pdf-lib and collect the image streams. Images under 256 pixels on their longest side aren't worth the effort and stay as they are.
- Decode them. JPEGs go straight to the canvas. Flate-compressed raw images are inflated with fflate, and PNG predictors are undone by hand.
- Draw the pixels onto a canvas, scaled so the longest side stays below a limit, and export a JPEG at lower quality.
- Keep the new version only if it's at least 10% smaller. Otherwise the original stays. Then set the stream's filter and dimensions and save with object streams.
We offer three levels. The strongest keeps the longest image side at 1,200 pixels and JPEG quality 0.6, which is roughly 150 dpi on a full A4 page. The lightest keeps 2,600 pixels at 0.85.
Some images are better left alone than rewritten badly: CMYK, indexed colors and 16-bit samples would change color on the way through a canvas, so we skip them. Images with a soft mask, the PDF way of storing transparency, get recompressed but keep their pixel size, so image and mask still line up. At the end the tool reports how many images it found and how many it actually recompressed. An honest "we saved 4%" beats a smaller file that looks wrong.
HTML to PDF: the one thing a browser can only print
A browser has exactly one way to turn HTML into a PDF: print it. So the HTML to PDF tool renders your markup in a sandboxed iframe and opens the print dialog, where you pick "Save as PDF". Page size and margins come from CSS:
@page {
size: A4;
margin: 15mm;
}
@media print {
.no-print {
display: none;
}
}
In Chrome and Edge the result comes from Chromium's layout engine, so it's faithful to modern CSS. What you can't do from a web page is skip the dialog, control the browser's own headers and footers reliably, or wait for remote fonts and scripts the way a headless browser can. That's the point where client-side stops and you need Chromium running on a server.
Write it once, test it in Node
One design choice saved us a lot of time: every operation is a plain function from bytes to bytes, and anything that needs a canvas gets one passed in.
interface RasterEnvironment<Canvas, Output> {
createCanvas(width: number, height: number): Canvas;
encode(canvas: Canvas, type: "png" | "jpeg", quality?: number): Promise<Output>;
}
In the browser, the environment creates DOM canvases and calls toBlob. In the test suite, the same functions run in Node with a native canvas package. Merge, split, rotate, compress and PDF to image are all covered by unit tests, without spinning up a browser for every case.
Where the browser runs out
Client-side isn't free of trade-offs:
- Memory is the user's. Files of several hundred megabytes depend on how much memory the tab gets. When a file fails to open, closing other tabs often helps.
- Rendering is PDF.js, not Acrobat. Unusual font encodings and some blend modes can look slightly different from desktop viewers. XFA forms we turn off entirely.
- Text you add needs fonts. pdf-lib's built-in standard fonts only cover Latin characters. Watermarks or page numbers in other scripts need an embedded font, and failing with a clear message beats drawing empty boxes.
- No automation. A tool in a tab is great for one file and useless for ten thousand a night. That's what a server-side API is for.
Takeaways
- Read files with
arrayBuffer(), hand back results with a Blob URL, and nothing ever needs to be uploaded. - pdf-lib covers structure, PDF.js covers pixels and text, and the canvas covers image encoding.
- Self-host the PDF.js worker and its assets, pass it a copy of your bytes, and cap canvas sizes.
- Keep operations as pure byte-in, byte-out functions so they run in Node for tests.
If you've built something similar, which PDF edge cases bit you?
Top comments (0)