DEV Community

sunshey
sunshey

Posted on

How I Compress PDFs in the Browser with Vue 3 and pdf-lib

I needed a dead-simple way to shrink PDFs without sending them to a server. Most tools want you to upload the file, wait for processing, then download it. For contracts and scans that's a dealbreaker.

So I built en.sotool.top/compress/ — pick a PDF, choose a compression level, get a smaller file. All in the browser. No server involved.

Here's how it works under the hood with Vue 3 and pdf-lib.


Why Client-Side?

The obvious reason is privacy. Contracts, financials, medical scans — people don't want them on a stranger's server. Beyond that:

  • No upload bandwidth limits
  • No file size caps from your backend
  • Nothing to clean up on a server
  • Works offline after the page loads

The catch? You're limited by what the browser can do. For compression, pdf-lib can rewrite the PDF with compressed images and stripped metadata, which covers most real-world cases.


The Stack

  • Vue 3 — UI and state
  • pdf-lib — Load, modify, save PDFs
  • HTML Canvas — Re-encode images at lower quality
  • File API — Read the uploaded PDF
npm install pdf-lib
Enter fullscreen mode Exit fullscreen mode

Loading the PDF

First, read the file into an ArrayBuffer and load it with pdf-lib.

import { PDFDocument } from 'pdf-lib';

async function loadPdf(file) {
  const arrayBuffer = await file.arrayBuffer();
  return PDFDocument.load(arrayBuffer);
}
Enter fullscreen mode Exit fullscreen mode

The Two Sources of PDF Bloat

Most large PDFs are fat for one of two reasons:

  1. Embedded images are stored at full resolution
  2. The PDF embeds fonts or metadata it doesn't really need

For browser-side compression, the biggest wins come from re-encoding images. Font and metadata cleanup helps, but image resizing gives you the dramatic size reductions.


Compressing Images with Canvas

The trick is extracting each image from the PDF, drawing it onto a Canvas at a smaller size or lower quality, then embedding it back.

async function compressImageBytes(imageBytes, quality = 0.7, maxWidth = 1200) {
  const blob = new Blob([imageBytes]);
  const bitmap = await createImageBitmap(blob);

  const scale = Math.min(1, maxWidth / bitmap.width);
  const width = Math.floor(bitmap.width * scale);
  const height = Math.floor(bitmap.height * scale);

  const canvas = document.createElement('canvas');
  canvas.width = width;
  canvas.height = height;
  const ctx = canvas.getContext('2d');
  ctx.drawImage(bitmap, 0, 0, width, height);

  const compressedBlob = await new Promise((resolve) => {
    canvas.toBlob(resolve, 'image/jpeg', quality);
  });

  return new Uint8Array(await compressedBlob.arrayBuffer());
}
Enter fullscreen mode Exit fullscreen mode

A few things worth noting:

  • createImageBitmap decodes the image efficiently in the browser
  • We scale down only if the image is wider than maxWidth
  • JPEG quality lets the user pick their compression level

Replacing Images in the PDF

pdf-lib doesn't have a high-level "replace all images" API, so you walk the pages and look for image operators.

async function compressPdfImages(pdfDoc, quality = 0.7, maxWidth = 1200) {
  const pages = pdfDoc.getPages();

  for (const page of pages) {
    const resources = page.node.Resources();
    if (!resources) continue;

    const xObject = resources.lookupMaybe('XObject');
    if (!xObject) continue;

    for (const [name, ref] of Object.entries(xObject.dict)) {
      const obj = xObject.lookupMaybe(name);
      if (obj && obj instanceof PDFStream) {
        const subtype = obj.getMaybe('Subtype')?.asString();
        if (subtype === 'Image') {
          const width = obj.get('Width').asNumber();
          const height = obj.get('Height').asNumber();
          const originalBytes = obj.getContents();

          const compressedBytes = await compressImageBytes(originalBytes, quality, maxWidth);

          const embeddedImage = await pdfDoc.embedJpg(compressedBytes);
          xObject.set(name, embeddedImage.ref);
        }
      }
    }
  }

  return pdfDoc;
}
Enter fullscreen mode Exit fullscreen mode

This is a simplified version. Production code handles PNG images, CMYK color spaces, and image masks more carefully.


Saving the Compressed PDF

const pdfBytes = await pdfDoc.save({
  useObjectStreams: true,
  addDefaultPage: false,
});
Enter fullscreen mode Exit fullscreen mode

useObjectStreams: true helps reduce file size by packing objects more efficiently.


Letting the User Pick the Balance

Different documents need different compression. A text-heavy report can handle aggressive compression. A portfolio with screenshots needs a lighter touch.

<script setup>
import { ref } from 'vue';

const file = ref(null);
const quality = ref(0.7);
const loading = ref(false);

async function handleCompress() {
  if (!file.value) return;
  loading.value = true;

  try {
    const pdfDoc = await loadPdf(file.value);
    await compressPdfImages(pdfDoc, quality.value);
    const pdfBytes = await pdfDoc.save({ useObjectStreams: true });
    downloadPdf(pdfBytes, 'compressed.pdf');
  } catch (e) {
    console.error(e);
    alert('Failed to compress PDF. Check the file.');
  } finally {
    loading.value = false;
  }
}
</script>

<template>
  <div>
    <input type="file" accept=".pdf" @change="e => file = e.target.files[0]" />
    <input type="range" min="0.3" max="1" step="0.1" v-model="quality" />
    <span>Quality: {{ Math.round(quality * 100) }}%</span>
    <button @click="handleCompress" :disabled="loading">
      {{ loading ? 'Compressing...' : 'Download Compressed PDF' }}
    </button>
  </div>
</template>
Enter fullscreen mode Exit fullscreen mode

Lessons Learned

Image extraction is messy. PDFs store images in wild ways — rotated, inverted, with masks, in CMYK, split into tiles. Handling every case perfectly is harder than it sounds.

JPEG re-encoding is the big win. For scan-heavy PDFs, re-encoding images at 70% quality can cut file size by 80% with barely visible quality loss.

Text-only PDFs don't compress much. If a PDF is mostly text and already optimized, there's little a browser tool can do.

Always let users preview. Showing before/after file size helps users trust the result.


Try It

The compression tool is live at en.sotool.top/compress/.

Free, no signup, nothing uploads to a server.

Full source is on GitHub. The compression logic is in src/views/Compress.vue.


Want More Advanced PDF Tools?

If you need OCR, form editing, digital signatures, or batch processing, Wondershare PDFelement is a solid desktop option. It keeps everything local.

This post contains affiliate links.


Have you built client-side PDF tools? What did you use — pdf-lib, PDF.js, or something else?

Top comments (0)