When I started building Tooliest, I made one rule for myself: no file ever gets uploaded to a server. Every tool had to run entirely in the browser.
Most categories were straightforward. A JSON formatter is just:
JSON.stringify(JSON.parse(input), null, 2)
A regex tester is a few lines of JavaScript. A CSS gradient generator is sliders and template literals.
Then I got to PDF tools.
PDF is a notoriously complex format. It's a 700-page specification. Most "browser-based" PDF tools are actually just thin frontends that POST your file to a backend running LibreOffice or Ghostscript. The no-upload constraint meant I had to find a completely different approach.
Here's exactly how I built 15 PDF tools that genuinely run in the browser — the libraries I used, the architecture decisions, and the edge cases that bit me.
The core library: pdf-lib
pdf-lib is a pure JavaScript library for creating and modifying PDFs. No native dependencies, no WebAssembly binary to load, no backend. It runs in any modern browser.
Install:
npm install pdf-lib
Or via CDN:
<script src="https://unpkg.com/pdf-lib/dist/pdf-lib.min.js"></script>
The mental model is clean. You load a PDF into a PDFDocument object, manipulate pages and content, then call .save() to get raw bytes back:
import { PDFDocument, StandardFonts, rgb } from 'pdf-lib';
const pdfDoc = await PDFDocument.create();
const page = pdfDoc.addPage([595, 842]); // A4 dimensions in points
const font = await pdfDoc.embedFont(StandardFonts.Helvetica);
page.drawText('Hello from the browser', {
x: 50,
y: 750,
size: 30,
font,
color: rgb(0, 0, 0),
});
const pdfBytes = await pdfDoc.save();
// Download it
const blob = new Blob([pdfBytes], { type: 'application/pdf' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'output.pdf';
a.click();
Everything happens in memory. Nothing touches a server. Let's build the actual tools.
1. PDF Merger
The most-used tool on the site. The logic is surprisingly clean:
import { PDFDocument } from 'pdf-lib';
async function mergePDFs(fileArray) {
const mergedPdf = await PDFDocument.create();
for (const file of fileArray) {
const arrayBuffer = await file.arrayBuffer();
const pdf = await PDFDocument.load(arrayBuffer);
const pageIndices = pdf.getPageIndices();
const copiedPages = await mergedPdf.copyPages(pdf, pageIndices);
copiedPages.forEach(page => mergedPdf.addPage(page));
}
const mergedBytes = await mergedPdf.save();
return mergedBytes;
}
copyPages is the key method here — it handles all internal reference resolution. Fonts, images, and annotations embedded in each source page all get carried across correctly into the new document. You don't have to think about the internal PDF object graph at all.
The UI layer uses drag-and-drop for file ordering, and PDF.js to render thumbnail previews of each page before the user commits to the merge. More on thumbnail rendering below.
2. PDF Splitter
Split a PDF by custom page ranges, extract specific pages, or divide into equal chunks:
async function splitPDF(file, ranges) {
// ranges is an array of page index arrays
// e.g. [[0, 1, 2], [3, 4], [5, 6, 7]] → three separate PDFs
const arrayBuffer = await file.arrayBuffer();
const sourcePdf = await PDFDocument.load(arrayBuffer);
const outputFiles = [];
for (const pageIndices of ranges) {
const newPdf = await PDFDocument.create();
const copiedPages = await newPdf.copyPages(sourcePdf, pageIndices);
copiedPages.forEach(page => newPdf.addPage(page));
const bytes = await newPdf.save();
outputFiles.push(bytes);
}
return outputFiles;
}
For the UI, I parse a range string like "1-3, 5, 7-9" into index arrays:
function parsePageRanges(rangeString, totalPages) {
const ranges = [];
const parts = rangeString.split(',').map(s => s.trim());
for (const part of parts) {
if (part.includes('-')) {
const [start, end] = part.split('-').map(n => parseInt(n) - 1);
const indices = [];
for (let i = start; i <= Math.min(end, totalPages - 1); i++) {
indices.push(i);
}
ranges.push(indices);
} else {
const pageNum = parseInt(part) - 1;
if (pageNum >= 0 && pageNum < totalPages) {
ranges.push([pageNum]);
}
}
}
return ranges;
}
3. PDF Compressor
This is where things get more nuanced. pdf-lib doesn't natively resample images embedded inside PDFs — it just repacks the document structure.
Two-level compression strategy:
Level 1 — Structure compression (always applied):
const compressedBytes = await pdfDoc.save({
useObjectStreams: true, // Compresses internal stream objects
});
useObjectStreams: true compresses the internal object streams and typically reduces file size by 10–25% with zero quality loss. Always use this.
Level 2 — Image resampling (for aggressive compression):
For deeper compression, I render each page to a canvas via PDF.js at reduced scale, then re-embed the result:
import * as pdfjsLib from 'pdfjs-dist';
async function compressByRendering(arrayBuffer, scale = 0.8) {
const pdf = await pdfjsLib.getDocument({ data: arrayBuffer }).promise;
const newPdf = await PDFDocument.create();
for (let i = 1; i <= pdf.numPages; i++) {
const page = await pdf.getPage(i);
const viewport = page.getViewport({ scale });
const canvas = document.createElement('canvas');
canvas.width = viewport.width;
canvas.height = viewport.height;
await page.render({
canvasContext: canvas.getContext('2d'),
viewport,
}).promise;
const imageData = canvas.toDataURL('image/jpeg', 0.75);
const base64 = imageData.split(',')[1];
const imageBytes = Uint8Array.from(atob(base64), c => c.charCodeAt(0));
const jpegImage = await newPdf.embedJpg(imageBytes);
const { width, height } = viewport;
const newPage = newPdf.addPage([width, height]);
newPage.drawImage(jpegImage, { x: 0, y: 0, width, height });
}
return await newPdf.save({ useObjectStreams: true });
}
⚠️ Trade-off: Level 2 compression converts vector text to rasterized JPEG, which looks fine on screen but loses text selectability and sharpness when printed at high DPI. I give users both options and label them clearly.
4. PDF Password Protection
pdf-lib has built-in encryption support:
async function protectPDF(file, userPassword, ownerPassword) {
const arrayBuffer = await file.arrayBuffer();
const pdfDoc = await PDFDocument.load(arrayBuffer);
const pdfBytes = await pdfDoc.save({
encrypted: true,
userPassword: userPassword,
ownerPassword: ownerPassword || userPassword,
permissions: {
printing: 'lowResolution',
modifying: false,
copying: false,
annotating: false,
fillingForms: true,
contentAccessibility: true,
documentAssembly: false,
},
});
return pdfBytes;
}
This generates AES-128 encrypted output that Acrobat, Preview, Foxit, and every standard PDF reader respects. The permissions object lets you set fine-grained access — for example, allowing form filling but blocking copy-paste.
5. PDF Watermark
Text watermarks across every page, with rotation and opacity:
import { PDFDocument, StandardFonts, rgb, degrees } from 'pdf-lib';
async function addWatermark(file, text, options = {}) {
const {
opacity = 0.3,
angle = 45,
fontSize = 60,
color = rgb(0.5, 0.5, 0.5),
} = options;
const arrayBuffer = await file.arrayBuffer();
const pdfDoc = await PDFDocument.load(arrayBuffer);
const font = await pdfDoc.embedFont(StandardFonts.HelveticaBold);
const pages = pdfDoc.getPages();
for (const page of pages) {
const { width, height } = page.getSize();
const textWidth = font.widthOfTextAtSize(text, fontSize);
page.drawText(text, {
x: width / 2 - textWidth / 2,
y: height / 2 - fontSize / 2,
size: fontSize,
font,
color,
opacity,
rotate: degrees(angle),
});
}
return await pdfDoc.save();
}
6. PDF Page Rotator
import { PDFDocument, degrees } from 'pdf-lib';
async function rotatePages(file, pageIndices, rotation) {
// rotation: 90, 180, or 270
const arrayBuffer = await file.arrayBuffer();
const pdfDoc = await PDFDocument.load(arrayBuffer);
const pages = pdfDoc.getPages();
for (const index of pageIndices) {
const page = pages[index];
const currentRotation = page.getRotation().angle;
page.setRotation(degrees((currentRotation + rotation) % 360));
}
return await pdfDoc.save();
}
7. PDF Page Numbers
import { PDFDocument, StandardFonts, rgb } from 'pdf-lib';
async function addPageNumbers(file, options = {}) {
const {
position = 'bottom-center',
startNumber = 1,
fontSize = 12,
format = 'Page {n} of {total}',
} = options;
const arrayBuffer = await file.arrayBuffer();
const pdfDoc = await PDFDocument.load(arrayBuffer);
const font = await pdfDoc.embedFont(StandardFonts.Helvetica);
const pages = pdfDoc.getPages();
const total = pages.length;
pages.forEach((page, i) => {
const { width, height } = page.getSize();
const label = format
.replace('{n}', i + startNumber)
.replace('{total}', total + startNumber - 1);
const textWidth = font.widthOfTextAtSize(label, fontSize);
const positions = {
'bottom-center': { x: width / 2 - textWidth / 2, y: 20 },
'bottom-right': { x: width - textWidth - 20, y: 20 },
'bottom-left': { x: 20, y: 20 },
'top-center': { x: width / 2 - textWidth / 2, y: height - 30 },
};
const { x, y } = positions[position] || positions['bottom-center'];
page.drawText(label, {
x, y,
size: fontSize,
font,
color: rgb(0, 0, 0),
});
});
return await pdfDoc.save();
}
The Web Worker architecture (critical for large files)
Processing a 50MB PDF on the main thread freezes the browser tab. The fix is straightforward — move all pdf-lib work into a Web Worker and transfer ArrayBuffer ownership instead of copying it:
// main.js
const worker = new Worker('pdf-worker.js');
const fileBuffer = await file.arrayBuffer();
worker.postMessage(
{ action: 'merge', buffers: [fileBuffer] },
[fileBuffer] // Transfer ownership — avoids 50MB copy
);
worker.onmessage = (e) => {
const { result } = e.data;
downloadPDF(result);
};
worker.onerror = (e) => {
console.error('Worker error:', e.message);
};
// pdf-worker.js
importScripts('https://unpkg.com/pdf-lib/dist/pdf-lib.min.js');
const { PDFDocument } = PDFLib;
self.onmessage = async (e) => {
const { action, buffers } = e.data;
try {
let result;
if (action === 'merge') result = await mergePDFs(buffers);
if (action === 'split') result = await splitPDF(buffers[0], e.data.ranges);
self.postMessage({ result }, [result.buffer]);
} catch (err) {
self.postMessage({ error: err.message });
}
};
The [fileBuffer] in postMessage transfers ownership of the buffer to the worker thread — the main thread can no longer access it, but you avoid a full memory copy. For a 50MB file, this is the difference between 50MB and 100MB peak memory usage.
Page thumbnail previews with PDF.js
The merger, splitter, and page reorder tools all need visual page previews. That's PDF.js territory:
import * as pdfjsLib from 'pdfjs-dist';
pdfjsLib.GlobalWorkerOptions.workerSrc =
'https://unpkg.com/pdfjs-dist/build/pdf.worker.min.js';
async function renderThumbnail(arrayBuffer, pageNumber, canvas, scale = 0.25) {
const pdf = await pdfjsLib.getDocument({ data: new Uint8Array(arrayBuffer) }).promise;
const page = await pdf.getPage(pageNumber); // 1-indexed
const viewport = page.getViewport({ scale });
canvas.width = viewport.width;
canvas.height = viewport.height;
await page.render({
canvasContext: canvas.getContext('2d'),
viewport,
}).promise;
}
Render at scale: 0.25 for thumbnails — fast to generate and sufficient resolution for a visual preview. For the full-size preview modal, use scale: 1.0.
Handling encrypted PDFs
Some user PDFs are password-protected. pdf-lib throws when you try to load them without providing the password:
async function loadPDFSafely(arrayBuffer, password = '') {
try {
return await PDFDocument.load(arrayBuffer, {
password,
ignoreEncryption: false,
});
} catch (err) {
if (err.message.includes('encrypted')) {
throw new Error('This PDF is password protected. Please enter the password.');
}
throw err;
}
}
Show a password input field in the UI when this error is caught, then retry with the user-provided password.
The complete PDF tool list
All 15 tools built with this stack:
| Tool | Key technique |
|---|---|
| PDF Merger |
copyPages across multiple documents |
| PDF Splitter | Range parsing + selective copyPages
|
| PDF Compressor |
useObjectStreams + optional canvas re-render |
| PDF Page Rotator | page.setRotation(degrees(...)) |
| PDF Page Reorder | Drag-and-drop indices + selective copyPages
|
| PDF Page Extractor | Single-range split |
| PDF Page Deleter | Inverted page selection |
| PDF Watermark |
page.drawText with opacity + rotation |
| PDF Page Numbers |
page.drawText with position calculation |
| PDF Password Protect | save({ encrypted: true, ... }) |
| Online Signature Maker | Canvas drawing → PNG → embedPng
|
| PDF to Images | PDF.js page render → canvas → download |
| Images to PDF |
embedJpg / embedPng + addPage
|
| Text to PDF |
drawText with line-wrapping logic |
| PDF to Text | PDF.js getTextContent()
|
What I'd do differently
Use a SharedArrayBuffer for very large files. For PDFs above 100MB, even transferring ownership has overhead. SharedArrayBuffer lets both threads access the same memory without any transfer cost — though it requires Cross-Origin-Opener-Policy and Cross-Origin-Embedder-Policy headers, which complicated my Netlify config.
Lazy-load pdf-lib. At 800KB minified, pdf-lib is not small. I now only import it when a user actually opens a PDF tool, not on initial page load. Shaved ~1.2 seconds off Time to Interactive for non-PDF pages.
Stream large output files. For PDFs above ~30MB output, calling URL.createObjectURL(blob) and triggering a download works fine. But showing progress during the save operation requires streaming, which pdf-lib doesn't natively support yet. Something to watch in future releases.
Try the tools
👉 tooliest.com/category/pdf — all 15 PDF tools, nothing uploaded.
If you hit a specific edge case I haven't covered — encrypted PDFs, unusual page sizes, corrupted source files — drop it in the comments. I've been through most of the weird cases at this point and happy to share what I found.
The full stack for Tooliest is static HTML/JS on Netlify free tier — $0 hosting, global CDN, zero backend. If you're building browser-based tools and want to compare notes on the architecture, find me in the comments.
Top comments (0)