A complete, fully local PDF toolkit — a React + TypeScript single-page app. Every tool runs inside the browser tab: no server, no uploads, no accounts.
| Concern | Choice |
|---|---|
| UI | React 19 + TypeScript (strict) |
| Build | Vite 6 |
| Styling | Tailwind CSS v4 (design tokens in src/styles/index.css) |
| Routing | React Router 7, BrowserRouter over prerendered paths |
| Hosting | Cloudflare Workers static assets (no Worker script) |
| PDF structure | @cantoo/pdf-lib |
| PDF rendering / text | pdfjs-dist (real worker, bundled; cMaps + standard fonts shipped) |
| DOCX / XLSX / PPTX / HTML | mammoth, jszip, xlsx, html2canvas |
| Wordmark | Bodoni Moda Bold (self-hosted Latin subset) |
Every tool lives at a real path — /merge-pdf, /compress-image — not behind a
hash. scripts/prerender.mjs writes dist/<slug>/index.html for each visible
tool at build time, each with its own title, description, canonical URL, Open
Graph tags and JSON-LD, plus a plain-HTML summary of the page. That summary is
rendered inside #root; React replaces it on mount.
This is what makes the tools findable. The app previously used a HashRouter because GitHub Pages has no rewrite rules, which meant all 30 tools shared a single indexable URL and the site shipped one description for the whole thing.
Old /#/slug links still work: main.tsx rewrites them to the path form
before the router mounts.
Two families share one shell. The PDF half follows iLovePDF's layout; the image
half follows iLoveIMG's, down to its blue CTA — which is why --color-brand
(red) and --color-img (blue) exist as separate tokens.
The wording is now our own. The original catalogue reused iLovePDF's marketing
strings verbatim, which was both a legal grey area and useless for search: 30
pages carrying a competitor's copy, all behind one URL. src/content/toolSeo.ts
replaced it with original copy written to describe what these tools actually do.
PDF — Organize — Merge, Split, Remove pages, Extract pages PDF — Optimize — Compress, Repair PDF — Convert to PDF — JPG, Word, PowerPoint, Excel, HTML PDF — Convert from PDF — JPG, Word*, PowerPoint*, Excel*, PDF/A* PDF — Edit — Rotate, Page numbers, Watermark, Crop PDF — Security — Unlock, Protect (AES-256), Sign
IMAGE — Optimize — Compress, Resize IMAGE — Edit — Crop, Rotate, Photo editor, Watermark, Blur face IMAGE — Convert — Convert to JPG, Convert from JPG, HTML to image IMAGE — Create — Meme generator
* Text-only recovery, so these are hidden: true: the route still works for an
old bookmark, but nothing advertises a tool whose output would disappoint. Six
entries are hidden in total — four PDF, two image — leaving 30 visible.
- Nothing leaves the device, and the browser enforces it. Files are read
with
File.arrayBuffer()and results handed back asBlobdownloads. On top of that,public/_headersships a Content-Security-Policy withdefault-src 'none'andconnect-src 'self', so a dependency that tried to send data anywhere would be blocked rather than merely caught in review. - Drag & drop anywhere on the tool page, with a full-bleed drop overlay.
- Drag to reorder file cards; the order is what gets merged.
- Page picking for Remove / Extract / Split happens on the canvas, where the thumbnails are legible — click to toggle, shift-click for a range.
- Light and dark themes, remembered across visits and applied before first paint (no flash).
- Installable app with an offline service worker.
- Fully responsive — a 300px options column on desktop, a slide-up settings sheet on phones.
- Two accent identities. PDF tools use the iLovePDF red (
--color-brand); image tools use the iLoveIMG blue (--color-img).isImageTool(slug)insrc/lib/accent.tsis the only place that decision is made, and the shell threads it through the uploader, the CTA, the drop outline and the result page. - A real image engine.
src/lib/image.tsdecodes and re-encodes through<canvas>only: progressive halving for clean downscales, a hand-written 24-bit BMP encoder, a flood-fill background remover, and a text/logo watermark renderer. No image dependency is installed for this.
The image half mirrors iloveimg.com's tool set, its panel wording and its layout, rebuilt to run locally:
| Tool | What it does locally |
|---|---|
| Compress IMAGE | Re-encode at three levels; refuses to return a bigger file |
| Resize IMAGE | Pixels or percentage, ratio lock, no-enlarge guard |
| Crop IMAGE | Draggable 8-handle box, aspect presets, flip, rotate |
| Rotate IMAGE | Per-file right/left queue with flips, matching the card control |
| Photo editor | Tabbed editor: filters, resize, crop, transform, draw, text, shapes, frame, background |
| Watermark IMAGE | Text or logo, position, opacity, angle, tiled mode |
| Blur face | Optional skin-tone detection plus a hand-drawn region editor |
| Convert to JPG | Quality presets, transparency flattened onto white |
| Convert from JPG | PNG or GIF output; GIF silently falls back to PNG in browsers that cannot encode it |
| HTML to image | Renders pasted markup off-screen and rasterises it |
| Meme generator | Top/bottom or in-picture captions, draggable layers |
Where a reference feature fundamentally needs a server — face detection models, fetching another site's page, server-side SVG rendering — the tool says so in its own panel rather than faking the result.
The wordmark is PAPER [mark] SNAP: two words in Bodoni Moda Bold, all caps,
with the document-and-heart mark set between them. Assets are generated from the
source artwork by _gen/make-brand.py:
| File | Purpose |
|---|---|
public/img/brand.svg |
Vector mark used in the header lockup |
public/img/app-icon-{192,512}.png |
PWA "any" icons |
public/img/app-icon-maskable-*.png |
Android maskable icons (safe-zone inset) |
public/img/apple-touch-icon.png |
iOS home-screen icon |
public/img/favicons-pdf/favicon-* |
Browser tabs |
The font lives at src/assets/fonts/BodoniModa-Bold.woff2 and is referenced as
var(--font-brand).
pnpm install
pnpm dev # http://localhost:5173
pnpm typecheck # tsc, strict
pnpm test # pure-logic regression checks
pnpm build # typecheck, build, prerender, CSP hash, service worker
pnpm preview # serve the built output
pnpm deploy # build, then wrangler deploypnpm test runs scripts/check-regressions.mjs: 30 assertions over the real
sources covering page-range parsing, the image accept filter, byte formatting,
the sanitiser's URL predicates, the pdf.js CMap/font wiring, and the SEO and
prerender coupling. It needs no test framework — the repo has no vitest/jest
dependency, and this was deliberately kept that way rather than adding one.
pnpm build chains these scripts after vite build, in this order:
| Step | What it adds |
|---|---|
scripts/prerender.mjs |
dist/<slug>/index.html per tool, sitemap, robots, 404 |
scripts/csp-hash.mjs |
the inline script's SHA-256 into dist/_headers |
scripts/build-sw.mjs |
a real precache list and content-hashed cache version |
vite (already run) |
bundles, plus pdf.js cmaps/ and standard_fonts/ |
Order matters: the CSP hash is computed from the built index.html, and the
service-worker precache list is built from whatever files exist in dist/.
The site is served from Cloudflare Workers static assets. wrangler.toml
declares no Worker script — the deployment is just dist/ — so a request that
matches a file never invokes a runtime, is free and unlimited, and the "no
server" property holds structurally rather than by convention.
pnpm deploy # build, then wrangler deployPushing to main runs .github/workflows/deploy.yml: typecheck, regression
checks, build, an assertion that the prerendered routes actually exist, then
wrangler deploy. It needs two repository secrets:
| Secret | Where to get it |
|---|---|
CLOUDFLARE_API_TOKEN |
Cloudflare dashboard → My Profile → API Tokens → Edit Cloudflare Workers template |
CLOUDFLARE_ACCOUNT_ID |
Cloudflare dashboard → Workers & Pages → Account ID |
SITE_ORIGIN in the workflow supplies the absolute origin used for canonical
URLs, Open Graph tags and the sitemap. Change it if you attach a custom domain.
public/_headers is copied verbatim into the build and applied by Cloudflare.
It carries the Content-Security-Policy that enforces the privacy promise —
default-src 'none', connect-src 'self', frame-ancestors 'none' — plus
immutable caching for hashed bundles. GitHub Pages has no mechanism for
custom headers at all, which is the main reason the site moved.
The inline theme script in index.html needs its SHA-256 in the policy, so
scripts/csp-hash.mjs recomputes it from the built HTML on every build. Do not
hand-edit that hash.
scripts/cf-sim.mjs serves dist/ with Cloudflare's routing and header
semantics so you can check a change locally before deploying.
src/
assets/ fonts (Graphik, Bodoni Moda) and the brand mark
components/ shell, tool page, cards, uploader, page grid, result views
content/ tool catalogue, icons, nav groups, long-form SEO copy
hooks/ theme, install prompt
lib/ PDF engine, image engine, ranges, sanitiser, format helpers
pages/ home, not-found
styles/ Tailwind entry, design tokens, dark theme
tools/ one module per tool family, plus the lazy registry
types.ts shared domain types
scripts/
prerender.mjs per-route HTML, sitemap, robots, 404
csp-hash.mjs inline-script hash -> _headers
build-sw.mjs service-worker precache list + cache version
check-regressions.mjs dependency-free assertions (pnpm test)
cf-sim.mjs serve dist/ with Cloudflare routing + header semantics
Note that src/lib/ is split so the PDF engine stays off the landing page's
critical path: format.ts, ranges.ts and sanitize.ts have no heavy
dependencies, and pdf.ts is imported dynamically by the components that need
it. A static import '@/lib/pdf' anywhere on the landing graph re-couples
pdf-lib and pdf.js to first paint.
Each tool is a ToolImpl: a Panel for the options column, an optional
Workarea for canvas content such as page grids, and a run() returning
ResultFile[]. Tool modules load on demand, so the landing page never downloads
the PDF engine.
src/content/tools.tsholds the 23 PDF entries and the nav groups;src/content/imageTools.tsholds the 13 image entries. Together they register 36 routes, of which 30 are visible.src/content/toolSeo.tsholds the long-form copy each prerendered page carries: lead paragraph, how-to steps, FAQ and a tool-specific privacy note.
_gen/gen-tools.mjs was a one-off bootstrap that read the v1 catalogue from
_legacy-src/ to carry the 23 original definitions over with their inline SVG
icons. Do not re-run it. It only knows that legacy set, and tools.ts has
been edited by hand many times since — running it would overwrite those edits.
It is kept for reference only.