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
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);
}
The Two Sources of PDF Bloat
Most large PDFs are fat for one of two reasons:
- Embedded images are stored at full resolution
- 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());
}
A few things worth noting:
-
createImageBitmapdecodes 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;
}
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,
});
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>
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)