A free, open-source, 100% in-browser file utility. Nothing is ever uploaded. Leads with the two things privacy-first tools usually skip: images (especially HEIC) and hidden metadata.
Candidates: Scrubly, CleanShare, NoUpload, LocalFile, Sanctum, OffShore (offline). Placeholder used in docs: Local File Studio.
- The file never leaves the device. No servers, no analytics, no third-party calls — at runtime, zero network requests after the page loads.
- The claim is verifiable, not promised. A user can open DevTools → Network and watch nothing happen. We enforce it in code (CSP) so even a bug can't leak a file.
- Honest, no tricks. No ads, no account, no "Pro" wall, no fake free trial. Every feature listed actually works; nothing is overpromised.
- Small and finished beats big and abandoned. A tight v1 that's polished and shipped.
- Pure front-end. Static assets only. Hostable on GitHub Pages at $0.
- All third-party code self-hosted/bundled — no runtime CDN, no remote fonts. (Required so the CSP below can be strict.)
- Heavy work runs in Web Workers so the UI never freezes; large WASM is lazy-loaded only when its tool is used.
- Works offline once loaded (bonus: ship a service worker → installable PWA, post-v1).
- Convert between JPG ⇄ PNG ⇄ WebP.
- Resize by max dimension / exact px / percentage, preserving aspect ratio; respect EXIF orientation.
- Compress with a quality slider and a live before/after size readout.
- Batch: drop many files, process all, download as a
.zip. - Libraries / approach:
- Baseline: native
<canvas>+canvas.toBlob(type, quality)— zero deps, covers convert/resize/basic compress. - Quality path: jSquash (
@jsquash/jpeg,@jsquash/png,@jsquash/webp,@jsquash/resize) — the actual Squoosh WASM codecs (MozJPEG / OxiPNG / WebP). Much better size-at-quality than canvas. Run in a worker. - Decision: ship canvas baseline in Phase 1, swap encoders to jSquash once the flow works. Keep a single
encodeImage()seam so the swap is local.
- Baseline: native
- Edge cases: huge images (cap canvas dimensions / warn), transparency (PNG/WebP keep alpha, JPG flattens to white — warn), animated formats out of scope for v1.
- Decode Apple HEIC/HEIF and export JPG or PNG. Batch + zip download.
- Why it matters: browsers can't natively decode HEIC (it's HEVC, patent-encumbered) — this is the reason people hit sketchy sites. Owning this cleanly is the differentiator.
- Library / approach: a libheif-WASM wrapper —
heic-to(modern) orheic2any/libheif-js. Pick one, wrap behind adecodeHeic(file): ImageBitmap/Blobseam.- The libheif WASM bundle is large (~2–3 MB). Lazy-load it in a worker only when the HEIC tool is first used. Show a one-time "loading decoder…" state.
- After decode, reuse the same
encodeImage()pipeline as §3.1 (so resize/quality come for free).
- Edge cases: multi-image HEIC (live photos / bursts) — export the primary image in v1, note others exist; 10/12-bit HDR HEIC — decode to 8-bit, acceptable for v1.
3.3 Clean — strip hidden metadata (the differentiator; lead the marketing with this)
- Images: detect and show what's hidden ("⚠ This photo contains GPS location, camera model, timestamp"), then strip with one click.
- Read/inspect:
exifr(fast; reads EXIF, GPS, XMP, ICC). Used to show the user what's there — this transparency is the whole pitch. - Strip — two modes:
- Lossless (default):
piexifjsremoves the EXIF block without re-encoding pixels → no quality loss. - Bulletproof: re-encode through canvas → drops all metadata categories, guaranteed clean (lossy).
- Lossless (default):
- Show a verifiable "before/after": re-run
exifron the output and display "0 metadata fields remaining."
- Read/inspect:
- PDFs: clear document metadata — Title/Author/Subject/Keywords/Creator/Producer/CreationDate/ModDate and XMP — via
pdf-lib. (Reworded from the original pitch: PDFs carry metadata, not a real "edit history" — that's an Office-doc thing, deliberately out of scope.) - Batch + zip. Same drop-many flow.
- Merge: drop multiple PDFs, reorder by drag, combine into one.
pdf-lib. - Split: select page ranges / extract pages → one or many output PDFs.
pdf-libfor the cut;pdfjs-distto render page thumbnails so users pick pages visually. - Edge cases: encrypted/password PDFs (detect, prompt or refuse gracefully); very large PDFs (process in worker, show progress).
- PDF compress. The honest hard one. Real compression = downsample/re-encode embedded images (à la Ghostscript). Options when we get to it:
- Lite (preferred): extract images, downsample via canvas/jSquash, rebuild with pdf-lib — and label it "lite," stating what it does.
- Heavy: Ghostscript-WASM — powerful but a large, fiddly bundle. Only if Lite underwhelms.
- PWA / offline install (service worker).
- More image formats (AVIF encode via jSquash, TIFF, animated WebP/GIF).
- PDF rotate / delete pages / reorder (cheap to add once merge/split exist).
| Concern | Choice | Why |
|---|---|---|
| Build | Vite | Fast dev, static output, first-class WASM + worker support |
| Language | TypeScript | Correctness + strong portfolio signal |
| UI | Preact + JSX | React ergonomics, ~4 KB, tiny bundle. (Vanilla TS is the simpler fallback if you'd rather.) |
| Styling | Plain CSS + custom properties | Light/dark via variables; no framework needed |
| Concurrency | Web Workers + Comlink | Keep UI responsive; Comlink makes worker calls read like async functions |
| Zip (batch download) | client-zip | Tiny, streaming, no-dep |
| Tests | Vitest (unit) + Playwright (1–2 e2e smokes) | Prove metadata-strip correctness; CV signal |
Core libraries: pdf-lib, pdfjs-dist, exifr, piexifjs, @jsquash/*, libheif wrapper (heic-to/heic2any).
UI (Preact, main thread)
└─ thin tool components: dropzone → options → run → preview → download
│ (Comlink)
▼
Workers (one per heavy domain, WASM lazy-loaded inside)
├─ image.worker → canvas/jSquash encode, resize, convert
├─ heic.worker → libheif-wasm decode (loaded on first HEIC use)
├─ meta.worker → exifr read, piexif/canvas strip, pdf-lib metadata clear
└─ pdf.worker → pdf-lib merge/split (+ pdfjs thumbnails on main thread)
- Seams to keep swappable:
encodeImage(),decodeHeic(),stripImageMeta(),readMeta(). Encoders/decoders can change without touching UI. - Data flow:
File→ArrayBuffer/ImageBitmap→ worker → resultBlob→ object URL for download. Nothing persisted; revoke URLs after download. - Lazy loading: dynamic
import()per tool so the HEIC/jSquash WASM isn't in the initial bundle. Initial load stays small; decoders fetch on demand from same-origin.
- Strict CSP via
<meta http-equiv="Content-Security-Policy">(and headers where possible):default-src 'self'; connect-src 'none'; img-src 'self' blob: data:; script-src 'self' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'→connect-src 'none'means the browser itself blocks any upload/fetch. A bug cannot leak a file. This is the killer detail: the guarantee is enforced, not promised. - "How to verify" panel: plain-language steps — open DevTools → Network → use the tool → see zero requests. Plus "this whole app is static; read the source on GitHub."
- No analytics, no fonts-from-CDN, no telemetry. Anything that would need
connect-srcis banned by design. - After-strip proof: re-scan cleaned files and show "0 metadata fields remaining" so cleaning is demonstrably real.
- Single page, tool cards / tabs: Images · HEIC · Clean · PDF.
- Each tool: large drag-and-drop zone (also click-to-pick, paste-to-add), options panel, Run, preview (before/after where it makes sense), Download (single file or zip for batches).
- Always-visible privacy strip: "Your files never leave this device — [verify]".
- States handled explicitly: empty, loading-decoder, processing (progress), success, error (bad/corrupt/encrypted file), unsupported format.
- Accessible: keyboard-operable dropzones, ARIA live region for progress/results, visible focus, prefers-color-scheme dark/light, responsive down to mobile.
/ (repo root)
├─ index.html # CSP meta lives here
├─ vite.config.ts # base = "/<repo-name>/" for Pages
├─ package.json
├─ tsconfig.json
├─ public/ # favicon, og image, static assets
├─ src/
│ ├─ main.tsx # app shell, routing between tools
│ ├─ ui/ # Dropzone, OptionsPanel, Preview, DownloadButton, PrivacyBar, VerifyPanel
│ ├─ tools/
│ │ ├─ images/ # convert/resize/compress UI
│ │ ├─ heic/
│ │ ├─ clean/
│ │ └─ pdf/ # merge, split
│ ├─ workers/
│ │ ├─ image.worker.ts
│ │ ├─ heic.worker.ts
│ │ ├─ meta.worker.ts
│ │ └─ pdf.worker.ts
│ ├─ lib/ # encodeImage, decodeHeic, readMeta, stripMeta, zip helpers (the seams)
│ └─ styles/
├─ tests/ # vitest unit + playwright e2e
├─ .github/workflows/deploy.yml
├─ README.md LICENSE NOTICE
- GitHub Pages via GitHub Actions (
actions/configure-pages→ build →actions/upload-pages-artifact→actions/deploy-pages). - Set Vite
base: "/<repo-name>/"(or"/"+ a custom domain viaCNAME). - Ship a "hello world" to Pages in Phase 0 so deploy is proven before features pile up.
- Note: GitHub Pages can't set HTTP response headers, so CSP rides in the
<meta>tag (works for our needs). A custom domain on Cloudflare Pages/Netlify later could add real header-level CSP + COOP/COEP if we ever need cross-origin isolation for threads.
- Vitest: the correctness-critical bits —
stripImageMetaactually removes GPS (assertexifrfinds nothing after), PDF metadata cleared, convert/resize output dimensions/format correct. - Playwright: 1–2 smoke flows — drop a fixture image → convert → download; drop a HEIC → get a JPG. Also assert no network requests during a run (Playwright can intercept) — doubles as a privacy regression test.
- Our code: MIT (friendliest for a portfolio/open-source story).
NOTICEfile crediting third-party licenses: libheif (LGPL/MIT components), MozJPEG (BSD), OxiPNG, pdf-lib (MIT), pdf.js (Apache-2.0), exifr (MIT), Comlink (Apache-2.0). Verify each at integration time and list versions.
- No server, no account, no cloud sync, no sharing links.
- No "edit history" claim for PDFs (not a real thing there).
- No PDF compress in v1 (it's v1.1, done honestly).
- No video/audio, no OCR, no animated-format editing in v1.
- No analytics or telemetry, ever.