DEV Community

Anurag Bhattarai
Anurag Bhattarai

Posted on

How I built 15 PDF tools that run entirely in the browser (no server, no uploads)

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

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

Or via CDN:

<script src="https://unpkg.com/pdf-lib/dist/pdf-lib.min.js"></script>
Enter fullscreen mode Exit fullscreen mode

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

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

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

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

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

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

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

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

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

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

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

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

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

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)