When I started building ToolZip, I made one rule: no file uploads. Everything had to run in the browser.
Images were straightforward — the Canvas API handles compression, conversion, and cropping natively. But PDF manipulation was a different challenge entirely. PDFs are binary, complex, and traditionally require server-side processing.
Here's how I built 6 PDF tools that run entirely client-side, and what I learned along the way.
The Tools
The PDF category now includes:
- PDF Merge — Combine multiple PDFs, reorder before merging
- PDF Split — Extract pages by range or split into individual files PDF Compress — Reduce file size using object streams
- Image to PDF — Convert JPG/PNG images into a single PDF
- PDF Rotate — Rotate individual pages or all at once
- PDF to Image — Export each page as PNG or JPG
All of them run in the browser. No file ever leaves the user's device.
The Core Library: pdf-lib
pdf-lib is the foundation for 5 of the 6 tools. It's a pure JavaScript library that can create and modify PDFs entirely in the browser using ArrayBuffers.
import { PDFDocument, degrees } from 'pdf-lib';
// Load a PDF from a File input
const arrayBuffer = await file.arrayBuffer();
const pdf = await PDFDocument.load(arrayBuffer);
// Copy pages to a new document
const newPdf = await PDFDocument.create();
const [page] = await newPdf.copyPages(pdf, [0]); // page index 0
newPdf.addPage(page);
// Save and download
const bytes = await newPdf.save();
const blob = new Blob([bytes], { type: 'application/pdf' });
The API is clean and the library handles the complexity of PDF structure internally. For merge, split, rotate, compress, and image-to-PDF — pdf-lib covers everything.
PDF Merge
The merge tool lets users add multiple files, reorder them with up/down buttons, then combine into one PDF.
const merged = await PDFDocument.create();
for (const file of orderedFiles) {
const bytes = await file.arrayBuffer();
const pdf = await PDFDocument.load(bytes);
const pages = await merged.copyPages(pdf, pdf.getPageIndices());
pages.forEach(page => merged.addPage(page));
}
const output = await merged.save();
The key insight: copyPages handles all the internal reference copying. You don't need to worry about fonts, images, or annotations embedded in each page — they come along automatically.
PDF Split
Split supports two modes: extract a page range, or split every page into a separate file.
// Parse ranges like "1-3, 5, 7-9" into zero-indexed page numbers
const parseRange = (rangeStr, total) => {
const pages = [];
rangeStr.split(',').forEach(part => {
const [start, end] = part.trim().split('-').map(n => parseInt(n) - 1);
if (end !== undefined) {
for (let i = start; i <= end; i++) pages.push(i);
} else {
pages.push(start);
}
});
return pages.filter(p => p >= 0 && p < total);
};
For "split all pages" mode, I create a separate PDF for each page and trigger sequential downloads with a small delay between them to avoid browser throttling:
for (let i = 0; i < pageCount; i++) {
const singlePage = await PDFDocument.create();
const [page] = await singlePage.copyPages(srcPdf, [i]);
singlePage.addPage(page);
const output = await singlePage.save();
downloadBlob(output, `page_${i + 1}.pdf`);
await new Promise(r => setTimeout(r, 200)); // prevent throttling
}
PDF Compress
PDF compression is the trickiest of the six tools to explain honestly. pdf-lib's save() method accepts a useObjectStreams option that compresses the PDF's internal object references:
const output = await pdf.save({ useObjectStreams: true });
This works well for PDFs with many internal objects (text-heavy documents, lots of annotations). But PDFs that are already compressed, or that are mostly scanned images, won't shrink much this way.
For significant image compression inside PDFs, you'd need to re-render each page and re-embed the images at lower quality — which is possible but much more complex. I've kept the current implementation simple and transparent: it shows users the before and after size so they can see exactly what changed.
Image to PDF
This one is straightforward: load images, draw them onto PDF pages at the image's natural dimensions.
const pdf = await PDFDocument.create();
for (const file of imageFiles) {
let img;
if (file.type === 'image/jpeg') {
img = await pdf.embedJpg(await file.arrayBuffer());
} else {
// Convert PNG/other to JPEG via canvas
const bitmap = await createImageBitmap(file);
const canvas = document.createElement('canvas');
canvas.width = bitmap.width;
canvas.height = bitmap.height;
canvas.getContext('2d').drawImage(bitmap, 0, 0);
const blob = await new Promise(r => canvas.toBlob(r, 'image/jpeg', 0.92));
img = await pdf.embedJpg(await blob.arrayBuffer());
}
const page = pdf.addPage([img.width, img.height]);
page.drawImage(img, { x: 0, y: 0, width: img.width, height: img.height });
}
One thing to note: pdf-lib's embedPng exists but I found converting everything to JPEG via canvas was more reliable across different PNG types (transparent backgrounds, indexed color, etc.).
PDF to Image: The Odd One Out
PDF to Image is the one tool that doesn't use pdf-lib — because pdf-lib can read and write PDFs, but it can't render them visually.
For rendering, I load PDF.js from CDN at runtime:
// Load PDF.js only when needed
if (!window.pdfjsLib) {
await new Promise((resolve, reject) => {
const script = document.createElement('script');
script.src = 'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.min.js';
script.onload = resolve;
script.onerror = reject;
document.head.appendChild(script);
});
window.pdfjsLib.GlobalWorkerOptions.workerSrc =
'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.worker.min.js';
}
// Render each page to canvas
const pdfDoc = await pdfjsLib.getDocument({ data: arrayBuffer }).promise;
for (let i = 1; i <= pdfDoc.numPages; i++) {
const page = await pdfDoc.getPage(i);
const viewport = page.getViewport({ scale: 2 }); // 2x for quality
const canvas = document.createElement('canvas');
canvas.width = viewport.width;
canvas.height = viewport.height;
await page.render({
canvasContext: canvas.getContext('2d'),
viewport
}).promise;
const dataUrl = canvas.toDataURL('image/png');
// offer for download
}
The scale factor matters. Scale 1 gives you screen resolution (~96dpi), scale 2 gives you print-quality (~192dpi). I let users choose between 1x, 2x, and 3x.
What I'd Do Differently
Lazy load pdf-lib. It's about 800KB uncompressed. I now use dynamic import() to load it only when the user actually opens a PDF tool, rather than on initial page load.
Show compression stats upfront. For PDF Compress, showing the expected size reduction before processing would set better expectations.
Web Workers for large files. Processing large PDFs on the main thread can freeze the UI momentarily. Moving the heavy operations to a Web Worker would make the experience smoother.
Try It
All 6 tools are live at toolzip.app under the PDF category. No signup, no upload, no server.
If you've built client-side PDF tools before, I'd be curious what libraries or approaches you used — drop a comment below.
Top comments (0)