DEV Community

toolzip
toolzip

Posted on

How I Built a Client-Side PDF Toolkit — 6 Tools, No Server

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' });
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

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);
};
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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 });
Enter fullscreen mode Exit fullscreen mode

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 });
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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)