The Problem
Every time you upload a PDF or image to an online tool, you're trusting a random server with your files. What if the tool could run entirely in your browser instead?
I built two sites that do exactly that:
- PDF Tools — 8 PDF tools (split, merge, compress, sign, and more)
- Image Tools — 10 image tools (compress, resize, crop, AI background removal, and more)

Zero file uploads. Everything runs in JavaScript on your device.
Tech Stack
React 19 + Vite 7
PDF side: pdf-lib, pdfjs-dist, ExcelJS, jsPDF
Image side: Canvas API, @imgly/background-removal, TensorFlow.js + ESRGAN
1. Code Splitting: 2,775KB → 248KB Initial Load
The first build was huge. Every library loaded upfront, even if the user only needed one tool.
Fix: React.lazy() + Vite manualChunks
// Each tool loads only when selected
const SplitMode = lazy(() => import('./components/SplitMode'));
const MergeMode = lazy(() => import('./components/MergeMode'));
const CompressMode = lazy(() => import('./components/CompressMode'));
// vite.config.js — separate heavy libs into their own chunks
manualChunks: {
'bg-removal': ['@imgly/background-removal'], // ONNX Runtime
'tfjs': ['@tensorflow/tfjs'], // ~2MB
'upscaler': ['upscaler', '@upscalerjs/esrgan-medium'],
'pdfjs': ['pdfjs-dist'],
}
Result: 91% reduction in initial load. Users only download what they use.
2. Canvas API Does More Than You Think
Most of Image Tools runs on Canvas API alone — no image processing libraries needed.
Compression — just toBlob() with quality parameter:
canvas.toBlob(blob => {
// compressed blob ready
}, 'image/jpeg', 0.7); // 70% quality
Free-angle rotation — the tricky part is calculating the expanded canvas size:
const rad = angle * Math.PI / 180;
const sin = Math.abs(Math.sin(rad));
const cos = Math.abs(Math.cos(rad));
// Canvas must be large enough to fit the rotated image
canvas.width = Math.ceil(w * cos + h * sin);
canvas.height = Math.ceil(w * sin + h * cos);
ctx.translate(canvas.width / 2, canvas.height / 2);
ctx.rotate(rad);
ctx.drawImage(img, -w / 2, -h / 2);
CSS Filters baked into images — apply filters and export as a file:
ctx.filter = `brightness(120%) contrast(110%) saturate(130%) grayscale(50%)`;
ctx.drawImage(img, 0, 0);
// Now export — filters are permanently applied to the pixels
3. Custom Binary Encoders for BMP, GIF, and ICO
Canvas toBlob() supports JPEG, PNG, WebP, and AVIF. But not BMP, GIF, or ICO.
I wrote custom encoders from scratch.
BMP — requires BGR byte order and bottom-up pixel storage:
// RGBA from Canvas → BGR for BMP (plus row padding to 4-byte boundary)
const rowSize = Math.ceil((width * 3) / 4) * 4;
for (let y = height - 1; y >= 0; y--) { // bottom-up
for (let x = 0; x < width; x++) {
const src = (y * width + x) * 4;
bytes[dst] = data[src + 2]; // B
bytes[dst + 1] = data[src + 1]; // G
bytes[dst + 2] = data[src]; // R
}
}
GIF — includes LZW compression and 256-color quantization:
// 1. Quantize colors to fit 256-color palette
// 2. Build color lookup table
// 3. LZW encode with dictionary reset at 4096 entries
// 4. Pack into GIF89a format
Writing a GIF encoder was the most challenging part — LZW compression with variable-width codes and dictionary management is not trivial.
4. AI in the Browser — No Server Needed
Background removal with ONNX Runtime (~50MB model, cached after first use):
const { removeBackground } = await import('@imgly/background-removal');
const blob = await removeBackground(file, {
progress: (key, current, total) => {
setProgress(Math.round((current / total) * 100));
},
});
// Returns transparent PNG

AI upscaling with TensorFlow.js + ESRGAN:
const upscaler = new Upscaler({ model });
const result = await upscaler.upscale(img, {
patchSize: 64, // Process in 64px tiles to avoid GPU OOM
padding: 4, // Overlap between tiles to prevent seams
});
The patchSize: 64 trick is critical — without it, large images crash the browser's GPU memory.
5. Detecting AI-Generated Image Metadata
The Metadata tool parses PNG binary chunks to detect AI generation parameters:
// PNG files contain tEXt and iTXt chunks with metadata
// AI tools embed their parameters here:
// - "parameters" → Stable Diffusion (AUTOMATIC1111)
// - "prompt" / "workflow" → ComfyUI
// - "comment" / "source" → NovelAI
This required writing a custom PNG chunk parser that reads the binary format byte by byte.
6. Lightweight i18n Without Libraries
Both sites support 5 languages (EN, JA, VI, ID, ZH) with a custom 30-line implementation:
const t = (key, ...args) => {
const str = translations[lang]?.[key] || translations.en[key] || key;
return str.replace(/\{(\d+)\}/g, (_, i) => args[Number(i)] ?? '');
};
// Auto-detect browser language on first visit
const browserLang = (navigator.language || '').toLowerCase();
if (browserLang.startsWith('ja')) return 'ja';
if (browserLang.startsWith('vi')) return 'vi';
// ...
No react-i18next, no formatjs. Just a plain object lookup with placeholder substitution.
Cost: $1.75/month
| Item | Cost |
|---|---|
| Hosting (Vercel) x2 | Free |
| Domains (Cloudflare) x2 | ~$21/year |
| SSL | Free (Cloudflare) |
| Libraries | All OSS |
| AI models | Browser-delivered |
| Total | ~$1.75/month |
Since all processing is client-side, server costs don't scale with users.
Links
- PDF Tools: pdftools-free.com
- Image Tools: imagetools-free.com
- GitHub (PDF): github.com/plusishibba-design/pdf-modify
- GitHub (Image): github.com/plusishibba-design/image-tools
If you're building browser-based tools, I hope some of these techniques are useful. Happy to answer questions!

Top comments (0)