Every "free PDF tool" site works the same way: you upload your file, a server does the work, you download the result. Which is fine for a restaurant menu and considerably less fine for a signed contract, a payslip, or a scan of your passport.
A friend and I wanted to know how much of that could just happen locally. Turns out: most of it. But the boundary is in a place I didn't expect, and a couple of the "obvious" implementations are quietly wrong in ways that matter.
Here's the map, based on building about 27 of these.
The easy tier: structural edits
Merging, splitting, rotating, reordering pages, adding page numbers — none of this needs you to render anything. A PDF is a document object model. pdf-lib lets you open it, move objects around, and write it back out.
const merged = await PDFDocument.create();
for (const bytes of files) {
const src = await PDFDocument.load(bytes);
const pages = await merged.copyPages(src, src.getPageIndices());
pages.forEach(p => merged.addPage(p));
}
const out = await merged.save();
That's the whole merge tool. It runs in milliseconds and there is genuinely no reason for this to ever touch a server. The fact that it usually does is a business decision, not a technical one.
The middle tier: you need pixels
Converting to JPG, compressing, extracting images — now you need pdf.js to actually rasterize pages onto a canvas. Slower, heavier, but still entirely local.
The trap: redaction
This one deserves its own section because getting it wrong is a real problem, and plenty of tools do get it wrong.
The intuitive implementation of "redact this paragraph" is: draw a black rectangle over it. It looks perfect. The PDF opens with a black bar exactly where the sensitive text was.
The text is still there.
PDFs keep content in layers. A rectangle drawn on top is a new object; the text object underneath is untouched. Select-all and copy, or run any text extractor, and the "redacted" content comes straight out. This has burned actual law firms and government agencies — there's a genuine history of court filings being un-redacted by journalists with Ctrl+A.
Doing it properly means destroying the information, not covering it:
- Render the page to a canvas with
pdf.js - Fill the redaction rectangles black on the canvas
- Build a new PDF from the resulting raster image
You lose the text layer for that page — the output is a picture of a document rather than a document. That is not a side effect to apologise for, it's the entire point. If the text is still selectable, you didn't redact anything.
The gotcha: pdf-lib can't encrypt
I wanted a "password protect this PDF" tool. pdf-lib doesn't do encryption. Not a flag I missed — it isn't implemented.
@cantoo/pdf-lib is a fork that adds it, with the same API:
await doc.encrypt({ userPassword: pw, ownerPassword: pw });
The reverse direction is more interesting. PDFs have two kinds of password:
-
Owner password — restrictions on printing, copying, editing. This is a flag the viewer is politely asked to respect. Load with
ignoreEncryption: true, save again, restrictions gone. It was never security. - User password — actual encryption. The file content is genuinely encrypted and without the password there is nothing to recover.
So the unlock tool removes the first and cannot touch the second, and the FAQ says exactly that. I'd rather explain the limit than have someone assume we're a cracking service.
"Editing" a PDF, honestly
The flagship tool is editing, and I want to be precise about what it does, because most tools in this space are vague on purpose.
You are not editing the original text stream. Reflowing text inside an existing PDF means reconstructing font metrics, kerning and line breaking for embedded subsets — that's a research project, not a weekend feature.
What you can do, and what actually solves the problem people have, is overlay: white-out box over the old text, new text on top, plus drawing, images and signatures. Export flattens it all with pdf-lib. From the user's side it reads as editing. It also works on scans, where there is no text layer at all.
The one thing that will eat an afternoon is coordinates. The browser puts the origin top-left, PDF points put it bottom-left, and your canvas is at some display scale that isn't 1:1:
const pdfX = cssX / scale;
const pdfY = pageHeight - (cssY / scale) - elementHeight;
Get that wrong and everything lands mirrored down the page, which is a genuinely funny bug the first time.
OCR, and being consistent about it
For scanned documents you need OCR, and tesseract.js is excellent. By default it fetches its worker, its WASM core and the language data — about 24MB — from a CDN at runtime.
Which would have meant a site whose entire premise is "your files never leave your browser" reaching out to a third party the moment you use it. Not the same thing as uploading your file, but not a distinction I wanted to have to explain either.
So it's all self-hosted:
const worker = await createWorker(lang, OEM.LSTM_ONLY, {
corePath: '/tesseract/',
langPath: '/tesseract/lang'
});
Bigger deploy, zero external calls. If you're making a privacy claim, it should survive someone opening the network tab.
What genuinely doesn't work well
PDF to Word. You can pull the text out with pdf.js and group it into lines by Y coordinate, and we ship that — but layout reconstruction, tables and styling are not happening client-side at any quality worth defending. The tool says so up front rather than producing a mess and letting you find out.
PowerPoint we skipped entirely. Bad effort-to-value ratio.
Where it landed
27 tools, all client-side, free, no watermark, no sign-up, no upload. Built with Astro, deployed as a static site. English and Spanish.
The stack is basically pdf-lib / @cantoo/pdf-lib for structure, pdf.js for rendering, tesseract.js for OCR, mammoth and docx for Word, SheetJS for spreadsheets. All of it has been sitting in npm for years. Nobody needed to build the upload-a-server version of this in the first place.
If you're doing something similar and hit a case where the browser genuinely can't cut it, I'd like to hear which one.
Top comments (0)